diff --git a/web/js/boot.js b/web/js/boot.js index 8a8993a..4cbb35b 100644 --- a/web/js/boot.js +++ b/web/js/boot.js @@ -164,12 +164,9 @@ bus.on('ui:pause', (e) => { paused = !!e?.paused; }); 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(); }); +// PAUSE ON BLUR lives in ui/cards.js, not here. Emitting ui:pause from boot halted the loop +// without raising the card that explains why, and left cards.js's own `paused` disagreeing with +// this one. One owner: cards.setPaused, which raises the card AND emits for us. // 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 diff --git a/web/js/flight/input.js b/web/js/flight/input.js index 8a4a8f9..56a3b6f 100644 --- a/web/js/flight/input.js +++ b/web/js/flight/input.js @@ -47,14 +47,23 @@ export function createInput(dom = document.body) { intent.aimDelta.y += e.movementY; }; 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 === 2) fire2Edge = true; }; const onMouseUp = (e) => { if (e.button === 0) mouseFire = false; }; const onLockChange = () => { 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 diff --git a/web/js/ui/cards.js b/web/js/ui/cards.js index 913db4b..02164fe 100644 --- a/web/js/ui/cards.js +++ b/web/js/ui/cards.js @@ -88,6 +88,14 @@ const CSS = ` 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.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); backdrop-filter:blur(2.5px) saturate(.7); -webkit-backdrop-filter:blur(2.5px) saturate(.7); } /* 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 * 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) { const q = new URLSearchParams(location.search); for (const [k, v] of Object.entries(patch)) { @@ -590,7 +620,20 @@ export function createCards({ if (v && (titleUp || debriefUp)) return; paused = v; 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 }); } @@ -745,8 +788,7 @@ export function createCards({ // 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 // document is not focused, and a rejected lock is not worth a thrown card. - try { document.getElementById('game')?.querySelector('canvas')?.requestPointerLock?.(); } - catch { /* no lock, keyboard still flies */ } + grabPointer(); bus.emit('ui:start', {}); return true; }; @@ -812,6 +854,17 @@ export function createCards({ addEventListener('keydown', onKey, 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 ------------------------------------------------------------------------------- const offs = []; @@ -859,6 +912,8 @@ export function createCards({ if (raf) { cancelAnimationFrame(raf); raf = 0; } removeEventListener('keydown', onKey, true); removeEventListener('pointerdown', onPointer, true); + removeEventListener('blur', onAway); + document.removeEventListener('visibilitychange', onHidden); for (const off of offs) off(); // 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.