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 oninit()so listeners attach exactly once.- All DOM event listeners on
#gamecanvas (mousedown,mouseup,mousemove,touchstart,touchmove,touchend,contextmenu). - Window-level
keydownlistener (Escape / R). - Gamepad polling logic (deadzone, left-stick → joystick angle/magnitude).
READS FROM
game.phase— gatesupdate()(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 ownsship.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 inpollGamepad().require('../rendering/camera')— lazy require ofCamera.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 fromtouchStart(touch) or stick axes (gamepad).playerInput.joystickMagnitude— clampedmin(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 inengine/playerintegrates. - 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 ownsship.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 = trueon mobile touchstart, so the touch branch implicitly handstargetAnglecontrol to whatever consumed the joystick fields — butinput.tsitself never writestargetAnglefrom 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_boundflag. Returns early if#gamecanvas not in DOM.Input.update()— called per frame from the main game loop. No-op unlessgame.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 incore’splayerInput, not here. _boundguard makesinit()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()forCamerato 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 (
dzlocal const, no named export). Beyond deadzone, magnitude is linearly rescaled to [0, 1] using(mag - dz) / (1 - dz). - Gamepad code reconstructs
joystickAnglefrom rescaled axes ((ly/mag)*scale,(lx/mag)*scale) — algebraically equivalent toatan2(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
joystickActiveflag as an ownership sentinel forship.targetAnglebetweenInput.update()andbridge.setInput()is shared concept-level glue — also referenced inwiki/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/60linear) and gamepad (deadzone-rescaled). If a third input source lands, extract anormalizeStick(magnitude, deadzone, saturation)helper rather than copying. playerInputshape. 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 underwiki/code/engine/core.mdrather than rediscovering from grep.