eloigil6 commited on
Commit
a39beee
·
1 Parent(s): 7e5fd4d

Enhance interactable features and visual labels in the scene. Updated index.html to add a new collection label, modified main.js to manage hover interactions for both vending machine and cassette collection, and adjusted style.css for consistent label styling. Improved world.js to include outlines for the cassette pile and walkman, enabling better user interaction.

Browse files
Files changed (4) hide show
  1. frontend/index.html +2 -1
  2. frontend/main.js +84 -26
  3. frontend/style.css +5 -5
  4. frontend/world.js +51 -8
frontend/index.html CHANGED
@@ -21,7 +21,8 @@
21
  <h1 id="title">LoFinity</h1>
22
  <p id="subtitle">♪ chill beats, freshly vended</p>
23
  </div>
24
- <div id="vending-label"><span>♪ vend a vibe</span></div>
 
25
 
26
  <div id="machine-modal" class="hidden">
27
  <button id="modal-close" aria-label="close">×</button>
 
21
  <h1 id="title">LoFinity</h1>
22
  <p id="subtitle">♪ chill beats, freshly vended</p>
23
  </div>
24
+ <div id="vending-label" class="hover-label"><span>♪ vend a vibe</span></div>
25
+ <div id="collection-label" class="hover-label"><span>♪ cassette collection</span></div>
26
 
27
  <div id="machine-modal" class="hidden">
28
  <button id="modal-close" aria-label="close">×</button>
frontend/main.js CHANGED
@@ -125,17 +125,37 @@ world.bird.matrixAutoUpdate = true;
125
  world.bird.userData.tail.matrixAutoUpdate = true;
126
 
127
  // ---------------------------------------------------------------------------
128
- // Vending machine interaction: hover outline + floating label, click hook
129
  // ---------------------------------------------------------------------------
130
 
131
  const raycaster = new THREE.Raycaster();
132
  const pointerNDC = new THREE.Vector2(-2, -2); // offscreen until first move
133
  let pointerActive = false;
134
- let vendingHovered = false;
135
 
136
- const vendingLabel = document.getElementById("vending-label");
137
  const labelAnchor = new THREE.Vector3();
