engine/input.ts

PURPOSE

DOM event plumbing for player control. Binds mouse, touch, keyboard, and gamepad events to the shared playerInput state singleton in core. Per-frame update() translates screen → world coordinates and writes ship.targetAngle for desktop mouse-aim. Ported from legacy 09a-input.js.

OWNS

  • Input._bound — idempotency flag on init() so listeners attach exactly once.
  • All DOM event listeners on #game canvas (mousedown, mouseup, mousemove, touchstart, touchmove, touchend, contextmenu).
  • Window-level keydown listener (Escape / R).
  • Gamepad polling logic (deadzone, left-stick → joystick angle/magnitude).

READS FROM

  • game.phase — gates update() (only runs during 'playing') and pause/restart key handling.
  • playerInput.isMobile — branches touch path between joystick mode and raw-pointer mode.
  • playerInput.joystickActive — bridge-mode sentinel; when true, update() skips angle override (the React bridge owns ship.targetAngle).
  • playerInput.touchStartX / touchStartY — joystick origin for delta math.
  • playerInput.mouseX / mouseY — current pointer in screen space (also written here).
  • ship.x / ship.y — for atan2 toward world-space cursor.
  • navigator.getGamepads() — first connected pad in pollGamepad().
  • require('../rendering/camera') — lazy require of Camera.toW() for screen-to-world conversion (try/catch fallback if module unavailable).

PUSHES TO

  • playerInput.isDown — true while mouse/touch is held.
  • playerInput.isThrusting — true while held (mouse/touch) or stick past deadzone (gamepad) or joystick delta > 10px (touch).
  • playerInput.mouseX / mouseY — last pointer screen coords.
  • playerInput.worldX / worldY — pointer in world space (desktop only).
  • playerInput.joystickActive — true on mobile touchstart, false on touchend.
  • playerInput.joystickAngle — atan2 of delta from touchStart (touch) or stick axes (gamepad).
  • playerInput.joystickMagnitude — clamped min(1, d/60) for touch; deadzone-rescaled (mag - dz) / (1 - dz) for gamepad.
  • ship.targetAngle — desktop mouse-aim only; bridge mode bypasses.
  • game.phase — Escape toggles 'playing' ↔ 'paused'.

DOES NOT

  • Fire weapons — fire input is consumed by the weapon system reading playerInput.isDown/isThrusting, not dispatched here.
  • Apply thrust — only flags isThrusting; player physics in engine/player integrates.
  • Handle restart on R — the keydown branch is currently a no-op stub (comment says “handled by game loop”).
  • Handle menu/UI input — the React shell owns menu nav; only Escape pause/unpause is wired.
  • Touch UI hit-testing — the bridge (setInput()) feeds joystick state from React UI when active.
  • Debounce or rising-edge gamepad buttons — fire and menu button handling is stubbed/commented out.

Signals

  • playerInput.joystickActive === true ⇒ bridge owns ship.targetAngle; Input.update() returns early after the phase check. This is the contract between raw DOM input and the React on-screen joystick.
  • Touch path always sets joystickActive = true on mobile touchstart, so the touch branch implicitly hands targetAngle control to whatever consumed the joystick fields — but input.ts itself never writes targetAngle from touch, only from desktop mouse.
  • e.preventDefault() is called on all touch events with { passive: false } to suppress scroll/zoom on mobile browsers.

Entry points

  • Input.init() — called once at engine boot to attach listeners. Idempotent via _bound flag. Returns early if #game canvas not in DOM.
  • Input.update() — called per frame from the main game loop. No-op unless game.phase === 'playing' and not in bridge/joystick mode.
  • Input.pollGamepad() — called per frame from the main game loop. No-op when no pad connected.

Pattern notes

  • Single module-level object literal (export const Input = { ... }) — no class, no instances. State lives in core’s playerInput, not here.
  • _bound guard makes init() safe to call from multiple boot paths (HMR, React mount).
  • Touch joystick is implemented as virtual stick at touch-down point: every touchstart resets touchStartX/Y, so the joystick recenters under each new finger placement. Magnitude saturates at 60px radius.
  • Desktop fallback uses lazy require() for Camera to avoid a circular import at module-load. Wrapped in try/catch; if camera isn’t loaded yet the frame silently skips angle update.
  • Gamepad deadzone is a flat 0.2 (dz local const, no named export). Beyond deadzone, magnitude is linearly rescaled to [0, 1] using (mag - dz) / (1 - dz).
  • Gamepad code reconstructs joystickAngle from rescaled axes ((ly/mag)*scale, (lx/mag)*scale) — algebraically equivalent to atan2(ly, lx) since the scale factor cancels in atan2. Verbose but matches the legacy port.
  • Comments mark out the fire-button and menu-button branches as future hooks; the gamepad poller only drives movement today.

EXTRACT-CANDIDATE

  • Bridge handoff contract. The joystickActive flag as an ownership sentinel for ship.targetAngle between Input.update() and bridge.setInput() is shared concept-level glue — also referenced in wiki/bridge.md. A single canonical page documenting “who owns targetAngle when” would prevent drift.
  • Deadzone / magnitude rescaling. The (mag - dz) / (1 - dz) formula is duplicated conceptually between touch (d/60 linear) and gamepad (deadzone-rescaled). If a third input source lands, extract a normalizeStick(magnitude, deadzone, saturation) helper rather than copying.
  • playerInput shape. Fields written here (worldX/Y, joystickActive, joystickAngle, joystickMagnitude, isThrusting, isDown, touchStartX/Y, isMobile) are the input contract for the whole engine; worth a canonical “playerInput schema” entry under wiki/code/engine/core.md rather than rediscovering from grep.