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>
158 lines
6.9 KiB
JavaScript
158 lines
6.9 KiB
JavaScript
// 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?.();
|
||
},
|
||
};
|
||
}
|