// boot.js (Lane F) — the shell. Creates renderer/scene/loop, picks the world (real or // stub), constructs assets + player + combat, pumps level events, hosts DBG. // Lanes: request wiring via NOTES, don't edit here. Round 2: A/B/C/D snippets integrated. import * as THREE from 'three'; import { parseFlags } from './core/flags.js'; import { createRng } from './core/rng.js'; import { createBus } from './core/bus.js'; import { createAssets } from './core/assets.js'; import { createWorld as createStubWorld, STUB_LEVEL } from './stub/world_stub.js'; import { createPlayer } from './flight/player.js'; import { createCombat } from './combat/index.js'; const flags = parseFlags(); const bus = createBus(); const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(innerWidth, innerHeight); document.getElementById('game').appendChild(renderer.domElement); const assets = await createAssets({ flags, renderer }); async function loadLevel(id) { try { const mod = await import('./levels/index.js'); // Lane C's registry return mod.getLevel(id); // default level lives there } catch (e) { console.info('[boot] Lane C registry not available —', e.message); return null; } } async function pickWorld() { const level = (await loadLevel(flags.lvl)) ?? STUB_LEVEL; const rng = createRng(flags.seed ?? level.seed); if (!flags.stub) { try { const mod = await import('./world/index.js'); // Lane A's world return mod.createWorld(level, { rng, assets }); } catch (e) { console.info('[boot] Lane A world not available, using stub —', e.message); } } return createStubWorld(level, { rng, assets }); // ?stub=1&lvl= composes (beat 1 only — stub is single-segment) } const world = await pickWorld(); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600); scene.add(world.group); scene.background = new THREE.Color(world.biomeAt(0).palette.void); // --- player + combat (Lane B). ?fly=1 keeps the noclip/drift camera instead. --- const rng = createRng(flags.seed ?? world.level?.seed ?? 0); const player = flags.fly ? null : createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement }); const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null; // --- level-event pump (owned by boot per round-2 ruling): emits C's events as the // player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump // (no double-spawns, collected pickups stay collected); hazards re-arm via combat.reset(), // which is the one replay a death actually needs (the thing that killed you works again). let _evts = [...(world.level?.events ?? [])].sort((a, b) => a.s - b.s), _ei = 0; const pumpTo = (s) => { while (_ei < _evts.length && s >= _evts[_ei].s) bus.emit('level:event', _evts[_ei++]); }; // --- run state: checkpoint -> death -> respawn, gate -> level:complete ------------------ // C's authoring laws carry this: checkpoints sit outside hazard zones and <=30s apart, so // "respawn at the last checkpoint crossed" is safe by construction. Stats accumulate across // deaths (a run's story includes its deaths); E renders the medal card off level:complete. const run = { t: 0, deaths: 0, checkpointS: player?.state.s ?? 0, respawnT: 0, done: false }; bus.on('level:event', (ev) => { if (ev.type === 'checkpoint') { run.checkpointS = ev.s; bus.emit('audio:cue', { name: 'checkpoint' }); } else if (ev.type === 'gate' && !run.done) { run.done = true; bus.emit('level:complete', { id: world.level?.id ?? null, to: ev.to ?? null, gate: ev.id ?? null, stats: { time: run.t, deaths: run.deaths, score: combat?.score.value ?? 0, kills: combat?.score.kills ?? 0, samples: combat?.score.samples ?? 0, }, par: world.level?.par ?? null, }); } }); bus.on('player:death', () => { run.deaths++; run.respawnT = 2.0; }); // 2s of feed-drop, then back function updateRun(dt) { if (!run.done) run.t += dt; if (run.respawnT > 0 && player) { run.respawnT -= dt; if (run.respawnT <= 0) { combat?.reset(run.checkpointS); // re-arm hazards at/ahead of the checkpoint player.respawn(run.checkpointS); } } } // --- debug drift/fly camera (?fly=1, or fallback when B's player is absent) --- let sCam = 5, look = { yaw: 0, pitch: 0 }, dragging = false, speed = world.biomeAt(0).flow * 0.6; const keys = new Set(); addEventListener('keydown', (e) => keys.add(e.code)); addEventListener('keyup', (e) => keys.delete(e.code)); addEventListener('mousedown', () => (dragging = true)); addEventListener('mouseup', () => (dragging = false)); addEventListener('mousemove', (e) => { if (!dragging || player) return; look.yaw -= e.movementX * 0.003; look.pitch = THREE.MathUtils.clamp(look.pitch - e.movementY * 0.003, -1.2, 1.2); }); function updateCamera(dt) { if (flags.fly) { if (keys.has('KeyW')) sCam += speed * 2 * dt; if (keys.has('KeyS')) sCam -= speed * 2 * dt; } else { sCam += speed * dt; // title-screen auto-drift } sCam = ((sCam % world.length) + world.length) % world.length; const f = world.sample(sCam), ahead = world.sample(Math.min(sCam + 12, world.length)); camera.position.copy(f.pos).addScaledVector(f.nor, -f.radius * 0.25); const target = ahead.pos.clone() .addScaledVector(f.bin, Math.sin(look.yaw) * 8) .addScaledVector(f.nor, Math.sin(look.pitch) * 8); camera.up.copy(f.nor); camera.lookAt(target); } // --- DBG (?dbg=1) — cite these numbers in NOTES; DBG.shot('name') saves a frame --- const dbgEl = document.getElementById('dbg'); let frames = 0, fpsT = 0, fps = 0; renderer.info.autoReset = false; // three resets info at render end — cache per-frame instead const stats = { draws: 0, tris: 0 }; window.DBG = { get draws() { return stats.draws; }, get tris() { return stats.tris; }, get fps() { return fps; }, world, player, combat, assets, shot(name = 'shot') { const a = document.createElement('a'); a.download = `${name}.png`; a.href = renderer.domElement.toDataURL('image/png'); a.click(); }, }; addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); }); // One deterministic simulation step — the whole game minus rendering. Exported so the // stepped-sim harness (f-progress documents it; rAF never fires in a hidden pane) exercises // the REAL loop — pump, run state and all — instead of a hand-rolled copy that drifts. function step(dt) { if (player) { player.update(dt); // must run first: combat hit-tests this frame's ship position pumpTo(player.state.s); combat.update(dt); updateRun(dt); world.update(dt, player.state.s); } else { updateCamera(dt); // ?fly=1 noclip / title drift world.update(dt, sCam); } } let last = performance.now(); function frame(now) { const dt = Math.min((now - last) / 1000, 0.05); last = now; step(dt); renderer.render(scene, camera); stats.draws = renderer.info.render.calls; stats.tris = renderer.info.render.triangles; renderer.info.reset(); frames++; fpsT += dt; if (fpsT >= 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; if (flags.dbg && !flags.shots) { const s = player?.state.s ?? sCam; dbgEl.textContent = `${fps} fps · ${DBG.draws} draws · ${(DBG.tris / 1000) | 0}k tris · s=${s.toFixed(0)} · ${world.level?.id ?? '?'} ${world.hash?.() ?? ''}`; } } requestAnimationFrame(frame); } if (flags.dbg && !flags.shots) dbgEl.style.display = 'block'; requestAnimationFrame(frame); export { scene, camera, renderer, bus, flags, world, player, combat, assets, step };