138
- const LABEL_OFFSET_Y = 5.6; // above the machine top
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
  window.addEventListener("pointermove", (e) => {
141
  pointerNDC.set(
@@ -148,42 +168,57 @@ document.addEventListener("pointerleave", () => {
148
  pointerActive = false;
149
  });
150
 
151
- function setVendingHover(hovered) {
152
- if (hovered === vendingHovered) return;
153
- vendingHovered = hovered;
154
- world.vending.userData.outline.visible = hovered;
155
- vendingLabel.classList.toggle("visible", hovered);
156
- canvas.style.cursor = hovered ? "pointer" : "";
 
 
 
 
 
 
157
  }
158
 
159
- function updateVendingHover() {
160
  if (intro.phase !== "idle" || view.mode !== "scene" || !pointerActive) {
161
- setVendingHover(false);
162
  return;
163
  }
164
  raycaster.setFromCamera(pointerNDC, camera);
165
- setVendingHover(raycaster.intersectObject(world.vending, true).length > 0);
 
 
 
 
166
 
167
- if (vendingHovered) {
168
- labelAnchor.copy(world.vending.position);
169
- labelAnchor.y += LABEL_OFFSET_Y;
170
  labelAnchor.project(camera);
171
- vendingLabel.style.left = `${((labelAnchor.x + 1) / 2) * window.innerWidth}px`;
172
- vendingLabel.style.top = `${((1 - labelAnchor.y) / 2) * window.innerHeight}px`;
173
  }
174
  }
175
 
176
  // ---------------------------------------------------------------------------
177
- // Machine view: zoom in on click, modal while zoomed, zoom back out on close
 
178
  // ---------------------------------------------------------------------------
179
 
180
  // Frames the machine on the left so the modal card sits beside it.
181
  const MACHINE_CAM = new THREE.Vector3(-2.3, 3.0, 10.8);
182
  const MACHINE_LOOK = new THREE.Vector3(-2.9, 2.6, 4.2);
 
 
 
183
  const ZOOM_MS = 1500;
184
 
185
  const view = {
186
- mode: "scene", // scene | zoom-in | machine | zoom-out
 
187
  t0: 0,
188
  camFrom: new THREE.Vector3(),
189
  camTo: new THREE.Vector3(),
@@ -201,21 +236,38 @@ function startViewTransition(mode, camTo, lookTo) {
201
  }
202
 
203
  function zoomToMachine() {
204
- setVendingHover(false);
 
205
  startViewTransition("zoom-in", MACHINE_CAM, MACHINE_LOOK);
206
  }
207
 
 
 
 
 
 
 
208
  function closeMachineView() {
209
  ui.closeModal();
210
  startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
211
  }
212
 
 
 
 
 
213
  canvas.addEventListener("click", () => {
214
- if (vendingHovered && view.mode === "scene" && intro.phase === "idle") {
215
- zoomToMachine();
 
 
216
  }
217
  });
218
 
 
 
 
 
219
  // Machine glow while generating
220
  let machineBusy = false;
221
  const SCREEN_BASE = new THREE.Color(0x1c2f28);
@@ -317,6 +369,12 @@ window.lofinityDebug = {
317
  closeMachine() {
318
  closeMachineView();
319
  },
 
 
 
 
 
 
320
  stats() {
321
  return { ...renderer.info.render, ...renderer.info.memory };
322
  },
@@ -392,8 +450,8 @@ function animate(now = 0) {
392
  lookTarget.lerpVectors(view.lookFrom, view.lookTo, e);
393
  if (t >= 1) {
394
  if (view.mode === "zoom-in") {
395
- view.mode = "machine";
396
- ui.openModal();
397
  } else {
398
  view.mode = "scene";
399
  intro.idleStart = elapsed; // sway ramps back in
@@ -415,7 +473,7 @@ function animate(now = 0) {
415
  }
416
 
417
  camera.lookAt(lookTarget);
418
- updateVendingHover();
419
  renderer.render(scene, camera);
420
  }
421
  animate();
 
125
  world.bird.userData.tail.matrixAutoUpdate = true;
126
 
127
  // ---------------------------------------------------------------------------
128
+ // Interactables: hover outline + floating label, click hook
129
  // ---------------------------------------------------------------------------
130
 
131
  const raycaster = new THREE.Raycaster();
132
  const pointerNDC = new THREE.Vector2(-2, -2); // offscreen until first move
133
  let pointerActive = false;
134
+ let hoveredItem = null;
135
 
 
136
  const labelAnchor = new THREE.Vector3();
137
+
138
+ // anchors are precomputed: nothing interactable ever moves
139
+ const collectionAnchor = world.collection.getWorldPosition(new THREE.Vector3());
140
+
141
+ const interactables = [
142
+ {
143
+ object: world.vending,
144
+ outlines: [world.vending.userData.outline],
145
+ label: document.getElementById("vending-label"),
146
+ anchor: world.vending.position.clone(),
147
+ labelOffsetY: 5.6, // above the machine top
148
+ onClick: () => zoomToMachine(),
149
+ },
150
+ {
151
+ object: world.collection,
152
+ outlines: world.collection.userData.outlines,
153
+ label: document.getElementById("collection-label"),
154
+ anchor: collectionAnchor,
155
+ labelOffsetY: 1.15, // above the bench backrest
156
+ onClick: () => zoomToBench(),
157
+ },
158
+ ];
159
 
160
  window.addEventListener("pointermove", (e) => {
161
  pointerNDC.set(
 
168
  pointerActive = false;
169
  });
170
 
171
+ function setHoveredItem(item) {
172
+ if (item === hoveredItem) return;
173
+ if (hoveredItem) {
174
+ for (const outline of hoveredItem.outlines) outline.visible = false;
175
+ hoveredItem.label.classList.remove("visible");
176
+ }
177
+ hoveredItem = item;
178
+ if (item) {
179
+ for (const outline of item.outlines) outline.visible = true;
180
+ item.label.classList.add("visible");
181
+ }
182
+ canvas.style.cursor = item ? "pointer" : "";
183
  }
184
 
185
+ function updateHover() {
186
  if (intro.phase !== "idle" || view.mode !== "scene" || !pointerActive) {
187
+ setHoveredItem(null);
188
  return;
189
  }
190
  raycaster.setFromCamera(pointerNDC, camera);
191
+ setHoveredItem(
192
+ interactables.find(
193
+ (item) => raycaster.intersectObject(item.object, true).length > 0
194
+ ) ?? null
195
+ );
196
 
197
+ if (hoveredItem) {
198
+ labelAnchor.copy(hoveredItem.anchor);
199
+ labelAnchor.y += hoveredItem.labelOffsetY;
200
  labelAnchor.project(camera);
201
+ hoveredItem.label.style.left = `${((labelAnchor.x + 1) / 2) * window.innerWidth}px`;
202
+ hoveredItem.label.style.top = `${((1 - labelAnchor.y) / 2) * window.innerHeight}px`;
203
  }
204
  }
205
 
206
  // ---------------------------------------------------------------------------
207
+ // Zoom views: machine (modal while zoomed) and bench (a close look at the
208
+ // tapes). Click zooms in, close/Esc/click zooms back out.
209
  // ---------------------------------------------------------------------------
210
 
211
  // Frames the machine on the left so the modal card sits beside it.
212
  const MACHINE_CAM = new THREE.Vector3(-2.3, 3.0, 10.8);
213
  const MACHINE_LOOK = new THREE.Vector3(-2.9, 2.6, 4.2);
214
+ // Leans over the bench seat where the tapes and walkman rest.
215
+ const BENCH_CAM = new THREE.Vector3(1.3, 3.3, 8.4);
216
+ const BENCH_LOOK = new THREE.Vector3(1.3, 1.15, 4.4);
217
  const ZOOM_MS = 1500;
218
 
219
  const view = {
220
+ mode: "scene", // scene | zoom-in | machine | bench | zoom-out
221
+ target: "machine", // what the current zoom-in lands on
222
  t0: 0,
223
  camFrom: new THREE.Vector3(),
224
  camTo: new THREE.Vector3(),
 
236
  }
237
 
238
  function zoomToMachine() {
239
+ setHoveredItem(null);
240
+ view.target = "machine";
241
  startViewTransition("zoom-in", MACHINE_CAM, MACHINE_LOOK);
242
  }
243
 
244
+ function zoomToBench() {
245
+ setHoveredItem(null);
246
+ view.target = "bench";
247
+ startViewTransition("zoom-in", BENCH_CAM, BENCH_LOOK);
248
+ }
249
+
250
  function closeMachineView() {
251
  ui.closeModal();
252
  startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
253
  }
254
 
255
+ function closeBenchView() {
256
+ startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
257
+ }
258
+
259
  canvas.addEventListener("click", () => {
260
+ if (hoveredItem && view.mode === "scene" && intro.phase === "idle") {
261
+ hoveredItem.onClick();
262
+ } else if (view.mode === "bench") {
263
+ closeBenchView();
264
  }
265
  });
266
 
267
+ window.addEventListener("keydown", (e) => {
268
+ if (e.key === "Escape" && view.mode === "bench") closeBenchView();
269
+ });
270
+
271
  // Machine glow while generating
272
  let machineBusy = false;
273
  const SCREEN_BASE = new THREE.Color(0x1c2f28);
 
369
  closeMachine() {
370
  closeMachineView();
371
  },
372
+ openBench() {
373
+ this.skipIntro();
374
+ view.mode = "bench";
375
+ camera.position.copy(BENCH_CAM);
376
+ lookTarget.copy(BENCH_LOOK);
377
+ },
378
  stats() {
379
  return { ...renderer.info.render, ...renderer.info.memory };
380
  },
 
450
  lookTarget.lerpVectors(view.lookFrom, view.lookTo, e);
451
  if (t >= 1) {
452
  if (view.mode === "zoom-in") {
453
+ view.mode = view.target;
454
+ if (view.target === "machine") ui.openModal();
455
  } else {
456
  view.mode = "scene";
457
  intro.idleStart = elapsed; // sway ramps back in
 
473
  }
474
 
475
  camera.lookAt(lookTarget);
476
+ updateHover();
477
  renderer.render(scene, camera);
478
  }
479
  animate();
frontend/style.css CHANGED
@@ -86,7 +86,7 @@ body {
86
  }
87
  }
88
 
89
- #vending-label {
90
  position: fixed;
91
  left: 0;
92
  top: 0;
@@ -97,11 +97,11 @@ body {
97
  transition: opacity 0.18s ease;
98
  }
99
 
100
- #vending-label.visible {
101
  opacity: 1;
102
  }
103
 
104
- #vending-label span {
105
  position: relative;
106
  display: inline-block;
107
  font-family: "Baloo 2", sans-serif;
@@ -115,7 +115,7 @@ body {
115
  white-space: nowrap;
116
  }
117
 
118
- #vending-label span::after {
119
  content: "";
120
  position: absolute;
121
  left: 50%;
@@ -128,7 +128,7 @@ body {
128
  z-index: -1;
129
  }
130
 
131
- #vending-label.visible span {
132
  animation:
133
  label-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both,
134
  label-bob 2s ease-in-out 0.45s infinite alternate;
 
86
  }
87
  }
88
 
89
+ .hover-label {
90
  position: fixed;
91
  left: 0;
92
  top: 0;
 
97
  transition: opacity 0.18s ease;
98
  }
99
 
100
+ .hover-label.visible {
101
  opacity: 1;
102
  }
103
 
104
+ .hover-label span {
105
  position: relative;
106
  display: inline-block;
107
  font-family: "Baloo 2", sans-serif;
 
115
  white-space: nowrap;
116
  }
117
 
118
+ .hover-label span::after {
119
  content: "";
120
  position: absolute;
121
  left: 50%;
 
128
  z-index: -1;
129
  }
130
 
131
+ .hover-label.visible span {
132
  animation:
133
  label-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both,
134
  label-bob 2s ease-in-out 0.45s infinite alternate;
frontend/world.js CHANGED
@@ -448,7 +448,27 @@ function buildCassettePile() {
448
  if (i === placements.length - 1) cassette.rotation.z = 0.07;
449
  g.add(cassette);
450
  });
451
- return shadows(g);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  }
453
 
454
  // --- walkman + vintage headphones ---------------------------------------------
@@ -472,7 +492,21 @@ function buildWalkman() {
472
  button.position.set(0.19, 0.12, -0.1 + i * 0.1);
473
  g.add(button);
474
  });
475
- return shadows(g);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  }
477
 
478
  function buildHeadphones() {
@@ -979,17 +1013,26 @@ export function buildWorld(scene) {
979
  bench.position.set(1.7, 0.3, 4.4);
980
  scene.add(bench);
981
 
982
- // someone left their tape collection on the bench…
 
 
 
 
983
  const cassettes = buildCassettePile();
984
- cassettes.position.set(-1.0, 1.05, -0.05);
985
  cassettes.rotation.y = -0.2;
986
- bench.add(cassettes);
987
 
988
  // …with their walkman and headphones beside it
989
  const walkman = buildWalkman();
990
- walkman.position.set(0.15, 1.05, 0.02);
991
  walkman.rotation.y = 0.35;
992
- bench.add(walkman);
 
 
 
 
 
993
 
994
  const headphones = buildHeadphones();
995
  headphones.position.set(0.85, 1.05, -0.04);
@@ -1097,5 +1140,5 @@ export function buildWorld(scene) {
1097
  const clouds = buildClouds();
1098
  for (const cloud of clouds) scene.add(cloud);
1099
 
1100
- return { vending, bench, sign, lamp, bigTree, house, clouds, bird };
1101
  }
 
448
  if (i === placements.length - 1) cassette.rotation.z = 0.07;
449
  g.add(cassette);
450
  });
451
+ shadows(g);
452
+
453
+ // Hover outline: one back-face hull per cassette so the highlight follows
454
+ // the silhouette of the pile (same trick as the vending machine).
455
+ const outline = new THREE.Group();
456
+ const outlineMaterial = new THREE.MeshBasicMaterial({
457
+ color: 0xffd84a,
458
+ side: THREE.BackSide,
459
+ });
460
+ placements.forEach(([x, y, z, yaw], i) => {
461
+ const hull = box(0.54, 0.16, 0.38, outlineMaterial);
462
+ hull.position.set(x, y + 0.04, z);
463
+ hull.rotation.y = yaw;
464
+ if (i === placements.length - 1) hull.rotation.z = 0.07;
465
+ outline.add(hull);
466
+ });
467
+ outline.visible = false;
468
+ g.add(outline);
469
+ g.userData.outline = outline;
470
+
471
+ return g;
472
  }
