@forgeax/engine-input 0.0.0-dev.8d955ade1c79

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,379 @@
1
+ // browser-backend-dual-instance.test.ts -- M1 w5: dual instance isolation unit test.
2
+ //
3
+ // Covers: two backend instances with different fake canvases,
4
+ // pointerlockchange on document must only affect the instance whose
5
+ // canvas matches pointerLockElement.
6
+ //
7
+ // charter awareness:
8
+ // F1 single-entry indexability -- all isolation tests in one file.
9
+ // P4 consistent abstraction -- instance isolation is natural
10
+ // from the backend's perspective (compare pointerLockElement === canvas).
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+ import { attachBrowserInputBackend } from '../browser-backend';
14
+
15
+ describe('browser-backend-dual-instance.test.ts (w5)', () => {
16
+ it('pointerlockchange on canvas A only affects instance A, not instance B', () => {
17
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
18
+ const makeTarget = (label: string) => ({
19
+ addEventListener(kind: string, handler: EventListener): void {
20
+ let perTarget = listeners.get(label);
21
+ if (!perTarget) {
22
+ perTarget = new Map();
23
+ listeners.set(label, perTarget);
24
+ }
25
+ let set = perTarget.get(kind);
26
+ if (!set) {
27
+ set = new Set();
28
+ perTarget.set(kind, set);
29
+ }
30
+ set.add(handler);
31
+ },
32
+ removeEventListener(kind: string, handler: EventListener): void {
33
+ listeners.get(label)?.get(kind)?.delete(handler);
34
+ },
35
+ });
36
+
37
+ let pointerLockEl: Element | null = null;
38
+
39
+ // Create two fake canvases.
40
+ const canvasA = {
41
+ ...makeTarget('canvasA'),
42
+ requestPointerLock() {},
43
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
44
+ width: 800,
45
+ height: 600,
46
+ style: { touchAction: '' },
47
+ setPointerCapture: () => {},
48
+ } as unknown as HTMLCanvasElement;
49
+
50
+ const canvasB = {
51
+ ...makeTarget('canvasB'),
52
+ requestPointerLock() {},
53
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
54
+ width: 800,
55
+ height: 600,
56
+ style: { touchAction: '' },
57
+ setPointerCapture: () => {},
58
+ } as unknown as HTMLCanvasElement;
59
+
60
+ // Shared document — both instances listen on it.
61
+ const doc = {
62
+ ...makeTarget('document'),
63
+ hasFocus() {
64
+ return true;
65
+ },
66
+ visibilityState: 'visible',
67
+ get pointerLockElement() {
68
+ return pointerLockEl;
69
+ },
70
+ exitPointerLock() {},
71
+ } as unknown as Document;
72
+
73
+ const win = makeTarget('window') as unknown as Window;
74
+
75
+ const handleA = attachBrowserInputBackend(canvasA, {
76
+ document: doc,
77
+ window: win,
78
+ });
79
+ const handleB = attachBrowserInputBackend(canvasB, {
80
+ document: doc,
81
+ window: win,
82
+ });
83
+
84
+ // Neither is locked initially.
85
+ expect(handleA.backend.sample().pointerLocked).toBe(false);
86
+ expect(handleB.backend.sample().pointerLocked).toBe(false);
87
+
88
+ // Lock canvas A via pointerlockchange.
89
+ pointerLockEl = canvasA;
90
+ const pcHandlers = listeners.get('document')?.get('pointerlockchange');
91
+ if (pcHandlers) {
92
+ const ev = new Event('pointerlockchange');
93
+ for (const h of pcHandlers) h(ev as Event);
94
+ }
95
+
96
+ // Only instance A should see pointerLocked=true.
97
+ expect(handleA.backend.sample().pointerLocked).toBe(true);
98
+ expect(handleB.backend.sample().pointerLocked).toBe(false);
99
+
100
+ // Unlock: pointerLockElement → null.
101
+ pointerLockEl = null;
102
+ if (pcHandlers) {
103
+ const ev = new Event('pointerlockchange');
104
+ for (const h of pcHandlers) h(ev as Event);
105
+ }
106
+ expect(handleA.backend.sample().pointerLocked).toBe(false);
107
+ expect(handleB.backend.sample().pointerLocked).toBe(false);
108
+ });
109
+
110
+ it('pointerlockchange on canvas B only affects instance B', () => {
111
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
112
+ const makeTarget = (label: string) => ({
113
+ addEventListener(kind: string, handler: EventListener): void {
114
+ let perTarget = listeners.get(label);
115
+ if (!perTarget) {
116
+ perTarget = new Map();
117
+ listeners.set(label, perTarget);
118
+ }
119
+ let set = perTarget.get(kind);
120
+ if (!set) {
121
+ set = new Set();
122
+ perTarget.set(kind, set);
123
+ }
124
+ set.add(handler);
125
+ },
126
+ removeEventListener(kind: string, handler: EventListener): void {
127
+ listeners.get(label)?.get(kind)?.delete(handler);
128
+ },
129
+ });
130
+
131
+ let pointerLockEl: Element | null = null;
132
+
133
+ const canvasA = {
134
+ ...makeTarget('canvasA'),
135
+ requestPointerLock() {},
136
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
137
+ width: 800,
138
+ height: 600,
139
+ style: { touchAction: '' },
140
+ setPointerCapture: () => {},
141
+ } as unknown as HTMLCanvasElement;
142
+
143
+ const canvasB = {
144
+ ...makeTarget('canvasB'),
145
+ requestPointerLock() {},
146
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
147
+ width: 800,
148
+ height: 600,
149
+ style: { touchAction: '' },
150
+ setPointerCapture: () => {},
151
+ } as unknown as HTMLCanvasElement;
152
+
153
+ const doc = {
154
+ ...makeTarget('document'),
155
+ hasFocus() {
156
+ return true;
157
+ },
158
+ visibilityState: 'visible',
159
+ get pointerLockElement() {
160
+ return pointerLockEl;
161
+ },
162
+ exitPointerLock() {},
163
+ } as unknown as Document;
164
+
165
+ const win = makeTarget('window') as unknown as Window;
166
+
167
+ const handleA = attachBrowserInputBackend(canvasA, {
168
+ document: doc,
169
+ window: win,
170
+ });
171
+ const handleB = attachBrowserInputBackend(canvasB, {
172
+ document: doc,
173
+ window: win,
174
+ });
175
+
176
+ // Lock canvas B.
177
+ pointerLockEl = canvasB;
178
+ const pcHandlers = listeners.get('document')?.get('pointerlockchange');
179
+ if (pcHandlers) {
180
+ const ev = new Event('pointerlockchange');
181
+ for (const h of pcHandlers) h(ev as Event);
182
+ }
183
+
184
+ expect(handleA.backend.sample().pointerLocked).toBe(false);
185
+ expect(handleB.backend.sample().pointerLocked).toBe(true);
186
+ });
187
+
188
+ it('provider path: two instances with independent providers do not interfere', () => {
189
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
190
+ const makeTarget = (label: string) => ({
191
+ addEventListener(kind: string, handler: EventListener): void {
192
+ let perTarget = listeners.get(label);
193
+ if (!perTarget) {
194
+ perTarget = new Map();
195
+ listeners.set(label, perTarget);
196
+ }
197
+ let set = perTarget.get(kind);
198
+ if (!set) {
199
+ set = new Set();
200
+ perTarget.set(kind, set);
201
+ }
202
+ set.add(handler);
203
+ },
204
+ removeEventListener(kind: string, handler: EventListener): void {
205
+ listeners.get(label)?.get(kind)?.delete(handler);
206
+ },
207
+ });
208
+
209
+ const pointerLockEl: Element | null = null;
210
+ let providerACalled = false;
211
+
212
+ const canvasA = {
213
+ ...makeTarget('canvasA'),
214
+ requestPointerLock() {},
215
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
216
+ width: 800,
217
+ height: 600,
218
+ style: { touchAction: '' },
219
+ setPointerCapture: () => {},
220
+ } as unknown as HTMLCanvasElement;
221
+
222
+ const canvasB = {
223
+ ...makeTarget('canvasB'),
224
+ requestPointerLock() {},
225
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
226
+ width: 800,
227
+ height: 600,
228
+ style: { touchAction: '' },
229
+ setPointerCapture: () => {},
230
+ } as unknown as HTMLCanvasElement;
231
+
232
+ const doc = {
233
+ ...makeTarget('document'),
234
+ hasFocus() {
235
+ return true;
236
+ },
237
+ visibilityState: 'visible',
238
+ get pointerLockElement() {
239
+ return pointerLockEl;
240
+ },
241
+ exitPointerLock() {},
242
+ } as unknown as Document;
243
+
244
+ const win = makeTarget('window') as unknown as Window;
245
+
246
+ // Instance A has a provider, instance B has W3C-only (no provider).
247
+ const handleA = attachBrowserInputBackend(canvasA, {
248
+ document: doc,
249
+ window: win,
250
+ lockProvider: {
251
+ requestLock() {
252
+ providerACalled = true;
253
+ },
254
+ exitLock() {},
255
+ },
256
+ });
257
+ const handleB = attachBrowserInputBackend(canvasB, {
258
+ document: doc,
259
+ window: win,
260
+ // No lockProvider — pure W3C path.
261
+ });
262
+
263
+ // Click on canvas A triggers provider.
264
+ const clickAH = listeners.get('canvasA')?.get('click');
265
+ if (clickAH) {
266
+ const ev = new Event('click');
267
+ for (const h of clickAH) h(ev as Event);
268
+ }
269
+ expect(providerACalled).toBe(true);
270
+
271
+ // Instance A is provider-locked; instance B is not.
272
+ expect(handleA.backend.sample().pointerLocked).toBe(true);
273
+ expect(handleB.backend.sample().pointerLocked).toBe(false);
274
+
275
+ // Instance B click should be on W3C path (no provider).
276
+ const clickBH = listeners.get('canvasB')?.get('click');
277
+ if (clickBH) {
278
+ const ev = new Event('click');
279
+ for (const h of clickBH) h(ev as Event);
280
+ }
281
+
282
+ // Instance B should still not be locked (W3C requestPointerLock was called
283
+ // but pointerlockchange hasn't fired).
284
+ expect(handleB.backend.sample().pointerLocked).toBe(false);
285
+ // Instance A should still be locked (no interference).
286
+ expect(handleA.backend.sample().pointerLocked).toBe(true);
287
+ });
288
+
289
+ it('detaching instance A does not affect instance B', () => {
290
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
291
+ const makeTarget = (label: string) => ({
292
+ addEventListener(kind: string, handler: EventListener): void {
293
+ let perTarget = listeners.get(label);
294
+ if (!perTarget) {
295
+ perTarget = new Map();
296
+ listeners.set(label, perTarget);
297
+ }
298
+ let set = perTarget.get(kind);
299
+ if (!set) {
300
+ set = new Set();
301
+ perTarget.set(kind, set);
302
+ }
303
+ set.add(handler);
304
+ },
305
+ removeEventListener(kind: string, handler: EventListener): void {
306
+ listeners.get(label)?.get(kind)?.delete(handler);
307
+ },
308
+ });
309
+
310
+ let pointerLockEl: Element | null = null;
311
+
312
+ const canvasA = {
313
+ ...makeTarget('canvasA'),
314
+ requestPointerLock() {},
315
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
316
+ width: 800,
317
+ height: 600,
318
+ style: { touchAction: '' },
319
+ setPointerCapture: () => {},
320
+ } as unknown as HTMLCanvasElement;
321
+
322
+ const canvasB = {
323
+ ...makeTarget('canvasB'),
324
+ requestPointerLock() {},
325
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
326
+ width: 800,
327
+ height: 600,
328
+ style: { touchAction: '' },
329
+ setPointerCapture: () => {},
330
+ } as unknown as HTMLCanvasElement;
331
+
332
+ const doc = {
333
+ ...makeTarget('document'),
334
+ hasFocus() {
335
+ return true;
336
+ },
337
+ visibilityState: 'visible',
338
+ get pointerLockElement() {
339
+ return pointerLockEl;
340
+ },
341
+ exitPointerLock() {},
342
+ } as unknown as Document;
343
+
344
+ const win = makeTarget('window') as unknown as Window;
345
+
346
+ const handleA = attachBrowserInputBackend(canvasA, {
347
+ document: doc,
348
+ window: win,
349
+ });
350
+ const handleB = attachBrowserInputBackend(canvasB, {
351
+ document: doc,
352
+ window: win,
353
+ });
354
+
355
+ // Lock both.
356
+ pointerLockEl = canvasA;
357
+ let pcHandlers = listeners.get('document')?.get('pointerlockchange');
358
+ if (pcHandlers) {
359
+ const ev = new Event('pointerlockchange');
360
+ for (const h of pcHandlers) h(ev as Event);
361
+ }
362
+ // Only canvasA gets locked because pointerLockElement === canvasA.
363
+ expect(handleA.backend.sample().pointerLocked).toBe(true);
364
+ expect(handleB.backend.sample().pointerLocked).toBe(false);
365
+
366
+ // Detach instance A.
367
+ handleA();
368
+
369
+ // Instance B should still function normally.
370
+ pointerLockEl = canvasB;
371
+ // Re-fetch pcHandlers since they may have been removed by handleA.
372
+ pcHandlers = listeners.get('document')?.get('pointerlockchange');
373
+ if (pcHandlers) {
374
+ const ev = new Event('pointerlockchange');
375
+ for (const h of pcHandlers) h(ev as Event);
376
+ }
377
+ expect(handleB.backend.sample().pointerLocked).toBe(true);
378
+ });
379
+ });
@@ -0,0 +1,239 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { deriveActionStates } from '../action-state';
3
+ import { attachBrowserInputBackend } from '../browser-backend';
4
+ import { snapshotFromSample } from '../input-snapshot';
5
+
6
+ type Listener = (event: Event) => void;
7
+
8
+ interface FocusLossEnv {
9
+ readonly backend: ReturnType<typeof attachBrowserInputBackend>['backend'];
10
+ readonly canvas: HTMLCanvasElement;
11
+ readonly pad: {
12
+ readonly index: number;
13
+ readonly id: string;
14
+ readonly connected: boolean;
15
+ readonly mapping: string;
16
+ readonly buttons: { value: number; pressed: boolean }[];
17
+ readonly axes: number[];
18
+ };
19
+ readonly exitPointerLockCalls: { count: number };
20
+ setFocused(value: boolean): void;
21
+ setVisibility(value: 'visible' | 'hidden'): void;
22
+ fire(target: 'canvas' | 'document' | 'window', kind: string, event: Event): void;
23
+ lockCanvas(): void;
24
+ unlockCanvas(): void;
25
+ }
26
+
27
+ function buildFocusLossEnv(): FocusLossEnv {
28
+ const listeners = new Map<string, Map<string, Set<Listener>>>();
29
+ const makeTarget = (label: string) => ({
30
+ addEventListener(kind: string, listener: Listener): void {
31
+ let targetListeners = listeners.get(label);
32
+ if (!targetListeners) {
33
+ targetListeners = new Map();
34
+ listeners.set(label, targetListeners);
35
+ }
36
+ let kindListeners = targetListeners.get(kind);
37
+ if (!kindListeners) {
38
+ kindListeners = new Set();
39
+ targetListeners.set(kind, kindListeners);
40
+ }
41
+ kindListeners.add(listener);
42
+ },
43
+ removeEventListener(kind: string, listener: Listener): void {
44
+ listeners.get(label)?.get(kind)?.delete(listener);
45
+ },
46
+ });
47
+
48
+ let focused = true;
49
+ let visibility: 'visible' | 'hidden' = 'visible';
50
+ let pointerLockElement: Element | null = null;
51
+ const exitPointerLockCalls = { count: 0 };
52
+ const pad = {
53
+ index: 0,
54
+ id: 'm19-test-pad',
55
+ connected: true,
56
+ mapping: 'standard',
57
+ buttons: Array.from({ length: 17 }, (_, index) => ({
58
+ value: index === 0 ? 1 : 0,
59
+ pressed: index === 0,
60
+ })),
61
+ axes: [0, 0, 0, 0],
62
+ };
63
+
64
+ const canvas = {
65
+ ...makeTarget('canvas'),
66
+ requestPointerLock() {},
67
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
68
+ width: 800,
69
+ height: 600,
70
+ style: { touchAction: '' },
71
+ setPointerCapture() {},
72
+ } as unknown as HTMLCanvasElement;
73
+
74
+ const doc = {
75
+ ...makeTarget('document'),
76
+ hasFocus() {
77
+ return focused;
78
+ },
79
+ get visibilityState() {
80
+ return visibility;
81
+ },
82
+ get pointerLockElement() {
83
+ return pointerLockElement;
84
+ },
85
+ exitPointerLock() {
86
+ exitPointerLockCalls.count += 1;
87
+ // Deliberately do not emit pointerlockchange: blur must publish the
88
+ // unlocked state immediately instead of waiting for the browser event.
89
+ pointerLockElement = null;
90
+ },
91
+ } as unknown as Document;
92
+
93
+ const win = makeTarget('window') as unknown as Window;
94
+ const handle = attachBrowserInputBackend(canvas, {
95
+ document: doc,
96
+ window: win,
97
+ navigator: { getGamepads: () => [pad] as unknown as Gamepad[] },
98
+ });
99
+
100
+ return {
101
+ backend: handle.backend,
102
+ canvas,
103
+ pad,
104
+ exitPointerLockCalls,
105
+ setFocused(value) {
106
+ focused = value;
107
+ },
108
+ setVisibility(value) {
109
+ visibility = value;
110
+ },
111
+ fire(target, kind, event) {
112
+ for (const listener of listeners.get(target)?.get(kind) ?? []) listener(event);
113
+ },
114
+ lockCanvas() {
115
+ pointerLockElement = canvas;
116
+ for (const listener of listeners.get('document')?.get('pointerlockchange') ?? []) {
117
+ listener({} as Event);
118
+ }
119
+ },
120
+ unlockCanvas() {
121
+ pointerLockElement = null;
122
+ for (const listener of listeners.get('document')?.get('pointerlockchange') ?? []) {
123
+ listener({} as Event);
124
+ }
125
+ },
126
+ };
127
+ }
128
+
129
+ function pointerEvent(
130
+ phase: 'down' | 'move',
131
+ pointerId: number,
132
+ pointerType: 'mouse' | 'touch',
133
+ x: number,
134
+ y: number,
135
+ ): Event {
136
+ return {
137
+ pointerId,
138
+ pointerType,
139
+ button: pointerType === 'mouse' ? 0 : -1,
140
+ clientX: x,
141
+ clientY: y,
142
+ pressure: pointerType === 'touch' ? 1 : 0.5,
143
+ movementX: phase === 'move' ? 1 : 0,
144
+ movementY: phase === 'move' ? 1 : 0,
145
+ } as unknown as Event;
146
+ }
147
+
148
+ describe('browser-backend focus loss', () => {
149
+ it('cancels live input, releases W3C lock, and baselines a held gamepad', () => {
150
+ const env = buildFocusLossEnv();
151
+
152
+ // Establish the initial gamepad baseline before any held input is acquired.
153
+ env.backend.sample();
154
+ env.backend.sample();
155
+ env.lockCanvas();
156
+ env.fire('window', 'keydown', { key: 'w', code: 'KeyW' } as unknown as Event);
157
+ env.fire('canvas', 'pointerdown', pointerEvent('down', 10, 'touch', 100, 200));
158
+ env.fire('canvas', 'pointerdown', pointerEvent('down', 11, 'touch', 200, 200));
159
+ env.fire('canvas', 'pointerdown', pointerEvent('down', 1, 'mouse', 400, 300));
160
+ const gestureStarted = env.backend.sample();
161
+ expect(gestureStarted.gestureEvents?.map((event) => event.kind)).toContain('pinch-begin');
162
+ env.fire('canvas', 'pointermove', pointerEvent('move', 10, 'touch', 80, 200));
163
+ env.fire('canvas', 'pointermove', pointerEvent('move', 11, 'touch', 240, 200));
164
+
165
+ const acquired = env.backend.sample();
166
+ expect(acquired.downCodes?.has('KeyW')).toBe(true);
167
+ expect(acquired.pointers).toHaveLength(3);
168
+ expect(acquired.gestures?.pinchScale).toBeGreaterThan(1);
169
+ expect(acquired.pointerLocked).toBe(true);
170
+ expect(acquired.buttons).toEqual([true, false, false]);
171
+ expect(acquired.gamepads?.[0]?.pressed.has(0)).toBe(true);
172
+ expect(acquired.gamepads?.[0]?.justPressed.has(0)).toBe(false);
173
+
174
+ env.setFocused(false);
175
+ env.setVisibility('hidden');
176
+ env.fire('document', 'visibilitychange', {} as Event);
177
+ expect(env.exitPointerLockCalls.count).toBe(1);
178
+
179
+ const cancelled = env.backend.sample();
180
+ expect(cancelled.downKeys.size).toBe(0);
181
+ expect(cancelled.downCodes?.size).toBe(0);
182
+ expect(cancelled.buttons).toEqual([false, false, false]);
183
+ expect(cancelled.focusReset).toBe(true);
184
+ expect(cancelled.pointerLocked).toBe(false);
185
+ expect(cancelled.pointers).toBeUndefined();
186
+ expect(cancelled.pointerEvents?.map((event) => event.phase)).toEqual([
187
+ 'cancel',
188
+ 'cancel',
189
+ 'cancel',
190
+ ]);
191
+ expect(cancelled.gestures).toBeUndefined();
192
+ expect(cancelled.gestureEvents?.map((event) => event.kind)).toContain('pinch-cancel');
193
+ expect(cancelled.gamepads?.[0]?.pressed.has(0)).toBe(true);
194
+ expect(cancelled.gamepads?.[0]?.justPressed.has(0)).toBe(false);
195
+ expect(cancelled.gamepads?.[0]?.justReleased.has(0)).toBe(false);
196
+ const previousSnapshot = snapshotFromSample(acquired);
197
+ expect(
198
+ snapshotFromSample(cancelled, undefined, undefined, previousSnapshot).mouse.justReleased(0),
199
+ ).toBe(false);
200
+ const inputMap = [
201
+ { action: 'keyboard', bindings: [{ type: 'key' as const, key: 'w' }] },
202
+ { action: 'mouse', bindings: [{ type: 'mouseButton' as const, button: 0 as const }] },
203
+ ];
204
+ const previousActions = deriveActionStates(acquired, inputMap);
205
+ const resetActions = deriveActionStates(cancelled, inputMap, previousActions);
206
+ expect(resetActions.every((action) => action.justReleased === false)).toBe(true);
207
+
208
+ const stableAfterLoss = env.backend.sample();
209
+ expect(stableAfterLoss.gamepads?.[0]?.justPressed.has(0)).toBe(false);
210
+ expect(stableAfterLoss.gamepads?.[0]?.justReleased.has(0)).toBe(false);
211
+
212
+ // A physical transition after the loss is a new edge; the old held state
213
+ // is not replayed merely because the tab became visible again.
214
+ env.pad.buttons[0] = { value: 0, pressed: false };
215
+ const released = env.backend.sample();
216
+ expect(released.gamepads?.[0]?.justReleased.has(0)).toBe(true);
217
+ env.pad.buttons[0] = { value: 1, pressed: true };
218
+ const reacquired = env.backend.sample();
219
+ expect(reacquired.gamepads?.[0]?.justPressed.has(0)).toBe(true);
220
+ });
221
+
222
+ it('treats a native pointer-lock exit as an input lifecycle boundary', () => {
223
+ const env = buildFocusLossEnv();
224
+ env.backend.sample();
225
+ env.lockCanvas();
226
+ env.fire('window', 'keydown', { key: 'w', code: 'KeyW' } as unknown as Event);
227
+ env.fire('canvas', 'pointerdown', pointerEvent('down', 1, 'mouse', 400, 300));
228
+ const acquired = env.backend.sample();
229
+ expect(acquired.downCodes?.has('KeyW')).toBe(true);
230
+ expect(acquired.pointerLocked).toBe(true);
231
+
232
+ env.unlockCanvas();
233
+ const reset = env.backend.sample();
234
+ expect(reset.downCodes?.size).toBe(0);
235
+ expect(reset.pointerLocked).toBe(false);
236
+ expect(reset.focusReset).toBe(true);
237
+ expect(reset.pointerEvents?.map((event) => event.phase)).toEqual(['cancel']);
238
+ });
239
+ });