/** * 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. * * 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. */ 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 { createCameraRig } from './camera.js'; // --------------------------------------------------------------------------- // 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'); }, }; } // --------------------------------------------------------------------------- // M0 placeholder player // --------------------------------------------------------------------------- /** * 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. * * 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. */ function createPlaceholderPlayer(scene, world, cameraRig) { const WALK = 2.2, RUN = 4.5; // m/s 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 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); 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; }, dispose() { removeEventListener('keydown', onDown); removeEventListener('keyup', onUp); }, }; } // --------------------------------------------------------------------------- // Boot // --------------------------------------------------------------------------- /** * @param {object} [opts] * @param {HTMLCanvasElement} [opts.canvas] */ export 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(); // 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 }); const world = createWorld(scene, { wind }); const cameraRig = createCameraRig(canvas); cameraRig.setSolids(world.solids); cameraRig.setGround(world.heightAt); const player = createPlaceholderPlayer(scene, world, cameraRig); const game = createGame(); // --- dev overlay (temporary — Lane A's hud.js replaces it after M0) ---- const hud = document.getElementById('dev'); const banner = document.getElementById('banner'); game.on('phaseChange', ({ to }) => { 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 ------------------------------------------------------------ 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 acc = 0; let simT = 0; let frames = 0, fpsT = 0, fps = 0; function step(dt, t) { 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. } 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, simT); simT += FIXED_DT; acc -= FIXED_DT; } cameraRig.update(raw, player.pos); 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); 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`; } 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; } }; globalThis.SHADES = api; return api; }