From 4b9517def5d8d2b0135ecbe0562cefcd560fd84a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 14:32:39 +0000 Subject: [PATCH 1/3] feat(game): add Xbox controller support via Gamepad API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps Xbox controller inputs to existing game actions using the Standard Gamepad API layout: - A button → Fire (Space) - B button → Return to menu (Escape) - Start button → Start / pause / resume (Enter) - D-Pad Up → Thrust (ArrowUp) - D-Pad Left → Turn left (ArrowLeft) - D-Pad Right → Turn right (ArrowRight) - Left thumbstick X/Y → Turn left/right and thrust (0.5 deadzone) Gamepad state is tracked separately from keyboard state so neither input source can accidentally clear the other. pollGamepad() is called once per animation frame in updateFrame() before input is processed. https://claude.ai/code/session_01FJ5AiP5JUoWm8boiaxVU1g --- src/game/AsteroidsGame.ts | 1 + src/game/input.ts | 56 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/game/AsteroidsGame.ts b/src/game/AsteroidsGame.ts index ef2e2c7..0e20661 100644 --- a/src/game/AsteroidsGame.ts +++ b/src/game/AsteroidsGame.ts @@ -394,6 +394,7 @@ export class AsteroidsGame { } private updateFrame(timestampMs: number): void { + this.input.pollGamepad(); this.handleGlobalInput(); if (this.mode === "playing") { diff --git a/src/game/input.ts b/src/game/input.ts index d65d8d5..fd8140d 100644 --- a/src/game/input.ts +++ b/src/game/input.ts @@ -36,9 +36,25 @@ function isEditableTarget(target: EventTarget | null): boolean { return false; } +// Xbox controller button index → key code mapping (Standard Gamepad API layout) +const GAMEPAD_BUTTON_MAP: Array<[number, string]> = [ + [0, "Space"], // A → Fire + [1, "Escape"], // B → Return to menu + [9, "Enter"], // Start → Start / resume / pause + [12, "ArrowUp"], // D-Pad Up → Thrust + [14, "ArrowLeft"], // D-Pad Left → Turn left + [15, "ArrowRight"],// D-Pad Right → Turn right +]; + +// Thumbstick deadzone threshold +const GAMEPAD_AXIS_THRESHOLD = 0.5; + export class InputController { private readonly down = new Set(); private readonly pressed = new Set(); + // Tracks which codes are currently held via gamepad (kept separate from + // keyboard state so releasing one input source never clears the other). + private readonly gamepadDown = new Set(); handleKeyDown(event: KeyboardEvent): void { if (!GAME_KEYS.has(event.code)) { @@ -74,8 +90,45 @@ export class InputController { this.down.delete(event.code); } + /** + * Poll all connected gamepads and synthesize key presses/releases. + * Must be called once at the start of each animation frame. + */ + pollGamepad(): void { + if (typeof navigator === "undefined" || !navigator.getGamepads) return; + + const gamepads = navigator.getGamepads(); + const newDown = new Set(); + + for (const pad of gamepads) { + if (!pad?.connected) continue; + + for (const [idx, code] of GAMEPAD_BUTTON_MAP) { + if (pad.buttons[idx]?.pressed) newDown.add(code); + } + + // Left thumbstick axes (index 0 = X, index 1 = Y) + const lx = pad.axes[0] ?? 0; + const ly = pad.axes[1] ?? 0; + if (lx < -GAMEPAD_AXIS_THRESHOLD) newDown.add("ArrowLeft"); + if (lx > GAMEPAD_AXIS_THRESHOLD) newDown.add("ArrowRight"); + if (ly < -GAMEPAD_AXIS_THRESHOLD) newDown.add("ArrowUp"); + } + + // Newly pressed this frame → register in shared pressed set + for (const code of newDown) { + if (!this.gamepadDown.has(code)) { + this.pressed.add(code); + } + } + + // Update gamepadDown to reflect current hardware state + this.gamepadDown.clear(); + for (const code of newDown) this.gamepadDown.add(code); + } + isDown(code: string): boolean { - return this.down.has(code); + return this.down.has(code) || this.gamepadDown.has(code); } consumePress(code: string): boolean { @@ -91,5 +144,6 @@ export class InputController { reset(): void { this.down.clear(); this.pressed.clear(); + this.gamepadDown.clear(); } } From 1242c19229072a729026af2c8c11a6ac6060abd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 14:33:59 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(game):=20remap=20Xbox=20triggers=20?= =?UTF-8?q?=E2=80=94=20LT=20ignition,=20RT=20fire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LT (button 6) → thrust (ArrowUp) - RT (button 7) → fire (Space) - Remove A button fire binding (triggers are now primary) https://claude.ai/code/session_01FJ5AiP5JUoWm8boiaxVU1g --- src/game/input.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/game/input.ts b/src/game/input.ts index fd8140d..b3e208e 100644 --- a/src/game/input.ts +++ b/src/game/input.ts @@ -38,8 +38,9 @@ function isEditableTarget(target: EventTarget | null): boolean { // Xbox controller button index → key code mapping (Standard Gamepad API layout) const GAMEPAD_BUTTON_MAP: Array<[number, string]> = [ - [0, "Space"], // A → Fire [1, "Escape"], // B → Return to menu + [6, "ArrowUp"], // LT → Ignition (thrust) + [7, "Space"], // RT → Fire [9, "Enter"], // Start → Start / resume / pause [12, "ArrowUp"], // D-Pad Up → Thrust [14, "ArrowLeft"], // D-Pad Left → Turn left From f9e35e2c6783272b2c52f8530981beea7555662c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 14:45:25 +0000 Subject: [PATCH 3/3] fix(input): Start button now pauses during active gameplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter (mapped from gamepad Start) was only handled in menu/game-over and paused states. Add the playing→paused transition so controller-only users can pause mid-game, mirroring the existing KeyP behaviour. https://claude.ai/code/session_01FJ5AiP5JUoWm8boiaxVU1g --- src/game/AsteroidsGame.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/AsteroidsGame.ts b/src/game/AsteroidsGame.ts index 0e20661..68c22ce 100644 --- a/src/game/AsteroidsGame.ts +++ b/src/game/AsteroidsGame.ts @@ -480,6 +480,10 @@ export class AsteroidsGame { if (this.mode === "menu" || this.mode === "game-over") { this.audio.enable(); this.startNewGame(); + } else if (this.mode === "playing") { + this.mode = "paused"; + this.pauseFromHidden = false; + this.audio.pauseMusic(); } else if (this.mode === "paused") { this.mode = "playing"; this.pauseFromHidden = false;