// 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) => { if (!intent.locked) { dom.requestPointerLock?.(); 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(); } }; 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?.(); }, }; }