473
 
474
  // --- walkman + vintage headphones ---------------------------------------------
 
492
  button.position.set(0.19, 0.12, -0.1 + i * 0.1);
493
  g.add(button);
494
  });
495
+ shadows(g);
496
+
497
+ // Hover outline hull (see buildCassettePile)
498
+ const outline = new THREE.Group();
499
+ const hull = box(0.6, 0.22, 0.46, new THREE.MeshBasicMaterial({
500
+ color: 0xffd84a,
501
+ side: THREE.BackSide,
502
+ }));
503
+ hull.position.y = 0.075;
504
+ outline.add(hull);
505
+ outline.visible = false;
506
+ g.add(outline);
507
+ g.userData.outline = outline;
508
+
509
+ return g;
510
  }
511
 
512
  function buildHeadphones() {
 
1013
  bench.position.set(1.7, 0.3, 4.4);
1014
  scene.add(bench);
1015
 
1016
+ // someone left their tape collection on the bench… (clickable as a group)
1017
+ const collection = new THREE.Group();
1018
+ collection.position.set(-0.4, 1.05, 0);
1019
+ bench.add(collection);
1020
+
1021
  const cassettes = buildCassettePile();
1022
+ cassettes.position.set(-0.6, 0, -0.05);
1023
  cassettes.rotation.y = -0.2;
1024
+ collection.add(cassettes);
1025
 
1026
  // …with their walkman and headphones beside it
1027
  const walkman = buildWalkman();
1028
+ walkman.position.set(0.55, 0, 0.02);
1029
  walkman.rotation.y = 0.35;
1030
+ collection.add(walkman);
1031
+
1032
+ collection.userData.outlines = [
1033
+ cassettes.userData.outline,
1034
+ walkman.userData.outline,
1035
+ ];
1036
 
1037
  const headphones = buildHeadphones();
1038
  headphones.position.set(0.85, 1.05, -0.04);
 
1140
  const clouds = buildClouds();
1141
  for (const cloud of clouds) scene.add(cloud);
1142
 
1143
+ return { vending, bench, collection, sign, lamp, bigTree, house, clouds, bird };
1144
  }