Spaces:
Sleeping
Sleeping
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- frontend/index.html +27 -0
- frontend/main.js +38 -3
- frontend/style.css +239 -0
- frontend/ui.js +30 -2
- 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 ==
|
|
|
|
|
|
|
| 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
|
| 343 |
const modalHidden = modal.classList.contains("hidden");
|
| 344 |
-
pill.classList.toggle(
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|