[lane E+B] The pause card stops fighting the player, and a refused pointer lock stops

faking a crash

Audit item 8, plus two bugs the last commit introduced and one it exposed.

REQUESTPOINTERLOCK RETURNS A PROMISE. Both new call sites wrapped it in try/catch, which catches
nothing — the rejection went to window.onunhandledrejection, which the previous commit had just
taught to paint a full-screen FEED LOST. So a browser declining a lock for any of its ordinary
reasons (document not focused; Chrome's ~1s lockout after the player leaves a lock with Escape)
presented to the player as the game crashing. Caught by clicking: "the root document of this
element is not valid for pointer lock" in 15px type over a game running perfectly well
underneath. All three sites — title dismissal, resume, and input.js's canvas click — now swallow
the rejection. A refused lock costs one click, which is what input.js has always been for.

PAUSE ON BLUR HAD NO CARD AND TWO OWNERS. boot.js emitted ui:pause directly, so the loop halted
while cards.js's own `paused` stayed false: the game stopped with nothing on screen to say why,
and the next P press raised a card that then took a SECOND press to leave. Moved to cards.js,
which owns the pause state — setPaused raises the card and emits. It already refuses while the
title or debrief is up, so alt-tabbing away from a card correctly does nothing. Verified: blur
raises PAUSED, one P resumes, exactly one true and one false on the bus.

THE PAUSE CARD WAS NOT MODAL. #ui is pointer-events:none and it inherits, so a click anywhere
but the RESUME button fell through to the canvas — whose mousedown handler re-requests pointer
lock whenever it is not held. Clicking beside the button captured the pointer, hid the cursor,
and left the player paused with nothing to press RESUME with. The dimmer takes pointer-events
now (our own handlers are capture-phase on window, so they still see every click; only the
canvas stops seeing them). The title keeps no dimmer and stays click-through by design.

Pausing also now releases the lock and resuming takes it back. Pausing with P used to keep the
pointer captured — a RESUME button that could not be pointed at — while Escape looked fine only
because the browser drops the lock for you. Same key, two behaviours, no stated reason.

And losing the lock now drops the queued EDGES, not just held keys: the Space you pressed to
reach the RESUME button was still in the queue when the card went, so the run restarted by
spending a boost you did not ask for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 22:11:29 +10:00
parent 4e4311b568
commit 9482b8a2bf
3 changed files with 72 additions and 11 deletions

View File

@ -164,12 +164,9 @@ bus.on('ui:pause', (e) => { paused = !!e?.paused; });
let started = !player; let started = !player;
bus.on('ui:start', () => { started = true; }); bus.on('ui:start', () => { started = true; });
// PAUSE ON BLUR. The consumer above already works; nothing ever produced for it except the // PAUSE ON BLUR lives in ui/cards.js, not here. Emitting ui:pause from boot halted the loop
// pause key. Alt-tab during a fight and you come back to a dead ship, which is nobody's idea of // without raising the card that explains why, and left cards.js's own `paused` disagreeing with
// a fair death. // this one. One owner: cards.setPaused, which raises the card AND emits for us.
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", // 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 // so an `acid` level event names a target height and the sim eases toward it every step — a

View File

@ -47,14 +47,23 @@ export function createInput(dom = document.body) {
intent.aimDelta.y += e.movementY; intent.aimDelta.y += e.movementY;
}; };
const onMouseDown = (e) => { const onMouseDown = (e) => {
if (!intent.locked) { dom.requestPointerLock?.(); return; } // The `.catch` is not optional: requestPointerLock returns a PROMISE, and a browser that
// declines (document not focused; Chrome's ~1s lockout after the user leaves a lock with
// Escape) rejects it. An unhandled rejection reaches boot's window handler and paints a
// full-screen FEED LOST over a game that is running perfectly well. Declining a lock is
// routine; the player clicks again.
if (!intent.locked) { dom.requestPointerLock?.()?.catch?.(() => {}); return; }
if (e.button === 0) mouseFire = true; if (e.button === 0) mouseFire = true;
if (e.button === 2) fire2Edge = true; if (e.button === 2) fire2Edge = true;
}; };
const onMouseUp = (e) => { if (e.button === 0) mouseFire = false; }; const onMouseUp = (e) => { if (e.button === 0) mouseFire = false; };
const onLockChange = () => { const onLockChange = () => {
intent.locked = document.pointerLockElement === dom; intent.locked = document.pointerLockElement === dom;
if (!intent.locked) { mouseFire = false; keys.clear(); } // Losing the lock (alt-tab, Escape, a pause) drops the EDGES too, not just the held state.
// They were kept, so the Space you pressed to reach the RESUME button was still sitting in
// the queue when the card went, and the run started again by spending a boost you did not
// ask for. Same for a torpedo fired at a pause card.
if (!intent.locked) { mouseFire = false; keys.clear(); boostEdge = false; fire2Edge = false; }
}; };
const onContext = (e) => e.preventDefault(); // RMB is the torpedo, not a menu const onContext = (e) => e.preventDefault(); // RMB is the torpedo, not a menu

