Spaces:
Running on Zero
Running on Zero
| // LoFinity — entry point: renderer, camera, sky, intro choreography and the | |
| // backend bridge. The world itself (vending machine, house, fields…) is | |
| // built in world.js. | |
| import * as THREE from "three"; | |
| import { mergeGeometries } from "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/utils/BufferGeometryUtils.js"; | |
| import { buildWorld } from "/static/world.js"; | |
| import { initUI } from "/static/ui.js"; | |
| // --------------------------------------------------------------------------- | |
| // Renderer / scene / camera | |
| // --------------------------------------------------------------------------- | |
| const canvas = document.getElementById("scene"); | |
| const renderer = new THREE.WebGLRenderer({ | |
| canvas, | |
| antialias: true, | |
| powerPreference: "low-power", | |
| }); | |
| // 1.5x is visually identical to 2x at this flat-shaded art style, ~44% fewer pixels | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| // nothing that casts shadows ever moves: bake the shadow map once | |
| renderer.shadowMap.autoUpdate = false; | |
| renderer.shadowMap.needsUpdate = true; | |
| const scene = new THREE.Scene(); | |
| scene.fog = new THREE.Fog(0xbfe3ff, 90, 420); | |
| const camera = new THREE.PerspectiveCamera( | |
| 55, | |
| window.innerWidth / window.innerHeight, | |
| 0.1, | |
| 1000, | |
| ); | |
| // The camera starts high in the sky gaze and dollies down/back during the | |
| // descent for a touch of parallax. | |
| const CAM_START = new THREE.Vector3(0, 7.4, 14.5); | |
| const CAM_END = new THREE.Vector3(0, 4.8, 17); | |
| const LOOK_SKY = new THREE.Vector3(0, 90, -60); | |
| const LOOK_SCENE = new THREE.Vector3(-0.5, 2.8, -3); | |
| camera.position.copy(CAM_START); | |
| camera.lookAt(LOOK_SKY); | |
| camera.layers.enable(1); // cloud layer | |
| window.addEventListener("resize", () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Sky: big inverted dome with a vertical anime-blue gradient | |
| // --------------------------------------------------------------------------- | |
| const skyMaterial = new THREE.ShaderMaterial({ | |
| side: THREE.BackSide, | |
| depthWrite: false, | |
| uniforms: { | |
| topColor: { value: new THREE.Color(0x1547a8) }, | |
| horizonColor: { value: new THREE.Color(0xbfe3ff) }, | |
| }, | |
| vertexShader: /* glsl */ ` | |
| varying vec3 vWorldPosition; | |
| void main() { | |
| vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: /* glsl */ ` | |
| uniform vec3 topColor; | |
| uniform vec3 horizonColor; | |
| varying vec3 vWorldPosition; | |
| void main() { | |
| float h = clamp(normalize(vWorldPosition).y, 0.0, 1.0); | |
| vec3 color = mix(horizonColor, topColor, pow(h, 0.55)); | |
| gl_FragColor = vec4(color, 1.0); | |
| } | |
| `, | |
| }); | |
| scene.add(new THREE.Mesh(new THREE.SphereGeometry(450, 32, 16), skyMaterial)); | |
| // --------------------------------------------------------------------------- | |
| // Lights | |
| // --------------------------------------------------------------------------- | |
| const hemi = new THREE.HemisphereLight(0xcfe8ff, 0xa8b48a, 0.85); | |
| scene.add(hemi); | |
| // Clouds (layer 1) get their own bounce light: white from above, cool | |
| // blue-gray from below — no green ground tint. | |
| const cloudLight = new THREE.HemisphereLight(0xf2f8ff, 0xc9d9ec, 0.9); | |
| cloudLight.layers.set(1); | |
| scene.add(cloudLight); | |
| const sun = new THREE.DirectionalLight(0xfff2dc, 1.6); | |
| sun.layers.enable(1); | |
| sun.position.set(30, 55, 20); | |
| sun.castShadow = true; | |
| sun.shadow.mapSize.set(2048, 2048); | |
| sun.shadow.camera.left = -45; | |
| sun.shadow.camera.right = 45; | |
| sun.shadow.camera.top = 45; | |
| sun.shadow.camera.bottom = -45; | |
| sun.shadow.camera.near = 5; | |
| sun.shadow.camera.far = 150; | |
| sun.shadow.bias = -0.0005; | |
| scene.add(sun); | |
| // --------------------------------------------------------------------------- | |
| // World | |
| // --------------------------------------------------------------------------- | |
| // Collapse the static background into one mesh per material. The scene is | |
| // hundreds of tiny flat-shaded meshes (trees, fences, sunflowers, fields…) | |
| // that never move, so bake each into world space and merge by material — | |
| // cutting draw calls ~3-4x. Interactables (vending/bench/lamp + their hover | |
| // outlines) and the animated bird/clouds are excluded so picking and | |
| // animation keep working; textured/basic meshes (signs, poster, sky) are | |
| // skipped automatically since they aren't plain opaque Lambert. | |
| function mergeStaticWorld(scene, world) { | |
| scene.updateMatrixWorld(true); | |
| const excludedRoots = [world.vending, world.bench, world.lamp, world.gameboy, ...world.clouds]; | |
| const isExcluded = (obj) => { | |
| for (let o = obj; o; o = o.parent) if (excludedRoots.includes(o)) return true; | |
| return false; | |
| }; | |
| const buckets = new Map(); // material signature -> meshes sharing it | |
| scene.traverse((obj) => { | |
| if (!obj.isMesh || isExcluded(obj)) return; | |
| const m = obj.material; | |
| if (Array.isArray(m) || !m.isMeshLambertMaterial) return; | |
| if (m.map || m.transparent || m.opacity < 1) return; | |
| const key = [ | |
| m.color.getHexString(), | |
| m.emissive.getHexString(), | |
| m.flatShading ? 1 : 0, | |
| m.side, | |
| obj.castShadow ? 1 : 0, | |
| obj.receiveShadow ? 1 : 0, | |
| ].join(":"); | |
| let b = buckets.get(key); | |
| if (!b) | |
| buckets.set( | |
| key, | |
| (b = { material: m, cast: obj.castShadow, receive: obj.receiveShadow, meshes: [] }), | |
| ); | |
| b.meshes.push(obj); | |
| }); | |
| let saved = 0; | |
| for (const b of buckets.values()) { | |
| if (b.meshes.length < 2) continue; // a lone mesh has nothing to merge with | |
| const geoms = b.meshes.map((mesh) => { | |
| // bake the world transform in, then strip everything but position/normal | |
| // so every geometry in the bucket has identical attributes to merge | |
| const g = mesh.geometry.index | |
| ? mesh.geometry.toNonIndexed() | |
| : mesh.geometry.clone(); | |
| g.applyMatrix4(mesh.matrixWorld); | |
| for (const name of Object.keys(g.attributes)) | |
| if (name !== "position" && name !== "normal") g.deleteAttribute(name); | |
| return g; | |
| }); | |
| const merged = mergeGeometries(geoms, false); | |
| geoms.forEach((g) => g.dispose()); | |
| if (!merged) continue; | |
| const mesh = new THREE.Mesh(merged, b.material); | |
| mesh.castShadow = b.cast; | |
| mesh.receiveShadow = b.receive; | |
| scene.add(mesh); | |
| for (const old of b.meshes) { | |
| old.geometry.dispose(); | |
| old.parent?.remove(old); | |
| } | |
| saved += b.meshes.length - 1; | |
| } | |
| return saved; | |
| } | |
| const world = buildWorld(scene); | |
| mergeStaticWorld(scene, world); | |
| // Lamp pool light: dark by day, ramps up in night mode. Sits at the bulb | |
| // (lamp group origin + the bulb's local offset). No shadows — the shadow map | |
| // is baked once, and this only lights up at night anyway. | |
| const lampLight = new THREE.PointLight(0xffd9a0, 0, 24, 2); | |
| lampLight.position.set( | |
| world.lamp.position.x - 1.85, | |
| world.lamp.position.y + 8.73, | |
| world.lamp.position.z, | |
| ); | |
| scene.add(lampLight); | |
| // Freeze transform recomputation for the (almost entirely) static scene; | |
| // only the clouds and the camera move. | |
| scene.traverse((obj) => { | |
| obj.updateMatrix(); | |
| obj.matrixAutoUpdate = false; | |
| }); | |
| for (const cloud of world.clouds) cloud.matrixAutoUpdate = true; | |
| world.bird.matrixAutoUpdate = true; | |
| world.bird.userData.tail.matrixAutoUpdate = true; | |
| // --------------------------------------------------------------------------- | |
| // Interactables: hover outline + floating label, click hook | |
| // --------------------------------------------------------------------------- | |
| const raycaster = new THREE.Raycaster(); | |
| const pointerNDC = new THREE.Vector2(-2, -2); // offscreen until first move | |
| let pointerActive = false; | |
| let hoveredItem = null; | |
| const labelAnchor = new THREE.Vector3(); | |
| // anchors are precomputed: nothing interactable ever moves | |
| const collectionAnchor = world.collection.getWorldPosition(new THREE.Vector3()); | |
| const interactables = [ | |
| { | |
| object: world.vending, | |
| outlines: [world.vending.userData.outline], | |
| label: document.getElementById("vending-label"), | |
| anchor: world.vending.position.clone(), | |
| labelOffsetY: 5.6, // above the machine top | |
| onClick: () => zoomToMachine(), | |
| }, | |
| { | |
| object: world.collection, | |
| outlines: world.collection.userData.outlines, | |
| label: document.getElementById("collection-label"), | |
| anchor: collectionAnchor, | |
| labelOffsetY: 1.15, // above the bench backrest | |
| onClick: () => zoomToBench(), | |
| }, | |
| { | |
| object: world.lamp, | |
| outlines: [world.lamp.userData.outline], | |
| label: document.getElementById("lamp-label"), | |
| anchor: world.lamp.position.clone(), | |
| labelOffsetY: 5.5, // pole mid-height; CSS sits the bubble to its left | |
| labelOffsetX: -0.3, // left of the bubble | |
| onClick: () => toggleNight(), | |
| }, | |
| { | |
| object: world.gameboy, | |
| outlines: [world.gameboy.userData.outline], | |
| label: document.getElementById("gameboy-label"), | |
| anchor: world.gameboy.position.clone(), | |
| labelOffsetY: 1.3, // floats just above the handheld on the ground | |
| onClick: () => zoomToGameboy(), | |
| }, | |
| ]; | |
| window.addEventListener("pointermove", (e) => { | |
| pointerNDC.set( | |
| (e.clientX / window.innerWidth) * 2 - 1, | |
| -(e.clientY / window.innerHeight) * 2 + 1, | |
| ); | |
| pointerActive = true; | |
| }); | |
| document.addEventListener("pointerleave", () => { | |
| pointerActive = false; | |
| }); | |
| function setHoveredItem(item) { | |
| if (item === hoveredItem) return; | |
| if (hoveredItem) { | |
| for (const outline of hoveredItem.outlines) outline.visible = false; | |
| hoveredItem.label.classList.remove("visible"); | |
| } | |
| hoveredItem = item; | |
| if (item) { | |
| for (const outline of item.outlines) outline.visible = true; | |
| item.label.classList.add("visible"); | |
| } | |
| canvas.style.cursor = item ? "pointer" : ""; | |
| } | |
| function updateHover() { | |
| if (intro.phase !== "idle" || view.mode !== "scene" || !pointerActive) { | |
| setHoveredItem(null); | |
| return; | |
| } | |
| raycaster.setFromCamera(pointerNDC, camera); | |
| setHoveredItem( | |
| interactables.find( | |
| (item) => raycaster.intersectObject(item.object, true).length > 0, | |
| ) ?? null, | |
| ); | |
| if (hoveredItem) { | |
| labelAnchor.copy(hoveredItem.anchor); | |
| labelAnchor.y += hoveredItem.labelOffsetY; | |
| labelAnchor.x += hoveredItem.labelOffsetX || 0; | |
| labelAnchor.project(camera); | |
| hoveredItem.label.style.left = `${((labelAnchor.x + 1) / 2) * window.innerWidth}px`; | |
| hoveredItem.label.style.top = `${((1 - labelAnchor.y) / 2) * window.innerHeight}px`; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Zoom views: machine (modal while zoomed) and bench (a close look at the | |
| // tapes). Click zooms in, close/Esc/click zooms back out. | |
| // --------------------------------------------------------------------------- | |
| // Frames the machine on the left so the modal card sits beside it. | |
| const MACHINE_CAM = new THREE.Vector3(-2.3, 3.0, 10.8); | |
| const MACHINE_LOOK = new THREE.Vector3(-2.9, 2.6, 4.2); | |
| // Leans over the bench seat where the tapes and walkman rest. | |
| const BENCH_CAM = new THREE.Vector3(1.3, 3.3, 8.4); | |
| const BENCH_LOOK = new THREE.Vector3(1.3, 1.15, 4.4); | |
| // Crouches toward the game boy on the sidewalk; the modal sits over it. | |
| const GAMEBOY_CAM = new THREE.Vector3(5.6, 2.3, 9.0); | |
| const GAMEBOY_LOOK = new THREE.Vector3(6.5, 0.3, 5.6); | |
| const ZOOM_MS = 1500; | |
| const view = { | |
| mode: "scene", // scene | zoom-in | machine | bench | gameboy | zoom-out | |
| target: "machine", // what the current zoom-in lands on | |
| t0: 0, | |
| camFrom: new THREE.Vector3(), | |
| camTo: new THREE.Vector3(), | |
| lookFrom: new THREE.Vector3(), | |
| lookTo: new THREE.Vector3(), | |
| }; | |
| function startViewTransition(mode, camTo, lookTo) { | |
| view.mode = mode; | |
| view.t0 = performance.now(); | |
| view.camFrom.copy(camera.position); | |
| view.lookFrom.copy(lookTarget); | |
| view.camTo.copy(camTo); | |
| view.lookTo.copy(lookTo); | |
| } | |
| function zoomToMachine() { | |
| setHoveredItem(null); | |
| view.target = "machine"; | |
| startViewTransition("zoom-in", MACHINE_CAM, MACHINE_LOOK); | |
| } | |
| function zoomToBench() { | |
| setHoveredItem(null); | |
| view.target = "bench"; | |
| startViewTransition("zoom-in", BENCH_CAM, BENCH_LOOK); | |
| } | |
| function closeMachineView() { | |
| ui.closeModal(); | |
| startViewTransition("zoom-out", CAM_END, LOOK_SCENE); | |
| } | |
| function closeBenchView() { | |
| ui.closeCollection(); | |
| startViewTransition("zoom-out", CAM_END, LOOK_SCENE); | |
| } | |
| function zoomToGameboy() { | |
| setHoveredItem(null); | |
| view.target = "gameboy"; | |
| startViewTransition("zoom-in", GAMEBOY_CAM, GAMEBOY_LOOK); | |
| } | |
| function closeGameboyView() { | |
| ui.closeGameboy(); | |
| startViewTransition("zoom-out", CAM_END, LOOK_SCENE); | |
| } | |
| canvas.addEventListener("click", () => { | |
| if (hoveredItem && view.mode === "scene" && intro.phase === "idle") { | |
| hoveredItem.onClick(); | |
| } else if (view.mode === "bench") { | |
| closeBenchView(); | |
| } | |
| }); | |
| window.addEventListener("keydown", (e) => { | |
| if (e.key !== "Escape") return; | |
| if (view.mode === "bench") closeBenchView(); | |
| else if (view.mode === "gameboy") closeGameboyView(); | |
| }); | |
| // Machine glow while generating | |
| let machineBusy = false; | |
| const SCREEN_BASE = new THREE.Color(0x1c2f28); | |
| const DISPENSER_BASE = new THREE.Color(0x10181d); | |
| function setMachineBusy(busy) { | |
| machineBusy = busy; | |
| if (!busy) { | |
| world.vending.userData.screenMaterial.color.copy(SCREEN_BASE); | |
| world.vending.userData.dispenserMaterial.color.copy(DISPENSER_BASE); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Day / night: clicking the lamp post eases the sky shader, fog and lights to | |
| // a moonlit palette and lights the lamp. No camera move — the scene just dims. | |
| // --------------------------------------------------------------------------- | |
| const DAY = { | |
| skyTop: new THREE.Color(0x1547a8), | |
| skyHorizon: new THREE.Color(0xbfe3ff), | |
| fog: new THREE.Color(0xbfe3ff), | |
| hemiSky: new THREE.Color(0xcfe8ff), | |
| hemiGround: new THREE.Color(0xa8b48a), | |
| sun: new THREE.Color(0xfff2dc), | |
| cloudSky: new THREE.Color(0xf2f8ff), | |
| cloudGround: new THREE.Color(0xc9d9ec), | |
| }; | |
| const NIGHT = { | |
| skyTop: new THREE.Color(0x05060f), | |
| skyHorizon: new THREE.Color(0x232a4d), | |
| fog: new THREE.Color(0x141a30), | |
| hemiSky: new THREE.Color(0x2a3358), | |
| hemiGround: new THREE.Color(0x1c2422), | |
| sun: new THREE.Color(0x93a9e6), // a cool, dim "moon" | |
| cloudSky: new THREE.Color(0x9fb0d8), | |
| cloudGround: new THREE.Color(0x2b3450), | |
| }; | |
| const NIGHT_KEY = "lofinity:night"; // localStorage flag, persists the choice | |
| let isNight = false; | |
| let nightTarget = 0; // 0 = day, 1 = night | |
| let nightMix = 0; // eased toward nightTarget in the render loop | |
| function applyPalette(m) { | |
| const sky = skyMaterial.uniforms; | |
| sky.topColor.value.lerpColors(DAY.skyTop, NIGHT.skyTop, m); | |
| sky.horizonColor.value.lerpColors(DAY.skyHorizon, NIGHT.skyHorizon, m); | |
| scene.fog.color.lerpColors(DAY.fog, NIGHT.fog, m); | |
| hemi.color.lerpColors(DAY.hemiSky, NIGHT.hemiSky, m); | |
| hemi.groundColor.lerpColors(DAY.hemiGround, NIGHT.hemiGround, m); | |
| hemi.intensity = THREE.MathUtils.lerp(0.85, 0.32, m); | |
| sun.color.lerpColors(DAY.sun, NIGHT.sun, m); | |
| sun.intensity = THREE.MathUtils.lerp(1.6, 0.5, m); | |
| cloudLight.color.lerpColors(DAY.cloudSky, NIGHT.cloudSky, m); | |
| cloudLight.groundColor.lerpColors(DAY.cloudGround, NIGHT.cloudGround, m); | |
| cloudLight.intensity = THREE.MathUtils.lerp(0.9, 0.4, m); | |
| lampLight.intensity = THREE.MathUtils.lerp(0, 2.6, m); | |
| } | |
| function setNightLabel() { | |
| const span = document.querySelector("#lamp-label span"); | |
| if (span) span.textContent = isNight ? "☀ back to day" : "☾ turn on night"; | |
| } | |
| function toggleNight() { | |
| isNight = !isNight; | |
| nightTarget = isNight ? 1 : 0; | |
| setNightLabel(); | |
| try { | |
| localStorage.setItem(NIGHT_KEY, isNight ? "1" : "0"); | |
| } catch { | |
| /* storage unavailable (private mode) — keep the choice in-memory only */ | |
| } | |
| } | |
| // restore the persisted day/night choice on load, applied instantly (no fade) | |
| try { | |
| if (localStorage.getItem(NIGHT_KEY) === "1") { | |
| isNight = true; | |
| nightTarget = 1; | |
| nightMix = 1; | |
| applyPalette(1); | |
| setNightLabel(); | |
| } | |
| } catch { | |
| /* unreadable storage — start in day mode */ | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Backend bridge + modal UI | |
| // --------------------------------------------------------------------------- | |
| let generateFn = null; | |
| const ui = initUI({ | |
| generate: (prompt, seconds) => { | |
| if (!generateFn) return Promise.reject(new Error("backend not connected")); | |
| return generateFn(prompt, seconds); | |
| }, | |
| // chunks-done/total for the brewing bar; a plain GET, independent of the | |
| // gradio client so it answers while a generation is in flight | |
| getProgress: async () => { | |
| try { | |
| const r = await fetch("/api/progress"); | |
| return r.ok ? await r.json() : null; | |
| } catch { | |
| return null; | |
| } | |
| }, | |
| onRequestClose: () => { | |
| if (view.mode === "machine") closeMachineView(); | |
| }, | |
| onRequestCloseCollection: () => { | |
| if (view.mode === "bench") closeBenchView(); | |
| }, | |
| onRequestCloseGameboy: () => { | |
| if (view.mode === "gameboy") closeGameboyView(); | |
| }, | |
| // "play while waiting": leave the beat brewing in the background and fly | |
| // from the machine over to the garden mini-game. | |
| onPlayWhileWaiting: () => { | |
| if (view.mode !== "machine") return; | |
| ui.closeModal(); | |
| zoomToGameboy(); | |
| }, | |
| onGeneratingChange: setMachineBusy, | |
| }); | |
| import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js") | |
| .then(async ({ Client }) => { | |
| const client = await Client.connect(window.location.origin); | |
| generateFn = async (prompt, seconds) => { | |
| const result = await client.predict("/generate_song", { prompt, seconds }); | |
| const data = result.data[0]; | |
| const dataUri = data.audio; | |
| if (!dataUri) throw new Error("no audio in response"); | |
| // The tape comes back as an inline base64 WAV; pull it into a Blob URL — | |
| // lighter for <audio> than a multi-MB data URI, and the blob's uuid path | |
| // gives the collection a stable per-tape key. Nothing touches disk. | |
| const blob = await (await fetch(dataUri)).blob(); | |
| const url = URL.createObjectURL(blob); | |
| return { title: data.title, url }; | |
| }; | |
| window.lofinity = { generate: generateFn }; | |
| console.log("[LoFinity] backend connected"); | |
| }) | |
| .catch((err) => console.warn("[LoFinity] backend not reachable:", err)); | |
| // --------------------------------------------------------------------------- | |
| // Intro: hold on the sky, then ease the gaze down to the scene | |
| // --------------------------------------------------------------------------- | |
| const DESCENT_MS = 4600; | |
| const intro = { phase: "loading", t0: 0, idleStart: 0 }; | |
| const lookTarget = LOOK_SKY.clone(); | |
| const overlay = document.getElementById("title-overlay"); | |
| function startDescent() { | |
| if (intro.phase !== "loading") return; | |
| intro.phase = "descending"; | |
| intro.t0 = performance.now(); | |
| overlay.classList.add("hidden"); | |
| // The button stops capturing clicks via CSS (#title-overlay.hidden #start-btn) | |
| // so it can keep fading out with the overlay instead of popping out of layout. | |
| } | |
| const pageLoaded = new Promise((resolve) => | |
| document.readyState === "complete" | |
| ? resolve() | |
| : window.addEventListener("load", resolve), | |
| ); | |
| // Click-to-start: the camera holds on the sky until the user clicks. The button | |
| // is revealed (and made clickable) once assets + fonts are ready; the same click | |
| // also kicks off the lobby music (audio autoplay needs a user gesture). | |
| const startBtn = document.getElementById("start-btn"); | |
| Promise.all([pageLoaded, document.fonts.ready]).then(() => | |
| startBtn.classList.add("ready"), | |
| ); | |
| startBtn.addEventListener("click", startDescent); | |
| const easeInOutCubic = (t) => | |
| t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; | |
| // Dev helpers: jump straight to any app state. | |
| window.lofinityDebug = { | |
| skipIntro() { | |
| intro.phase = "idle"; | |
| intro.idleStart = 0; | |
| camera.position.copy(CAM_END); | |
| lookTarget.copy(LOOK_SCENE); | |
| overlay.classList.add("hidden"); | |
| }, | |
| holdSky() { | |
| intro.phase = "held"; | |
| camera.position.copy(CAM_START); | |
| lookTarget.copy(LOOK_SKY); | |
| overlay.classList.remove("hidden"); | |
| }, | |
| openMachine() { | |
| this.skipIntro(); | |
| view.mode = "machine"; | |
| camera.position.copy(MACHINE_CAM); | |
| lookTarget.copy(MACHINE_LOOK); | |
| ui.openModal(); | |
| }, | |
| closeMachine() { | |
| closeMachineView(); | |
| }, | |
| openBench() { | |
| this.skipIntro(); | |
| view.mode = "bench"; | |
| camera.position.copy(BENCH_CAM); | |
| lookTarget.copy(BENCH_LOOK); | |
| ui.openCollection(); | |
| }, | |
| openGameboy() { | |
| this.skipIntro(); | |
| view.mode = "gameboy"; | |
| camera.position.copy(GAMEBOY_CAM); | |
| lookTarget.copy(GAMEBOY_LOOK); | |
| ui.openGameboy(); | |
| }, | |
| stats() { | |
| return { ...renderer.info.render, ...renderer.info.memory }; | |
| }, | |
| goto(px, py, pz, lx, ly, lz) { | |
| this.skipIntro(); | |
| camera.position.set(px, py, pz); | |
| lookTarget.set(lx, ly, lz); | |
| view.mode = "machine"; // parks the camera (no idle sway) | |
| }, | |
| birdInfo() { | |
| const b = world.bird; | |
| return { | |
| rotY: b.rotation.y, | |
| posY: b.position.y, | |
| auto: b.matrixAutoUpdate, | |
| matrixRotY: Math.atan2(b.matrix.elements[8], b.matrix.elements[10]), | |
| }; | |
| }, | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Render loop | |
| // --------------------------------------------------------------------------- | |
| const clock = new THREE.Clock(); | |
| // Frame governor: a chill scene doesn't need 120fps. Idle runs at 30fps; | |
| // camera transitions get 60 so they stay silky. | |
| let lastRender = 0; | |
| function animate(now = 0) { | |
| requestAnimationFrame(animate); | |
| const transitioning = | |
| intro.phase === "descending" || | |
| view.mode === "zoom-in" || | |
| view.mode === "zoom-out" || | |
| nightMix !== nightTarget; | |
| const interval = transitioning ? 1000 / 60 : 1000 / 30; | |
| if (now - lastRender < interval - 1) return; | |
| const dt = Math.min((now - lastRender) / 1000, 0.1); | |
| lastRender = now; | |
| const elapsed = clock.getElapsedTime(); | |
| // ease the day/night palette toward its target, only while it's moving | |
| if (nightMix !== nightTarget) { | |
| nightMix = THREE.MathUtils.damp(nightMix, nightTarget, 3.2, dt); | |
| if (Math.abs(nightMix - nightTarget) < 0.001) nightMix = nightTarget; | |
| applyPalette(nightMix); | |
| } | |
| for (const cloud of world.clouds) { | |
| cloud.position.x += cloud.userData.speed * dt; | |
| if (cloud.position.x > 170) cloud.position.x = -170; | |
| } | |
| // the bench bird looks around, hops now and then, flicks its tail | |
| const bird = world.bird; | |
| bird.rotation.y = | |
| -0.5 + Math.sin(elapsed * 0.4) * 0.4 + Math.sin(elapsed * 1.1) * 0.12; | |
| bird.position.y = | |
| bird.userData.baseY + | |
| Math.pow(Math.max(0, Math.sin(elapsed * 1.9)), 12) * 0.06; | |
| bird.userData.tail.rotation.x = | |
| -0.35 + Math.pow(Math.max(0, Math.sin(elapsed * 1.3 + 1)), 8) * 0.5; | |
| if (intro.phase === "loading" || intro.phase === "held") { | |
| // Barely-there float while gazing at the sky | |
| camera.position.y = CAM_START.y + Math.sin(elapsed * 0.5) * 0.08; | |
| } else if (intro.phase === "descending") { | |
| const t = Math.min((performance.now() - intro.t0) / DESCENT_MS, 1); | |
| const e = easeInOutCubic(t); | |
| camera.position.lerpVectors(CAM_START, CAM_END, e); | |
| lookTarget.lerpVectors(LOOK_SKY, LOOK_SCENE, e); | |
| if (t >= 1) { | |
| intro.phase = "idle"; | |
| intro.idleStart = elapsed; | |
| } | |
| } else if (view.mode === "zoom-in" || view.mode === "zoom-out") { | |
| const t = Math.min((performance.now() - view.t0) / ZOOM_MS, 1); | |
| const e = easeInOutCubic(t); | |
| camera.position.lerpVectors(view.camFrom, view.camTo, e); | |
| lookTarget.lerpVectors(view.lookFrom, view.lookTo, e); | |
| if (t >= 1) { | |
| if (view.mode === "zoom-in") { | |
| view.mode = view.target; | |
| if (view.target === "machine") ui.openModal(); | |
| else if (view.target === "bench") ui.openCollection(); | |
| else if (view.target === "gameboy") ui.openGameboy(); | |
| } else { | |
| view.mode = "scene"; | |
| intro.idleStart = elapsed; // sway ramps back in | |
| } | |
| } | |
| } else if (intro.phase === "idle" && view.mode === "scene") { | |
| // Gentle lofi sway, ramping in so the descent lands without a jolt | |
| const tSway = elapsed - intro.idleStart; | |
| const ramp = Math.min(1, tSway / 5); | |
| camera.position.x = CAM_END.x + Math.sin(tSway * 0.25) * 0.25 * ramp; | |
| camera.position.y = CAM_END.y + Math.sin(tSway * 0.4) * 0.12 * ramp; | |
| } | |
| if (machineBusy) { | |
| // the machine hums while it brews | |
| const pulse = 0.5 + 0.5 * Math.sin(elapsed * 6); | |
| world.vending.userData.screenMaterial.color.setHSL( | |
| 0.38, | |
| 0.55, | |
| 0.12 + 0.3 * pulse, | |
| ); | |
| world.vending.userData.dispenserMaterial.color.setHSL( | |
| 0.13, | |
| 0.85, | |
| 0.08 + 0.3 * pulse, | |
| ); | |
| } | |
| camera.lookAt(lookTarget); | |
| updateHover(); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |