HardYards/web/world/js/main.js
m3ultra 323352fe50 Assemble the game: real wind, player, sail, sky and debris (gate 1)
SPRINT2 §Lane A steps 1-4. The placeholder capsule and stub wind are gone; every
lane's proven module now runs in one yard.

Wind goes through a router. 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 a calm day while the other half is in a gale. Shelters apply to
every storm, since the trees don't stop existing when the weather turns.

skyfx is rebuilt rather than re-pointed: it reads the storm's sky block at
construction. Verified its dispose() hands world.sun/world.hemi back exactly
(2.0/1.8 after a 40 s storm had them at 1.07/1.13) and that nothing compounds
over repeated phase cycles — that was Lane C's §Lane C.5 ask.

Two seams needed care. createSailView reads rig.pos/rig.tris, which don't exist
until attach() allocates them, and a re-rig can change the grid — so rigSail() is
the one door both boot and Lane B's picking adapter come through, rebuilding the
view and re-wiring interact (whose targets close over a corners array that
attach() replaces). And knockdown(t, dirX, dirZ) takes the sim clock first, not
the impact: passing debris impact there would jam ~40 into the state machine's
start time and the player would never get up.

Storm_02 verified end to end by hand: the carabiner blows at t=45.4 s, and p2's
shackle cascades at t=56 s — one second after the southerly change. Coverage over
the bed is 1.0 intact and 0 once two corners are gone, which is the whole game in
one number. 0.63 ms/frame (0.17 sim + 0.45 render) against a 16.67 ms budget.
Selftest 121/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:38:03 +10:00

413 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 70192 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;
}