diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 36723e0..d60e173 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -344,33 +344,191 @@ describe('BT.cameraReset', () => { // #region BT.buttonDown / BT.buttonPressed / BT.buttonReleased describe('BT.buttonDown', () => { - it('returns false (not yet implemented)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false for gamepad buttons (not yet implemented)', () => { expect(BT.buttonDown(BT.BTN_A)).toBe(false); }); it('accepts an optional player index without throwing', () => { expect(BT.buttonDown(BT.BTN_START, 2)).toBe(false); }); + + it('delegates pointer buttons to the pointer subsystem', () => { + const isButtonDown = vi.fn().mockReturnValue(true); + 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); + }); + + it('returns false for pointer buttons when the engine is not initialized', () => { + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue(null); + + expect(BT.buttonDown(BT.BTN_POINTER_A)).toBe(false); + expect(BT.buttonDown(BT.BTN_POINTER_D, 3)).toBe(false); + }); }); describe('BT.buttonPressed', () => { - it('returns false (not yet implemented)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false for gamepad buttons (not yet implemented)', () => { expect(BT.buttonPressed(BT.BTN_B)).toBe(false); }); it('accepts an optional player index without throwing', () => { expect(BT.buttonPressed(BT.BTN_B, 1)).toBe(false); }); + + it('delegates pointer buttons to the pointer subsystem', () => { + const isButtonPressed = vi.fn().mockReturnValue(true); + 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); + }); }); describe('BT.buttonReleased', () => { - it('returns false (not yet implemented)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false for gamepad buttons (not yet implemented)', () => { expect(BT.buttonReleased(BT.BTN_X)).toBe(false); }); it('accepts an optional player index without throwing', () => { expect(BT.buttonReleased(BT.BTN_X, 3)).toBe(false); }); + + it('delegates pointer buttons to the pointer subsystem', () => { + const isButtonReleased = vi.fn().mockReturnValue(true); + 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); + }); +}); + +// #endregion + +// #region BT pointer queries + +describe('BT.pointerPos', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns Vector2i.zero() when the engine is not initialized', () => { + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue(null); + + const pos = BT.pointerPos(); + + expect(pos.x).toBe(0); + expect(pos.y).toBe(0); + }); + + it('delegates to the pointer subsystem with the supplied slot index', () => { + const expected = new Vector2i(80, 60); + const getPos = vi.fn().mockReturnValue(expected); + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ getPos } as never); + + const pos = BT.pointerPos(2); + + expect(getPos).toHaveBeenCalledWith(2); + expect(pos).toBe(expected); + }); + + it('defaults the pointer index to 0 (mouse)', () => { + const getPos = vi.fn().mockReturnValue(new Vector2i()); + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ getPos } as never); + + BT.pointerPos(); + + expect(getPos).toHaveBeenCalledWith(0); + }); +}); + +describe('BT.pointerDelta', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns Vector2i.zero() when the engine is not initialized', () => { + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue(null); + + const delta = BT.pointerDelta(); + + expect(delta.x).toBe(0); + expect(delta.y).toBe(0); + }); + + it('delegates to the pointer subsystem', () => { + const expected = new Vector2i(5, -3); + const getDelta = vi.fn().mockReturnValue(expected); + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ getDelta } as never); + + expect(BT.pointerDelta(1)).toBe(expected); + expect(getDelta).toHaveBeenCalledWith(1); + }); +}); + +describe('BT.pointerPosValid', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false when the engine is not initialized', () => { + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue(null); + + expect(BT.pointerPosValid()).toBe(false); + }); + + it('delegates to the pointer subsystem', () => { + const isValid = vi.fn().mockReturnValue(true); + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ isValid } as never); + + expect(BT.pointerPosValid(0)).toBe(true); + expect(isValid).toHaveBeenCalledWith(0); + }); +}); + +describe('BT.pointerScrollDelta', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 0 when the engine is not initialized', () => { + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue(null); + + expect(BT.pointerScrollDelta()).toBe(0); + }); + + it('delegates to the pointer subsystem', () => { + const getScrollDelta = vi.fn().mockReturnValue(42); + vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ getScrollDelta } as never); + + expect(BT.pointerScrollDelta()).toBe(42); + }); +}); + +// #endregion + +// #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); + }); }); // #endregion diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 7cbcec5..3180e96 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -102,15 +102,39 @@ export const BT = { /** Select button code. */ BTN_SELECT: 11, - /** Left mouse / primary pointer button code. */ + /** + * Primary pointer button code. + * + * Maps to mouse left for slot 0; touch contact for slots 1-3. + */ BTN_POINTER_A: 20, - /** Right mouse / secondary pointer button code. */ + /** + * Secondary pointer button code. + * + * Maps to mouse right for slot 0 (matches RetroBlit canonical, not the + * DOM `PointerEvent.button` index where 1 is middle and 2 is right). + * Always `false` for touch slots 1-3. + */ BTN_POINTER_B: 21, - /** Middle mouse / tertiary pointer button code. */ + /** + * Tertiary pointer button code. + * + * Maps to mouse middle for slot 0 (matches RetroBlit canonical, not the + * DOM `PointerEvent.button` index where 1 is middle and 2 is right). + * Always `false` for touch slots 1-3. + */ BTN_POINTER_C: 22, + /** + * Auxiliary pointer button code. + * + * 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, + // #endregion // #region Initialization @@ -489,47 +513,131 @@ export const BT = { // #endregion - // #region Input - Gamepad Buttons + // #region Input - Pointer /** - * Checks whether a gamepad button is currently held. + * Returns the position of the pointer in the given slot, in display coordinates. * - * This API is currently a stub and always returns `false`. + * Slot 0 is the mouse; slots 1 through 3 are touch / pen contacts assigned + * in arrival order. Returns `Vector2i.zero()` when the engine has not been + * initialized, the slot index is out of `[0, 3]`, or the slot has no live + * pointer. * - * @param _button - Button constant from the `BTN_*` set. - * @param _player - Zero-based player index. - * @returns `true` while the button remains pressed. Returns `false` until the input system is implemented. + * @param pointerIndex - Pointer slot (defaults to 0 = mouse). + * @returns Pointer position in display coordinates. */ - buttonDown: (_button: number, _player: number = 0): boolean => { - // TODO: Implement input system. + pointerPos: (pointerIndex: number = 0): Vector2i => { + return BTAPI.instance.getPointer()?.getPos(pointerIndex) ?? Vector2i.zero(); + }, + + /** + * Returns the position delta `(pos - prevPos)` for a pointer slot since the previous frame. + * + * Reset to zero by the engine at the start of each frame. Returns + * `Vector2i.zero()` when the engine is not initialized or the slot index + * is out of range. + * + * @param pointerIndex - Pointer slot (defaults to 0 = mouse). + * @returns Per-frame movement in display coordinates. + */ + pointerDelta: (pointerIndex: number = 0): Vector2i => { + return BTAPI.instance.getPointer()?.getDelta(pointerIndex) ?? Vector2i.zero(); + }, + + /** + * Reports whether the given pointer slot has a live pointer. + * + * For slot 0 (mouse) this is true while the mouse is hovering inside the + * canvas; cleared on `pointerleave`. For slots 1-3 (touch / pen) this is + * true while the contact is down. + * + * @param pointerIndex - Pointer slot (defaults to 0 = mouse). + * @returns `true` while the slot has live position data. + */ + pointerPosValid: (pointerIndex: number = 0): boolean => { + return BTAPI.instance.getPointer()?.isValid(pointerIndex) ?? false; + }, + + /** + * Returns the wheel scroll delta accumulated during the current frame, in pixels. + * + * Aggregates `WheelEvent.deltaY` across all wheel events received since + * the last frame, normalizing line and page delta modes to pixels. + * Returns `0` when the engine is not initialized. + * + * @returns Vertical scroll delta in pixels for the current frame. + */ + pointerScrollDelta: (): number => { + return BTAPI.instance.getPointer()?.getScrollDelta() ?? 0; + }, + + // #endregion + + // #region Input - Buttons + + /** + * Checks whether a button is currently held. + * + * For pointer buttons (`BTN_POINTER_A..D`), the second parameter is the + * pointer slot index (0 = mouse, 1-3 = touch / pen). For mouse slot 0: + * `A` is left, `B` is right, `C` is middle, `D` is back / forward + * (matches RetroBlit canonical, not DOM `PointerEvent.button` index). + * Touch / pen slots only support `A`; B/C/D return `false`. + * + * Gamepad codes (`BTN_UP..BTN_SELECT`) are not yet implemented and always + * return `false` until VV-135 lands. + * + * @param button - Button constant from the `BTN_*` set. + * @param player - Zero-based player index for gamepads, or pointer slot + * (0-3) for `BTN_POINTER_*`. + * @returns `true` while the button remains pressed. + */ + buttonDown: (button: number, player: number = 0): boolean => { + if (button >= 20 && button <= 23) { + return BTAPI.instance.getPointer()?.isButtonDown(button, player) ?? false; + } + + // TODO: Implement gamepad input (VV-135). return false; }, /** - * Checks whether a gamepad button was pressed on the current frame. + * Checks whether a button was pressed on the current frame. * - * This API is currently a stub and always returns `false`. + * Same parameter semantics as {@link buttonDown}; returns `true` only on + * the frame the button transitions from up to down. * - * @param _button - Button constant from the `BTN_*` set. - * @param _player - Zero-based player index. - * @returns `true` on the transition frame. Returns `false` until the input system is implemented. + * @param button - Button constant from the `BTN_*` set. + * @param player - Zero-based player index for gamepads, or pointer slot + * (0-3) for `BTN_POINTER_*`. + * @returns `true` on the transition frame. */ - buttonPressed: (_button: number, _player: number = 0): boolean => { - // TODO: Implement input system. + buttonPressed: (button: number, player: number = 0): boolean => { + if (button >= 20 && button <= 23) { + return BTAPI.instance.getPointer()?.isButtonPressed(button, player) ?? false; + } + + // TODO: Implement gamepad input (VV-135). return false; }, /** - * Checks whether a gamepad button was released on the current frame. + * Checks whether a button was released on the current frame. * - * This API is currently a stub and always returns `false`. + * Same parameter semantics as {@link buttonDown}; returns `true` only on + * the frame the button transitions from down to up. * - * @param _button - Button constant from the `BTN_*` set. - * @param _player - Zero-based player index. - * @returns `true` on the release frame. Returns `false` until the input system is implemented. + * @param button - Button constant from the `BTN_*` set. + * @param player - Zero-based player index for gamepads, or pointer slot + * (0-3) for `BTN_POINTER_*`. + * @returns `true` on the release frame. */ - buttonReleased: (_button: number, _player: number = 0): boolean => { - // TODO: Implement input system. + buttonReleased: (button: number, player: number = 0): boolean => { + if (button >= 20 && button <= 23) { + return BTAPI.instance.getPointer()?.isButtonReleased(button, player) ?? false; + } + + // TODO: Implement gamepad input (VV-135). return false; }, diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 5a28f5c..fc032eb 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -54,8 +54,10 @@ function makeMockCanvas(): HTMLCanvasElement { return { width: 0, height: 0, - style: { width: '', height: '' }, + style: { width: '', height: '', touchAction: '' }, getContext: (type: string) => (type === 'webgpu' ? createMockGPUCanvasContext() : null), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), } as unknown as HTMLCanvasElement; } @@ -147,6 +149,10 @@ describe('BTAPI', () => { expect(BTAPI.instance.getRenderer()).toBeNull(); }); + it('getPointer should return null before init', () => { + expect(BTAPI.instance.getPointer()).toBeNull(); + }); + it('getHardwareSettings should return null before init', () => { expect(BTAPI.instance.getHardwareSettings()).toBeNull(); }); @@ -360,6 +366,17 @@ describe('BTAPI', () => { expect(BTAPI.instance.getCanvas()).toBe(canvas); expect(BTAPI.instance.getRenderer()).not.toBeNull(); expect(BTAPI.instance.getHardwareSettings()).not.toBeNull(); + expect(BTAPI.instance.getPointer()).not.toBeNull(); + }); + + it('stop detaches pointer input so subsequent getPointer returns null', async () => { + await BTAPI.instance.initialize(makeMockDemo(), makeMockCanvas()); + + expect(BTAPI.instance.getPointer()).not.toBeNull(); + + BTAPI.instance.stop(); + + expect(BTAPI.instance.getPointer()).toBeNull(); }); it('should start the game loop on success', async () => { diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index a10ab10..1577c7b 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 { PointerInput } from '../input/PointerInput'; import type { Effect } from '../render/effects/Effect'; import { Renderer } from '../render/Renderer'; import type { Color32 } from '../utils/Color32'; @@ -91,8 +92,11 @@ export class BTAPI { /** Built-in 6x14 system font for BT.systemPrint(). */ private systemFont: BitmapFont | null = null; + /** Pointer / mouse / touch input subsystem. Created during {@link initialize}. */ + private pointer: PointerInput | null = null; + // TODO: Additional subsystems for future implementation: - // InputManager, AudioManager, AssetManager + // KeyboardInput (VV-134), GamepadInput (VV-135), AudioManager, AssetManager // #endregion @@ -206,7 +210,12 @@ export class BTAPI { // Create the built-in system font (synchronous, no GPU needed yet). this.systemFont = createSystemFont(); - // TODO: Initialize input, audio, etc. + // Initialize pointer input. Listeners attach to the canvas; per-frame + // resets are wired to GameLoop.onTickStart below. + this.pointer = new PointerInput(); + this.pointer.attach(canvas, this.hwSettings.displaySize); + + // TODO: Initialize keyboard (VV-134), gamepad (VV-135), audio. // Initialize the demo. console.log('[BT] Initializing demo'); @@ -239,6 +248,12 @@ export class BTAPI { this.renderer.endFrame(); } + + // Snapshot pointer state for next frame's edge detection / delta. + // Must run AFTER demo.update + demo.render have read the current + // state, so prev = "state when update last looked", letting any + // event that arrives before the next tick be visible as a transition. + this.pointer?.endFrame(); }, onFrameDrop, ); @@ -251,10 +266,15 @@ export class BTAPI { } /** - * Stops the active game loop if one exists. + * Stops the active game loop and detaches input listeners. + * + * The pointer subsystem is detached so DOM listeners do not leak across + * engine restarts (relevant in tests where the same DOM persists). */ public stop(): void { this.loop?.stop(); + this.pointer?.detach(); + this.pointer = null; } // #endregion @@ -328,6 +348,16 @@ export class BTAPI { return this.renderer; } + /** + * Gets the pointer input subsystem created during initialization. + * + * @returns Pointer input instance, or null when the engine has not been + * initialized yet (or has been stopped). + */ + public getPointer(): PointerInput | null { + return this.pointer; + } + /** * Gets the active engine palette. * diff --git a/src/input/PointerInput.test.ts b/src/input/PointerInput.test.ts new file mode 100644 index 0000000..ff66dc3 --- /dev/null +++ b/src/input/PointerInput.test.ts @@ -0,0 +1,789 @@ +// @vitest-environment happy-dom + +/** + * Unit tests for {@link PointerInput}. + * + * Verifies the slot-allocation rules, button mapping, coordinate conversion, + * per-frame reset, lifecycle (attach / detach), and edge-case safe-default + * behavior of the DOM-backed pointer input subsystem. The test file uses + * happy-dom for `PointerEvent`, `WheelEvent`, and canvas DOM APIs. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Vector2i } from '../utils/Vector2i'; +import { POINTER_SLOT_COUNT, PointerInput } from './PointerInput'; + +const BTN_POINTER_A = 20; +const BTN_POINTER_B = 21; +const BTN_POINTER_C = 22; +const BTN_POINTER_D = 23; + +const DISPLAY_WIDTH = 320; +const DISPLAY_HEIGHT = 240; + +const RECT_LEFT = 10; +const RECT_TOP = 20; +const RECT_WIDTH = 640; +const RECT_HEIGHT = 480; + +interface BoundingRect { + left: number; + top: number; + width: number; + height: number; +} + +/** + * Mounts a canvas and stubs `getBoundingClientRect` so coordinate conversion + * is deterministic. + */ +const createCanvas = ( + rect: BoundingRect = { left: RECT_LEFT, top: RECT_TOP, width: RECT_WIDTH, height: RECT_HEIGHT }, +): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + + canvas.getBoundingClientRect = vi.fn(() => ({ + left: rect.left, + top: rect.top, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + width: rect.width, + height: rect.height, + x: rect.left, + y: rect.top, + toJSON: () => ({}), + })) as unknown as typeof canvas.getBoundingClientRect; + + document.body.appendChild(canvas); + + return canvas; +}; + +/** + * Builds a PointerEvent. happy-dom supports the constructor; we keep this in + * a helper so the test cases stay terse. + */ +const pointerEvent = ( + type: string, + init: { + pointerId: number; + pointerType?: 'mouse' | 'touch' | 'pen'; + button?: number; + clientX?: number; + clientY?: number; + }, +): PointerEvent => + new PointerEvent(type, { + pointerId: init.pointerId, + pointerType: init.pointerType ?? 'mouse', + button: init.button ?? 0, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + bubbles: true, + cancelable: true, + }); + +describe('PointerInput', () => { + let canvas: HTMLCanvasElement; + let input: PointerInput; + + beforeEach(() => { + canvas = createCanvas(); + input = new PointerInput(); + input.attach(canvas, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); + }); + + afterEach(() => { + input.detach(); + canvas.remove(); + }); + + // #region Construction + + describe('construction', () => { + it('initializes all slots as inactive', () => { + const fresh = new PointerInput(); + + for (let i = 0; i < POINTER_SLOT_COUNT; i++) { + expect(fresh.isValid(i)).toBe(false); + } + + expect(fresh.getScrollDelta()).toBe(0); + }); + }); + + // #endregion + + // #region Lifecycle + + describe('attach / detach', () => { + it('sets canvas.style.touchAction to "none" on attach', () => { + const c = createCanvas(); + const p = new PointerInput(); + + expect(c.style.touchAction).toBe(''); + p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); + expect(c.style.touchAction).toBe('none'); + + p.detach(); + c.remove(); + }); + + it('restores the original touchAction on detach', () => { + const c = createCanvas(); + c.style.touchAction = 'pan-y'; + const p = new PointerInput(); + + p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); + expect(c.style.touchAction).toBe('none'); + + p.detach(); + expect(c.style.touchAction).toBe('pan-y'); + + c.remove(); + }); + + it('removes listeners on detach so subsequent events are ignored', () => { + input.detach(); + + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isValid(0)).toBe(false); + }); + + it('safe to call detach before attach', () => { + const p = new PointerInput(); + + expect(() => p.detach()).not.toThrow(); + }); + + it('safe to call attach twice (re-binds listeners cleanly)', () => { + const otherCanvas = createCanvas(); + + input.attach(otherCanvas, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); + + otherCanvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 5, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isValid(0)).toBe(true); + + otherCanvas.remove(); + }); + }); + + // #endregion + + // #region Mouse Slot 0 + + describe('mouse routing (slot 0)', () => { + it('routes mouse pointerdown button 0 to slot 0 with BTN_POINTER_A held', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isValid(0)).toBe(true); + expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(true); + expect(input.isButtonDown(BTN_POINTER_B, 0)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_C, 0)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_D, 0)).toBe(false); + }); + + it.each([ + { domButton: 0, btn: BTN_POINTER_A, name: 'A (left)' }, + { domButton: 2, btn: BTN_POINTER_B, name: 'B (right)' }, + { domButton: 1, btn: BTN_POINTER_C, name: 'C (middle)' }, + { domButton: 3, btn: BTN_POINTER_D, name: 'D (back)' }, + { domButton: 4, btn: BTN_POINTER_D, name: 'D (forward)' }, + ])('maps DOM button $domButton to BTN_POINTER_$name', ({ domButton, btn }) => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: domButton, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isButtonDown(btn, 0)).toBe(true); + }); + + it('activates slot 0 on a mouse pointermove without a prior pointerdown', () => { + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), + ); + + expect(input.isValid(0)).toBe(true); + }); + + it('clears all four mouse buttons and deactivates slot 0 on pointerleave', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 2, + clientX: 50, + clientY: 50, + }), + ); + + canvas.dispatchEvent( + pointerEvent('pointerleave', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), + ); + + expect(input.isValid(0)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_B, 0)).toBe(false); + }); + + it('keeps slot 0 valid after pointerup (only the button releases)', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + + canvas.dispatchEvent( + pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50 }), + ); + + expect(input.isValid(0)).toBe(true); + expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(false); + }); + }); + + // #endregion + + // #region Touch Slot Allocation + + describe('touch slot allocation', () => { + it('routes three sequential touches to slots 1, 2, 3 in arrival order', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 100, + pointerType: 'touch', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 101, + pointerType: 'touch', + button: 0, + clientX: 20, + clientY: 20, + }), + ); + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 102, + pointerType: 'touch', + button: 0, + clientX: 30, + clientY: 30, + }), + ); + + expect(input.isValid(1)).toBe(true); + expect(input.isValid(2)).toBe(true); + expect(input.isValid(3)).toBe(true); + expect(input.isButtonDown(BTN_POINTER_A, 1)).toBe(true); + expect(input.isButtonDown(BTN_POINTER_A, 2)).toBe(true); + expect(input.isButtonDown(BTN_POINTER_A, 3)).toBe(true); + }); + + it('drops a 4th simultaneous touch silently when all slots are full', () => { + for (let i = 0; i < 4; i++) { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 200 + i, + pointerType: 'touch', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + } + + // Slots 1-3 occupied; 4th touch should not throw or appear anywhere. + expect(input.isValid(1)).toBe(true); + expect(input.isValid(2)).toBe(true); + expect(input.isValid(3)).toBe(true); + }); + + it('frees a touch slot on pointerup; the next touch reuses it', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 300, + pointerType: 'touch', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + canvas.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 300, + pointerType: 'touch', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + + expect(input.isValid(1)).toBe(false); + + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 301, + pointerType: 'touch', + button: 0, + clientX: 20, + clientY: 20, + }), + ); + + expect(input.isValid(1)).toBe(true); + }); + + it('frees a touch slot on pointercancel', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 400, + pointerType: 'touch', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + + canvas.dispatchEvent( + pointerEvent('pointercancel', { pointerId: 400, pointerType: 'touch', clientX: 10, clientY: 10 }), + ); + + expect(input.isValid(1)).toBe(false); + }); + + it('reports BTN_POINTER_B/C/D as false for touch slots even when valid', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 500, + pointerType: 'touch', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + + expect(input.isValid(1)).toBe(true); + expect(input.isButtonDown(BTN_POINTER_A, 1)).toBe(true); + expect(input.isButtonDown(BTN_POINTER_B, 1)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_C, 1)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_D, 1)).toBe(false); + }); + + it('routes pen input to touch slots 1-3 (not slot 0)', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 600, + pointerType: 'pen', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + + expect(input.isValid(0)).toBe(false); + expect(input.isValid(1)).toBe(true); + }); + }); + + // #endregion + + // #region Coordinate Conversion + + describe('coordinate conversion', () => { + it('maps clientX/clientY to display coordinates via getBoundingClientRect', () => { + // rect = { left:10, top:20, width:640, height:480 } + // clientX 170 -> (170-10)/640 = 0.25 of width -> 0.25 * 320 = 80 + // clientY 140 -> (140-20)/480 = 0.25 of height -> 0.25 * 240 = 60 + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 170, clientY: 140 }), + ); + + const pos = input.getPos(0); + + expect(pos.x).toBe(80); + expect(pos.y).toBe(60); + }); + + it('does not throw on a zero-sized canvas (skips the position update)', () => { + const c = createCanvas({ left: 0, top: 0, width: 0, height: 0 }); + const p = new PointerInput(); + + p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); + + expect(() => { + c.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), + ); + }).not.toThrow(); + + // Position stays at zero since the update is skipped to avoid NaN. + const pos = p.getPos(0); + + expect(pos.x).toBe(0); + expect(pos.y).toBe(0); + + p.detach(); + c.remove(); + }); + }); + + // #endregion + + // #region Per-Frame State + + // The lifecycle model: each rAF tick is "events arrive (between ticks) -> + // update / render read state -> endFrame snapshots current as prev". The + // tests simulate one tick by dispatching events (the inter-frame window), + // querying state (the update / render phase), then calling endFrame. + describe('endFrame and per-frame state', () => { + it('reports delta from prev snapshot to current pos', () => { + // Tick 1 inter-frame: activation move. Slot 0 becomes valid and + // its prevPos is synced to the entry pos so the first delta is + // zero (see "reports zero delta on the first frame after mouse + // activation"). Closing tick 1 with endFrame leaves prev = pos. + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 170, clientY: 140 }), + ); + input.endFrame(); + + // Tick 2 inter-frame: a single further move. + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 186, clientY: 156 }), + ); + + // Tick 2 update: delta = current pos - prev pos snapshotted at + // the end of tick 1. + // rect = { left:10, top:20, width:640, height:480 }, display 320x240. + // (170, 140) -> (80, 60). (186, 156) -> (88, 68). + // Expected delta = (88 - 80, 68 - 60) = (8, 8). + const delta = input.getDelta(0); + expect(delta.x).toBe(8); + expect(delta.y).toBe(8); + }); + + it('zeroes delta after endFrame snapshots the new prevPos', () => { + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 60 }), + ); + + input.endFrame(); + + // Without further movement, the next read sees no delta. + const delta = input.getDelta(0); + expect(delta.x).toBe(0); + expect(delta.y).toBe(0); + }); + + it('zeroes scrollDelta on endFrame', () => { + canvas.dispatchEvent( + new WheelEvent('wheel', { deltaY: 100, deltaMode: 0, bubbles: true, cancelable: true }), + ); + + expect(input.getScrollDelta()).toBe(100); + + input.endFrame(); + + expect(input.getScrollDelta()).toBe(0); + }); + + it('reports isButtonPressed only on the tick after the button transitions to down', () => { + // Inter-frame window: button goes down. + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + + // First tick after the down event sees the press edge. + expect(input.isButtonPressed(BTN_POINTER_A, 0)).toBe(true); + + input.endFrame(); + + // Subsequent tick: button is still held but no new edge. + expect(input.isButtonPressed(BTN_POINTER_A, 0)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(true); + }); + + it('reports isButtonReleased only on the tick after the button transitions to up', () => { + // Tick 1 inter-frame: button goes down. + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + input.endFrame(); + + // Tick 2 inter-frame: button goes up. + canvas.dispatchEvent( + pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 50, clientY: 50 }), + ); + + expect(input.isButtonReleased(BTN_POINTER_A, 0)).toBe(true); + + input.endFrame(); + + expect(input.isButtonReleased(BTN_POINTER_A, 0)).toBe(false); + }); + + it('preserves pos and delta on the touch-release frame', () => { + // Simulates a tick lifecycle with a touch landing, dragging, then + // releasing - the demo's drag-and-flick pattern. The release-frame + // delta must reflect the final movement so the demo can use it as + // throw velocity. + + // Tick 1 inter-frame: touch lands. + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 700, + pointerType: 'touch', + button: 0, + clientX: 170, + clientY: 140, + }), + ); + input.endFrame(); + + // Tick 2 inter-frame: touch moves a small step. + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 700, pointerType: 'touch', clientX: 178, clientY: 148 }), + ); + input.endFrame(); + + // Tick 3 inter-frame: touch moves further, then releases. + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 700, pointerType: 'touch', clientX: 186, clientY: 156 }), + ); + canvas.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 700, + pointerType: 'touch', + button: 0, + clientX: 186, + clientY: 156, + }), + ); + + // Tick 3 update: must see the release edge AND a non-zero delta + // representing the final movement (so a demo can throw with it). + expect(input.isButtonReleased(BTN_POINTER_A, 1)).toBe(true); + + const releasePos = input.getPos(1); + const releaseDelta = input.getDelta(1); + + // clientX 186 in a 640-wide rect at left=10 is (186-10)/640 = 0.275 -> 0.275 * 320 = 88. + // clientX 178 -> (178-10)/640 = 0.2625 -> 0.2625 * 320 = 84. + // So the final movement of the tick is from (84, 50) to (88, 53). + expect(releasePos.x).toBe(88); + expect(releaseDelta.x).toBeGreaterThan(0); + expect(releaseDelta.y).toBeGreaterThan(0); + }); + + it('reports zero delta on the first frame after touch activation', () => { + // Without prevPos sync on activation, the very first frame's + // delta would be (touchPos - 0) which feeds a phantom velocity + // into demos that read pointerDelta on the press frame. + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 800, + pointerType: 'touch', + button: 0, + clientX: 170, + clientY: 140, + }), + ); + + const delta = input.getDelta(1); + + expect(delta.x).toBe(0); + expect(delta.y).toBe(0); + }); + + it('reports zero delta on the first frame after mouse activation', () => { + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 170, clientY: 140 }), + ); + + const delta = input.getDelta(0); + + expect(delta.x).toBe(0); + expect(delta.y).toBe(0); + }); + + it('does not lose the press edge when down events arrive between ticks', () => { + // Regression test for the original bug: with prev snapshotted at + // start-of-tick, an event between tick N and tick N+1 would set + // a=true, then beginFrame N+1 would copy prev=true, hiding the + // press from update. With endFrame timing, prev = "a at last + // endFrame" = false, so the transition is correctly observed. + input.endFrame(); // tick 1 end, no events + + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 2, + clientX: 50, + clientY: 50, + }), + ); + + // Tick 2 update: must see the press edge. + expect(input.isButtonPressed(BTN_POINTER_B, 0)).toBe(true); + }); + }); + + // #endregion + + // #region Wheel + + describe('wheel handling', () => { + it('uses raw deltaY for deltaMode 0 (PIXEL)', () => { + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 5, deltaMode: 0, bubbles: true, cancelable: true })); + + expect(input.getScrollDelta()).toBe(5); + }); + + it('multiplies deltaY by 16 for deltaMode 1 (LINE)', () => { + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 5, deltaMode: 1, bubbles: true, cancelable: true })); + + expect(input.getScrollDelta()).toBe(80); + }); + + it('multiplies deltaY by window.innerHeight for deltaMode 2 (PAGE)', () => { + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 5, deltaMode: 2, bubbles: true, cancelable: true })); + + expect(input.getScrollDelta()).toBe(5 * window.innerHeight); + }); + + it('accumulates multiple wheel events within a frame', () => { + canvas.dispatchEvent( + new WheelEvent('wheel', { deltaY: 10, deltaMode: 0, bubbles: true, cancelable: true }), + ); + canvas.dispatchEvent( + new WheelEvent('wheel', { deltaY: -3, deltaMode: 0, bubbles: true, cancelable: true }), + ); + + expect(input.getScrollDelta()).toBe(7); + }); + + it('calls preventDefault on wheel events', () => { + const wheelEvent = new WheelEvent('wheel', { deltaY: 5, deltaMode: 0, bubbles: true, cancelable: true }); + const preventDefault = vi.spyOn(wheelEvent, 'preventDefault'); + + canvas.dispatchEvent(wheelEvent); + + expect(preventDefault).toHaveBeenCalled(); + }); + }); + + // #endregion + + // #region Context Menu + + describe('context menu suppression', () => { + it('calls preventDefault on contextmenu events', () => { + const event = new Event('contextmenu', { bubbles: true, cancelable: true }); + const preventDefault = vi.spyOn(event, 'preventDefault'); + + canvas.dispatchEvent(event); + + expect(preventDefault).toHaveBeenCalled(); + }); + }); + + // #endregion + + // #region Safe Defaults + + describe('out-of-range slot index', () => { + it.each([-1, POINTER_SLOT_COUNT, 99, 1.5, Number.NaN])('returns safe defaults for slot %s', (slot) => { + const pos = input.getPos(slot); + const delta = input.getDelta(slot); + + expect(pos.x).toBe(0); + expect(pos.y).toBe(0); + expect(delta.x).toBe(0); + expect(delta.y).toBe(0); + expect(input.isValid(slot)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_A, slot)).toBe(false); + expect(input.isButtonPressed(BTN_POINTER_A, slot)).toBe(false); + expect(input.isButtonReleased(BTN_POINTER_A, slot)).toBe(false); + }); + + it('returns false for unknown button codes', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isButtonDown(99, 0)).toBe(false); + expect(input.isButtonPressed(99, 0)).toBe(false); + expect(input.isButtonReleased(99, 0)).toBe(false); + }); + }); + + // #endregion +}); diff --git a/src/input/PointerInput.ts b/src/input/PointerInput.ts new file mode 100644 index 0000000..950abbb --- /dev/null +++ b/src/input/PointerInput.ts @@ -0,0 +1,856 @@ +/** + * Pointer / mouse / touch input subsystem. + * + * Tracks up to four logical pointer slots: + * - slot 0 is reserved for the mouse + * - slots 1-3 fill from touch / pen contacts in arrival order + * + * Mirrors the slot-allocation rules of RetroBlit's `RBInput.cs` while adapting + * the event source from Unity's polling input API to DOM `PointerEvent` / + * `WheelEvent` listeners on the rendering canvas. + * + * Coordinate output is in the engine's logical display space (the + * `displaySize` configured by the demo via `HardwareSettings`), independent + * of the canvas's CSS or backing-buffer pixel size. This matches the rest of + * the rendering API where everything works in display coordinates. + */ + +import type { Vector2i } from '../utils/Vector2i'; +import { Vector2i as Vector2iImpl } from '../utils/Vector2i'; + +// #region Constants + +/** Maximum number of simultaneously tracked pointers (slot 0 = mouse, 1-3 = touch / pen). */ +export const POINTER_SLOT_COUNT = 4; + +/** Pixels-per-line conversion for `WheelEvent.deltaMode === DOM_DELTA_LINE`. */ +const WHEEL_LINE_HEIGHT_PX = 16; + +/** Primary pointer button code (mouse left / touch contact). Mirrors `BT.BTN_POINTER_A`. */ +const BTN_POINTER_A = 20; + +/** Secondary pointer button code (mouse right). Mirrors `BT.BTN_POINTER_B`. */ +const BTN_POINTER_B = 21; + +/** Tertiary pointer button code (mouse middle). Mirrors `BT.BTN_POINTER_C`. */ +const BTN_POINTER_C = 22; + +/** Auxiliary pointer button code (mouse back / forward). Mirrors `BT.BTN_POINTER_D`. */ +const BTN_POINTER_D = 23; + +// #endregion + +// #region Types + +/** + * Internal per-slot pointer state. + * + * `pos` and `prevPos` are reused `Vector2i` instances; allocation discipline + * keeps the event hot path free of per-call object churn. + */ +interface PointerSlot { + /** Native `pointerId` currently bound to this slot, or `null` when the slot is empty. */ + pointerId: number | null; + + /** Source pointer device kind, or `null` when the slot is empty. */ + pointerType: 'mouse' | 'touch' | 'pen' | null; + + /** True when this slot represents an active pointer (mouse hovering, touch in contact). */ + valid: boolean; + + /** Last known position in display coordinates (mutated in place by event handlers). */ + pos: Vector2i; + + /** Snapshot of `pos` taken at `endFrame()`; used to compute the per-frame delta. */ + prevPos: Vector2i; + + /** Current state of `BTN_POINTER_A` for this slot. */ + a: boolean; + + /** Current state of `BTN_POINTER_B` for this slot. */ + b: boolean; + + /** Current state of `BTN_POINTER_C` for this slot. */ + c: boolean; + + /** Current state of `BTN_POINTER_D` for this slot. */ + d: boolean; + + /** Snapshot of `a` taken at `endFrame()`, used for pressed/released edge detection. */ + prevA: boolean; + + /** Snapshot of `b` taken at `endFrame()`. */ + prevB: boolean; + + /** Snapshot of `c` taken at `endFrame()`. */ + prevC: boolean; + + /** Snapshot of `d` taken at `endFrame()`. */ + prevD: boolean; +} + +// #endregion + +// #region PointerInput Class + +/** + * DOM-backed pointer input tracker. + * + * Construct, then call {@link attach} with the rendering canvas and the + * engine's logical display size. Call {@link endFrame} once per rAF tick + * AFTER the demo's `update()` and `render()` so per-frame deltas reset and + * edge-detection prev-state is captured for the next tick. + * + * Position queries return display-space coordinates. The slot index parameter + * (`0` = mouse, `1`..`3` = touch / pen in arrival order) is range-checked; + * out-of-range queries return safe defaults rather than throwing. + */ +export class PointerInput { + // #region State + + /** + * Per-slot pointer state. Fixed-length tuple of {@link POINTER_SLOT_COUNT} + * entries (0 = mouse, 1-3 = touch / pen). + */ + private readonly slots: readonly [PointerSlot, PointerSlot, PointerSlot, PointerSlot]; + + /** Maps native `pointerId` -> slot index, so up / move / cancel events can route correctly. */ + private readonly pointerIdToSlot: Map = new Map(); + + /** Accumulated wheel delta in pixels for the current frame. */ + private scrollDeltaY: number = 0; + + /** Canvas to receive listeners; `null` until {@link attach} is called. */ + private canvas: HTMLCanvasElement | null = null; + + /** Logical display size used for screen-to-display coordinate conversion. */ + private displaySize: Vector2i | null = null; + + /** Captured `canvas.style.touchAction` value, restored by {@link detach}. */ + private originalTouchAction: string | null = null; + + // #endregion + + // #region Bound Listeners + + private readonly onPointerMove: (event: PointerEvent) => void; + private readonly onPointerDown: (event: PointerEvent) => void; + private readonly onPointerUp: (event: PointerEvent) => void; + private readonly onPointerCancel: (event: PointerEvent) => void; + private readonly onPointerLeave: (event: PointerEvent) => void; + private readonly onWheel: (event: WheelEvent) => void; + private readonly onContextMenu: (event: Event) => void; + + // #endregion + + // #region Constructor + + /** + * Creates a `PointerInput` with all slots inactive. + * + * Listeners are bound here so {@link detach} can remove the same function + * references that {@link attach} added. + */ + constructor() { + this.slots = [this.createEmptySlot(), this.createEmptySlot(), this.createEmptySlot(), this.createEmptySlot()]; + + this.onPointerMove = (event) => this.handlePointerMove(event); + this.onPointerDown = (event) => this.handlePointerDown(event); + this.onPointerUp = (event) => this.handlePointerUp(event); + this.onPointerCancel = (event) => this.handlePointerCancel(event); + this.onPointerLeave = (event) => this.handlePointerLeave(event); + this.onWheel = (event) => this.handleWheel(event); + this.onContextMenu = (event) => event.preventDefault(); + } + + // #endregion + + // #region Lifecycle + + /** + * Attaches DOM listeners to the canvas and stores the logical display size. + * + * Installs three page-interaction guards on the canvas: + * - `wheel.preventDefault()` (with `{ passive: false }`) so the browser + * doesn't scroll the page when the user spins the wheel over the game + * - `canvas.style.touchAction = 'none'` so iOS Safari doesn't intercept + * touches for pinch-zoom or double-tap-zoom + * - `contextmenu.preventDefault()` so right-click feeds `BTN_POINTER_B` + * instead of popping the OS context menu + * + * @param canvas - Canvas element rendering the engine output. + * @param displaySize - Logical display size used to convert screen coordinates. + */ + public attach(canvas: HTMLCanvasElement, displaySize: Vector2i): void { + this.detach(); + + this.canvas = canvas; + this.displaySize = displaySize; + + this.originalTouchAction = canvas.style.touchAction; + canvas.style.touchAction = 'none'; + + canvas.addEventListener('pointermove', this.onPointerMove); + canvas.addEventListener('pointerdown', this.onPointerDown); + canvas.addEventListener('pointerup', this.onPointerUp); + canvas.addEventListener('pointercancel', this.onPointerCancel); + canvas.addEventListener('pointerleave', this.onPointerLeave); + canvas.addEventListener('wheel', this.onWheel, { passive: false }); + canvas.addEventListener('contextmenu', this.onContextMenu); + } + + /** + * Removes all DOM listeners and resets per-slot state. + * + * Restores the canvas's original `touchAction` value (whatever it was at + * the time of {@link attach}) so this class doesn't leak CSS state. + * Safe to call repeatedly or before {@link attach}. + */ + public detach(): void { + const canvas = this.canvas; + + if (canvas !== null) { + canvas.removeEventListener('pointermove', this.onPointerMove); + canvas.removeEventListener('pointerdown', this.onPointerDown); + canvas.removeEventListener('pointerup', this.onPointerUp); + canvas.removeEventListener('pointercancel', this.onPointerCancel); + canvas.removeEventListener('pointerleave', this.onPointerLeave); + canvas.removeEventListener('wheel', this.onWheel); + canvas.removeEventListener('contextmenu', this.onContextMenu); + + if (this.originalTouchAction !== null) { + canvas.style.touchAction = this.originalTouchAction; + } + } + + this.canvas = null; + this.displaySize = null; + this.originalTouchAction = null; + + this.pointerIdToSlot.clear(); + this.scrollDeltaY = 0; + + for (const slot of this.slots) { + this.resetSlot(slot); + } + } + + /** + * Snapshots per-frame state at the END of each rAF tick. + * + * Must be called once per tick AFTER the demo's `update()` and `render()` + * have read the current input state. Snapshots `pos -> prevPos` so the + * next frame's `getDelta` reflects movement during the inter-frame gap, + * snapshots `a..d -> prevA..prevD` so the next frame's `isButtonPressed` + * / `isButtonReleased` see the transition, and clears the accumulated + * wheel delta so the next frame's `getScrollDelta` accumulates fresh. + * + * The end-of-tick timing matters: DOM pointer / wheel events fire + * asynchronously between rAF callbacks. Snapshotting at the START of a + * tick would capture the post-event state as `prev`, hiding edges that + * occurred during the inter-frame gap. By snapshotting at the END of the + * tick, `prev` is the state at the moment `update()` last looked, and + * any event that arrives before the next `update()` is correctly visible + * as a transition. + */ + public endFrame(): void { + for (const slot of this.slots) { + slot.prevPos.copyFrom(slot.pos); + slot.prevA = slot.a; + slot.prevB = slot.b; + slot.prevC = slot.c; + slot.prevD = slot.d; + } + + this.scrollDeltaY = 0; + } + + // #endregion + + // #region Public Queries + + /** + * Returns the position of the pointer in slot `slot` in display coordinates. + * + * Returns `Vector2i.zero()` when the slot index is out of `[0, POINTER_SLOT_COUNT - 1]`. + * The returned value is a clone; callers may mutate it without affecting internal state. + * + * @param slot - Pointer slot index (0 = mouse, 1-3 = touch / pen). + * @returns Pointer position in display coordinates, or `Vector2i.zero()` for invalid slots. + */ + public getPos(slot: number): Vector2i { + const s = this.getSlotOrNull(slot); + + if (s === null) { + return Vector2iImpl.zero(); + } + + return s.pos.clone(); + } + + /** + * Returns the position delta `(pos - prevPos)` for slot `slot` since the last `endFrame()`. + * + * Returns `Vector2i.zero()` when the slot index is out of range. + * + * @param slot - Pointer slot index. + * @returns Per-frame delta in display coordinates. + */ + public getDelta(slot: number): Vector2i { + const s = this.getSlotOrNull(slot); + + if (s === null) { + return Vector2iImpl.zero(); + } + + return new Vector2iImpl(s.pos.x - s.prevPos.x, s.pos.y - s.prevPos.y); + } + + /** + * Reports whether the pointer in slot `slot` is currently active. + * + * For slot 0 (mouse) this means a `pointermove` has been observed inside + * the canvas and no subsequent `pointerleave` has cleared it. For touch / + * pen slots this means the contact is still down. + * + * @param slot - Pointer slot index. + * @returns `true` when the slot has live position data. + */ + public isValid(slot: number): boolean { + return this.getSlotOrNull(slot)?.valid ?? false; + } + + /** + * Returns the wheel scroll delta accumulated during the current frame, in pixels. + * + * Reset to zero by `endFrame()`. Aggregates `WheelEvent.deltaY` across + * all wheel events received since the last reset, normalizing + * `deltaMode === DOM_DELTA_LINE` (1 line = 16 px) and `DOM_DELTA_PAGE` + * (1 page = `window.innerHeight` px). + * + * @returns Vertical scroll delta for the current frame in pixels. + */ + public getScrollDelta(): number { + return this.scrollDeltaY; + } + + /** + * Reports whether the given pointer button is held in slot `slot`. + * + * For slot 0 (mouse): `BTN_POINTER_A` is left, `B` is right, `C` is middle, + * `D` is back / forward (matches RetroBlit canonical, not DOM + * `PointerEvent.button` index order). + * + * For slots 1-3 (touch / pen): only `BTN_POINTER_A` is ever true while the + * contact is down; `B`, `C`, `D` always return `false`. + * + * @param button - One of `BTN_POINTER_A..D`. + * @param slot - Pointer slot index. + * @returns `true` while the button remains pressed. + */ + public isButtonDown(button: number, slot: number): boolean { + const s = this.getSlotOrNull(slot); + + if (s === null || !s.valid) { + return false; + } + + switch (button) { + case BTN_POINTER_A: + return s.a; + case BTN_POINTER_B: + return s.b; + case BTN_POINTER_C: + return s.c; + case BTN_POINTER_D: + return s.d; + default: + return false; + } + } + + /** + * Reports whether the given pointer button transitioned to down on the current frame. + * + * @param button - One of `BTN_POINTER_A..D`. + * @param slot - Pointer slot index. + * @returns `true` only on the frame the button transitions from up to down. + */ + public isButtonPressed(button: number, slot: number): boolean { + const s = this.getSlotOrNull(slot); + + if (s === null) { + return false; + } + + switch (button) { + case BTN_POINTER_A: + return s.a && !s.prevA; + case BTN_POINTER_B: + return s.b && !s.prevB; + case BTN_POINTER_C: + return s.c && !s.prevC; + case BTN_POINTER_D: + return s.d && !s.prevD; + default: + return false; + } + } + + /** + * Reports whether the given pointer button transitioned to up on the current frame. + * + * @param button - One of `BTN_POINTER_A..D`. + * @param slot - Pointer slot index. + * @returns `true` only on the frame the button transitions from down to up. + */ + public isButtonReleased(button: number, slot: number): boolean { + const s = this.getSlotOrNull(slot); + + if (s === null) { + return false; + } + + switch (button) { + case BTN_POINTER_A: + return !s.a && s.prevA; + case BTN_POINTER_B: + return !s.b && s.prevB; + case BTN_POINTER_C: + return !s.c && s.prevC; + case BTN_POINTER_D: + return !s.d && s.prevD; + default: + return false; + } + } + + // #endregion + + // #region Event Handlers + + /** + * Routes a `pointermove` event: updates slot 0 for mouse, or the slot + * already bound to this `pointerId` for touch / pen. Drops touch / pen + * moves with no slot binding (allocation only happens on `pointerdown`). + * + * @param event - DOM pointer event from the canvas. + */ + private handlePointerMove(event: PointerEvent): void { + if (event.pointerType === 'mouse') { + const slot = this.slots[0]; + const wasValid = slot.valid; + + slot.pointerId = event.pointerId; + slot.pointerType = 'mouse'; + slot.valid = true; + + this.updateSlotPosition(slot, event.clientX, event.clientY); + + // Activation: sync prevPos to the entry pos so the first frame's + // delta is zero rather than a jump from (0, 0) (or wherever the + // slot was previously zeroed) to the entry point. + if (!wasValid) { + slot.prevPos.copyFrom(slot.pos); + } + + return; + } + + const slot = this.lookupSlotByPointerId(event.pointerId); + + if (slot === null) { + return; + } + + this.updateSlotPosition(slot, event.clientX, event.clientY); + } + + /** + * Routes a `pointerdown` event. Mouse events claim slot 0 and set the + * mapped button. Touch / pen events allocate the first free slot in + * 1..3 and set `BTN_POINTER_A`; events that arrive while all touch + * slots are full are dropped silently. + * + * @param event - DOM pointer event from the canvas. + */ + private handlePointerDown(event: PointerEvent): void { + if (event.pointerType === 'mouse') { + const slot = this.slots[0]; + const wasValid = slot.valid; + + slot.pointerId = event.pointerId; + slot.pointerType = 'mouse'; + slot.valid = true; + + this.updateSlotPosition(slot, event.clientX, event.clientY); + this.setMouseButton(slot, event.button, true); + + // Activation: sync prevPos so the first delta is zero. Without + // this, the very first read after the mouse enters would see a + // delta from (0, 0) to the entry point. + if (!wasValid) { + slot.prevPos.copyFrom(slot.pos); + } + + return; + } + + // Touch or pen: route to the first free slot in 1..POINTER_SLOT_COUNT - 1. + if (this.pointerIdToSlot.has(event.pointerId)) { + return; + } + + const slotIndex = this.allocateTouchSlot(); + + if (slotIndex === -1) { + // All touch slots are full; drop this event silently. + return; + } + + // eslint-disable-next-line security/detect-object-injection -- slotIndex returned by allocateTouchSlot is bounded to [1, POINTER_SLOT_COUNT) + const slot = this.slots[slotIndex]; + + if (slot === undefined) { + return; + } + + slot.pointerId = event.pointerId; + slot.pointerType = event.pointerType === 'pen' ? 'pen' : 'touch'; + slot.valid = true; + slot.a = true; + + this.updateSlotPosition(slot, event.clientX, event.clientY); + + // Touch / pen always allocates a fresh slot; sync prevPos so the + // first delta is zero. Without this, prevPos still holds the freed + // position from the previous occupant of this slot. + slot.prevPos.copyFrom(slot.pos); + + this.pointerIdToSlot.set(event.pointerId, slotIndex); + } + + /** + * Routes a `pointerup` event. Mouse events clear the matching button on + * slot 0 but leave the slot valid. Touch / pen events free their slot + * completely so a subsequent contact can reuse it. + * + * @param event - DOM pointer event from the canvas. + */ + private handlePointerUp(event: PointerEvent): void { + if (event.pointerType === 'mouse') { + const slot = this.slots[0]; + + this.setMouseButton(slot, event.button, false); + + return; + } + + const slotIndex = this.pointerIdToSlot.get(event.pointerId); + + if (slotIndex === undefined) { + return; + } + + this.freeSlot(slotIndex); + } + + /** + * Routes a `pointercancel` event (browser-initiated abort). Behaves the + * same as `pointerleave`: deactivates slot 0 for mouse, frees the slot + * for touch / pen. + * + * @param event - DOM pointer event from the canvas. + */ + private handlePointerCancel(event: PointerEvent): void { + if (event.pointerType === 'mouse') { + this.deactivateMouseSlot(); + + return; + } + + const slotIndex = this.pointerIdToSlot.get(event.pointerId); + + if (slotIndex === undefined) { + return; + } + + this.freeSlot(slotIndex); + } + + /** + * Routes a `pointerleave` event (pointer left the canvas bounds). For + * mouse: deactivates slot 0 and clears all buttons. For touch / pen: + * frees the slot. + * + * @param event - DOM pointer event from the canvas. + */ + private handlePointerLeave(event: PointerEvent): void { + if (event.pointerType === 'mouse') { + this.deactivateMouseSlot(); + + return; + } + + const slotIndex = this.pointerIdToSlot.get(event.pointerId); + + if (slotIndex === undefined) { + return; + } + + this.freeSlot(slotIndex); + } + + /** + * Routes a `wheel` event: normalizes `deltaY` to pixels (line and page + * delta modes are converted) and accumulates into `scrollDeltaY` for + * the current frame. Calls `preventDefault` so the page does not scroll + * while the user is interacting with the canvas. + * + * @param event - DOM wheel event from the canvas. + */ + private handleWheel(event: WheelEvent): void { + event.preventDefault(); + + let pixels = event.deltaY; + + if (event.deltaMode === 1) { + // DOM_DELTA_LINE + pixels *= WHEEL_LINE_HEIGHT_PX; + } else if (event.deltaMode === 2) { + // DOM_DELTA_PAGE + pixels *= window.innerHeight; + } + + this.scrollDeltaY += pixels; + } + + // #endregion + + // #region Slot Helpers + + /** + * Builds a fresh `PointerSlot` with newly allocated `Vector2i` instances + * so each slot owns its own position storage. + * + * @returns Slot in the empty / inactive state. + */ + private createEmptySlot(): PointerSlot { + return { + pointerId: null, + pointerType: null, + valid: false, + pos: new Vector2iImpl(0, 0), + prevPos: new Vector2iImpl(0, 0), + a: false, + b: false, + c: false, + d: false, + prevA: false, + prevB: false, + prevC: false, + prevD: false, + }; + } + + /** + * Returns a slot to the empty / inactive state without reallocating its + * `Vector2i` storage. + * + * @param slot - Slot to reset in place. + */ + private resetSlot(slot: PointerSlot): void { + slot.pointerId = null; + slot.pointerType = null; + slot.valid = false; + slot.pos.set(0, 0); + slot.prevPos.set(0, 0); + slot.a = false; + slot.b = false; + slot.c = false; + slot.d = false; + slot.prevA = false; + slot.prevB = false; + slot.prevC = false; + slot.prevD = false; + } + + /** + * Finds the first free touch / pen slot in `[1, POINTER_SLOT_COUNT)`. + * + * @returns Index of the free slot, or `-1` when slots 1..3 are all in use. + */ + private allocateTouchSlot(): number { + for (let i = 1; i < POINTER_SLOT_COUNT; i++) { + // eslint-disable-next-line security/detect-object-injection -- bounded loop counter + if (this.slots[i]?.pointerId === null) { + return i; + } + } + + return -1; + } + + /** + * Marks a touch / pen slot inactive: clears pointerId, valid flag, button + * state, and the pointerId -> slot mapping. Called on `pointerup`, + * `pointercancel`, and `pointerleave` for touch / pen events. + * + * Deliberately preserves `pos` and `prevPos` so the release-frame caller + * can still read the final position via `getPos` and the release-frame + * velocity via `getDelta`. The next pointerdown that reuses this slot + * resyncs `prevPos` so the first frame's delta starts at zero. + * + * @param slotIndex - Index of the slot to free. + */ + private freeSlot(slotIndex: number): void { + // eslint-disable-next-line security/detect-object-injection -- slotIndex resolved from pointerIdToSlot, always in [0, POINTER_SLOT_COUNT) + const slot = this.slots[slotIndex]; + + if (slot === undefined) { + return; + } + + if (slot.pointerId !== null) { + this.pointerIdToSlot.delete(slot.pointerId); + } + + slot.pointerId = null; + slot.pointerType = null; + slot.valid = false; + slot.a = false; + slot.b = false; + slot.c = false; + slot.d = false; + } + + /** + * Deactivates slot 0 (mouse) on `pointerleave` / `pointercancel`. + * + * Clears all four buttons and marks the slot invalid, but leaves `pos` + * intact so a subsequent `pointermove` can pick up where it left off. + */ + private deactivateMouseSlot(): void { + const slot = this.slots[0]; + + if (slot.pointerId !== null) { + this.pointerIdToSlot.delete(slot.pointerId); + } + + slot.pointerId = null; + slot.pointerType = null; + slot.valid = false; + slot.a = false; + slot.b = false; + slot.c = false; + slot.d = false; + } + + /** + * Maps a DOM `PointerEvent.button` value to the corresponding pointer + * button on slot 0 and sets it to `pressed`. + * + * The mapping intentionally follows RetroBlit canonical (A=left, B=right, + * C=middle), not the DOM index order (where 1 is middle and 2 is right). + * Buttons 3 and 4 (back / forward) both map to `D`. Other values are + * silently ignored. + * + * @param slot - Mouse slot to update (always slot 0). + * @param button - DOM `PointerEvent.button` value. + * @param pressed - `true` to set the button down, `false` to release it. + */ + private setMouseButton(slot: PointerSlot, button: number, pressed: boolean): void { + switch (button) { + case 0: + slot.a = pressed; + break; + case 2: + slot.b = pressed; + break; + case 1: + slot.c = pressed; + break; + case 3: + case 4: + slot.d = pressed; + break; + default: + break; + } + } + + /** + * Converts viewport coordinates from a DOM event to display-space pixels + * and writes them to the slot's `pos` in place. + * + * Skips the update when the canvas has zero size (no layout yet) so the + * division does not produce NaN coordinates. + * + * @param slot - Slot whose position to update. + * @param clientX - DOM `clientX` from the source event. + * @param clientY - DOM `clientY` from the source event. + */ + private updateSlotPosition(slot: PointerSlot, clientX: number, clientY: number): void { + const canvas = this.canvas; + const displaySize = this.displaySize; + + if (canvas === null || displaySize === null) { + return; + } + + const rect = canvas.getBoundingClientRect(); + + // Guard against a zero-sized canvas (no layout yet) which would + // produce NaN coordinates from the division below. + if (rect.width === 0 || rect.height === 0) { + return; + } + + const x = Math.floor(((clientX - rect.left) / rect.width) * displaySize.x); + const y = Math.floor(((clientY - rect.top) / rect.height) * displaySize.y); + + slot.pos.set(x, y); + } + + /** + * Returns the slot at `index` if `index` is a valid slot, or `null` otherwise. + * + * Used by all public read methods to bounds-check the caller-supplied slot + * argument while keeping `noUncheckedIndexedAccess` happy. + * + * @param index - Slot index to look up. + * @returns Slot at the index, or `null` for out-of-range indices. + */ + private getSlotOrNull(index: number): PointerSlot | null { + if (!Number.isInteger(index) || index < 0 || index >= POINTER_SLOT_COUNT) { + return null; + } + + // eslint-disable-next-line security/detect-object-injection -- bounds checked above + return this.slots[index] ?? null; + } + + /** + * Looks up the slot bound to a native `pointerId`, or `null` when none is bound. + * + * Resolves the `pointerIdToSlot` map and the indexed slot lookup in one + * helper so event handlers can early-return without repeating the guards. + * + * @param pointerId - Native `pointerId` from a DOM pointer event. + * @returns Slot bound to this `pointerId`, or `null` when no slot is bound. + */ + private lookupSlotByPointerId(pointerId: number): PointerSlot | null { + const index = this.pointerIdToSlot.get(pointerId); + + if (index === undefined) { + return null; + } + + // eslint-disable-next-line security/detect-object-injection -- index originated from pointerIdToSlot, always in [0, POINTER_SLOT_COUNT) + return this.slots[index] ?? null; + } + + // #endregion +} + +// #endregion -- 2.54.0 From 9d07f8c4f653fe0321c6248f4de61b93ed90ec7c Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Tue, 5 May 2026 16:51:58 +0200 Subject: [PATCH 2/8] test(input): add missing branch coverage for PointerInput Covers uncovered branches that caused overall branch coverage to drop to 79.46% (below the 80% threshold) after adding PointerInput.ts: - isButtonPressed / isButtonReleased for BTN_POINTER_C and BTN_POINTER_D (and BTN_POINTER_B for released), covering the switch cases and both sides of each && binary expression - Mouse pointercancel and pointerleave without prior activation (null pointerId path in deactivateMouseSlot) - Duplicate pointerdown for the same touch ID (silent drop) - Touch pointermove, pointerup, pointercancel, and pointerleave for unknown slot IDs (all early-return guard branches) - Unknown DOM mouse button code hitting the default case in setMouseButton Branch coverage: 78.65% -> 81.3% (PointerInput.ts: 71.55% -> 93.96%). Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/input/PointerInput.test.ts | 230 +++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/src/input/PointerInput.test.ts b/src/input/PointerInput.test.ts index ff66dc3..fd2d91f 100644 --- a/src/input/PointerInput.test.ts +++ b/src/input/PointerInput.test.ts @@ -285,6 +285,51 @@ describe('PointerInput', () => { expect(input.isValid(0)).toBe(true); expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(false); }); + + it('ignores an unknown DOM mouse button code (default case in setMouseButton)', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 5, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isButtonDown(BTN_POINTER_A, 0)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_B, 0)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_C, 0)).toBe(false); + expect(input.isButtonDown(BTN_POINTER_D, 0)).toBe(false); + }); + + it('deactivates mouse slot on pointercancel', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isValid(0)).toBe(true); + + canvas.dispatchEvent( + pointerEvent('pointercancel', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), + ); + + expect(input.isValid(0)).toBe(false); + }); + + it('handles pointerleave for mouse when slot was never activated (null pointerId path)', () => { + canvas.dispatchEvent( + pointerEvent('pointerleave', { pointerId: 1, pointerType: 'mouse', clientX: 50, clientY: 50 }), + ); + + expect(input.isValid(0)).toBe(false); + }); }); // #endregion @@ -433,6 +478,91 @@ describe('PointerInput', () => { expect(input.isValid(0)).toBe(false); expect(input.isValid(1)).toBe(true); }); + + it('ignores a duplicate pointerdown for the same touch ID', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 900, + pointerType: 'touch', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + + expect(input.isValid(1)).toBe(true); + + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 900, + pointerType: 'touch', + button: 0, + clientX: 20, + clientY: 20, + }), + ); + + expect(input.isValid(1)).toBe(true); + expect(input.isValid(2)).toBe(false); + }); + + it('ignores touch pointermove with no prior pointerdown (unknown slot)', () => { + expect(() => { + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 999, pointerType: 'touch', clientX: 50, clientY: 50 }), + ); + }).not.toThrow(); + + expect(input.isValid(1)).toBe(false); + }); + + it('ignores touch pointerup with no prior pointerdown (unknown slot)', () => { + expect(() => { + canvas.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 999, + pointerType: 'touch', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + }).not.toThrow(); + }); + + it('ignores touch pointercancel with no prior pointerdown (unknown slot)', () => { + expect(() => { + canvas.dispatchEvent( + pointerEvent('pointercancel', { pointerId: 999, pointerType: 'touch', clientX: 50, clientY: 50 }), + ); + }).not.toThrow(); + }); + + it('frees a touch slot on pointerleave and ignores leave for an unregistered touch ID', () => { + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1001, + pointerType: 'touch', + button: 0, + clientX: 10, + clientY: 10, + }), + ); + + expect(input.isValid(1)).toBe(true); + + canvas.dispatchEvent( + pointerEvent('pointerleave', { pointerId: 1001, pointerType: 'touch', clientX: 10, clientY: 10 }), + ); + + expect(input.isValid(1)).toBe(false); + + expect(() => { + canvas.dispatchEvent( + pointerEvent('pointerleave', { pointerId: 9999, pointerType: 'touch', clientX: 10, clientY: 10 }), + ); + }).not.toThrow(); + }); }); // #endregion @@ -688,6 +818,106 @@ describe('PointerInput', () => { // Tick 2 update: must see the press edge. expect(input.isButtonPressed(BTN_POINTER_B, 0)).toBe(true); }); + + it('reports isButtonPressed for BTN_POINTER_C and BTN_POINTER_D on their press frames', () => { + // Before any press: covers the short-circuit false path for C and D. + expect(input.isButtonPressed(BTN_POINTER_C, 0)).toBe(false); + expect(input.isButtonPressed(BTN_POINTER_D, 0)).toBe(false); + + // Press C (DOM button 1 = middle). + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 1, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isButtonPressed(BTN_POINTER_C, 0)).toBe(true); + + input.endFrame(); + + expect(input.isButtonPressed(BTN_POINTER_C, 0)).toBe(false); + + // Press D (DOM button 3 = back). + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 3, + clientX: 50, + clientY: 50, + }), + ); + + expect(input.isButtonPressed(BTN_POINTER_D, 0)).toBe(true); + + input.endFrame(); + + expect(input.isButtonPressed(BTN_POINTER_D, 0)).toBe(false); + }); + + it('reports isButtonReleased for BTN_POINTER_B, BTN_POINTER_C, and BTN_POINTER_D on their release frames', () => { + // Press B, C, D together. + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 2, + clientX: 50, + clientY: 50, + }), + ); + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 1, + clientX: 50, + clientY: 50, + }), + ); + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 3, + clientX: 50, + clientY: 50, + }), + ); + input.endFrame(); + + // Release B; C and D still held. + canvas.dispatchEvent( + pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 2, clientX: 50, clientY: 50 }), + ); + + expect(input.isButtonReleased(BTN_POINTER_B, 0)).toBe(true); + // C still held: !s.c is false, covers the short-circuit false path for C. + expect(input.isButtonReleased(BTN_POINTER_C, 0)).toBe(false); + expect(input.isButtonReleased(BTN_POINTER_D, 0)).toBe(false); + + input.endFrame(); + + // Release C. + canvas.dispatchEvent( + pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 1, clientX: 50, clientY: 50 }), + ); + + expect(input.isButtonReleased(BTN_POINTER_C, 0)).toBe(true); + + input.endFrame(); + + // Release D. + canvas.dispatchEvent( + pointerEvent('pointerup', { pointerId: 1, pointerType: 'mouse', button: 3, clientX: 50, clientY: 50 }), + ); + + expect(input.isButtonReleased(BTN_POINTER_D, 0)).toBe(true); + }); }); // #endregion -- 2.54.0 From 98218b2e639dad70e72bcde0702a96e5164f0edd Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Tue, 5 May 2026 16:55:27 +0200 Subject: [PATCH 3/8] fix(input): harden pointer input correctness and resource safety - Record final event coordinates in handlePointerUp for touch/pen slots before freeing them, so release-frame pointerPos/pointerDelta are not stale - Clamp computed pixel coordinates to [0, displaySize - 1] in updateSlotPosition, preventing out-of-bounds values at the right/bottom canvas edge - Call setPointerCapture on touch/pen down so off-canvas drags continue delivering events; guard releasePointerCapture in freeSlot with hasPointerCapture to avoid NotFoundError on auto-released pointers - Detach PointerInput listeners and clear the reference when demo.initialize() returns false or throws, preventing DOM listener leaks Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/core/BTAPI.ts | 16 +++++++++++++++- src/input/PointerInput.ts | 24 ++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 1577c7b..da2e6c4 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -220,8 +220,22 @@ export class BTAPI { // Initialize the demo. console.log('[BT] Initializing demo'); - if (!(await demo.initialize())) { + let demoInitOk: boolean; + + try { + demoInitOk = await demo.initialize(); + } catch (err) { + console.error('[BT] Demo initialization threw', err); + this.pointer?.detach(); + this.pointer = null; + + return false; + } + + if (!demoInitOk) { console.error('[BT] Demo initialization failed'); + this.pointer?.detach(); + this.pointer = null; return false; } diff --git a/src/input/PointerInput.ts b/src/input/PointerInput.ts index 950abbb..74282f7 100644 --- a/src/input/PointerInput.ts +++ b/src/input/PointerInput.ts @@ -528,6 +528,9 @@ export class PointerInput { slot.prevPos.copyFrom(slot.pos); this.pointerIdToSlot.set(event.pointerId, slotIndex); + + // Capture so off-canvas drags keep delivering events to this canvas. + this.canvas?.setPointerCapture(event.pointerId); } /** @@ -552,6 +555,13 @@ export class PointerInput { return; } + // eslint-disable-next-line security/detect-object-injection -- slotIndex resolved from pointerIdToSlot, always in [0, POINTER_SLOT_COUNT) + const slot = this.slots[slotIndex]; + + if (slot !== undefined) { + this.updateSlotPosition(slot, event.clientX, event.clientY); + } + this.freeSlot(slotIndex); } @@ -712,6 +722,10 @@ export class PointerInput { } if (slot.pointerId !== null) { + if (this.canvas?.hasPointerCapture(slot.pointerId)) { + this.canvas.releasePointerCapture(slot.pointerId); + } + this.pointerIdToSlot.delete(slot.pointerId); } @@ -806,8 +820,14 @@ export class PointerInput { return; } - const x = Math.floor(((clientX - rect.left) / rect.width) * displaySize.x); - const y = Math.floor(((clientY - rect.top) / rect.height) * displaySize.y); + const x = Math.max( + 0, + Math.min(Math.floor(((clientX - rect.left) / rect.width) * displaySize.x), displaySize.x - 1), + ); + const y = Math.max( + 0, + Math.min(Math.floor(((clientY - rect.top) / rect.height) * displaySize.y), displaySize.y - 1), + ); slot.pos.set(x, y); } -- 2.54.0 From 6e3d772cc493f951ead0be2361f4961b4083b4b6 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Tue, 5 May 2026 17:08:24 +0200 Subject: [PATCH 4/8] feat(input): add hideCursor / showCursor to PointerInput and BT namespace Demos that draw their own crosshair or cursor sprite need to suppress the native OS arrow cursor while the pointer is over the canvas. - PointerInput saves canvas.style.cursor at attach() time and restores it on detach(), mirroring the existing touchAction guard - hideCursor() sets cursor:none; showCursor() restores the saved value - both methods are no-ops before attach so call order is safe - BT.hideCursor() / BT.showCursor() delegate through the BTAPI facade - four unit tests cover hide, show, detach-restore, and pre-attach no-op Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/BlitTech.ts | 23 +++++++++++++++++ src/input/PointerInput.test.ts | 45 ++++++++++++++++++++++++++++++++++ src/input/PointerInput.ts | 35 ++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 3180e96..0de7466 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -571,6 +571,29 @@ export const BT = { return BTAPI.instance.getPointer()?.getScrollDelta() ?? 0; }, + /** + * Hides the native OS cursor while the pointer is over the canvas. + * + * Call once from `initialize()` when the demo draws its own crosshair or + * cursor sprite in place of the system arrow. The cursor is restored + * automatically when the engine shuts down. + * + * No-op before the engine is initialized. + */ + hideCursor: (): void => { + BTAPI.instance.getPointer()?.hideCursor(); + }, + + /** + * Restores the native OS cursor over the canvas. + * + * Reverses a prior {@link hideCursor} call. No-op before the engine is + * initialized. + */ + showCursor: (): void => { + BTAPI.instance.getPointer()?.showCursor(); + }, + // #endregion // #region Input - Buttons diff --git a/src/input/PointerInput.test.ts b/src/input/PointerInput.test.ts index fd2d91f..1022e05 100644 --- a/src/input/PointerInput.test.ts +++ b/src/input/PointerInput.test.ts @@ -187,6 +187,51 @@ describe('PointerInput', () => { }); }); + describe('cursor management', () => { + it('hideCursor sets canvas.style.cursor to "none"', () => { + input.hideCursor(); + expect(canvas.style.cursor).toBe('none'); + }); + + it('showCursor restores the cursor value captured at attach time', () => { + const c = createCanvas(); + c.style.cursor = 'crosshair'; + const p = new PointerInput(); + + p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); + p.hideCursor(); + expect(c.style.cursor).toBe('none'); + + p.showCursor(); + expect(c.style.cursor).toBe('crosshair'); + + p.detach(); + c.remove(); + }); + + it('detach restores the cursor saved at attach time', () => { + const c = createCanvas(); + c.style.cursor = 'wait'; + const p = new PointerInput(); + + p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); + p.hideCursor(); + expect(c.style.cursor).toBe('none'); + + p.detach(); + expect(c.style.cursor).toBe('wait'); + + c.remove(); + }); + + it('hideCursor and showCursor are no-ops before attach', () => { + const p = new PointerInput(); + + expect(() => p.hideCursor()).not.toThrow(); + expect(() => p.showCursor()).not.toThrow(); + }); + }); + // #endregion // #region Mouse Slot 0 diff --git a/src/input/PointerInput.ts b/src/input/PointerInput.ts index 74282f7..c061eeb 100644 --- a/src/input/PointerInput.ts +++ b/src/input/PointerInput.ts @@ -129,6 +129,9 @@ export class PointerInput { /** Captured `canvas.style.touchAction` value, restored by {@link detach}. */ private originalTouchAction: string | null = null; + /** Captured `canvas.style.cursor` value, restored by {@link detach}. */ + private originalCursor: string | null = null; + // #endregion // #region Bound Listeners @@ -189,6 +192,7 @@ export class PointerInput { this.originalTouchAction = canvas.style.touchAction; canvas.style.touchAction = 'none'; + this.originalCursor = canvas.style.cursor; canvas.addEventListener('pointermove', this.onPointerMove); canvas.addEventListener('pointerdown', this.onPointerDown); @@ -221,11 +225,16 @@ export class PointerInput { if (this.originalTouchAction !== null) { canvas.style.touchAction = this.originalTouchAction; } + + if (this.originalCursor !== null) { + canvas.style.cursor = this.originalCursor; + } } this.canvas = null; this.displaySize = null; this.originalTouchAction = null; + this.originalCursor = null; this.pointerIdToSlot.clear(); this.scrollDeltaY = 0; @@ -427,6 +436,32 @@ export class PointerInput { // #endregion + // #region Cursor + + /** + * Hides the native OS cursor while the pointer is over the canvas. + * + * Sets `canvas.style.cursor = 'none'`. No-op when not attached. + */ + public hideCursor(): void { + if (this.canvas !== null) { + this.canvas.style.cursor = 'none'; + } + } + + /** + * Restores the native OS cursor to the value it had at {@link attach} time. + * + * No-op when not attached. + */ + public showCursor(): void { + if (this.canvas !== null) { + this.canvas.style.cursor = this.originalCursor ?? ''; + } + } + + // #endregion + // #region Event Handlers /** -- 2.54.0 From d9ef377c2ab9e9ae188324d5881be773b36412d1 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Tue, 5 May 2026 17:10:57 +0200 Subject: [PATCH 5/8] fix(input): release pointer captures on detach and record final mouse position on pointerup In detach(), iterate pointerIdToSlot and call releasePointerCapture() for each active capture before removing event listeners. Without this, any in-flight touch or pen contacts leave dangling browser-side capture state when the input system is torn down. In handlePointerUp(), call updateSlotPosition() before setMouseButton() for mouse events, matching the existing touch/pen path. The terminal position from the pointerup event was previously discarded, leaving the slot with a stale coordinate on the release frame. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/input/PointerInput.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/input/PointerInput.ts b/src/input/PointerInput.ts index c061eeb..0a13ac8 100644 --- a/src/input/PointerInput.ts +++ b/src/input/PointerInput.ts @@ -214,6 +214,16 @@ export class PointerInput { const canvas = this.canvas; if (canvas !== null) { + for (const [pointerId] of this.pointerIdToSlot) { + try { + if (canvas.hasPointerCapture(pointerId)) { + canvas.releasePointerCapture(pointerId); + } + } catch { + // Element may have been removed from the DOM already. + } + } + canvas.removeEventListener('pointermove', this.onPointerMove); canvas.removeEventListener('pointerdown', this.onPointerDown); canvas.removeEventListener('pointerup', this.onPointerUp); @@ -579,6 +589,7 @@ export class PointerInput { if (event.pointerType === 'mouse') { const slot = this.slots[0]; + this.updateSlotPosition(slot, event.clientX, event.clientY); this.setMouseButton(slot, event.button, false); return; -- 2.54.0 From e9845e3b301456ad7fe659e4f9c69f6565043456 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Tue, 5 May 2026 17:16:58 +0200 Subject: [PATCH 6/8] docs(input): add pointer input guide and update README + CLAUDE.md Add docs/input.md covering the four-slot pointer model, position / delta / button / scroll-delta API, button mapping table, cursor control, frame-timing semantics, page-interaction guards, and coordinate conversion math. Update README.md: - Add pointer input feature bullet in the Features list - Add docs/input.md link to the Documentation section - Add src/input/PointerInput.ts to the project structure tree - Update Quick Start update() comment to mention the pointer API - Replace the stale "Input: not yet implemented" stub with a full API reference; keyboard and gamepad stubs become separate sections Update CLAUDE.md architecture tree to include src/input/. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- CLAUDE.md | 2 + README.md | 60 +++++++++++++++++-- docs/input.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 docs/input.md diff --git a/CLAUDE.md b/CLAUDE.md index 02953ff..f683156 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,8 @@ src/ Palette.ts # 256-entry indexed color palette PaletteEffect.ts # Palette effect system (cycle, fade, flash, swap) palettes/ # Built-in preset palette data (VGA, CGA, C64, etc.) + input/ + PointerInput.ts # DOM-backed pointer / mouse / touch / pen tracker (4 slots) utils/ Bootstrap.ts # Demo bootstrap utilities BootstrapHelpers.ts # WebGPU detection, canvas lookup, error display diff --git a/README.md b/README.md index c396a5b..a9f328b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ primitives, and fonts. - **Bitmap fonts**: variable-width font rendering with palette offset support - **Camera system**: scrolling with offset and reset - **Asset loading**: sprite sheets and bitmap fonts from images with automatic caching +- **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 - **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 @@ -75,6 +77,7 @@ 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 input system, slot model, button mapping, scroll delta, and cursor control - **[Developer Experience Guide](docs/developer-experience-guide.md)** — Development workflow and tooling (roadmap) ## Scripts @@ -172,7 +175,8 @@ class MyDemo implements IBlitTechDemo { */ update(): void { // Demo logic at fixed timestep (60 FPS) - // Note: Keyboard input (BT.keyDown, BT.keyPressed) is planned but not yet implemented + // Pointer input: BT.pointerPos(), BT.buttonDown(BT.BTN_POINTER_A) + // Keyboard input (BT.keyDown, BT.keyPressed) is planned but not yet implemented } /** @@ -238,6 +242,8 @@ blit-tech/ │ │ ├── pixel/ # Pixel-tier (PixelGlitch, PixelMosaic) │ │ ├── display/ # Display-tier (BarrelDistortion, Scanlines, ...) │ │ └── presets/ # crtPipBoy, amber, green +│ ├── input/ +│ │ └── PointerInput.ts # DOM-backed pointer / mouse / touch / pen tracker │ ├── utils/ │ │ ├── Bootstrap.ts # Demo bootstrap utilities │ │ ├── BootstrapHelpers.ts # WebGPU detection, error display @@ -499,9 +505,55 @@ BitmapFont.load(url); // Load bitmap font (static method) ### Input -**Note:** Keyboard and gamepad input methods (`BT.keyDown()`, `BT.keyPressed()`, `BT.buttonDown()`, etc.) are planned -but not yet implemented. They currently return `false`. Button constants (`BT.BTN_UP`, `BT.BTN_A`, etc.) are defined for -future use. See the Blit-Tech Demos repository for workarounds using browser APIs directly. +#### Pointer (mouse, touch, pen) + +The engine tracks up to four pointer slots. Slot 0 is always the mouse; slots 1-3 are touch and pen contacts assigned in +arrival order. All coordinates are in logical display space (the `displaySize` from `queryHardware()`). + +```ts +// Position and movement +BT.pointerPos(); // mouse position (slot 0), returns Vector2i +BT.pointerPos(1); // first touch contact position +BT.pointerDelta(); // mouse movement since last frame +BT.pointerDelta(1); // first touch contact movement +BT.pointerPosValid(); // true while mouse is inside the canvas +BT.pointerPosValid(1); // true while touch slot 1 is in contact +BT.pointerScrollDelta(); // vertical wheel delta in pixels for this frame + +// Buttons — use with BT.buttonDown / buttonPressed / buttonReleased +// Second argument is the pointer slot (0 = mouse, 1-3 = touch / pen) +BT.buttonDown(BT.BTN_POINTER_A); // mouse left button held +BT.buttonDown(BT.BTN_POINTER_B); // mouse right button held +BT.buttonDown(BT.BTN_POINTER_C); // mouse middle button held +BT.buttonDown(BT.BTN_POINTER_D); // mouse back / forward button held +BT.buttonDown(BT.BTN_POINTER_A, 1); // first touch contact down +BT.buttonPressed(BT.BTN_POINTER_A); // mouse left — frame of press only +BT.buttonReleased(BT.BTN_POINTER_A); // mouse left — frame of release only + +// Cursor +BT.hideCursor(); // hide the OS cursor over the canvas (draw your own) +BT.showCursor(); // restore the OS cursor +``` + +Button mapping for slot 0 (mouse) follows the RetroBlit canonical order: + +- `BTN_POINTER_A` — left button (DOM button 0) +- `BTN_POINTER_B` — right button (DOM button 2) +- `BTN_POINTER_C` — middle button (DOM button 1) +- `BTN_POINTER_D` — back / forward extra buttons (DOM buttons 3 and 4) + +Touch and pen slots only report `BTN_POINTER_A` while in contact; B, C, and D always return `false`. + +See the [Input Guide](docs/input.md) for the full slot model, frame-timing semantics, and scroll delta details. + +#### Keyboard + +Keyboard input (`BT.keyDown()`, `BT.keyPressed()`, `BT.keyReleased()`) is planned but not yet implemented. These methods +currently return `false`. See the Blit-Tech Demos repository for workarounds using browser APIs directly. + +#### Gamepad + +Gamepad input (`BT.buttonDown()` with `BTN_UP..BTN_SELECT`) is planned but not yet implemented. ## Browser Compatibility diff --git a/docs/input.md b/docs/input.md new file mode 100644 index 0000000..5d84946 --- /dev/null +++ b/docs/input.md @@ -0,0 +1,161 @@ +# Input Guide + +Blit-Tech provides a DOM-backed pointer input subsystem that handles mouse, touch, and pen contacts through a unified +four-slot model. All coordinates are returned in logical display space (the `displaySize` configured in +`queryHardware()`), independent of the canvas's CSS or backing-buffer size. + +Keyboard and gamepad input APIs exist as stubs (`BT.keyDown()`, `BT.buttonDown()` with `BTN_UP..BTN_SELECT`) but are not +yet implemented and always return `false`. + +## Pointer Slot Model + +The engine tracks up to four simultaneous pointers: + +| Slot | Source | +| ---- | -------------------------------------------- | +| 0 | Mouse (always reserved) | +| 1 | First touch / pen contact (in arrival order) | +| 2 | Second touch / pen contact | +| 3 | Third touch / pen contact | + +Overflow contacts beyond slot 3 are dropped silently. The constant `POINTER_SLOT_COUNT` (exported from the library) +equals `4`. + +### Mouse slot (slot 0) + +- Becomes valid on the first `pointermove` inside the canvas. +- Stays valid while the mouse remains over the canvas; cleared on `pointerleave`. +- Tracks all four buttons (A, B, C, D). +- Position is preserved after the mouse leaves; a new `pointermove` re-syncs it. + +### Touch / pen slots (slots 1-3) + +- Allocated on `pointerdown` in the first free slot; freed on `pointerup`, `pointercancel`, or `pointerleave`. +- Only `BTN_POINTER_A` reports true while the contact is down; B, C, D always return false. +- Position and delta are preserved on release so release-frame code can read the final position and flick velocity. +- The engine calls `setPointerCapture` so off-canvas drags continue delivering events. + +## Position and Delta + +```ts +// Current position in display coordinates +const pos = BT.pointerPos(); // slot 0 (mouse) +const touch = BT.pointerPos(1); // slot 1 (first touch) + +// Movement since the last frame +const delta = BT.pointerDelta(); // slot 0 (mouse) +const td = BT.pointerDelta(1); // slot 1 + +// Validity check — false means no live pointer in this slot +if (BT.pointerPosValid()) { + /* mouse is over the canvas */ +} +if (BT.pointerPosValid(1)) { + /* touch slot 1 is active */ +} +``` + +`BT.pointerPos()` returns `Vector2i.zero()` when the slot index is out of `[0, 3]` or the engine is not initialized. +`BT.pointerDelta()` similarly returns `Vector2i.zero()` for invalid slots. + +## Buttons + +Use `BT.buttonDown()`, `BT.buttonPressed()`, and `BT.buttonReleased()` with the `BTN_POINTER_*` constants. The second +parameter is the pointer slot (defaults to `0`): + +```ts +// Hold detection +BT.buttonDown(BT.BTN_POINTER_A); // left mouse button held +BT.buttonDown(BT.BTN_POINTER_B); // right mouse button held +BT.buttonDown(BT.BTN_POINTER_C); // middle mouse button held +BT.buttonDown(BT.BTN_POINTER_D); // back / forward button held +BT.buttonDown(BT.BTN_POINTER_A, 1); // touch slot 1 in contact + +// Edge detection (true only on the transition frame) +BT.buttonPressed(BT.BTN_POINTER_A); // left button just pressed +BT.buttonReleased(BT.BTN_POINTER_A); // left button just released +BT.buttonPressed(BT.BTN_POINTER_A, 2); // touch slot 2 just touched down +``` + +### Mouse button mapping + +The mapping follows the RetroBlit canonical order, not the DOM `PointerEvent.button` index: + +| Button constant | Mouse button | DOM button index | +| --------------- | -------------------- | ---------------- | +| `BTN_POINTER_A` | Left | 0 | +| `BTN_POINTER_B` | Right | 2 | +| `BTN_POINTER_C` | Middle / wheel click | 1 | +| `BTN_POINTER_D` | Back or forward | 3 or 4 | + +## Scroll Delta + +```ts +// Accumulated vertical scroll for the current frame, in pixels +const scroll = BT.pointerScrollDelta(); + +if (scroll > 0) { + /* scrolled down */ +} +if (scroll < 0) { + /* scrolled up */ +} +``` + +The value aggregates all `WheelEvent.deltaY` values received since the last frame, normalizing line and page delta modes +to pixels. It resets to zero each frame. The engine also calls `preventDefault()` on wheel events so the page does not +scroll while the canvas has focus. + +## Cursor Control + +```ts +// In initialize(): hide the OS cursor and draw your own crosshair / sprite +BT.hideCursor(); + +// Restore at any time +BT.showCursor(); +``` + +`hideCursor()` sets `canvas.style.cursor = 'none'`. `showCursor()` restores whatever the cursor was at `attach()` time. +Both are no-ops before the engine is initialized. The cursor is restored automatically when the engine stops. + +## Frame-Timing Semantics + +State is snapshotted at the **end** of each animation frame, after `demo.update()` and `demo.render()` have run. This +means: + +- Any pointer event that fires between two frames is visible as a transition on the **next** `update()` call. +- `pointerDelta()` reflects movement between the last `update()` and the current one. +- `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). + +## Page-Interaction Guards + +`attach()` installs three guards on the canvas to prevent browser defaults from interfering: + +- `wheel` with `{ passive: false }` and `preventDefault()` — prevents page scroll on wheel events. +- `canvas.style.touchAction = 'none'` — prevents iOS Safari pinch-zoom and double-tap-zoom. +- `contextmenu` with `preventDefault()` — prevents the OS context menu on right-click so `BTN_POINTER_B` works. + +`detach()` reverses all three and removes all event listeners. This happens automatically when the engine stops or when +`demo.initialize()` throws. + +## Coordinate Conversion + +Client coordinates from DOM events are converted to display space using the canvas bounding rect: + +``` +display_x = floor((clientX - rect.left) / rect.width * displaySize.x) +display_y = floor((clientY - rect.top) / rect.height * displaySize.y) +``` + +Coordinates are clamped to `[0, displaySize - 1]` on each axis. The conversion is skipped when the canvas has zero size +(no layout yet) to avoid NaN coordinates. + +## Implementation Notes + +- `PointerInput` is internal to the engine; import from `blit-tech` and access through `BT.*` methods. +- The `POINTER_SLOT_COUNT` constant (value `4`) is exported for demos that iterate over slots. +- `PointerInput` is created and `attach()`-ed inside `BTAPI.initialize()`, so it is ready before `demo.initialize()` + runs. +- `stop()` calls `detach()` and clears the reference to prevent DOM listener leaks. -- 2.54.0 From a24e6fc4f9ee2b7ad419fd8659fd3029e9501c47 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Tue, 5 May 2026 17:28:32 +0200 Subject: [PATCH 7/8] test(input): add detach capture release and mouse flick regression tests Add two missing test cases to PointerInput and correct a stale JSDoc comment. - Adds 'releases active pointer captures on detach': dispatches a touch pointerdown to register a pointerId in pointerIdToSlot, stubs hasPointerCapture via vi.spyOn, then asserts releasePointerCapture is called with the captured pointerId after detach(). - Adds 'preserves pos and delta on the mouse-release frame': mirrors the existing touch-release flick test for mouse slot 0; verifies that isButtonReleased(BTN_POINTER_A, 0) is true and getDelta(0) is non-zero on the same frame as pointerup. - Fixes pointerDelta JSDoc in BlitTech.ts: replaces "at the start of each frame" with the accurate endFrame() timing (after update/render). Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- src/BlitTech.ts | 7 +-- src/input/PointerInput.test.ts | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 0de7466..c11cb05 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -533,9 +533,10 @@ export const BT = { /** * Returns the position delta `(pos - prevPos)` for a pointer slot since the previous frame. * - * Reset to zero by the engine at the start of each frame. Returns - * `Vector2i.zero()` when the engine is not initialized or the slot index - * is out of range. + * Reflects movement accumulated between the previous and current frame. + * Snapshotted and reset by the engine at `endFrame()`, which runs after + * `update()` and `render()`. Returns `Vector2i.zero()` when the engine is + * not initialized or `pointerIndex` is out of range. * * @param pointerIndex - Pointer slot (defaults to 0 = mouse). * @returns Per-frame movement in display coordinates. diff --git a/src/input/PointerInput.test.ts b/src/input/PointerInput.test.ts index 1022e05..8637048 100644 --- a/src/input/PointerInput.test.ts +++ b/src/input/PointerInput.test.ts @@ -185,6 +185,34 @@ describe('PointerInput', () => { otherCanvas.remove(); }); + + it('releases active pointer captures on detach', () => { + const c = createCanvas(); + const p = new PointerInput(); + p.attach(c, new Vector2i(DISPLAY_WIDTH, DISPLAY_HEIGHT)); + + // Register a touch contact so a pointerId is tracked in pointerIdToSlot. + c.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 42, + pointerType: 'touch', + button: 0, + clientX: 50, + clientY: 50, + }), + ); + + // Stub hasPointerCapture so detach() believes the capture is active. + vi.spyOn(c, 'hasPointerCapture').mockReturnValue(true); + const releaseSpy = vi.spyOn(c, 'releasePointerCapture'); + + p.detach(); + + expect(releaseSpy).toHaveBeenCalledWith(42); + + vi.restoreAllMocks(); + c.remove(); + }); }); describe('cursor management', () => { @@ -811,6 +839,57 @@ describe('PointerInput', () => { expect(releaseDelta.y).toBeGreaterThan(0); }); + it('preserves pos and delta on the mouse-release frame', () => { + // Mirrors the touch-release flick test but uses mouse on slot 0. The + // release-frame delta must reflect the final movement so a demo can + // use it as throw velocity. + + // Tick 1 inter-frame: mouse button pressed. + canvas.dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 170, + clientY: 140, + }), + ); + input.endFrame(); + + // Tick 2 inter-frame: mouse moves a small step. + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 178, clientY: 148 }), + ); + input.endFrame(); + + // Tick 3 inter-frame: mouse moves further, then releases. + canvas.dispatchEvent( + pointerEvent('pointermove', { pointerId: 1, pointerType: 'mouse', clientX: 186, clientY: 156 }), + ); + canvas.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientX: 186, + clientY: 156, + }), + ); + + // Tick 3 update: must see the release edge AND a non-zero delta + // representing the final movement (so a demo can throw with it). + expect(input.isButtonReleased(BTN_POINTER_A, 0)).toBe(true); + + const releasePos = input.getPos(0); + const releaseDelta = input.getDelta(0); + + // clientX 186 in a 640-wide rect at left=10: (186-10)/640 * 320 = 88. + // clientX 178 -> (178-10)/640 * 320 = 84. Final movement: (84,64) -> (88,68). + expect(releasePos.x).toBe(88); + expect(releaseDelta.x).toBeGreaterThan(0); + expect(releaseDelta.y).toBeGreaterThan(0); + }); + it('reports zero delta on the first frame after touch activation', () => { // Without prevPos sync on activation, the very first frame's // delta would be (touchPos - 0) which feeds a phantom velocity -- 2.54.0 From bc8b021b18b1be01519a66c77756e982261cb6b4 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Tue, 5 May 2026 17:36:01 +0200 Subject: [PATCH 8/8] chore(docs): fix the code snippet Signed-off-by: Vaclav Vancura --- docs/input.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/input.md b/docs/input.md index 5d84946..cfa4699 100644 --- a/docs/input.md +++ b/docs/input.md @@ -144,7 +144,7 @@ means: Client coordinates from DOM events are converted to display space using the canvas bounding rect: -``` +```text display_x = floor((clientX - rect.left) / rect.width * displaySize.x) display_y = floor((clientY - rect.top) / rect.height * displaySize.y) ```