/** * SHADES — boot, game loop, phase machine. Lane A owns this file. * * This is the assembly point: every other lane's module is proven in isolation, * and this file is where they become one game. Two rules make that possible and * are worth not breaking: * * - **Nothing auto-runs on import.** index.html calls boot(). That keeps * createGame() importable from selftest.html, which must never construct a * WebGLRenderer. * - **The loop is a fixed-dt accumulator.** Sim modules only ever see FIXED_DT, * never a real frame delta. That is the whole reason selftest can fast-forward * a 90 s storm in milliseconds and get the numbers the player got. */ import * as THREE from '../vendor/three.module.js'; import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, Emitter } from './contracts.js'; import { createWorld } from './world.js'; import { createCameraRig } from './camera.js'; import { loadStorm, createWind } from './weather.js'; import { SailRig, createSailView } from './sail.js'; import { createPlayer } from './player.js'; import { Interact, wireYardActions } from './interact.js'; import { createDebris } from './debris.js'; import { createSkyFx } from './skyfx.js'; /** Which storm each phase runs under (SPRINT2 §Lane A.1). */ const CALM_STORM = 'storm_01_gentle'; const WILD_STORM = 'storm_02_wildnight'; // --------------------------------------------------------------------------- // Phase machine // --------------------------------------------------------------------------- /** * forecast → prep → storm → aftermath → forecast. * @returns {import('./contracts.js').Game} */ export function createGame() { const emitter = new Emitter(); let phase = 'forecast'; let phaseT = 0; return { get phase() { return phase; }, /** Seconds since this phase began. The storm clock. */ get phaseT() { return phaseT; }, on(type, fn) { return emitter.on(type, fn); }, /** @param {'forecast'|'prep'|'storm'|'aftermath'} next */ setPhase(next) { if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`); if (next === phase) return; const from = phase; phase = next; phaseT = 0; emitter.emit('phaseChange', { from, to: next }); }, /** Advance to the next phase in loop order. */ advance() { this.setPhase(PHASES[(PHASES.indexOf(phase) + 1) % PHASES.length]); }, tick(dt) { phaseT += dt; // The storm is on a timer; every other phase waits for the player. if (phase === 'storm' && phaseT >= STORM_LEN) this.setPhase('aftermath'); }, }; } // --------------------------------------------------------------------------- // Wind router // --------------------------------------------------------------------------- /** * One wind object whose identity never changes, delegating to whichever storm * is currently running. * * Every consumer binds to wind exactly once, at construction — the yard closes * over it for tree sway, createPlayer takes it in opts, createDebris reads its * event stream. So swapping storm_01 for storm_02 at the phase change has to be * a re-point, not a re-wire, or half the game would still be sampling the calm * day while the other half is in a gale. * * Shelters are applied to every storm rather than just the active one: they * describe the yard's trees, which don't stop existing when the weather turns. * * @param {object[]} all every wind this session can switch between */ function createWindRouter(all) { let active = all[0]; const router = { /** The wind currently in force. Assign through use(). */ get active() { return active; }, use(w) { active = w; return router; }, sample: (pos, t, out) => active.sample(pos, t, out), speedAt: (pos, t) => active.speedAt(pos, t), gustTelegraph: (t) => active.gustTelegraph(t), eventsBetween: (a, b) => active.eventsBetween(a, b), rainAt: (t) => active.rainAt(t), dirAt: (t) => active.dirAt(t), setShelters(list) { for (const w of all) w.setShelters(list); return router; }, setSheltersFromTrees(trees, o = {}) { return router.setShelters(trees.map((tr) => ({ x: tr.pos ? tr.pos.x : tr.x, z: tr.pos ? tr.pos.z : tr.z, radius: o.radius ?? tr.radius ?? 3, strength: o.strength ?? 0.45, length: o.length ?? 14, }))); }, get duration() { return active.duration; }, get gusts() { return active.gusts; }, get def() { return active.def; }, get seed() { return active.seed; }, get core() { return active.core; }, }; return router; } // --------------------------------------------------------------------------- // Debris models // --------------------------------------------------------------------------- /** * Lane E's crates and tubs, keyed by the names storm JSON spawns and debris.js * has radii for. A browser can't glob a directory, so the list is explicit — * and it should stay matched to MODEL_SPEC in debris.js (Lane C's ask: tell them * rather than fighting the radii). * * Missing files are not fatal: debris.js falls back to a graybox box per piece, * which is exactly the degrade-quietly behaviour Lane C designed for. */ const DEBRIS_MODELS = ['BlueCrate_v2', 'BlackTub_v2', 'WhiteTub_v2', 'WoodenBin_v2']; async function loadDebrisModels() { const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js'); const loader = new GLTFLoader(); const out = {}; await Promise.all(DEBRIS_MODELS.map(async (name) => { try { const gltf = await loader.loadAsync(`./models/debris/${name}.glb`); gltf.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); out[name] = gltf.scene; } catch (err) { console.warn(`[main] debris model ${name} unavailable, using graybox:`, err.message); } })); return out; } // --------------------------------------------------------------------------- // Boot // --------------------------------------------------------------------------- /** * @param {object} [opts] * @param {HTMLCanvasElement} [opts.canvas] */ export async function boot(opts = {}) { const canvas = opts.canvas ?? document.getElementById('c'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; const scene = new THREE.Scene(); // --- 1. weather --------------------------------------------------------- // Both storms load up front: the forecast card needs to read storm_02's shape // before the player has agreed to face it. const [calmDef, wildDef] = await Promise.all([loadStorm(CALM_STORM), loadStorm(WILD_STORM)]); const calmWind = createWind(calmDef); const wildWind = createWind(wildDef); const wind = createWindRouter([calmWind, wildWind]); // --- world & camera ----------------------------------------------------- const world = createWorld(scene, { wind }); const cameraRig = createCameraRig(canvas); cameraRig.setSolids(world.solids); cameraRig.setGround(world.heightAt); // Lane C: trees don't shelter anything until they're told where they are. wind.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree')); // --- 2. player ---------------------------------------------------------- const interact = new Interact(); const player = await createPlayer(scene, world, cameraRig, { wind, interact }); // --- 3. sail ------------------------------------------------------------ const rig = new SailRig({ anchors: world.anchors }); let sailView = null; /** * Attach the cloth across 4 anchors and (re)build its view. * * The order here is load-bearing. createSailView reads rig.pos and rig.tris, * which don't exist until attach() allocates them in _build() — build the view * first and it throws on an undefined array. A re-rig can also change the grid, * so the view has to be rebuilt rather than reused. Both facts make this the * single door that boot and Lane B's picking adapter should come through. * * Re-wiring interact each time is deliberate: its targets close over corner * objects and attach() makes a fresh corners array, so stale closures would * point at corners the sim no longer steps. The ids are stable, so this * replaces the old targets rather than stacking duplicates. */ async function rigSail(anchorIds, hwChoices, tension = 1.0) { rig.attach(anchorIds, hwChoices, tension); if (sailView) { scene.remove(sailView); sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); }); } sailView = await createSailView(rig); scene.add(sailView); wireYardActions(interact, { sailRig: rig, world }); return sailView; } // Until Lane B's prep-phase picking adapter lands (SPRINT2 §B.3), rig a // default quad so the yard has a live sail and Lane D has something to // repair. Deliberately the prototype's AUTO loadout — one dodgy carabiner // corner. It also spans most of the yard, which is the 70–192 m² problem // decision 2 fixes in step 6, not a fault in the cloth. await rigSail(['h1', 'h3', 'p2', 'p1'], [HARDWARE[2], HARDWARE[1], HARDWARE[1], HARDWARE[0]]); const game = createGame(); // --- clocks ------------------------------------------------------------- // Two of them, and the distinction matters. `simT` is wall-clock seconds since // boot. `windT` is STORM time — storm JSON is authored with t=0 at the storm's // first gust, so it's phase time during the storm, and off-storm it wraps the // calm day around its own duration so the breeze keeps breathing however long // you spend rigging. Every sim module samples windT; nothing samples simT. let simT = 0; let windT = 0; let acc = 0; function windTime() { if (game.phase === 'storm') return game.phaseT; return simT % Math.max(1, calmWind.duration); } // --- 4. sky, audio, debris --------------------------------------------- const events = []; const pushEvent = (text) => { events.push({ t: game.phaseT, text }); if (events.length > 4) events.shift(); }; const debris = createDebris({ wind, scene, heightAt: world.heightAt, // knockdown(t, dirX, dirZ) — the first arg is the sim clock, NOT the impact // magnitude. Passing `impact` here would jam ~40 into the state machine's // start time and the player would never get up. The piece's own velocity is // the direction, so you fall the way the crate was travelling. onHitPlayer: (piece) => player.sim.knockdown(windT, piece.vx, piece.vz), onEvent: pushEvent, }); debris.setModels(await loadDebrisModels()); // skyfx reads the storm's `sky` block at construction (darkness, cloud scroll, // night), so it is rebuilt when the storm changes rather than re-pointed like // wind. dispose() hands world.sun/world.hemi back exactly as they were, which // is what makes that safe to do mid-session. let sky = null; let audioUnlocked = false; function makeSky() { if (sky) sky.dispose(); sky = createSkyFx({ scene, camera: cameraRig.object, wind, sun: world.sun, hemi: world.hemi, onEvent: pushEvent, }); if (audioUnlocked) sky.unlockAudio(); return sky; } makeSky(); // Browsers won't start an AudioContext without a gesture. Without this the // storm is silent, and half of DESIGN.md's threat model is audible. const unlock = () => { if (audioUnlocked) return; audioUnlocked = true; sky?.unlockAudio(); removeEventListener('pointerdown', unlock); removeEventListener('keydown', unlock); }; addEventListener('pointerdown', unlock); addEventListener('keydown', unlock); // --- dev overlay (temporary — hud.js replaces it in step 7) ------------- const hud = document.getElementById('dev'); const banner = document.getElementById('banner'); addEventListener('keydown', (e) => { if (e.key === 'Enter') game.advance(); }); // --- phases ------------------------------------------------------------- game.on('phaseChange', ({ to }) => { wind.use(to === 'storm' ? wildWind : calmWind); makeSky(); events.length = 0; if (banner) { banner.textContent = to.toUpperCase(); banner.style.opacity = '1'; setTimeout(() => { banner.style.opacity = '0'; }, 1400); } }); // --- resize ------------------------------------------------------------- function resize() { const w = canvas.clientWidth || innerWidth; const h = canvas.clientHeight || innerHeight; renderer.setSize(w, h, false); cameraRig.resize(w, h); } addEventListener('resize', resize); resize(); // --- loop --------------------------------------------------------------- const clock = new THREE.Clock(); let frames = 0, fpsT = 0, fps = 0; function step(dt) { game.tick(dt); simT += dt; windT = windTime(); world.update(dt, windT); player.update(dt, windT); rig.step(dt, wind, windT); debris.step(dt, windT, { player: player.sim, sail: rig }); sky?.step(dt, windT, { sail: rig }); } function frame() { // Clamped so a background tab or a breakpoint doesn't make the sim try to // catch up over thousands of steps and lock the page. const raw = Math.min(0.25, clock.getDelta()); acc += raw; let guard = 0; while (acc >= FIXED_DT && guard++ < 60) { step(FIXED_DT); acc -= FIXED_DT; } cameraRig.update(raw, player.pos); sailView?.update(); renderer.render(scene, cameraRig.object); frames++; fpsT += raw; if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; } if (hud) { const wt = windT; const speed = wind.speedAt(player.pos, wt); const tel = wind.gustTelegraph(wt); const worst = rig.corners.reduce((m, c) => Math.max(m, c.load || 0), 0); hud.textContent = `${fps.toFixed(0)} fps | ${game.phase} ${game.phaseT.toFixed(1)}s | ` + `wind ${speed.toFixed(1)} m/s${tel ? ` | GUST in ${tel.eta.toFixed(1)}s` : ''} | ` + `worst corner ${worst.toFixed(1)} | debris ${debris.pieces.length}` + `${events.length ? ` | ${events[events.length - 1].text}` : ''}`; } requestAnimationFrame(frame); } requestAnimationFrame(frame); // Handy for poking at the world from the console, and for the selftest-free // hand checks the sprint's acceptance actually turns on. const api = { renderer, scene, world, cameraRig, player, game, wind, rig, rigSail, get sailView() { return sailView; }, debris, interact, events, get sky() { return sky; }, get simT() { return simT; }, windTime, calmWind, wildWind, /** * Drive the sim by hand at fixed dt, and draw on demand. rAF is throttled to * a standstill in a hidden tab, so these are the only honest way to * fast-forward a storm or capture one from a headless browser — which is * exactly what this sprint's "90 s storm_02 run captured" acceptance needs. * Same code path the rAF loop uses; no test-only branch to drift. */ step, render() { sailView?.update(); renderer.render(scene, cameraRig.object); }, }; globalThis.SHADES = api; return api; }