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