Selftest on merged main: 169 pass / 0 fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
413 lines
15 KiB
JavaScript
413 lines
15 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';
|
||
|
||
/** 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);
|
||
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;
|
||
}
|