@forgeax/engine-input 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +312 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/browser-backend-dual-instance.test.d.ts +2 -0
  5. package/dist/__tests__/browser-backend-dual-instance.test.d.ts.map +1 -0
  6. package/dist/__tests__/browser-backend-focus-loss.test.d.ts +2 -0
  7. package/dist/__tests__/browser-backend-focus-loss.test.d.ts.map +1 -0
  8. package/dist/__tests__/browser-backend-lock-error.test.d.ts +2 -0
  9. package/dist/__tests__/browser-backend-lock-error.test.d.ts.map +1 -0
  10. package/dist/__tests__/browser-backend-lock-gate.test.d.ts +2 -0
  11. package/dist/__tests__/browser-backend-lock-gate.test.d.ts.map +1 -0
  12. package/dist/__tests__/browser-backend-lock-release.test.d.ts +2 -0
  13. package/dist/__tests__/browser-backend-lock-release.test.d.ts.map +1 -0
  14. package/dist/__tests__/browser-backend-lock-state.test.d.ts +2 -0
  15. package/dist/__tests__/browser-backend-lock-state.test.d.ts.map +1 -0
  16. package/dist/__tests__/canvas-input-boundary.test.d.ts +2 -0
  17. package/dist/__tests__/canvas-input-boundary.test.d.ts.map +1 -0
  18. package/dist/__tests__/canvas-input-owner.test-d.d.ts +2 -0
  19. package/dist/__tests__/canvas-input-owner.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/composite-backend.test.d.ts +2 -0
  21. package/dist/__tests__/composite-backend.test.d.ts.map +1 -0
  22. package/dist/__tests__/controller-platform-owner.test-d.d.ts +2 -0
  23. package/dist/__tests__/controller-platform-owner.test-d.d.ts.map +1 -0
  24. package/dist/__tests__/input-snapshot.test-d.d.ts +2 -0
  25. package/dist/__tests__/input-snapshot.test-d.d.ts.map +1 -0
  26. package/dist/__tests__/input-snapshot.test.d.ts +2 -0
  27. package/dist/__tests__/input-snapshot.test.d.ts.map +1 -0
  28. package/dist/__tests__/swipe-direction-owner.test-d.d.ts +2 -0
  29. package/dist/__tests__/swipe-direction-owner.test-d.d.ts.map +1 -0
  30. package/dist/__tests__/ui-input-matrix.test.d.ts +2 -0
  31. package/dist/__tests__/ui-input-matrix.test.d.ts.map +1 -0
  32. package/dist/__tests__/ui-ownership-edge.test.d.ts +2 -0
  33. package/dist/__tests__/ui-ownership-edge.test.d.ts.map +1 -0
  34. package/dist/__tests__/ui-ownership.test.d.ts +2 -0
  35. package/dist/__tests__/ui-ownership.test.d.ts.map +1 -0
  36. package/dist/__tests__/ui-reset-idempotence.test.d.ts +2 -0
  37. package/dist/__tests__/ui-reset-idempotence.test.d.ts.map +1 -0
  38. package/dist/__tests__/ui-reset.test.d.ts +2 -0
  39. package/dist/__tests__/ui-reset.test.d.ts.map +1 -0
  40. package/dist/action-state.d.ts +146 -0
  41. package/dist/action-state.d.ts.map +1 -0
  42. package/dist/browser-backend.d.ts +121 -0
  43. package/dist/browser-backend.d.ts.map +1 -0
  44. package/dist/canvas-input-boundary.d.ts +13 -0
  45. package/dist/canvas-input-boundary.d.ts.map +1 -0
  46. package/dist/composite-backend.d.ts +34 -0
  47. package/dist/composite-backend.d.ts.map +1 -0
  48. package/dist/controller-db-data.d.ts +5 -0
  49. package/dist/controller-db-data.d.ts.map +1 -0
  50. package/dist/controller-db-data.mjs +12 -0
  51. package/dist/controller-db-data.mjs.map +1 -0
  52. package/dist/controller-db.d.ts +70 -0
  53. package/dist/controller-db.d.ts.map +1 -0
  54. package/dist/controller-db.mjs +119 -0
  55. package/dist/controller-db.mjs.map +1 -0
  56. package/dist/frame-start-scan-system.d.ts +37 -0
  57. package/dist/frame-start-scan-system.d.ts.map +1 -0
  58. package/dist/gamepad-frame.d.ts +42 -0
  59. package/dist/gamepad-frame.d.ts.map +1 -0
  60. package/dist/gesture-recognizer.d.ts +145 -0
  61. package/dist/gesture-recognizer.d.ts.map +1 -0
  62. package/dist/index.d.ts +13 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.mjs +1502 -0
  65. package/dist/index.mjs.map +1 -0
  66. package/dist/input-snapshot.d.ts +420 -0
  67. package/dist/input-snapshot.d.ts.map +1 -0
  68. package/dist/plugin-service.d.ts +11 -0
  69. package/dist/plugin-service.d.ts.map +1 -0
  70. package/dist/ui-ownership.d.ts +17 -0
  71. package/dist/ui-ownership.d.ts.map +1 -0
  72. package/dist/virtual-joystick.d.ts +39 -0
  73. package/dist/virtual-joystick.d.ts.map +1 -0
  74. package/package.json +72 -0
  75. package/src/__tests__/browser-backend-dual-instance.test.ts +379 -0
  76. package/src/__tests__/browser-backend-focus-loss.test.ts +239 -0
  77. package/src/__tests__/browser-backend-lock-error.test.ts +425 -0
  78. package/src/__tests__/browser-backend-lock-gate.test.ts +283 -0
  79. package/src/__tests__/browser-backend-lock-release.test.ts +641 -0
  80. package/src/__tests__/browser-backend-lock-state.test.ts +604 -0
  81. package/src/__tests__/canvas-input-boundary.test.ts +65 -0
  82. package/src/__tests__/canvas-input-owner.test-d.ts +39 -0
  83. package/src/__tests__/composite-backend.test.ts +299 -0
  84. package/src/__tests__/controller-platform-owner.test-d.ts +46 -0
  85. package/src/__tests__/input-snapshot.test-d.ts +78 -0
  86. package/src/__tests__/input-snapshot.test.ts +96 -0
  87. package/src/__tests__/swipe-direction-owner.test-d.ts +45 -0
  88. package/src/__tests__/ui-input-matrix.test.ts +31 -0
  89. package/src/__tests__/ui-ownership-edge.test.ts +33 -0
  90. package/src/__tests__/ui-ownership.test.ts +35 -0
  91. package/src/__tests__/ui-reset-idempotence.test.ts +17 -0
  92. package/src/__tests__/ui-reset.test.ts +25 -0
  93. package/src/action-state.ts +380 -0
  94. package/src/browser-backend.ts +884 -0
  95. package/src/canvas-input-boundary.ts +81 -0
  96. package/src/composite-backend.ts +189 -0
  97. package/src/controller-db-data.ts +25 -0
  98. package/src/controller-db.ts +218 -0
  99. package/src/frame-start-scan-system.ts +93 -0
  100. package/src/gamepad-frame.ts +259 -0
  101. package/src/gesture-recognizer.ts +495 -0
  102. package/src/index.ts +101 -0
  103. package/src/input-snapshot.ts +732 -0
  104. package/src/plugin-service.ts +31 -0
  105. package/src/ui-ownership.ts +61 -0
  106. package/src/virtual-joystick.ts +103 -0
  107. package/vendor/gamecontrollerdb.txt +2114 -0
