diff --git a/web/js/audio/engine.js b/web/js/audio/engine.js index c523c21..67e4c3e 100644 --- a/web/js/audio/engine.js +++ b/web/js/audio/engine.js @@ -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. diff --git a/web/js/boot.js b/web/js/boot.js index 1e7db89..9de6f7b 100644 --- a/web/js/boot.js +++ b/web/js/boot.js @@ -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 = + '
feed lost
' + + `
${String(what).replace(/[<&]/g, '')}
` + + (detail ? `
${String(detail).slice(0, 200).replace(/[<&]/g, '')}
` : '') + + '
reload to retry · ?lvl=L1_mouth for the start
'; + 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); diff --git a/web/js/core/bus.js b/web/js/core/bus.js index 89f51c5..75da3dc 100644 --- a/web/js/core/bus.js +++ b/web/js/core/bus.js @@ -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); } + } }, }; }