@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,732 @@
1
+ // input-snapshot.ts -- frozen frame-start input snapshot Resource for
2
+ // forgeax-engine. Multi-device surface (keyboard + mouse + gamepad + pointer +
3
+ // virtualAxis) — each cluster is a frozen reader on the per-frame Sample.
4
+ //
5
+ // charter awareness:
6
+ // F1 single-entry indexability -- barrel exports all types; IDE autocomplete
7
+ // reaches the full surface from one import
8
+ // F2 minimal surface -- each device cluster exposes only its natural
9
+ // readpoints; no phantom methods or modal parameters
10
+ // P3 explicit failure -- no thrown errors from accessor methods; absent
11
+ // keys / pre-start state / disconnected slots / unsupported devices
12
+ // all report `false` / `0` / zero-vector (the empty signal IS the
13
+ // signal). `mouse.button(i)` / `gamepad.button(b)` parameters are
14
+ // literal unions so out-of-range indices are TS compile errors rather
15
+ // than runtime bounds-clamps.
16
+ // P4 consistent abstraction -- the snapshot hides the producer
17
+ // (browser listener wiring + gamepad polling) entirely; consumers read
18
+ // via the `InputSnapshot` Resource regardless of backend
19
+ // P5 producer/consumer split -- the InputBackend protocol decouples
20
+ // the producer from the snapshot; `frame-start-scan-system.ts` is the
21
+ // bridge that calls `backend.sample()` and writes the Resource
22
+
23
+ import type { ActionConfig, ActionState, GetVectorOptions } from './action-state';
24
+ import { getAxis, getVector } from './action-state';
25
+ import type { GestureEvent, GestureState } from './gesture-recognizer';
26
+ import { IDENTITY_GESTURE } from './gesture-recognizer';
27
+
28
+ // D-5 / AC-19: pointerType is narrowed from string to a 3-literal union.
29
+ // The W3C Pointer Events spec allows '' when the device type cannot be
30
+ // detected; coercion maps '' to 'mouse' at the producer, so the snapshot
31
+ // only ever sees one of these three canonical values.
32
+ export type PointerType = 'mouse' | 'pen' | 'touch';
33
+
34
+ /** Standard-layout gamepad button index (0-16 per W3C Gamepad spec). */
35
+ export type GamepadButtonIndex =
36
+ | 0
37
+ | 1
38
+ | 2
39
+ | 3
40
+ | 4
41
+ | 5
42
+ | 6
43
+ | 7
44
+ | 8
45
+ | 9
46
+ | 10
47
+ | 11
48
+ | 12
49
+ | 13
50
+ | 14
51
+ | 15
52
+ | 16;
53
+
54
+ /** Standard-layout gamepad axis index (0-3 per W3C Gamepad spec). */
55
+ export type GamepadAxisIndex = 0 | 1 | 2 | 3;
56
+
57
+ /** W3C MouseEvent.button index (0=primary, 1=auxiliary, 2=secondary). */
58
+ export type MouseButtonIndex = 0 | 1 | 2;
59
+
60
+ /** Per-slot gamepad frame data produced by `sample()`. */
61
+ export interface GamepadSlotSample {
62
+ readonly index: number;
63
+ /**
64
+ * True when the browser reports a standard mapping OR the SDL controller
65
+ * DB has normalized a non-standard layout at the acquisition layer (M3
66
+ * D-1 redefinition). The Feat1 meaning ("browser-reported standard
67
+ * mapping only") is widened here: a non-standard pad whose GUID matches
68
+ * gamecontrollerdb.txt is re-projected onto the standard layout and
69
+ * reported as standardMapping=true, so bindings and direct reads see a
70
+ * uniform standard button/axis space. A non-standard pad with no DB match
71
+ * (or before the DB has loaded) keeps standardMapping=false + empty
72
+ * readpoints (graceful degradation).
73
+ */
74
+ readonly standardMapping: boolean;
75
+ readonly pressed: ReadonlySet<number>;
76
+ readonly justPressed: ReadonlySet<number>;
77
+ readonly justReleased: ReadonlySet<number>;
78
+ readonly buttonValues: ReadonlyMap<number, number>;
79
+ readonly axes: readonly [number, number, number, number];
80
+ }
81
+
82
+ /** Per-pointer live state (active contacts tracked by pointerId).
83
+ *
84
+ * D-5 / E-10: `pointerType` is a 3-literal union. `'mouse'` is the default
85
+ * placeholder for inactive pointers — when `active=false`, `pointerType` is
86
+ * always `'mouse'`. The semantic "no pointer" is carried by the `active`
87
+ * field, never by a sentinel string value.
88
+ */
89
+ export interface PointerSample {
90
+ readonly pointerId: number;
91
+ readonly x: number;
92
+ readonly y: number;
93
+ readonly pressure: number;
94
+ readonly pointerType: PointerType;
95
+ readonly active: boolean;
96
+ readonly delta: { readonly x: number; readonly y: number };
97
+ }
98
+
99
+ /** Per-frame phase event (down/move/up/cancel queue, one-frame lifecycle). */
100
+ export interface PointerPhaseEvent {
101
+ readonly pointerId: number;
102
+ readonly phase: 'down' | 'move' | 'up' | 'cancel';
103
+ readonly x: number;
104
+ readonly y: number;
105
+ readonly pressure: number;
106
+ readonly pointerType: PointerType;
107
+ }
108
+
109
+ /** Per-frame virtual axis output (named joystick derived from pointer input). */
110
+ export interface VirtualAxisSample {
111
+ readonly name: string;
112
+ readonly x: number;
113
+ readonly y: number;
114
+ }
115
+
116
+ /**
117
+ * Configuration for an on-screen virtual joystick (M3). Consumed by
118
+ * attachBrowserInputBackend and passed through to deriveVirtualAxes.
119
+ *
120
+ * Fixed mode: origin is config.anchor (or region center if anchor omitted).
121
+ * Floating mode: origin is the first pointerdown position within the region.
122
+ */
123
+ export interface VirtualJoystickConfig {
124
+ readonly name: string;
125
+ readonly mode: 'fixed' | 'floating';
126
+ /** Canvas-pixel region where touches are eligible to bind this joystick. */
127
+ readonly region: {
128
+ readonly x: number;
129
+ readonly y: number;
130
+ readonly width: number;
131
+ readonly height: number;
132
+ };
133
+ /** Fixed-mode origin anchor. Defaults to region center when omitted. */
134
+ readonly anchor?: { readonly x: number; readonly y: number } | undefined;
135
+ /** Max drag radius in canvas pixels. Vector is clamped then normalized to radius. */
136
+ readonly radius: number;
137
+ /** Normalized deadzone (0..1). |vec| < deadzone → zero vector output. */
138
+ readonly deadzone: number;
139
+ }
140
+
141
+ /** Capability snapshot (frozen at backend attach time). */
142
+ export interface Capabilities {
143
+ readonly gamepad: boolean;
144
+ readonly pointer: boolean;
145
+ }
146
+
147
+ /**
148
+ * Frozen value-shape view exposed to user systems via the `InputSnapshot`
149
+ * Resource. The methods are pure reads; calling them does not mutate any
150
+ * accumulator (charter P3 -- no observable side effects from the accessor
151
+ * surface). A fresh `InputSnapshot` instance is constructed by the
152
+ * frame-start scan system once per `world.update()` and replaces the
153
+ * previous Resource value.
154
+ */
155
+ export interface InputSnapshot {
156
+ /** Keyboard view (held state plus frame edges). */
157
+ readonly keyboard: {
158
+ /**
159
+ * `true` while `key` is currently held. `key` matches the value the
160
+ * backend records (KeyboardEvent.key for the browser backend). When
161
+ * the key is not in the held set, returns `false` -- never throws
162
+ * (charter P3: empty signal is the signal).
163
+ */
164
+ down(key: string): boolean;
165
+ /** `true` for the first frame in which `key` becomes held. */
166
+ justPressed(key: string): boolean;
167
+ /**
168
+ * `true` for one frame after the key was released (the up-edge). The
169
+ * edge collapses on the following `world.update()`. `key` not in the
170
+ * up-edge set returns `false`.
171
+ */
172
+ up(key: string): boolean;
173
+ /** Code-key equivalent of `down`, using KeyboardEvent.code identity. */
174
+ downCode(code: string): boolean;
175
+ /** Code-key equivalent of `justPressed`. */
176
+ justPressedCode(code: string): boolean;
177
+ /** Code-key equivalent of `up`. */
178
+ upCode(code: string): boolean;
179
+ };
180
+ /** Mouse view (charter F2 minimal surface: position + movementDelta + button + wheelDelta). */
181
+ readonly mouse: {
182
+ /**
183
+ * Latest canvas-pixel cursor position. This is available during hover;
184
+ * it does not require a button press or pointer capture. The position is
185
+ * frozen with the rest of the frame-start snapshot.
186
+ */
187
+ readonly position: { readonly x: number; readonly y: number } | undefined;
188
+ /**
189
+ * PointerLock-style accumulated movement since the previous frame.
190
+ * Value is frozen at frame-start; reading it does not clear the
191
+ * accumulator. The next `world.update()` produces a fresh delta
192
+ * (zero if no movement events arrived).
193
+ */
194
+ readonly movementDelta: { readonly x: number; readonly y: number };
195
+ /**
196
+ * Merged pointer-lock state: true when either W3C pointer-lock
197
+ * (pointerLockElement === this backend's canvas) OR the lockProvider
198
+ * path has engaged. Consumers read this to decide whether to consume
199
+ * movementDelta for look/camera rotation. Required field, alongside
200
+ * movementDelta -- both facts sit at the same attribute path for
201
+ * single-point indexing (charter F1).
202
+ */
203
+ readonly pointerLocked: boolean;
204
+ /**
205
+ * `true` while the W3C MouseEvent.button slot `i` is held. `i` is
206
+ * narrowed to the literal `0 | 1 | 2` so out-of-range indices are
207
+ * rejected at compile time (charter P3 explicit failure: TS literal
208
+ * narrowing replaces a runtime bounds-clamp).
209
+ *
210
+ * - 0 -- primary button (left)
211
+ * - 1 -- auxiliary button (middle)
212
+ * - 2 -- secondary button (right)
213
+ */
214
+ button(i: MouseButtonIndex): boolean;
215
+ /** `true` for the first frame in which button `i` becomes held. */
216
+ justPressed(i: MouseButtonIndex): boolean;
217
+ /** `true` for the frame in which button `i` is released. */
218
+ justReleased(i: MouseButtonIndex): boolean;
219
+ /**
220
+ * Discrete wheel notches accumulated since the previous frame.
221
+ * Plan-strategy D-5 sign-discrete: each `WheelEvent` contributes
222
+ * `Math.sign(event.deltaY)` to the per-frame accumulator regardless
223
+ * of `deltaMode` (PIXEL / LINE / PAGE all collapse). Sign convention
224
+ * follows W3C `WheelEvent.deltaY`: positive = scroll down / away
225
+ * from user, negative = scroll up / toward user.
226
+ *
227
+ * Value is frozen at frame-start; reading it does not clear the
228
+ * accumulator. The next `world.update()` produces a fresh delta
229
+ * (zero when no wheel events arrived).
230
+ *
231
+ * Trade-off (plan-strategy R-7): trackpad high-resolution wheel
232
+ * events collapse to one notch per event, losing sub-notch fidelity.
233
+ * AI users who need analog magnitude must opt in via a future
234
+ * unitful surface (OOS-7 ScrollSnapshot is the deferred shape).
235
+ */
236
+ readonly wheelDelta: number;
237
+ };
238
+ /**
239
+ * Per-slot gamepad reader. Returns a reader object for the gamepad at
240
+ * slot `i` regardless of connection state: disconnected or out-of-range
241
+ * slots report `connected=false`, all button/axis readpoints return
242
+ * `false`/`0` (charter P3 empty signal). The slot index is intentionally
243
+ * not narrowed — platform slot counts vary, and bounds handling is a
244
+ * runtime concern.
245
+ */
246
+ gamepad(i: number): {
247
+ readonly connected: boolean;
248
+ readonly standardMapping: boolean;
249
+ button(b: GamepadButtonIndex): boolean;
250
+ buttonValue(b: GamepadButtonIndex): number;
251
+ justPressed(b: GamepadButtonIndex): boolean;
252
+ justReleased(b: GamepadButtonIndex): boolean;
253
+ axis(a: GamepadAxisIndex): number;
254
+ };
255
+ /**
256
+ * Frozen capabilities snapshot, determined once at backend attach time.
257
+ * Consumers use this to decide whether gamepad/pointer readpoints will
258
+ * ever carry live data, without probing per-frame.
259
+ */
260
+ readonly capabilities: Capabilities;
261
+ /**
262
+ * Per-pointerId reader. Returns `{ active: true/false, x, y, pressure,
263
+ * pointerType, delta }` for the given pointerId. Unknown pointerIds
264
+ * return `{ active: false, ... }` without throwing.
265
+ */
266
+ pointer(
267
+ id: number,
268
+ ): PointerSample & { readonly delta: { readonly x: number; readonly y: number } };
269
+ /**
270
+ * Named virtual axis reader (joystick-derived). Returns `{ x, y }` for
271
+ * the given joystick config name. Unknown names return the zero-vector
272
+ * `{ x: 0, y: 0 }` without throwing.
273
+ */
274
+ virtualAxis(name: string): { readonly x: number; readonly y: number };
275
+ /**
276
+ * Per-frame phase event queue (down/move/up/cancel). One-frame
277
+ * lifecycle; drained by `sample()` at frame end. Empty array when no
278
+ * pointer events occurred this frame.
279
+ */
280
+ readonly pointerEvents: readonly PointerPhaseEvent[];
281
+ /**
282
+ * Continuous gesture values (pinch scale + rotation angle) for the
283
+ * active dual-finger gesture. Returns the identity empty signal
284
+ * (`pinchScale=1`, `rotationAngle=0`) when no gesture is active (AC-12);
285
+ * an active gesture retains its value on idle frames (fingers held still
286
+ * are not a gesture end). AC-11: reading the same frame's snapshot twice
287
+ * returns the identical object.
288
+ */
289
+ readonly gesture: GestureState;
290
+ /**
291
+ * Per-frame gesture lifecycle + instantaneous event queue (begin / end /
292
+ * cancel / swipe / long-press / double-tap). One-frame lifecycle, same
293
+ * pattern as `pointerEvents` (charter P4). Empty array when no gesture
294
+ * activity occurred this frame. Closed discriminant union (AC-13): a
295
+ * consumer exhaustively switches on `kind` without a default branch.
296
+ */
297
+ readonly gestureEvents: readonly GestureEvent[];
298
+ /**
299
+ * Action mapping readpoint. Returns a frozen reader for the named action.
300
+ * When `name` is registered in the InputMap, `isPressed()` / `justPressed()`
301
+ * / `justReleased()` / `strength` reflect the derived state. Unregistered
302
+ * action names return empty signal (false / 0 / false / false / 0) without
303
+ * throwing (charter P3: empty signal is the signal). AC-11: calling
304
+ * action() multiple times in the same frame returns identical values.
305
+ */
306
+ action(name: string): {
307
+ readonly isPressed: () => boolean;
308
+ readonly justPressed: () => boolean;
309
+ readonly justReleased: () => boolean;
310
+ readonly strength: number;
311
+ };
312
+ /**
313
+ * Compose a 1D axis value from two opposing actions.
314
+ *
315
+ * Returns `strength(pos) - strength(neg)`, range [-1, 1].
316
+ * Both registered: combines normally. One unregistered: contributes 0
317
+ * (E-3). Same action for both ends: always 0 (E-12).
318
+ *
319
+ * @param neg - Action name for the negative direction (e.g. 'moveLeft').
320
+ * @param pos - Action name for the positive direction (e.g. 'moveRight').
321
+ */
322
+ getAxis(neg: string, pos: string): number;
323
+ /**
324
+ * Compose a 2D vector from four directional actions with a single radial
325
+ * deadzone (Godot input.cpp three-branch formula). Uses `raw` (not
326
+ * `strength`) to avoid per-axis deadzone stacking into a square deadzone
327
+ * (AC-06).
328
+ *
329
+ * @param negX - Action name for negative X (e.g. 'moveLeft').
330
+ * @param posX - Action name for positive X (e.g. 'moveRight').
331
+ * @param negY - Action name for negative Y (e.g. 'moveUp').
332
+ * @param posY - Action name for positive Y (e.g. 'moveDown').
333
+ * @param opts - Optional override (deadzone).
334
+ */
335
+ getVector(
336
+ negX: string,
337
+ posX: string,
338
+ negY: string,
339
+ posY: string,
340
+ opts?: GetVectorOptions,
341
+ ): { readonly x: number; readonly y: number };
342
+ }
343
+
344
+ /**
345
+ * Backend protocol consumed by the frame-start scan system. The browser
346
+ * implementation (`browser-backend.ts`) is the production producer;
347
+ * tests inject fakes that emit synthetic samples.
348
+ */
349
+ export interface InputBackend {
350
+ /**
351
+ * Produce one frame's worth of input data and reset the per-frame
352
+ * accumulators (movement delta + up-edge set). Held-key state and
353
+ * held-button state survive across calls.
354
+ *
355
+ * Contract notes:
356
+ * - `downKeys` is the held-key set as of the call (held across frames)
357
+ * - `upKeys` is the up-edge set since the previous sample (cleared
358
+ * after the call so the edge appears in exactly one frame)
359
+ * - `buttons` is the held-button slot tuple `[b0, b1, b2]`
360
+ * - `movementX` / `movementY` are the accumulated PointerLock delta
361
+ * since the previous sample (cleared after the call)
362
+ * - `focused` mirrors `document.hasFocus()` so the scan system can
363
+ * suppress spurious up-edges across alt-tab transitions
364
+ */
365
+ sample(): InputBackendSample;
366
+ /**
367
+ * Command-set game gate for pointer-lock. When set to `false`, the
368
+ * backend will not request pointer-lock on click AND will immediately
369
+ * release any currently active lock (W3C path: `exitPointerLock`;
370
+ * provider path: `exitLock()`). When `true` (default), pointer-lock
371
+ * can be requested through the normal gate logic.
372
+ *
373
+ * Optional -- backends without pointer-lock support omit this method.
374
+ */
375
+ setPointerLockAllowed?(allowed: boolean): void;
376
+ /**
377
+ * Atomically discard acquired state without detaching listeners. A host calls
378
+ * this when it revokes a control lease so held keys/buttons, contacts, frame
379
+ * edges, deltas and pointer lock cannot bleed into a later consumer. Optional
380
+ * keeps synthetic backends source-compatible.
381
+ */
382
+ clear?(): void;
383
+ /** Detach DOM listeners; called when the engine shuts down. */
384
+ detach(): void;
385
+ }
386
+
387
+ /** Snapshot value returned by `InputBackend.sample()` (POD). */
388
+ export interface InputBackendSample {
389
+ readonly downKeys: ReadonlySet<string>;
390
+ readonly upKeys: ReadonlySet<string>;
391
+ /** Physical code identity (KeyboardEvent.code), when the producer has it. */
392
+ readonly downCodes?: ReadonlySet<string>;
393
+ /** One-frame physical code release edges, when the producer has them. */
394
+ readonly upCodes?: ReadonlySet<string>;
395
+ readonly buttons: readonly [boolean, boolean, boolean];
396
+ readonly movementX: number;
397
+ readonly movementY: number;
398
+ /** Latest canvas-pixel mouse position; absent for non-pointer backends. */
399
+ readonly mouseX?: number;
400
+ readonly mouseY?: number;
401
+ /**
402
+ * Sign-discrete wheel notch accumulator since the previous sample
403
+ * (plan-strategy D-5). Browser backend writes `Math.sign(deltaY)` per
404
+ * `WheelEvent`; sample() drains the value and resets the producer
405
+ * accumulator (mirrors movementX/Y semantics).
406
+ */
407
+ readonly wheelDelta: number;
408
+ /** True for the first sample after blur/hidden state reset. */
409
+ readonly focusReset?: boolean;
410
+ readonly focused: boolean;
411
+ /** M1+ gamepad per-slot frame data (optional — absent when backend lacks gamepad support). */
412
+ readonly gamepads?: readonly GamepadSlotSample[];
413
+ /** M1+ frozen capability snapshot (optional — absent for pre-M1 backends). */
414
+ readonly capabilities?: Capabilities;
415
+ /** M2+ active pointer contacts (optional — absent for keyboard-only backends). */
416
+ readonly pointers?: readonly PointerSample[];
417
+ /** M2+ per-frame phase event queue (optional — absent when no pointer events occurred). */
418
+ readonly pointerEvents?: readonly PointerPhaseEvent[];
419
+ /** M3+ virtual joystick axis outputs (optional — absent when no virtual joysticks configured). */
420
+ readonly virtualAxes?: readonly VirtualAxisSample[];
421
+ /** M5+ continuous gesture values (optional — absent when no gesture is active). */
422
+ readonly gestures?: GestureState;
423
+ /** M5+ per-frame gesture event queue (optional — absent when no gesture activity occurred). */
424
+ readonly gestureEvents?: readonly GestureEvent[];
425
+ /**
426
+ * Merged pointer-lock state: true when either W3C pointer-lock is active
427
+ * (pointerLockElement === this backend's canvas) OR the lockProvider path
428
+ * has engaged (requestLock() was called and not yet released). Required
429
+ * field -- always present, defaults to false when no lock is active.
430
+ */
431
+ readonly pointerLocked: boolean;
432
+ }
433
+
434
+ /** Build the neutral backend sample used before or outside an active consumer. */
435
+ export function createEmptyInputBackendSample(): InputBackendSample {
436
+ return {
437
+ downKeys: new Set<string>(),
438
+ upKeys: new Set<string>(),
439
+ buttons: [false, false, false],
440
+ movementX: 0,
441
+ movementY: 0,
442
+ wheelDelta: 0,
443
+ focused: true,
444
+ pointerLocked: false,
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Build an `InputSnapshot` from a backend sample. The returned object is
450
+ * frozen on construction: `down/up/button` are bound to the closed-over
451
+ * sets, and `movementDelta` is a frozen literal. Subsequent backend
452
+ * activity does not bleed into the snapshot.
453
+ */
454
+ function emptyGamepadReader(): ReturnType<InputSnapshot['gamepad']> {
455
+ return Object.freeze({
456
+ connected: false,
457
+ standardMapping: false,
458
+ button(_b: GamepadButtonIndex): boolean {
459
+ return false;
460
+ },
461
+ buttonValue(_b: GamepadButtonIndex): number {
462
+ return 0;
463
+ },
464
+ justPressed(_b: GamepadButtonIndex): boolean {
465
+ return false;
466
+ },
467
+ justReleased(_b: GamepadButtonIndex): boolean {
468
+ return false;
469
+ },
470
+ axis(_a: GamepadAxisIndex): number {
471
+ return 0;
472
+ },
473
+ });
474
+ }
475
+
476
+ function buildGamepadReader(slot: GamepadSlotSample): ReturnType<InputSnapshot['gamepad']> {
477
+ if (!slot.standardMapping) {
478
+ // AC-04: non-standard layout reports connected=true +
479
+ // standardMapping=false + all readpoints empty signal.
480
+ return Object.freeze({
481
+ connected: true,
482
+ standardMapping: false,
483
+ button: emptyGamepadReader().button,
484
+ buttonValue: emptyGamepadReader().buttonValue,
485
+ justPressed: emptyGamepadReader().justPressed,
486
+ justReleased: emptyGamepadReader().justReleased,
487
+ axis: emptyGamepadReader().axis,
488
+ });
489
+ }
490
+ return Object.freeze({
491
+ connected: true,
492
+ standardMapping: true,
493
+ button(b: GamepadButtonIndex): boolean {
494
+ return slot.pressed.has(b);
495
+ },
496
+ buttonValue(b: GamepadButtonIndex): number {
497
+ return slot.buttonValues.get(b) ?? 0;
498
+ },
499
+ justPressed(b: GamepadButtonIndex): boolean {
500
+ return slot.justPressed.has(b);
501
+ },
502
+ justReleased(b: GamepadButtonIndex): boolean {
503
+ return slot.justReleased.has(b);
504
+ },
505
+ axis(a: GamepadAxisIndex): number {
506
+ return slot.axes[a];
507
+ },
508
+ });
509
+ }
510
+
511
+ /**
512
+ * Build an `InputSnapshot` from a backend sample.
513
+ *
514
+ * @param sample - One frame's raw input backend sample (POD).
515
+ * @param actionStates - Optional per-frame derived action states from
516
+ * deriveActionStates(). When provided, snap.action(name) returns mapped
517
+ * values; when absent or empty, all action readpoints return empty signal
518
+ * (charter P3: false/0, never throws). AC-11: actionStates is frozen into
519
+ * the snapshot so same-frame re-reads return identical values.
520
+ */
521
+ export function snapshotFromSample(
522
+ sample: InputBackendSample,
523
+ actionStates?: readonly ActionState[],
524
+ inputMap?: readonly ActionConfig[],
525
+ previousSnapshot?: InputSnapshot,
526
+ ): InputSnapshot {
527
+ // structuralCopy: copying into local sets isolates the snapshot from
528
+ // any later backend mutation (the browser backend reuses its internal
529
+ // Set across frames). architecture-principles #2 derive: a Snapshot is
530
+ // a derived view of the producer's state at one instant.
531
+ const heldKeys = new Set<string>(sample.downKeys);
532
+ const upEdges = new Set<string>(sample.upKeys);
533
+ const justPressedKeys = new Set<string>();
534
+ for (const key of heldKeys) {
535
+ if (previousSnapshot === undefined || !previousSnapshot.keyboard.down(key)) {
536
+ justPressedKeys.add(key);
537
+ }
538
+ }
539
+ const heldCodes = new Set<string>(sample.downCodes ?? []);
540
+ const upCodeEdges = new Set<string>(sample.upCodes ?? []);
541
+ const justPressedCodes = new Set<string>();
542
+ for (const code of heldCodes) {
543
+ if (previousSnapshot === undefined || !previousSnapshot.keyboard.downCode(code)) {
544
+ justPressedCodes.add(code);
545
+ }
546
+ }
547
+ const buttons: readonly [boolean, boolean, boolean] = [
548
+ sample.buttons[0],
549
+ sample.buttons[1],
550
+ sample.buttons[2],
551
+ ];
552
+ const justPressedButtons: readonly [boolean, boolean, boolean] = [
553
+ buttons[0] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(0)),
554
+ buttons[1] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(1)),
555
+ buttons[2] && (previousSnapshot === undefined || !previousSnapshot.mouse.button(2)),
556
+ ];
557
+ const justReleasedButtons: readonly [boolean, boolean, boolean] = [
558
+ sample.focusReset !== true && previousSnapshot?.mouse.button(0) === true && !buttons[0],
559
+ sample.focusReset !== true && previousSnapshot?.mouse.button(1) === true && !buttons[1],
560
+ sample.focusReset !== true && previousSnapshot?.mouse.button(2) === true && !buttons[2],
561
+ ];
562
+ const movementDelta = Object.freeze({ x: sample.movementX, y: sample.movementY });
563
+ const mousePosition =
564
+ sample.mouseX === undefined || sample.mouseY === undefined
565
+ ? undefined
566
+ : Object.freeze({ x: sample.mouseX, y: sample.mouseY });
567
+ const wheelDelta = sample.wheelDelta;
568
+
569
+ // D-9: use `??` empty-signal defaults for all new optional fields.
570
+ const gamepadSlots: readonly GamepadSlotSample[] = sample.gamepads ?? [];
571
+ const caps: Capabilities =
572
+ sample.capabilities ?? Object.freeze({ gamepad: false, pointer: false });
573
+ const pointerEvts: readonly PointerPhaseEvent[] = sample.pointerEvents ?? [];
574
+ // D-4 / AC-12: no active gesture -> identity empty signal; gesture events
575
+ // default to an empty one-frame queue (mirrors pointerEvents).
576
+ const gesture: GestureState = sample.gestures ?? IDENTITY_GESTURE;
577
+ const gestureEvts: readonly GestureEvent[] = sample.gestureEvents ?? [];
578
+ // virtualAxes: consumed by M3 virtualAxis reader; stored as local for
579
+ // the snapshot closure below.
580
+ const _virtualAxes: readonly VirtualAxisSample[] = sample.virtualAxes ?? [];
581
+
582
+ const virtualAxesMap = new Map<string, VirtualAxisSample>();
583
+ for (const va of _virtualAxes) {
584
+ virtualAxesMap.set(va.name, va);
585
+ }
586
+
587
+ // Build index→slot map so gamepad(i) looks up by slot.index, not array position.
588
+ // The browser getGamepads() returns null-padded arrays where position IS the
589
+ // index, but the diffGamepadFrame output is a sparse list keyed by .index.
590
+ const gamepadSlotMap = new Map<number, GamepadSlotSample>();
591
+ for (const slot of gamepadSlots) {
592
+ gamepadSlotMap.set(slot.index, slot);
593
+ }
594
+
595
+ // Build action lookup map for snap.action(name) readpoint.
596
+ const actionMap = new Map<string, ActionState>();
597
+ if (actionStates) {
598
+ for (const a of actionStates) {
599
+ actionMap.set(a.action, a);
600
+ }
601
+ }
602
+
603
+ function emptyActionReader(): ReturnType<InputSnapshot['action']> {
604
+ return Object.freeze({
605
+ isPressed: () => false,
606
+ justPressed: () => false,
607
+ justReleased: () => false,
608
+ strength: 0,
609
+ });
610
+ }
611
+
612
+ const snapshot: InputSnapshot = {
613
+ keyboard: {
614
+ down(key) {
615
+ return heldKeys.has(key);
616
+ },
617
+ justPressed(key) {
618
+ return justPressedKeys.has(key);
619
+ },
620
+ up(key) {
621
+ return upEdges.has(key);
622
+ },
623
+ downCode(code) {
624
+ return heldCodes.has(code);
625
+ },
626
+ justPressedCode(code) {
627
+ return justPressedCodes.has(code);
628
+ },
629
+ upCode(code) {
630
+ return upCodeEdges.has(code);
631
+ },
632
+ },
633
+ mouse: {
634
+ position: mousePosition,
635
+ movementDelta,
636
+ pointerLocked: sample.pointerLocked,
637
+ button(i) {
638
+ // i is narrowed to 0 | 1 | 2 by the type system; the runtime
639
+ // index is therefore guaranteed to land in `buttons` (charter P3
640
+ // -- no defensive fallback necessary because the type narrows the
641
+ // input domain to the legal slots).
642
+ return buttons[i] === true;
643
+ },
644
+ justPressed(i) {
645
+ return justPressedButtons[i] === true;
646
+ },
647
+ justReleased(i) {
648
+ return justReleasedButtons[i] === true;
649
+ },
650
+ wheelDelta,
651
+ },
652
+ gamepad(i) {
653
+ const slot = gamepadSlotMap.get(i);
654
+ if (!slot) return emptyGamepadReader();
655
+ return buildGamepadReader(slot);
656
+ },
657
+ capabilities: caps,
658
+ pointer(id) {
659
+ const entry = (sample.pointers ?? []).find((p) => p.pointerId === id);
660
+ if (!entry) {
661
+ return Object.freeze({
662
+ active: false,
663
+ pointerId: -1,
664
+ x: 0,
665
+ y: 0,
666
+ pressure: 0,
667
+ pointerType: 'mouse' as PointerType,
668
+ delta: Object.freeze({ x: 0, y: 0 }),
669
+ });
670
+ }
671
+ return Object.freeze({
672
+ ...entry,
673
+ delta: Object.freeze({ x: entry.delta.x, y: entry.delta.y }),
674
+ });
675
+ },
676
+ virtualAxis(name) {
677
+ const va = virtualAxesMap.get(name);
678
+ if (!va) return Object.freeze({ x: 0, y: 0 });
679
+ return Object.freeze({ x: va.x, y: va.y });
680
+ },
681
+ action(name) {
682
+ const s = actionMap.get(name);
683
+ if (!s) return emptyActionReader();
684
+ return Object.freeze({
685
+ isPressed: () => s.pressed,
686
+ justPressed: () => s.justPressed,
687
+ justReleased: () => s.justReleased,
688
+ strength: s.strength,
689
+ });
690
+ },
691
+ getAxis(neg, pos) {
692
+ if (!actionStates || !inputMap) return 0;
693
+ return getAxis(inputMap, actionStates, neg, pos);
694
+ },
695
+ getVector(negX, posX, negY, posY, opts) {
696
+ if (!actionStates || !inputMap) return { x: 0, y: 0 };
697
+ return getVector(inputMap, actionStates, negX, posX, negY, posY, opts);
698
+ },
699
+ pointerEvents: pointerEvts,
700
+ gesture,
701
+ gestureEvents: gestureEvts,
702
+ };
703
+ (snapshot as unknown as Record<string, unknown>)._actionStates = actionStates;
704
+ (snapshot as unknown as Record<string, unknown>)._inputMap = inputMap;
705
+ return Object.freeze(snapshot);
706
+ }
707
+
708
+ /**
709
+ * @internal Read previously derived action states from a snapshot for edge diff.
710
+ * Used by the frame-start scan system (D-6: prev snapshot = edge baseline).
711
+ */
712
+ export function readActionStatesForEdgeDiff(
713
+ snap: InputSnapshot,
714
+ ): readonly ActionState[] | undefined {
715
+ return (snap as unknown as { _actionStates?: readonly ActionState[] })._actionStates;
716
+ }
717
+
718
+ /**
719
+ * Construct an empty snapshot for the pre-start window
720
+ * (`engine.run()` has not yet attached a backend). Returns the full
721
+ * multi-device shape as `snapshotFromSample`, with every accessor
722
+ * returning `false` / `0` / zero-vector. AI users can put this into the
723
+ * Resource store to satisfy `world.getResource('InputSnapshot')` calls
724
+ * in fixtures or unit tests (charter P3: empty signal is the signal).
725
+ * The underlying 7-field POD sample contract is unchanged (D-9).
726
+ */
727
+ export function createInputSnapshot(): InputSnapshot {
728
+ return snapshotFromSample(createEmptyInputBackendSample());
729
+ }
730
+
731
+ /** Stable Resource key for `world.insertResource` / `world.getResource`. */
732
+ export const INPUT_SNAPSHOT_RESOURCE_KEY = 'InputSnapshot';