[lane F+E] The run gets a lifecycle, and failure gets a face
Audit items 4 and 5. Both are shell behaviour a finished game is expected to have and this one
never did.
THE RUN NOW STARTS WHEN YOU START IT. cards.js has emitted `ui:start` on title dismissal since
round 1 and its own comment says nothing consumes it — so the simulation ran behind the title
card. Measured: 1.2 s of sitting on the title moved the ship and burned 1.2 s off the par clock
before the player had touched anything. L1's first checkpoint (s=20) and the opening comms line
both fired while the title was still up, and a hazard could hurt you before you started, which is
why cards.js already carries a player:damage auto-dismiss as a safety valve. Now `started` gates
step(); verified s stays at 2 across a second of real frames.
PAUSE ON BLUR. The ui:pause consumer has worked since round 2 and nothing but the pause key ever
produced for it. Alt-tab mid-fight and you came back to a dead ship. blur + visibilitychange now
produce it.
AUDIO HEARS THE PAUSE. engine.js handled eleven bus events and not that one, so the bed, drone,
grain scheduler and heartbeat ran at full level behind the pause card, and the heart slewed to
its resting 50 bpm while you read and snapped back on resume. Ducks the master rather than
suspending the context — the heartbeat's pump schedules against ctx.currentTime and would wake
up in the past.
FAILURE HAS A FACE. Every failure mode in this game presented as the same black rectangle, so a
player could not tell "loading" from "crashed" and had nothing to report:
- loadLevel did `return mod.getLevel(id)` without awaiting, so a rejection escaped its own
try/catch and killed module evaluation. A mistyped ?lvl= now falls back to the stub tube
(which names itself STUB ESOPHAGUS) instead of a dead page.
- frame() re-armed its rAF at the BOTTOM, so one throw anywhere stopped the loop forever while
the canvas kept showing the last good frame. Body is guarded; the loop always re-arms.
- bus.emit ran listeners bare, so a throw in one subscriber aborted the rest of the emit — and
player:state is emitted every frame from inside step(), so one bad frame in the HUD took
combat and audio down with it. Listeners are isolated now.
- webglcontextlost had no handler at all: a laptop sleeping left a white page with a floating
HUD.
All four now land on a FEED LOST card naming the cause, in the game's own voice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
caa5f95339
commit
628ee150ca
@ -1385,6 +1385,21 @@ export function createAudio({ bus, flags = {}, assets = null, level = null, worl
|
||||
// ═══ 11. BUS SUBSCRIPTIONS ══════════════════════════════════════════════════════════════════
|
||||
// Verified against the emitters: every field below was read out of the file that emits it.
|
||||
|
||||
// PAUSE. The engine handled eleven bus events and not this one, so the bed, the drone, the
|
||||
// grain scheduler and the heartbeat all ran at full level behind the pause card — and the
|
||||
// heart slewed down to its resting 50 bpm while you read, then snapped back on resume.
|
||||
// Ducking the master (rather than ctx.suspend) keeps ctx.currentTime advancing, which the
|
||||
// heartbeat's setTimeout pump schedules against and would otherwise wake up in the past.
|
||||
let pauseDuck = false;
|
||||
offs.push(bus.on('ui:pause', (e) => {
|
||||
const p = !!e?.paused;
|
||||
if (p === pauseDuck) return;
|
||||
pauseDuck = p;
|
||||
const t = t0();
|
||||
master.gain.cancelScheduledValues(t);
|
||||
master.gain.setValueAtTime(master.gain.value, t);
|
||||
master.gain.linearRampToValueAtTime(p ? 0.0001 : baseMaster(), t + 0.18);
|
||||
}));
|
||||
offs.push(bus.on('audio:cue', (e) => { if (e && e.name) cue(e.name); }));
|
||||
|
||||
// player:state — 60 Hz. R4: scalars only, no allocation, no node work. Also the biome edge.
|
||||
|
||||
@ -14,17 +14,55 @@ 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 });
|
||||
|
||||
async function loadLevel(id) {
|
||||
try {
|
||||
const mod = await import('./levels/index.js'); // Lane C's registry
|
||||
return mod.getLevel(id); // default level lives there
|
||||
// `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.
|
||||
return await mod.getLevel(id); // default level lives there
|
||||
} catch (e) {
|
||||
console.info('[boot] Lane C registry not available —', e.message);
|
||||
return null;
|
||||
@ -109,6 +147,22 @@ if (player) {
|
||||
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
|
||||
@ -279,9 +333,21 @@ function step(dt) {
|
||||
|
||||
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 (!paused) step(dt);
|
||||
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;
|
||||
@ -295,7 +361,6 @@ function frame(now) {
|
||||
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);
|
||||
|
||||
@ -11,7 +11,15 @@ export function createBus() {
|
||||
off(name, fn) { map.get(name)?.delete(fn); },
|
||||
emit(name, payload) {
|
||||
const set = map.get(name);
|
||||
if (set) for (const fn of [...set]) fn(payload);
|
||||
if (!set) return;
|
||||
// ISOLATED. Listeners run bare here until now, so one throw in (say) the HUD aborted the
|
||||
// rest of the emit — and since player:state is emitted every frame from inside step(),
|
||||
// a single bad frame in one subscriber took down combat, audio and the run loop with it.
|
||||
// A UI module failing should cost its own readout and nothing else, which is the same
|
||||
// principle boot already applies when CONSTRUCTING them.
|
||||
for (const fn of [...set]) {
|
||||
try { fn(payload); } catch (e) { console.error(`[bus] listener for "${name}" threw —`, e); }
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user