eloigil6 commited on
Commit
576433d
·
1 Parent(s): e1a3e6d

Add Game Boy feature and modal interaction. Introduced a Game Boy model in the scene, updated main.js to handle zoom interactions and modal display, and enhanced UI components in ui.js for opening and closing the Game Boy modal. Added corresponding styles in style.css for the modal's appearance and animations. Updated index.html to include a new label for the Game Boy interaction.

Browse files
Files changed (5) hide show
  1. frontend/index.html +27 -0
  2. frontend/main.js +38 -3
  3. frontend/style.css +239 -0
  4. frontend/ui.js +30 -2
  5. frontend/world.js +82 -1
frontend/index.html CHANGED
@@ -29,6 +29,7 @@
29
  <span>♪ cassette collection</span>
30
  </div>
31
  <div id="lamp-label" class="hover-label"><span>☾ turn on night</span></div>
 
32
 
33
  <div id="machine-modal" class="hidden">
34
  <button id="modal-close" aria-label="close">×</button>
@@ -175,6 +176,32 @@
175
  </a>
176
  </div>
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  <div id="now-playing" class="hidden">
179
  <button id="np-toggle" aria-label="play/pause">❚❚</button>
180
  <span id="np-title"></span>
 
29
  <span>♪ cassette collection</span>
30
  </div>
31
  <div id="lamp-label" class="hover-label"><span>☾ turn on night</span></div>
32
+ <div id="gameboy-label" class="hover-label"><span>▸ play</span></div>
33
 
34
  <div id="machine-modal" class="hidden">
35
  <button id="modal-close" aria-label="close">×</button>
 
176
  </a>
177
  </div>
178
 
179
+ <div id="gameboy-modal" class="hidden">
180
+ <button id="gameboy-close" aria-label="close">×</button>
181
+ <div class="gb-shell">
182
+ <div class="gb-screen-frame">
183
+ <div class="gb-power"><i></i>BATTERY</div>
184
+ <div id="gameboy-screen" class="gb-screen">
185
+ <p class="gb-line">★ MINI-GAME ★</p>
186
+ <p class="gb-line gb-blink">▶ COMING SOON</p>
187
+ </div>
188
+ </div>
189
+ <div class="gb-logo">LoFinity<span>♪</span></div>
190
+ <div class="gb-controls">
191
+ <div class="gb-dpad" aria-hidden="true">
192
+ <span class="gb-pad-v"></span><span class="gb-pad-h"></span>
193
+ </div>
194
+ <div class="gb-ab" aria-hidden="true">
195
+ <span class="gb-btn">B</span><span class="gb-btn">A</span>
196
+ </div>
197
+ </div>
198
+ <div class="gb-startsel" aria-hidden="true"><span></span><span></span></div>
199
+ <div class="gb-speaker" aria-hidden="true">
200
+ <i></i><i></i><i></i><i></i><i></i><i></i>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
  <div id="now-playing" class="hidden">
206
  <button id="np-toggle" aria-label="play/pause">❚❚</button>
207
  <span id="np-title"></span>
frontend/main.js CHANGED
@@ -123,7 +123,7 @@ scene.add(sun);
123
  // skipped automatically since they aren't plain opaque Lambert.
