guts/web/js/flight/dev.js
jing 0a038582a7 [lane B] Round 1: flight controller, cannon, enemy framework on the stub
Flight (js/flight/): tube-mode controller in spline space (s,x,y) — flow-locked
forward motion, throttle as a spring-back lean, boost + i-frames, banking chase
cam on the parallel-transport frame, arcade wall response (shove + graze damage,
never a hard stop). Twin-stick input: WASD/left-stick moves the ship in the disc,
mouse/right-stick aims, Shift/Ctrl is throttle. Gamepad supported.

Combat (js/combat/): lysozyme cannon (pooled, instanced, heat-limited with
hysteresis lockout) + antacid torpedo emitting level:neutralize. Enemy framework
with floater/seeker/turret and turn-rate-limited homing darts, spawned from C's
level:event. Coat/hull model and the full bus surface E builds against.

Measured (deterministic stepper): flow-lock exact (3s at flow 14 -> s=44.0);
boost peak 30.8 u/s; heat 4s fire -> 2s lockout; wall slam 13.3 coat at 11.3 u/s
with forward speed untouched; 10 combat draws at 55 live enemies (budget <=80);
0.10 ms/frame CPU; 0 leaks over 20 create/dispose cycles. fps NOT claimed — rAF
does not run in the automated pane. Evidence: docs/shots/laneB/.

Three findings escalated in LANE_B_NOTES:
- qa.sh gate #1 is a no-op: node --check silently exits 0 on ESM, so the syntax
  gate has never checked anything. I shipped a real SyntaxError past a GREEN qa.
  Fix + verification handed to F.
- Surf is structurally broken: the peristalsis wave (13.64 u/s) is slower than
  the player (19.6 u/s), so level 2's signature mechanic loses to mashing
  throttle. Not tunable from this lane; escalated to A/F with options.
- Custom ShaderMaterials need #include <colorspace_fragment>, else ART_BIBLE's
  hostile amber reaches the display as rgb(255,26,6). Fixed here; stub/walls
  inherit it.

js/flight/dev.html is a dev harness (boot.js has no player wiring yet); it
retires once F pastes the snippet in LANE_B_NOTES.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:56:32 +10:00

163 lines
7.0 KiB
JavaScript

// 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);