The router is a hand-maintained delegation list and it has already cost us once: when Lane C added rainMmPerHour/rainDepthMm, it didn't forward them, and nothing went red — every suite holds a real wind, only the GAME holds the router. Lane B's ponding would have passed every assert it had and done nothing in the yard; the integrator caught it by hand at merge. The class matters more than the instance. Sprint 5's headline system is Lane C's hailAt(), and decision 13 hangs the whole garden score off it — the same silent swallow would make rigging look irrelevant to the garden all over again, which is the exact thing the sprint exists to fix. The assert diffs the router against a real wind and names what's missing. Verified it fires rather than just passing: rebuilt Sprint 4's pre-fix router and it reports exactly rainMmPerHour, rainDepthMm. Garden drain now goes through sky.gardenExposure() — same arithmetic, but it's Lane C's term to own, and decision 13 extends precisely this shape, so hail lands as one added term rather than a rewrite. Numbers unmoved: good rig 53%. Also re-measured Sprint 4's headline finding against a TRUE control (fresh boot, nothing ever rigged, shadow over bed 0.000) because my original control left the previous round's sail in the shadow grid: no-sail is 48.5% vs a good rig's 53%. The finding stands, and decision 13's premise with it. Selftest 209/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
605 lines
24 KiB
JavaScript
605 lines
24 KiB
JavaScript
/**
|
||
* 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';
|
||
import { createRiggingUI } from './rigging.js';
|
||
import { createHud } from './hud.js';
|
||
|
||
/** The calm day the forecast and prep phases run under. */
|
||
const CALM_STORM = 'storm_01_gentle';
|
||
|
||
/** The storms you can pick from the forecast card — this is the difficulty select. */
|
||
const STORMS = ['storm_01_gentle', 'storm_03_southerly', 'storm_02_wildnight'];
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
function createGarden(world) {
|
||
let hp = 100;
|
||
let state = 'full';
|
||
return {
|
||
get hp() { return hp; },
|
||
get state() { return state; },
|
||
reset() { hp = 100; state = 'full'; world.setPlants('full'); },
|
||
/**
|
||
* @param {number} dt
|
||
* @param {number} exposure 0..1 — how hard the bed is being hit right now.
|
||
* 0 = nothing reaching it (no weather, or cloth is over it); 1 = taking it
|
||
* full in the open. Lane C's sky.gardenExposure() computes it.
|
||
*
|
||
* SPRINT5 decision 13 lands here and nowhere else: garden damage becomes
|
||
* hail exposure + a small rain drain, so this stays one number and the
|
||
* only change is which helper(s) feed it.
|
||
*/
|
||
step(dt, exposure) {
|
||
if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * dt);
|
||
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
|
||
if (next !== state) { state = next; world.setPlants(next); }
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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),
|
||
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 ---------------------------------------------------------
|
||
// 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 -----------------------------------------------------
|
||
const world = createWorld(scene, { wind });
|
||
// 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;
|
||
const win = hp >= 50 && lost.length < 2;
|
||
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.',
|
||
verdict: hp >= 85 && !lost.length ? 'THE GARDEN MADE IT — not a leaf out of place.'
|
||
: win ? 'THE GARDEN MADE IT. Mostly.'
|
||
: hp < 50 ? 'THE GARDEN IS GONE. The rain found what you skimped on.'
|
||
: 'THE SAIL LOST. Warranty callout in the rain for you.',
|
||
};
|
||
}
|
||
|
||
// --- 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 === 'forecast') {
|
||
garden.reset();
|
||
resetRig();
|
||
player.sim.carrying = null;
|
||
hud.showForecast(
|
||
STORMS.map((key) => ({ key, def: defs[key] })),
|
||
(key) => { stormKey = key; game.setPhase('prep'); },
|
||
);
|
||
}
|
||
// 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') {
|
||
hud.showAftermath(scoreRun(), () => 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;
|
||
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.
|
||
hud.showForecast(
|
||
STORMS.map((key) => ({ key, def: defs[key] })),
|
||
(key) => { stormKey = key; game.setPhase('prep'); },
|
||
);
|
||
|
||
// --- 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') {
|
||
garden.step(dt, sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|