@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,81 @@
|
|
|
1
|
+
// @forgeax/engine-input -- exclusive host-owned canvas input boundary.
|
|
2
|
+
//
|
|
3
|
+
// A physical canvas has one DOM acquisition backend. Hosts with two worlds (for
|
|
4
|
+
// example editor + transient Play) route that one producer through these two
|
|
5
|
+
// InputBackend views. This is exclusive ownership, deliberately separate from
|
|
6
|
+
// synthetic/replay input composition.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createEmptyInputBackendSample,
|
|
10
|
+
type InputBackend,
|
|
11
|
+
type InputBackendSample,
|
|
12
|
+
} from './input-snapshot';
|
|
13
|
+
import { isUiOwnedEvent } from './ui-ownership';
|
|
14
|
+
|
|
15
|
+
export interface CanvasInputBoundary {
|
|
16
|
+
readonly editor: InputBackend;
|
|
17
|
+
readonly game: InputBackend;
|
|
18
|
+
owner(): 'editor' | 'game';
|
|
19
|
+
grantGame(): void;
|
|
20
|
+
revokeGame(): void;
|
|
21
|
+
/** Clear the active consumer when an event originated in the host UI. */
|
|
22
|
+
handleUiEvent(event: Event, host: Node): boolean;
|
|
23
|
+
detach(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type CanvasInputOwner = ReturnType<CanvasInputBoundary['owner']>;
|
|
27
|
+
|
|
28
|
+
export function createCanvasInputBoundary(source: InputBackend): CanvasInputBoundary {
|
|
29
|
+
let active: CanvasInputOwner = 'editor';
|
|
30
|
+
let gamePointerLockAllowed = false;
|
|
31
|
+
source.setPointerLockAllowed?.(false);
|
|
32
|
+
|
|
33
|
+
const empty = (): InputBackendSample => createEmptyInputBackendSample();
|
|
34
|
+
|
|
35
|
+
const clear = (): void => {
|
|
36
|
+
source.clear?.();
|
|
37
|
+
source.setPointerLockAllowed?.(false);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const routed = (consumer: CanvasInputOwner): InputBackend => ({
|
|
41
|
+
sample: () => (consumer === active ? source.sample() : empty()),
|
|
42
|
+
setPointerLockAllowed: (allowed) => {
|
|
43
|
+
if (consumer === 'game') {
|
|
44
|
+
gamePointerLockAllowed = allowed;
|
|
45
|
+
source.setPointerLockAllowed?.(active === 'game' && allowed);
|
|
46
|
+
} else {
|
|
47
|
+
source.setPointerLockAllowed?.(false);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
clear: () => {
|
|
51
|
+
if (consumer === active) clear();
|
|
52
|
+
},
|
|
53
|
+
detach: () => source.detach(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const grantGame = (): void => {
|
|
57
|
+
if (active === 'game') return;
|
|
58
|
+
clear();
|
|
59
|
+
active = 'game';
|
|
60
|
+
source.setPointerLockAllowed?.(gamePointerLockAllowed);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const revokeGame = (): void => {
|
|
64
|
+
clear();
|
|
65
|
+
active = 'editor';
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
editor: routed('editor'),
|
|
70
|
+
game: routed('game'),
|
|
71
|
+
owner: () => active,
|
|
72
|
+
grantGame,
|
|
73
|
+
revokeGame,
|
|
74
|
+
handleUiEvent: (event, host) => {
|
|
75
|
+
if (!isUiOwnedEvent(event, host)) return false;
|
|
76
|
+
clear();
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
detach: () => source.detach(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// @forgeax/engine-input -- CompositeInputBackend (synthetic-input decorator).
|
|
2
|
+
//
|
|
3
|
+
// PROBLEM. The scan system reads ONE `InputBackend` from `INPUT_BACKEND_KEY`
|
|
4
|
+
// each frame. An AI / record-replay harness needs to feed synthetic input into
|
|
5
|
+
// that same slot WITHOUT evicting the human's browser backend -- because a human
|
|
6
|
+
// may take over at any instant (PIE two-world model + charter §8 human-as-final-
|
|
7
|
+
// authority). Overwriting the resource locks the human out; wrapping it does not.
|
|
8
|
+
//
|
|
9
|
+
// SOLUTION. `makeCompositeBackend(inner)` returns an `InputBackend` that HOLDS
|
|
10
|
+
// the human backend as `inner` and layers a programmatic injection surface
|
|
11
|
+
// (`press` / `release` / `setButton` / `addMovement` / `addWheel`) on top. The
|
|
12
|
+
// scan system stays variant-agnostic: it still calls `sample()` on one backend
|
|
13
|
+
// (Depend-on-Abstractions -- the consumer never learns a composite exists).
|
|
14
|
+
//
|
|
15
|
+
// SIDE-EFFECT CONTRACT. `inner.sample()` DRAINS its per-frame accumulators
|
|
16
|
+
// (up-edges, movement delta, wheel notches) on read, so the composite calls it
|
|
17
|
+
// EXACTLY ONCE per `sample()` and drains its OWN injected accumulators in the
|
|
18
|
+
// same pass. Field-by-field merge honoring each field's lifecycle:
|
|
19
|
+
//
|
|
20
|
+
// downKeys held across frames -> UNION(inner, injected.held)
|
|
21
|
+
// upKeys lives one frame -> UNION; injected up-edge set drained here
|
|
22
|
+
// buttons held tuple -> OR per slot
|
|
23
|
+
// movementX/Y, wheelDelta -> SUM; injected side reset to 0 here
|
|
24
|
+
// focused gates up-edge suppress -> inner.focused || injectionActive
|
|
25
|
+
// pointerLocked -> inner (AI must not fabricate a lock)
|
|
26
|
+
// optional (pointers/gamepads/...) -> pass through from inner untouched
|
|
27
|
+
//
|
|
28
|
+
// YIELD-TO-HUMAN (default on). A human-held key on `inner` suppresses only the
|
|
29
|
+
// injected state for that SAME key in that frame. Other AI keys continue, so a
|
|
30
|
+
// human can take a key without silencing unrelated synthetic input. This is the
|
|
31
|
+
// structural realization of "human wins" without a global takeover policy.
|
|
32
|
+
|
|
33
|
+
import type { InputBackend, InputBackendSample } from './input-snapshot';
|
|
34
|
+
|
|
35
|
+
/** Options controlling the composite merge policy. */
|
|
36
|
+
export interface CompositeBackendOptions {
|
|
37
|
+
/**
|
|
38
|
+
* When `true` (default), a key held on the inner (human) backend suppresses
|
|
39
|
+
* injected state only for that same key in that frame. Set `false` for
|
|
40
|
+
* record/replay or AI-solo scenarios where both sources should coexist even
|
|
41
|
+
* on key overlap.
|
|
42
|
+
*/
|
|
43
|
+
readonly yieldToHuman?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A composite backend: an `InputBackend` (drop-in for `INPUT_BACKEND_KEY`) plus
|
|
48
|
+
* a programmatic injection surface. All injection is additive over the wrapped
|
|
49
|
+
* human backend.
|
|
50
|
+
*/
|
|
51
|
+
export interface CompositeInputBackend extends InputBackend {
|
|
52
|
+
/** Hold `key` down (mirrors a keydown). Idempotent. */
|
|
53
|
+
press(key: string): void;
|
|
54
|
+
/** Release `key` (mirrors a keyup); emits a one-frame up-edge on next sample. */
|
|
55
|
+
release(key: string): void;
|
|
56
|
+
/** Set an injected mouse-button slot (0/1/2) held-state. */
|
|
57
|
+
setButton(slot: 0 | 1 | 2, down: boolean): void;
|
|
58
|
+
/** Accumulate injected pointer-lock movement delta (drained on next sample). */
|
|
59
|
+
addMovement(dx: number, dy: number): void;
|
|
60
|
+
/** Accumulate injected wheel notches (drained on next sample). */
|
|
61
|
+
addWheel(notches: number): void;
|
|
62
|
+
/** Drop all injected state; currently-held keys emit a clean up-edge once. */
|
|
63
|
+
clearInjected(): void;
|
|
64
|
+
/** Toggle the yield-to-human gate at runtime. */
|
|
65
|
+
setYieldToHuman(yield_: boolean): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function makeCompositeBackend(
|
|
69
|
+
inner: InputBackend,
|
|
70
|
+
options?: CompositeBackendOptions,
|
|
71
|
+
): CompositeInputBackend {
|
|
72
|
+
let yieldToHuman = options?.yieldToHuman ?? true;
|
|
73
|
+
|
|
74
|
+
// Injected held-state (survives across frames).
|
|
75
|
+
const heldKeys = new Set<string>();
|
|
76
|
+
const buttons: [boolean, boolean, boolean] = [false, false, false];
|
|
77
|
+
// Injected per-frame accumulators (drained on each sample()).
|
|
78
|
+
const upEdges = new Set<string>();
|
|
79
|
+
let mvx = 0;
|
|
80
|
+
let mvy = 0;
|
|
81
|
+
let wheel = 0;
|
|
82
|
+
|
|
83
|
+
function injectionActive(): boolean {
|
|
84
|
+
return heldKeys.size > 0 || upEdges.size > 0 || buttons.some((b) => b);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sample(): InputBackendSample {
|
|
88
|
+
// Call inner EXACTLY once -- it drains its own accumulators here.
|
|
89
|
+
const base = inner.sample();
|
|
90
|
+
|
|
91
|
+
// yield-to-human is PER-KEY: an injected key is suppressed only when the human
|
|
92
|
+
// is holding the SAME key this frame. This is the minimal realization of
|
|
93
|
+
// charter §8 (the human always wins a key they touch) WITHOUT the collateral of
|
|
94
|
+
// a global gate — a human strafing D does not silence an AI holding W, so the
|
|
95
|
+
// two genuinely coexist (that is the whole point of a composite; a caller who
|
|
96
|
+
// wants "human takes over everything" can clearInjected() at the policy layer).
|
|
97
|
+
// downKeys: union of human-held and injected-held, minus injected keys the human
|
|
98
|
+
// is also pressing. A yielded injected release is likewise a no-op: emitting its
|
|
99
|
+
// up-edge would incorrectly release the human-held key in the scan system.
|
|
100
|
+
const downKeys = new Set(base.downKeys);
|
|
101
|
+
for (const k of heldKeys) {
|
|
102
|
+
if (yieldToHuman && base.downKeys.has(k)) continue; // human owns this exact key
|
|
103
|
+
downKeys.add(k);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// upKeys: union of human up-edges and injected up-edges (one-frame life), except
|
|
107
|
+
// injected edges for keys that the human still owns under the per-key yield gate.
|
|
108
|
+
const mergedUp = new Set(base.upKeys);
|
|
109
|
+
for (const k of upEdges) {
|
|
110
|
+
if (yieldToHuman && base.downKeys.has(k)) continue;
|
|
111
|
+
mergedUp.add(k);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// buttons: OR per slot.
|
|
115
|
+
const mergedButtons: readonly [boolean, boolean, boolean] = [
|
|
116
|
+
base.buttons[0] || buttons[0],
|
|
117
|
+
base.buttons[1] || buttons[1],
|
|
118
|
+
base.buttons[2] || buttons[2],
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
// focused: keep true while WE are injecting, so the scan system does not
|
|
122
|
+
// treat a headless/backgrounded tab (inner.focused === false) as a reason
|
|
123
|
+
// to suppress our up-edges. Otherwise mirror inner.
|
|
124
|
+
const focused = base.focused || injectionActive();
|
|
125
|
+
|
|
126
|
+
const out: InputBackendSample = {
|
|
127
|
+
...base, // carry inner optional fields (pointers/gamepads/gestures/...) untouched
|
|
128
|
+
downKeys,
|
|
129
|
+
upKeys: mergedUp,
|
|
130
|
+
buttons: mergedButtons,
|
|
131
|
+
movementX: base.movementX + mvx,
|
|
132
|
+
movementY: base.movementY + mvy,
|
|
133
|
+
wheelDelta: base.wheelDelta + wheel,
|
|
134
|
+
focused,
|
|
135
|
+
pointerLocked: base.pointerLocked, // AI never fabricates a lock
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Drain injected per-frame accumulators (mirrors inner's own drain).
|
|
139
|
+
upEdges.clear();
|
|
140
|
+
mvx = 0;
|
|
141
|
+
mvy = 0;
|
|
142
|
+
wheel = 0;
|
|
143
|
+
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Only surface setPointerLockAllowed when the inner backend supports it, so
|
|
148
|
+
// the optional method is truly absent (not `undefined`) under
|
|
149
|
+
// exactOptionalPropertyTypes -- and forwards to the human backend when present.
|
|
150
|
+
const lockGate: Pick<InputBackend, 'setPointerLockAllowed'> = inner.setPointerLockAllowed
|
|
151
|
+
? { setPointerLockAllowed: (allowed: boolean) => inner.setPointerLockAllowed?.(allowed) }
|
|
152
|
+
: {};
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
sample,
|
|
156
|
+
...lockGate,
|
|
157
|
+
// Teardown belongs to the human backend -- forward it.
|
|
158
|
+
detach: () => inner.detach(),
|
|
159
|
+
|
|
160
|
+
press(key) {
|
|
161
|
+
heldKeys.add(key);
|
|
162
|
+
upEdges.delete(key); // re-press cancels a pending release edge
|
|
163
|
+
},
|
|
164
|
+
release(key) {
|
|
165
|
+
if (heldKeys.delete(key)) upEdges.add(key); // held -> emit one up-edge
|
|
166
|
+
},
|
|
167
|
+
setButton(slot, down) {
|
|
168
|
+
buttons[slot] = down;
|
|
169
|
+
},
|
|
170
|
+
addMovement(dx, dy) {
|
|
171
|
+
mvx += dx;
|
|
172
|
+
mvy += dy;
|
|
173
|
+
},
|
|
174
|
+
addWheel(notches) {
|
|
175
|
+
wheel += notches;
|
|
176
|
+
},
|
|
177
|
+
clearInjected() {
|
|
178
|
+
for (const k of heldKeys) upEdges.add(k); // clean release edge for each
|
|
179
|
+
heldKeys.clear();
|
|
180
|
+
buttons[0] = buttons[1] = buttons[2] = false;
|
|
181
|
+
mvx = 0;
|
|
182
|
+
mvy = 0;
|
|
183
|
+
wheel = 0;
|
|
184
|
+
},
|
|
185
|
+
setYieldToHuman(yield_) {
|
|
186
|
+
yieldToHuman = yield_;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// controller-db-data.ts -- the 554KB vendored SDL_GameControllerDB, inlined
|
|
2
|
+
// at build time via esbuild's text loader (tsup.config.ts loader['.txt']).
|
|
3
|
+
//
|
|
4
|
+
// This module is a SEPARATE sub-export (`@forgeax/engine-input/controller-db-data`)
|
|
5
|
+
// so the vendored 554KB text NEVER enters the main-entry bundle. The
|
|
6
|
+
// browser backend dynamic-imports it only on first sight of a non-standard
|
|
7
|
+
// gamepad (D-2 lazy-load; C-5 the DB size must not sit in the default path).
|
|
8
|
+
//
|
|
9
|
+
// The `?raw`-free plain import returns the inlined string because tsup is
|
|
10
|
+
// configured with loader: { '.txt': 'text' }. In a vitest/vite context the
|
|
11
|
+
// default `.txt` import is a URL, so consumers under test inject their own
|
|
12
|
+
// DB text instead of importing this module (D-13 loadControllerDb override).
|
|
13
|
+
|
|
14
|
+
// @ts-expect-error -- esbuild text loader inlines the .txt file as a string
|
|
15
|
+
// module; there is no ambient type declaration for `*.txt` imports and one
|
|
16
|
+
// is not warranted for a single build-time asset.
|
|
17
|
+
import bundledControllerDb from '../vendor/gamecontrollerdb.txt';
|
|
18
|
+
|
|
19
|
+
/** The vendored gamecontrollerdb.txt contents, inlined at build time. */
|
|
20
|
+
export const BUNDLED_CONTROLLER_DB: string = bundledControllerDb as string;
|
|
21
|
+
|
|
22
|
+
/** Default loader used by the backend when no override is supplied (D-13). */
|
|
23
|
+
export function loadBundledControllerDb(): Promise<string> {
|
|
24
|
+
return Promise.resolve(BUNDLED_CONTROLLER_DB);
|
|
25
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// controller-db.ts -- pure SDL_GameControllerDB parsing + GUID derivation.
|
|
2
|
+
//
|
|
3
|
+
// This module holds ONLY pure functions (no DOM, no ECS, no vendored data
|
|
4
|
+
// import). The 554KB gamecontrollerdb.txt lives in the separate
|
|
5
|
+
// `./controller-db-data` sub-export so it never enters the main-entry
|
|
6
|
+
// bundle; the backend dynamic-imports both this module and the data module
|
|
7
|
+
// on first sight of a non-standard gamepad (D-2 lazy-load).
|
|
8
|
+
//
|
|
9
|
+
// SDL GUID reference: KB source
|
|
10
|
+
// .forgeax-harness/knowledge-base/sources/2026-07-07-sdl-guid-browser-gamepad-id-mapping.md
|
|
11
|
+
//
|
|
12
|
+
// D-13 strategy 2: build a match GUID from VID/PID with bus=USB(0x03),
|
|
13
|
+
// CRC=0, version=0, driver signature/data=0. This matches the vast
|
|
14
|
+
// majority of gamecontrollerdb.txt native entries directly.
|
|
15
|
+
|
|
16
|
+
/** A single mapping token target: physical button / axis / hat index. */
|
|
17
|
+
export type MappingToken =
|
|
18
|
+
| { readonly kind: 'button'; readonly index: number }
|
|
19
|
+
| { readonly kind: 'axis'; readonly index: number; readonly half?: '+' | '-' }
|
|
20
|
+
| { readonly kind: 'hat'; readonly index: number; readonly mask: number };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parsed mapping tokens for one controller entry, keyed by the SDL logical
|
|
24
|
+
* name (`a`, `b`, `leftx`, `dpup`, `lefttrigger`, ...). Values are the
|
|
25
|
+
* physical source (button `bN`, axis `aN`, hat `hN.M`).
|
|
26
|
+
*/
|
|
27
|
+
export type MappingTokens = Readonly<Record<string, MappingToken>>;
|
|
28
|
+
|
|
29
|
+
/** One controller-DB row: its platform section + parsed mapping tokens. */
|
|
30
|
+
export interface ControllerDbEntry {
|
|
31
|
+
readonly platform: string | undefined;
|
|
32
|
+
readonly tokens: MappingTokens;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parsed controller DB: SDL GUID (32-char hex) -> one entry per platform
|
|
37
|
+
* section. The same GUID can appear in multiple platform sections, so the
|
|
38
|
+
* value is an array.
|
|
39
|
+
*/
|
|
40
|
+
export type ControllerDb = Readonly<Record<string, readonly ControllerDbEntry[]>>;
|
|
41
|
+
|
|
42
|
+
/** Recognised web platform section labels (D-13). */
|
|
43
|
+
const CONTROLLER_PLATFORMS = ['Windows', 'Mac OS X', 'Linux', 'Android', 'iOS'] as const;
|
|
44
|
+
export type ControllerPlatform = (typeof CONTROLLER_PLATFORMS)[number];
|
|
45
|
+
|
|
46
|
+
/** Parse one `key:source` mapping token into a MappingToken. */
|
|
47
|
+
function parseMappingToken(source: string): MappingToken | undefined {
|
|
48
|
+
// Half-axis prefix: +aN / -aN.
|
|
49
|
+
let half: '+' | '-' | undefined;
|
|
50
|
+
let body = source;
|
|
51
|
+
if (body.startsWith('+') || body.startsWith('-')) {
|
|
52
|
+
half = body[0] as '+' | '-';
|
|
53
|
+
body = body.slice(1);
|
|
54
|
+
}
|
|
55
|
+
const prefix = body[0];
|
|
56
|
+
if (prefix === 'b') {
|
|
57
|
+
const index = Number.parseInt(body.slice(1), 10);
|
|
58
|
+
return Number.isNaN(index) ? undefined : { kind: 'button', index };
|
|
59
|
+
}
|
|
60
|
+
if (prefix === 'a') {
|
|
61
|
+
const index = Number.parseInt(body.slice(1), 10);
|
|
62
|
+
if (Number.isNaN(index)) return undefined;
|
|
63
|
+
return half ? { kind: 'axis', index, half } : { kind: 'axis', index };
|
|
64
|
+
}
|
|
65
|
+
if (prefix === 'h') {
|
|
66
|
+
// hN.M -- hat N, direction mask M.
|
|
67
|
+
const dot = body.indexOf('.');
|
|
68
|
+
if (dot < 0) return undefined;
|
|
69
|
+
const index = Number.parseInt(body.slice(1, dot), 10);
|
|
70
|
+
const mask = Number.parseInt(body.slice(dot + 1), 10);
|
|
71
|
+
return Number.isNaN(index) || Number.isNaN(mask) ? undefined : { kind: 'hat', index, mask };
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse a gamecontrollerdb.txt string into a ControllerDb map. Comment
|
|
78
|
+
* (`#`) and blank lines are skipped. Each data line is
|
|
79
|
+
* `GUID,Name,token:source,...,platform:PLATFORM,`.
|
|
80
|
+
*/
|
|
81
|
+
export function parseControllerDb(txt: string): ControllerDb {
|
|
82
|
+
const db: Record<string, ControllerDbEntry[]> = {};
|
|
83
|
+
for (const rawLine of txt.split('\n')) {
|
|
84
|
+
const line = rawLine.trim();
|
|
85
|
+
if (line.length === 0 || line.startsWith('#')) continue;
|
|
86
|
+
const fields = line.split(',');
|
|
87
|
+
const guid = fields[0];
|
|
88
|
+
if (!guid || guid.length !== 32) continue;
|
|
89
|
+
let platform: string | undefined;
|
|
90
|
+
const tokens: Record<string, MappingToken> = {};
|
|
91
|
+
// fields[1] is the human-readable name; mapping tokens start at index 2.
|
|
92
|
+
for (let i = 2; i < fields.length; i++) {
|
|
93
|
+
const field = fields[i];
|
|
94
|
+
if (!field) continue;
|
|
95
|
+
const colon = field.indexOf(':');
|
|
96
|
+
if (colon < 0) continue;
|
|
97
|
+
const key = field.slice(0, colon);
|
|
98
|
+
const source = field.slice(colon + 1);
|
|
99
|
+
if (key === 'platform') {
|
|
100
|
+
platform = source;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// crc / sdk / hint fields are not physical mappings; skip non b/a/h.
|
|
104
|
+
const token = parseMappingToken(source);
|
|
105
|
+
if (token) tokens[key] = token;
|
|
106
|
+
}
|
|
107
|
+
const entry: ControllerDbEntry = { platform, tokens };
|
|
108
|
+
const bucket = db[guid];
|
|
109
|
+
if (bucket) {
|
|
110
|
+
bucket.push(entry);
|
|
111
|
+
} else {
|
|
112
|
+
db[guid] = [entry];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return db;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Format a 16-bit value as a 4-char little-endian hex pair. */
|
|
119
|
+
function le16Hex(value: number): string {
|
|
120
|
+
const lo = value & 0xff;
|
|
121
|
+
const hi = (value >> 8) & 0xff;
|
|
122
|
+
return lo.toString(16).padStart(2, '0') + hi.toString(16).padStart(2, '0');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a 32-char SDL GUID hex from a VID/PID pair (D-13 strategy 2):
|
|
127
|
+
* bus (default USB 0x03) + CRC=0 + VID + fill + PID + fill + version=0 +
|
|
128
|
+
* driver sig/data=0. Each 16-bit field is little-endian within the string.
|
|
129
|
+
*
|
|
130
|
+
* Example: buildGuidFromVidPid(0x045e, 0x028e) ->
|
|
131
|
+
* '030000005e0400008e02000000000000' (Xbox 360).
|
|
132
|
+
*/
|
|
133
|
+
export function buildGuidFromVidPid(vid: number, pid: number, bus = 0x03): string {
|
|
134
|
+
return (
|
|
135
|
+
le16Hex(bus) + // bytes 0-1: bus type
|
|
136
|
+
'0000' + // bytes 2-3: CRC = 0
|
|
137
|
+
le16Hex(vid) + // bytes 4-5: VID (LE)
|
|
138
|
+
'0000' + // bytes 6-7: fill
|
|
139
|
+
le16Hex(pid) + // bytes 8-9: PID (LE)
|
|
140
|
+
'0000' + // bytes 10-11: fill
|
|
141
|
+
'0000' + // bytes 12-13: version = 0
|
|
142
|
+
'0000' // bytes 14-15: driver signature + data = 0
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const CHROME_VENDOR_RE = /Vendor:\s*([0-9a-f]{1,4})/i;
|
|
147
|
+
const CHROME_PRODUCT_RE = /Product:\s*([0-9a-f]{1,4})/i;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extract an SDL GUID from a browser `Gamepad.id` string. Handles the
|
|
151
|
+
* Chrome/Edge (`Vendor: XXXX Product: YYYY`) and Firefox (`VID-PID-Name`,
|
|
152
|
+
* leading-zero tolerant) formats. Returns undefined when VID/PID cannot be
|
|
153
|
+
* extracted (Safari name-only strings, XInput devices) so the caller keeps
|
|
154
|
+
* the Feat1 empty-signal (graceful degradation, R-3).
|
|
155
|
+
*/
|
|
156
|
+
export function extractGuidFromGamepadId(id: string): string | undefined {
|
|
157
|
+
// XInput devices expose no VID/PID in either Chrome or Firefox.
|
|
158
|
+
if (id.toLowerCase().includes('xinput')) return undefined;
|
|
159
|
+
|
|
160
|
+
// Chrome / Edge: "... (STANDARD GAMEPAD Vendor: 054c Product: 09cc)".
|
|
161
|
+
const vendorMatch = id.match(CHROME_VENDOR_RE);
|
|
162
|
+
const productMatch = id.match(CHROME_PRODUCT_RE);
|
|
163
|
+
if (vendorMatch?.[1] && productMatch?.[1]) {
|
|
164
|
+
const vid = Number.parseInt(vendorMatch[1], 16);
|
|
165
|
+
const pid = Number.parseInt(productMatch[1], 16);
|
|
166
|
+
if (!Number.isNaN(vid) && !Number.isNaN(pid)) return buildGuidFromVidPid(vid, pid);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Firefox: "046d-c216-Logitech Dual Action" (VID-PID-Name). The first two
|
|
170
|
+
// dash segments must be pure hex; a name-only Safari string fails this.
|
|
171
|
+
const parts = id.split('-');
|
|
172
|
+
if (parts.length >= 3 && parts[0] && parts[1]) {
|
|
173
|
+
const vidHex = parts[0];
|
|
174
|
+
const pidHex = parts[1];
|
|
175
|
+
if (/^[0-9a-f]{1,4}$/i.test(vidHex) && /^[0-9a-f]{1,4}$/i.test(pidHex)) {
|
|
176
|
+
const vid = Number.parseInt(vidHex, 16);
|
|
177
|
+
const pid = Number.parseInt(pidHex, 16);
|
|
178
|
+
if (!Number.isNaN(vid) && !Number.isNaN(pid)) return buildGuidFromVidPid(vid, pid);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Map a `navigator.userAgent` string to a gamecontrollerdb.txt platform
|
|
187
|
+
* section label (D-13). Returns undefined when the UA is unrecognised, in
|
|
188
|
+
* which case the caller falls back to any-platform entry selection.
|
|
189
|
+
*/
|
|
190
|
+
export function platformFromUserAgent(ua: string): ControllerPlatform | undefined {
|
|
191
|
+
// Order matters: iOS + Android both contain substrings that overlap with
|
|
192
|
+
// the desktop checks, so test the mobile/specific cases first.
|
|
193
|
+
if (/iPhone|iPad|iPod/.test(ua)) return CONTROLLER_PLATFORMS[4];
|
|
194
|
+
if (/Android/.test(ua)) return CONTROLLER_PLATFORMS[3];
|
|
195
|
+
if (/Windows/.test(ua)) return CONTROLLER_PLATFORMS[0];
|
|
196
|
+
if (/Mac OS X|Macintosh/.test(ua)) return CONTROLLER_PLATFORMS[1];
|
|
197
|
+
if (/Linux/.test(ua)) return CONTROLLER_PLATFORMS[2];
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Select the best mapping entry for a GUID: prefer the entry whose platform
|
|
203
|
+
* section matches `platform`, else fall back to the first available entry
|
|
204
|
+
* (any platform). Returns undefined when the GUID is absent from the DB.
|
|
205
|
+
*/
|
|
206
|
+
export function selectBestMappingEntry(
|
|
207
|
+
db: ControllerDb,
|
|
208
|
+
guid: string,
|
|
209
|
+
platform: string | undefined,
|
|
210
|
+
): ControllerDbEntry | undefined {
|
|
211
|
+
const entries = db[guid];
|
|
212
|
+
if (!entries || entries.length === 0) return undefined;
|
|
213
|
+
if (platform) {
|
|
214
|
+
const match = entries.find((e) => e.platform === platform);
|
|
215
|
+
if (match) return match;
|
|
216
|
+
}
|
|
217
|
+
return entries[0];
|
|
218
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// frame-start-scan-system.ts -- bridges an `InputBackend` producer into
|
|
2
|
+
// the `InputSnapshot` Resource consumed by user systems (charter P5).
|
|
3
|
+
//
|
|
4
|
+
// Plan-strategy section 2.10 D-5 locks the system to a frame-start position
|
|
5
|
+
// in the schedule: user systems declare `after: ['input-frame-start-scan']`
|
|
6
|
+
// to read the snapshot. The system itself holds zero queries; it only
|
|
7
|
+
// pulls one sample from the backend and writes the Resource.
|
|
8
|
+
//
|
|
9
|
+
// The backend is supplied via the `InputBackend` World resource rather than a
|
|
10
|
+
// captured closure. Resource reads remain explicit in the system body.
|
|
11
|
+
|
|
12
|
+
import { defineSystem, defineSystemSet, type SystemHandle } from '@forgeax/engine-ecs';
|
|
13
|
+
import {
|
|
14
|
+
type ActionConfig,
|
|
15
|
+
type ActionState,
|
|
16
|
+
deriveActionStates,
|
|
17
|
+
INPUT_MAP_KEY,
|
|
18
|
+
} from './action-state';
|
|
19
|
+
import {
|
|
20
|
+
INPUT_SNAPSHOT_RESOURCE_KEY,
|
|
21
|
+
type InputBackend,
|
|
22
|
+
type InputSnapshot,
|
|
23
|
+
readActionStatesForEdgeDiff,
|
|
24
|
+
snapshotFromSample,
|
|
25
|
+
} from './input-snapshot';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stable system name (locked by `frame-start-scan-system.test.ts`). User
|
|
29
|
+
* systems reference it through `after: [FRAME_START_SCAN_SYSTEM_NAME]`
|
|
30
|
+
* to ensure they observe the freshly written snapshot.
|
|
31
|
+
*/
|
|
32
|
+
export const FRAME_START_SCAN_SYSTEM_NAME = 'input-frame-start-scan';
|
|
33
|
+
export const InputSet = defineSystemSet({ name: 'input' });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resource key under which the {@link InputBackend} producer is inserted
|
|
37
|
+
* (M2 — full resource-ification, D-2 / D-7). The frame-start scan system
|
|
38
|
+
* declares it in `resources`; the fn body reads it back via
|
|
39
|
+
* `world.getResource(INPUT_BACKEND_KEY)`.
|
|
40
|
+
*
|
|
41
|
+
* Aligns with the `INPUT_SNAPSHOT_RESOURCE_KEY` naming. Consumers import the
|
|
42
|
+
* constant rather than the bare string so a typo degrades to an import error
|
|
43
|
+
* (charter P3).
|
|
44
|
+
*/
|
|
45
|
+
export const INPUT_BACKEND_KEY = 'InputBackend' as const;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The frame-start scan system token (M2 — full resource-ification, D-4).
|
|
49
|
+
*
|
|
50
|
+
* Module-level `defineSystem` with the real fn body — no factory, no closure.
|
|
51
|
+
* Each `world.update()` tick:
|
|
52
|
+
* 1. reads the {@link InputBackend} from `INPUT_BACKEND_KEY` and calls
|
|
53
|
+
* `backend.sample()` to drain the per-frame accumulator (movement delta +
|
|
54
|
+
* up-edge set);
|
|
55
|
+
* 2. derives a fresh `InputSnapshot` via `snapshotFromSample`;
|
|
56
|
+
* 3. writes it under `INPUT_SNAPSHOT_RESOURCE_KEY` via
|
|
57
|
+
* `world.insertResource` -- idempotent overwrite, charter P4
|
|
58
|
+
* consistent abstraction (consumers always read the same Resource key
|
|
59
|
+
* regardless of which backend produced the sample).
|
|
60
|
+
*
|
|
61
|
+
* Labelled `'input'` (spec §6.2 label-anchor map).
|
|
62
|
+
*/
|
|
63
|
+
export const InputFrameStartScan: SystemHandle<readonly []> = defineSystem({
|
|
64
|
+
name: FRAME_START_SCAN_SYSTEM_NAME,
|
|
65
|
+
queries: [],
|
|
66
|
+
fn: (world) => {
|
|
67
|
+
const backend = world.getResource<InputBackend>(INPUT_BACKEND_KEY);
|
|
68
|
+
const sample = backend.sample();
|
|
69
|
+
|
|
70
|
+
// Action mapping: derive states from InputMap Resource + prev snapshot edges.
|
|
71
|
+
// D-6: edge baseline reuses existing prev InputSnapshot Resource (Derive,
|
|
72
|
+
// Don't Duplicate — zero new cross-frame state carrier).
|
|
73
|
+
let actionStates: ReturnType<typeof deriveActionStates> | undefined;
|
|
74
|
+
let inputMap: readonly ActionConfig[] | undefined;
|
|
75
|
+
if (world.hasResource(INPUT_MAP_KEY)) {
|
|
76
|
+
inputMap = world.getResource<readonly ActionConfig[]>(INPUT_MAP_KEY);
|
|
77
|
+
// Read prev snapshot's internal action states for edge diff.
|
|
78
|
+
// First frame: no prev snapshot → justPressed = pressed (same as Feat1 gamepad).
|
|
79
|
+
let prevActionStates: readonly ActionState[] | undefined;
|
|
80
|
+
if (world.hasResource(INPUT_SNAPSHOT_RESOURCE_KEY)) {
|
|
81
|
+
const prevSnap = world.getResource<InputSnapshot>(INPUT_SNAPSHOT_RESOURCE_KEY);
|
|
82
|
+
prevActionStates = readActionStatesForEdgeDiff(prevSnap);
|
|
83
|
+
}
|
|
84
|
+
actionStates = deriveActionStates(sample, inputMap, prevActionStates);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const previousSnapshot = world.hasResource(INPUT_SNAPSHOT_RESOURCE_KEY)
|
|
88
|
+
? world.getResource<InputSnapshot>(INPUT_SNAPSHOT_RESOURCE_KEY)
|
|
89
|
+
: undefined;
|
|
90
|
+
const snapshot = snapshotFromSample(sample, actionStates, inputMap, previousSnapshot);
|
|
91
|
+
world.insertResource(INPUT_SNAPSHOT_RESOURCE_KEY, snapshot);
|
|
92
|
+
},
|
|
93
|
+
});
|