// flight/dev.js (Lane B) — harness boot for dev.html. Mirrors the parts of Lane F's boot.js // that Lane B needs, and NOTHING else. Not shipped; not the game's entry point. // // It exists so flight/combat can be flown, tuned and screenshotted while boot.js still has // no player wiring. The exact snippet F needs to paste into boot.js is in LANE_B_NOTES.md; // when that lands, this file's only remaining job is isolated feel-tuning. import * as THREE from 'three'; import { parseFlags } from '../core/flags.js'; import { createRng } from '../core/rng.js'; import { createBus } from '../core/bus.js'; import { createWorld as createStubWorld, STUB_LEVEL } from '../stub/world_stub.js'; import { createPlayer } from './player.js'; import { createCombat } from '../combat/index.js'; const flags = parseFlags(); const bus = createBus(); const rng = createRng(flags.seed ?? STUB_LEVEL.seed); const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(innerWidth, innerHeight); document.body.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600); const world = await createStubWorld(STUB_LEVEL, { rng }); scene.add(world.group); scene.background = new THREE.Color(world.biomeAt(0).palette.void); const player = createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement }); const combat = createCombat({ scene, world, bus, rng, flags, player }); // --- dev-only level pump ---------------------------------------------------------------- // Emits `level:event` as the player crosses each event's s. NOBODY owns this in the shipped // build yet — TECH.md specifies the event but not its emitter. Flagged to F in NOTES; this // local copy keeps Lane B unblocked without implementing another lane's side. function createLevelPump(level, b) { const evts = [...(level.events ?? [])].sort((a, x) => a.s - x.s); let i = 0; return { update(s) { while (i < evts.length && s >= evts[i].s) b.emit('level:event', evts[i++]); }, reset() { i = 0; }, }; } const pump = createLevelPump(world.level, bus); // --- DBG (mirrors boot.js so NOTES numbers are measured the same way) -------------------- const dbgEl = document.getElementById('dbg'); const hintEl = document.getElementById('hint'); let frames = 0, fpsT = 0, fps = 0; renderer.info.autoReset = false; const stats = { draws: 0, tris: 0 }; let hud = null; bus.on('player:state', (s) => (hud = s)); let cstate = null; bus.on('combat:state', (s) => (cstate = s)); window.DBG = { get draws() { return stats.draws; }, get tris() { return stats.tris; }, get fps() { return fps; }, world, player, combat, bus, flags, renderer, scene, camera, // Leak test (charter: "everything pooled + disposed, leak-test enter/exit"). Builds and // tears down N player+combat pairs and reports GPU resource deltas via renderer.info. leakTest(cycles = 5) { // Render FIRST: renderer.info.memory only counts geometries actually uploaded to the // GPU, so a baseline taken before any render reads 0 and the harness's own persistent // objects then look like a leak on the next sample. Ask me how I know. renderer.render(scene, camera); const before = { ...renderer.info.memory, children: scene.children.length }; for (let i = 0; i < cycles; i++) { const p = createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement }); const c = createCombat({ scene, world, bus, rng, flags, player: p }); c.enemies.spawn('floater', { s: 10 }); c.enemies.spawn('turret', { s: 20 }); p.update(1 / 60); c.update(1 / 60); renderer.render(scene, camera); c.dispose(); p.dispose(); } const after = { ...renderer.info.memory, children: scene.children.length }; return { before, after, leaked: { geometries: after.geometries - before.geometries, textures: after.textures - before.textures, children: after.children - before.children } }; }, shot(name = 'shot') { const a = document.createElement('a'); a.download = `${name}.png`; a.href = renderer.domElement.toDataURL('image/png'); a.click(); }, // helpers for eyeballing behaviours without flying to them spawn: (type, at = 12) => combat.enemies.spawn(type, { s: player.state.s + at }), tp: (s) => { player.state.s = s; pump.reset(); }, // Synchronous fixed-timestep advance. Two reasons this exists: // 1. requestAnimationFrame does not run in a hidden/on-demand-rendered tab, so the loop // can't be exercised from an automated browser session without it. // 2. It's deterministic — DBG.step(120, 1/60) is exactly 2 s of game time every time, // independent of frame pacing, so measurements quoted in NOTES are reproducible. step(n = 1, dt = 1 / 60) { for (let i = 0; i < n; i++) tick(dt); renderer.render(scene, camera); stats.draws = renderer.info.render.calls; stats.tris = renderer.info.render.triangles; renderer.info.reset(); return { s: player.state.s, draws: stats.draws, tris: stats.tris }; }, get state() { return hud; }, get combatState() { return cstate; }, }; addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); }); // The one true update order (mirror it in boot.js — see LANE_B_NOTES §-> Lane F): // player -> level pump -> combat -> world // player first so combat hit-tests against this frame's ship position; world last so its // shader time matches the s we just moved to. function tick(dt) { player.update(dt); pump.update(player.state.s); combat.update(dt); world.update(dt, player.state.s); } let last = performance.now(); function frame(now) { const dt = Math.min((now - last) / 1000, 0.05); last = now; tick(dt); renderer.render(scene, camera); stats.draws = renderer.info.render.calls; stats.tris = renderer.info.render.triangles; renderer.info.reset(); hintEl.classList.toggle('gone', !!document.pointerLockElement || flags.shots); frames++; fpsT += dt; if (fpsT >= 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; if (!flags.shots && hud) { dbgEl.textContent = `${fps} fps · ${stats.draws} draws · ${(stats.tris / 1000) | 0}k tris\n` + `s ${hud.s.toFixed(0)}/${hud.length | 0} spd ${hud.speed.toFixed(1)} (flow ${hud.flow}) thr ${hud.throttle.toFixed(2)}` + `${hud.surfing ? ' ◄SURF►' : ''}${hud.iframes ? ' [i]' : ''}\n` + `coat ${hud.coat.toFixed(0)}/${hud.coatMax} hull ${hud.hull.toFixed(0)}/${hud.hullMax} ` + `boost ${hud.boostReady ? 'READY' : hud.boostCd.toFixed(1)}\n` + (cstate ? `heat ${cstate.heat.toFixed(0)}/${cstate.heatMax}${cstate.overheated ? ' OVERHEAT' : ''} ` + `torp ${cstate.ammo}/${cstate.ammoMax} score ${cstate.score} kills ${cstate.kills} live ${cstate.enemies}` : ''); } } requestAnimationFrame(frame); } if (flags.shots) dbgEl.style.display = 'none'; requestAnimationFrame(frame);