guts/web/js/boot.js
type-two 5a891107e2 [lane B+F] The enemies were never wearing their meshes: boot forgot to pass assets to combat
Chasing "more detailed looking enemies" turned up the reason detail never landed, and it was
not the art.

boot.js built combat as `createCombat({ scene, world, bus, rng, flags, player })` — no `assets`.
createCombat defaults it to null, so createEnemies got null, so `assets?.get?.('models', type)`
returned null for EVERY archetype and every enemy in the game has been quietly drawing its
procedural primitive. Lane D's GLBs loaded correctly, verified correctly, and were never once
rendered. spore_pod has been an icosahedron since the day it shipped; the Giardia and H. pylori
meshes from an hour ago were cones.

This is the nastiest shape a bug can have: the fallback path is INDISTINGUISHABLE from success.
`assets.get('models','spore_pod').geometry` was there and correct every time I checked it — I
was checking the loader, never the pool that consumes it. Measured after the one-word fix: pool
geometry goes 42 verts -> 6056 (spore_pod), and the new pathogens render at 2234 (Giardia) and
2500 (H. pylori).

Also in: the entity matcap the same investigation was written to prototype. emissiveMat takes an
optional matcap and applies it as LUMINANCE ONLY — sampled, reduced to luma, multiplied into the
core — so an amber foe stays exactly amber and a cyan commensal stays exactly cyan. The
ART_BIBLE readability law that the whole friend/foe system rests on is untouched by construction,
while the silhouettes stop being flat glows and start reading as lit, wet, three-dimensional
organisms. Switchable with ?matcap=0 for A/B, and absent-safe if D's matcap never shipped.
Weapons and pickups deliberately keep the flat look — a tracer is not a creature.

Net effect for playtest: this is the first build where the pathogens are actually visible as
pathogens. The GLB pipeline was correct for weeks; the wiring was one word short.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 18:31:14 +10:00

279 lines
13 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 });
// `assets` is not optional here, and leaving it out was a silent, months-old bug: combat
// defaults it to null, so `assets.get('models', type)` in enemies.js always returned null and
// EVERY enemy quietly drew its procedural primitive. Lane D's GLBs loaded fine, were verified
// fine, and were never once rendered — the fallback path is indistinguishable from success
// unless you check the pool's geometry, which nothing did.
const combat = player ? createCombat({ scene, world, bus, rng, flags, assets, 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, fbMod, cardsMod, audioMod] = await Promise.all([
import('./ui/hud.js'), import('./ui/comms.js'), import('./ui/feedback.js'),
import('./ui/cards.js'), import('./audio/engine.js'),
]);
const args = { bus, flags, assets, level: world.level, world };
// Each module is constructed independently: one that throws must not cost us the rest.
// A missing score readout is a blemish; a UI layer that fails to build in a game whose
// only other feedback is the wall rushing past is an unplayable build.
const parts = {};
for (const [key, make] of [
['hud', () => hudMod.createHUD({ bus, flags, level: world.level })],
['comms', () => commsMod.createComms({ bus, flags })],
['feedback', () => fbMod.createFeedback(args)],
['cards', () => cardsMod.createCards(args)],
['audio', () => audioMod.createAudio(args)],
]) {
try { parts[key] = make(); } catch (e) { console.info(`[boot] Lane E ${key} failed —`, e.message); }
}
const ticking = Object.values(parts).filter((p) => typeof p?.update === 'function');
ui = {
...parts,
// The HUD is bus-driven and needs no tick; the audio heartbeat, the corruption decay
// and the card animations do. Called from frame(), never from step(): step() is the
// deterministic sim that stepped-sim harnesses drive thousands of times a second, and
// it must not do DOM or WebAudio work.
update(dt) { for (const p of ticking) p.update(dt); },
dispose() { for (const p of Object.values(parts)) { try { p?.dispose?.(); } catch { /* keep going */ } } },
};
} catch (e) {
console.info('[boot] Lane E UI not available —', e.message);
}
}
// Pause. cards.js raises ui:pause; somebody has to honour it or the button is a lie (its
// reviewer flagged exactly that, and it is boot's to fix — E cannot reach the loop).
// Gated in frame(), NOT in step(), so a paused game still renders and animates its overlay
// while the simulation holds still — and so stepped sims, which call step() directly, are
// unaffected by a UI state they have no business knowing about.
let paused = false;
bus.on('ui:pause', (e) => { paused = !!e?.paused; });
// The acid sea's level. world/acid.js exposes set(y, dt) and says "C's event pump drives this",
// so an `acid` level event names a target height and the sim eases toward it every step — a
// rising tide is a beat C can author, and because it is eased in step() (not frame()) it stays
// deterministic for stepped sims. No sea on this level => the event is inert.
let acidTarget = null;
bus.on('level:event', (ev) => { if (ev?.type === 'acid') acidTarget = ev.height ?? 0; });
// --- 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;
// A gate may carry a SECRET branch (`secretTo` + `secretNeed`): C's ileocecal valve opens
// onto the appendix instead of the colon when BIOME STANDING has earned it. Resolved here
// because boot owns where the run goes next, and read at the moment of crossing — the
// reputation you hold when you arrive is the one that counts.
const standing = combat?.karma ?? 0; // conduct, not the spendable meter
const secret = ev.secretTo && standing >= (ev.secretNeed ?? 70);
const to = secret ? ev.secretTo : (ev.to ?? null);
if (secret) bus.emit('secret:open', { gate: ev.id ?? null, to, standing });
bus.emit('level:complete', {
id: world.level?.id ?? null, to, gate: ev.id ?? null, secret: !!secret,
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,
});
}
});
// CONTINUE on the medal card -> load the next segment. cards.js raises ui:continue with C's
// `next` id; consuming it here is what makes the campaign a campaign rather than three levels
// in a folder. A full page load is deliberate (ponytail: the alternative is rebuilding world +
// player + combat + UI in place and re-wiring every bus listener, to save ~1s on a transition
// that happens 4 times a run) — it also guarantees no leaked pools or stale event pumps.
// Flags ride along, so ?dbg=1 / ?seed= survive the hop. Unknown/absent `to` = campaign over:
// stay on the card rather than dumping the player into the stub tube.
bus.on('ui:continue', async (e) => {
if (!e?.to) return;
const mod = await import('./levels/index.js').catch(() => null);
if (!mod?.CAMPAIGN?.includes(e.to)) { console.info(`[boot] no level "${e.to}" yet — campaign ends here`); return; }
const q = new URLSearchParams(location.search);
q.set('lvl', e.to);
location.search = q.toString();
});
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);
if (acidTarget !== null && world.acid) world.acid.set(acidTarget, dt); // the tide eases
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;
if (!paused) step(dt);
ui?.update?.(dt); // overlay keeps animating while paused — the pause card moves
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 };