View File

@ -88,6 +88,14 @@ const CSS = `
nothing an opacity:0 overlay still composites every frame. */ nothing an opacity:0 overlay still composites every frame. */
.crd .scr { position:absolute; inset:0; display:none; align-items:center; justify-content:center; } .crd .scr { position:absolute; inset:0; display:none; align-items:center; justify-content:center; }
.crd .scr.on { display:flex; animation:crdIn .22s ease-out both; } .crd .scr.on { display:flex; animation:crdIn .22s ease-out both; }
/* A dimmed screen is MODAL, and pointer-events is what makes that true rather than decorative.
Without this, a click while paused fell through to the canvas, whose mousedown handler
(flight/input.js:50) re-requests pointer lock whenever it is not held so clicking next to
the RESUME button re-captured the pointer, hid the cursor, and left the player paused with
no pointer to press RESUME with. Our own dismiss handlers are capture-phase on window, so
they still see every click; only the canvas underneath stops seeing them. The TITLE has no
dimmer (it uses .tscrim) and is deliberately still click-through. */
.crd .scr.on .dimmer { pointer-events:auto; }
.crd .dimmer { position:absolute; inset:0; background:rgba(2,16,13,.62); .crd .dimmer { position:absolute; inset:0; background:rgba(2,16,13,.62);
backdrop-filter:blur(2.5px) saturate(.7); -webkit-backdrop-filter:blur(2.5px) saturate(.7); } backdrop-filter:blur(2.5px) saturate(.7); -webkit-backdrop-filter:blur(2.5px) saturate(.7); }
/* the "feed": static repeating gradient, painted once, never animated */ /* the "feed": static repeating gradient, painted once, never animated */
@ -249,6 +257,28 @@ const MEDAL_DOT = { platinum: C.surf, gold: C.warn, silver: C.ink, bronze: '#c88
* happens a handful of times is a leak hunt nobody asked for. A key with a null value is * happens a handful of times is a leak hunt nobody asked for. A key with a null value is
* REMOVED, which is how the difficulty toggle drops a stale ?diff= override. * REMOVED, which is how the difficulty toggle drops a stale ?diff= override.
*/ */
/**
* Take the pointer lock, and treat a refusal as the nothing it is.
*
* requestPointerLock() RETURNS A PROMISE in current browsers. A try/catch around it catches
* nothing, and the rejection surfaces as an `unhandledrejection` which boot.js, correctly,
* turns into a full-screen FEED LOST card. So the browser declining a lock for any of its many
* ordinary reasons (the document isn't focused, or Chrome's ~1s lockout after the user leaves
* a lock with Escape) presented to the player as the game crashing. Caught live, on the pause
* card, by a lock request that was refused: "the root document of this element is not valid
* for pointer lock" over the top of a game that was running perfectly well underneath.
*
* A refused lock costs the player one click on the canvas flight/input.js re-requests it from
* its own mousedown so there is nothing here worth telling anyone about.
*/
function grabPointer() {
try {
// The optional-call chain can yield undefined on browsers that predate the promise form
// (they throw synchronously instead), hence both the ?. and the try.
document.getElementById('game')?.querySelector('canvas')?.requestPointerLock?.()?.catch?.(() => {});
} catch { /* no lock; the player clicks and input.js takes it from there */ }
}
function go(patch) { function go(patch) {
const q = new URLSearchParams(location.search); const q = new URLSearchParams(location.search);
for (const [k, v] of Object.entries(patch)) { for (const [k, v] of Object.entries(patch)) {
@ -590,7 +620,20 @@ export function createCards({
if (v && (titleUp || debriefUp)) return; if (v && (titleUp || debriefUp)) return;
paused = v; paused = v;
pause.scr.classList.toggle('on', paused); pause.scr.classList.toggle('on', paused);
if (paused) { paintPause(); pause.resumeBtn.focus?.(); } // THE POINTER HAS TO EXIST FOR THE BUTTON TO MEAN ANYTHING. Pausing with P kept the pointer
// lock, so the cursor stayed captured and invisible — the card showed a RESUME button that
// could not be pointed at. (Pausing with Escape looked fine only because the BROWSER drops
// the lock for you, which is also why the two keys behaved differently for no stated reason.)
// Coming back, we ask for it again from inside the resume gesture, so mouse-look is live the
// instant the card goes; if the browser refuses — Chrome rate-limits a re-lock for about a
// second after an Escape exit — the canvas click path in input.js is still there as it was.
if (paused) {
if (document.pointerLockElement) document.exitPointerLock?.();
paintPause();
pause.resumeBtn.focus?.();
} else {
grabPointer();
}
bus.emit('ui:pause', { paused }); bus.emit('ui:pause', { paused });
} }
@ -745,8 +788,7 @@ export function createCards({
// here makes "click to fly" literally true however the card was dismissed. Must be inside a // here makes "click to fly" literally true however the card was dismissed. Must be inside a
// user gesture, which every dismissal path is. Wrapped: browsers reject the request if the // user gesture, which every dismissal path is. Wrapped: browsers reject the request if the
// document is not focused, and a rejected lock is not worth a thrown card. // document is not focused, and a rejected lock is not worth a thrown card.
try { document.getElementById('game')?.querySelector('canvas')?.requestPointerLock?.(); } grabPointer();
catch { /* no lock, keyboard still flies */ }
bus.emit('ui:start', {}); bus.emit('ui:start', {});
return true; return true;
}; };
@ -812,6 +854,17 @@ export function createCards({
addEventListener('keydown', onKey, true); addEventListener('keydown', onKey, true);
addEventListener('pointerdown', onPointer, true); addEventListener('pointerdown', onPointer, true);
// PAUSE ON BLUR, and it has to live HERE. This started in boot.js emitting ui:pause directly,
// which halted the loop and left this file's `paused` still false — so the game stopped with
// no card to say why, two owners disagreed about the pause state, and the next P press showed
// a card that then took a SECOND press to leave. The pause state has one owner: setPaused.
// It already refuses while the title or the debrief is up, so alt-tabbing away from a card
// does nothing, which is right.
const onAway = () => setPaused(true);
addEventListener('blur', onAway);
const onHidden = () => { if (document.hidden) onAway(); };
document.addEventListener('visibilitychange', onHidden);
// ---- bus ------------------------------------------------------------------------------- // ---- bus -------------------------------------------------------------------------------
const offs = []; const offs = [];
@ -859,6 +912,8 @@ export function createCards({
if (raf) { cancelAnimationFrame(raf); raf = 0; } if (raf) { cancelAnimationFrame(raf); raf = 0; }
removeEventListener('keydown', onKey, true); removeEventListener('keydown', onKey, true);
removeEventListener('pointerdown', onPointer, true); removeEventListener('pointerdown', onPointer, true);
removeEventListener('blur', onAway);
document.removeEventListener('visibilitychange', onHidden);
for (const off of offs) off(); for (const off of offs) off();
// If we disposed mid-pause, the loop is still holding for us — release it, or disposing // If we disposed mid-pause, the loop is still holding for us — release it, or disposing
// the UI freezes the game with no screen left to un-freeze it. // the UI freezes the game with no screen left to un-freeze it.