diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 0647ff6..3214a47 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -380,6 +380,14 @@ describe('BT.buttonDown', () => { expect(BT.buttonDown(BT.BTN_POINTER_A)).toBe(false); expect(BT.buttonDown(BT.BTN_POINTER_D, 3)).toBe(false); }); + + 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); + }); }); describe('BT.buttonPressed', () => { @@ -449,14 +457,6 @@ describe('BT.buttonReleased', () => { 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); - }); }); // #endregion diff --git a/src/BlitTech.ts b/src/BlitTech.ts index ea9cb26..a374c47 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -55,6 +55,7 @@ let keyboardFaceButtonKeysPlayer1: Map; /** Pointer button bit mask (`BTN_POINTER_A..D`). */ const POINTER_BUTTON_MASK = (1 << 12) | (1 << 13) | (1 << 14) | (1 << 15); +const POINTER_FLAGS = [1 << 12, 1 << 13, 1 << 14, 1 << 15] as const; /** Face button bit mask (`BTN_UP..BTN_SELECT`). */ const FACE_BUTTON_MASK = (1 << 12) - 1; @@ -758,7 +759,7 @@ export const BT = { const pointerMask = button & POINTER_BUTTON_MASK; if (pointerMask !== 0) { - for (const pointerFlag of [1 << 12, 1 << 13, 1 << 14, 1 << 15]) { + for (const pointerFlag of POINTER_FLAGS) { if ((pointerMask & pointerFlag) === 0) { continue; } @@ -811,7 +812,7 @@ export const BT = { const pointerMask = button & POINTER_BUTTON_MASK; if (pointerMask !== 0) { - for (const pointerFlag of [1 << 12, 1 << 13, 1 << 14, 1 << 15]) { + for (const pointerFlag of POINTER_FLAGS) { if ((pointerMask & pointerFlag) === 0) { continue; } @@ -870,7 +871,7 @@ export const BT = { const pointerMask = button & POINTER_BUTTON_MASK; if (pointerMask !== 0) { - for (const pointerFlag of [1 << 12, 1 << 13, 1 << 14, 1 << 15]) { + for (const pointerFlag of POINTER_FLAGS) { if ((pointerMask & pointerFlag) === 0) { continue; } diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 4a81deb..01f09d6 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -441,7 +441,17 @@ describe('BTAPI', () => { const endFrameSpy = vi.spyOn(gamepad as NonNullable, 'endFrame'); // GameLoop.start uses a double-rAF bootstrap before the first tick. + const maxIterations = 1000; + let iterations = 0; + while (rafCallbacks.length > 0) { + iterations++; + if (iterations > maxIterations) { + throw new Error( + 'Exceeded max rAF callback drain iterations before gamepad.endFrame was called; possible loop stall.', + ); + } + const cb = rafCallbacks.shift(); if (cb) { diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 856907d..94ef4c4 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -140,7 +140,10 @@ export class BTAPI { // #region Initialization /** - * Removes pointer and keyboard subsystems (detach DOM listeners, clear refs). + * Removes pointer, keyboard, and gamepad subsystems. + * + * Pointer/keyboard detach DOM listeners; gamepad detaches polling state and + * clears subsystem references. */ private clearInputSubsystems(): void { this.pointer?.detach(); @@ -328,10 +331,11 @@ export class BTAPI { } /** - * Stops the active game loop and detaches input listeners. + * Stops the active game loop and detaches input subsystems. * - * The pointer and keyboard subsystems are detached so DOM listeners do not - * leak across engine restarts (relevant in tests where the same DOM persists). + * Pointer, keyboard, and gamepad subsystems are detached so listeners and + * polling state do not leak across engine restarts (relevant in tests where + * the same DOM persists). */ public stop(): void { this.loop?.stop(); diff --git a/src/input/GamepadInput.test.ts b/src/input/GamepadInput.test.ts index 4da20fa..a33922e 100644 --- a/src/input/GamepadInput.test.ts +++ b/src/input/GamepadInput.test.ts @@ -5,15 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BT } from '../BlitTech'; 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[]; @@ -79,38 +73,38 @@ describe('GamepadInput', () => { 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); + expect(input.isButtonDown(BT.BTN_A, 0)).toBe(true); + expect(input.isButtonPressed(BT.BTN_A, 0, undefined, 10)).toBe(true); + expect(input.isButtonReleased(BT.BTN_A, 0)).toBe(false); input.endFrame(10); - expect(input.isButtonPressed(BTN_A, 0, undefined, 11)).toBe(false); + expect(input.isButtonPressed(BT.BTN_A, 0, undefined, 11)).toBe(false); pads[0] = makeGamepad({ pressed: [] }); - expect(input.isButtonReleased(BTN_A, 0)).toBe(true); + expect(input.isButtonReleased(BT.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); + expect(input.isButtonPressed(BT.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); + expect(input.isButtonPressed(BT.BTN_A, 0, 3, 6)).toBe(false); + expect(input.isButtonPressed(BT.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); + expect(input.isButtonDown(BT.BTN_A | BT.BTN_B, 0)).toBe(true); + expect(input.isButtonDown(BT.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); + expect(input.isButtonDown(BT.BTN_UP, 0)).toBe(true); }); it('applies dead zone to stick axes and keeps trigger range', () => { @@ -118,18 +112,18 @@ describe('GamepadInput', () => { 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); + expect(input.getAxis(BT.AXIS_LEFT_X, 0)).toBe(0); + expect(input.getAxis(BT.AXIS_TRIGGER_L, 0)).toBe(0.25); input.setDeadZone(0.2); - expect(input.getAxis(AXIS_LEFT_X, 0)).toBeGreaterThan(0); + expect(input.getAxis(BT.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.isButtonDown(BT.BTN_A, -1)).toBe(false); + expect(input.isButtonPressed(BT.BTN_A, 99, undefined, 0)).toBe(false); + expect(input.isButtonReleased(BT.BTN_A, 3)).toBe(false); + expect(input.getAxis(BT.AXIS_LEFT_X, 2)).toBe(0); expect(input.connectedCount()).toBe(0); }); @@ -138,6 +132,6 @@ describe('GamepadInput', () => { input.endFrame(1); pads[0] = null; - expect(input.isButtonReleased(BTN_A | BTN_B, 0)).toBe(true); + expect(input.isButtonReleased(BT.BTN_A | BT.BTN_B, 0)).toBe(true); }); });