guts/web/js/flight/input.js
type-two 9482b8a2bf [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>
2026-07-26 22:11:29 +10:00

158 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// flight/input.js (Lane B) — raw devices -> one normalized intent object. No game logic
// here; player.js reads `intent` and never touches an event listener.
//
// The scheme is twin-stick, which is what makes the charter's "mouse-aim + WASD" and
// "gamepad twin-stick" the same game rather than two:
// left hand (WASD / left stick) -> move the ship inside the cross-section disc
// right hand (mouse / right stick) -> aim the reticle (the ship's nose follows it, lagged)
// Shift/Ctrl (or LT/RT) -> throttle, springs back to neutral when released
// Space / A / RB -> boost
// LMB / RT / X -> fire
// WASD is therefore NOT throttle. Throttle is a modifier because it lives in [0.6,1.4] and
// spends most of its life at 1.0 — it's a lean, not a stick.
const DEADZONE = 0.18;
const dz = (v) => (Math.abs(v) < DEADZONE ? 0 : (v - Math.sign(v) * DEADZONE) / (1 - DEADZONE));
export function createInput(dom = document.body) {
const keys = new Set();
const intent = {
disc: { x: 0, y: 0 }, // 1..1 desired disc movement (x = lateral/bin, y = up/nor)
aimDelta: { x: 0, y: 0 }, // mouse pixels accumulated since the last read (consumed)
aimStick: { x: 0, y: 0 }, // 1..1 right stick
throttle: 0, // 1..1 (Ctrl .. Shift)
boost: false, // edge-triggered: true for exactly one read
fire: false, // held
fire2: false, // edge-triggered
locked: false, // pointer lock state (the "am I flying" signal)
};
// Held inputs are tracked per-device and OR'd each sample. Sharing one flag would latch:
// the trigger sets it true, and only mouse-up ever sets it false again.
let mouseFire = false, padFire = false;
let boostEdge = false, fire2Edge = false, padBoostWas = false, padFire2Was = false;
const onKeyDown = (e) => {
keys.add(e.code);
if (e.code === 'Space') { boostEdge = true; e.preventDefault(); }
if (e.code === 'KeyF') fire2Edge = true;
};
const onKeyUp = (e) => keys.delete(e.code);
const onBlur = () => keys.clear(); // alt-tab must not leave a key stuck down
const onMouseMove = (e) => {
if (!intent.locked) return;
intent.aimDelta.x += e.movementX;
intent.aimDelta.y += e.movementY;
};
const onMouseDown = (e) => {
// 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;
// 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
addEventListener('keydown', onKeyDown);
addEventListener('keyup', onKeyUp);
addEventListener('blur', onBlur);
addEventListener('mousemove', onMouseMove);
dom.addEventListener('mousedown', onMouseDown);
addEventListener('mouseup', onMouseUp);
document.addEventListener('pointerlockchange', onLockChange);
dom.addEventListener('contextmenu', onContext);
function readPad() {
padFire = false;
const pads = navigator.getGamepads?.() || [];
for (const p of pads) {
if (!p?.connected) continue;
const [lx, ly, rx, ry] = p.axes;
// left stick wins over WASD only when actually deflected, so both can coexist
const dx = dz(lx ?? 0), dy = dz(ly ?? 0);
if (dx || dy) { intent.disc.x = dx; intent.disc.y = -dy; }
intent.aimStick.x = dz(rx ?? 0);
intent.aimStick.y = -dz(ry ?? 0);
const lt = p.buttons[6]?.value ?? 0, rt = p.buttons[7]?.value ?? 0;
if (lt > 0.05 || rt > 0.05) intent.throttle = rt - lt;
padFire = rt > 0.5;
const padBoost = !!(p.buttons[0]?.pressed || p.buttons[5]?.pressed);
if (padBoost && !padBoostWas) boostEdge = true;
padBoostWas = padBoost;
const padFire2 = !!p.buttons[2]?.pressed;
if (padFire2 && !padFire2Was) fire2Edge = true;
padFire2Was = padFire2;
return;
}
}
return {
intent,
// Called once per frame by player.js, before it reads `intent`.
sample() {
intent.disc.x = (keys.has('KeyD') ? 1 : 0) - (keys.has('KeyA') ? 1 : 0);
intent.disc.y = (keys.has('KeyW') ? 1 : 0) - (keys.has('KeyS') ? 1 : 0);
intent.aimStick.x = 0; intent.aimStick.y = 0;
intent.throttle = (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 1 : 0) -
(keys.has('ControlLeft') || keys.has('ControlRight') ? 1 : 0);
readPad();
intent.fire = mouseFire || padFire;
// normalize diagonal so corner-dodging isn't 41% faster than a cardinal one
const m = Math.hypot(intent.disc.x, intent.disc.y);
if (m > 1) { intent.disc.x /= m; intent.disc.y /= m; }
intent.boost = boostEdge; boostEdge = false;
intent.fire2 = fire2Edge; fire2Edge = false;
return intent;
},
// player.js consumes the accumulated mouse delta exactly once per frame
takeAim() {
const d = { x: intent.aimDelta.x, y: intent.aimDelta.y };
intent.aimDelta.x = 0; intent.aimDelta.y = 0;
return d;
},
// Harness/test hook (flight/dev.html). Keyboard intents can be driven with real
// KeyboardEvents, but firing is gated behind pointer lock, which an automated session
// can't be granted — so the trigger needs a door. Dev-only; nothing ships against it.
_debug: {
setFire(v) { mouseFire = !!v; },
pulseFire2() { fire2Edge = true; },
pulseBoost() { boostEdge = true; },
key(code, down = true) { down ? keys.add(code) : keys.delete(code); },
},
dispose() {
removeEventListener('keydown', onKeyDown);
removeEventListener('keyup', onKeyUp);
removeEventListener('blur', onBlur);
removeEventListener('mousemove', onMouseMove);
dom.removeEventListener('mousedown', onMouseDown);
removeEventListener('mouseup', onMouseUp);
document.removeEventListener('pointerlockchange', onLockChange);
dom.removeEventListener('contextmenu', onContext);
if (document.pointerLockElement === dom) document.exitPointerLock?.();
},
};
}