@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,495 @@
1
+ // gesture-recognizer.ts -- pure-function gesture state machines for
2
+ // forgeax-engine input (M5). Zero DOM / ECS dependencies: node-testable.
3
+ //
4
+ // charter awareness:
5
+ // C-3 (single legal cross-frame state holder) -- the recognizer owns the
6
+ // only sanctioned cross-frame gesture state. The browser backend stores
7
+ // a `RecognizerState` in its closure (alongside prevGamepadFrame /
8
+ // pointerMap / vjBindState) and threads it through `processGestureFrame`
9
+ // every `sample()`. This module never mutates its inputs; it returns a
10
+ // fresh `newState` the producer must store for the next frame.
11
+ // D-3 clock decoupling -- all timers advance off the injected `now`
12
+ // value, NOT off pointer-event arrival. An idle frame (empty phase
13
+ // queue) still advances long-press timing, so recognition is decoupled
14
+ // from event frequency (AC-16). The F-1 falsification test proves an
15
+ // event-frequency-coupled impl fails.
16
+ // D-4 dual channel -- returns continuous values (`gestureState`:
17
+ // pinchScale / rotationAngle) plus a one-frame lifecycle event list
18
+ // (`gestureEvents`). The backend forwards these as the optional
19
+ // `gestures?` / `gestureEvents?` sample fields (D-4 / F1 precedent).
20
+ // D-10 thresholds -- long-press 500ms + slop 10px, double-tap 350ms +
21
+ // 10px, swipe 0.5 px/ms over a 100ms window; exported as constants.
22
+ // D-11 pinch/rotate -- lock the earliest two pointers, ignore a third;
23
+ // 2->1 emits end + freezes continuous values; a fresh pair emits begin
24
+ // and resets to identity (1.0 / 0).
25
+
26
+ import type { PointerPhaseEvent, PointerType } from './input-snapshot';
27
+
28
+ // D-10 threshold defaults (LayaAir + Godot prior-art, plan-strategy section 2).
29
+ export const LONG_PRESS_DURATION_MS = 500;
30
+ export const LONG_PRESS_SLOP = 10;
31
+ export const DOUBLE_TAP_INTERVAL_MS = 350;
32
+ export const DOUBLE_TAP_DISTANCE = 10;
33
+ export const SWIPE_VELOCITY_THRESHOLD = 0.5;
34
+ export const SWIPE_WINDOW_MS = 100;
35
+
36
+ /** Swipe direction taken from the dominant displacement axis (D-10). */
37
+ const SWIPE_DIRECTIONS = ['left', 'right', 'up', 'down'] as const;
38
+ export type SwipeDirection = (typeof SWIPE_DIRECTIONS)[number];
39
+
40
+ /**
41
+ * Continuous gesture values read each frame via `snap.gesture` (D-4).
42
+ * Identity when no gesture is active or after a cancel/reset (AC-12):
43
+ * pinchScale = 1.0, rotationAngle = 0.
44
+ */
45
+ export interface GestureState {
46
+ readonly pinchScale: number;
47
+ readonly rotationAngle: number;
48
+ }
49
+
50
+ /**
51
+ * Closed discriminant union of gesture lifecycle + instantaneous events
52
+ * (AC-13). One-frame lifecycle: an event appears in exactly one frame's
53
+ * `gestureEvents`. Every member carries `pointerType` (narrowed union,
54
+ * AC-19) so consumers can exhaustively switch on gesture kind AND device
55
+ * type without a default branch.
56
+ */
57
+ export type GestureEvent =
58
+ | {
59
+ readonly kind: 'pinch-begin';
60
+ readonly pointerIds: readonly [number, number];
61
+ readonly pointerType: PointerType;
62
+ readonly timestamp: number;
63
+ }
64
+ | {
65
+ readonly kind: 'pinch-end';
66
+ readonly pointerIds: readonly [number, number];
67
+ readonly pointerType: PointerType;
68
+ readonly timestamp: number;
69
+ }
70
+ | {
71
+ readonly kind: 'pinch-cancel';
72
+ readonly pointerIds: readonly [number, number];
73
+ readonly pointerType: PointerType;
74
+ readonly timestamp: number;
75
+ }
76
+ | {
77
+ readonly kind: 'rotate-begin';
78
+ readonly pointerIds: readonly [number, number];
79
+ readonly pointerType: PointerType;
80
+ readonly timestamp: number;
81
+ }
82
+ | {
83
+ readonly kind: 'rotate-end';
84
+ readonly pointerIds: readonly [number, number];
85
+ readonly pointerType: PointerType;
86
+ readonly timestamp: number;
87
+ }
88
+ | {
89
+ readonly kind: 'rotate-cancel';
90
+ readonly pointerIds: readonly [number, number];
91
+ readonly pointerType: PointerType;
92
+ readonly timestamp: number;
93
+ }
94
+ | {
95
+ readonly kind: 'swipe';
96
+ readonly pointerId: number;
97
+ readonly direction: SwipeDirection;
98
+ readonly pointerType: PointerType;
99
+ readonly timestamp: number;
100
+ }
101
+ | {
102
+ readonly kind: 'long-press';
103
+ readonly pointerId: number;
104
+ readonly x: number;
105
+ readonly y: number;
106
+ readonly pointerType: PointerType;
107
+ readonly timestamp: number;
108
+ }
109
+ | {
110
+ readonly kind: 'double-tap';
111
+ readonly pointerId: number;
112
+ readonly x: number;
113
+ readonly y: number;
114
+ readonly pointerType: PointerType;
115
+ readonly timestamp: number;
116
+ };
117
+
118
+ /** Live position + device type for a pointer (subset of the backend pointerMap entry). */
119
+ export interface RecognizerPointer {
120
+ readonly x: number;
121
+ readonly y: number;
122
+ readonly pointerType: PointerType;
123
+ }
124
+
125
+ /** Locked dual-finger tracker for pinch + rotate (D-11). */
126
+ export interface PinchRotateTracker {
127
+ readonly pointerA: number;
128
+ readonly pointerB: number;
129
+ readonly initialDistance: number;
130
+ /** Previous frame's raw atan2 angle; rotation accumulates frame deltas (D-11). */
131
+ readonly lastAngle: number;
132
+ readonly pointerType: PointerType;
133
+ }
134
+
135
+ /** Per-pointer long-press arming state (D-10). */
136
+ export interface LongPressTracker {
137
+ readonly downTime: number;
138
+ readonly x: number;
139
+ readonly y: number;
140
+ readonly pointerType: PointerType;
141
+ readonly armed: boolean;
142
+ readonly fired: boolean;
143
+ }
144
+
145
+ /** Timestamped position sample for swipe velocity (D-10, 100ms window). */
146
+ export interface SwipeSample {
147
+ readonly t: number;
148
+ readonly x: number;
149
+ readonly y: number;
150
+ }
151
+
152
+ /** Last recorded tap for double-tap window matching (D-10). */
153
+ export interface TapRecord {
154
+ readonly time: number;
155
+ readonly x: number;
156
+ readonly y: number;
157
+ }
158
+
159
+ /**
160
+ * The single legal cross-frame gesture state (C-3). Immutable: each
161
+ * `processGestureFrame` returns a fresh copy; the producer stores it for
162
+ * the next frame. `gesture` is the current continuous value read by
163
+ * `snap.gesture` -- live during pinch, frozen after a 2->1 end, identity
164
+ * after cancel/reset or when no gesture ever activated.
165
+ */
166
+ export interface RecognizerState {
167
+ readonly pinch: PinchRotateTracker | null;
168
+ readonly gesture: GestureState;
169
+ readonly longPresses: ReadonlyMap<number, LongPressTracker>;
170
+ readonly lastTap: TapRecord | null;
171
+ readonly swipeHistory: ReadonlyMap<number, readonly SwipeSample[]>;
172
+ }
173
+
174
+ /** Result of processing one frame of phase events (D-4 dual channel). */
175
+ export interface GestureFrameResult {
176
+ readonly newState: RecognizerState;
177
+ readonly gestureState: GestureState;
178
+ readonly gestureEvents: readonly GestureEvent[];
179
+ }
180
+
181
+ /** Identity continuous value (AC-12): no scale, no rotation. */
182
+ export const IDENTITY_GESTURE: GestureState = Object.freeze({ pinchScale: 1, rotationAngle: 0 });
183
+
184
+ /** Fresh recognizer state with no active gestures. */
185
+ export function createRecognizerState(): RecognizerState {
186
+ return {
187
+ pinch: null,
188
+ gesture: IDENTITY_GESTURE,
189
+ longPresses: new Map(),
190
+ lastTap: null,
191
+ swipeHistory: new Map(),
192
+ };
193
+ }
194
+
195
+ // ─── pure math helpers ───────────────────────────────────────────────
196
+
197
+ function distance(ax: number, ay: number, bx: number, by: number): number {
198
+ return Math.hypot(bx - ax, by - ay);
199
+ }
200
+
201
+ function angleOf(ax: number, ay: number, bx: number, by: number): number {
202
+ return Math.atan2(by - ay, bx - ax);
203
+ }
204
+
205
+ /** Wrap an angle to (-PI, PI] so accumulated rotation never jumps a full turn. */
206
+ function normalizeAngle(a: number): number {
207
+ let r = a;
208
+ while (r > Math.PI) r -= 2 * Math.PI;
209
+ while (r <= -Math.PI) r += 2 * Math.PI;
210
+ return r;
211
+ }
212
+
213
+ function dominantDirection(dx: number, dy: number): SwipeDirection {
214
+ if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? SWIPE_DIRECTIONS[1] : SWIPE_DIRECTIONS[0];
215
+ // Screen space: +y points down.
216
+ return dy >= 0 ? SWIPE_DIRECTIONS[3] : SWIPE_DIRECTIONS[2];
217
+ }
218
+
219
+ /**
220
+ * AC-19 real consumption path: exhaustive switch on `PointerType` with no
221
+ * default branch. Every gesture event's `pointerType` flows through here at
222
+ * construction, so adding a 4th `PointerType` member fails to compile
223
+ * (the function would then be able to return `undefined`, violating its
224
+ * `PointerType` return type). This is the sanctioned AC-19 consumption
225
+ * proof required by plan-strategy section 7 (M5 boundary).
226
+ */
227
+ function narrowGesturePointerType(pt: PointerType): PointerType {
228
+ switch (pt) {
229
+ case 'mouse':
230
+ return 'mouse';
231
+ case 'pen':
232
+ return 'pen';
233
+ case 'touch':
234
+ return 'touch';
235
+ }
236
+ }
237
+
238
+ // ─── mutable per-frame working context ───────────────────────────────
239
+
240
+ interface WorkContext {
241
+ pinch: PinchRotateTracker | null;
242
+ gesture: { pinchScale: number; rotationAngle: number };
243
+ longPresses: Map<number, LongPressTracker>;
244
+ lastTap: TapRecord | null;
245
+ swipeHistory: Map<number, SwipeSample[]>;
246
+ events: GestureEvent[];
247
+ }
248
+
249
+ function emitPinchLifecycle(
250
+ ctx: WorkContext,
251
+ phase: 'begin' | 'end' | 'cancel',
252
+ tracker: PinchRotateTracker,
253
+ now: number,
254
+ ): void {
255
+ const pointerIds: readonly [number, number] = [tracker.pointerA, tracker.pointerB];
256
+ const pointerType = narrowGesturePointerType(tracker.pointerType);
257
+ ctx.events.push({ kind: `pinch-${phase}`, pointerIds, pointerType, timestamp: now });
258
+ ctx.events.push({ kind: `rotate-${phase}`, pointerIds, pointerType, timestamp: now });
259
+ }
260
+
261
+ function handleDown(ctx: WorkContext, ev: PointerPhaseEvent, now: number): void {
262
+ // Arm a long-press timer + start a swipe velocity window + tap start.
263
+ ctx.longPresses.set(ev.pointerId, {
264
+ downTime: now,
265
+ x: ev.x,
266
+ y: ev.y,
267
+ pointerType: ev.pointerType,
268
+ armed: true,
269
+ fired: false,
270
+ });
271
+ ctx.swipeHistory.set(ev.pointerId, [{ t: now, x: ev.x, y: ev.y }]);
272
+ }
273
+
274
+ function handleMove(ctx: WorkContext, ev: PointerPhaseEvent, now: number): void {
275
+ // Disarm the long-press if the finger strays beyond slop (D-10).
276
+ const lp = ctx.longPresses.get(ev.pointerId);
277
+ if (lp?.armed && distance(lp.x, lp.y, ev.x, ev.y) > LONG_PRESS_SLOP) {
278
+ ctx.longPresses.set(ev.pointerId, { ...lp, armed: false });
279
+ }
280
+ // Append to the swipe velocity window, pruning samples outside it.
281
+ const hist = ctx.swipeHistory.get(ev.pointerId);
282
+ if (hist) {
283
+ hist.push({ t: now, x: ev.x, y: ev.y });
284
+ pruneSwipeWindow(hist, now);
285
+ }
286
+ }
287
+
288
+ function pruneSwipeWindow(hist: SwipeSample[], now: number): void {
289
+ const cutoff = now - SWIPE_WINDOW_MS;
290
+ // Keep at least the last sample so an up always has a reference point.
291
+ while (hist.length > 1 && hist[0] !== undefined && hist[0].t < cutoff) {
292
+ hist.shift();
293
+ }
294
+ }
295
+
296
+ function detectSwipe(ctx: WorkContext, ev: PointerPhaseEvent, now: number): void {
297
+ const hist = ctx.swipeHistory.get(ev.pointerId);
298
+ if (!hist || hist.length === 0) return;
299
+ const cutoff = now - SWIPE_WINDOW_MS;
300
+ const oldest = hist.find((s) => s.t >= cutoff) ?? hist[0];
301
+ if (!oldest) return;
302
+ const dt = now - oldest.t;
303
+ if (dt <= 0) return;
304
+ const dist = distance(oldest.x, oldest.y, ev.x, ev.y);
305
+ if (dist / dt < SWIPE_VELOCITY_THRESHOLD) return;
306
+ ctx.events.push({
307
+ kind: 'swipe',
308
+ pointerId: ev.pointerId,
309
+ direction: dominantDirection(ev.x - oldest.x, ev.y - oldest.y),
310
+ pointerType: narrowGesturePointerType(ev.pointerType),
311
+ timestamp: now,
312
+ });
313
+ }
314
+
315
+ function detectDoubleTap(ctx: WorkContext, ev: PointerPhaseEvent, now: number): void {
316
+ const prev = ctx.lastTap;
317
+ const withinTime = prev !== null && now - prev.time <= DOUBLE_TAP_INTERVAL_MS;
318
+ const withinDist = prev !== null && distance(prev.x, prev.y, ev.x, ev.y) <= DOUBLE_TAP_DISTANCE;
319
+ if (prev !== null && withinTime && withinDist) {
320
+ ctx.events.push({
321
+ kind: 'double-tap',
322
+ pointerId: ev.pointerId,
323
+ x: ev.x,
324
+ y: ev.y,
325
+ pointerType: narrowGesturePointerType(ev.pointerType),
326
+ timestamp: now,
327
+ });
328
+ ctx.lastTap = null; // consumed; a 3rd tap starts a fresh pair
329
+ return;
330
+ }
331
+ ctx.lastTap = { time: now, x: ev.x, y: ev.y };
332
+ }
333
+
334
+ function endPinchIfMember(
335
+ ctx: WorkContext,
336
+ pointerId: number,
337
+ phase: 'end' | 'cancel',
338
+ now: number,
339
+ ): void {
340
+ const pinch = ctx.pinch;
341
+ if (!pinch || (pinch.pointerA !== pointerId && pinch.pointerB !== pointerId)) return;
342
+ emitPinchLifecycle(ctx, phase, pinch, now);
343
+ ctx.pinch = null;
344
+ if (phase === 'cancel') {
345
+ // Cancel resets to identity (AC-18); end (2->1) freezes the last value (D-11).
346
+ ctx.gesture = { pinchScale: 1, rotationAngle: 0 };
347
+ }
348
+ }
349
+
350
+ function handleUp(ctx: WorkContext, ev: PointerPhaseEvent, now: number): void {
351
+ detectSwipe(ctx, ev, now);
352
+ detectDoubleTap(ctx, ev, now);
353
+ ctx.longPresses.delete(ev.pointerId);
354
+ ctx.swipeHistory.delete(ev.pointerId);
355
+ endPinchIfMember(ctx, ev.pointerId, 'end', now);
356
+ }
357
+
358
+ function handleCancel(ctx: WorkContext, ev: PointerPhaseEvent, now: number): void {
359
+ ctx.longPresses.delete(ev.pointerId);
360
+ ctx.swipeHistory.delete(ev.pointerId);
361
+ endPinchIfMember(ctx, ev.pointerId, 'cancel', now);
362
+ }
363
+
364
+ function tryLockPair(
365
+ ctx: WorkContext,
366
+ pointerMap: ReadonlyMap<number, RecognizerPointer>,
367
+ now: number,
368
+ ): void {
369
+ if (ctx.pinch !== null || pointerMap.size < 2) return;
370
+ // D-11: lock the earliest two pointers by ascending pointerId; a later
371
+ // (third+) finger is ignored while the pair holds.
372
+ const ids = [...pointerMap.keys()].sort((a, b) => a - b);
373
+ const idA = ids[0];
374
+ const idB = ids[1];
375
+ if (idA === undefined || idB === undefined) return;
376
+ const a = pointerMap.get(idA);
377
+ const b = pointerMap.get(idB);
378
+ if (!a || !b) return;
379
+ ctx.pinch = {
380
+ pointerA: idA,
381
+ pointerB: idB,
382
+ initialDistance: distance(a.x, a.y, b.x, b.y) || 1,
383
+ lastAngle: angleOf(a.x, a.y, b.x, b.y),
384
+ pointerType: a.pointerType,
385
+ };
386
+ ctx.gesture = { pinchScale: 1, rotationAngle: 0 };
387
+ emitPinchLifecycle(ctx, 'begin', ctx.pinch, now);
388
+ }
389
+
390
+ function updatePinchContinuous(
391
+ ctx: WorkContext,
392
+ pointerMap: ReadonlyMap<number, RecognizerPointer>,
393
+ ): void {
394
+ const pinch = ctx.pinch;
395
+ if (!pinch) return;
396
+ const a = pointerMap.get(pinch.pointerA);
397
+ const b = pointerMap.get(pinch.pointerB);
398
+ if (!a || !b) return; // a member briefly absent: keep the last value
399
+ const curDist = distance(a.x, a.y, b.x, b.y);
400
+ const curAngle = angleOf(a.x, a.y, b.x, b.y);
401
+ const delta = normalizeAngle(curAngle - pinch.lastAngle);
402
+ ctx.gesture = {
403
+ pinchScale: curDist / pinch.initialDistance,
404
+ rotationAngle: ctx.gesture.rotationAngle + delta,
405
+ };
406
+ ctx.pinch = { ...pinch, lastAngle: curAngle };
407
+ }
408
+
409
+ function fireLongPresses(
410
+ ctx: WorkContext,
411
+ pointerMap: ReadonlyMap<number, RecognizerPointer>,
412
+ now: number,
413
+ ): void {
414
+ const pinch = ctx.pinch;
415
+ for (const [id, lp] of ctx.longPresses) {
416
+ if (!lp.armed || lp.fired) continue;
417
+ if (now - lp.downTime < LONG_PRESS_DURATION_MS) continue;
418
+ if (!pointerMap.has(id)) continue; // finger must still be down
419
+ // A finger locked into an active pinch pair is committed to the dual
420
+ // gesture and must not also fire a long-press (D-11 dual-finger lock).
421
+ if (pinch && (pinch.pointerA === id || pinch.pointerB === id)) continue;
422
+ ctx.events.push({
423
+ kind: 'long-press',
424
+ pointerId: id,
425
+ x: lp.x,
426
+ y: lp.y,
427
+ pointerType: narrowGesturePointerType(lp.pointerType),
428
+ timestamp: now,
429
+ });
430
+ ctx.longPresses.set(id, { ...lp, fired: true });
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Process one frame of pointer phase events, advancing all timers off
436
+ * `now`, and return the new state + continuous values + one-frame event
437
+ * list (D-4). Never mutates its inputs.
438
+ */
439
+ export function processGestureFrame(
440
+ phaseQueue: readonly PointerPhaseEvent[],
441
+ pointerMap: ReadonlyMap<number, RecognizerPointer>,
442
+ prevState: RecognizerState,
443
+ now: number,
444
+ ): GestureFrameResult {
445
+ const ctx: WorkContext = {
446
+ pinch: prevState.pinch,
447
+ gesture: { ...prevState.gesture },
448
+ longPresses: new Map(prevState.longPresses),
449
+ lastTap: prevState.lastTap,
450
+ swipeHistory: cloneSwipeHistory(prevState.swipeHistory),
451
+ events: [],
452
+ };
453
+
454
+ for (const ev of phaseQueue) {
455
+ switch (ev.phase) {
456
+ case 'down':
457
+ handleDown(ctx, ev, now);
458
+ break;
459
+ case 'move':
460
+ handleMove(ctx, ev, now);
461
+ break;
462
+ case 'up':
463
+ handleUp(ctx, ev, now);
464
+ break;
465
+ case 'cancel':
466
+ handleCancel(ctx, ev, now);
467
+ break;
468
+ }
469
+ }
470
+
471
+ tryLockPair(ctx, pointerMap, now);
472
+ updatePinchContinuous(ctx, pointerMap);
473
+ fireLongPresses(ctx, pointerMap, now);
474
+
475
+ const gesture: GestureState = Object.freeze({
476
+ pinchScale: ctx.gesture.pinchScale,
477
+ rotationAngle: ctx.gesture.rotationAngle,
478
+ });
479
+ const newState: RecognizerState = {
480
+ pinch: ctx.pinch,
481
+ gesture,
482
+ longPresses: ctx.longPresses,
483
+ lastTap: ctx.lastTap,
484
+ swipeHistory: ctx.swipeHistory,
485
+ };
486
+ return { newState, gestureState: gesture, gestureEvents: ctx.events };
487
+ }
488
+
489
+ function cloneSwipeHistory(
490
+ src: ReadonlyMap<number, readonly SwipeSample[]>,
491
+ ): Map<number, SwipeSample[]> {
492
+ const out = new Map<number, SwipeSample[]>();
493
+ for (const [id, samples] of src) out.set(id, [...samples]);
494
+ return out;
495
+ }
package/src/index.ts ADDED
@@ -0,0 +1,101 @@
1
+ // @forgeax/engine-input -- public surface (charter F2 minimal surface).
2
+ //
3
+ // AI users:
4
+ // - Insert the `InputBackend` producer as a World resource under
5
+ // `INPUT_BACKEND_KEY`, then add the `InputFrameStartScan` system token to
6
+ // the schedule. After `world.update()` runs, read the snapshot through
7
+ // `world.getResource<InputSnapshot>('InputSnapshot')` (or the
8
+ // re-exported `INPUT_SNAPSHOT_RESOURCE_KEY` constant).
9
+ // - In a browser context, attach a PointerLock-aware producer with
10
+ // `attachBrowserInputBackend(canvas)`; the returned callable is
11
+ // both a detach handle and an `InputBackend`.
12
+ // - For headless tests / pre-start fixtures, `createInputSnapshot()`
13
+ // returns an empty snapshot whose accessors all evaluate to false /
14
+ // `{ x: 0, y: 0 }` (charter P3: empty signal is the signal).
15
+ //
16
+ // Single import path:
17
+ // import {
18
+ // attachBrowserInputBackend,
19
+ // INPUT_BACKEND_KEY,
20
+ // InputFrameStartScan,
21
+ // createInputSnapshot,
22
+ // INPUT_SNAPSHOT_RESOURCE_KEY,
23
+ // type InputSnapshot,
24
+ // type InputBackend,
25
+ // } from '@forgeax/engine-input';
26
+
27
+ export type {
28
+ ActionBinding,
29
+ ActionConfig,
30
+ ActionState,
31
+ GetVectorOptions,
32
+ } from './action-state';
33
+ export {
34
+ deriveActionStates,
35
+ getAxis,
36
+ getVector,
37
+ INPUT_MAP_KEY,
38
+ } from './action-state';
39
+ export {
40
+ attachBrowserInputBackend,
41
+ type BrowserInputBackendOptions,
42
+ type PointerLockProvider,
43
+ } from './browser-backend';
44
+ export {
45
+ type CanvasInputBoundary,
46
+ createCanvasInputBoundary,
47
+ } from './canvas-input-boundary';
48
+ export {
49
+ type CompositeBackendOptions,
50
+ type CompositeInputBackend,
51
+ makeCompositeBackend,
52
+ } from './composite-backend';
53
+ export {
54
+ FRAME_START_SCAN_SYSTEM_NAME,
55
+ INPUT_BACKEND_KEY,
56
+ InputFrameStartScan,
57
+ InputSet,
58
+ } from './frame-start-scan-system';
59
+ export type {
60
+ GestureEvent,
61
+ GestureState,
62
+ SwipeDirection,
63
+ } from './gesture-recognizer';
64
+ export {
65
+ DOUBLE_TAP_DISTANCE,
66
+ DOUBLE_TAP_INTERVAL_MS,
67
+ IDENTITY_GESTURE,
68
+ LONG_PRESS_DURATION_MS,
69
+ LONG_PRESS_SLOP,
70
+ SWIPE_VELOCITY_THRESHOLD,
71
+ SWIPE_WINDOW_MS,
72
+ } from './gesture-recognizer';
73
+ export type {
74
+ Capabilities,
75
+ GamepadAxisIndex,
76
+ GamepadButtonIndex,
77
+ GamepadSlotSample,
78
+ InputBackend,
79
+ InputBackendSample,
80
+ InputSnapshot,
81
+ MouseButtonIndex,
82
+ PointerPhaseEvent,
83
+ PointerSample,
84
+ PointerType,
85
+ VirtualAxisSample,
86
+ VirtualJoystickConfig,
87
+ } from './input-snapshot';
88
+ export {
89
+ createInputSnapshot,
90
+ INPUT_SNAPSHOT_RESOURCE_KEY,
91
+ snapshotFromSample,
92
+ } from './input-snapshot';
93
+ export { inputBackendPlugin, ownedInputBackendPlugin } from './plugin-service';
94
+ export {
95
+ createUiInputResetBoundary,
96
+ isUiOwnedEvent,
97
+ resolveUiOwnership,
98
+ type UiInputResetBoundary,
99
+ type UiInputResetBoundaryOptions,
100
+ type UiOwnershipResult,
101
+ } from './ui-ownership';