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>
163 lines
7.0 KiB
JavaScript
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);
|