#ui had been an empty div since round 0 — E never ran. It has an owner now. HUD (charter #1): coat/hull, heat, torpedo pips, speed/flow/throttle, boost, score, combo, biome + segment name, and a progress rail with C's 10 checkpoints ticked. Bus-only — zero imports from B, exactly as the charter asks. player:state and combat:state arrive every frame and ARE the clock, so the HUD cannot tick while the game isn't running. 60fps law: DOM is built once; the hot path is transform + textContent-on-change only. Bars scale (scaleX), they never resize — a width:% write per bar per frame is a layout pass. Torpedo pips rebuild only when ammoMax changes, not when ammo does. COMMS — on nobody's list. Argued for, then built. The GDD says "Star Fox arcade flow" and there was nobody in the game but the player. Three-hander: voss (attending, terse), park (resident, over-excited), adeyemi (immunology, apologetic — and amber, the colour of the cells attacking you; his fault). Deterministic round-robin per trigger key, no RNG. Priority ladder + per-key cooldowns so the crew shuts up during a firefight. It turned out to be more than flavour: it is the tutorialisation channel the GDD asks for and L1 has no other vehicle for. The hazard lines teach what C's HAZARDS registry note says the hazard IS ("ring gate. it opens on a beat — match the beat, do not ram it"). Keys are the real HAZARDS ids, checked against levels/enemies.js rather than guessed — all three L2 kinds get bespoke lines, zero generic fallbacks. A level's worth of dialogue is now a data edit in LINES. Three defects, all mine, all found by the stepped sim, none visible to the eye: - The progress rail was dead. I read `level.length` — a field that does not exist. The level carries segments, not a total. It now uses the runtime world.length that B already divides into p.progress. - My clock was fiction. Comms counted `+= 0.1` per timer tick and called it seconds; a backgrounded tab throttles setTimeout to ~1s, so lines held ~10x too long. It now reads performance.now(). - Every hazard warning had the LOWEST priority in the game. PRI has a key `warn`, but say() is called with `warn_reflux_surge`, so PRI[key] was undefined and fell through to the default of 1 — the surge screaming "GO. GO." could be talked over by park admiring a specimen. Keys now resolve exact-then-family. The evidence shot IS the fix: same seed, same frame, the warning now sits where the checkpoint line used to be. ### -> Lane B — player:state stops emitting the moment you die player.js:103 early-returns before the emit when !st.alive. So `alive` is unobservable (E can only ever read true — the field is currently decorative), and E cannot use player:state as a clock, because it stops exactly when the death UI needs to run and through boot's whole 2.0s respawn window. Comms works around it with its own wall-clock. The death/respawn feed-drop (charter #4) will not be able to. Ask is in NOTES; not patching your file. ### -> Lane F — two contract items I edited boot.js (~10 lines mirroring your own dynamic-import/try-catch/ console.info fallback) because #ui needed an owner and two modules nothing constructs aren't worth much. Ratify or move it. Second: shot_sink.py cannot photograph Lane E — it POSTs renderer.domElement.toDataURL(), and the HUD is a DOM overlay, so the house evidence tool renders it invisible. This shot is a composite (canvas -> 2D canvas, then #ui via an SVG foreignObject). Offer to lift it into pipeline/ as DBG.shotUI() stands. qa GREEN. Evidence: docs/shots/laneE/round2_hud_aortic.webp (s=1111, seed 7, live hazard warn + x6 chain). Dev server guts-e on 8146. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
210 lines
8.5 KiB
JavaScript
210 lines
8.5 KiB
JavaScript
// 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=<id> 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;
|
|
|
|
// --- UI (Lane E). Constructed here because #ui needs an owner and E is bus-only: it never
|
|
// sees player/combat, just the events they emit. After combat so every subscription exists
|
|
// before the first state emit; skipped under ?fly=1, which has no player to instrument.
|
|
let ui = null;
|
|
if (player) {
|
|
try {
|
|
const [hudMod, commsMod] = await Promise.all([import('./ui/hud.js'), import('./ui/comms.js')]);
|
|
ui = {
|
|
hud: hudMod.createHUD({ bus, flags, level: world.level }),
|
|
comms: commsMod.createComms({ bus, flags }),
|
|
};
|
|
} catch (e) {
|
|
console.info('[boot] Lane E UI not available —', e.message);
|
|
}
|
|
}
|
|
|
|
// --- 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, ui,
|
|
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, ui, step };
|