Audit items 9, 10 and 11 — three features whose backends were finished and whose front ends did not exist. DIFFICULTY WAS UNREACHABLE. levels/difficulty.js has carried three fully specified tiers since round 2 — Endoscopy, Clinical, Terminal, with per-tier assists and a note explaining each — and every single call site in the codebase took the default. `easy` and `hard` were a table nobody could select. getLevel now takes the saved difficulty (?diff= still wins, so a test URL stays absolute), and the title has the switch. Verified live: the world boots at 11 spawns on Terminal against 8 on Clinical. THE CAMPAIGN WAS SIX LEVELS WITH ONE WAY IN. Reaching anything but L1 meant hand-editing ?lvl= in the address bar. The title now lists the campaign, unlocking each segment as the one before it is filed, showing the medal you hold on each. `LS_` is C's secret prefix, so the appendix stays a row of question marks until you have earned your way in — naming it in the menu would have given away the one thing in this game you have to discover. THE CAMPAIGN HAD NO ENDING. C's anal_verge gate carries `to: null` — the only gate in the game that does, its own note calling it "the end of the campaign" — and boot's ui:continue handler opened with `if (!e.to) return`. Finishing GUTS parked you on a medal card forever with no way out but the address bar, and the card said "any key", the same words it uses when it has nothing to tell you. Now the clear is filed and counted, the card says you are out, and the title carries CANAL CLEARED. New core/save.js is the whole persistence layer: one localStorage key, one flat object, best time / best score / best medal kept per column (they need not come from the same run) plus an attempt count. Every entry point is wrapped — Safari private mode throws on setItem, and a save file must never be able to stop the game booting. Its selfcheck (`node web/js/core/save.js --selfcheck`, now in qa.sh) covers the best-of comparisons and feeds it corrupt, future-versioned and malformed saves. cards.js records rather than boot: this file computes the grade, so routing it through the bus for boot to re-derive would be two copies of the medal table waiting to disagree. One bug found by clicking rather than reading: the first cut of the chips had no pointer-events:auto. #ui is pointer-events:none and it inherits — the file's own header says so — so the buttons rendered, highlighted nothing, and passed the click through to the canvas, which on the title starts the run. It looked like a menu and behaved like wallpaper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
385 lines
19 KiB
JavaScript
385 lines
19 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 * as store from './core/save.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();
|
|
|
|
/**
|
|
* Say something went wrong, on the screen, in the fiction.
|
|
*
|
|
* Every failure in this game used to present as the same black rectangle — a bad ?lvl=, a throw
|
|
* inside a bus listener, a lost WebGL context — so a player could not tell "loading" from
|
|
* "crashed" and had nothing to report. This is the difference between a bug report and a shrug.
|
|
*/
|
|
let _failed = false;
|
|
function fail(what, detail) {
|
|
if (_failed) return; // first cause only; the rest are noise
|
|
_failed = true;
|
|
console.error('[guts]', what, detail ?? '');
|
|
const el = document.createElement('div');
|
|
el.setAttribute('style', [
|
|
'position:fixed', 'inset:0', 'display:grid', 'place-content:center', 'gap:10px',
|
|
'background:#04070a', 'color:#39e6ff', 'z-index:9999', 'text-align:center',
|
|
'font:12px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace', 'letter-spacing:.16em',
|
|
'text-transform:uppercase', 'padding:24px',
|
|
].join(';'));
|
|
el.innerHTML =
|
|
'<div style="font-size:15px;letter-spacing:.3em">feed lost</div>' +
|
|
`<div style="opacity:.6">${String(what).replace(/[<&]/g, '')}</div>` +
|
|
(detail ? `<div style="opacity:.32;text-transform:none;letter-spacing:.06em">${String(detail).slice(0, 200).replace(/[<&]/g, '')}</div>` : '') +
|
|
'<div style="opacity:.45;margin-top:8px">reload to retry · ?lvl=L1_mouth for the start</div>';
|
|
document.body.appendChild(el);
|
|
}
|
|
addEventListener('error', (e) => fail('script error', e?.message));
|
|
addEventListener('unhandledrejection', (e) => fail('failed to load', e?.reason?.message ?? e?.reason));
|
|
|
|
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);
|
|
// A laptop sleeping or a display being hotplugged takes the GL context away; without this the
|
|
// canvas simply stops updating and the HUD floats over a white page forever.
|
|
renderer.domElement.addEventListener('webglcontextlost', (e) => {
|
|
e.preventDefault();
|
|
fail('graphics context lost', 'the browser released the GPU — reloading restores it');
|
|
});
|
|
|
|
const assets = await createAssets({ flags, renderer });
|
|
|
|
let campaign = []; // C's level order, captured at load so the title can offer a level select
|
|
|
|
async function loadLevel(id) {
|
|
try {
|
|
const mod = await import('./levels/index.js'); // Lane C's registry
|
|
campaign = mod.CAMPAIGN ?? [];
|
|
// `await`, not a bare return: without it the promise is handed to the caller and a rejection
|
|
// (a mistyped ?lvl=, a 404 on the JSON) escapes this catch entirely, kills top-level module
|
|
// evaluation, and the player gets a black page with no DBG, no HUD and no message.
|
|
// DIFFICULTY IS NOW REACHABLE. levels/difficulty.js has carried three fully specified tiers
|
|
// since round 2 and every caller in the codebase took the default, so `easy` and `hard`
|
|
// existed only as a table nobody could select. ?diff= wins over the save so a test URL is
|
|
// still absolute; the save is what an actual player set on the title.
|
|
return await mod.getLevel(id, { difficulty: flags.diff ?? store.load().diff });
|
|
} 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, campaign };
|
|
// 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 RUN HAS A START. cards.js has emitted `ui:start` on title dismissal since round 1 and its
|
|
// own comment says nothing consumes it — so the sim ran behind the title card. Three costs, all
|
|
// real: the debrief's DURATION-vs-PAR included however long you spent reading the title, L1's
|
|
// first checkpoint and the opening comms line both fired while it was still up, and a hazard
|
|
// could hurt you before you had touched a key (which is why cards.js already carries a
|
|
// player:damage auto-dismiss as a safety valve). ?fly=1 has no title, so it starts immediately.
|
|
let started = !player;
|
|
bus.on('ui:start', () => { started = true; });
|
|
|
|
// PAUSE ON BLUR. The consumer above already works; nothing ever produced for it except the
|
|
// pause key. Alt-tab during a fight and you come back to a dead ship, which is nobody's idea of
|
|
// a fair death.
|
|
const autoPause = () => { if (started && !paused) bus.emit('ui:pause', { paused: true, auto: true }); };
|
|
addEventListener('blur', autoPause);
|
|
document.addEventListener('visibilitychange', () => { if (document.hidden) autoPause(); });
|
|
|
|
// 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 _spent = new Set(); // indices consumed for good — never re-fire these
|
|
|
|
// Which event types must COME BACK after a death. The original pump was strictly monotonic and
|
|
// the reasoning was right for most types (re-firing `spawn` double-spawns enemies that survived
|
|
// your death; re-firing `pickup` resurrects ones you already banked) — but it silently applied
|
|
// to `boss` too, so dying to any of the five bosses DELETED it: you respawned, flew through an
|
|
// empty lair, crossed the gate and were handed a graded medal card for a fight that never
|
|
// happened. boss.js's own header promises "a wipe costs seconds", which only holds if the
|
|
// encounter re-arms. `acid` re-fires so L3's tide sits where the checkpoint left it.
|
|
const REARM = new Set(['boss', 'acid']);
|
|
|
|
const pumpTo = (s) => {
|
|
while (_ei < _evts.length && s >= _evts[_ei].s) {
|
|
const i = _ei++;
|
|
if (!_spent.has(i)) bus.emit('level:event', _evts[i]);
|
|
}
|
|
};
|
|
|
|
/** Respawn: rewind the pump to `s`, retiring everything that must not happen twice. */
|
|
const rewindTo = (s) => {
|
|
let i = 0;
|
|
while (i < _evts.length && _evts[i].s < s) i++;
|
|
for (let k = i; k < _ei; k++) if (!REARM.has(_evts[k].type)) _spent.add(k);
|
|
_ei = i;
|
|
};
|
|
|
|
// --- 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) => {
|
|
// THE CAMPAIGN HAS AN ENDING. C's anal_verge gate carries `to: null` — the only gate in the
|
|
// game that does, and its own note calls it "the end of the campaign". That arrived at a
|
|
// handler which returned early, so finishing GUTS left you parked on a medal card forever
|
|
// with no way back but the address bar. Now it is filed and you are returned to the title,
|
|
// which is where the clear count is displayed.
|
|
if (!e?.to) {
|
|
if (e?.from) { store.clearCampaign(); location.search = ''; }
|
|
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
|
|
rewindTo(run.checkpointS); // ...and the boss you just died to
|
|
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, bus, // bus: so a UI screen can be driven without playing to it
|
|
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) {
|
|
// The rAF chain re-arms at the BOTTOM of this function, so a single throw anywhere inside it
|
|
// stops the loop for good — the canvas keeps displaying its last frame and the game looks
|
|
// hung rather than crashed. Guard the body, re-arm regardless, and say so on screen once.
|
|
try {
|
|
frameBody(now);
|
|
} catch (e) {
|
|
fail('simulation stopped', e?.message);
|
|
}
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
function frameBody(now) {
|
|
const dt = Math.min((now - last) / 1000, 0.05);
|
|
last = now;
|
|
if (started && !paused) step(dt); // the run does not tick behind the title card
|
|
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?.() ?? ''}`;
|
|
}
|
|
}
|
|
}
|
|
if (flags.dbg && !flags.shots) dbgEl.style.display = 'block';
|
|
requestAnimationFrame(frame);
|
|
|
|
export { scene, camera, renderer, bus, flags, world, player, combat, assets, ui, step };
|