diff --git a/.claude/launch.json b/.claude/launch.json index 9dd0588..22c729e 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,24 +4,8 @@ { "name": "shades3d", "runtimeExecutable": "python3", - "runtimeArgs": [ - "server.py", - "--port", - "8809" - ], - "port": 8809 - }, - { - "name": "shades-proto", - "runtimeExecutable": "python3", - "runtimeArgs": [ - "-m", - "http.server", - "8642", - "--directory", - "prototype" - ], - "port": 8642 + "runtimeArgs": ["server.py", "--port", "8811"], + "port": 8811 } ] } diff --git a/web/world/index.html b/web/world/index.html index cef5064..a4479b7 100644 --- a/web/world/index.html +++ b/web/world/index.html @@ -36,7 +36,13 @@ diff --git a/web/world/js/main.js b/web/world/js/main.js index 698a077..192e227 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -1,18 +1,32 @@ /** * SHADES — boot, game loop, phase machine. Lane A owns this file. * - * Nothing is auto-run on import: index.html calls boot(). That keeps createGame() - * importable from selftest.html, which must never construct a WebGLRenderer. + * 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: * - * 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 a few milliseconds and get the same numbers the player got. + * - **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, YARD, Emitter, createStubWind } from './contracts.js'; -import { createWorld, heightAt } from './world.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 @@ -58,81 +72,91 @@ export function createGame() { } // --------------------------------------------------------------------------- -// M0 placeholder player +// Wind router // --------------------------------------------------------------------------- /** - * A 1.7 m capsule that walks. This exists ONLY so the camera has something to - * follow and the yard scale is legible before Lane D lands. + * One wind object whose identity never changes, delegating to whichever storm + * is currently running. * - * Lane D: replace the call site in boot() with your player.js factory — the - * shape you need to satisfy is `Player` in contracts.js ({pos, carrying, busy, - * update}) — then delete this function. Everything else in this file already - * talks to you through that contract, so nothing else should need to change. + * 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 createPlaceholderPlayer(scene, world, cameraRig) { - const WALK = 2.2, RUN = 4.5; // m/s +function createWindRouter(all) { + let active = all[0]; - const mesh = new THREE.Mesh( - new THREE.CapsuleGeometry(0.28, 1.14, 4, 12), - new THREE.MeshStandardMaterial({ color: 0xffd27a, roughness: 0.7 }), - ); - mesh.name = 'player_placeholder'; - mesh.castShadow = true; - scene.add(mesh); + const router = { + /** The wind currently in force. Assign through use(). */ + get active() { return active; }, + use(w) { active = w; return router; }, - const keys = new Set(); - const onDown = (e) => { - keys.add(e.key.toLowerCase()); - if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase())) e.preventDefault(); - }; - const onUp = (e) => keys.delete(e.key.toLowerCase()); - addEventListener('keydown', onDown); - addEventListener('keyup', onUp); + 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), - const pos = new THREE.Vector3(0, heightAt(0, 6), 6); - const move = new THREE.Vector3(); - const fwd = new THREE.Vector3(); - const right = new THREE.Vector3(); - let facing = 0; - - const hx = YARD.width / 2 - 0.5, hz = YARD.depth / 2 - 0.5; - - return { - pos, - carrying: null, - busy: false, - mesh, - - update(dt) { - const yaw = cameraRig.yaw; - // Camera-relative: forward is where the camera is looking, flattened. - fwd.set(-Math.sin(yaw), 0, -Math.cos(yaw)); - right.set(Math.cos(yaw), 0, -Math.sin(yaw)); - - move.set(0, 0, 0); - if (keys.has('w') || keys.has('arrowup')) move.add(fwd); - if (keys.has('s') || keys.has('arrowdown')) move.sub(fwd); - if (keys.has('d') || keys.has('arrowright')) move.add(right); - if (keys.has('a') || keys.has('arrowleft')) move.sub(right); - - if (move.lengthSq() > 0) { - move.normalize().multiplyScalar((keys.has('shift') ? RUN : WALK) * dt); - pos.x = Math.max(-hx, Math.min(hx, pos.x + move.x)); - pos.z = Math.max(-hz, Math.min(hz, pos.z + move.z)); - facing = Math.atan2(move.x, move.z); - } - pos.y = world.heightAt(pos.x, pos.z); - - mesh.position.set(pos.x, pos.y + 0.85, pos.z); // capsule centre - mesh.rotation.y = facing; + 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, + }))); }, - dispose() { - removeEventListener('keydown', onDown); - removeEventListener('keyup', onUp); - }, + 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; } // --------------------------------------------------------------------------- @@ -143,7 +167,7 @@ function createPlaceholderPlayer(scene, world, cameraRig) { * @param {object} [opts] * @param {HTMLCanvasElement} [opts.canvas] */ -export function boot(opts = {}) { +export async function boot(opts = {}) { const canvas = opts.canvas ?? document.getElementById('c'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); @@ -155,33 +179,154 @@ export function boot(opts = {}) { const scene = new THREE.Scene(); - // Lane C: swap createStubWind() for your createWeather(). Everything that - // moves reads this one object, so that swap is the whole integration. - const wind = createStubWind({ calm: true }); + // --- 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); - const player = createPlaceholderPlayer(scene, world, cameraRig); + // 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(); - // --- dev overlay (temporary — Lane A's hud.js replaces it after M0) ---- + // --- 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); } }); - addEventListener('keydown', (e) => { - if (e.key === 'Enter') game.advance(); - }); - // --- resize ------------------------------------------------------------ + // --- resize ------------------------------------------------------------- function resize() { const w = canvas.clientWidth || innerWidth; const h = canvas.clientHeight || innerHeight; @@ -191,18 +336,19 @@ export function boot(opts = {}) { addEventListener('resize', resize); resize(); - // --- loop -------------------------------------------------------------- + // --- loop --------------------------------------------------------------- const clock = new THREE.Clock(); - let acc = 0; - let simT = 0; let frames = 0, fpsT = 0, fps = 0; - function step(dt, t) { + function step(dt) { game.tick(dt); - world.update(dt, t); - player.update(dt, t); - // Lane B: sailRig.step(dt, wind, t) goes here. - // Lane C: debris.step(dt, wind, t) goes here. + 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() { @@ -212,28 +358,55 @@ export function boot(opts = {}) { acc += raw; let guard = 0; while (acc >= FIXED_DT && guard++ < 60) { - step(FIXED_DT, simT); - simT += FIXED_DT; + 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 w = wind.sample(player.pos, simT); + 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 | phase ${game.phase} ${game.phaseT.toFixed(1)}s | ` + - `wind ${w.length().toFixed(1)} m/s | t ${simT.toFixed(1)}s`; + `${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. - const api = { renderer, scene, world, cameraRig, player, game, wind, get simT() { return simT; } }; + // 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; }