From 0e2bad74b15e3ce96dd4134ca1694feaa3d1c105 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Thu, 7 May 2026 14:39:27 +0200 Subject: [PATCH] feat(input): implement gamepad input system with bitmask button api Add a full Gamepad API-backed input subsystem with per-frame snapshots, dead-zone-filtered axes, player connectivity helpers, and repeat-capable button edge detection. Migrate BTN_* constants to bit flags, merge keyboard+gamepad semantics for players 0/1, keep players 2/3 gamepad-only, and update tests/docs to reflect the new input contract. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Vaclav Vancura --- CLAUDE.md | 9 + README.md | 26 +- cspell.json | 1 + docs/input.md | 58 ++- src/BlitTech.test.ts | 113 ++++- src/BlitTech.ts | 303 ++++++++++--- src/core/BTAPI.test.ts | 40 ++ src/core/BTAPI.ts | 25 +- src/input/GamepadInput.test.ts | 143 +++++++ src/input/GamepadInput.ts | 723 ++++++++++++++++++++++++++++++++ src/input/KeyboardInput.test.ts | 8 +- src/input/defaultKeyboardMap.ts | 75 ++-- 12 files changed, 1391 insertions(+), 133 deletions(-) create mode 100644 src/input/GamepadInput.test.ts create mode 100644 src/input/GamepadInput.ts diff --git a/CLAUDE.md b/CLAUDE.md index a5d42d8..05085d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,15 @@ Dual WebGPU pipeline architecture: 5. **No `any` types** - use `unknown` or proper types 6. **Type-only imports** - `import type { ... }` for types +## Input Conventions + +- `BTN_*` constants are bit flags (powers of 2), not sequential integers +- `BT.buttonDown` / `BT.buttonPressed` / `BT.buttonReleased` use ANY-match semantics for masks +- Face buttons: players `0` and `1` are keyboard OR gamepad; players `2` and `3` are gamepad-only +- Input previous-state rollover is end-of-frame aligned (same snapshot model across pointer/keyboard/gamepad) +- Default gamepad stick dead zone is `0.75` +- Triggers are axis-only for now (`AXIS_TRIGGER_L` / `AXIS_TRIGGER_R`); trigger button constants are tracked in `VV-481` + ## Code Style - 4-space indent, 120-char line width diff --git a/README.md b/README.md index e6075db..c0a48ab 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,10 @@ primitives, and fonts. - **Pointer input**: mouse, touch, and pen unified under four pointer slots (`BT.pointerPos`, `BT.pointerDelta`, `BT.buttonDown` with `BTN_POINTER_A..D`); scroll delta, cursor hide/show, display-space coordinates - **Keyboard input**: raw keys (`BT.keyDown`, `BT.keyPressed`, `BT.keyReleased` using `KeyboardEvent.code`), virtual - face buttons for players 0–1 (`BT.buttonDown` with `BTN_UP`…`BTN_SELECT`), built-in default maps, remapping via - `BT.inputMap` / `BT.inputMapReset`, and text accumulation via `BT.inputString` + face buttons (`BT.buttonDown` with `BTN_UP`…`BTN_SELECT`) with keyboard+gamepad merge for players 0–1, built-in + default maps, remapping via `BT.inputMap` / `BT.inputMapReset`, and text accumulation via `BT.inputString` +- **Gamepad input**: up to four players via standard Gamepad API (`BT.gamepadConnected`, `BT.gamepadCount`, + `BT.getAxis`), with stick dead zone and face-button support through `BT.button*` - **Fixed timestep**: deterministic 60 FPS loop with tick counter and optional dropped-frame detection - **Clean API**: all engine access through the `BT` namespace - **Display scaling**: optional CSS upscaling via `canvasDisplaySize` for crisp pixel art @@ -80,8 +82,8 @@ Additional documentation is available in the `docs/` directory: `BloomEffect`, writing custom effects, and shader attribution - **[Bitmap Fonts Guide](docs/bitmap-fonts.md)** — Built-in system font, `.btfont` format spec, BMFont conversion, and font rendering API -- **[Input Guide](docs/input.md)** — Pointer and keyboard input, slot model, face-button maps, remapping, scroll delta, - and cursor control +- **[Input Guide](docs/input.md)** — Pointer, keyboard, and gamepad input; slot model; button masks; remapping; axis + constants; scroll delta; and cursor control - **[Developer Experience Guide](docs/developer-experience-guide.md)** — Development workflow and tooling (roadmap) ## Scripts @@ -563,20 +565,24 @@ BT.keyReleased('Escape'); // release edge BT.inputString(); // text since last frame (filtered `beforeinput`) ``` -Face buttons (`BT.BTN_UP` through `BT.BTN_SELECT`) for **players 0 and 1** read mapped keys with OR semantics. Defaults -match `BT.DEFAULT_KEYBOARD_PLAYER1` and `BT.DEFAULT_KEYBOARD_PLAYER2`. Remap at runtime: +Face buttons (`BT.BTN_UP` through `BT.BTN_SELECT`) are bit flags. For **players 0 and 1**, `BT.button*` merges keyboard +maps and gamepad state (OR semantics). For **players 2 and 3**, `BT.button*` uses gamepad state only. Defaults match +`BT.DEFAULT_KEYBOARD_PLAYER1` and `BT.DEFAULT_KEYBOARD_PLAYER2`. Remap at runtime: ```ts BT.inputMap(0, BT.BTN_UP, 'ArrowUp', 'KeyW'); BT.inputMapReset(); // restore built-in defaults for both keyboard players ``` -Players **2** and **3** have no keyboard mapping for face buttons yet (`BT.buttonDown(BTN_*, 2|3)` stays `false` until -gamepad support lands). - #### Gamepad -Gamepad input for face buttons on players 2 and 3 (and any future pad-backed players) is not implemented yet. +```ts +BT.gamepadConnected(BT.PLAYER_ONE); +BT.gamepadCount(); + +BT.getAxis(BT.AXIS_LEFT_X, BT.PLAYER_ONE); // -1.0..1.0 (dead-zone filtered) +BT.getAxis(BT.AXIS_TRIGGER_L, BT.PLAYER_ONE); // 0.0..1.0 +``` ## Browser Compatibility diff --git a/cspell.json b/cspell.json index 9b9bb1c..b5744a8 100644 --- a/cspell.json +++ b/cspell.json @@ -4,6 +4,7 @@ "allowCompoundWords": true, "dictionaries": ["typescript", "node", "html", "css", "fonts", "npm", "softwareTerms"], "words": [ + "ABXY", "ABGR", "antialiasing", "bgra", diff --git a/docs/input.md b/docs/input.md index b197921..421ebaa 100644 --- a/docs/input.md +++ b/docs/input.md @@ -1,8 +1,8 @@ # Input Guide Blit-Tech provides DOM-backed input: **pointer** (mouse, touch, pen), **keyboard** (`KeyboardEvent.code` tracking and -virtual face buttons for two players), and **text accumulation** for UI entry (`BT.inputString()`). Gamepad support for -face buttons on players 2 and 3 is not implemented yet. +virtual face buttons), **gamepad** (up to four players via `navigator.getGamepads()`), and **text accumulation** for UI +entry (`BT.inputString()`). All pointer coordinates are returned in logical display space (the `displaySize` configured in `queryHardware()`), independent of the canvas's CSS or backing-buffer size. @@ -115,19 +115,21 @@ if (BT.keyReleased('Escape')) { } ``` -### Face buttons (players 0 and 1) +### Face buttons (`BTN_UP` through `BTN_SELECT`) -Constants `BT.BTN_UP` through `BT.BTN_SELECT` map to directional pad, action buttons, shoulders, start, and select. For -**player 0** and **player 1**, `BT.buttonDown` / `BT.buttonPressed` / `BT.buttonReleased` use per-player keyboard maps: -each logical button is true if **any** mapped key is active (OR semantics). +Face button constants are bit flags. You can pass a single button or a combined mask; matching uses **ANY** semantics: +`BT.buttonDown(BT.BTN_A | BT.BTN_B)` is true when either A or B is down. -For mapped face buttons, `BT.buttonPressed` is edge-only (no repeat interval parameter). Use -`BT.keyPressed(code, repeatRate)` when you need tick-based repeat behavior. +For **player 0** and **player 1**, face-button reads merge keyboard maps and gamepad state (logical OR). For **player +2** and **player 3**, face-button reads use gamepad only. + +`BT.buttonPressed` supports optional tick-based repeat via `repeatRate` (`0` or omitted = edge only), matching +`BT.keyPressed` semantics. ```ts // Player 0 (default: WASD-style + Space / KeyB for A, etc.) BT.buttonDown(BT.BTN_UP, 0); -BT.buttonPressed(BT.BTN_A, 0); +BT.buttonPressed(BT.BTN_A, 0, 6); // edge + repeat every 6 ticks while held // Player 1 (default: arrow keys + alternate bindings) BT.buttonDown(BT.BTN_LEFT, 1); @@ -158,11 +160,30 @@ Pass **no** key codes to clear keyboard bindings for that player and button unti BT.inputMap(0, BT.BTN_X); // player 0 X has no keyboard keys until remapped ``` -### Players 2 and 3 +### Gamepad API + +```ts +BT.gamepadConnected(0); // true when player 0 has a connected gamepad +BT.gamepadCount(); // number of connected gamepads (0..4) + +BT.getAxis(BT.AXIS_LEFT_X, 0); // -1.0 .. 1.0 (dead-zone filtered) +BT.getAxis(BT.AXIS_TRIGGER_L, 0); // 0.0 .. 1.0 +``` + +Axis constants: + +- `AXIS_LEFT_X`, `AXIS_LEFT_Y` +- `AXIS_RIGHT_X`, `AXIS_RIGHT_Y` +- `AXIS_TRIGGER_L`, `AXIS_TRIGGER_R` + +Player constants: + +- `PLAYER_ONE` (`0`) +- `PLAYER_TWO` (`1`) +- `PLAYER_THREE` (`2`) +- `PLAYER_FOUR` (`3`) -There is **no** keyboard fallback for face buttons on players 2 and 3; `BT.buttonDown(BTN_*, 2)` and `..., 3)` stay -`false` until gamepad support exists. Pointer buttons (`BTN_POINTER_*`) still use the second argument as the pointer -slot index (0–3), not a gamepad player index. +Default dead zone for analog sticks is `0.75` (`GamepadInput.DEFAULT_GAMEPAD_DEAD_ZONE`). ### Text input buffer @@ -210,6 +231,8 @@ means: - `pointerDelta()` reflects movement between the last `update()` and the current one. - Keyboard-held keys and edges follow the same end-of-frame snapshot timing as pointer input (`KeyboardInput.endFrame` aligns with pointer flush). +- Gamepad previous-state rollover also happens at end-of-frame, while current gamepad state is polled from the Gamepad + API during button/axis queries. - `buttonPressed()` / `buttonReleased()` edges are never lost even when a press and release both arrive in the same inter-frame gap (they appear as pressed-then-released across consecutive frames). @@ -238,10 +261,11 @@ Coordinates are clamped to `[0, displaySize - 1]` on each axis. The conversion i ## Implementation Notes -- `PointerInput` and `KeyboardInput` are internal; import from `blit-tech` and access through `BT.*` methods. +- `PointerInput`, `KeyboardInput`, and `GamepadInput` are internal; import from `blit-tech` and access through `BT.*` + methods. - Default keyboard tables live in `defaultKeyboardMap.ts`; runtime remaps are stored in the `BT` facade and reset with `BT.inputMapReset()`. - The `POINTER_SLOT_COUNT` constant (value `4`) is exported for demos that iterate over slots. -- `PointerInput` and `KeyboardInput` are created and `attach()`-ed inside `BTAPI.initialize()`, so they are ready before - `demo.initialize()` runs. -- `stop()` calls `detach()` on both subsystems and clears references to prevent DOM listener leaks. +- `PointerInput`, `KeyboardInput`, and `GamepadInput` are created and attached inside `BTAPI.initialize()`, so they are + ready before `demo.initialize()` runs. +- `stop()` calls `detach()` on all three input subsystems and clears references to prevent listener leaks. diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 40dfefe..0647ff6 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -349,7 +349,7 @@ describe('BT.buttonDown', () => { vi.restoreAllMocks(); }); - it('returns false for face buttons when keyboard is unavailable', () => { + it('returns false for face buttons when keyboard and gamepad are unavailable', () => { expect(BT.buttonDown(BT.BTN_A)).toBe(false); }); @@ -362,7 +362,16 @@ describe('BT.buttonDown', () => { vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ isButtonDown } as never); expect(BT.buttonDown(BT.BTN_POINTER_A, 0)).toBe(true); - expect(isButtonDown).toHaveBeenCalledWith(BT.BTN_POINTER_A, 0); + expect(isButtonDown).toHaveBeenCalledWith(20, 0); + }); + + it('uses ANY semantics for combined button masks', () => { + const isButtonDown = vi.fn().mockImplementation((codes: readonly string[]) => codes.includes('KeyA')); + vi.spyOn(BTAPI.instance, 'getKeyboard').mockReturnValue({ isButtonDown } as never); + + BT.inputMap(0, BT.BTN_A, 'KeyA'); + + expect(BT.buttonDown(BT.BTN_A | BT.BTN_B, 0)).toBe(true); }); it('returns false for pointer buttons when the engine is not initialized', () => { @@ -378,7 +387,7 @@ describe('BT.buttonPressed', () => { vi.restoreAllMocks(); }); - it('returns false for face buttons when keyboard is unavailable', () => { + it('returns false for face buttons when keyboard and gamepad are unavailable', () => { expect(BT.buttonPressed(BT.BTN_B)).toBe(false); }); @@ -391,7 +400,20 @@ describe('BT.buttonPressed', () => { vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ isButtonPressed } as never); expect(BT.buttonPressed(BT.BTN_POINTER_B, 0)).toBe(true); - expect(isButtonPressed).toHaveBeenCalledWith(BT.BTN_POINTER_B, 0); + expect(isButtonPressed).toHaveBeenCalledWith(21, 0); + }); + + it('forwards repeatRate to keyboard/gamepad face-button queries', () => { + vi.spyOn(BTAPI.instance, 'getTicks').mockReturnValue(120); + const isButtonPressedKeyboard = vi.fn().mockReturnValue(false); + const isButtonPressedGamepad = vi.fn().mockReturnValue(true); + vi.spyOn(BTAPI.instance, 'getKeyboard').mockReturnValue({ isButtonPressed: isButtonPressedKeyboard } as never); + vi.spyOn(BTAPI.instance, 'getGamepad').mockReturnValue({ isButtonPressed: isButtonPressedGamepad } as never); + + BT.buttonPressed(BT.BTN_A, 0, 6); + + expect(isButtonPressedKeyboard).toHaveBeenCalledWith(expect.any(Array), 6, 120); + expect(isButtonPressedGamepad).toHaveBeenCalledWith(BT.BTN_A, 0, 6, 120); }); }); @@ -413,7 +435,27 @@ describe('BT.buttonReleased', () => { vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ isButtonReleased } as never); expect(BT.buttonReleased(BT.BTN_POINTER_C, 2)).toBe(true); - expect(isButtonReleased).toHaveBeenCalledWith(BT.BTN_POINTER_C, 2); + expect(isButtonReleased).toHaveBeenCalledWith(22, 2); + }); + + it('merges keyboard and gamepad for players 0 and 1', () => { + const isButtonReleasedKeyboard = vi.fn().mockReturnValue(false); + const isButtonReleased = vi.fn().mockReturnValue(true); + vi.spyOn(BTAPI.instance, 'getKeyboard').mockReturnValue({ + isButtonReleased: isButtonReleasedKeyboard, + } as never); + vi.spyOn(BTAPI.instance, 'getGamepad').mockReturnValue({ isButtonReleased } as never); + + expect(BT.buttonReleased(BT.BTN_A, 1)).toBe(true); + expect(isButtonReleased).toHaveBeenCalledWith(BT.BTN_A, 1); + }); + + it('uses gamepad for player 2+ when keyboard maps are unavailable', () => { + const isButtonDown = vi.fn().mockReturnValue(true); + vi.spyOn(BTAPI.instance, 'getGamepad').mockReturnValue({ isButtonDown } as never); + + expect(BT.buttonDown(BT.BTN_START, 2)).toBe(true); + expect(isButtonDown).toHaveBeenCalledWith(BT.BTN_START, 2); }); }); @@ -470,10 +512,14 @@ describe('BT.inputMap / BT.inputMapReset', () => { BT.inputMapReset(); BT.buttonDown(BT.BTN_UP, 0); - expect(isButtonDown).toHaveBeenCalledWith([...BT.DEFAULT_KEYBOARD_PLAYER1[BT.BTN_UP as FaceButtonCode]]); + expect(isButtonDown).toHaveBeenCalledWith([ + ...(BT.DEFAULT_KEYBOARD_PLAYER1[BT.BTN_UP as FaceButtonCode] ?? []), + ]); BT.buttonDown(BT.BTN_UP, 1); - expect(isButtonDown).toHaveBeenCalledWith([...BT.DEFAULT_KEYBOARD_PLAYER2[BT.BTN_UP as FaceButtonCode]]); + expect(isButtonDown).toHaveBeenCalledWith([ + ...(BT.DEFAULT_KEYBOARD_PLAYER2[BT.BTN_UP as FaceButtonCode] ?? []), + ]); }); it('ignores remap attempts for unsupported keyboard players', () => { @@ -483,7 +529,7 @@ describe('BT.inputMap / BT.inputMapReset', () => { BT.inputMap(2, BT.BTN_A, 'KeyZ'); BT.buttonDown(BT.BTN_A, 0); - expect(isButtonDown).toHaveBeenCalledWith([...BT.DEFAULT_KEYBOARD_PLAYER1[BT.BTN_A as FaceButtonCode]]); + expect(isButtonDown).toHaveBeenCalledWith([...(BT.DEFAULT_KEYBOARD_PLAYER1[BT.BTN_A as FaceButtonCode] ?? [])]); }); it('ignores remap attempts with out-of-range face button ids', () => { @@ -493,7 +539,7 @@ describe('BT.inputMap / BT.inputMapReset', () => { BT.inputMap(0, 99, 'KeyZ'); BT.buttonDown(BT.BTN_A, 0); - expect(isButtonDown).toHaveBeenCalledWith([...BT.DEFAULT_KEYBOARD_PLAYER1[BT.BTN_A as FaceButtonCode]]); + expect(isButtonDown).toHaveBeenCalledWith([...(BT.DEFAULT_KEYBOARD_PLAYER1[BT.BTN_A as FaceButtonCode] ?? [])]); }); it('allows clearing keyboard bindings with an empty key list', () => { @@ -614,11 +660,11 @@ describe('BT.pointerScrollDelta', () => { // #region Pointer button constants describe('Pointer button constants', () => { - it('exposes BTN_POINTER_A..D as 20-23', () => { - expect(BT.BTN_POINTER_A).toBe(20); - expect(BT.BTN_POINTER_B).toBe(21); - expect(BT.BTN_POINTER_C).toBe(22); - expect(BT.BTN_POINTER_D).toBe(23); + it('exposes BTN_POINTER_A..D as bit flags', () => { + expect(BT.BTN_POINTER_A).toBe(1 << 12); + expect(BT.BTN_POINTER_B).toBe(1 << 13); + expect(BT.BTN_POINTER_C).toBe(1 << 14); + expect(BT.BTN_POINTER_D).toBe(1 << 15); }); }); @@ -626,6 +672,45 @@ describe('Pointer button constants', () => { // #region BT.keyDown / BT.keyPressed / BT.keyReleased +describe('BT gamepad constants and APIs', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exposes player constants', () => { + expect(BT.PLAYER_ONE).toBe(0); + expect(BT.PLAYER_TWO).toBe(1); + expect(BT.PLAYER_THREE).toBe(2); + expect(BT.PLAYER_FOUR).toBe(3); + }); + + it('exposes axis constants', () => { + expect(BT.AXIS_LEFT_X).toBe(0); + expect(BT.AXIS_LEFT_Y).toBe(1); + expect(BT.AXIS_RIGHT_X).toBe(2); + expect(BT.AXIS_RIGHT_Y).toBe(3); + expect(BT.AXIS_TRIGGER_L).toBe(4); + expect(BT.AXIS_TRIGGER_R).toBe(5); + }); + + it('exposes combo constants', () => { + expect(BT.BTN_ABXY).toBe(BT.BTN_A | BT.BTN_B | BT.BTN_X | BT.BTN_Y); + expect(BT.BTN_SHOULDER).toBe(BT.BTN_L | BT.BTN_R); + expect(BT.BTN_POINTER_ANY).toBe(BT.BTN_POINTER_A | BT.BTN_POINTER_B | BT.BTN_POINTER_C | BT.BTN_POINTER_D); + }); + + it('delegates getAxis/gamepadConnected/gamepadCount to gamepad subsystem', () => { + const getAxis = vi.fn().mockReturnValue(0.25); + const isConnected = vi.fn().mockReturnValue(true); + const connectedCount = vi.fn().mockReturnValue(2); + vi.spyOn(BTAPI.instance, 'getGamepad').mockReturnValue({ getAxis, isConnected, connectedCount } as never); + + expect(BT.getAxis(BT.AXIS_LEFT_X, 1)).toBe(0.25); + expect(BT.gamepadConnected(1)).toBe(true); + expect(BT.gamepadCount()).toBe(2); + }); +}); + describe('BT.keyDown', () => { it('returns false when the engine is not initialized', () => { expect(BT.keyDown('Space')).toBe(false); diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 50057e7..ea9cb26 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -21,6 +21,7 @@ import { createDefaultKeyboardRuntimeMaps, DEFAULT_KEYBOARD_PLAYER1, DEFAULT_KEYBOARD_PLAYER2, + FACE_BUTTON_FLAGS, type FaceButtonCode, } from './input/defaultKeyboardMap'; import { BarrelDistortion } from './render/effects/display/BarrelDistortion'; @@ -52,6 +53,12 @@ let keyboardFaceButtonKeysPlayer0: Map; /** Runtime face-button → key-code lists for keyboard player 1 (mutable via {@link BT.inputMap}). */ let keyboardFaceButtonKeysPlayer1: Map; +/** Pointer button bit mask (`BTN_POINTER_A..D`). */ +const POINTER_BUTTON_MASK = (1 << 12) | (1 << 13) | (1 << 14) | (1 << 15); + +/** Face button bit mask (`BTN_UP..BTN_SELECT`). */ +const FACE_BUTTON_MASK = (1 << 12) - 1; + /** * Replaces runtime keyboard maps with fresh copies of {@link DEFAULT_KEYBOARD_PLAYER1} / * {@link DEFAULT_KEYBOARD_PLAYER2}. @@ -74,7 +81,7 @@ resetKeyboardFaceButtonMaps(); * @returns Key codes for that mapping, or `null` if unsupported. */ function faceButtonKeys(button: number, player: number): readonly string[] | null { - if (button < 0 || button > 11) { + if (!FACE_BUTTON_FLAGS.includes(button as FaceButtonCode)) { return null; } @@ -89,6 +96,27 @@ function faceButtonKeys(button: number, player: number): readonly string[] | nul return null; } +/** + * Maps a single pointer button bit flag to the pointer subsystem button code. + * + * @param pointerFlag - One pointer button bit from `BTN_POINTER_A..D`. + * @returns Pointer subsystem button code (`20..23`) or `null` if not a pointer button. + */ +function pointerFlagToPointerCode(pointerFlag: number): number | null { + switch (pointerFlag) { + case 1 << 12: + return 20; + case 1 << 13: + return 21; + case 1 << 14: + return 22; + case 1 << 15: + return 23; + default: + return null; + } +} + // #region Public API /** Main Blit-Tech API namespace used by runtime demos. */ @@ -114,48 +142,48 @@ export const BT = { // #region Constants - Button Codes - /** Up button code. */ - BTN_UP: 0, + /** Up button bit flag. */ + BTN_UP: 1 << 0, - /** Down button code. */ - BTN_DOWN: 1, + /** Down button bit flag. */ + BTN_DOWN: 1 << 1, - /** Left button code. */ - BTN_LEFT: 2, + /** Left button bit flag. */ + BTN_LEFT: 1 << 2, - /** Right button code. */ - BTN_RIGHT: 3, + /** Right button bit flag. */ + BTN_RIGHT: 1 << 3, - /** A button code. */ - BTN_A: 4, + /** A button bit flag. */ + BTN_A: 1 << 4, - /** B button code. */ - BTN_B: 5, + /** B button bit flag. */ + BTN_B: 1 << 5, - /** X button code. */ - BTN_X: 6, + /** X button bit flag. */ + BTN_X: 1 << 6, - /** Y button code. */ - BTN_Y: 7, + /** Y button bit flag. */ + BTN_Y: 1 << 7, - /** Left shoulder button code. */ - BTN_L: 8, + /** Left shoulder button bit flag. */ + BTN_L: 1 << 8, - /** Right shoulder button code. */ - BTN_R: 9, + /** Right shoulder button bit flag. */ + BTN_R: 1 << 9, - /** Start button code. */ - BTN_START: 10, + /** Start button bit flag. */ + BTN_START: 1 << 10, - /** Select button code. */ - BTN_SELECT: 11, + /** Select button bit flag. */ + BTN_SELECT: 1 << 11, /** * Primary pointer button code. * * Maps to mouse left for slot 0; touch contact for slots 1-3. */ - BTN_POINTER_A: 20, + BTN_POINTER_A: 1 << 12, /** * Secondary pointer button code. @@ -164,7 +192,7 @@ export const BT = { * DOM `PointerEvent.button` index where 1 is middle and 2 is right). * Always `false` for touch slots 1-3. */ - BTN_POINTER_B: 21, + BTN_POINTER_B: 1 << 13, /** * Tertiary pointer button code. @@ -173,7 +201,7 @@ export const BT = { * DOM `PointerEvent.button` index where 1 is middle and 2 is right). * Always `false` for touch slots 1-3. */ - BTN_POINTER_C: 22, + BTN_POINTER_C: 1 << 14, /** * Auxiliary pointer button code. @@ -181,7 +209,46 @@ export const BT = { * Maps to mouse back/forward extra buttons (DOM `PointerEvent.button` * 3 or 4) for slot 0. Always `false` for touch slots 1-3. */ - BTN_POINTER_D: 23, + BTN_POINTER_D: 1 << 15, + + /** Player one index. */ + PLAYER_ONE: 0, + + /** Player two index. */ + PLAYER_TWO: 1, + + /** Player three index. */ + PLAYER_THREE: 2, + + /** Player four index. */ + PLAYER_FOUR: 3, + + /** Left stick horizontal axis index. */ + AXIS_LEFT_X: 0, + + /** Left stick vertical axis index. */ + AXIS_LEFT_Y: 1, + + /** Right stick horizontal axis index. */ + AXIS_RIGHT_X: 2, + + /** Right stick vertical axis index. */ + AXIS_RIGHT_Y: 3, + + /** Left trigger axis index (0.0 to 1.0). */ + AXIS_TRIGGER_L: 4, + + /** Right trigger axis index (0.0 to 1.0). */ + AXIS_TRIGGER_R: 5, + + /** All face buttons (A/B/X/Y). */ + BTN_ABXY: (1 << 4) | (1 << 5) | (1 << 6) | (1 << 7), + + /** Both shoulder buttons. */ + BTN_SHOULDER: (1 << 8) | (1 << 9), + + /** Any pointer button (A/B/C/D). */ + BTN_POINTER_ANY: (1 << 12) | (1 << 13) | (1 << 14) | (1 << 15), /** * Default `KeyboardEvent.code` values for player 1 face buttons (VV-435). @@ -669,29 +736,57 @@ export const BT = { * (matches RetroBlit canonical, not DOM `PointerEvent.button` index). * Touch / pen slots only support `A`; B/C/D return `false`. * - * For `BTN_UP`…`BTN_SELECT`, players `0` and `1` use the runtime keyboard maps - * (defaults match `BT.DEFAULT_KEYBOARD_PLAYER1` / `BT.DEFAULT_KEYBOARD_PLAYER2`; - * customize with {@link BT.inputMap}). Players `2` and `3` have no keyboard - * mapping (gamepad when VV-135 lands). - * Pointer codes (`BTN_POINTER_*`) use the `player` argument as the pointer slot. + * `button` accepts one or more bit flags from the `BTN_*` set (for example + * `BT.BTN_A | BT.BTN_B`). Matching uses ANY semantics: returns `true` when + * any selected button is held. + * + * For face buttons (`BTN_UP`…`BTN_SELECT`), players `0` and `1` merge keyboard + * and gamepad input (logical OR). Players `2` and `3` use gamepad only. + * Pointer flags (`BTN_POINTER_*`) use the `player` argument as pointer slot. * * @param button - Button constant from the `BTN_*` set. * @param player - Zero-based player index for gamepads / keyboard, or pointer slot * (0-3) for `BTN_POINTER_*`. * @returns `true` while the button remains pressed. */ + // eslint-disable-next-line complexity -- explicit per-flag routing keeps input semantics easy to audit. buttonDown: (button: number, player: number = 0): boolean => { - if (button >= 20 && button <= 23) { - return BTAPI.instance.getPointer()?.isButtonDown(button, player) ?? false; + if (!Number.isInteger(button) || button <= 0) { + return false; } - const keys = faceButtonKeys(button, player); + const pointerMask = button & POINTER_BUTTON_MASK; + + if (pointerMask !== 0) { + for (const pointerFlag of [1 << 12, 1 << 13, 1 << 14, 1 << 15]) { + if ((pointerMask & pointerFlag) === 0) { + continue; + } + + const pointerCode = pointerFlagToPointerCode(pointerFlag); + + if (pointerCode !== null && (BTAPI.instance.getPointer()?.isButtonDown(pointerCode, player) ?? false)) { + return true; + } + } + } + + const faceMask = button & FACE_BUTTON_MASK; + + for (const faceButton of FACE_BUTTON_FLAGS) { + if ((faceMask & faceButton) === 0) { + continue; + } + + const keyboardMatch = + BTAPI.instance.getKeyboard()?.isButtonDown(faceButtonKeys(faceButton, player) ?? []) ?? false; + const gamepadMatch = BTAPI.instance.getGamepad()?.isButtonDown(faceButton, player) ?? false; - if (keys) { - return BTAPI.instance.getKeyboard()?.isButtonDown(keys) ?? false; + if (keyboardMatch || gamepadMatch) { + return true; + } } - // TODO: Implement gamepad input (VV-135). return false; }, @@ -704,22 +799,54 @@ export const BT = { * @param button - Button constant from the `BTN_*` set. * @param player - Zero-based player index for gamepads, or pointer slot * (0-3) for `BTN_POINTER_*`. + * @param repeatRate - Optional repeat interval in fixed ticks (`0`/omitted = edge only). * @returns `true` on the transition frame. */ - buttonPressed: (button: number, player: number = 0): boolean => { - if (button >= 20 && button <= 23) { - return BTAPI.instance.getPointer()?.isButtonPressed(button, player) ?? false; + // eslint-disable-next-line complexity -- explicit per-flag routing keeps input semantics easy to audit. + buttonPressed: (button: number, player: number = 0, repeatRate?: number): boolean => { + if (!Number.isInteger(button) || button <= 0) { + return false; } - const keys = faceButtonKeys(button, player); + const pointerMask = button & POINTER_BUTTON_MASK; - if (keys) { - const tick = BTAPI.instance.getTicks(); + if (pointerMask !== 0) { + for (const pointerFlag of [1 << 12, 1 << 13, 1 << 14, 1 << 15]) { + if ((pointerMask & pointerFlag) === 0) { + continue; + } - return BTAPI.instance.getKeyboard()?.isButtonPressed(keys, undefined, tick) ?? false; + const pointerCode = pointerFlagToPointerCode(pointerFlag); + + if ( + pointerCode !== null && + (BTAPI.instance.getPointer()?.isButtonPressed(pointerCode, player) ?? false) + ) { + return true; + } + } + } + + const faceMask = button & FACE_BUTTON_MASK; + const tick = BTAPI.instance.getTicks(); + + for (const faceButton of FACE_BUTTON_FLAGS) { + if ((faceMask & faceButton) === 0) { + continue; + } + + const keyboardMatch = + BTAPI.instance + .getKeyboard() + ?.isButtonPressed(faceButtonKeys(faceButton, player) ?? [], repeatRate, tick) ?? false; + const gamepadMatch = + BTAPI.instance.getGamepad()?.isButtonPressed(faceButton, player, repeatRate, tick) ?? false; + + if (keyboardMatch || gamepadMatch) { + return true; + } } - // TODO: Implement gamepad input (VV-135). return false; }, @@ -734,18 +861,47 @@ export const BT = { * (0-3) for `BTN_POINTER_*`. * @returns `true` on the release frame. */ + // eslint-disable-next-line complexity -- explicit per-flag routing keeps input semantics easy to audit. buttonReleased: (button: number, player: number = 0): boolean => { - if (button >= 20 && button <= 23) { - return BTAPI.instance.getPointer()?.isButtonReleased(button, player) ?? false; + if (!Number.isInteger(button) || button <= 0) { + return false; + } + + const pointerMask = button & POINTER_BUTTON_MASK; + + if (pointerMask !== 0) { + for (const pointerFlag of [1 << 12, 1 << 13, 1 << 14, 1 << 15]) { + if ((pointerMask & pointerFlag) === 0) { + continue; + } + + const pointerCode = pointerFlagToPointerCode(pointerFlag); + + if ( + pointerCode !== null && + (BTAPI.instance.getPointer()?.isButtonReleased(pointerCode, player) ?? false) + ) { + return true; + } + } } - const keys = faceButtonKeys(button, player); + const faceMask = button & FACE_BUTTON_MASK; + + for (const faceButton of FACE_BUTTON_FLAGS) { + if ((faceMask & faceButton) === 0) { + continue; + } - if (keys) { - return BTAPI.instance.getKeyboard()?.isButtonReleased(keys) ?? false; + const keyboardMatch = + BTAPI.instance.getKeyboard()?.isButtonReleased(faceButtonKeys(faceButton, player) ?? []) ?? false; + const gamepadMatch = BTAPI.instance.getGamepad()?.isButtonReleased(faceButton, player) ?? false; + + if (keyboardMatch || gamepadMatch) { + return true; + } } - // TODO: Implement gamepad input (VV-135). return false; }, @@ -753,8 +909,8 @@ export const BT = { * Assigns one or more `KeyboardEvent.code` values to a face button for a keyboard player. * * Logical button state is the OR of all listed keys. Only players `0` and `1` - * support keyboard; other indices no-op. Button must be `BT.BTN_UP` … - * `BT.BTN_SELECT` (`0`…`11`); out-of-range values no-op. Pass an empty key list + * support keyboard; other indices no-op. `button` must be one face-button + * bit flag (`BT.BTN_UP` … `BT.BTN_SELECT`). Pass an empty key list * to clear keyboard bindings for that button until remapped again. * * @param player - Zero-based player index (`0` or `1`). @@ -766,7 +922,7 @@ export const BT = { return; } - if (button < 0 || button > 11) { + if (!FACE_BUTTON_FLAGS.includes(button as FaceButtonCode)) { return; } @@ -788,6 +944,39 @@ export const BT = { resetKeyboardFaceButtonMaps(); }, + /** + * Reads a gamepad axis value for a player. + * + * Stick axes return values in `[-1.0, 1.0]` with dead-zone filtering. + * Trigger axes return values in `[0.0, 1.0]`. + * + * @param axis - Axis constant (`AXIS_LEFT_X` .. `AXIS_TRIGGER_R`). + * @param player - Zero-based player index (`0`..`3`). + * @returns Axis value, or `0` when unavailable. + */ + getAxis: (axis: number, player: number = 0): number => { + return BTAPI.instance.getGamepad()?.getAxis(axis, player) ?? 0; + }, + + /** + * Reports whether a player's gamepad is connected. + * + * @param player - Zero-based player index (`0`..`3`). + * @returns `true` when a gamepad is available for that slot. + */ + gamepadConnected: (player: number = 0): boolean => { + return BTAPI.instance.getGamepad()?.isConnected(player) ?? false; + }, + + /** + * Returns the number of currently connected gamepads (max 4). + * + * @returns Connected gamepad count. + */ + gamepadCount: (): number => { + return BTAPI.instance.getGamepad()?.connectedCount() ?? 0; + }, + // #endregion // #region Input - Keyboard diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 835164c..4a81deb 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -157,6 +157,10 @@ describe('BTAPI', () => { expect(BTAPI.instance.getKeyboard()).toBeNull(); }); + it('getGamepad should return null before init', () => { + expect(BTAPI.instance.getGamepad()).toBeNull(); + }); + it('getHardwareSettings should return null before init', () => { expect(BTAPI.instance.getHardwareSettings()).toBeNull(); }); @@ -372,6 +376,7 @@ describe('BTAPI', () => { expect(BTAPI.instance.getHardwareSettings()).not.toBeNull(); expect(BTAPI.instance.getPointer()).not.toBeNull(); expect(BTAPI.instance.getKeyboard()).not.toBeNull(); + expect(BTAPI.instance.getGamepad()).not.toBeNull(); }); it('stop detaches pointer and keyboard input so subsequent accessors return null', async () => { @@ -379,11 +384,13 @@ describe('BTAPI', () => { expect(BTAPI.instance.getPointer()).not.toBeNull(); expect(BTAPI.instance.getKeyboard()).not.toBeNull(); + expect(BTAPI.instance.getGamepad()).not.toBeNull(); BTAPI.instance.stop(); expect(BTAPI.instance.getPointer()).toBeNull(); expect(BTAPI.instance.getKeyboard()).toBeNull(); + expect(BTAPI.instance.getGamepad()).toBeNull(); }); it('double initialize without stop detaches prior pointer and keyboard before reattaching', async () => { @@ -416,6 +423,39 @@ describe('BTAPI', () => { expect(requestAnimationFrame).toHaveBeenCalled(); }); + it('calls gamepad.endFrame during render-phase input flush', async () => { + const rafCallbacks: FrameRequestCallback[] = []; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }), + ); + + await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + + const gamepad = BTAPI.instance.getGamepad(); + expect(gamepad).not.toBeNull(); + BTAPI.instance.setPalette(new Palette(16)); + + const endFrameSpy = vi.spyOn(gamepad as NonNullable, 'endFrame'); + // GameLoop.start uses a double-rAF bootstrap before the first tick. + while (rafCallbacks.length > 0) { + const cb = rafCallbacks.shift(); + + if (cb) { + cb(16); + } + + if (endFrameSpy.mock.calls.length > 0) { + break; + } + } + + expect(endFrameSpy).toHaveBeenCalled(); + }); + it('stop should not throw after successful initialization', async () => { await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 0ab9adf..856907d 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -10,6 +10,7 @@ import { } from '../assets/PaletteEffect'; import type { SpriteSheet } from '../assets/SpriteSheet'; import { createSystemFont } from '../assets/SystemFont'; +import { GamepadInput } from '../input/GamepadInput'; import { KeyboardInput } from '../input/KeyboardInput'; import { PointerInput } from '../input/PointerInput'; import type { Effect } from '../render/effects/Effect'; @@ -99,8 +100,11 @@ export class BTAPI { /** Keyboard input (VV-134). Created during {@link initialize}. */ private keyboard: KeyboardInput | null = null; + /** Gamepad input (VV-135). Created during {@link initialize}. */ + private gamepad: GamepadInput | null = null; + // TODO: Additional subsystems for future implementation: - // GamepadInput (VV-135), AudioManager, AssetManager + // AudioManager, AssetManager // #endregion @@ -143,6 +147,8 @@ export class BTAPI { this.pointer = null; this.keyboard?.detach(); this.keyboard = null; + this.gamepad?.detach(); + this.gamepad = null; } /** @@ -264,7 +270,11 @@ export class BTAPI { getTicks: () => this.loop?.getTicks() ?? 0, }); - // TODO: Initialize gamepad (VV-135), audio. + this.gamepad?.detach(); + this.gamepad = new GamepadInput(); + this.gamepad.attach(); + + // TODO: Initialize audio. // Initialize the demo. console.log('[BT] Initializing demo'); @@ -305,6 +315,7 @@ export class BTAPI { const tick = this.loop?.getTicks() ?? 0; this.keyboard?.endFrame(tick); + this.gamepad?.endFrame(tick); }, onFrameDrop, ); @@ -418,6 +429,16 @@ export class BTAPI { return this.keyboard; } + /** + * Gets the gamepad input subsystem created during initialization. + * + * @returns Gamepad input instance, or null when the engine has not been + * initialized yet (or has been stopped). + */ + public getGamepad(): GamepadInput | null { + return this.gamepad; + } + /** * Gets the active engine palette. * diff --git a/src/input/GamepadInput.test.ts b/src/input/GamepadInput.test.ts new file mode 100644 index 0000000..4da20fa --- /dev/null +++ b/src/input/GamepadInput.test.ts @@ -0,0 +1,143 @@ +/** + * Unit tests for {@link GamepadInput}. + */ +/* eslint-disable security/detect-object-injection */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_GAMEPAD_DEAD_ZONE, GamepadInput } from './GamepadInput'; + +const BTN_A = 1 << 4; +const BTN_B = 1 << 5; +const BTN_UP = 1 << 0; + +const AXIS_LEFT_X = 0; +const AXIS_TRIGGER_L = 4; + +interface PadState { + connected?: boolean; + buttons?: number[]; + pressed?: number[]; + axes?: number[]; +} + +function makeGamepad(state: PadState): Gamepad { + const buttons = Array.from({ length: 16 }, (_, index) => ({ + pressed: state.pressed?.includes(index) ?? false, + touched: false, + value: state.buttons?.[index] ?? (state.pressed?.includes(index) ? 1 : 0), + })); + + return { + id: 'test-pad', + index: 0, + connected: state.connected ?? true, + mapping: 'standard', + timestamp: 0, + axes: state.axes ?? [0, 0, 0, 0], + buttons, + vibrationActuator: null, + hapticActuators: [], + } as unknown as Gamepad; +} + +describe('GamepadInput', () => { + let pads: (Gamepad | null)[]; + let input: GamepadInput; + + beforeEach(() => { + pads = [null, null, null, null]; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + getGamepads: vi.fn(() => pads), + }, + }); + input = new GamepadInput(); + input.attach(); + }); + + afterEach(() => { + input.detach(); + vi.restoreAllMocks(); + }); + + it('uses default dead zone', () => { + expect(input.getDeadZone()).toBe(DEFAULT_GAMEPAD_DEAD_ZONE); + }); + + it('tracks connected gamepads and counts connected players', () => { + pads[0] = makeGamepad({ connected: true }); + pads[1] = makeGamepad({ connected: true }); + + expect(input.isConnected(0)).toBe(true); + expect(input.isConnected(1)).toBe(true); + expect(input.isConnected(2)).toBe(false); + expect(input.connectedCount()).toBe(2); + }); + + it('reports down/pressed/released edges across endFrame', () => { + pads[0] = makeGamepad({ pressed: [0] }); + + expect(input.isButtonDown(BTN_A, 0)).toBe(true); + expect(input.isButtonPressed(BTN_A, 0, undefined, 10)).toBe(true); + expect(input.isButtonReleased(BTN_A, 0)).toBe(false); + + input.endFrame(10); + + expect(input.isButtonPressed(BTN_A, 0, undefined, 11)).toBe(false); + + pads[0] = makeGamepad({ pressed: [] }); + + expect(input.isButtonReleased(BTN_A, 0)).toBe(true); + }); + + it('supports repeat behavior for held buttons', () => { + pads[0] = makeGamepad({ pressed: [0] }); + + expect(input.isButtonPressed(BTN_A, 0, 3, 5)).toBe(true); + input.endFrame(5); + + expect(input.isButtonPressed(BTN_A, 0, 3, 6)).toBe(false); + expect(input.isButtonPressed(BTN_A, 0, 3, 8)).toBe(true); + }); + + it('uses ANY semantics for bitmasks', () => { + pads[0] = makeGamepad({ pressed: [0] }); + expect(input.isButtonDown(BTN_A | BTN_B, 0)).toBe(true); + expect(input.isButtonDown(BTN_B, 0)).toBe(false); + }); + + it('maps dpad buttons to direction flags', () => { + pads[0] = makeGamepad({ pressed: [12] }); + expect(input.isButtonDown(BTN_UP, 0)).toBe(true); + }); + + it('applies dead zone to stick axes and keeps trigger range', () => { + pads[0] = makeGamepad({ + axes: [0.7, 0, 0, 0], + buttons: [0, 0, 0, 0, 0, 0, 0.25], + }); + expect(input.getAxis(AXIS_LEFT_X, 0)).toBe(0); + expect(input.getAxis(AXIS_TRIGGER_L, 0)).toBe(0.25); + + input.setDeadZone(0.2); + expect(input.getAxis(AXIS_LEFT_X, 0)).toBeGreaterThan(0); + }); + + it('returns safe defaults for invalid players or disconnected states', () => { + expect(input.isButtonDown(BTN_A, -1)).toBe(false); + expect(input.isButtonPressed(BTN_A, 99, undefined, 0)).toBe(false); + expect(input.isButtonReleased(BTN_A, 3)).toBe(false); + expect(input.getAxis(AXIS_LEFT_X, 2)).toBe(0); + expect(input.connectedCount()).toBe(0); + }); + + it('treats disconnect as release for previously held buttons', () => { + pads[0] = makeGamepad({ pressed: [0, 1] }); + input.endFrame(1); + + pads[0] = null; + expect(input.isButtonReleased(BTN_A | BTN_B, 0)).toBe(true); + }); +}); diff --git a/src/input/GamepadInput.ts b/src/input/GamepadInput.ts new file mode 100644 index 0000000..7ef58ff --- /dev/null +++ b/src/input/GamepadInput.ts @@ -0,0 +1,723 @@ +/** + * Gamepad input subsystem (VV-135). + * + * Uses the browser Gamepad API and snapshots previous-state at end-of-frame for + * edge detection (`pressed`/`released`) and optional repeat timing. + */ +/* eslint-disable security/detect-object-injection */ + +// #region Constants + +/** Maximum supported local gamepad players. */ +export const GAMEPAD_PLAYER_COUNT = 4; + +/** Default analog dead zone for stick axes. */ +export const DEFAULT_GAMEPAD_DEAD_ZONE = 0.75; + +/** Standard mapping button indices (W3C standard gamepad mapping). */ +const GP_BUTTON_A = 0; +const GP_BUTTON_B = 1; +const GP_BUTTON_X = 2; +const GP_BUTTON_Y = 3; +const GP_BUTTON_L = 4; +const GP_BUTTON_R = 5; +const GP_BUTTON_SELECT = 8; +const GP_BUTTON_START = 9; +const GP_BUTTON_UP = 12; +const GP_BUTTON_DOWN = 13; +const GP_BUTTON_LEFT = 14; +const GP_BUTTON_RIGHT = 15; + +/** Face/button bit flags mirrored from `BT.BTN_*` to avoid circular imports. */ +const BTN_UP = 1 << 0; +const BTN_DOWN = 1 << 1; +const BTN_LEFT = 1 << 2; +const BTN_RIGHT = 1 << 3; +const BTN_A = 1 << 4; +const BTN_B = 1 << 5; +const BTN_X = 1 << 6; +const BTN_Y = 1 << 7; +const BTN_L = 1 << 8; +const BTN_R = 1 << 9; +const BTN_START = 1 << 10; +const BTN_SELECT = 1 << 11; + +/** Axis constants mirrored from `BT.AXIS_*` to avoid circular imports. */ +const AXIS_LEFT_X = 0; +const AXIS_LEFT_Y = 1; +const AXIS_RIGHT_X = 2; +const AXIS_RIGHT_Y = 3; +const AXIS_TRIGGER_L = 4; +const AXIS_TRIGGER_R = 5; + +const VALID_BUTTON_FLAGS = [ + BTN_UP, + BTN_DOWN, + BTN_LEFT, + BTN_RIGHT, + BTN_A, + BTN_B, + BTN_X, + BTN_Y, + BTN_L, + BTN_R, + BTN_START, + BTN_SELECT, +] as const; + +const VALID_AXIS_INDICES = [ + AXIS_LEFT_X, + AXIS_LEFT_Y, + AXIS_RIGHT_X, + AXIS_RIGHT_Y, + AXIS_TRIGGER_L, + AXIS_TRIGGER_R, +] as const; + +// #endregion + +// #region Types + +/** + * Per-player gamepad snapshot used for current and previous frame state. + */ +interface PlayerSnapshot { + /** Whether a gamepad is connected for this player slot. */ + connected: boolean; + /** Current button-state bitmask (`BTN_*`). */ + buttons: number; + /** Snapshot axis values in `AXIS_*` order. */ + axes: readonly [number, number, number, number, number, number]; +} + +// #endregion + +// #region GamepadInput + +/** + * Polling-based gamepad input tracker with per-frame previous-state snapshots. + */ +export class GamepadInput { + /** Current polled state per player slot. */ + private readonly current: readonly [PlayerSnapshot, PlayerSnapshot, PlayerSnapshot, PlayerSnapshot]; + + /** End-of-frame previous snapshot per player slot. */ + private readonly previous: readonly [PlayerSnapshot, PlayerSnapshot, PlayerSnapshot, PlayerSnapshot]; + + /** First tick each button was pressed per player (for repeat behavior). */ + private readonly firstPressTick: readonly [ + Map, + Map, + Map, + Map, + ]; + + /** Current analog stick dead-zone threshold. */ + private deadZone: number; + + /** Event handler for `gamepadconnected`. */ + private readonly onConnected: (event: Event) => void; + + /** Event handler for `gamepaddisconnected`. */ + private readonly onDisconnected: (event: Event) => void; + + /** + * Creates a gamepad input tracker. + * + * @param deadZone - Stick dead-zone threshold in `[0, 0.99]`. + */ + constructor(deadZone: number = DEFAULT_GAMEPAD_DEAD_ZONE) { + this.current = [ + this.createEmptySnapshot(), + this.createEmptySnapshot(), + this.createEmptySnapshot(), + this.createEmptySnapshot(), + ]; + this.previous = [ + this.createEmptySnapshot(), + this.createEmptySnapshot(), + this.createEmptySnapshot(), + this.createEmptySnapshot(), + ]; + this.firstPressTick = [new Map(), new Map(), new Map(), new Map()]; + this.deadZone = this.sanitizeDeadZone(deadZone); + this.onConnected = () => this.pollGamepads(); + this.onDisconnected = () => this.pollGamepads(); + } + + /** + * Attaches global gamepad connect/disconnect listeners. + * + * Also performs an immediate state poll so queries are accurate before the + * first frame-end snapshot. + */ + public attach(): void { + if (typeof globalThis.window !== 'undefined') { + globalThis.window.addEventListener('gamepadconnected', this.onConnected); + globalThis.window.addEventListener('gamepaddisconnected', this.onDisconnected); + } + + this.pollGamepads(); + } + + /** + * Detaches global listeners and clears cached state. + */ + public detach(): void { + if (typeof globalThis.window !== 'undefined') { + globalThis.window.removeEventListener('gamepadconnected', this.onConnected); + globalThis.window.removeEventListener('gamepaddisconnected', this.onDisconnected); + } + + this.clearAllState(); + } + + /** + * Sets the analog stick dead zone used by {@link getAxis} for stick axes. + * + * @param deadZone - New dead-zone threshold. + */ + public setDeadZone(deadZone: number): void { + this.deadZone = this.sanitizeDeadZone(deadZone); + } + + /** + * Returns the current analog stick dead-zone threshold. + * + * @returns Current dead-zone threshold in `[0, 0.99]`. + */ + public getDeadZone(): number { + return this.deadZone; + } + + /** + * Snapshots current state into previous-state storage for next frame's edge detection. + * + * @param _currentTick - Current engine tick (unused; kept for BTAPI parity). + */ + public endFrame(_currentTick: number): void { + this.pollGamepads(); + + for (let i = 0; i < GAMEPAD_PLAYER_COUNT; i++) { + const current = this.current[i]; + const previous = this.previous[i]; + + if (!current || !previous) { + continue; + } + + previous.connected = current.connected; + previous.buttons = current.buttons; + previous.axes = [...current.axes] as PlayerSnapshot['axes']; + + if (!current.connected) { + this.firstPressTick[i]?.clear(); + } + } + } + + /** + * Reports whether any button in `buttonMask` is currently held for `player`. + * + * @param buttonMask - One or more `BTN_*` bit flags. + * @param player - Zero-based player index. + * @returns `true` when any requested button is currently down. + */ + public isButtonDown(buttonMask: number, player: number): boolean { + const index = this.normalizePlayer(player); + + if (index === null || buttonMask <= 0) { + return false; + } + + this.pollGamepads(); + + const current = this.current[index]; + + if (!current?.connected) { + return false; + } + + return (current.buttons & buttonMask) !== 0; + } + + /** + * Reports press-edge (and optional repeat) for button masks. + * + * Matching uses ANY semantics across `buttonMask` bits. + * + * @param buttonMask - One or more `BTN_*` bit flags. + * @param player - Zero-based player index. + * @param repeatRate - Tick interval for repeat (`<= 0` or omitted = edge only). + * @param currentTick - Current fixed-update tick. + * @returns `true` on press edge or repeat tick. + */ + public isButtonPressed( + buttonMask: number, + player: number, + repeatRate: number | undefined, + currentTick: number, + ): boolean { + const index = this.normalizePlayer(player); + + if (index === null || buttonMask <= 0) { + return false; + } + + this.pollGamepads(); + + const current = this.current[index]; + const previous = this.previous[index]; + + if (!current?.connected || !previous) { + return false; + } + + const edgeMask = current.buttons & ~previous.buttons & buttonMask; + + if (edgeMask !== 0) { + this.recordNewPressTicks(index, edgeMask, currentTick); + return true; + } + + if (repeatRate === undefined || repeatRate <= 0) { + return false; + } + + const heldMask = current.buttons & buttonMask; + + if (heldMask === 0) { + return false; + } + + const first = this.getMinFirstPressTick(index, heldMask); + + if (first === undefined) { + return false; + } + + const dt = currentTick - first; + + return dt > 0 && dt % repeatRate === 0; + } + + /** + * Reports whether any button in `buttonMask` was released this frame. + * + * @param buttonMask - One or more `BTN_*` bit flags. + * @param player - Zero-based player index. + * @returns `true` when any requested button transitions from down to up. + */ + public isButtonReleased(buttonMask: number, player: number): boolean { + const index = this.normalizePlayer(player); + + if (index === null || buttonMask <= 0) { + return false; + } + + this.pollGamepads(); + + const current = this.current[index]; + const previous = this.previous[index]; + + if (!previous) { + return false; + } + + if (!current?.connected && previous.connected) { + return (previous.buttons & buttonMask) !== 0; + } + + if (!current?.connected) { + return false; + } + + return (~current.buttons & previous.buttons & buttonMask) !== 0; + } + + /** + * Reads a gamepad axis for a player. + * + * Stick axes apply dead-zone filtering. Trigger axes return raw `[0, 1]`. + * + * @param axis - Axis constant (`AXIS_*`). + * @param player - Zero-based player index. + * @returns Axis value, or `0` for invalid/disconnected inputs. + */ + public getAxis(axis: number, player: number): number { + const index = this.normalizePlayer(player); + + if (index === null || !VALID_AXIS_INDICES.includes(axis as (typeof VALID_AXIS_INDICES)[number])) { + return 0; + } + + this.pollGamepads(); + + const snapshot = this.current[index]; + + if (!snapshot?.connected) { + return 0; + } + + return snapshot.axes[axis as (typeof VALID_AXIS_INDICES)[number]] ?? 0; + } + + /** + * Reports whether a gamepad is connected for the given player slot. + * + * @param player - Zero-based player index. + * @returns `true` when connected. + */ + public isConnected(player: number): boolean { + const index = this.normalizePlayer(player); + + if (index === null) { + return false; + } + + this.pollGamepads(); + + return this.current[index]?.connected ?? false; + } + + /** + * Counts connected gamepads across tracked player slots. + * + * @returns Number of connected gamepads in `[0, GAMEPAD_PLAYER_COUNT]`. + */ + public connectedCount(): number { + this.pollGamepads(); + + let count = 0; + + for (let i = 0; i < GAMEPAD_PLAYER_COUNT; i++) { + if (this.current[i]?.connected) { + count++; + } + } + + return count; + } + + // #endregion + + // #region Private + + /** + * Creates an empty disconnected player snapshot. + * + * @returns Fresh disconnected snapshot. + */ + private createEmptySnapshot(): PlayerSnapshot { + return { + connected: false, + buttons: 0, + axes: [0, 0, 0, 0, 0, 0], + }; + } + + /** + * Normalizes and clamps dead-zone configuration. + * + * @param deadZone - Requested dead-zone value. + * @returns Clamped dead-zone value. + */ + private sanitizeDeadZone(deadZone: number): number { + if (!Number.isFinite(deadZone)) { + return DEFAULT_GAMEPAD_DEAD_ZONE; + } + + return Math.max(0, Math.min(deadZone, 0.99)); + } + + /** + * Polls `navigator.getGamepads()` and refreshes current snapshots. + */ + private pollGamepads(): void { + const pads = this.readGamepads(); + + for (let player = 0; player < GAMEPAD_PLAYER_COUNT; player++) { + const snapshot = this.current[player]; + + if (!snapshot) { + continue; + } + + const pad = pads[player]; + + if (!pad?.connected) { + snapshot.connected = false; + snapshot.buttons = 0; + snapshot.axes = [0, 0, 0, 0, 0, 0]; + continue; + } + + snapshot.connected = true; + snapshot.buttons = this.mapButtons(pad); + snapshot.axes = this.mapAxes(pad); + this.dropReleasedTickAnchors(player, snapshot.buttons); + } + } + + /** + * Safely reads browser gamepads (empty when unavailable). + * + * @returns Current browser gamepad array or an empty array. + */ + private readGamepads(): readonly (Gamepad | null)[] { + if (typeof globalThis.navigator === 'undefined' || typeof globalThis.navigator.getGamepads !== 'function') { + return []; + } + + return globalThis.navigator.getGamepads(); + } + + /** + * Maps standard Gamepad API buttons to `BTN_*` bit flags. + * + * @param pad - Gamepad object from browser API. + * @returns Button-state bitmask. + */ + private mapButtons(pad: Gamepad): number { + let mask = 0; + + if (this.isPadButtonDown(pad, GP_BUTTON_UP)) { + mask |= BTN_UP; + } + if (this.isPadButtonDown(pad, GP_BUTTON_DOWN)) { + mask |= BTN_DOWN; + } + if (this.isPadButtonDown(pad, GP_BUTTON_LEFT)) { + mask |= BTN_LEFT; + } + if (this.isPadButtonDown(pad, GP_BUTTON_RIGHT)) { + mask |= BTN_RIGHT; + } + if (this.isPadButtonDown(pad, GP_BUTTON_A)) { + mask |= BTN_A; + } + if (this.isPadButtonDown(pad, GP_BUTTON_B)) { + mask |= BTN_B; + } + if (this.isPadButtonDown(pad, GP_BUTTON_X)) { + mask |= BTN_X; + } + if (this.isPadButtonDown(pad, GP_BUTTON_Y)) { + mask |= BTN_Y; + } + if (this.isPadButtonDown(pad, GP_BUTTON_L)) { + mask |= BTN_L; + } + if (this.isPadButtonDown(pad, GP_BUTTON_R)) { + mask |= BTN_R; + } + if (this.isPadButtonDown(pad, GP_BUTTON_START)) { + mask |= BTN_START; + } + if (this.isPadButtonDown(pad, GP_BUTTON_SELECT)) { + mask |= BTN_SELECT; + } + + return mask; + } + + /** + * Maps gamepad axis/button values into `AXIS_*` order. + * + * @param pad - Gamepad object from browser API. + * @returns Axis tuple in engine API order. + */ + private mapAxes(pad: Gamepad): PlayerSnapshot['axes'] { + const leftX = this.applyStickDeadZone(this.getPadAxis(pad, 0)); + const leftY = this.applyStickDeadZone(this.getPadAxis(pad, 1)); + const rightX = this.applyStickDeadZone(this.getPadAxis(pad, 2)); + const rightY = this.applyStickDeadZone(this.getPadAxis(pad, 3)); + const triggerL = this.getPadButtonValue(pad, 6); + const triggerR = this.getPadButtonValue(pad, 7); + + return [leftX, leftY, rightX, rightY, triggerL, triggerR]; + } + + /** + * Reads and clamps a raw stick axis to `[-1, 1]`. + * + * @param pad - Gamepad object. + * @param index - Raw axis index. + * @returns Clamped axis value. + */ + private getPadAxis(pad: Gamepad, index: number): number { + const value = pad.axes[index] ?? 0; + + if (!Number.isFinite(value)) { + return 0; + } + + return Math.max(-1, Math.min(1, value)); + } + + /** + * Reads a trigger/button analog value and clamps to `[0, 1]`. + * + * @param pad - Gamepad object. + * @param index - Raw button index. + * @returns Clamped analog value. + */ + private getPadButtonValue(pad: Gamepad, index: number): number { + const button = pad.buttons[index]; + + if (!button) { + return 0; + } + + const value = Number.isFinite(button.value) ? button.value : button.pressed ? 1 : 0; + + return Math.max(0, Math.min(1, value)); + } + + /** + * Checks digital down-state for a gamepad button. + * + * @param pad - Gamepad object. + * @param index - Raw button index. + * @returns `true` when considered pressed. + */ + private isPadButtonDown(pad: Gamepad, index: number): boolean { + const button = pad.buttons[index]; + + if (!button) { + return false; + } + + return button.pressed || button.value >= 0.5; + } + + /** + * Applies configured dead zone and re-normalizes stick range. + * + * @param value - Raw stick axis value. + * @returns Dead-zone filtered value. + */ + private applyStickDeadZone(value: number): number { + const abs = Math.abs(value); + + if (abs <= this.deadZone) { + return 0; + } + + const normalized = (abs - this.deadZone) / (1 - this.deadZone); + + return Math.sign(value) * Math.min(1, normalized); + } + + /** + * Validates player slot index. + * + * @param player - Caller-supplied player index. + * @returns Normalized index or `null` when invalid. + */ + private normalizePlayer(player: number): number | null { + if (!Number.isInteger(player) || player < 0 || player >= GAMEPAD_PLAYER_COUNT) { + return null; + } + + return player; + } + + /** + * Stores first-press ticks for newly pressed buttons in this frame. + * + * @param player - Player slot index. + * @param edgeMask - Newly pressed button bits. + * @param tick - Current engine tick. + */ + private recordNewPressTicks(player: number, edgeMask: number, tick: number): void { + const table = this.firstPressTick[player]; + + if (!table) { + return; + } + + for (const flag of VALID_BUTTON_FLAGS) { + if ((edgeMask & flag) !== 0 && !table.has(flag)) { + table.set(flag, tick); + } + } + } + + /** + * Finds the oldest held-button first-press tick within `heldMask`. + * + * @param player - Player slot index. + * @param heldMask - Currently held button bits. + * @returns Earliest held-button tick anchor, if any. + */ + private getMinFirstPressTick(player: number, heldMask: number): number | undefined { + const table = this.firstPressTick[player]; + + if (!table) { + return undefined; + } + + let min: number | undefined; + + for (const flag of VALID_BUTTON_FLAGS) { + if ((heldMask & flag) === 0) { + continue; + } + + const t = table.get(flag); + + if (t !== undefined && (min === undefined || t < min)) { + min = t; + } + } + + return min; + } + + /** + * Removes repeat anchors for buttons no longer held. + * + * @param player - Player slot index. + * @param heldMask - Current held button mask. + */ + private dropReleasedTickAnchors(player: number, heldMask: number): void { + const table = this.firstPressTick[player]; + + if (!table) { + return; + } + + for (const flag of VALID_BUTTON_FLAGS) { + if ((heldMask & flag) === 0) { + table.delete(flag); + } + } + } + + /** + * Clears all snapshots and repeat anchors. + */ + private clearAllState(): void { + for (let i = 0; i < GAMEPAD_PLAYER_COUNT; i++) { + const current = this.current[i]; + const previous = this.previous[i]; + + if (current) { + current.connected = false; + current.buttons = 0; + current.axes = [0, 0, 0, 0, 0, 0]; + } + + if (previous) { + previous.connected = false; + previous.buttons = 0; + previous.axes = [0, 0, 0, 0, 0, 0]; + } + + this.firstPressTick[i]?.clear(); + } + } +} + +// #endregion diff --git a/src/input/KeyboardInput.test.ts b/src/input/KeyboardInput.test.ts index 330a93d..1b3e60b 100644 --- a/src/input/KeyboardInput.test.ts +++ b/src/input/KeyboardInput.test.ts @@ -285,7 +285,7 @@ describe('KeyboardInput', () => { kb.attach(canvas, { getTicks: () => tick }); - const codes = DEFAULT_KEYBOARD_PLAYER1[0]; + const codes = DEFAULT_KEYBOARD_PLAYER1[1 << 0] ?? []; kb.endFrame(0); @@ -306,7 +306,7 @@ describe('KeyboardInput', () => { kb.attach(canvas, { getTicks: () => tick }); - const codes = DEFAULT_KEYBOARD_PLAYER1[0]; + const codes = DEFAULT_KEYBOARD_PLAYER1[1 << 0] ?? []; kb.endFrame(0); @@ -342,7 +342,7 @@ describe('KeyboardInput', () => { kb.attach(canvas, { getTicks: () => tick }); - const codes = DEFAULT_KEYBOARD_PLAYER1[0]; + const codes = DEFAULT_KEYBOARD_PLAYER1[1 << 0] ?? []; canvas.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyW', bubbles: true })); @@ -357,7 +357,7 @@ describe('KeyboardInput', () => { kb.attach(canvas, { getTicks: () => tick }); - const codes = DEFAULT_KEYBOARD_PLAYER1[4]; + const codes = DEFAULT_KEYBOARD_PLAYER1[1 << 4] ?? []; canvas.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyB', bubbles: true })); diff --git a/src/input/defaultKeyboardMap.ts b/src/input/defaultKeyboardMap.ts index b1096d8..434d251 100644 --- a/src/input/defaultKeyboardMap.ts +++ b/src/input/defaultKeyboardMap.ts @@ -4,44 +4,61 @@ * Values are `KeyboardEvent.code` strings. Logical button state is the OR of * all listed keys for that button. */ +/* eslint-disable security/detect-object-injection */ -/** Button codes matching `BT.BTN_UP` … `BT.BTN_SELECT` (0–11). */ -export type FaceButtonCode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; +/** Face-button bit flags matching `BT.BTN_UP` … `BT.BTN_SELECT`. */ +export const FACE_BUTTON_FLAGS = [ + 1 << 0, + 1 << 1, + 1 << 2, + 1 << 3, + 1 << 4, + 1 << 5, + 1 << 6, + 1 << 7, + 1 << 8, + 1 << 9, + 1 << 10, + 1 << 11, +] as const; + +/** Face-button bit-flag code type. */ +export type FaceButtonCode = (typeof FACE_BUTTON_FLAGS)[number]; /** * Player 1 default keyboard map (WASD, Space/KeyB, etc.). */ export const DEFAULT_KEYBOARD_PLAYER1: Readonly> = { - 0: ['KeyW'], - 1: ['KeyS'], - 2: ['KeyA'], - 3: ['KeyD'], - 4: ['Space', 'KeyB'], - 5: ['KeyN'], - 6: [], - 7: [], - 8: [], - 9: [], - 10: ['Digit5'], - 11: ['Escape'], + [1 << 0]: ['KeyW'], + [1 << 1]: ['KeyS'], + [1 << 2]: ['KeyA'], + [1 << 3]: ['KeyD'], + [1 << 4]: ['Space', 'KeyB'], + [1 << 5]: ['KeyN'], + [1 << 6]: [], + [1 << 7]: [], + [1 << 8]: [], + [1 << 9]: [], + [1 << 10]: ['Digit5'], + [1 << 11]: ['Escape'], }; /** * Player 2 default keyboard map (arrows, numpad alternates). */ export const DEFAULT_KEYBOARD_PLAYER2: Readonly> = { - 0: ['ArrowUp'], - 1: ['ArrowDown'], - 2: ['ArrowLeft'], - 3: ['ArrowRight'], - 4: ['Semicolon', 'Numpad1'], - 5: ['Quote', 'Numpad2'], - 6: [], - 7: [], - 8: [], - 9: [], - 10: ['Backspace', 'NumpadDivide'], - 11: [], + [1 << 0]: ['ArrowUp'], + [1 << 1]: ['ArrowDown'], + [1 << 2]: ['ArrowLeft'], + [1 << 3]: ['ArrowRight'], + [1 << 4]: ['Semicolon', 'Numpad1'], + [1 << 5]: ['Quote', 'Numpad2'], + [1 << 6]: [], + [1 << 7]: [], + [1 << 8]: [], + [1 << 9]: [], + [1 << 10]: ['Backspace', 'NumpadDivide'], + [1 << 11]: [], }; /** @@ -50,15 +67,15 @@ export const DEFAULT_KEYBOARD_PLAYER2: Readonly>, ): Map { const result = new Map(); - for (let button = 0; button <= 11; button++) { - const codes = source[button as FaceButtonCode]; + for (const button of FACE_BUTTON_FLAGS) { + const codes = source[button] ?? []; result.set(button, [...codes]); } -- 2.51.2