@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.
- package/LICENSE +202 -0
- package/README.md +312 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/browser-backend-dual-instance.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-dual-instance.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-focus-loss.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-focus-loss.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-error.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-error.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-gate.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-gate.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-release.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-release.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-state.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-state.test.d.ts.map +1 -0
- package/dist/__tests__/canvas-input-boundary.test.d.ts +2 -0
- package/dist/__tests__/canvas-input-boundary.test.d.ts.map +1 -0
- package/dist/__tests__/canvas-input-owner.test-d.d.ts +2 -0
- package/dist/__tests__/canvas-input-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/composite-backend.test.d.ts +2 -0
- package/dist/__tests__/composite-backend.test.d.ts.map +1 -0
- package/dist/__tests__/controller-platform-owner.test-d.d.ts +2 -0
- package/dist/__tests__/controller-platform-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/input-snapshot.test-d.d.ts +2 -0
- package/dist/__tests__/input-snapshot.test-d.d.ts.map +1 -0
- package/dist/__tests__/input-snapshot.test.d.ts +2 -0
- package/dist/__tests__/input-snapshot.test.d.ts.map +1 -0
- package/dist/__tests__/swipe-direction-owner.test-d.d.ts +2 -0
- package/dist/__tests__/swipe-direction-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/ui-input-matrix.test.d.ts +2 -0
- package/dist/__tests__/ui-input-matrix.test.d.ts.map +1 -0
- package/dist/__tests__/ui-ownership-edge.test.d.ts +2 -0
- package/dist/__tests__/ui-ownership-edge.test.d.ts.map +1 -0
- package/dist/__tests__/ui-ownership.test.d.ts +2 -0
- package/dist/__tests__/ui-ownership.test.d.ts.map +1 -0
- package/dist/__tests__/ui-reset-idempotence.test.d.ts +2 -0
- package/dist/__tests__/ui-reset-idempotence.test.d.ts.map +1 -0
- package/dist/__tests__/ui-reset.test.d.ts +2 -0
- package/dist/__tests__/ui-reset.test.d.ts.map +1 -0
- package/dist/action-state.d.ts +146 -0
- package/dist/action-state.d.ts.map +1 -0
- package/dist/browser-backend.d.ts +121 -0
- package/dist/browser-backend.d.ts.map +1 -0
- package/dist/canvas-input-boundary.d.ts +13 -0
- package/dist/canvas-input-boundary.d.ts.map +1 -0
- package/dist/composite-backend.d.ts +34 -0
- package/dist/composite-backend.d.ts.map +1 -0
- package/dist/controller-db-data.d.ts +5 -0
- package/dist/controller-db-data.d.ts.map +1 -0
- package/dist/controller-db-data.mjs +12 -0
- package/dist/controller-db-data.mjs.map +1 -0
- package/dist/controller-db.d.ts +70 -0
- package/dist/controller-db.d.ts.map +1 -0
- package/dist/controller-db.mjs +119 -0
- package/dist/controller-db.mjs.map +1 -0
- package/dist/frame-start-scan-system.d.ts +37 -0
- package/dist/frame-start-scan-system.d.ts.map +1 -0
- package/dist/gamepad-frame.d.ts +42 -0
- package/dist/gamepad-frame.d.ts.map +1 -0
- package/dist/gesture-recognizer.d.ts +145 -0
- package/dist/gesture-recognizer.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1502 -0
- package/dist/index.mjs.map +1 -0
- package/dist/input-snapshot.d.ts +420 -0
- package/dist/input-snapshot.d.ts.map +1 -0
- package/dist/plugin-service.d.ts +11 -0
- package/dist/plugin-service.d.ts.map +1 -0
- package/dist/ui-ownership.d.ts +17 -0
- package/dist/ui-ownership.d.ts.map +1 -0
- package/dist/virtual-joystick.d.ts +39 -0
- package/dist/virtual-joystick.d.ts.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/browser-backend-dual-instance.test.ts +379 -0
- package/src/__tests__/browser-backend-focus-loss.test.ts +239 -0
- package/src/__tests__/browser-backend-lock-error.test.ts +425 -0
- package/src/__tests__/browser-backend-lock-gate.test.ts +283 -0
- package/src/__tests__/browser-backend-lock-release.test.ts +641 -0
- package/src/__tests__/browser-backend-lock-state.test.ts +604 -0
- package/src/__tests__/canvas-input-boundary.test.ts +65 -0
- package/src/__tests__/canvas-input-owner.test-d.ts +39 -0
- package/src/__tests__/composite-backend.test.ts +299 -0
- package/src/__tests__/controller-platform-owner.test-d.ts +46 -0
- package/src/__tests__/input-snapshot.test-d.ts +78 -0
- package/src/__tests__/input-snapshot.test.ts +96 -0
- package/src/__tests__/swipe-direction-owner.test-d.ts +45 -0
- package/src/__tests__/ui-input-matrix.test.ts +31 -0
- package/src/__tests__/ui-ownership-edge.test.ts +33 -0
- package/src/__tests__/ui-ownership.test.ts +35 -0
- package/src/__tests__/ui-reset-idempotence.test.ts +17 -0
- package/src/__tests__/ui-reset.test.ts +25 -0
- package/src/action-state.ts +380 -0
- package/src/browser-backend.ts +884 -0
- package/src/canvas-input-boundary.ts +81 -0
- package/src/composite-backend.ts +189 -0
- package/src/controller-db-data.ts +25 -0
- package/src/controller-db.ts +218 -0
- package/src/frame-start-scan-system.ts +93 -0
- package/src/gamepad-frame.ts +259 -0
- package/src/gesture-recognizer.ts +495 -0
- package/src/index.ts +101 -0
- package/src/input-snapshot.ts +732 -0
- package/src/plugin-service.ts +31 -0
- package/src/ui-ownership.ts +61 -0
- package/src/virtual-joystick.ts +103 -0
- 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';
|