Something went wrong. Try again.
A WebGPU retro game engine for TypeScript, inspired by RetroBlit. Build pixel-perfect 2D games with a clean, fantasy-console-style API.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923/** * 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<number, number> = 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;
/** Captured `canvas.style.cursor` value, restored by {@link detach}. */ private originalCursor: 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'; this.originalCursor = canvas.style.cursor;
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) { 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); 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; }
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;
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 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
/** * 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);
// Capture so off-canvas drags keep delivering events to this canvas. this.canvas?.setPointerCapture(event.pointerId); }
/** * 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.updateSlotPosition(slot, event.clientX, event.clientY); this.setMouseButton(slot, event.button, false);
return; }
const slotIndex = this.pointerIdToSlot.get(event.pointerId);
if (slotIndex === undefined) { 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); }
/** * 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) { if (this.canvas?.hasPointerCapture(slot.pointerId)) { this.canvas.releasePointerCapture(slot.pointerId); }
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.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); }
/** * 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