/** * 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, SPARE_COST, Emitter } from './contracts.js'; import { createWorld, loadSite } 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'; import { createRiggingUI } from './rigging.js'; import { createHud } from './hud.js'; import { createWeek, NIGHTS } from './week.js'; /** The calm day the forecast and prep phases run under. */ const CALM_STORM = 'storm_01_gentle'; /** * Every storm the session can run. This used to be a three-storm difficulty * select; the week's ladder (week.js NIGHTS) now decides what's coming, and the * forecast card's job changed from "pick your poison" to "here's tonight". */ const STORMS = NIGHTS; /** * How fast an unprotected garden dies, in HP per second at full rain. * * Decision 7: drain is rain × (1 − rain shadow), NOT sun coverage. At night the * sun shadow is a number about nothing, and storm_02 is a wildnight. * * ⚠️ This number is doing less work than it looks like it is, and the reason is * worth reading before retuning it. Measured over storm_02 with a rig that holds * 4/4 corners all night: sun coverage over the bed stays ~50%, but the RAIN * shadow decays 0.38 → 0.04 as the wind builds, averaging 0.23. Driving rain * blows under the sail and the shadow walks off the bed — which is Lane C's * model being right about weather, not a bug. * * The consequence is that a perfectly rigged bed only ever sits ~23% drier than * a bare one, so NO value here separates good rigging from none; at 1.6 both * ended dead, and the spread stays ~20 points at any setting. 0.9 is chosen to * put a good rig around 60% (a win) and a bare bed just under 50% (a loss), so * the loop is playable and scores something — but the lever that actually needs * moving is the shadow geometry, not this. Flagged for Lane C in THREADS. */ const GARDEN_DRAIN = 0.9; /** * Decision 13's weights. Hail is what actually kills a garden and cloth honestly * stops it (stones fall ≤20° off vertical; rain leans 73° in a gale and walks * straight under the sail — Lane C's numbers). Rain is demoted to a drizzle of * damage so the night still costs you something when it isn't hailing. * * SPRINT6 gate 1 lists these first among the balance levers. */ const HAIL_WEIGHT = 5.0; const RAIN_WEIGHT = 0.25; /** * The garden: the thing you are actually protecting, and the only score that * matters. Deliberately not inside hud.js — the HUD reads, it doesn't decide. * * It keeps its damage split by CAUSE, and that isn't bookkeeping for its own * sake: the aftermath verdict is the game's entire feedback channel, and a * verdict that guesses teaches the wrong lesson. A garden that only knows it * went from 100 to 39 cannot tell a player whether their hardware let go or * their perfectly-held sail simply wasn't over the bed — and those are opposite * mistakes with opposite fixes. */ function createGarden(world) { let hp = 100; let state = 'full'; let byHail = 0; let byRain = 0; return { get hp() { return hp; }, get state() { return state; }, /** HP lost to each cause this run. The verdict reads these. */ get damage() { return { hail: byHail, rain: byRain }; }, reset() { hp = 100; state = 'full'; byHail = 0; byRain = 0; world.setPlants('full'); }, /** * Decision 13, and the two terms stay SEPARATE on the way in rather than * being pre-summed by the caller — that sum is exactly the information the * verdict needs and can never recover afterwards. * * @param {number} dt * @param {number} hail 0..1 sky.gardenHailExposure(bed, t) * @param {number} rain 0..1 sky.gardenExposure(bed, t) */ step(dt, hail, rain) { const dHail = HAIL_WEIGHT * hail * GARDEN_DRAIN * dt; const dRain = RAIN_WEIGHT * rain * GARDEN_DRAIN * dt; const total = Math.min(hp, dHail + dRain); if (total > 0) { // Attribute proportionally, so the split still adds up on the last tick // when the garden bottoms out at 0 mid-step. const scale = total / (dHail + dRain); byHail += dHail * scale; byRain += dRain * scale; hp -= total; } const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead'; if (next !== state) { state = next; world.setPlants(next); } }, }; } /** * Why the night went the way it did — read from what ACTUALLY happened. * * This is the whole of the game's feedback channel, and it was lying: any run * ending under 50 read "the rain found what you skimped on", including a run * that held 4/4 corners and skimped on nothing. That is not a typo-grade bug. * DESIGN.md wants every disaster to replay in the player's head as "…the * shackle, I knew about the shackle" — a verdict that blames the wrong thing * teaches the opposite of the lesson the storm just spent 90 seconds giving. * * So: pick from the failure modes the run can prove, in the order the player * most needs to hear them. The two garden losses are deliberately different * sentences, because they are opposite mistakes — hardware you under-bought * versus a rig that held perfectly and simply wasn't over the bed. * * @returns {{verdict: string, mode: string}} */ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped }) { const worst = lost.length ? lost.reduce((a, c) => (a && a.hw.rating <= c.hw.rating ? a : c)) : null; const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : ''; const hailKilled = dmg.hail > dmg.rain; // SPRINT9 decision 1. This case had to come FIRST because it did not used to // exist: `lost >= 2` was unconditionally a cascade-and-a-loss, and under the // new rule it can be the best night the game has. A sail that tore itself // apart while the bed came through is DESIGN.md's actual story — the firework // you paid for — and the aftermath's hardware bill is where that payment goes. if (win && lost.length >= 2) { return { mode: 'pyrrhic', verdict: `THE GARDEN MADE IT. The sail didn't — the ${named} went first and took ` + `${lost.length - 1} more with it. That is what the sail was for.` }; } if (!win && lost.length >= 2) { return { mode: 'cascade', verdict: `THE SAIL LOST. The ${named} went first — and took ${lost.length - 1} more with it.` }; } if (lost.length === 1 && !win) { return { mode: 'corner', verdict: `THE SAIL LOST. One corner short: the ${named} let go.` }; } if (!win && hailKilled) { // The lesson the old verdict destroyed. Every corner held; the rig was // simply not over the thing it was paid to protect. return { mode: 'uncovered', verdict: 'THE GARDEN IS GONE — and every corner held. The hail fell where your sail wasn\'t.' }; } if (!win) { return { mode: 'rain', verdict: 'THE GARDEN IS GONE. Not dramatic — just a long night of rain on open ground.' }; } if (pondDumped > 50) { return { mode: 'broomed', verdict: `THE GARDEN MADE IT. You put ${Math.round(pondDumped)} kg of water on your own head to do it.` }; } if (pondPeak > 80) { return { mode: 'ponded', verdict: 'THE GARDEN MADE IT — with a belly full of water. That flat spot will find you.' }; } if (lost.length === 1) { return { mode: 'held-one-down', verdict: `THE GARDEN MADE IT. The ${named} let go and the rest carried it.` }; } if (hp >= 85) { return { mode: 'clean', verdict: 'THE GARDEN MADE IT — not a leaf out of place.' }; } return { mode: 'mostly', verdict: 'THE GARDEN MADE IT. Mostly.' }; } // --------------------------------------------------------------------------- // 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. * * ⚠️ **This delegation list is hand-maintained, and that has already cost us * once.** When Lane C added rainMmPerHour/rainDepthMm for ponding, this router * didn't forward them: every test still passed, because tests hold a real wind * while only the GAME holds the router — so B's ponding would have been green * across the board and done nothing in the actual yard. The integrator caught it * by hand at merge. * * Anything new on the wind contract must be added here too. `js/tests/a.test.js` * has a tripwire that diffs this object against a real wind and fails naming * whatever is missing, so the next omission is a red test rather than a system * that silently isn't plugged in. * * @param {object[]} all every wind this session can switch between */ export 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), rainMmPerHour: (t) => active.rainMmPerHour(t), rainDepthMm: (a, b) => active.rainDepthMm(a, b), hailAt: (t) => active.hailAt(t), // SPRINT5 decision 13 — the garden score hangs off this 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, }))); }, setVenturi(list) { // SPRINT9 site_02 — funnels are site geometry for (const w of all) w.setVenturi(list); return router; }, get venturi() { return active.venturi; }, get hailSize() { return active.hailSize; }, // SPRINT5 decision 13 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 --------------------------------------------------------- // Every storm loads up front: the forecast card has to read their shapes to // sell them before the player has agreed to face one. const defs = Object.fromEntries( await Promise.all(STORMS.map(async (k) => [k, await loadStorm(k)])), ); const winds = Object.fromEntries(Object.entries(defs).map(([k, def]) => [k, createWind(def)])); const calmWind = winds[CALM_STORM]; let stormKey = 'storm_02_wildnight'; const wind = createWindRouter(Object.values(winds)); // --- world & camera ----------------------------------------------------- // SPRINT10: the yard is data. `site` decides which one, and the week hands it // over per night — night three is the corner block. const site = await loadSite(opts.site ?? week.site); const world = createWorld(scene, { wind, site }); // Lane C: a venturi is a shelter's opposite — a gap SPEEDS wind along its axis. // Absent/empty is a perfect no-op, which is what keeps backyard_01 identical. wind.setVenturi?.(site.wind?.venturi ?? []); // Lane E's GLBs land over the graybox. Awaited here because it resolves // world.shedTable onto E's baked pickup_anchor, and wireYardActions (below, // inside rigSail) reads that position when it registers the spare pickup. await world.dress(); 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. const game = createGame(); const garden = createGarden(world); // --- 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); // --- 5. the face -------------------------------------------------------- const banner = document.getElementById('banner'); const hud = createHud({ scene, camera: cameraRig.object, game, world, wind, player, rig, garden, events, getSky: () => sky, }); // Lane B's picking adapter. `panel:false` — their built-in panel is a fine // bench, but hud.js owns the screen now, so the two shouldn't both draw. const rigging = await createRiggingUI({ scene, camera: cameraRig.object, domElement: canvas, world, onCommit: (ids, hw, tension) => { void rigSail(ids, hw, tension); }, onMessage: pushEvent, panel: true, }); /** * Clear last round's rig so "play again" is a new job, not a continuation. * * Without this you inherit the previous round's corners AND its spent budget — * a second round starts at $35 with three corners already hung, and the four * anchors you click get silently ignored because the session is already full. * That is not a subtle failure; it makes the second round unplayable. * * Done through the session's own public moves rather than by reaching into its * fields: unrig() refunds the corner's CURRENT hardware and setSpares(0) * refunds the spare, so the budget walks back to $80 on its own and the * economy stays the single source of truth. (Lane B: a `session.reset()` would * say this better than five lines of mine — asked in THREADS.) */ function resetRig() { const s = rigging.session; for (const p of [...s.picks]) s.unrig(p.anchorId); s.setSpares(0); s.setTension(1.0); } /** What the storm cost, assembled at the moment it ends. */ function scoreRun() { const lost = rig.corners.filter((c) => c.broken); const bill = lost.reduce((s, c) => s + c.hw.cost, 0); const collateral = []; // The gnome is collateral if the sail came down over it. DESIGN.md: the // worst debris in any storm is your own failed work. if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue }); const s = rigging.summary; const hp = garden.hp; /** * SPRINT9 decision 1 — Lane A's ruling on the pyrrhic win. It was * `hp >= 50 && lost.length < 2`; the corner clause is gone. * * **The garden is the client's. The sail is yours.** Saving the bed with a * rig that tore itself apart is the job done expensively — not a failure. * DESIGN.md doesn't call a cascade a loss, it calls it "a firework you paid * for", and *paid for* is the whole point: the aftermath already bills you * for the broken hardware, the collateral, and a week's fee you didn't earn * cleanly. Gating the win on corners bills you twice for the same night and * then lies about it — which is the same species as the verdict that used to * tell a 4/4 hold they'd skimped. * * B and D both arrived here independently with the numbers, and B checked * what it does NOT break: cheap rigs still lose (4× carabiner → hp 38), rigs * that miss the bed still lose (hp 36), and $80 still cannot buy immunity. * The corners are priced, not gated. */ const win = hp >= 50; const dmg = garden.damage; const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped }); return { hp, cornersLost: lost.length, cornersTotal: rig.corners.length || 4, bill, collateral, budgetLeft: Math.max(0, s.budget - bill - collateral.reduce((a, c) => a + c.cost, 0)), win, subtitle: lost.length ? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.` : 'Every corner held.', /** Decision 13's headline: how much of the bed's damage the cloth stopped. */ hailBlocked: dmg.hail + dmg.rain > 0 ? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain` : 'Nothing reached the bed.', /** * What you can unclip and take home: hardware still on an unbroken corner, * plus a spare you never had to use. week.js refunds it at half — a shackle * that rode out a gale isn't new any more. Broken gear is worth nothing, * which is the whole of `bill`. */ intactHardwareValue: rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0) + (rigging.session.spares ?? 0) * SPARE_COST, pondPeak, pondDumped, verdict, /** Which failure mode the verdict is speaking from. Asserted in a.test.js. */ verdictMode: mode, }; } // --- the week ------------------------------------------------------------ // Five nights, one wallet. The forecast stops being a difficulty picker — the // ladder decides what's coming and the only question left is what you rig // against it with the money you have. const week = createWeek(); let spentThisNight = 0; /** * Tonight's card. Reads the bank, not START_BUDGET. * * NOTE the `budget` we hand the shop: `rigging.session.spent` computes * `START_BUDGET - budget` against the module constant rather than the session's * own starting cash, so with a bank of $63 it reports $17 spent before you buy * anything. Lane B's file, flagged in THREADS — main.js therefore tracks * `spentThisNight` itself off the bank rather than trusting `.spent`. */ function showTonight() { // Reaching into Lane B's session to re-bank the shop. `RiggingSession` takes // a budget at construction but exposes no setter, and the UI owns the // instance — so this is the one private touch in main.js, done loudly rather // than by forking their file. I've asked for `session.setBudget(n)` in // THREADS; when it lands these two lines become one. (Same route // `session.reset()` took: I faked it, asked, they landed it, I deleted the // fake.) rigging.session._startBudget = week.bank; rigging.session.reset(); spentThisNight = 0; hud.showForecast( { key: week.stormKey, def: defs[week.stormKey] }, { night: week.night, nights: week.nights, bank: week.bank, log: week.log }, () => { stormKey = week.stormKey; game.setPhase('prep'); }, ); } // --- the night's evidence ------------------------------------------------ // What the sail carried and what the player took on the head, so the verdict // can point at things that actually happened rather than infer from the score. let pondPeak = 0; let pondDumped = 0; // Subscribed once: rigSail() calls attach() on the same rig object, so the // Emitter survives a re-rig. Lane B tags a dump with `reason` when the water // left because something failed (corner break, belly tear) and leaves it off // when the player poked it out with the broom — which is the difference // between "the sail lost its water" and "you wore it". rig.events.on('pondDump', (e) => { if (!e.reason) pondDumped += e.kg; }); // --- phases ------------------------------------------------------------- game.on('phaseChange', ({ to }) => { // Prep and forecast happen on the calm day; the storm you picked only // arrives when you say go. wind.use(to === 'storm' ? winds[stormKey] : calmWind); makeSky(); events.length = 0; rigging.setActive(to === 'prep'); if (to === 'storm') { pondPeak = 0; pondDumped = 0; } if (to === 'forecast') { garden.reset(); resetRig(); player.sim.carrying = null; hud.setDawn(false); showTonight(); } // The card belongs to the phase, not to the button that happened to open it. // Tying its lifetime to the forecast button meant any other route into prep // (a debug jump, a future timer, "play again" landing somewhere new) left a // card floating over a live game, swallowing input. if (to === 'prep' || to === 'storm') hud.hideCard(); if (to === 'prep') { hud.setHelp('click an anchor to rig · click again to cycle hardware · shift-click to remove · [ ] tension · S spare · ENTER when you have four'); } if (to === 'storm') { hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit'); } if (to === 'aftermath') { // The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note // is right and it's the whole reason this isn't one call: the storm ends, // the light returns, and only then are you told how you did. Sell the // survival first. hud.setDawn(true); const run = scoreRun(); const settlement = week.settle(run, defs[week.stormKey], spentThisNight); hud.showAftermath({ ...run, week: settlement }, () => { if (settlement.outcome === 'continue') { week.advance(); game.setPhase('forecast'); } else hud.showEndCard(settlement, () => { week.reset(); game.setPhase('forecast'); }); }); } if (banner && to !== 'forecast' && to !== 'aftermath') { banner.textContent = to.toUpperCase(); banner.style.opacity = '1'; setTimeout(() => { banner.style.opacity = '0'; }, 1400); } }); addEventListener('keydown', (e) => { if (e.key !== 'Enter' || hud.cardOpen) return; // Leaving prep means committing the rig — Lane B's session refuses if it // isn't four corners, and says so in the ticker. if (game.phase === 'prep') { if (!rigging.commit()) return; // Off the bank, not off START_BUDGET — see the note on showTonight(). spentThisNight = week.bank - rigging.summary.budget; player.sim.carrying = null; if (rigging.summary.spares > 0) pushEvent(`spare shackle on the shed table — grab it before you need it`); } game.advance(); }); // Straight into the forecast: the card is the game's front door, and it now // opens on night one of five rather than a difficulty menu. showTonight(); // --- 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(); const dev = document.getElementById('dev'); 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); debris.step(dt, windT, { player: player.sim, sail: rig }); sky?.step(dt, windT, { sail: rig }); rigging.update(dt, windT); // Decision 7: what hurts the garden is weather the sail didn't stop. Only // during the storm — the calm day's drizzle is scenery. // // Through Lane C's combined helper rather than my own rain × (1 − shadow): // it's the same arithmetic, it's their term to own, and SPRINT5 decision 13 // extends exactly this shape (+ gardenHailExposure) — so when hail lands // this is one added term here, not a rewrite. if (game.phase === 'storm') { // Decision 13 (SPRINT5): hail is the headline garden threat — it falls // steep, so the sail blocks it and the score finally rewards rigging // (C proved 4.4× separation). Rain stays as the small honest drain that // walks under a sail in a gale. Weights chosen so storm_02 unprotected // loses ~50 HP to its hail bursts (11.4 hail-seconds × 5.0 × 0.9/s) and // ~10 to rain, while a bed-covering rig cuts the hail term ~4.4×. const rainExp = sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0; const hailExp = sky?.gardenHailExposure ? sky.gardenHailExposure(world.gardenBed, windT) : 0; // Passed separately, not pre-summed: the split is what makes the verdict // able to tell "your hardware went" from "your sail wasn't over the bed". garden.step(dt, hailExp, rainExp); // Pond evidence for the aftermath. Peak is what the sail carried at its // worst; dumped is what the player took on the head with the broom. Both // are things the verdict can honestly point at. if (typeof rig.pondMass === 'function') { pondPeak = Math.max(pondPeak, rig.pondMass()); } } } 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); hud.update(raw, windT); frames++; fpsT += raw; if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; } if (dev) dev.textContent = `${fps.toFixed(0)} fps · ${game.phase} ${game.phaseT.toFixed(1)}s · t ${simT.toFixed(0)}s · debris ${debris.pieces.length}`; 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, hud, rigging, garden, defs, winds, get stormKey() { return stormKey; }, set stormKey(k) { stormKey = k; }, scoreRun, get sky() { return sky; }, get simT() { return simT; }, windTime, calmWind, /** * 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; }