124
  function mergeStaticWorld(scene, world) {
125
  scene.updateMatrixWorld(true);
126
- const excludedRoots = [world.vending, world.bench, world.lamp, ...world.clouds];
127
  const isExcluded = (obj) => {
128
  for (let o = obj; o; o = o.parent) if (excludedRoots.includes(o)) return true;
129
  return false;
@@ -246,6 +246,14 @@ const interactables = [
246
  labelOffsetX: -0.3, // left of the bubble
247
  onClick: () => toggleNight(),
248
  },
 
 
 
 
 
 
 
 
249
  ];
250
 
251
  window.addEventListener("pointermove", (e) => {
@@ -306,10 +314,13 @@ const MACHINE_LOOK = new THREE.Vector3(-2.9, 2.6, 4.2);
306
  // Leans over the bench seat where the tapes and walkman rest.
307
  const BENCH_CAM = new THREE.Vector3(1.3, 3.3, 8.4);
308
  const BENCH_LOOK = new THREE.Vector3(1.3, 1.15, 4.4);
 
 
 
309
  const ZOOM_MS = 1500;
310
 
311
  const view = {
312
- mode: "scene", // scene | zoom-in | machine | bench | zoom-out
313
  target: "machine", // what the current zoom-in lands on
314
  t0: 0,
315
  camFrom: new THREE.Vector3(),
@@ -349,6 +360,17 @@ function closeBenchView() {
349
  startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
350
  }
351
 
 
 
 
 
 
 
 
 
 
 
 
352
  canvas.addEventListener("click", () => {
353
  if (hoveredItem && view.mode === "scene" && intro.phase === "idle") {
354
  hoveredItem.onClick();
@@ -358,7 +380,9 @@ canvas.addEventListener("click", () => {
358
  });
359
 
360
  window.addEventListener("keydown", (e) => {
361
- if (e.key === "Escape" && view.mode === "bench") closeBenchView();
 
 
362
  });
363
 
364
  // Machine glow while generating
@@ -473,6 +497,9 @@ const ui = initUI({
473
  onRequestCloseCollection: () => {
474
  if (view.mode === "bench") closeBenchView();
475
  },
 
 
 
476
  onGeneratingChange: setMachineBusy,
477
  });
478
 
@@ -559,6 +586,13 @@ window.lofinityDebug = {
559
  lookTarget.copy(BENCH_LOOK);
560
  ui.openCollection();
561
  },
 
 
 
 
 
 
 
562
  stats() {
563
  return { ...renderer.info.render, ...renderer.info.memory };
564
  },
@@ -646,6 +680,7 @@ function animate(now = 0) {
646
  view.mode = view.target;
647
  if (view.target === "machine") ui.openModal();
648
  else if (view.target === "bench") ui.openCollection();
 
649
  } else {
650
  view.mode = "scene";
651
  intro.idleStart = elapsed; // sway ramps back in
 
123
  // skipped automatically since they aren't plain opaque Lambert.
124
  function mergeStaticWorld(scene, world) {
125
  scene.updateMatrixWorld(true);
126
+ const excludedRoots = [world.vending, world.bench, world.lamp, world.gameboy, ...world.clouds];
127
  const isExcluded = (obj) => {
128
  for (let o = obj; o; o = o.parent) if (excludedRoots.includes(o)) return true;
129
  return false;
 
246
  labelOffsetX: -0.3, // left of the bubble
247
  onClick: () => toggleNight(),
248
  },
249
+ {
250
+ object: world.gameboy,
251
+ outlines: [world.gameboy.userData.outline],
252
+ label: document.getElementById("gameboy-label"),
253
+ anchor: world.gameboy.position.clone(),
254
+ labelOffsetY: 1.3, // floats just above the handheld on the ground
255
+ onClick: () => zoomToGameboy(),
256
+ },
257
  ];
258
 
259
  window.addEventListener("pointermove", (e) => {
 
314
  // Leans over the bench seat where the tapes and walkman rest.
315
  const BENCH_CAM = new THREE.Vector3(1.3, 3.3, 8.4);
316
  const BENCH_LOOK = new THREE.Vector3(1.3, 1.15, 4.4);
317
+ // Crouches toward the game boy on the sidewalk; the modal sits over it.
318
+ const GAMEBOY_CAM = new THREE.Vector3(5.6, 2.3, 9.0);
319
+ const GAMEBOY_LOOK = new THREE.Vector3(6.5, 0.3, 5.6);
320
  const ZOOM_MS = 1500;
321
 
322
  const view = {
323
+ mode: "scene", // scene | zoom-in | machine | bench | gameboy | zoom-out
324
  target: "machine", // what the current zoom-in lands on
325
  t0: 0,
326
  camFrom: new THREE.Vector3(),
 
360
  startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
361
  }
362
 
363
+ function zoomToGameboy() {
364
+ setHoveredItem(null);
365
+ view.target = "gameboy";
366
+ startViewTransition("zoom-in", GAMEBOY_CAM, GAMEBOY_LOOK);
367
+ }
368
+
369
+ function closeGameboyView() {
370
+ ui.closeGameboy();
371
+ startViewTransition("zoom-out", CAM_END, LOOK_SCENE);
372
+ }
373
+
374
  canvas.addEventListener("click", () => {
375
  if (hoveredItem && view.mode === "scene" && intro.phase === "idle") {
376
  hoveredItem.onClick();
 
380
  });
381
 
382
  window.addEventListener("keydown", (e) => {
383
+ if (e.key !== "Escape") return;
384
+ if (view.mode === "bench") closeBenchView();
385
+ else if (view.mode === "gameboy") closeGameboyView();
386
  });
387
 
388
  // Machine glow while generating
 
497
  onRequestCloseCollection: () => {
498
  if (view.mode === "bench") closeBenchView();
499
  },
500
+ onRequestCloseGameboy: () => {
501
+ if (view.mode === "gameboy") closeGameboyView();
502
+ },
503
  onGeneratingChange: setMachineBusy,
504
  });
505
 
 
586
  lookTarget.copy(BENCH_LOOK);
587
  ui.openCollection();
588
  },
589
+ openGameboy() {
590
+ this.skipIntro();
591
+ view.mode = "gameboy";
592
+ camera.position.copy(GAMEBOY_CAM);
593
+ lookTarget.copy(GAMEBOY_LOOK);
594
+ ui.openGameboy();
595
+ },
596
  stats() {
597
  return { ...renderer.info.render, ...renderer.info.memory };
598
  },
 
680
  view.mode = view.target;
681
  if (view.target === "machine") ui.openModal();
682
  else if (view.target === "bench") ui.openCollection();
683
+ else if (view.target === "gameboy") ui.openGameboy();
684
  } else {
685
  view.mode = "scene";
686
  intro.idleStart = elapsed; // sway ramps back in
frontend/style.css CHANGED
@@ -1128,3 +1128,242 @@ body {
1128
  opacity: 0.4;
1129
  pointer-events: none;
1130
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
  opacity: 0.4;
1129
  pointer-events: none;
1130
  }
1131
+
1132
+ /* --- game boy modal --------------------------------------------------------- */
1133
+
1134
+ #gameboy-modal {
1135
+ position: fixed;
1136
+ top: 50%;
1137
+ left: 50%;
1138
+ transform: translate(-50%, -50%);
1139
+ z-index: 19;
1140
+ transition: opacity 0.3s ease;
1141
+ }
1142
+
1143
+ #gameboy-modal.hidden {
1144
+ opacity: 0;
1145
+ pointer-events: none;
1146
+ }
1147
+
1148
+ #gameboy-modal.opening .gb-shell {
1149
+ animation: gb-pop 0.42s cubic-bezier(0.34, 1.56, 0.64, 1) both;
1150
+ }
1151
+
1152
+ #gameboy-modal.closing .gb-shell {
1153
+ animation: gb-drop 0.28s ease both;
1154
+ }
1155
+
1156
+ @keyframes gb-pop {
1157
+ from {
1158
+ opacity: 0;
1159
+ transform: translateY(26px) scale(0.85);
1160
+ }
1161
+ to {
1162
+ opacity: 1;
1163
+ transform: translateY(0) scale(1);
1164
+ }
1165
+ }
1166
+
1167
+ @keyframes gb-drop {
1168
+ to {
1169
+ opacity: 0;
1170
+ transform: translateY(20px) scale(0.92);
1171
+ }
1172
+ }
1173
+
1174
+ #gameboy-close {
1175
+ position: absolute;
1176
+ top: -14px;
1177
+ right: -14px;
1178
+ z-index: 2;
1179
+ width: 36px;
1180
+ height: 36px;
1181
+ border-radius: 50%;
1182
+ border: 1px solid rgba(255, 255, 255, 0.5);
1183
+ background: rgba(255, 255, 255, 0.22);
1184
+ backdrop-filter: blur(10px);
1185
+ -webkit-backdrop-filter: blur(10px);
1186
+ color: #fff;
1187
+ font-size: 1.15rem;
1188
+ line-height: 1;
1189
+ cursor: pointer;
1190
+ text-shadow: 0 1px 3px rgba(15, 40, 90, 0.4);
1191
+ }
1192
+
1193
+ #gameboy-close:hover {
1194
+ background: rgba(255, 255, 255, 0.38);
1195
+ }
1196
+
1197
+ .gb-shell {
1198
+ width: min(330px, 86vw);
1199
+ padding: 22px 22px 26px;
1200
+ background: linear-gradient(160deg, #d3d0c6, #b7b4a9);
1201
+ border-radius: 16px 16px 64px 16px;
1202
+ box-shadow:
1203
+ inset 0 2px 0 rgba(255, 255, 255, 0.6),
1204
+ inset 0 -6px 14px rgba(0, 0, 0, 0.18),
1205
+ 0 24px 60px rgba(15, 40, 90, 0.45);
1206
+ font-family: "Baloo 2", sans-serif;
1207
+ }
1208
+
1209
+ .gb-screen-frame {
1210
+ background: linear-gradient(160deg, #585d56, #3f443d);
1211
+ border-radius: 10px 10px 30px 10px;
1212
+ padding: 14px 26px 22px;
1213
+ box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.5);
1214
+ }
1215
+
1216
+ .gb-power {
1217
+ display: flex;
1218
+ align-items: center;
1219
+ gap: 7px;
1220
+ margin: 0 0 8px 2px;
1221
+ font-size: 0.52rem;
1222
+ letter-spacing: 0.14em;
1223
+ font-weight: 700;
1224
+ color: #c4c8d0;
1225
+ }
1226
+
1227
+ .gb-power i {
1228
+ width: 8px;
1229
+ height: 8px;
1230
+ border-radius: 50%;
1231
+ background: #d9534f;
1232
+ box-shadow: 0 0 6px #ff6a64;
1233
+ }
1234
+
1235
+ .gb-screen {
1236
+ aspect-ratio: 10 / 9;
1237
+ display: flex;
1238
+ flex-direction: column;
1239
+ align-items: center;
1240
+ justify-content: center;
1241
+ gap: 10px;
1242
+ border-radius: 4px;
1243
+ color: #213a08;
1244
+ box-shadow:
1245
+ inset 0 0 0 2px #6b8b06,
1246
+ inset 0 2px 10px rgba(0, 0, 0, 0.25);
1247
+ background-image:
1248
+ linear-gradient(#9bbc0f, #8ba30c),
1249
+ repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.05) 0 2px, transparent 2px 4px);
1250
+ }
1251
+
1252
+ .gb-line {
1253
+ margin: 0;
1254
+ font-family: "DotGothic16", monospace;
1255
+ font-size: 0.95rem;
1256
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
1257
+ }
1258
+
1259
+ .gb-blink {
1260
+ animation: gb-blink 1s steps(1) infinite;
1261
+ }
1262
+
1263
+ @keyframes gb-blink {
1264
+ 50% {
1265
+ opacity: 0;
1266
+ }
1267
+ }
1268
+
1269
+ .gb-logo {
1270
+ margin: 12px 0 14px;
1271
+ text-align: center;
1272
+ font-weight: 800;
1273
+ font-style: italic;
1274
+ font-size: 1.05rem;
1275
+ color: #2c3550;
1276
+ }
1277
+
1278
+ .gb-logo span {
1279
+ color: #8c2d52;
1280
+ margin-left: 2px;
1281
+ }
1282
+
1283
+ .gb-controls {
1284
+ display: flex;
1285
+ align-items: center;
1286
+ justify-content: space-between;
1287
+ padding: 0 6px;
1288
+ }
1289
+
1290
+ .gb-dpad {
1291
+ position: relative;
1292
+ width: 56px;
1293
+ height: 56px;
1294
+ }
1295
+
1296
+ .gb-pad-v,
1297
+ .gb-pad-h {
1298
+ position: absolute;
1299
+ background: #2b2f2b;
1300
+ border-radius: 4px;
1301
+ box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.12);
1302
+ }
1303
+
1304
+ .gb-pad-v {
1305
+ left: 19px;
1306
+ top: 0;
1307
+ width: 18px;
1308
+ height: 56px;
1309
+ }
1310
+
1311
+ .gb-pad-h {
1312
+ top: 19px;
1313
+ left: 0;
1314
+ width: 56px;
1315
+ height: 18px;
1316
+ }
1317
+
1318
+ .gb-ab {
1319
+ display: flex;
1320
+ gap: 14px;
1321
+ transform: rotate(-18deg);
1322
+ }
1323
+
1324
+ .gb-btn {
1325
+ width: 40px;
1326
+ height: 40px;
1327
+ border-radius: 50%;
1328
+ background: radial-gradient(circle at 35% 30%, #a8406b, #7a1f45);
1329
+ color: #f2d6e2;
1330
+ font-weight: 800;
1331
+ font-size: 0.95rem;
1332
+ display: flex;
1333
+ align-items: center;
1334
+ justify-content: center;
1335
+ box-shadow:
1336
+ 0 3px 5px rgba(0, 0, 0, 0.3),
1337
+ inset 0 2px 3px rgba(255, 255, 255, 0.25);
1338
+ }
1339
+
1340
+ .gb-startsel {
1341
+ display: flex;
1342
+ justify-content: center;
1343
+ gap: 18px;
1344
+ margin-top: 18px;
1345
+ }
1346
+
1347
+ .gb-startsel span {
1348
+ width: 34px;
1349
+ height: 11px;
1350
+ border-radius: 999px;
1351
+ background: #6b7280;
1352
+ transform: rotate(-22deg);
1353
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
1354
+ }
1355
+
1356
+ .gb-speaker {
1357
+ display: flex;
1358
+ gap: 6px;
1359
+ justify-content: flex-end;
1360
+ margin: 16px 10px 0 0;
1361
+ transform: rotate(-22deg);
1362
+ }
1363
+
1364
+ .gb-speaker i {
1365
+ width: 4px;
1366
+ height: 26px;
1367
+ border-radius: 999px;
1368
+ background: rgba(0, 0, 0, 0.22);
1369
+ }
frontend/ui.js CHANGED
@@ -9,9 +9,11 @@ export function initUI({
9
  listSongs,
10
  onRequestClose,
11
  onRequestCloseCollection,
 
12
  onGeneratingChange,
13
  }) {
14
  const modal = $("machine-modal");
 
15
  const input = $("prompt-input");
16
  const coinBtn = $("coin-button");
17
  const controlsRow = $("controls-row");
@@ -43,6 +45,7 @@ export function initUI({
43
  audio.loop = true; // a cassette of endless chill
44
  let busy = false;
45
  let collectionOpen = false;
 
46
  let currentSong = null; // { title, url } of the tape in the deck
47
 
48
  function setStage(stage) {
@@ -302,6 +305,7 @@ export function initUI({
302
  deckProgress.addEventListener("click", seek(deckProgress));
303
 
304
  $("collection-close").addEventListener("click", () => onRequestCloseCollection());
 
305
  caroPrev.addEventListener("click", () => centerOn(centerIndex - 1, { load: true }));
306
  caroNext.addEventListener("click", () => centerOn(centerIndex + 1, { load: true }));
307
 
@@ -339,12 +343,16 @@ export function initUI({
339
  });
340
 
341
  function syncPill() {
342
- // the corner pill yields to the machine modal and the tape deck
343
  const modalHidden = modal.classList.contains("hidden");
344
- pill.classList.toggle("hidden", !(audio.src && modalHidden && !collectionOpen));
 
 
 
345
  }
346
 
347
  let closeTimer = null;
 
348
 
349
  return {
350
  openModal() {
@@ -370,5 +378,25 @@ export function initUI({
370
  },
371
  openCollection,
372
  closeCollection,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  };
374
  }
 
9
  listSongs,
10
  onRequestClose,
11
  onRequestCloseCollection,
12
+ onRequestCloseGameboy,
13
  onGeneratingChange,
14
  }) {
15
  const modal = $("machine-modal");
16
+ const gameboyModal = $("gameboy-modal");
17
  const input = $("prompt-input");
18
  const coinBtn = $("coin-button");
19
  const controlsRow = $("controls-row");
 
45
  audio.loop = true; // a cassette of endless chill
46
  let busy = false;
47
  let collectionOpen = false;
48
+ let gameboyOpen = false;
49
  let currentSong = null; // { title, url } of the tape in the deck
50
 
51
  function setStage(stage) {
 
305
  deckProgress.addEventListener("click", seek(deckProgress));
306
 
307
  $("collection-close").addEventListener("click", () => onRequestCloseCollection());
308
+ $("gameboy-close").addEventListener("click", () => onRequestCloseGameboy());
309
  caroPrev.addEventListener("click", () => centerOn(centerIndex - 1, { load: true }));
310
  caroNext.addEventListener("click", () => centerOn(centerIndex + 1, { load: true }));
311
 
 
343
  });
344
 
345
  function syncPill() {
346
+ // the corner pill yields to the machine modal, the collection, and the game boy
347
  const modalHidden = modal.classList.contains("hidden");
348
+ pill.classList.toggle(
349
+ "hidden",
350
+ !(audio.src && modalHidden && !collectionOpen && !gameboyOpen),
351
+ );
352
  }
353
 
354
  let closeTimer = null;
355
+ let gbCloseTimer = null;
356
 
357
  return {
358
  openModal() {
 
378
  },
379
  openCollection,
380
  closeCollection,
381
+ openGameboy() {
382
+ clearTimeout(gbCloseTimer);
383
+ gameboyOpen = true;
384
+ gameboyModal.classList.remove("hidden", "closing");
385
+ void gameboyModal.offsetWidth; // restart the entrance animation
386
+ gameboyModal.classList.add("opening");
387
+ syncPill();
388
+ },
389
+ closeGameboy() {
390
+ gameboyOpen = false;
391
+ if (gameboyModal.classList.contains("hidden")) return syncPill();
392
+ gameboyModal.classList.remove("opening");
393
+ gameboyModal.classList.add("closing");
394
+ clearTimeout(gbCloseTimer);
395
+ gbCloseTimer = setTimeout(() => {
396
+ gameboyModal.classList.add("hidden");
397
+ gameboyModal.classList.remove("closing");
398
+ }, 300);
399
+ syncPill();
400
+ },
401
  };
402
  }
frontend/world.js CHANGED
@@ -529,6 +529,81 @@ function buildHeadphones() {
529
  return shadows(g);
530
  }
531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  // --- little bird ---------------------------------------------------------------
533
 
534
  function buildBird() {
@@ -1098,6 +1173,12 @@ export function buildWorld(scene) {
1098
  lamp.position.set(9.6, 0.3, 3.6);
1099
  scene.add(lamp);
1100
 
 
 
 
 
 
 
1101
  const wall = buildStoneWall();
1102
  wall.position.set(-13.5, 0, 3.4);
1103
  wall.rotation.y = 0.25;
@@ -1167,5 +1248,5 @@ export function buildWorld(scene) {
1167
  const clouds = buildClouds();
1168
  for (const cloud of clouds) scene.add(cloud);
1169
 
1170
- return { vending, bench, collection, sign, lamp, bigTree, house, clouds, bird };
1171
  }
 
529
  return shadows(g);
530
  }
531
 
532
+ // --- game boy (dropped on the sidewalk) -------------------------------------
533
+
534
+ function buildGameboy() {
535
+ const g = new THREE.Group();
536
+ // classic DMG shell lying flat, face up. local axes: x=width, z=length, y=up
537
+ const body = box(0.42, 0.1, 0.64, lambert(0xbfc4bd));
538
+ body.position.y = 0.05;
539
+ g.add(body);
540
+
541
+ const lip = box(0.4, 0.02, 0.62, lambert(0xced2cb));
542
+ lip.position.y = 0.1;
543
+ g.add(lip);
544
+
545
+ // screen bezel (upper half, toward -z) + glowing green LCD
546
+ const bezel = box(0.34, 0.03, 0.28, lambert(0x49544b));
547
+ bezel.position.set(0, 0.105, -0.14);
548
+ g.add(bezel);
549
+ const screen = new THREE.Mesh(
550
+ new THREE.BoxGeometry(0.26, 0.034, 0.2),
551
+ new THREE.MeshBasicMaterial({ color: 0x9bbc0f })
552
+ );
553
+ screen.position.set(0, 0.108, -0.14);
554
+ g.add(screen);
555
+
556
+ // d-pad (lower-left): a dark cross
557
+ const dpadMat = lambert(0x2b2f2b);
558
+ for (const [w, d] of [[0.045, 0.12], [0.12, 0.045]]) {
559
+ const arm = box(w, 0.022, d, dpadMat);
560
+ arm.position.set(-0.11, 0.108, 0.14);
561
+ g.add(arm);
562
+ }
563
+
564
+ // A/B buttons (lower-right): dark magenta, set diagonally
565
+ const btnMat = lambert(0x8c2d52);
566
+ for (const [bx, bz] of [[0.07, 0.18], [0.155, 0.12]]) {
567
+ const btn = cyl(0.032, 0.032, 0.024, 12, btnMat);
568
+ btn.position.set(bx, 0.11, bz);
569
+ g.add(btn);
570
+ }
571
+
572
+ // start / select: two little grey pills, angled
573
+ const pillMat = lambert(0x6b7280);
574
+ for (const px of [-0.04, 0.05]) {
575
+ const pill = box(0.07, 0.018, 0.022, pillMat);
576
+ pill.position.set(px, 0.103, 0.265);
577
+ pill.rotation.y = -0.4;
578
+ g.add(pill);
579
+ }
580
+
581
+ // speaker grille (lower-right corner): a few slits
582
+ const grilleMat = lambert(0x9aa09a);
583
+ for (let i = 0; i < 4; i++) {
584
+ const slit = box(0.012, 0.016, 0.07, grilleMat);
585
+ slit.position.set(0.1 + i * 0.022, 0.104, 0.27);
586
+ slit.rotation.y = -0.5;
587
+ g.add(slit);
588
+ }
589
+
590
+ shadows(g);
591
+
592
+ // hover outline: a single inflated back-face hull (see buildWalkman)
593
+ const outline = new THREE.Group();
594
+ const hull = box(0.5, 0.2, 0.72, new THREE.MeshBasicMaterial({
595
+ color: 0xffd84a,
596
+ side: THREE.BackSide,
597
+ }));
598
+ hull.position.y = 0.07;
599
+ outline.add(hull);
600
+ outline.visible = false;
601
+ g.add(outline);
602
+ g.userData.outline = outline;
603
+
604
+ return g;
605
+ }
606
+
607
  // --- little bird ---------------------------------------------------------------
608
 
609
  function buildBird() {
 
1173
  lamp.position.set(9.6, 0.3, 3.6);
1174
  scene.add(lamp);
1175
 
1176
+ // someone dropped their game boy on the sidewalk between the bench and lamp
1177
+ const gameboy = buildGameboy();
1178
+ gameboy.position.set(6.6, 0.3, 5.7);
1179
+ gameboy.rotation.y = -0.55;
1180
+ scene.add(gameboy);
1181
+
1182
  const wall = buildStoneWall();
1183
  wall.position.set(-13.5, 0, 3.4);
1184
  wall.rotation.y = 0.25;
 
1248
  const clouds = buildClouds();
1249
  for (const cloud of clouds) scene.add(cloud);
1250
 
1251
+ return { vending, bench, collection, sign, lamp, gameboy, bigTree, house, clouds, bird };
1252
  }