@@ -0,0 +1,884 @@
1
+ // browser-backend.ts -- DOM PointerLock + keyboard / mouse listener producer.
2
+ //
3
+ // charter awareness:
4
+ // F2 minimal surface -- exposes only `attachBrowserInputBackend(canvas)`
5
+ // returning a `(): void` detach handle; PointerLock + listener wiring
6
+ // is internal (plan OOS-1 explicit -- not part of the user-facing
7
+ // surface)
8
+ // P3 explicit failure -- detach is part of the contract; double-detach
9
+ // is a no-op (idempotent)
10
+ // P4 consistent abstraction -- the snapshot consumer does not see
11
+ // PointerLock state machine or `movementX/Y` units; only
12
+ // `mouse.movementDelta`
13
+ // P5 producer/consumer split -- this file is the only DOM-touching
14
+ // surface in the package; everything else operates on the
15
+ // `InputBackend` protocol (see input-snapshot.ts)
16
+ //
17
+ // Note: the actual PointerLock pump runs in a real browser; node-based
18
+ // vitest unit tests inject fake backends through the `InputBackend`
19
+ // protocol. The browser-mode coverage of this file is the M2b layer
20
+ // (plan-strategy section 5.4 reason for the 70% floor).
21
+
22
+ import type { ControllerDb, MappingTokens } from './controller-db';
23
+ import { diffGamepadFrame, type RawGamepadStub } from './gamepad-frame';
24
+ import {
25
+ createRecognizerState,
26
+ type GestureEvent,
27
+ type GestureState,
28
+ processGestureFrame,
29
+ type RecognizerPointer,
30
+ type RecognizerState,
31
+ } from './gesture-recognizer';
32
+ import type {
33
+ Capabilities,
34
+ GamepadSlotSample,
35
+ InputBackend,
36
+ InputBackendSample,
37
+ PointerPhaseEvent,
38
+ PointerSample,
39
+ PointerType,
40
+ VirtualAxisSample,
41
+ VirtualJoystickConfig,
42
+ } from './input-snapshot';
43
+ import { isUiOwnedEvent } from './ui-ownership';
44
+ import { type BindState, deriveVirtualAxes, handleVirtualJoystickUnbind } from './virtual-joystick';
45
+
46
+ // D-5: normalize W3C PointerEvent.pointerType to the canonical 3-literal union.
47
+ // The spec allows '' when device type is undetectable; we map it to 'mouse' so
48
+ // the snapshot consumers (action matchers, gesture recognizers) can exhaustively
49
+ // switch on PointerType without a default branch (AC-19).
50
+ export function coercePointerType(raw: string): PointerType {
51
+ if (raw === 'pen') return 'pen';
52
+ if (raw === 'touch') return 'touch';
53
+ return 'mouse';
54
+ }
55
+
56
+ /**
57
+ * Options accepted by `attachBrowserInputBackend`.
58
+ */
59
+ export interface BrowserInputBackendOptions {
60
+ /** Optional host-owned UI root. Events in this root never reach gameplay. */
61
+ readonly uiRoot?: Node;
62
+ /**
63
+ * Optional document handle (testing override). Defaults to `document`.
64
+ * Plan-strategy section 9 row 7 (`document.hasFocus()` semantics)
65
+ * relies on this to decide whether the up-edge set should be cleared
66
+ * when focus is lost.
67
+ */
68
+ readonly document?: Document;
69
+ /**
70
+ * Optional window handle (testing override). Defaults to `window`.
71
+ * Used to attach key listeners (capturing focus loss) without
72
+ * requiring the canvas itself to receive keyboard events.
73
+ */
74
+ readonly window?: Window;
75
+ /**
76
+ * Neutral gate for the auto PointerLock-on-click. When provided and it
77
+ * returns false, the click handler skips `requestPointerLock()` — the host
78
+ * decides whether a click should capture the cursor right now. Defaults to
79
+ * always-locking (returns true), so the standalone game runtime keeps its
80
+ * MVP behaviour unchanged. This predicate is deliberately host-opaque: the
81
+ * backend never learns WHY locking is (dis)allowed — it only asks. A host
82
+ * that mounts this canvas in a non-game context (e.g. an editor viewport
83
+ * that owns the cursor for orbit/pick) supplies a predicate that returns
84
+ * false there. The input package carries zero knowledge of those contexts.
85
+ */
86
+ readonly pointerLockAllowed?: () => boolean;
87
+ /**
88
+ * Optional navigator handle (testing override). Defaults to
89
+ * `globalThis.navigator`. Used to poll `navigator.getGamepads()` each
90
+ * frame. Mirrors the `document`/`window` override pattern.
91
+ */
92
+ readonly navigator?: { getGamepads?(): (Gamepad | null)[] };
93
+ /**
94
+ * Optional virtual joystick configurations (M3). When provided, the
95
+ * backend auto-binds the first pointerdown within each config's region
96
+ * and derives per-frame VirtualAxisSample outputs via deriveVirtualAxes.
97
+ */
98
+ readonly virtualJoysticks?: readonly VirtualJoystickConfig[];
99
+ /**
100
+ * Optional SDL controller DB text loader (M3 D-13 test injection). When
101
+ * omitted, the backend dynamic-imports the vendored 554KB DB from
102
+ * `@forgeax/engine-input/controller-db-data` on first sight of a
103
+ * non-standard gamepad (D-2 lazy-load: the 554KB never sits in the
104
+ * default path). Tests inject a synthetic DB string to avoid loading the
105
+ * real vendored file. Returns the raw gamecontrollerdb.txt contents.
106
+ */
107
+ readonly loadControllerDb?: () => Promise<string>;
108
+ /**
109
+ * Optional monotonic clock (M5 D-3 test injection). Defaults to
110
+ * `() => performance.now()`. The gesture recognizer advances all timers
111
+ * (long-press duration, double-tap / swipe windows) off this clock, NOT
112
+ * off pointer-event arrival, so recognition stays decoupled from event
113
+ * frequency (AC-16). Tests inject a fake clock for deterministic timing,
114
+ * mirroring the `document` / `window` / `navigator` override pattern.
115
+ */
116
+ readonly now?: () => number;
117
+ /**
118
+ * Optional lock provider (D-2). When provided, pointer-lock requests
119
+ * route through requestLock() / exitLock() instead of the W3C
120
+ * requestPointerLock / exitPointerLock API. The backend remains
121
+ * host-opaque -- it never learns whether the provider wraps Tauri
122
+ * native-grab, postMessage, or any other mechanism.
123
+ */
124
+ readonly lockProvider?: PointerLockProvider;
125
+ /**
126
+ * Optional callback for lock request failures (D-4). When W3C
127
+ * requestPointerLock rejects or lockProvider.requestLock/exitLock
128
+ * throws/rejects, the backend calls this with a detail object.
129
+ * Without this callback, failures are silently caught (backward
130
+ * compatible but contrary to charter P3).
131
+ */
132
+ readonly onLockError?: (detail: { path: 'w3c' | 'provider'; cause: unknown }) => void;
133
+ }
134
+
135
+ /**
136
+ * Lock provider interface (D-2). A host injects this to replace the
137
+ * W3C Pointer Lock API path. requestLock() may return void (fire-and-forget,
138
+ * D-7 optimistic placement) or Promise<void> (awaitable). exitLock() is
139
+ * called when the backend needs to release the lock (ESC / blur / detach /
140
+ * setPointerLockAllowed(false)).
141
+ */
142
+ export interface PointerLockProvider {
143
+ /**
144
+ * Request pointer lock. The backend optimistically sets providerLocked=true
145
+ * when this is called (D-7). On throw/reject, the backend calls onLockError
146
+ * and rolls back providerLocked.
147
+ */
148
+ requestLock(): void | Promise<void>;
149
+ /** Release pointer lock. */
150
+ exitLock(): void;
151
+ }
152
+
153
+ /**
154
+ * Attach DOM listeners (keydown / keyup / mousedown / mouseup /
155
+ * pointerlockchange / mousemove inside lock / blur) to `canvas` and the
156
+ * surrounding window. Returns a `detach` callable that:
157
+ *
158
+ * - removes all listeners
159
+ * - exits PointerLock if currently locked
160
+ * - drops every internal accumulator
161
+ *
162
+ * The returned object also implements the `InputBackend` protocol -- the
163
+ * runtime inserts it under `INPUT_BACKEND_KEY` and adds the
164
+ * `InputFrameStartScan` system, which reads it back each tick.
165
+ * Calling `detach()` and the protocol's `detach()` are equivalent
166
+ * (idempotent; double-detach is safe -- charter P3).
167
+ */
168
+ export function attachBrowserInputBackend(
169
+ canvas: HTMLCanvasElement,
170
+ options: BrowserInputBackendOptions = {},
171
+ ): (() => void) & { backend: InputBackend } {
172
+ const doc = options.document ?? globalThis.document;
173
+ const win = options.window ?? globalThis.window;
174
+
175
+ const heldKeys = new Set<string>();
176
+ const upEdges = new Set<string>();
177
+ const heldCodes = new Set<string>();
178
+ const upCodeEdges = new Set<string>();
179
+ const buttons: [boolean, boolean, boolean] = [false, false, false];
180
+ let mvx = 0;
181
+ let mvy = 0;
182
+ let wheelAccum = 0;
183
+ let detached = false;
184
+
185
+ const isUiEvent = (event: Event): boolean =>
186
+ options.uiRoot !== undefined && isUiOwnedEvent(event, options.uiRoot);
187
+
188
+ // w6: merged pointer-lock state tracking (D-1).
189
+ // w3cLocked is driven by document-level pointerlockchange events,
190
+ // isolated per-instance by comparing pointerLockElement === this canvas.
191
+ let w3cLocked = false;
192
+ // providerLocked is set optimistically when lockProvider.requestLock()
193
+ // is called (D-7); cleared by exitLock() or on rejection.
194
+ let providerLocked = false;
195
+ // gameGate is a command-set boolean (setPointerLockAllowed), default true.
196
+ let gameGate = true;
197
+
198
+ // w8 (D-1/D-3): release the provider lock idempotently. Calls the injected
199
+ // exitLock (routing any throw to onLockError as { path: 'provider' }) and
200
+ // clears providerLocked unconditionally. Shared by every provider-release
201
+ // site (ESC, blur, setPointerLockAllowed(false), detach) so the release
202
+ // semantics live in one place.
203
+ function releaseProviderLock(): void {
204
+ if (providerLocked && options.lockProvider?.exitLock) {
205
+ try {
206
+ options.lockProvider.exitLock();
207
+ } catch (err: unknown) {
208
+ if (options.onLockError) options.onLockError({ path: 'provider', cause: err });
209
+ }
210
+ }
211
+ providerLocked = false;
212
+ }
213
+
214
+ // D-4: capability detected once at attach time.
215
+ const nav =
216
+ options.navigator ?? (globalThis as { navigator?: { getGamepads?(): unknown } }).navigator;
217
+ const gamepadAvailable = typeof nav?.getGamepads === 'function';
218
+ const pointerAvailable = typeof globalThis.PointerEvent !== 'undefined';
219
+ const caps: Capabilities = { gamepad: gamepadAvailable, pointer: pointerAvailable };
220
+
221
+ // D-1: prevGamepadFrame is the only cross-frame state holder for gamepad diff.
222
+ // Stored in backend closure; diffGamepadFrame compares prev vs cur each sample().
223
+ let prevGamepadFrame = new Map<number, GamepadSlotSample>();
224
+ // Focus loss discards the old physical frame. The first successful sample
225
+ // afterwards establishes a new baseline instead of turning a still-held
226
+ // button into a synthetic justPressed edge.
227
+ let gamepadBaselinePending = false;
228
+ let focusResetPending = false;
229
+
230
+ // M3 D-2 lazy-load state. The SDL DB (554KB) is loaded once, on first sight
231
+ // of a non-standard gamepad, then cached here. `controllerDb` stays
232
+ // undefined until the async load resolves; frames before that keep the
233
+ // Feat1 empty signal (graceful degradation). `dbLoadStarted` prevents
234
+ // re-triggering the load on every frame while the promise is pending.
235
+ let controllerDb: ControllerDb | undefined;
236
+ let dbLoadStarted = false;
237
+ // Per-id GUID resolution cache (avoids re-parsing the same Gamepad.id).
238
+ const guidCache = new Map<string, string | undefined>();
239
+
240
+ // M3 D-13: the DB parser + GUID helpers live in the pure controller-db
241
+ // module. They are dynamic-imported alongside the data so neither the
242
+ // parser nor the 554KB vendored text enters the main-entry bundle.
243
+ let controllerDbApi:
244
+ | {
245
+ parseControllerDb: (txt: string) => ControllerDb;
246
+ extractGuidFromGamepadId: (id: string) => string | undefined;
247
+ selectBestMappingEntry: (
248
+ db: ControllerDb,
249
+ guid: string,
250
+ platform: string | undefined,
251
+ ) => { readonly tokens: MappingTokens } | undefined;
252
+ platformFromUserAgent: (ua: string) => string | undefined;
253
+ }
254
+ | undefined;
255
+
256
+ function kickOffDbLoad(): void {
257
+ if (dbLoadStarted) return;
258
+ dbLoadStarted = true;
259
+ const loadApi = controllerDbApi
260
+ ? Promise.resolve(controllerDbApi)
261
+ : import('@forgeax/engine-input/controller-db').then((m) => {
262
+ controllerDbApi = m;
263
+ return m;
264
+ });
265
+ const loadText = options.loadControllerDb
266
+ ? options.loadControllerDb()
267
+ : import('@forgeax/engine-input/controller-db-data').then((m) => m.loadBundledControllerDb());
268
+ Promise.all([loadApi, loadText])
269
+ .then(([api, txt]) => {
270
+ controllerDb = api.parseControllerDb(txt);
271
+ })
272
+ .catch(() => {
273
+ // Load failed (offline chunk / bad text): keep the Feat1 empty
274
+ // signal. Reset so a later frame may retry.
275
+ dbLoadStarted = false;
276
+ });
277
+ }
278
+
279
+ // Acquisition-layer remap lookup passed to diffGamepadFrame. Returns the
280
+ // standard-layout mapping tokens for a Gamepad.id, or null when the DB is
281
+ // not yet loaded / the GUID is unextractable / no DB entry matches.
282
+ function remapLookup(gamepadId: string): MappingTokens | null {
283
+ if (!controllerDb || !controllerDbApi) return null;
284
+ let guid = guidCache.get(gamepadId);
285
+ if (!guidCache.has(gamepadId)) {
286
+ guid = controllerDbApi.extractGuidFromGamepadId(gamepadId);
287
+ guidCache.set(gamepadId, guid);
288
+ }
289
+ if (!guid) return null;
290
+ const ua =
291
+ typeof globalThis.navigator?.userAgent === 'string' ? globalThis.navigator.userAgent : '';
292
+ const platform = controllerDbApi.platformFromUserAgent(ua);
293
+ const entry = controllerDbApi.selectBestMappingEntry(controllerDb, guid, platform);
294
+ return entry ? entry.tokens : null;
295
+ }
296
+
297
+ // w15: pointer map (pointerId → live position), phase queue (one-frame lifecycle),
298
+ // and per-pointer previous position for cross-frame delta.
299
+ const pointerMap = new Map<
300
+ number,
301
+ {
302
+ x: number;
303
+ y: number;
304
+ pressure: number;
305
+ pointerType: PointerType;
306
+ prevX: number;
307
+ prevY: number;
308
+ }
309
+ >();
310
+ const phaseQueue: PointerPhaseEvent[] = [];
311
+ let mouseX: number | undefined;
312
+ let mouseY: number | undefined;
313
+
314
+ // w21: virtual joystick binding state (per-joystick name → BindState).
315
+ const vjConfigs = options.virtualJoysticks ?? [];
316
+ const vjBindState = new Map<string, BindState>();
317
+
318
+ // M5 D-3/D-4: gesture recognizer cross-frame state lives in this closure
319
+ // (C-3: the only sanctioned cross-frame gesture holder, alongside
320
+ // prevGamepadFrame / pointerMap / vjBindState). `now` is injectable for
321
+ // deterministic test timing; production uses performance.now().
322
+ const now = options.now ?? (() => performance.now());
323
+ let recognizerState: RecognizerState = createRecognizerState();
324
+
325
+ // DPR coordinate helpers. computePointerCoords applies the standard DPR-correct
326
+ // canvas-pixel formula; falls back to clientX/clientY when getBoundingClientRect
327
+ // is missing (fake canvas in unit tests).
328
+ function computePointerCoords(ev: PointerEvent): { x: number; y: number } {
329
+ const rect = canvas.getBoundingClientRect?.();
330
+ if (!rect) return { x: ev.clientX, y: ev.clientY };
331
+ const scaleX = canvas.width / rect.width;
332
+ const scaleY = canvas.height / rect.height;
333
+ return {
334
+ x: (ev.clientX - rect.left) * scaleX,
335
+ y: (ev.clientY - rect.top) * scaleY,
336
+ };
337
+ }
338
+
339
+ function isFocused(): boolean {
340
+ // `document.hasFocus()` indicates whether the tab is foreground;
341
+ // when unfocused we suppress up-edges so stale releases do not fire
342
+ // after alt-tabbing back (section 9 row 7).
343
+ return typeof doc?.hasFocus === 'function' ? doc.hasFocus() : true;
344
+ }
345
+
346
+ function onKeyDown(ev: KeyboardEvent): void {
347
+ if (isUiEvent(ev)) {
348
+ clear();
349
+ return;
350
+ }
351
+ heldKeys.add(ev.key);
352
+ upEdges.delete(ev.key);
353
+ if (ev.code) {
354
+ heldCodes.add(ev.code);
355
+ upCodeEdges.delete(ev.code);
356
+ }
357
+ // w8 (D-1): ESC releases provider lock (W3C path handles ESC via browser
358
+ // pointerlockchange). Only acts when providerLocked is true.
359
+ if (ev.key === 'Escape' && providerLocked) {
360
+ releaseProviderLock();
361
+ }
362
+ }
363
+ function onKeyUp(ev: KeyboardEvent): void {
364
+ if (isUiEvent(ev)) return;
365
+ heldKeys.delete(ev.key);
366
+ if (isFocused()) {
367
+ upEdges.add(ev.key);
368
+ }
369
+ if (ev.code) {
370
+ heldCodes.delete(ev.code);
371
+ if (isFocused()) upCodeEdges.add(ev.code);
372
+ }
373
+ }
374
+ function onPointerDown(ev: PointerEvent): void {
375
+ if (isUiEvent(ev)) {
376
+ clear();
377
+ return;
378
+ }
379
+ // Only mouse-type pointers affect the mouse button cluster (D-3).
380
+ if (ev.pointerType === 'mouse') {
381
+ if (ev.button === 0 || ev.button === 1 || ev.button === 2) {
382
+ buttons[ev.button] = true;
383
+ }
384
+ }
385
+ // D-5: setPointerCapture for coherent pointer event dispatching — but NOT
386
+ // while pointer lock is active. Pointer capture and pointer lock are mutually
387
+ // exclusive (W3C): setPointerCapture throws InvalidStateError when the element
388
+ // already holds the lock. Under lock the cursor is confined and every pointer
389
+ // event already targets the locked element, so capture is redundant anyway.
390
+ // The try/catch is belt-and-suspenders for the race where lock/capture state
391
+ // flips between the pointerlockchange event and this handler.
392
+ if (!w3cLocked && !providerLocked && typeof canvas.setPointerCapture === 'function') {
393
+ try {
394
+ canvas.setPointerCapture(ev.pointerId);
395
+ } catch {
396
+ /* capture illegal in the current pointer state (e.g. just-acquired lock) — safe to skip */
397
+ }
398
+ }
399
+ // Track pointer in map for multi-pointer + delta (w15).
400
+ const coords = computePointerCoords(ev);
401
+ if (ev.pointerType === 'mouse') {
402
+ mouseX = coords.x;
403
+ mouseY = coords.y;
404
+ }
405
+ pointerMap.set(ev.pointerId, {
406
+ x: coords.x,
407
+ y: coords.y,
408
+ pressure: ev.pressure,
409
+ pointerType: coercePointerType(ev.pointerType),
410
+ prevX: coords.x,
411
+ prevY: coords.y,
412
+ });
413
+ phaseQueue.push({
414
+ pointerId: ev.pointerId,
415
+ phase: 'down',
416
+ x: coords.x,
417
+ y: coords.y,
418
+ pressure: ev.pressure,
419
+ pointerType: coercePointerType(ev.pointerType),
420
+ });
421
+ // w21: virtual joystick auto-bind -- first config whose region contains
422
+ // the pointerdown position, if not already bound to another pointer.
423
+ if (vjConfigs.length > 0) {
424
+ for (const cfg of vjConfigs) {
425
+ const { region } = cfg;
426
+ if (
427
+ coords.x >= region.x &&
428
+ coords.x <= region.x + region.width &&
429
+ coords.y >= region.y &&
430
+ coords.y <= region.y + region.height
431
+ ) {
432
+ const existing = vjBindState.get(cfg.name);
433
+ if (!existing?.pointerId) {
434
+ const originX =
435
+ cfg.mode === 'fixed' ? (cfg.anchor?.x ?? region.x + region.width / 2) : coords.x;
436
+ const originY =
437
+ cfg.mode === 'fixed' ? (cfg.anchor?.y ?? region.y + region.height / 2) : coords.y;
438
+ if (existing) {
439
+ existing.pointerId = ev.pointerId;
440
+ existing.originX = originX;
441
+ existing.originY = originY;
442
+ } else {
443
+ vjBindState.set(cfg.name, { pointerId: ev.pointerId, originX, originY });
444
+ }
445
+ // Only bind the first matching config per pointerdown.
446
+ break;
447
+ }
448
+ }
449
+ }
450
+ }
451
+ }
452
+ function onPointerUp(ev: PointerEvent): void {
453
+ if (isUiEvent(ev)) return;
454
+ if (ev.pointerType === 'mouse') {
455
+ if (ev.button === 0 || ev.button === 1 || ev.button === 2) {
456
+ buttons[ev.button] = false;
457
+ }
458
+ }
459
+ const entry = pointerMap.get(ev.pointerId);
460
+ if (entry) {
461
+ phaseQueue.push({
462
+ pointerId: ev.pointerId,
463
+ phase: 'up',
464
+ x: entry.x,
465
+ y: entry.y,
466
+ pressure: ev.pressure,
467
+ pointerType: coercePointerType(ev.pointerType),
468
+ });
469
+ pointerMap.delete(ev.pointerId);
470
+ }
471
+ // w21: unbind virtual joystick bound to this pointer.
472
+ if (vjBindState.size > 0) {
473
+ handleVirtualJoystickUnbind(vjBindState, ev.pointerId);
474
+ }
475
+ }
476
+ function onPointerMove(ev: PointerEvent): void {
477
+ if (isUiEvent(ev)) return;
478
+ // D-3: movementDelta from pointermove (PointerEvent extends MouseEvent).
479
+ if (ev.pointerType === 'mouse') {
480
+ mvx += ev.movementX;
481
+ mvy += ev.movementY;
482
+ }
483
+ // Update live position; prevX/prevY NOT updated here (AC-09: prev only
484
+ // snapshots at sample() time, preventing Bevy #12442 zero-delta bug).
485
+ const coords = computePointerCoords(ev);
486
+ if (ev.pointerType === 'mouse') {
487
+ mouseX = coords.x;
488
+ mouseY = coords.y;
489
+ }
490
+ const entry = pointerMap.get(ev.pointerId);
491
+ if (entry) {
492
+ entry.x = coords.x;
493
+ entry.y = coords.y;
494
+ entry.pressure = ev.pressure;
495
+ phaseQueue.push({
496
+ pointerId: ev.pointerId,
497
+ phase: 'move',
498
+ x: coords.x,
499
+ y: coords.y,
500
+ pressure: ev.pressure,
501
+ pointerType: coercePointerType(ev.pointerType),
502
+ });
503
+ }
504
+ }
505
+ function onPointerCancel(ev: PointerEvent): void {
506
+ if (isUiEvent(ev)) return;
507
+ const entry = pointerMap.get(ev.pointerId);
508
+ if (entry) {
509
+ phaseQueue.push({
510
+ pointerId: ev.pointerId,
511
+ phase: 'cancel',
512
+ x: entry.x,
513
+ y: entry.y,
514
+ pressure: ev.pressure,
515
+ pointerType: coercePointerType(ev.pointerType),
516
+ });
517
+ pointerMap.delete(ev.pointerId);
518
+ }
519
+ // w21: unbind virtual joystick on pointer cancel.
520
+ if (vjBindState.size > 0) {
521
+ handleVirtualJoystickUnbind(vjBindState, ev.pointerId);
522
+ }
523
+ }
524
+ function onVisibilityChange(): void {
525
+ if (doc.visibilityState === 'hidden') {
526
+ onBlur();
527
+ }
528
+ }
529
+ function onWheel(ev: WheelEvent): void {
530
+ if (isUiEvent(ev)) {
531
+ clear();
532
+ return;
533
+ }
534
+ // plan-strategy D-5 sign-discrete normalization: collapse `WheelEvent`
535
+ // across the three `deltaMode` units (PIXEL / LINE / PAGE) by taking
536
+ // `Math.sign(deltaY)` per event. Trade-off documented at the
537
+ // InputSnapshot.mouse.wheelDelta JSDoc + plan-strategy R-7.
538
+ const dy = ev.deltaY;
539
+ if (typeof dy === 'number' && dy !== 0) {
540
+ wheelAccum += dy > 0 ? 1 : -1;
541
+ }
542
+ }
543
+ function onBlur(): void {
544
+ // A standalone backend preserves its current-frame cancellation semantics.
545
+ // A host control boundary calls clear() separately on the same blur event so
546
+ // no state crosses a lease; the source itself still emits cancellation to its
547
+ // sole consumer as it did before ownership routing existed.
548
+ upEdges.clear();
549
+ upCodeEdges.clear();
550
+ heldKeys.clear();
551
+ heldCodes.clear();
552
+ buttons[0] = false;
553
+ buttons[1] = false;
554
+ buttons[2] = false;
555
+ mvx = 0;
556
+ mvy = 0;
557
+ wheelAccum = 0;
558
+ // Pointer lock is not allowed to survive a focus boundary. Publish the
559
+ // unlocked state synchronously; pointerlockchange may arrive later (or
560
+ // be suppressed by the browser while the document is hidden).
561
+ if (typeof doc.exitPointerLock === 'function' && doc.pointerLockElement === canvas) {
562
+ doc.exitPointerLock();
563
+ }
564
+ w3cLocked = false;
565
+ releaseProviderLock();
566
+ if (pointerMap.size > 0) {
567
+ for (const [id, entry] of pointerMap) {
568
+ phaseQueue.push({
569
+ pointerId: id,
570
+ phase: 'cancel',
571
+ x: entry.x,
572
+ y: entry.y,
573
+ pressure: entry.pressure,
574
+ pointerType: entry.pointerType,
575
+ });
576
+ }
577
+ pointerMap.clear();
578
+ }
579
+ prevGamepadFrame.clear();
580
+ gamepadBaselinePending = true;
581
+ focusResetPending = true;
582
+ vjBindState.clear();
583
+ }
584
+
585
+ // Use try-blocks: jsdom / fake canvases passed by tests may lack
586
+ // `addEventListener`. We probe and skip silently to keep the unit-test
587
+ // surface workable (charter P3: detach must always succeed even if
588
+ // attach was a partial wiring).
589
+ const safeAdd = <K extends string>(
590
+ target: { addEventListener?: (k: K, h: EventListener) => void } | undefined,
591
+ kind: K,
592
+ handler: EventListener,
593
+ ): void => {
594
+ target?.addEventListener?.(kind, handler);
595
+ };
596
+ const safeRemove = <K extends string>(
597
+ target: { removeEventListener?: (k: K, h: EventListener) => void } | undefined,
598
+ kind: K,
599
+ handler: EventListener,
600
+ ): void => {
601
+ target?.removeEventListener?.(kind, handler);
602
+ };
603
+
604
+ safeAdd(win, 'keydown', onKeyDown as EventListener);
605
+ safeAdd(win, 'keyup', onKeyUp as EventListener);
606
+ safeAdd(win, 'blur', onBlur as EventListener);
607
+ safeAdd(canvas, 'pointerdown', onPointerDown as EventListener);
608
+ safeAdd(canvas, 'pointerup', onPointerUp as EventListener);
609
+ safeAdd(canvas, 'pointermove', onPointerMove as EventListener);
610
+ safeAdd(canvas, 'pointercancel', onPointerCancel as EventListener);
611
+ safeAdd(canvas, 'wheel', onWheel as EventListener);
612
+ safeAdd(doc, 'visibilitychange', onVisibilityChange as EventListener);
613
+
614
+ // w6 (D-1): track W3C pointer-lock state via document-level pointerlockchange.
615
+ // Per-instance isolation: only update w3cLocked when pointerLockElement matches
616
+ // this backend's canvas. A native unlock is also a lifecycle boundary: browsers
617
+ // do not all pair an OS focus transition with a DOM blur event, so clear the
618
+ // physical frame when an owned lock exits after being acquired.
619
+ function onPointerLockChange(): void {
620
+ const wasLocked = w3cLocked;
621
+ w3cLocked = doc.pointerLockElement === canvas;
622
+ if (wasLocked && !w3cLocked && !providerLocked) onBlur();
623
+ }
624
+ safeAdd(doc, 'pointerlockchange', onPointerLockChange as EventListener);
625
+
626
+ // PointerLock entry must be triggered by user activation (W3C requires
627
+ // a click / keydown handler to call `requestPointerLock()`). The MVP
628
+ // wires a click listener on the canvas as the activation surface;
629
+ // OOS-1 keeps any explicit "request lock" / "exit lock" surface out
630
+ // of the snapshot itself.
631
+ // D-5: touch-action:none on the canvas so the browser does not interpret
632
+ // touch gestures (scroll/pinch/zoom) and deprioritizes pointer events.
633
+ // Presence-detect .style (fake canvas in unit tests may lack it).
634
+ let _prevTouchAction: string | undefined;
635
+ if (canvas.style) {
636
+ _prevTouchAction = canvas.style.touchAction;
637
+ canvas.style.touchAction = 'none';
638
+ }
639
+
640
+ function onCanvasClick(): void {
641
+ // D-3: dual gate synthesis -- gameGate (command-set by template) AND
642
+ // hostPredicate (per-click evaluated by host). Both must pass to proceed.
643
+ if (!gameGate) return;
644
+ // Host gate: when the host says "not now" (e.g. an editor viewport that owns
645
+ // the cursor outside the play-game quadrant) skip the lock entirely. Default
646
+ // is always-allow, so the standalone game runtime is unchanged.
647
+ if (options.pointerLockAllowed && !options.pointerLockAllowed()) return;
648
+ // Pointer Lock requires window focus; skip silently when unfocused.
649
+ if (typeof doc.hasFocus === 'function' && !doc.hasFocus()) return;
650
+
651
+ // D-2 / D-7: lockProvider path takes priority over W3C.
652
+ if (options.lockProvider) {
653
+ providerLocked = true; // D-7 optimistic placement
654
+ try {
655
+ const result = options.lockProvider.requestLock();
656
+ if (result && typeof (result as Promise<void>).catch === 'function') {
657
+ (result as Promise<void>).catch((cause: unknown) => {
658
+ providerLocked = false;
659
+ if (options.onLockError) {
660
+ options.onLockError({ path: 'provider', cause });
661
+ }
662
+ });
663
+ }
664
+ } catch (cause: unknown) {
665
+ // Synchronous throw from requestLock.
666
+ providerLocked = false;
667
+ if (options.onLockError) {
668
+ options.onLockError({ path: 'provider', cause });
669
+ }
670
+ }
671
+ return;
672
+ }
673
+
674
+ // W3C path: standard requestPointerLock.
675
+ const fn = canvas.requestPointerLock;
676
+ if (typeof fn !== 'function') return;
677
+ const r = fn.call(canvas) as unknown;
678
+ if (r && typeof (r as Promise<void>).catch === 'function') {
679
+ (r as Promise<void>).catch((cause: unknown) => {
680
+ if (options.onLockError) {
681
+ options.onLockError({ path: 'w3c', cause });
682
+ }
683
+ });
684
+ }
685
+ }
686
+ safeAdd(canvas, 'click', onCanvasClick as EventListener);
687
+
688
+ function sample(): InputBackendSample {
689
+ // D-1: gamepad edge diff derived inside sample() — the only
690
+ // cross-frame state holder. getGamepads() is polled once per frame;
691
+ // null-padded arrays use gamepad.index as slot key.
692
+ let gamepads: GamepadSlotSample[] | undefined;
693
+ if (gamepadAvailable) {
694
+ try {
695
+ const rawGamepads = (nav.getGamepads?.() ?? []) as (RawGamepadStub | null)[];
696
+ const valid: RawGamepadStub[] = [];
697
+ let hasNonStandard = false;
698
+ for (const gp of rawGamepads) {
699
+ if (!gp?.connected) continue;
700
+ valid.push(gp);
701
+ if (gp.mapping !== 'standard') hasNonStandard = true;
702
+ }
703
+ // M3 D-2 / C-5: only a real non-standard gamepad triggers the DB
704
+ // load. Standard pads never pull in the 554KB DB.
705
+ if (hasNonStandard) kickOffDbLoad();
706
+ gamepads = diffGamepadFrame(prevGamepadFrame, valid, remapLookup);
707
+ if (gamepadBaselinePending) {
708
+ gamepads = gamepads.map((slot) => ({
709
+ ...slot,
710
+ justPressed: new Set<number>(),
711
+ justReleased: new Set<number>(),
712
+ }));
713
+ gamepadBaselinePending = false;
714
+ }
715
+ // Store this frame's state for next frame's diff.
716
+ const nextFrame = new Map<number, GamepadSlotSample>();
717
+ for (const slot of gamepads) {
718
+ // Only store currently-connected slots for the next diff.
719
+ // Disconnected slots (standardMapping=false, pressed=empty)
720
+ // are not stored; they won't appear in next frame's prev.
721
+ if (slot.pressed.size > 0 || slot.standardMapping) {
722
+ nextFrame.set(slot.index, slot);
723
+ }
724
+ }
725
+ prevGamepadFrame = nextFrame;
726
+ } catch {
727
+ // getGamepads() threw — treat as if no gamepad API.
728
+ // AC-05: API unstable environment does not crash.
729
+ }
730
+ }
731
+
732
+ // w15: freeze pointer map → PointerSample[] with cross-frame delta.
733
+ // Delta = current frozen position − previous frozen position (AC-09).
734
+ let pointers: PointerSample[] | undefined;
735
+ if (pointerMap.size > 0) {
736
+ pointers = [];
737
+ for (const [id, entry] of pointerMap) {
738
+ const deltaX = entry.x - entry.prevX;
739
+ const deltaY = entry.y - entry.prevY;
740
+ pointers.push({
741
+ pointerId: id,
742
+ x: entry.x,
743
+ y: entry.y,
744
+ pressure: entry.pressure,
745
+ pointerType: entry.pointerType,
746
+ active: true,
747
+ delta: Object.freeze({ x: deltaX, y: deltaY }),
748
+ });
749
+ // Update prev position for next frame's delta (D-1 frame-end freeze).
750
+ entry.prevX = entry.x;
751
+ entry.prevY = entry.y;
752
+ }
753
+ }
754
+
755
+ // w15: snapshot the phase queue for this frame.
756
+ const pointerEvents: PointerPhaseEvent[] | undefined =
757
+ phaseQueue.length > 0 ? [...phaseQueue] : undefined;
758
+
759
+ // w21: derive virtual joystick axes from live pointer positions.
760
+ let virtualAxes: VirtualAxisSample[] | undefined;
761
+ if (vjConfigs.length > 0) {
762
+ virtualAxes = deriveVirtualAxes(vjConfigs, pointerMap, vjBindState);
763
+ }
764
+
765
+ // M5 D-4: run the gesture recognizer over this frame's phase queue BEFORE
766
+ // it is drained below. onBlur / pointercancel pushed cancel phases into
767
+ // the same queue, so the recognizer naturally resets active gestures +
768
+ // emits cancel events (AC-18) without a separate reset path. All timers
769
+ // advance off `now()` (D-3), decoupled from event frequency (AC-16).
770
+ const gestureResult = processGestureFrame(
771
+ phaseQueue,
772
+ pointerMap as ReadonlyMap<number, RecognizerPointer>,
773
+ recognizerState,
774
+ now(),
775
+ );
776
+ recognizerState = gestureResult.newState;
777
+ // Emit optional fields only when there is gesture signal to carry: an
778
+ // active/frozen continuous value (non-identity) or lifecycle events.
779
+ const gestureEvents: readonly GestureEvent[] | undefined =
780
+ gestureResult.gestureEvents.length > 0 ? gestureResult.gestureEvents : undefined;
781
+ const gs = gestureResult.gestureState;
782
+ const gestures: GestureState | undefined =
783
+ gs.pinchScale !== 1 || gs.rotationAngle !== 0 ? gs : undefined;
784
+
785
+ const out: InputBackendSample = {
786
+ downKeys: new Set(heldKeys),
787
+ upKeys: new Set(upEdges),
788
+ downCodes: new Set(heldCodes),
789
+ upCodes: new Set(upCodeEdges),
790
+ buttons: [buttons[0], buttons[1], buttons[2]] as readonly [boolean, boolean, boolean],
791
+ movementX: mvx,
792
+ movementY: mvy,
793
+ ...(mouseX !== undefined && mouseY !== undefined ? { mouseX, mouseY } : {}),
794
+ wheelDelta: wheelAccum,
795
+ focused: isFocused(),
796
+ capabilities: caps,
797
+ pointerLocked: w3cLocked || providerLocked,
798
+ ...(gamepads ? { gamepads } : {}),
799
+ ...(pointers ? { pointers } : {}),
800
+ ...(pointerEvents ? { pointerEvents } : {}),
801
+ ...(virtualAxes ? { virtualAxes } : {}),
802
+ ...(gestures ? { gestures } : {}),
803
+ ...(gestureEvents ? { gestureEvents } : {}),
804
+ ...(focusResetPending ? { focusReset: true } : {}),
805
+ };
806
+ // Reset per-frame accumulators (movement delta + key edges + wheel notches + phase queue).
807
+ upEdges.clear();
808
+ upCodeEdges.clear();
809
+ mvx = 0;
810
+ mvy = 0;
811
+ wheelAccum = 0;
812
+ phaseQueue.length = 0;
813
+ focusResetPending = false;
814
+ return out;
815
+ }
816
+
817
+ // w8 (D-3): command-set game gate for pointer-lock. set(false) immediately
818
+ // releases any active lock on both W3C and provider paths.
819
+ function setPointerLockAllowed(allowed: boolean): void {
820
+ gameGate = allowed;
821
+ if (!allowed) {
822
+ // W3C path: exit pointer-lock if currently locked on this canvas.
823
+ if (typeof doc.exitPointerLock === 'function' && doc.pointerLockElement === canvas) {
824
+ doc.exitPointerLock();
825
+ }
826
+ // Provider path: call exitLock and clear providerLocked.
827
+ releaseProviderLock();
828
+ }
829
+ }
830
+
831
+ function clear(): void {
832
+ heldKeys.clear();
833
+ upEdges.clear();
834
+ heldCodes.clear();
835
+ upCodeEdges.clear();
836
+ pointerMap.clear();
837
+ phaseQueue.length = 0;
838
+ prevGamepadFrame.clear();
839
+ vjBindState.clear();
840
+ recognizerState = createRecognizerState();
841
+ buttons[0] = false;
842
+ buttons[1] = false;
843
+ buttons[2] = false;
844
+ mvx = 0;
845
+ mvy = 0;
846
+ wheelAccum = 0;
847
+ if (typeof doc?.exitPointerLock === 'function' && doc.pointerLockElement === canvas) {
848
+ doc.exitPointerLock();
849
+ }
850
+ // `pointerlockchange` is asynchronous. The boundary must nevertheless
851
+ // publish an unlocked frame immediately after revocation.
852
+ w3cLocked = false;
853
+ releaseProviderLock();
854
+ }
855
+
856
+ function detach(): void {
857
+ if (detached) return;
858
+ detached = true;
859
+ safeRemove(win, 'keydown', onKeyDown as EventListener);
860
+ safeRemove(win, 'keyup', onKeyUp as EventListener);
861
+ safeRemove(win, 'blur', onBlur as EventListener);
862
+ safeRemove(canvas, 'pointerdown', onPointerDown as EventListener);
863
+ safeRemove(canvas, 'pointerup', onPointerUp as EventListener);
864
+ safeRemove(canvas, 'pointermove', onPointerMove as EventListener);
865
+ safeRemove(canvas, 'pointercancel', onPointerCancel as EventListener);
866
+ safeRemove(canvas, 'wheel', onWheel as EventListener);
867
+ safeRemove(doc, 'visibilitychange', onVisibilityChange as EventListener);
868
+ safeRemove(doc, 'pointerlockchange', onPointerLockChange as EventListener);
869
+ safeRemove(canvas, 'click', onCanvasClick as EventListener);
870
+ clear();
871
+ // D-5: restore original touch-action value.
872
+ if (canvas.style && _prevTouchAction !== undefined) {
873
+ canvas.style.touchAction = _prevTouchAction;
874
+ }
875
+ }
876
+
877
+ const backend: InputBackend = { sample, clear, detach, setPointerLockAllowed };
878
+
879
+ // Returned callable doubles as the InputBackend (detach + sample). AI
880
+ // users see one symbol with both shapes, mirroring the
881
+ // produces-detach-and-protocol convention of `effect`-style libraries.
882
+ const handle = Object.assign(detach, { backend });
883
+ return handle;
884
+ }