@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -1,6 +1,24 @@
1
1
  import { resolveUrl } from '../loader';
2
2
  import { SceneParseError } from '../scene/parse';
3
- import type { InputBinding, InputMapFile } from './input-types';
3
+ import type {
4
+ ActionValueOf,
5
+ ActionValueSource,
6
+ ActionValueType,
7
+ BindingConflict,
8
+ BindingPrompt,
9
+ InputBinding,
10
+ InputMapFile,
11
+ PromptDevice,
12
+ Vector2,
13
+ } from './input-types';
14
+ import {
15
+ bindingDeviceFamily,
16
+ gamepadAxisLabel,
17
+ gamepadAxisPairLabel,
18
+ keyLabel,
19
+ mouseButtonLabel,
20
+ STANDARD_GAMEPAD_BUTTON_LABELS,
21
+ } from './prompt-labels';
4
22
  import { InputMapFileSchema } from './schema';
5
23
 
6
24
  /**
@@ -20,6 +38,70 @@ function isTextEntryFocused(): boolean {
20
38
  );
21
39
  }
22
40
 
41
+ /**
42
+ * Thrown by `InputManager.setVirtualAction`/`tapVirtualAction` (Task 1.4,
43
+ * ACCEPTANCE-DRIVER-BUILD-PLAN.md / SYNTHETIC-PLAYER-SPEC.md §3.2) for an
44
+ * action name that was never `registerAction`/`loadMap`-ed. Carries a
45
+ * machine-readable `code` and `data.registered` (every declared action name)
46
+ * rather than requiring a caller to parse the message.
47
+ */
48
+ export class InputActionError extends Error {
49
+ readonly code = 'INPUT_ACTION_NOT_FOUND' as const;
50
+ readonly data: { registered: string[] };
51
+
52
+ constructor(action: string, method: string, registered: string[]) {
53
+ super(
54
+ `InputManager.${method}: unknown action "${action}". Registered actions: ` +
55
+ (registered.length ? registered.join(', ') : '(none)'),
56
+ );
57
+ this.name = 'InputActionError';
58
+ this.data = { registered };
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Thrown by `InputManager.scheduleActionAtTick` (D15/T-D15.5,
64
+ * docs/D15-DETERMINISM-DESIGN.md §2.c) when the named `tick` has already
65
+ * elapsed — scheduling only ever applies to the CURRENT or a future tick,
66
+ * never one already serviced by `poll()`. Carries `data.currentTick` — the
67
+ * NEXT tick `poll()` will service (not the tick that just elapsed) — so a
68
+ * caller can retry by scheduling for `data.currentTick` or later.
69
+ */
70
+ export class InputTickError extends Error {
71
+ readonly code = 'TICK_ALREADY_PASSED' as const;
72
+ readonly data: { currentTick: number };
73
+
74
+ constructor(tick: number, currentTick: number) {
75
+ super(
76
+ `InputManager.scheduleActionAtTick: tick ${tick} has already passed (next tick to be ` +
77
+ `serviced: ${currentTick}) — scheduling only applies to the current or a future tick`,
78
+ );
79
+ this.name = 'InputTickError';
80
+ this.data = { currentTick };
81
+ }
82
+ }
83
+
84
+ /** Shallow equality for a resolved action value (boolean/number/Vector2) —
85
+ * D15/T-D15.5's recording tap uses this to emit DELTAS only (the trace
86
+ * format's "deltas only" line), never a full snapshot every tick. `prev`
87
+ * is `undefined` for an action never recorded before (always "changed"). */
88
+ function actionValuesEqual(
89
+ prev: boolean | number | { x: number; y: number } | undefined,
90
+ next: boolean | number | { x: number; y: number },
91
+ ): boolean {
92
+ if (prev === undefined) return false;
93
+ if (typeof prev !== typeof next) return false;
94
+ if (typeof prev === 'object' && typeof next === 'object') {
95
+ return prev.x === next.x && prev.y === next.y;
96
+ }
97
+ return prev === next;
98
+ }
99
+
100
+ /** Cap on {@link InputManager}'s post-gate input-trace ring (D15/T-D15.5) —
101
+ * mirrors `debug-registry.ts`'s own `RING_CAP` precedent for the debug event
102
+ * ring: bounds memory for a recording session a caller forgot to stop. */
103
+ const INPUT_TRACE_CAP = 2000;
104
+
23
105
  /**
24
106
  * Maps raw keyboard/mouse/gamepad events → named actions.
25
107
  *
@@ -31,9 +113,182 @@ function isTextEntryFocused(): boolean {
31
113
  * if (input.isPressed('jump')) { ... }
32
114
  * if (input.isJustPressed('attack')) { ... }
33
115
  * input.endFrame(); // call at end of frame
116
+ *
117
+ * F1 (spec §12 "Define Typed Action Values") — typed action values:
118
+ *
119
+ * Every action DECLARES a value shape (`InputAction.valueType`, defaulted to
120
+ * `'digital'` — see `input-types.ts`'s `ActionValueType`). Each shape has its
121
+ * own typed getter, none of which return `any`:
122
+ *
123
+ * - `'digital'` — `isPressed`/`isJustPressed`/`isJustReleased` -> `boolean`
124
+ * - `'scalar'` — `getScalar` -> `number`
125
+ * - `'vector2'` — `getVector2` -> `Vector2` ({x,y})
126
+ * - `'pointerDelta'` — `getPointerDelta` -> `Vector2`
127
+ * - `'pointerPosition'` — `getPointerPosition` -> `Vector2`
128
+ *
129
+ * Reading an action through the WRONG getter (e.g. `getScalar` on a
130
+ * `'digital'` action) throws — that's the type-safety enforcement point,
131
+ * since actions are declared in data (JSON), not TS types, so there's no
132
+ * compiler backstop otherwise. `readAction(name, expectedType)` is a generic
133
+ * entry point over the same getters, typed via `ActionValueOf<T>`.
134
+ *
135
+ * Each typed getter has a `*Source` sibling (e.g. `getScalarSource`) that
136
+ * returns `ActionValueSource | null` — which binding (and, for gamepad
137
+ * bindings, which connected gamepad, or for injected test bindings, which
138
+ * named source) produced the current value. That's the "device/source
139
+ * metadata" AC.
140
+ *
141
+ * Deadzone + normalization:
142
+ * - scalar: below `deadzone` magnitude -> 0; at/above it, rescaled from 0
143
+ * (at the deadzone edge) to ±1 (at |raw| = 1), sign preserved
144
+ * (`rescaleScalar`).
145
+ * - vector2: below `deadzone` magnitude -> {0,0}; at/above it, direction is
146
+ * preserved and magnitude is rescaled the same way, then the result is
147
+ * clamped to the unit circle — magnitude never exceeds 1 even for a raw
148
+ * diagonal input like {1,1} (`rescaleVector2`).
149
+ *
150
+ * Simultaneous-binding combine rules (documented here, spec §12 F1
151
+ * "simultaneous bindings combine predictably"):
152
+ * - digital: OR — any one satisfied binding makes the action pressed
153
+ * (pre-existing behavior, unchanged).
154
+ * - scalar: max-magnitude across contributing bindings' (deadzone-applied)
155
+ * values, sign preserved; ties keep the first-listed binding
156
+ * (deterministic given binding array order).
157
+ * - vector2: sum contributing (deadzone-applied) {x,y} values component-
158
+ * wise, then clamp the sum to the unit circle (never renormalized UP —
159
+ * only ever scaled down if the sum exceeds magnitude 1).
160
+ * - pointerDelta: sum contributing deltas (matches the existing
161
+ * mouseDeltaX/Y + lookStick accumulation precedent below).
162
+ * - pointerPosition: last-write-wins — the contributing source most
163
+ * recently updated (by injection call order) wins; position isn't
164
+ * additive so summing would be meaningless.
165
+ *
166
+ * Synthetic input injection (test hook, also the shape of F2's "injected
167
+ * test input" backend): `injectAxis`/`injectVector2`/`injectPointerDelta`/
168
+ * `injectPointerPosition` push a raw value into a named source, read back by
169
+ * binding an action to `{ type: 'test_axis' | 'test_vector2' |
170
+ * 'test_pointer_delta' | 'test_pointer_position', sourceId }` — the exact
171
+ * same aggregation path (deadzone/normalize/combine) real device bindings
172
+ * use.
173
+ *
174
+ * F2 (spec §12 "Complete Device Backends") completes the real-device side of
175
+ * that same typed-value path:
176
+ *
177
+ * - `mouse_move` — un-rejected (schema.ts). Feeds `getPointerDelta` (raw
178
+ * accumulated `mouseDeltaX/Y`, no deadzone) and `getVector2` (the same
179
+ * delta, deadzone-rescaled + unit-circle-clamped).
180
+ * - `gamepad_axis_pair` — un-rejected (schema.ts). Feeds `getVector2` with
181
+ * the coupled `(xAxis, yAxis)` reading, deadzone-rescaled like a real
182
+ * stick (default deadzone 0.15, matching `gamepad_axis`).
183
+ * - `touch_button`/`touch_stick` — new binding kinds for touch/virtual
184
+ * controls. `setTouchButton(sourceId, pressed)` feeds a digital action
185
+ * (edge-tracked like `mouse_button`); `setTouchStick(sourceId, value)`
186
+ * feeds a vector2 action (deadzone-rescaled like a gamepad stick). These
187
+ * are the injectable entry points a real on-screen touch UI's own
188
+ * touchstart/touchmove/touchend handlers would call — that DOM wiring is
189
+ * a thin adapter at the edge (browser-verified separately, e.g. F4/e2e);
190
+ * the mapping logic here is unit-tested headlessly with no DOM.
191
+ * - Focus gating — `blur`/`focus` on `window` (guarded, real DOM only) now
192
+ * drop input the same way `setEnabled(false)` does (flushing held
193
+ * key/mouse/touch-button state) without touching the `enabled` flag the
194
+ * editor drives — see `focused`/`inputActive` below. This is what stops
195
+ * a stuck key/button when the user alt-tabs away mid-press.
196
+ * - Pointer lock — `pointerlockchange` on `document` (guarded) tracks
197
+ * `isPointerLocked()`; losing lock flushes mouse-button state (a click
198
+ * held at the moment lock exits must not stick).
199
+ * - Gamepad device identity — `getGamepadInfo(index)` exposes `{index, id,
200
+ * mapping, connected}`; `ActionValueSource.gamepadId` (in addition to the
201
+ * pre-existing `gamepadIndex`) is populated for every
202
+ * gamepad_button/gamepad_axis/gamepad_axis_pair source.
203
+ * - Disconnection/reconnection needs no new tracking: `poll()` already
204
+ * re-reads `navigator.getGamepads()` every frame and every read (digital
205
+ * edges, scalar/vector2 contributions) already skips a `null` gamepad
206
+ * entry, so a disconnected gamepad's bound actions go neutral (and a
207
+ * previously-held button correctly fires `isJustReleased`) with no
208
+ * special-cased code — see the state-transition tests.
209
+ *
210
+ * F3 (spec §12 "Add Rebinding and Prompts") completes the input system with
211
+ * rebinding, structural conflict detection, and device-prompt resolution:
212
+ *
213
+ * - Listing/mutating: `getBindings`/`setBindings`/`addBinding`/
214
+ * `removeBinding`/`replaceBinding` — the same `this.actions` map F1/F2
215
+ * already read, now with a public read + mutation surface (there was
216
+ * previously none — every existing call site either loaded a map once or
217
+ * read through the typed getters).
218
+ * - Reset-to-defaults: `registerAction`/`loadMap`/`loadMapObject` snapshot
219
+ * each action's bindings into `defaultBindings` (a deep clone) at
220
+ * registration/load time; `resetBindings`/`resetAllBindings` restore from
221
+ * that snapshot. Later rebinding calls do NOT touch the snapshot, so
222
+ * "reset" always means "back to what was authored/loaded", not "back to
223
+ * the last reset".
224
+ * - Persist: `toInputMapFile()` serializes the live action set back into
225
+ * the exact `.inputmap.json` shape (`InputMapFile`) `loadMap` reads;
226
+ * `loadMapObject(data)` is `loadMap`'s synchronous, no-`fetch` sibling —
227
+ * both funnel through the same `InputMapFileSchema`-validated
228
+ * `applyParsedMap`. This is the round-trip seam: serialize, write/hand to
229
+ * something else, reload, and rebound actions survive. It complements —
230
+ * doesn't replace — the SDK's `project.inputMap.read/validate/update`
231
+ * operations (B2, `packages/vgai-sdk/src/project/input-map-operations.ts`):
232
+ * those are FILE-level (a CLI/agent editing the `.inputmap.json` on disk,
233
+ * no running game involved); this is RUNTIME-level (a live game rebinding
234
+ * while playing). Both validate through the identical
235
+ * `InputMapFileSchema`, so a file B2 wrote loads here unchanged, and a
236
+ * map this class serializes is a valid document for B2 to read back.
237
+ * - Conflicts: `findConflicts(proposed, excludeActionName?)` scans every
238
+ * OTHER registered action's bindings for one that's structurally
239
+ * equivalent to `proposed` (see the private `bindingsCollide` — same
240
+ * physical input identity: same key code, same mouse button, the same
241
+ * gamepad button/axis/axis-pair, the same touch `sourceId`, etc.) and
242
+ * returns the (possibly empty) list as plain `BindingConflict` data —
243
+ * never throws. A rebinding UI calls this BEFORE calling
244
+ * `addBinding`/`replaceBinding`/`setBindings` and decides what to do with
245
+ * a non-empty result (block, warn, let the user steal the binding by
246
+ * removing it from the other action first).
247
+ * - Prompts: `getPrompt(actionName, device?, gamepadIndex?)` resolves a
248
+ * `BindingPrompt` (display label + icon name) for whichever of the
249
+ * action's bindings belongs to the requested `PromptDevice` family
250
+ * (keyboard/mouse/gamepad/touch — `bindingDeviceFamily` in
251
+ * `prompt-labels.ts`), using `getGamepadInfo` to prefer the standard
252
+ * mapping's canonical glyph names (`'A'`, `'LB'`, ...) for gamepad
253
+ * buttons and falling back to a raw index label (or a "disconnected"
254
+ * label) when the pad isn't in standard mapping or isn't connected.
255
+ * `device` is optional (F4 below) — omit it to resolve against
256
+ * `getLastActiveDevice()` instead.
257
+ *
258
+ * F4 (spec §12 "Schema and Examples") closes F3's one open gap — a
259
+ * current-ACTIVE-device tracker, so a prompt UI can switch its displayed
260
+ * label as the player switches controllers instead of the caller having to
261
+ * track that itself:
262
+ *
263
+ * - `getLastActiveDevice()` returns the `PromptDevice` family of whichever
264
+ * input arrived most recently — keyboard on `keydown`, mouse on
265
+ * `mousedown`/a real `mousemove`, gamepad on a button press or a stick
266
+ * pushed meaningfully off-center (checked in `poll()`), touch on
267
+ * `setTouchButton(_, true)` or `setTouchStick` pushed meaningfully
268
+ * off-center. `null` before any input has arrived.
269
+ * - `getPrompt(actionName)` (device omitted) resolves against it directly,
270
+ * falling back to `'keyboard'` before any input has arrived — a HUD can
271
+ * call it every frame with no extra bookkeeping and the label switches on
272
+ * its own.
34
273
  */
35
274
  export class InputManager {
36
275
  private actions = new Map<string, InputBinding[]>();
276
+ // F1 — each registered/loaded action's declared value shape (defaults to
277
+ // 'digital' — see registerAction/loadMap). Kept as a sibling map rather than
278
+ // folded into `actions` so the pre-F1 `Map<string, InputBinding[]>` shape
279
+ // (and every existing direct read of it, e.g. in tests) stays unchanged.
280
+ private actionValueTypes = new Map<string, ActionValueType>();
281
+ // F3 — a deep-cloned snapshot of each action's bindings AT REGISTRATION/LOAD
282
+ // TIME (registerAction/loadMap/loadMapObject), restored by
283
+ // resetBindings/resetAllBindings. Later rebinding calls (setBindings/
284
+ // addBinding/removeBinding/replaceBinding) deliberately do not touch this —
285
+ // "reset" always means "back to the originally authored/loaded defaults".
286
+ private defaultBindings = new Map<string, InputBinding[]>();
287
+ // F3 — the `version` field of the last `loadMap`/`loadMapObject`-ed
288
+ // document, carried forward by `toInputMapFile()` so a round-trip
289
+ // (serialize -> reload) preserves it. Defaults to 1 for a manager built
290
+ // purely via `registerAction` (no file was ever loaded).
291
+ private loadedMapVersion = 1;
37
292
  private keysDown = new Set<string>();
38
293
  private keysJustDown = new Set<string>();
39
294
  private keysJustUp = new Set<string>();
@@ -61,6 +316,129 @@ export class InputManager {
61
316
  private gamepadAxesPrev = new Map<string, number>();
62
317
  private lookStickX = 0;
63
318
  private lookStickY = 0;
319
+ // F1 — injected-test-input raw value stores, keyed by caller-chosen
320
+ // `sourceId` (see the class doc comment's "Synthetic input injection"
321
+ // section). Axis/Vector2/pointer-position are LEVEL values (persist across
322
+ // frames until changed, mirroring a real analog stick/gamepad axis —
323
+ // there's no hardware poll to naturally refresh them each frame); pointer
324
+ // delta is a per-frame accumulator (cleared in endFrame(), mirroring
325
+ // mouseDeltaX/Y above).
326
+ private testAxisValues = new Map<string, number>();
327
+ private testVector2Values = new Map<string, Vector2>();
328
+ private testPointerDeltaAccum = new Map<string, Vector2>();
329
+ private testPointerPositionValues = new Map<string, { value: Vector2; seq: number }>();
330
+ // Monotonic counter stamped onto each injectPointerPosition call so the
331
+ // pointerPosition combine rule (last-write-wins) can tell which of several
332
+ // contributing sources was written most recently within/across frames.
333
+ private pointerPositionSeq = 0;
334
+ // F2 — touch/virtual-control state, keyed by caller-chosen `sourceId` (the
335
+ // touch UI's own zone/knob id — see setTouchButton/setTouchStick). Buttons
336
+ // mirror the mouse-button edge-tracking shape (Down/JustDown/JustUp sets);
337
+ // sticks are LEVEL values like a gamepad axis (persist until changed).
338
+ private touchButtonsDown = new Set<string>();
339
+ private touchButtonsJustDown = new Set<string>();
340
+ private touchButtonsJustUp = new Set<string>();
341
+ private touchStickValues = new Map<string, Vector2>();
342
+ // F4 (spec §12 "Schema and Examples" — the F3 prompt-switching gap) — which
343
+ // device family most recently produced REAL input, so a prompt UI can
344
+ // switch its displayed label ('Space' -> 'A' -> a touch control's name) as
345
+ // the player switches controllers, without the caller having to guess or
346
+ // track this itself. Updated at the same points raw input arrives: keydown
347
+ // (`'keyboard'`), mousedown/mousemove (`'mouse'`), a gamepad button press or
348
+ // a stick pushed meaningfully past rest (`'gamepad'`, both detected in
349
+ // `poll()` — a raw resting stick reports small non-zero noise on some
350
+ // pads, so this deliberately checks a coarse magnitude, not "any non-zero
351
+ // axis"), and a touch button press / stick pushed past rest
352
+ // (`'touch'` — `setTouchButton`/`setTouchStick`). `null` until the very
353
+ // first input of any kind arrives. See `getLastActiveDevice`/`getPrompt`.
354
+ private lastActiveDevice: PromptDevice | null = null;
355
+ /** Coarse "is this stick meaningfully off-center" check shared by the
356
+ * gamepad and touch-stick active-device heuristics above — deliberately a
357
+ * higher bar than a binding's own (often much smaller) configured
358
+ * deadzone, since this is "did the player just grab this control", not
359
+ * "does this binding's value count as non-zero". */
360
+ private static isStickActive(v: Vector2): boolean {
361
+ return Math.hypot(v.x, v.y) > 0.3;
362
+ }
363
+ // Task 1.4 (ACCEPTANCE-DRIVER-BUILD-PLAN.md) — action-level virtual input
364
+ // for a synthetic player, promoted from hollowstone's `VirtualInput`. Held
365
+ // digitals OR into isPressed alongside binding contributions (like
366
+ // touchButtonsDown above); tap queues, then is promoted to "active" for
367
+ // exactly one poll()/endFrame() bracket (see poll()/endFrame() below,
368
+ // mirroring hollowstone's queued/beginFrame contract); scalar/vector2 are
369
+ // level values feeding the same max-magnitude/sum combine every real
370
+ // binding uses (collectScalarContributions/collectVector2Contributions).
371
+ private virtualDigitalHeld = new Set<string>();
372
+ private virtualDigitalTapQueued = new Set<string>();
373
+ private virtualDigitalTapActive = new Set<string>();
374
+ /** pulse-runner friction #4 — the release-edge mirror of
375
+ * `virtualDigitalTapActive`: a direct `setVirtualAction(action, false)` (or
376
+ * `clearVirtualActions()`) on an action that WAS held writes here so
377
+ * `isJustReleased` fires exactly once, mirroring real `onKeyUp`'s
378
+ * immediate `keysJustUp` write (no queue/promotion needed — unlike the
379
+ * press edge, nothing in `poll()` reassigns this set out from under a
380
+ * write that lands before `poll()` runs). Cleared every `endFrame()`,
381
+ * same bracket as every other just-* edge set. */
382
+ private virtualDigitalJustReleased = new Set<string>();
383
+ private virtualScalarValues = new Map<string, number>();
384
+ private virtualVector2Values = new Map<string, Vector2>();
385
+ // D15/T-D15.5 (docs/D15-DETERMINISM-DESIGN.md §2.c) — tick-indexed input
386
+ // scheduling + post-gate recording.
387
+ //
388
+ // `currentTick` is a FALLBACK, self-incrementing counter, used only when a
389
+ // caller never tells `poll()` which tick it's servicing (every existing
390
+ // bare/headless caller — tests, a mount with no `Game` shell behind it —
391
+ // keeps this exact pre-existing behavior, zero regression). The REAL
392
+ // per-world wiring (`vgai-scene-game-adapter.ts`) instead passes the
393
+ // shared `Game`-level tick counter into every `poll(tick)` call — this is
394
+ // deliberate: an InputManager-local counter drifts from the actual game
395
+ // tick for a paused/frozen world (its `poll()` isn't called every game
396
+ // tick, so a local increment-per-call counter undercounts) — see the
397
+ // `lastServicedTick` gap-handling below, which structurally can't drift
398
+ // because it's driven by whatever tick value the CALLER (ultimately the
399
+ // shared `Game` tick) actually supplies.
400
+ private currentTick = 0;
401
+ /** The last tick value `poll()` actually serviced, or `-1` before the
402
+ * first call. Used to detect a GAP (this world's input phase didn't run
403
+ * for one or more intervening ticks — e.g. frozen/paused while other
404
+ * roots kept advancing the shared game tick) so any schedule entries
405
+ * inside that gap can be dropped (with an observable event) instead of
406
+ * silently rotting in {@link scheduledActions} forever. */
407
+ private lastServicedTick = -1;
408
+ /** Pending `scheduleActionAtTick` actuations, grouped by their exact target
409
+ * tick. Applied (and removed) at the START of that tick's `poll()` — see
410
+ * `applyScheduledActionsForTick`. A tick whose input phase is skipped
411
+ * entirely (this world paused/frozen, or a gap between two serviced
412
+ * ticks) never reaches it — its scheduled entries are dropped, each
413
+ * emitting one `'input.schedule.dropped'` debug event (`{tick, action}`)
414
+ * via {@link setDebugEmit}'s sink if one is wired — NOT retried on a
415
+ * later tick (mirrors `clearVirtualActions`-on-gate-close: no stuck
416
+ * actuation ever silently fires late). */
417
+ private scheduledActions = new Map<
418
+ number,
419
+ { action: string; value: boolean | number | { x: number; y: number } }[]
420
+ >();
421
+ /** Optional sink for the `'input.schedule.dropped'` debug event (`{tick,
422
+ * action}`) emitted whenever a scheduled entry is discarded because its
423
+ * target tick's input phase never ran. Wired by whoever constructs this
424
+ * `InputManager` with access to a `DebugRegistry`
425
+ * (`vgai-scene-game-adapter.ts`, the same seed spot as
426
+ * `setVirtualInputTarget`/`setInputActionsSource`) — `null` (the
427
+ * default) for a bare/headless `InputManager`, in which case a drop
428
+ * stays silent (matching pre-D15/T-D15.5 behavior). */
429
+ private debugEmit: ((event: string, detail?: unknown) => void) | null = null;
430
+ /** D15/T-D15.5 recording toggle — see {@link startInputRecording}. */
431
+ private inputRecording = false;
432
+ /** The post-gate action-delta trace (§2.c's format sketch) — capped so an
433
+ * accidentally-long recording session can't grow this unboundedly. */
434
+ private inputTrace: {
435
+ tick: number;
436
+ actions: Record<string, boolean | number | { x: number; y: number }>;
437
+ }[] = [];
438
+ /** Last value recorded per action, so `recordPostGateTick` only ever
439
+ * appends a DELTA (the format sketch's "deltas only" line), not a full
440
+ * snapshot every tick. Cleared on `startInputRecording()`. */
441
+ private lastTraceSnapshot = new Map<string, boolean | number | { x: number; y: number }>();
64
442
  private disposed = false;
65
443
  /**
66
444
  * When false, all raw input is ignored and held/transient state is cleared.
@@ -71,16 +449,59 @@ export class InputManager {
71
449
  * for its whole lifetime.
72
450
  */
73
451
  private enabled = true;
452
+ /**
453
+ * F2 — real window focus, distinct from `enabled` (which is the editor's
454
+ * explicit play-mode gate). `false` while the tab/window is blurred (real
455
+ * `blur`/`focus` events — see the constructor). Held keyboard/mouse/touch
456
+ * state is flushed on blur so a key/button held at alt-tab time doesn't
457
+ * stick (the browser stops delivering keyup/mouseup while blurred).
458
+ */
459
+ private focused = true;
460
+ /** Both gates a standalone game must pass for input to be live (spec §12
461
+ * F2 "focus gating"): the editor's explicit `enabled` flag AND real
462
+ * window focus. Used everywhere `enabled` alone used to be checked. */
463
+ private get inputActive(): boolean {
464
+ return this.enabled && this.focused;
465
+ }
466
+ /** #144 — the gate MACHINE input passes (virtual actions and
467
+ * tick-scheduled actuations): the editor's explicit `enabled` flag ONLY.
468
+ * Window focus deliberately does not participate: a synthetic player
469
+ * drives the shared editor tab precisely while the human's focus is
470
+ * elsewhere (their terminal, another window) — the first live in-editor
471
+ * probe run froze mid-suite the moment the owner started typing to their
472
+ * agent, which is the bug this gate split fixes. Real-device input keeps
473
+ * the stricter `inputActive` (enabled AND focused) gate above. */
474
+ private get machineInputActive(): boolean {
475
+ return this.enabled;
476
+ }
477
+ /** F2 — real pointer-lock state (`document.pointerLockElement`), tracked via
478
+ * `pointerlockchange` (see the constructor). */
479
+ private pointerLocked = false;
74
480
  /** Element currently wired for click-to-pointer-lock, and its listener. */
75
481
  private pointerLockElement: HTMLElement | null = null;
76
482
  private onPointerLockClick = () => {
77
483
  this.pointerLockElement?.requestPointerLock();
78
484
  };
485
+ private onPointerLockChange = () => {
486
+ const doc = typeof document !== 'undefined' ? document : null;
487
+ const wasLocked = this.pointerLocked;
488
+ this.pointerLocked =
489
+ !!this.pointerLockElement && doc?.pointerLockElement === this.pointerLockElement;
490
+ // Losing lock mid-click must not leave a mouse button stuck down — the
491
+ // browser may not deliver a mouseup when lock exits (e.g. Escape).
492
+ if (wasLocked && !this.pointerLocked) {
493
+ this.mouseButtons.clear();
494
+ this.mouseButtonsJustDown.clear();
495
+ this.mouseButtonsJustUp.clear();
496
+ }
497
+ };
79
498
 
80
499
  private onKeyDown = (e: KeyboardEvent) => {
81
- // Ignore game input while suspended or while the user is typing into a text
82
- // field (e.g. renaming an entity / editing an inspector value in the editor).
83
- if (!this.enabled || isTextEntryFocused()) return;
500
+ // Ignore game input while suspended, unfocused, or while the user is typing
501
+ // into a text field (e.g. renaming an entity / editing an inspector value
502
+ // in the editor).
503
+ if (!this.inputActive || isTextEntryFocused()) return;
504
+ this.lastActiveDevice = 'keyboard';
84
505
  if (!this.keysDown.has(e.code)) {
85
506
  this.keysJustDown.add(e.code);
86
507
  }
@@ -88,35 +509,124 @@ export class InputManager {
88
509
  };
89
510
 
90
511
  private onKeyUp = (e: KeyboardEvent) => {
91
- if (!this.enabled) return;
512
+ if (!this.inputActive) return;
92
513
  this.keysDown.delete(e.code);
93
514
  this.keysJustUp.add(e.code);
94
515
  };
95
516
 
96
517
  private onMouseDown = (e: MouseEvent) => {
97
- if (!this.enabled) return;
518
+ if (!this.inputActive) return;
519
+ this.lastActiveDevice = 'mouse';
98
520
  this.mouseButtons.add(e.button);
99
521
  this.mouseButtonsJustDown.add(e.button);
100
522
  };
101
523
 
102
524
  private onMouseUp = (e: MouseEvent) => {
103
- if (!this.enabled) return;
525
+ if (!this.inputActive) return;
104
526
  this.mouseButtons.delete(e.button);
105
527
  this.mouseButtonsJustUp.add(e.button);
106
528
  };
107
529
 
108
530
  private onMouseMove = (e: MouseEvent) => {
109
- if (!this.enabled) return;
531
+ if (!this.inputActive) return;
532
+ if (e.movementX !== 0 || e.movementY !== 0) this.lastActiveDevice = 'mouse';
110
533
  this.mouseDeltaX += e.movementX;
111
534
  this.mouseDeltaY += e.movementY;
112
535
  };
113
536
 
537
+ /** F2 — real window blur: drop focus and flush held REAL-device state
538
+ * (the browser stops delivering keyup/mouseup while blurred, so a key
539
+ * held at alt-tab time would stick). #144: virtual-action state is
540
+ * deliberately NOT flushed here — a bot's held action survives the human
541
+ * looking away (see `machineInputActive`); only `setEnabled(false)`
542
+ * clears it. */
543
+ private onWindowBlur = () => {
544
+ this.focused = false;
545
+ this.flushRealHeldState();
546
+ };
547
+
548
+ /** F2 — real window focus regain. No flush needed (there's nothing held to
549
+ * flush — a fresh keydown/mousedown is required to register pressed
550
+ * again, exactly like re-enabling via `setEnabled(true)`). */
551
+ private onWindowFocus = () => {
552
+ this.focused = true;
553
+ };
554
+
555
+ /** Clear held/transient REAL-device state (keyboard/mouse/touch-button) —
556
+ * shared by `setEnabled(false)` (via `flushHeldState`) and real blur
557
+ * (`onWindowBlur`), so a key/button physically held when input is
558
+ * suspended doesn't linger. Level values (gamepad axes, touch sticks,
559
+ * injected test axes) are deliberately NOT flushed here — they are gated
560
+ * at READ time instead (see `inputActive` checks in `poll()`/the
561
+ * `collect*Contributions` methods), matching the pre-existing
562
+ * gamepad-axis convention. */
563
+ private flushRealHeldState(): void {
564
+ this.keysDown.clear();
565
+ this.keysJustDown.clear();
566
+ this.keysJustUp.clear();
567
+ this.mouseButtons.clear();
568
+ this.mouseButtonsJustDown.clear();
569
+ this.mouseButtonsJustUp.clear();
570
+ this.mouseDeltaX = 0;
571
+ this.mouseDeltaY = 0;
572
+ this.touchButtonsDown.clear();
573
+ this.touchButtonsJustDown.clear();
574
+ this.touchButtonsJustUp.clear();
575
+ }
576
+
577
+ /** The full flush `setEnabled(false)` performs: real-device state AND
578
+ * virtual-action state. Task 1.4's rule ("a bot's held action must not
579
+ * survive input being suspended") now applies only to the editor's
580
+ * explicit gate — #144 moved real window blur onto `flushRealHeldState`
581
+ * alone, so machine input survives the human's focus leaving the tab.
582
+ * No release edge is manufactured here (`manufactureReleaseEdge: false`,
583
+ * the default) — this is a SUSPEND, not a deliberate release, and mirrors
584
+ * `flushRealHeldState`'s own silence (a key physically held at
585
+ * suspend/blur time is wiped with no `keysJustUp`, "flush held state,
586
+ * don't corrupt history", see `poll()`'s doc comment). */
587
+ private flushHeldState(): void {
588
+ this.flushRealHeldState();
589
+ this.clearVirtualActionState();
590
+ }
591
+
592
+ /** Shared by the public `clearVirtualActions()` and `flushHeldState()`
593
+ * above — clears every virtual-action store (held digitals, queued/active
594
+ * taps, scalar/vector2 values, the release-edge set itself).
595
+ *
596
+ * `manufactureReleaseEdge` (default `false`): when `true` — only
597
+ * `clearVirtualActions()`'s own deliberate "let go" passes this — every
598
+ * currently-held digital in `virtualDigitalHeld` first gets an
599
+ * `isJustReleased` edge written to `virtualDigitalJustReleased`, mirroring
600
+ * a real key-up (`onKeyUp` → `keysJustUp`). `flushHeldState()` (the
601
+ * editor-gate suspend path) deliberately passes `false` — see its own doc
602
+ * comment for why a suspend must stay silent. */
603
+ private clearVirtualActionState(options?: { manufactureReleaseEdge: boolean }): void {
604
+ if (options?.manufactureReleaseEdge) {
605
+ for (const action of this.virtualDigitalHeld) {
606
+ this.virtualDigitalJustReleased.add(action);
607
+ }
608
+ }
609
+ this.virtualDigitalHeld.clear();
610
+ this.virtualDigitalTapQueued.clear();
611
+ this.virtualDigitalTapActive.clear();
612
+ this.virtualScalarValues.clear();
613
+ this.virtualVector2Values.clear();
614
+ }
615
+
114
616
  constructor() {
115
617
  window.addEventListener('keydown', this.onKeyDown);
116
618
  window.addEventListener('keyup', this.onKeyUp);
117
619
  window.addEventListener('mousedown', this.onMouseDown);
118
620
  window.addEventListener('mouseup', this.onMouseUp);
119
621
  window.addEventListener('mousemove', this.onMouseMove);
622
+ window.addEventListener('blur', this.onWindowBlur);
623
+ window.addEventListener('focus', this.onWindowFocus);
624
+ // `document` isn't touched by any pre-F2 code path (the headless vitest
625
+ // env stubs `window` only) — guard the same way `isTextEntryFocused` does
626
+ // so tests that don't need pointer-lock coverage need no `document` stub.
627
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
628
+ document.addEventListener('pointerlockchange', this.onPointerLockChange);
629
+ }
120
630
  }
121
631
 
122
632
  /**
@@ -143,14 +653,72 @@ export class InputManager {
143
653
  console.error(message);
144
654
  throw err instanceof SceneParseError ? err : new Error(message);
145
655
  }
656
+ this.applyParsedMap(data);
657
+ }
658
+
659
+ /**
660
+ * F3 — `loadMap`'s synchronous, no-`fetch` sibling: apply an already-in-
661
+ * memory (or freshly-deserialized) input-map DOCUMENT, validated through
662
+ * the identical `InputMapFileSchema` (throws `SceneParseError` naming no
663
+ * file, matching `loadMap`'s error shape minus the URL). This is the other
664
+ * half of the F3 persist round-trip: `toInputMapFile()` serializes,
665
+ * `loadMapObject` re-applies — no network/file I/O required, so it's also
666
+ * how a headless test proves a rebind survives a save/reload cycle.
667
+ */
668
+ loadMapObject(data: unknown): InputMapFile {
669
+ const result = InputMapFileSchema.safeParse(data);
670
+ if (!result.success) throw new SceneParseError(result.error.issues);
671
+ this.applyParsedMap(result.data);
672
+ return result.data;
673
+ }
674
+
675
+ /** Shared apply step for `loadMap`/`loadMapObject`: copy each action's
676
+ * bindings + declared valueType into the live maps, and snapshot the
677
+ * bindings as that action's F3 reset-to-defaults baseline. */
678
+ private applyParsedMap(data: InputMapFile): void {
679
+ this.loadedMapVersion = data.version;
146
680
  for (const [name, action] of Object.entries(data.actions)) {
147
681
  this.actions.set(name, action.bindings);
682
+ // `InputActionSchema.valueType` has a Zod `.default('digital')`, so a
683
+ // successfully-parsed action always carries one even when the source
684
+ // JSON omitted it.
685
+ this.actionValueTypes.set(name, action.valueType ?? 'digital');
686
+ this.defaultBindings.set(name, structuredClone(action.bindings));
148
687
  }
149
688
  }
150
689
 
151
- /** Register an action programmatically */
152
- registerAction(name: string, bindings: InputBinding[]) {
690
+ /** Register an action programmatically. `valueType` declares the action's
691
+ * value shape (F1) defaults to `'digital'`, matching every call site
692
+ * written before F1. F3 — also snapshots `bindings` as this action's
693
+ * reset-to-defaults baseline (see `resetBindings`). */
694
+ registerAction(name: string, bindings: InputBinding[], valueType: ActionValueType = 'digital') {
153
695
  this.actions.set(name, bindings);
696
+ this.actionValueTypes.set(name, valueType);
697
+ this.defaultBindings.set(name, structuredClone(bindings));
698
+ }
699
+
700
+ /** The declared value type for a registered/loaded action (F1) — `'digital'`
701
+ * for an action that never specified one (including an unregistered/typo'd
702
+ * name — harmless, since every typed getter already treats an unknown
703
+ * action as inert). */
704
+ getActionValueType(actionName: string): ActionValueType {
705
+ return this.actionValueTypes.get(actionName) ?? 'digital';
706
+ }
707
+
708
+ /** Enforces the F1 type-safety AC: reading an action through a typed getter
709
+ * whose value type doesn't match the action's DECLARED `valueType` throws,
710
+ * naming both. Unknown actions (never registered/loaded) are exempt — they
711
+ * already read as inert/zeroed by every getter, matching pre-F1 behavior
712
+ * for a typo'd action name. */
713
+ private assertValueType(actionName: string, expected: ActionValueType, method: string): void {
714
+ const actual = this.actionValueTypes.get(actionName);
715
+ if (actual !== undefined && actual !== expected) {
716
+ throw new Error(
717
+ `InputManager.${method}: action "${actionName}" is declared valueType '${actual}', not ` +
718
+ `'${expected}'. Typed reads must match an action's declared valueType (F1, spec §12) — ` +
719
+ `use the getter for '${actual}' instead.`,
720
+ );
721
+ }
154
722
  }
155
723
 
156
724
  /** The names of all registered actions (for the InputAdapter to enumerate). */
@@ -158,23 +726,110 @@ export class InputManager {
158
726
  return [...this.actions.keys()];
159
727
  }
160
728
 
161
- /** Poll gamepads and virtual look stick (call at start of frame) */
162
- poll() {
729
+ /**
730
+ * Poll gamepads and virtual look stick (call at start of frame).
731
+ *
732
+ * `tick` (D15/T-D15.3/.5) — the GAME tick this call services. Pass the
733
+ * live, shared `Game` tick counter here (`vgai-scene-game-adapter.ts` does,
734
+ * via `DebugRegistry.getGameTick()`) so `scheduleActionAtTick`'s numbering
735
+ * never drifts from the actual game tick — the bug an earlier revision of
736
+ * this feature had: an InputManager-LOCAL counter, incremented once per
737
+ * `poll()` call, undercounts for a paused/frozen world (its `poll()` isn't
738
+ * called every game tick while frozen), so a schedule set against "tick
739
+ * 500" could fire at the wrong wall-tick once the world resumed. Omit it
740
+ * (every existing bare/headless caller does) to fall back to the
741
+ * self-incrementing `currentTick` counter — unchanged pre-D15 behavior.
742
+ */
743
+ poll(tick?: number) {
744
+ const servicedTick = tick !== undefined ? tick : this.currentTick;
745
+ // D15/T-D15.5 — a GAP: one or more ticks strictly between the last tick
746
+ // this InputManager serviced and this one never ran its input phase at
747
+ // all (this world was paused/frozen and skipped straight from tick A to
748
+ // a later tick B > A+1 once stepped/resumed again — the frozen-world
749
+ // scenario objection above). Any entries scheduled for a tick inside
750
+ // that gap can never be honored — drop them (each emitting one
751
+ // `'input.schedule.dropped'` event) rather than leaving them to rot in
752
+ // `scheduledActions` forever. Guarded on `size > 0` so the overwhelmingly
753
+ // common case (nothing ever scheduled) costs nothing, even across a
754
+ // very large gap.
755
+ if (this.scheduledActions.size > 0) {
756
+ // Iterate the (tiny) schedule map, never the gap's tick RANGE — a
757
+ // world resuming after a very long freeze (or one mounting late into
758
+ // a game already millions of ticks in) must not pay O(gap) here.
759
+ // Sorted so drop events still arrive in tick order.
760
+ const gapped = [...this.scheduledActions.keys()]
761
+ .filter((t) => t > this.lastServicedTick && t < servicedTick)
762
+ .sort((a, b) => a - b);
763
+ for (const t of gapped) {
764
+ const skipped = this.scheduledActions.get(t)!;
765
+ this.scheduledActions.delete(t);
766
+ for (const entry of skipped) {
767
+ this.debugEmit?.('input.schedule.dropped', { tick: t, action: entry.action });
768
+ }
769
+ }
770
+ }
771
+ this.lastServicedTick = servicedTick;
772
+ this.currentTick = servicedTick + 1;
163
773
  if (!this.enabled) {
164
- // Input suspended (editor-gated play mode). Unlike keyboard/mouse,
165
- // gamepad state isn't DOM-event-driven `isPressed`/`isJustPressed`/
166
- // `isJustReleased` read `this.gamepads` (and the edge-tracked sets)
167
- // directly, not a live poll of `navigator`. So gate the READ here: leave
168
- // `this.gamepads` empty so every gamepad-bound check sees no gamepads
169
- // and reports false. Deliberately leave the edge-tracking sets
774
+ // Input suspended by the editor's explicit gate (play stopped /
775
+ // game-tab-inactive). #144: this branch no longer covers real window
776
+ // blur an enabled-but-blurred tick falls through so MACHINE input
777
+ // (virtual actions, scheduled actuations) keeps flowing while the
778
+ // human's focus is elsewhere; real devices are gated further down.
779
+ // Unlike keyboard/mouse, gamepad state isn't DOM-event-driven
780
+ // `isPressed`/`isJustPressed`/`isJustReleased` read `this.gamepads`
781
+ // (and the edge-tracked sets) directly, not a live poll of `navigator`.
782
+ // So gate the READ here: leave `this.gamepads` empty so every
783
+ // gamepad-bound check sees no gamepads and reports false. Deliberately
784
+ // leave the edge-tracking sets
170
785
  // (gamepadButtons{Prev,Down,JustDown,JustUp}, gamepadAxes{Current,Prev})
171
786
  // untouched — they freeze at their last real values while disabled and
172
787
  // resume diffing against genuinely-last-observed hardware state once
173
788
  // re-enabled, so there's no phantom edge on re-enable (mirrors the
174
789
  // "flush held state, don't corrupt history" intent of setEnabled below).
790
+ // D15/T-D15.5: a tick serviced while gated drops its OWN scheduled
791
+ // entries too (never applied, never retried later) — same honesty line
792
+ // `setVirtualAction`'s own gate already draws — each emitting the same
793
+ // `'input.schedule.dropped'` event the gap-handling above does.
794
+ const gatedDrop = this.scheduledActions.get(servicedTick);
795
+ if (gatedDrop) {
796
+ this.scheduledActions.delete(servicedTick);
797
+ for (const entry of gatedDrop) {
798
+ this.debugEmit?.('input.schedule.dropped', { tick: servicedTick, action: entry.action });
799
+ }
800
+ }
801
+ this.gamepads = [];
802
+ return;
803
+ }
804
+ // Task 1.4 — promote queued tapVirtualAction() calls to this frame's
805
+ // "active" just-pressed set (hollowstone's queued/beginFrame contract,
806
+ // ported onto this engine's existing fixed-step poll()/endFrame()
807
+ // bracket instead of a bespoke beginFrame() — poll() already runs at the
808
+ // start of the fixed step). Cleared in endFrame() below.
809
+ this.virtualDigitalTapActive = this.virtualDigitalTapQueued;
810
+ this.virtualDigitalTapQueued = new Set();
811
+ // D15/T-D15.5 — apply any actuation scheduled for exactly this tick
812
+ // (spec §2.c: "applied at the START of the target tick's input phase",
813
+ // before any gameplay phase reads input — this still runs before every
814
+ // OTHER phase this tick, `input` being the first system phase). Placed
815
+ // AFTER the tap-queue promotion immediately above (not before) so a
816
+ // scheduled digital `true` can add itself to `virtualDigitalTapActive`
817
+ // (see `applyScheduledActionsForTick`) without that promotion's
818
+ // reassignment wiping it out.
819
+ this.applyScheduledActionsForTick(servicedTick);
820
+
821
+ if (!this.focused) {
822
+ // #144 — enabled but blurred: everything ABOVE this line is machine
823
+ // input (tap promotion, scheduled actuations) and stays live; real
824
+ // devices below gate exactly as the old combined branch did (no
825
+ // gamepads presented, edge sets frozen at their last real values).
826
+ // The post-gate trace still records — "what the sim actually
827
+ // consumed" includes machine input delivered while blurred.
175
828
  this.gamepads = [];
829
+ this.recordPostGateTick(servicedTick);
176
830
  return;
177
831
  }
832
+
178
833
  this.gamepads = navigator.getGamepads ? [...navigator.getGamepads()] : [];
179
834
 
180
835
  // Gamepad button edge detection: diff this frame's pressed set against the
@@ -194,6 +849,8 @@ export class InputManager {
194
849
  if (!current.has(key)) this.gamepadButtonsJustUp.add(key);
195
850
  }
196
851
  this.gamepadButtonsDown = current;
852
+ // F4 — a button press is unambiguous real gamepad input.
853
+ if (current.size > 0) this.lastActiveDevice = 'gamepad';
197
854
 
198
855
  // Snapshot this frame's raw axis values per (gamepad, axis) — independent
199
856
  // of any binding's direction/deadzone, mirroring the button snapshot
@@ -204,7 +861,11 @@ export class InputManager {
204
861
  const gp = this.gamepads[i];
205
862
  if (!gp) continue;
206
863
  for (let a = 0; a < gp.axes.length; a++) {
207
- currentAxes.set(`${i}:${a}`, gp.axes[a] ?? 0);
864
+ const value = gp.axes[a] ?? 0;
865
+ currentAxes.set(`${i}:${a}`, value);
866
+ // F4 — a stick pushed meaningfully off-center is also unambiguous
867
+ // real gamepad input (see `isStickActive`'s doc comment).
868
+ if (Math.abs(value) > 0.3) this.lastActiveDevice = 'gamepad';
208
869
  }
209
870
  }
210
871
  this.gamepadAxesCurrent = currentAxes;
@@ -215,6 +876,102 @@ export class InputManager {
215
876
  this.mouseDeltaX += this.lookStickX * scale;
216
877
  this.mouseDeltaY += this.lookStickY * scale;
217
878
  }
879
+ // D15/T-D15.5 — tap the POST-GATE resolved values for this tick LAST
880
+ // (every binding/virtual/scheduled contribution above has already been
881
+ // folded in): "what the sim actually consumed", the honesty line the
882
+ // design doc draws for record-now/replay-in-SP5 (§2.c).
883
+ this.recordPostGateTick(servicedTick);
884
+ }
885
+
886
+ /**
887
+ * D15/T-D15.5 — apply every actuation `scheduleActionAtTick` queued for
888
+ * exactly `tick`, then forget them (a tick is serviced at most once).
889
+ *
890
+ * Digital `true` gets SPECIAL handling (the objection-1 fix): it promotes
891
+ * the action into this tick's `virtualDigitalTapActive` set directly — the
892
+ * same one-tick "active" pulse `tapVirtualAction` uses — so `isJustPressed`
893
+ * reads true for EXACTLY this tick (false on the next, per `endFrame()`'s
894
+ * clear) while `isPressed` keeps reading true afterward (still held) until
895
+ * a later scheduled/explicit `false` releases it. This direct write is
896
+ * safe here (unlike `applyVirtualActionValue`'s equivalent direct-path
897
+ * case — see its doc comment) specifically because this method only ever
898
+ * runs from inside `poll()`, AFTER `poll()`'s own tap-queue-promotion
899
+ * reassignment of `virtualDigitalTapActive` has already happened for this
900
+ * tick (see `poll()`'s own comment on why it's placed after) — so there's
901
+ * no risk of this write being silently wiped by that reassignment.
902
+ *
903
+ * pulse-runner friction #4: a prior revision of this comment
904
+ * used to say `setVirtualAction`'s plain write "never produces a
905
+ * just-pressed edge at all" and that the edge fix landed ONLY here, for
906
+ * this scheduled path — true at the time, and exactly the gap a blind
907
+ * dogfood run (pulse-runner) hit through the direct `setVirtualAction`/
908
+ * `holdFor` path. `applyVirtualActionValue` now manufactures the same kind
909
+ * of edge for THAT path too (queued instead of written directly, for the
910
+ * timing reason above) — this is no longer the only path that does.
911
+ *
912
+ * A digital `false` here simply releases the hold — no edge is
913
+ * manufactured for it at a SCHEDULED tick (no caller needs a virtual
914
+ * `isJustReleased` fired at an exact future tick today); the direct
915
+ * `setVirtualAction(action, false)`/`clearVirtualActions()` paths DO
916
+ * manufacture one now (see `applyVirtualActionValue`/
917
+ * `clearVirtualActionState`). Scalar/vector2 values apply directly,
918
+ * unchanged.
919
+ */
920
+ private applyScheduledActionsForTick(tick: number): void {
921
+ const pending = this.scheduledActions.get(tick);
922
+ if (!pending) return;
923
+ this.scheduledActions.delete(tick);
924
+ for (const entry of pending) {
925
+ if (typeof entry.value === 'boolean') {
926
+ if (entry.value) {
927
+ this.virtualDigitalHeld.add(entry.action);
928
+ this.virtualDigitalTapActive.add(entry.action);
929
+ } else {
930
+ this.virtualDigitalHeld.delete(entry.action);
931
+ }
932
+ } else {
933
+ this.applyVirtualActionValue(entry.action, entry.value);
934
+ }
935
+ }
936
+ }
937
+
938
+ /** D15/T-D15.5 — append this tick's action-value DELTA to the recording
939
+ * ring, iff a recording is active. Reads every declared digital/scalar/
940
+ * vector2 action through the exact same typed getters gameplay code would
941
+ * (`readAction`), so the trace reflects what a component reading these
942
+ * actions THIS tick actually sees post-gate — never a raw/ungated value.
943
+ * `pointerDelta`/`pointerPosition` actions are skipped (a different,
944
+ * frame-accumulated shape, and not part of the virtual-action/schedule
945
+ * surface this trace exists to prove).
946
+ *
947
+ * Two replay-affecting caveats a future SP5 consumer must know:
948
+ * 1. A gated stretch never calls this at all (`poll()`'s gated branch
949
+ * returns before reaching it) — no entries are recorded for ticks
950
+ * input was suspended. Any change only OBSERVABLE once re-enabled (a
951
+ * delta against `lastTraceSnapshot`) lands as a delta AT the re-enable
952
+ * tick, not at whatever tick it actually happened.
953
+ * 2. This ring is capped (`INPUT_TRACE_CAP`) and evicts via `shift()` once
954
+ * it fills — dropping the OLDEST entry. Because entries are DELTAS,
955
+ * evicting the earliest entries drops the base state later deltas were
956
+ * relative to — this ring is not safe to replay from its start once it
957
+ * has ever been capped. */
958
+ private recordPostGateTick(tick: number): void {
959
+ if (!this.inputRecording) return;
960
+ const deltas: Record<string, boolean | number | { x: number; y: number }> = {};
961
+ let changed = false;
962
+ for (const name of this.actionNames()) {
963
+ const type = this.getActionValueType(name);
964
+ if (type !== 'digital' && type !== 'scalar' && type !== 'vector2') continue;
965
+ const value = this.readAction(name, type);
966
+ const prev = this.lastTraceSnapshot.get(name);
967
+ if (actionValuesEqual(prev, value)) continue;
968
+ deltas[name] = value;
969
+ this.lastTraceSnapshot.set(name, value);
970
+ changed = true;
971
+ }
972
+ if (!changed) return;
973
+ this.inputTrace.push({ tick, actions: deltas });
974
+ if (this.inputTrace.length > INPUT_TRACE_CAP) this.inputTrace.shift();
218
975
  }
219
976
 
220
977
  /** Has a `gamepad_axis` binding's raw value crossed its direction+deadzone
@@ -229,6 +986,12 @@ export class InputManager {
229
986
 
230
987
  /** Is an action currently held down? */
231
988
  isPressed(actionName: string): boolean {
989
+ this.assertValueType(actionName, 'digital', 'isPressed');
990
+ // Task 1.4 — a held virtual action, or a tap promoted to this frame's
991
+ // "active" set, ORs into isPressed alongside binding contributions.
992
+ if (this.virtualDigitalHeld.has(actionName) || this.virtualDigitalTapActive.has(actionName)) {
993
+ return true;
994
+ }
232
995
  const bindings = this.actions.get(actionName);
233
996
  if (!bindings) return false;
234
997
 
@@ -254,6 +1017,9 @@ export class InputManager {
254
1017
  }
255
1018
  break;
256
1019
  }
1020
+ case 'touch_button':
1021
+ if (this.touchButtonsDown.has(binding.sourceId)) return true;
1022
+ break;
257
1023
  }
258
1024
  }
259
1025
  return false;
@@ -261,6 +1027,11 @@ export class InputManager {
261
1027
 
262
1028
  /** Was an action pressed this frame (not held from previous)? */
263
1029
  isJustPressed(actionName: string): boolean {
1030
+ this.assertValueType(actionName, 'digital', 'isJustPressed');
1031
+ // Task 1.4 — a tapVirtualAction() queued on a prior frame reads
1032
+ // just-pressed for exactly the one poll()/endFrame() bracket it was
1033
+ // promoted into (see poll()/endFrame() below).
1034
+ if (this.virtualDigitalTapActive.has(actionName)) return true;
264
1035
  const bindings = this.actions.get(actionName);
265
1036
  if (!bindings) return false;
266
1037
 
@@ -294,6 +1065,9 @@ export class InputManager {
294
1065
  }
295
1066
  break;
296
1067
  }
1068
+ case 'touch_button':
1069
+ if (this.touchButtonsJustDown.has(binding.sourceId)) return true;
1070
+ break;
297
1071
  }
298
1072
  }
299
1073
  return false;
@@ -301,6 +1075,12 @@ export class InputManager {
301
1075
 
302
1076
  /** Was an action released this frame (held last frame, up now)? */
303
1077
  isJustReleased(actionName: string): boolean {
1078
+ this.assertValueType(actionName, 'digital', 'isJustReleased');
1079
+ // pulse-runner friction #4 — mirrors isJustPressed's virtual check above:
1080
+ // a direct setVirtualAction(action, false) or clearVirtualActions() on a
1081
+ // held action manufactures exactly one just-released edge here, matching
1082
+ // a real key-up's keysJustUp.
1083
+ if (this.virtualDigitalJustReleased.has(actionName)) return true;
304
1084
  const bindings = this.actions.get(actionName);
305
1085
  if (!bindings) return false;
306
1086
 
@@ -331,11 +1111,472 @@ export class InputManager {
331
1111
  }
332
1112
  break;
333
1113
  }
1114
+ case 'touch_button':
1115
+ if (this.touchButtonsJustUp.has(binding.sourceId)) return true;
1116
+ break;
334
1117
  }
335
1118
  }
336
1119
  return false;
337
1120
  }
338
1121
 
1122
+ /** Device/source metadata (F1 AC) for a digital action: which binding is
1123
+ * CURRENTLY satisfying `isPressed`, or `null` if none is (including an
1124
+ * unknown action). First-satisfied-binding-wins, mirroring isPressed's own
1125
+ * iteration order. */
1126
+ getDigitalSource(actionName: string): ActionValueSource | null {
1127
+ this.assertValueType(actionName, 'digital', 'getDigitalSource');
1128
+ // #144 — mirror isPressed's own pre-gate virtual check: a machine-held
1129
+ // action reports its source even while the window is blurred.
1130
+ if (
1131
+ this.machineInputActive &&
1132
+ (this.virtualDigitalHeld.has(actionName) || this.virtualDigitalTapActive.has(actionName))
1133
+ ) {
1134
+ return { bindingType: 'virtual' };
1135
+ }
1136
+ if (!this.inputActive) return null;
1137
+ const bindings = this.actions.get(actionName);
1138
+ if (!bindings) return null;
1139
+ for (const binding of bindings) {
1140
+ switch (binding.type) {
1141
+ case 'key':
1142
+ if (this.keysDown.has(binding.code)) return { bindingType: 'key' };
1143
+ break;
1144
+ case 'mouse_button':
1145
+ if (this.mouseButtons.has(binding.button)) return { bindingType: 'mouse_button' };
1146
+ break;
1147
+ case 'gamepad_button':
1148
+ for (let i = 0; i < this.gamepads.length; i++) {
1149
+ const gp = this.gamepads[i];
1150
+ if (gp?.buttons[binding.button]?.pressed) {
1151
+ return { bindingType: 'gamepad_button', gamepadIndex: i, gamepadId: gp.id };
1152
+ }
1153
+ }
1154
+ break;
1155
+ case 'gamepad_axis': {
1156
+ const dz = binding.deadzone ?? 0.15;
1157
+ for (let i = 0; i < this.gamepads.length; i++) {
1158
+ const gp = this.gamepads[i];
1159
+ if (!gp) continue;
1160
+ const val = gp.axes[binding.axis] ?? 0;
1161
+ if (InputManager.axisThresholdMet(val, binding.direction, dz)) {
1162
+ return { bindingType: 'gamepad_axis', gamepadIndex: i, gamepadId: gp.id };
1163
+ }
1164
+ }
1165
+ break;
1166
+ }
1167
+ case 'touch_button':
1168
+ if (this.touchButtonsDown.has(binding.sourceId)) {
1169
+ return { bindingType: 'touch_button', sourceId: binding.sourceId };
1170
+ }
1171
+ break;
1172
+ }
1173
+ }
1174
+ return null;
1175
+ }
1176
+
1177
+ // ==========================================================================
1178
+ // F1 — typed scalar/vector2/pointer reads. See the class doc comment above
1179
+ // for the deadzone/normalize/combine rules these implement.
1180
+ // ==========================================================================
1181
+
1182
+ /** 1D deadzone rescale: magnitudes below `deadzone` become 0; magnitudes
1183
+ * at/above it rescale from 0 (at the deadzone edge) to ±1 (at |raw| = 1),
1184
+ * sign preserved. `raw` is clamped to [-1, 1] first so an out-of-range
1185
+ * input can't overshoot past ±1 the other end. */
1186
+ private static rescaleScalar(raw: number, deadzone: number): number {
1187
+ const clamped = Math.max(-1, Math.min(1, raw));
1188
+ const mag = Math.abs(clamped);
1189
+ if (mag < deadzone) return 0;
1190
+ const rescaled = deadzone >= 1 ? 0 : (mag - deadzone) / (1 - deadzone);
1191
+ return Math.sign(clamped) * Math.min(1, rescaled);
1192
+ }
1193
+
1194
+ /** Radial deadzone + unit-circle normalization for a Vector2: magnitude
1195
+ * below `deadzone` snaps to {0,0}; at/above it, direction is preserved and
1196
+ * magnitude is rescaled the same way `rescaleScalar` rescales a 1D
1197
+ * magnitude (never renormalized UP past 1 — only ever scaled down). */
1198
+ private static rescaleVector2(raw: Vector2, deadzone: number): Vector2 {
1199
+ const mag = Math.hypot(raw.x, raw.y);
1200
+ if (mag === 0 || mag < deadzone) return { x: 0, y: 0 };
1201
+ const rescaledMag = Math.min(1, deadzone >= 1 ? 0 : (mag - deadzone) / (1 - deadzone));
1202
+ const scale = rescaledMag / mag;
1203
+ return { x: raw.x * scale, y: raw.y * scale };
1204
+ }
1205
+
1206
+ /** Clamp a Vector2's magnitude to at most 1 (unit circle), preserving
1207
+ * direction — the second half of the vector2 "sum-then-clamp" combine
1208
+ * rule (a diagonal sum like {0.8, 0.8} has magnitude > 1). */
1209
+ private static clampVector2(v: Vector2): Vector2 {
1210
+ const mag = Math.hypot(v.x, v.y);
1211
+ if (mag <= 1 || mag === 0) return v;
1212
+ return { x: v.x / mag, y: v.y / mag };
1213
+ }
1214
+
1215
+ /** Every `scalar`-relevant binding's (deadzone-applied) contribution for
1216
+ * `actionName`, one entry per binding that can produce a scalar value —
1217
+ * `gamepad_axis` (real device, already implemented pre-F1 for digital
1218
+ * thresholding — F1 additionally reads its raw signed value for scalar
1219
+ * actions, ignoring the binding's `direction` field, which is a digital-
1220
+ * only hint) and `test_axis` (F1's injected-test-input source). `mouse_move`
1221
+ * deliberately does NOT feed scalar (spec §12 F2 — no natural single-axis
1222
+ * selector field is defined for it; it feeds pointerDelta/vector2 only —
1223
+ * see `collectPointerDeltaContributions`/`collectVector2Contributions`). */
1224
+ private collectScalarContributions(
1225
+ actionName: string,
1226
+ ): { value: number; source: ActionValueSource }[] {
1227
+ const out: { value: number; source: ActionValueSource }[] = [];
1228
+ // Task 1.4 — a virtual scalar value joins the same max-magnitude combine
1229
+ // real bindings do (computeScalar below). #144: it passes the MACHINE
1230
+ // gate (enabled only), so a bot's held value reads through while the
1231
+ // window is blurred; real bindings below keep the full focus gate.
1232
+ const virtual = this.virtualScalarValues.get(actionName);
1233
+ if (virtual !== undefined && this.machineInputActive) {
1234
+ out.push({ value: virtual, source: { bindingType: 'virtual' } });
1235
+ }
1236
+ if (!this.inputActive) return out;
1237
+ const bindings = this.actions.get(actionName);
1238
+ if (!bindings) return out;
1239
+ for (const binding of bindings) {
1240
+ if (binding.type === 'gamepad_axis') {
1241
+ const dz = binding.deadzone ?? 0.15;
1242
+ for (let i = 0; i < this.gamepads.length; i++) {
1243
+ const gp = this.gamepads[i];
1244
+ if (!gp) continue;
1245
+ const raw = gp.axes[binding.axis] ?? 0;
1246
+ out.push({
1247
+ value: InputManager.rescaleScalar(raw, dz),
1248
+ source: { bindingType: 'gamepad_axis', gamepadIndex: i, gamepadId: gp.id },
1249
+ });
1250
+ }
1251
+ } else if (binding.type === 'test_axis') {
1252
+ const raw = this.testAxisValues.get(binding.sourceId) ?? 0;
1253
+ out.push({
1254
+ value: InputManager.rescaleScalar(raw, binding.deadzone ?? 0),
1255
+ source: { bindingType: 'test_axis', sourceId: binding.sourceId },
1256
+ });
1257
+ }
1258
+ }
1259
+ return out;
1260
+ }
1261
+
1262
+ private computeScalar(actionName: string): { value: number; source: ActionValueSource | null } {
1263
+ this.assertValueType(actionName, 'scalar', 'getScalar');
1264
+ let best: { value: number; source: ActionValueSource } | null = null;
1265
+ for (const c of this.collectScalarContributions(actionName)) {
1266
+ if (!best || Math.abs(c.value) > Math.abs(best.value)) best = c;
1267
+ }
1268
+ if (!best || best.value === 0) return { value: 0, source: null };
1269
+ return best;
1270
+ }
1271
+
1272
+ /** Read a `'scalar'`-valueType action's current value: max-magnitude across
1273
+ * contributing (deadzone-applied) bindings, sign preserved (combine rule —
1274
+ * see the class doc comment). Throws if the action is declared a
1275
+ * different valueType. */
1276
+ getScalar(actionName: string): number {
1277
+ return this.computeScalar(actionName).value;
1278
+ }
1279
+
1280
+ /** Device/source metadata for {@link getScalar} — which binding is
1281
+ * currently driving the combined value, or `null` if every contribution is
1282
+ * zero (including an unknown action). */
1283
+ getScalarSource(actionName: string): ActionValueSource | null {
1284
+ return this.computeScalar(actionName).source;
1285
+ }
1286
+
1287
+ /** `gamepad_axis_pair` contribution(s) for {@link collectVector2Contributions} — one entry
1288
+ * per connected gamepad, deadzone-rescaled (default 0.15, matching `gamepad_axis`). Broken
1289
+ * out to a helper so the per-gamepad loop doesn't add to the main dispatcher's complexity. */
1290
+ private contributeGamepadAxisPair(
1291
+ binding: Extract<InputBinding, { type: 'gamepad_axis_pair' }>,
1292
+ ): { value: Vector2; source: ActionValueSource }[] {
1293
+ const dz = binding.deadzone ?? 0.15;
1294
+ const out: { value: Vector2; source: ActionValueSource }[] = [];
1295
+ for (let i = 0; i < this.gamepads.length; i++) {
1296
+ const gp = this.gamepads[i];
1297
+ if (!gp) continue;
1298
+ const raw = { x: gp.axes[binding.xAxis] ?? 0, y: gp.axes[binding.yAxis] ?? 0 };
1299
+ out.push({
1300
+ value: InputManager.rescaleVector2(raw, dz),
1301
+ source: { bindingType: 'gamepad_axis_pair', gamepadIndex: i, gamepadId: gp.id },
1302
+ });
1303
+ }
1304
+ return out;
1305
+ }
1306
+
1307
+ /** Every `vector2`-relevant binding's (deadzone-applied) contribution:
1308
+ * `test_vector2` (F1's injected-test-input source), plus F2's real-device
1309
+ * cases — `gamepad_axis_pair` (see {@link contributeGamepadAxisPair}), `mouse_move` (the
1310
+ * accumulated mouse delta, deadzone default 0 — raw pixel deltas have no natural "at rest"
1311
+ * jitter floor the way an analog stick does), and `touch_stick` (a virtual on-screen stick,
1312
+ * deadzone default 0). */
1313
+ private collectVector2Contributions(
1314
+ actionName: string,
1315
+ ): { value: Vector2; source: ActionValueSource }[] {
1316
+ const out: { value: Vector2; source: ActionValueSource }[] = [];
1317
+ // Task 1.4 — a virtual vector2 value joins the same sum-then-clamp
1318
+ // combine real bindings do (computeVector2 below). #144: machine gate
1319
+ // only, same as the scalar collector above.
1320
+ const virtual = this.virtualVector2Values.get(actionName);
1321
+ if (virtual !== undefined && this.machineInputActive) {
1322
+ out.push({ value: virtual, source: { bindingType: 'virtual' } });
1323
+ }
1324
+ if (!this.inputActive) return out;
1325
+ const bindings = this.actions.get(actionName);
1326
+ if (!bindings) return out;
1327
+ for (const binding of bindings) {
1328
+ switch (binding.type) {
1329
+ case 'test_vector2': {
1330
+ const raw = this.testVector2Values.get(binding.sourceId) ?? { x: 0, y: 0 };
1331
+ out.push({
1332
+ value: InputManager.rescaleVector2(raw, binding.deadzone ?? 0),
1333
+ source: { bindingType: 'test_vector2', sourceId: binding.sourceId },
1334
+ });
1335
+ break;
1336
+ }
1337
+ case 'gamepad_axis_pair':
1338
+ out.push(...this.contributeGamepadAxisPair(binding));
1339
+ break;
1340
+ case 'mouse_move': {
1341
+ const raw = { x: this.mouseDeltaX, y: this.mouseDeltaY };
1342
+ out.push({
1343
+ value: InputManager.rescaleVector2(raw, binding.deadzone ?? 0),
1344
+ source: { bindingType: 'mouse_move' },
1345
+ });
1346
+ break;
1347
+ }
1348
+ case 'touch_stick': {
1349
+ const raw = this.touchStickValues.get(binding.sourceId) ?? { x: 0, y: 0 };
1350
+ out.push({
1351
+ value: InputManager.rescaleVector2(raw, binding.deadzone ?? 0),
1352
+ source: { bindingType: 'touch_stick', sourceId: binding.sourceId },
1353
+ });
1354
+ break;
1355
+ }
1356
+ }
1357
+ }
1358
+ return out;
1359
+ }
1360
+
1361
+ private computeVector2(actionName: string): { value: Vector2; source: ActionValueSource | null } {
1362
+ this.assertValueType(actionName, 'vector2', 'getVector2');
1363
+ let sum: Vector2 = { x: 0, y: 0 };
1364
+ let bestSource: ActionValueSource | null = null;
1365
+ let bestMag = 0;
1366
+ for (const c of this.collectVector2Contributions(actionName)) {
1367
+ sum = { x: sum.x + c.value.x, y: sum.y + c.value.y };
1368
+ const mag = Math.hypot(c.value.x, c.value.y);
1369
+ if (mag > bestMag) {
1370
+ bestMag = mag;
1371
+ bestSource = c.source;
1372
+ }
1373
+ }
1374
+ const clamped = InputManager.clampVector2(sum);
1375
+ if (clamped.x === 0 && clamped.y === 0) return { value: clamped, source: null };
1376
+ return { value: clamped, source: bestSource };
1377
+ }
1378
+
1379
+ /** Read a `'vector2'`-valueType action's current value: sum contributing
1380
+ * (deadzone-applied) {x,y} values component-wise, then clamp to the unit
1381
+ * circle (combine rule — see the class doc comment). Throws if the action
1382
+ * is declared a different valueType. */
1383
+ getVector2(actionName: string): Vector2 {
1384
+ return this.computeVector2(actionName).value;
1385
+ }
1386
+
1387
+ /** Device/source metadata for {@link getVector2} — the contributing binding
1388
+ * with the largest (pre-combine) magnitude, or `null` if the combined
1389
+ * value is {0,0} (including an unknown action). */
1390
+ getVector2Source(actionName: string): ActionValueSource | null {
1391
+ return this.computeVector2(actionName).source;
1392
+ }
1393
+
1394
+ /** Every `pointerDelta`-relevant binding's contribution: `test_pointer_delta`
1395
+ * (F1's injected-test-input source) and — F2 — `mouse_move`, reading the
1396
+ * real accumulated `mouseDeltaX/Y` (the same accumulator `onMouseMove`
1397
+ * fills and `getMouseDelta()` reads). No deadzone for either (deltas are
1398
+ * not deadzoned). */
1399
+ private collectPointerDeltaContributions(
1400
+ actionName: string,
1401
+ ): { value: Vector2; source: ActionValueSource }[] {
1402
+ if (!this.inputActive) return [];
1403
+ const bindings = this.actions.get(actionName);
1404
+ if (!bindings) return [];
1405
+ const out: { value: Vector2; source: ActionValueSource }[] = [];
1406
+ for (const binding of bindings) {
1407
+ if (binding.type === 'test_pointer_delta') {
1408
+ const value = this.testPointerDeltaAccum.get(binding.sourceId) ?? { x: 0, y: 0 };
1409
+ out.push({
1410
+ value,
1411
+ source: { bindingType: 'test_pointer_delta', sourceId: binding.sourceId },
1412
+ });
1413
+ } else if (binding.type === 'mouse_move') {
1414
+ out.push({
1415
+ value: { x: this.mouseDeltaX, y: this.mouseDeltaY },
1416
+ source: { bindingType: 'mouse_move' },
1417
+ });
1418
+ }
1419
+ }
1420
+ return out;
1421
+ }
1422
+
1423
+ private computePointerDelta(actionName: string): {
1424
+ value: Vector2;
1425
+ source: ActionValueSource | null;
1426
+ } {
1427
+ this.assertValueType(actionName, 'pointerDelta', 'getPointerDelta');
1428
+ let sum: Vector2 = { x: 0, y: 0 };
1429
+ let bestSource: ActionValueSource | null = null;
1430
+ let bestMag = 0;
1431
+ for (const c of this.collectPointerDeltaContributions(actionName)) {
1432
+ sum = { x: sum.x + c.value.x, y: sum.y + c.value.y };
1433
+ const mag = Math.hypot(c.value.x, c.value.y);
1434
+ if (mag > bestMag) {
1435
+ bestMag = mag;
1436
+ bestSource = c.source;
1437
+ }
1438
+ }
1439
+ if (sum.x === 0 && sum.y === 0) return { value: sum, source: null };
1440
+ return { value: sum, source: bestSource };
1441
+ }
1442
+
1443
+ /** Read a `'pointerDelta'`-valueType action's this-frame movement: sum of
1444
+ * contributing bindings' deltas (combine rule — see the class doc
1445
+ * comment). Throws if the action is declared a different valueType. */
1446
+ getPointerDelta(actionName: string): Vector2 {
1447
+ return this.computePointerDelta(actionName).value;
1448
+ }
1449
+
1450
+ /** Device/source metadata for {@link getPointerDelta}. */
1451
+ getPointerDeltaSource(actionName: string): ActionValueSource | null {
1452
+ return this.computePointerDelta(actionName).source;
1453
+ }
1454
+
1455
+ /** Every `pointerPosition`-relevant binding's contribution, each tagged
1456
+ * with the injection-order sequence number used to break ties (last-
1457
+ * write-wins) — today only `test_pointer_position` (F1's injected-test-
1458
+ * input source). */
1459
+ private collectPointerPositionContributions(
1460
+ actionName: string,
1461
+ ): { value: Vector2; seq: number; source: ActionValueSource }[] {
1462
+ if (!this.inputActive) return [];
1463
+ const bindings = this.actions.get(actionName);
1464
+ if (!bindings) return [];
1465
+ const out: { value: Vector2; seq: number; source: ActionValueSource }[] = [];
1466
+ for (const binding of bindings) {
1467
+ if (binding.type === 'test_pointer_position') {
1468
+ const entry = this.testPointerPositionValues.get(binding.sourceId);
1469
+ if (entry) {
1470
+ out.push({
1471
+ value: entry.value,
1472
+ seq: entry.seq,
1473
+ source: { bindingType: 'test_pointer_position', sourceId: binding.sourceId },
1474
+ });
1475
+ }
1476
+ }
1477
+ }
1478
+ return out;
1479
+ }
1480
+
1481
+ private computePointerPosition(actionName: string): {
1482
+ value: Vector2;
1483
+ source: ActionValueSource | null;
1484
+ } {
1485
+ this.assertValueType(actionName, 'pointerPosition', 'getPointerPosition');
1486
+ const contributions = this.collectPointerPositionContributions(actionName);
1487
+ if (contributions.length === 0) return { value: { x: 0, y: 0 }, source: null };
1488
+ let best = contributions[0]!;
1489
+ for (const c of contributions) {
1490
+ if (c.seq > best.seq) best = c;
1491
+ }
1492
+ return { value: best.value, source: best.source };
1493
+ }
1494
+
1495
+ /** Read a `'pointerPosition'`-valueType action's current absolute position:
1496
+ * last-write-wins across contributing bindings (combine rule — see the
1497
+ * class doc comment; position isn't additive, so it is never summed).
1498
+ * Throws if the action is declared a different valueType. */
1499
+ getPointerPosition(actionName: string): Vector2 {
1500
+ return this.computePointerPosition(actionName).value;
1501
+ }
1502
+
1503
+ /** Device/source metadata for {@link getPointerPosition} — the most
1504
+ * recently-written contributing source. */
1505
+ getPointerPositionSource(actionName: string): ActionValueSource | null {
1506
+ return this.computePointerPosition(actionName).source;
1507
+ }
1508
+
1509
+ /** Generic typed read (F1): dispatches to the matching typed getter based
1510
+ * on `expectedType`, returning `ActionValueOf<T>` — a caller with a
1511
+ * statically-known action-value-type map gets a fully-inferred, non-`any`
1512
+ * return type. Throws the same mismatch error as the dedicated getters if
1513
+ * the action's declared valueType disagrees with `expectedType`. */
1514
+ readAction<T extends ActionValueType>(actionName: string, expectedType: T): ActionValueOf<T> {
1515
+ switch (expectedType) {
1516
+ case 'digital':
1517
+ return this.isPressed(actionName) as ActionValueOf<T>;
1518
+ case 'scalar':
1519
+ return this.getScalar(actionName) as ActionValueOf<T>;
1520
+ case 'vector2':
1521
+ return this.getVector2(actionName) as ActionValueOf<T>;
1522
+ case 'pointerDelta':
1523
+ return this.getPointerDelta(actionName) as ActionValueOf<T>;
1524
+ case 'pointerPosition':
1525
+ return this.getPointerPosition(actionName) as ActionValueOf<T>;
1526
+ default:
1527
+ throw new Error(`InputManager.readAction: unknown value type "${expectedType as string}"`);
1528
+ }
1529
+ }
1530
+
1531
+ /** Inject a synthetic scalar value for a named test source (F1's injected-
1532
+ * test-input hook — also the shape of F2's "injected test input" backend).
1533
+ * Bind an action to `{ type: 'test_axis', sourceId }` (`valueType:
1534
+ * 'scalar'`) to read it back via {@link getScalar}. The value PERSISTS
1535
+ * across frames (like a real analog stick held in position) until changed
1536
+ * or cleared via {@link clearAxis}. */
1537
+ injectAxis(sourceId: string, value: number): void {
1538
+ this.testAxisValues.set(sourceId, value);
1539
+ }
1540
+
1541
+ /** Clear an injected scalar source (e.g. simulating an at-rest/disconnected
1542
+ * device). */
1543
+ clearAxis(sourceId: string): void {
1544
+ this.testAxisValues.delete(sourceId);
1545
+ }
1546
+
1547
+ /** Inject a synthetic {x,y} value for a named test source. Bind an action
1548
+ * to `{ type: 'test_vector2', sourceId }` (`valueType: 'vector2'`) to read
1549
+ * it back via {@link getVector2}. Persists across frames until changed or
1550
+ * cleared via {@link clearVector2}. */
1551
+ injectVector2(sourceId: string, value: Vector2): void {
1552
+ this.testVector2Values.set(sourceId, value);
1553
+ }
1554
+
1555
+ /** Clear an injected Vector2 source. */
1556
+ clearVector2(sourceId: string): void {
1557
+ this.testVector2Values.delete(sourceId);
1558
+ }
1559
+
1560
+ /** Accumulate a synthetic pointer delta for a named test source — mirrors
1561
+ * real mouse-move delta accumulation (`onMouseMove` above): multiple calls
1562
+ * within the same frame sum, and the accumulator is cleared in
1563
+ * {@link endFrame}. Bind an action to `{ type: 'test_pointer_delta',
1564
+ * sourceId }` (`valueType: 'pointerDelta'`) to read it back via
1565
+ * {@link getPointerDelta}. */
1566
+ injectPointerDelta(sourceId: string, delta: Vector2): void {
1567
+ const prev = this.testPointerDeltaAccum.get(sourceId) ?? { x: 0, y: 0 };
1568
+ this.testPointerDeltaAccum.set(sourceId, { x: prev.x + delta.x, y: prev.y + delta.y });
1569
+ }
1570
+
1571
+ /** Set a synthetic absolute pointer position for a named test source.
1572
+ * Persists (last-write-wins across contributing sources on read — see the
1573
+ * pointerPosition combine rule) until changed. Bind an action to `{ type:
1574
+ * 'test_pointer_position', sourceId }` (`valueType: 'pointerPosition'`) to
1575
+ * read it back via {@link getPointerPosition}. */
1576
+ injectPointerPosition(sourceId: string, value: Vector2): void {
1577
+ this.testPointerPositionValues.set(sourceId, { value, seq: ++this.pointerPositionSeq });
1578
+ }
1579
+
339
1580
  /** Whether input capture is currently enabled (see {@link setEnabled}). */
340
1581
  isEnabled(): boolean {
341
1582
  return this.enabled;
@@ -344,24 +1585,46 @@ export class InputManager {
344
1585
  /**
345
1586
  * Enable/disable input capture. The editor host disables the running game's
346
1587
  * input while its viewport isn't focused so keystrokes don't leak in from
347
- * the editor. Disabling clears all held/transient keyboard/mouse state so
348
- * nothing sticks (e.g. a held movement key when you switch back to the
349
- * Scene tab mid-flight) — gamepad state doesn't need clearing here since
350
- * `poll()` itself stops reading it while disabled (see poll()'s doc comment).
1588
+ * the editor. Disabling clears all held/transient keyboard/mouse/touch-button
1589
+ * state (via {@link flushHeldState}) so nothing sticks (e.g. a held movement
1590
+ * key when you switch back to the Scene tab mid-flight) — gamepad state
1591
+ * doesn't need clearing here since `poll()` itself stops reading it while
1592
+ * disabled (see poll()'s doc comment).
351
1593
  */
352
1594
  setEnabled(enabled: boolean) {
353
1595
  if (this.enabled === enabled) return;
354
1596
  this.enabled = enabled;
355
- if (!enabled) {
356
- this.keysDown.clear();
357
- this.keysJustDown.clear();
358
- this.keysJustUp.clear();
359
- this.mouseButtons.clear();
360
- this.mouseButtonsJustDown.clear();
361
- this.mouseButtonsJustUp.clear();
362
- this.mouseDeltaX = 0;
363
- this.mouseDeltaY = 0;
364
- }
1597
+ if (!enabled) this.flushHeldState();
1598
+ }
1599
+
1600
+ /** Whether the window/tab is currently focused (F2, spec §12 "focus
1601
+ * gating") — `false` while blurred (real `blur`/`focus` events; see the
1602
+ * constructor). Distinct from {@link isEnabled}, which is the editor's
1603
+ * explicit play-mode gate. */
1604
+ isFocused(): boolean {
1605
+ return this.focused;
1606
+ }
1607
+
1608
+ /** Whether the pointer is currently locked to {@link requestPointerLock}'s
1609
+ * element (F2, spec §12 "pointer lock") — tracked via the real
1610
+ * `pointerlockchange` event. */
1611
+ isPointerLocked(): boolean {
1612
+ return this.pointerLocked;
1613
+ }
1614
+
1615
+ /** Device identity for a connected gamepad slot (F2, spec §12 "expose
1616
+ * device identity" + "prefer the standard mapping"), or `null` if nothing
1617
+ * is connected at `index` (including while input is disabled/unfocused,
1618
+ * since `poll()` empties `this.gamepads` then). `mapping` is the Gamepad
1619
+ * API's own `'standard'`/`''` value — bindings author button/axis indices
1620
+ * assuming the W3C Standard Gamepad layout, so a non-`'standard'` mapping
1621
+ * means those indices may not correspond as documented. */
1622
+ getGamepadInfo(
1623
+ index: number,
1624
+ ): { index: number; id: string; mapping: string; connected: boolean } | null {
1625
+ const gp = this.gamepads[index];
1626
+ if (!gp) return null;
1627
+ return { index, id: gp.id, mapping: gp.mapping, connected: gp.connected };
365
1628
  }
366
1629
 
367
1630
  /** Get mouse movement delta this frame */
@@ -369,6 +1632,278 @@ export class InputManager {
369
1632
  return { x: this.mouseDeltaX, y: this.mouseDeltaY };
370
1633
  }
371
1634
 
1635
+ /**
1636
+ * F4 (spec §12 "Schema and Examples" — the F3 prompt-switching gap): which
1637
+ * device family most recently produced real input, or `null` before any
1638
+ * input has arrived. A prompt UI polls this (or lets {@link getPrompt}
1639
+ * consult it automatically by omitting `device`) to switch its displayed
1640
+ * label as the player switches controllers — e.g. showing `'Space'` while
1641
+ * they're on keyboard, then `'A'` the moment they touch a gamepad.
1642
+ */
1643
+ getLastActiveDevice(): PromptDevice | null {
1644
+ return this.lastActiveDevice;
1645
+ }
1646
+
1647
+ // ==========================================================================
1648
+ // F3 — rebinding: list/replace/add/remove/reset/persist. See the class doc
1649
+ // comment above for the full design note.
1650
+ // ==========================================================================
1651
+
1652
+ /** The current bindings for `actionName` (F3 AC "listed") — a defensive
1653
+ * deep clone, so mutating the returned array/objects never affects the
1654
+ * live binding list. `[]` for an unknown/never-registered action. */
1655
+ getBindings(actionName: string): InputBinding[] {
1656
+ const bindings = this.actions.get(actionName);
1657
+ return bindings ? structuredClone(bindings) : [];
1658
+ }
1659
+
1660
+ /** Wholesale-replace `actionName`'s bindings array (F3 AC "replaced"). Does
1661
+ * NOT touch the reset-to-defaults snapshot — see `resetBindings`. A no-op
1662
+ * target for an action that was never registered is allowed (it simply
1663
+ * registers one with `valueType: 'digital'`, matching `registerAction`'s
1664
+ * own default) since a rebinding UI operates on actions it already knows
1665
+ * about, but shouldn't have to special-case "brand new action" separately. */
1666
+ setBindings(actionName: string, bindings: InputBinding[]): void {
1667
+ if (!this.actionValueTypes.has(actionName)) this.actionValueTypes.set(actionName, 'digital');
1668
+ this.actions.set(actionName, structuredClone(bindings));
1669
+ }
1670
+
1671
+ /** Append one binding to `actionName` (F3 AC "added"). */
1672
+ addBinding(actionName: string, binding: InputBinding): void {
1673
+ const bindings = this.actions.get(actionName) ?? [];
1674
+ this.actions.set(actionName, [...bindings, structuredClone(binding)]);
1675
+ if (!this.actionValueTypes.has(actionName)) this.actionValueTypes.set(actionName, 'digital');
1676
+ }
1677
+
1678
+ /** Remove the binding at `index` from `actionName` (F3 AC "removed"). A
1679
+ * no-op if the action is unknown or `index` is out of range (rather than
1680
+ * throwing) — mirrors every other getter's "unknown action is inert"
1681
+ * convention, so a UI doesn't need a separate existence check first. */
1682
+ removeBinding(actionName: string, index: number): void {
1683
+ const bindings = this.actions.get(actionName);
1684
+ if (!bindings || index < 0 || index >= bindings.length) return;
1685
+ this.actions.set(actionName, [...bindings.slice(0, index), ...bindings.slice(index + 1)]);
1686
+ }
1687
+
1688
+ /** Replace a single binding slot at `index` on `actionName` — the shape a
1689
+ * rebinding UI actually wants ("rebind THIS control"), built on
1690
+ * `getBindings`/`setBindings`. A no-op if the action is unknown or `index`
1691
+ * is out of range. */
1692
+ replaceBinding(actionName: string, index: number, binding: InputBinding): void {
1693
+ const bindings = this.actions.get(actionName);
1694
+ if (!bindings || index < 0 || index >= bindings.length) return;
1695
+ const next = bindings.slice();
1696
+ next[index] = structuredClone(binding);
1697
+ this.actions.set(actionName, next);
1698
+ }
1699
+
1700
+ /** Restore `actionName`'s bindings to the snapshot taken at
1701
+ * `registerAction`/`loadMap`/`loadMapObject` time (F3 AC "reset"). A no-op
1702
+ * for an action with no snapshot (never registered/loaded — there is no
1703
+ * "default" to reset to). */
1704
+ resetBindings(actionName: string): void {
1705
+ const defaults = this.defaultBindings.get(actionName);
1706
+ if (!defaults) return;
1707
+ this.actions.set(actionName, structuredClone(defaults));
1708
+ }
1709
+
1710
+ /** Reset every action that has a snapshot back to its registered/loaded
1711
+ * defaults. Convenience batch form of `resetBindings`. */
1712
+ resetAllBindings(): void {
1713
+ for (const actionName of this.defaultBindings.keys()) this.resetBindings(actionName);
1714
+ }
1715
+
1716
+ /**
1717
+ * Whether `a` and `b` are the SAME physical input (F3's structural-conflict
1718
+ * identity check — see `findConflicts`). Deliberately narrower than deep
1719
+ * equality: `deadzone` never participates (two bindings on the same key
1720
+ * with different deadzones are still the same physical control), and a
1721
+ * `gamepad_axis`'s `direction` DOES participate — opposite directions of one
1722
+ * physical axis (e.g. "accelerate" on positive, "brake" on negative) are a
1723
+ * legitimate, non-conflicting pair, not a collision.
1724
+ */
1725
+ private static bindingsCollide(a: InputBinding, b: InputBinding): boolean {
1726
+ if (a.type !== b.type) return false;
1727
+ switch (a.type) {
1728
+ case 'key':
1729
+ return a.code === (b as typeof a).code;
1730
+ case 'mouse_button':
1731
+ return a.button === (b as typeof a).button;
1732
+ case 'mouse_move':
1733
+ return true; // there is only one physical mouse-move source
1734
+ case 'gamepad_button':
1735
+ return a.button === (b as typeof a).button;
1736
+ case 'gamepad_axis': {
1737
+ const other = b as typeof a;
1738
+ return a.axis === other.axis && a.direction === other.direction;
1739
+ }
1740
+ case 'gamepad_axis_pair': {
1741
+ const other = b as typeof a;
1742
+ return a.xAxis === other.xAxis && a.yAxis === other.yAxis;
1743
+ }
1744
+ case 'touch_button':
1745
+ case 'touch_stick':
1746
+ case 'test_axis':
1747
+ case 'test_vector2':
1748
+ case 'test_pointer_delta':
1749
+ case 'test_pointer_position':
1750
+ return a.sourceId === (b as typeof a).sourceId;
1751
+ default:
1752
+ return false;
1753
+ }
1754
+ }
1755
+
1756
+ /**
1757
+ * Every EXISTING binding, on every OTHER registered action, that is
1758
+ * structurally the same physical input as `proposed` (F3 AC "conflicts are
1759
+ * detected and returned structurally") — never throws, always a plain
1760
+ * (possibly empty) array a rebinding UI inspects before committing a
1761
+ * rebind. `excludeActionName` is normally the action being rebound itself
1762
+ * (so an action isn't reported as conflicting with its own current
1763
+ * binding when re-proposing an unchanged value).
1764
+ */
1765
+ findConflicts(proposed: InputBinding, excludeActionName?: string): BindingConflict[] {
1766
+ const conflicts: BindingConflict[] = [];
1767
+ for (const [actionName, bindings] of this.actions) {
1768
+ if (actionName === excludeActionName) continue;
1769
+ bindings.forEach((binding, bindingIndex) => {
1770
+ if (InputManager.bindingsCollide(proposed, binding)) {
1771
+ conflicts.push({ actionName, bindingIndex, binding: structuredClone(binding) });
1772
+ }
1773
+ });
1774
+ }
1775
+ return conflicts;
1776
+ }
1777
+
1778
+ /**
1779
+ * Serialize every registered/loaded action back into the `.inputmap.json`
1780
+ * document shape (F3 AC "persisted") — the exact `InputMapFile` shape
1781
+ * `loadMap`/`loadMapObject` read, so `manager.loadMapObject(manager.toInputMapFile())`
1782
+ * on a fresh instance reproduces the same live bindings. `version` is
1783
+ * carried forward from the last loaded map when known, else `1` (a
1784
+ * manager built purely via `registerAction` calls, never `loadMap`, has no
1785
+ * file-format version to preserve).
1786
+ */
1787
+ toInputMapFile(): InputMapFile {
1788
+ const actions: InputMapFile['actions'] = {};
1789
+ for (const [name, bindings] of this.actions) {
1790
+ actions[name] = {
1791
+ valueType: this.actionValueTypes.get(name) ?? 'digital',
1792
+ bindings: structuredClone(bindings),
1793
+ };
1794
+ }
1795
+ return { version: this.loadedMapVersion, actions };
1796
+ }
1797
+
1798
+ /**
1799
+ * Resolve a display prompt (F3 AC: labels/icons like `'Space'`, `'A'`, or a
1800
+ * touch control's name) for whichever of `actionName`'s bindings belongs to
1801
+ * the requested `device` family. `null` when the action has no binding for
1802
+ * that device (including an unknown action). `gamepadIndex` (default 0)
1803
+ * selects which connected pad's `mapping`/connected state to consult for a
1804
+ * `gamepad_*` binding — see `getGamepadInfo`.
1805
+ *
1806
+ * F4 — `device` is now OPTIONAL: omit it (or pass `undefined`) to resolve
1807
+ * against {@link getLastActiveDevice} instead (falling back to `'keyboard'`
1808
+ * before any input has arrived), so a prompt UI that wants "show whatever
1809
+ * the player is actually holding right now" doesn't have to track the
1810
+ * active device itself — it can just call `getPrompt(actionName)` every
1811
+ * frame and the label switches on its own as the player changes controllers.
1812
+ * Passing an explicit `device` (as every pre-F4 caller does) is unaffected.
1813
+ */
1814
+ getPrompt(actionName: string, device?: PromptDevice, gamepadIndex = 0): BindingPrompt | null {
1815
+ const resolvedDevice = device ?? this.lastActiveDevice ?? 'keyboard';
1816
+ const bindings = this.actions.get(actionName);
1817
+ if (!bindings) return null;
1818
+ const binding = bindings.find((b) => bindingDeviceFamily(b.type) === resolvedDevice);
1819
+ if (!binding) return null;
1820
+
1821
+ switch (binding.type) {
1822
+ case 'key':
1823
+ return {
1824
+ device: 'keyboard',
1825
+ bindingType: 'key',
1826
+ label: keyLabel(binding.code),
1827
+ icon: keyLabel(binding.code),
1828
+ };
1829
+ case 'mouse_button':
1830
+ return {
1831
+ device: 'mouse',
1832
+ bindingType: 'mouse_button',
1833
+ label: mouseButtonLabel(binding.button),
1834
+ icon: 'mouse',
1835
+ };
1836
+ case 'mouse_move':
1837
+ return { device: 'mouse', bindingType: 'mouse_move', label: 'Mouse', icon: 'mouse' };
1838
+ case 'gamepad_button': {
1839
+ const info = this.getGamepadInfo(gamepadIndex);
1840
+ if (!info?.connected) {
1841
+ return {
1842
+ device: 'gamepad',
1843
+ bindingType: 'gamepad_button',
1844
+ label: 'Gamepad Disconnected',
1845
+ icon: 'gamepad-off',
1846
+ };
1847
+ }
1848
+ const label =
1849
+ info.mapping === 'standard'
1850
+ ? (STANDARD_GAMEPAD_BUTTON_LABELS[binding.button] ?? `Button ${binding.button}`)
1851
+ : `Button ${binding.button}`;
1852
+ return { device: 'gamepad', bindingType: 'gamepad_button', label, icon: label };
1853
+ }
1854
+ case 'gamepad_axis': {
1855
+ const info = this.getGamepadInfo(gamepadIndex);
1856
+ if (!info?.connected) {
1857
+ return {
1858
+ device: 'gamepad',
1859
+ bindingType: 'gamepad_axis',
1860
+ label: 'Gamepad Disconnected',
1861
+ icon: 'gamepad-off',
1862
+ };
1863
+ }
1864
+ return {
1865
+ device: 'gamepad',
1866
+ bindingType: 'gamepad_axis',
1867
+ label: gamepadAxisLabel(binding.axis),
1868
+ icon: 'gamepad-stick',
1869
+ };
1870
+ }
1871
+ case 'gamepad_axis_pair': {
1872
+ const info = this.getGamepadInfo(gamepadIndex);
1873
+ if (!info?.connected) {
1874
+ return {
1875
+ device: 'gamepad',
1876
+ bindingType: 'gamepad_axis_pair',
1877
+ label: 'Gamepad Disconnected',
1878
+ icon: 'gamepad-off',
1879
+ };
1880
+ }
1881
+ return {
1882
+ device: 'gamepad',
1883
+ bindingType: 'gamepad_axis_pair',
1884
+ label: gamepadAxisPairLabel(binding.xAxis, binding.yAxis),
1885
+ icon: 'gamepad-stick',
1886
+ };
1887
+ }
1888
+ case 'touch_button':
1889
+ return {
1890
+ device: 'touch',
1891
+ bindingType: 'touch_button',
1892
+ label: binding.sourceId,
1893
+ icon: 'touch',
1894
+ };
1895
+ case 'touch_stick':
1896
+ return {
1897
+ device: 'touch',
1898
+ bindingType: 'touch_stick',
1899
+ label: binding.sourceId,
1900
+ icon: 'touch',
1901
+ };
1902
+ default:
1903
+ return null;
1904
+ }
1905
+ }
1906
+
372
1907
  /** Call at end of frame to clear per-frame state */
373
1908
  endFrame() {
374
1909
  this.keysJustDown.clear();
@@ -385,6 +1920,21 @@ export class InputManager {
385
1920
  this.gamepadAxesPrev = this.gamepadAxesCurrent;
386
1921
  this.mouseDeltaX = 0;
387
1922
  this.mouseDeltaY = 0;
1923
+ // F1 — pointerDelta is a per-frame accumulator, same shape as
1924
+ // mouseDeltaX/Y above; axis/vector2/pointerPosition are level values and
1925
+ // deliberately NOT reset here (they persist until changed/cleared).
1926
+ this.testPointerDeltaAccum.clear();
1927
+ // F2 — touch-button edges, same shape as the mouse-button edges above.
1928
+ this.touchButtonsJustDown.clear();
1929
+ this.touchButtonsJustUp.clear();
1930
+ // Task 1.4 — a promoted tap is just-pressed for exactly one poll()/
1931
+ // endFrame() bracket.
1932
+ this.virtualDigitalTapActive.clear();
1933
+ // Mirrors the line above for the release edge a direct
1934
+ // `setVirtualAction(action, false)`/`clearVirtualActions()` manufactures
1935
+ // (see `virtualDigitalJustReleased`'s doc comment) — just-released for
1936
+ // exactly one poll()/endFrame() bracket too.
1937
+ this.virtualDigitalJustReleased.clear();
388
1938
  }
389
1939
 
390
1940
  /** Inject synthetic key state from touch controls */
@@ -410,6 +1960,304 @@ export class InputManager {
410
1960
  this.mouseDeltaY += dy;
411
1961
  }
412
1962
 
1963
+ /**
1964
+ * F2 (spec §12 "Complete Device Backends") — the touch/virtual-control
1965
+ * digital backend. Set a named virtual button's (`sourceId` — a zone/knob
1966
+ * id the touch UI chooses) pressed state; edge-tracked exactly like a real
1967
+ * mouse button (`onMouseDown`/`onMouseUp` above) so `isJustPressed`/
1968
+ * `isJustReleased` work for `{ type: 'touch_button', sourceId }` bindings.
1969
+ * This is the injectable entry point a real on-screen button's own
1970
+ * touchstart/touchend handler would call — that DOM wiring is a thin
1971
+ * adapter at the edge (browser-verified separately); this method is the
1972
+ * MAPPING logic, unit-tested headlessly with no DOM.
1973
+ */
1974
+ setTouchButton(sourceId: string, pressed: boolean): void {
1975
+ if (!this.inputActive) return;
1976
+ if (pressed) {
1977
+ if (!this.touchButtonsDown.has(sourceId)) this.touchButtonsJustDown.add(sourceId);
1978
+ this.touchButtonsDown.add(sourceId);
1979
+ this.lastActiveDevice = 'touch'; // F4
1980
+ } else {
1981
+ if (this.touchButtonsDown.has(sourceId)) this.touchButtonsJustUp.add(sourceId);
1982
+ this.touchButtonsDown.delete(sourceId);
1983
+ }
1984
+ }
1985
+
1986
+ /**
1987
+ * F2 — the touch/virtual-control analog backend. Set a named virtual
1988
+ * stick's (`sourceId`) raw `{x,y}` position (e.g. -1..1 per axis, like a
1989
+ * gamepad stick); read back via `getVector2` on a `{ type: 'touch_stick',
1990
+ * sourceId }` binding, deadzone-rescaled the same way a gamepad stick is.
1991
+ * Persists across frames (a level value, like a real analog stick held in
1992
+ * position) until changed or cleared via {@link clearTouchStick}.
1993
+ */
1994
+ setTouchStick(sourceId: string, value: Vector2): void {
1995
+ this.touchStickValues.set(sourceId, value);
1996
+ // F4 — mirrors the gamepad-axis heuristic in poll() (see `isStickActive`).
1997
+ if (InputManager.isStickActive(value)) this.lastActiveDevice = 'touch';
1998
+ }
1999
+
2000
+ /** Clear a virtual stick's value (e.g. simulating the touch point lifting —
2001
+ * the knob returns to center). */
2002
+ clearTouchStick(sourceId: string): void {
2003
+ this.touchStickValues.delete(sourceId);
2004
+ }
2005
+
2006
+ // ==========================================================================
2007
+ // Task 1.4 (ACCEPTANCE-DRIVER-BUILD-PLAN.md / SYNTHETIC-PLAYER-SPEC.md §3.2)
2008
+ // — action-level virtual input for a synthetic player. Promoted from
2009
+ // hollowstone's proven `VirtualInput` (`bot/virtual-input.ts`): a bot drives
2010
+ // NAMED ACTIONS (not raw devices), and its contributions join action
2011
+ // resolution at exactly the same points binding contributions do (see
2012
+ // isPressed/isJustPressed and collectScalarContributions/
2013
+ // collectVector2Contributions above). #144 split the gates: virtual input
2014
+ // passes `machineInputActive` (the editor's `enabled` flag only), while
2015
+ // human input keeps the full `inputActive` (enabled AND focused) gate — a
2016
+ // synthetic player must keep playing the shared editor tab while the
2017
+ // human's focus is elsewhere. Unlike every typed getter above (which
2018
+ // treats an unknown action as inert), these THROW on an unknown name — a
2019
+ // synthetic-player caller needs a loud signal that the action it's driving
2020
+ // doesn't exist, not a silently-ignored actuation.
2021
+ // ==========================================================================
2022
+
2023
+ /** Existence check shared by `setVirtualAction`/`tapVirtualAction` — throws
2024
+ * `InputActionError` (`code: 'INPUT_ACTION_NOT_FOUND'`, `data.registered`
2025
+ * = every declared action name) for a name that was never
2026
+ * `registerAction`/`loadMap`-ed. */
2027
+ private assertActionExists(actionName: string, method: string): void {
2028
+ if (!this.actionValueTypes.has(actionName)) {
2029
+ throw new InputActionError(actionName, method, this.actionNames());
2030
+ }
2031
+ }
2032
+
2033
+ /** Which declared `ActionValueType` a `setVirtualAction` value shape
2034
+ * corresponds to — the type-safety check mirrors every typed getter's
2035
+ * `assertValueType` (F1). `pointerDelta`/`pointerPosition` actions aren't
2036
+ * drivable through `setVirtualAction` (no boolean/number/Vector2 call
2037
+ * shape maps to them) — use `injectPointerDelta`/`injectPointerPosition`. */
2038
+ private static virtualValueCategory(
2039
+ value: boolean | number | { x: number; y: number },
2040
+ ): ActionValueType {
2041
+ if (typeof value === 'boolean') return 'digital';
2042
+ if (typeof value === 'number') return 'scalar';
2043
+ return 'vector2';
2044
+ }
2045
+
2046
+ /**
2047
+ * Set a virtual value for `action`, OR'd/summed into the same action reads
2048
+ * humans drive (see the section doc comment above). A `boolean` holds a
2049
+ * digital action pressed until set `false`/cleared; a `number` or `{x,y}`
2050
+ * is a level value combined via the action's own scalar max-magnitude /
2051
+ * vector2 sum-then-clamp rule.
2052
+ *
2053
+ * A digital `false`→`true` transition manufactures a genuine `isJustPressed`
2054
+ * edge, exactly one real key-down does (pulse-runner friction #4 —
2055
+ * previously this write-half only ever held the action pressed with NO
2056
+ * edge at all; every edge-triggered ability driven through this direct
2057
+ * path was unprovable). Re-setting `true` while already held (auto-repeat)
2058
+ * does NOT re-fire it — see {@link applyVirtualActionValue}. The mirror
2059
+ * `true`→`false` transition manufactures an `isJustReleased` edge the same
2060
+ * way a real key-up does.
2061
+ *
2062
+ * Throws `InputActionError` for an unregistered `action`, and the same
2063
+ * valueType-mismatch error every typed getter throws (`assertValueType`)
2064
+ * when `value`'s shape doesn't match the action's declared `valueType`.
2065
+ *
2066
+ * Gated (`setEnabled(false)` — play stopped or editor-gated; #144: window
2067
+ * focus no longer gates machine input): writes NOTHING — matching
2068
+ * `setTouchButton`'s write-gate precedent above — and returns
2069
+ * `{delivered: false, reason}` rather than a silent no-op, so a probe/bot
2070
+ * never mistakes a swallowed actuation for a delivered one.
2071
+ */
2072
+ setVirtualAction(
2073
+ action: string,
2074
+ value: boolean | number | { x: number; y: number },
2075
+ ): { delivered: boolean; reason?: string } {
2076
+ this.assertActionExists(action, 'setVirtualAction');
2077
+ const expected = InputManager.virtualValueCategory(value);
2078
+ this.assertValueType(action, expected, 'setVirtualAction');
2079
+ if (!this.machineInputActive) {
2080
+ return { delivered: false, reason: 'input-gated (input disabled by the editor)' };
2081
+ }
2082
+ this.applyVirtualActionValue(action, value);
2083
+ return { delivered: true };
2084
+ }
2085
+
2086
+ /**
2087
+ * The write half of {@link setVirtualAction} — extracted so D15/T-D15.5's
2088
+ * `scheduleActionAtTick` can apply a non-boolean (scalar/vector2) queued
2089
+ * actuation through the exact same code path (no gate check here: the two
2090
+ * callers gate differently — `setVirtualAction` above checks
2091
+ * `inputActive` before calling this; `applyScheduledActionsForTick` is
2092
+ * only ever reached from inside `poll()` AFTER its own gate check has
2093
+ * already returned early on a gated tick). `applyScheduledActionsForTick`
2094
+ * still handles digital `true` itself rather than calling here (its
2095
+ * one-tick edge must land EXACTLY at the scheduled tick — see its own doc
2096
+ * comment) — this method's digital branch is `setVirtualAction`'s direct
2097
+ * path, called at an arbitrary point relative to the poll()/endFrame()
2098
+ * bracket.
2099
+ *
2100
+ * pulse-runner friction #4 — the digital branch used to be a bare held-flag
2101
+ * write with no manufactured edge at all (the comment that used to sit
2102
+ * here read: "A digital value is handled by `applyScheduledActionsForTick`
2103
+ * itself (it needs the extra just-pressed edge a plain held-value write
2104
+ * doesn't produce)" — i.e. the edge fix shipped ONLY for the scheduled
2105
+ * path, never for this one, which is exactly the gap a blind dogfood run
2106
+ * hit: `setVirtualAction('jump', true)` held the action down but
2107
+ * `isJustPressed('jump')` never fired). Fixed here by reusing
2108
+ * `tapVirtualAction`'s exact one-tick-pulse queue mechanism for this
2109
+ * DIRECT case, specifically because this method's caller can land at any
2110
+ * point relative to `poll()`: writing `virtualDigitalTapActive` directly
2111
+ * here (as the scheduled path safely does, from inside `poll()` itself)
2112
+ * would risk being silently wiped by `poll()`'s own unconditional
2113
+ * `virtualDigitalTapActive = virtualDigitalTapQueued` reassignment,
2114
+ * depending on timing. Queueing side-steps that hazard by construction —
2115
+ * the same reason `tapVirtualAction` queues instead of writing directly. A
2116
+ * redundant `true` while already held (`virtualDigitalHeld` already has
2117
+ * `action`) does not queue again, matching a real key's auto-repeat never
2118
+ * re-firing `isJustPressed`. The `false` branch mirrors real `onKeyUp`
2119
+ * instead (immediate write, no queue needed — nothing in `poll()`
2120
+ * reassigns `virtualDigitalJustReleased`), and only fires when the action
2121
+ * was actually held (no released→released re-fire).
2122
+ */
2123
+ private applyVirtualActionValue(
2124
+ action: string,
2125
+ value: boolean | number | { x: number; y: number },
2126
+ ): void {
2127
+ switch (InputManager.virtualValueCategory(value)) {
2128
+ case 'digital':
2129
+ if (value as boolean) {
2130
+ if (!this.virtualDigitalHeld.has(action)) this.virtualDigitalTapQueued.add(action);
2131
+ this.virtualDigitalHeld.add(action);
2132
+ } else {
2133
+ if (this.virtualDigitalHeld.has(action)) this.virtualDigitalJustReleased.add(action);
2134
+ this.virtualDigitalHeld.delete(action);
2135
+ }
2136
+ break;
2137
+ case 'scalar':
2138
+ this.virtualScalarValues.set(action, value as number);
2139
+ break;
2140
+ default:
2141
+ this.virtualVector2Values.set(action, value as { x: number; y: number });
2142
+ break;
2143
+ }
2144
+ }
2145
+
2146
+ /**
2147
+ * One-frame press of a `'digital'` action (Task 1.4): queues `action` so it
2148
+ * reads `isJustPressed`/`isPressed` true for EXACTLY the next `poll()`'s
2149
+ * frame — promoted to the "active" set at the top of `poll()`, cleared in
2150
+ * `endFrame()` alongside every other just-pressed edge set (see both
2151
+ * above). Hollowstone's `queued`/`beginFrame` tap contract, ported onto
2152
+ * this engine's existing fixed-step `poll()`/`endFrame()` bracket instead
2153
+ * of a bespoke `beginFrame()` hook.
2154
+ *
2155
+ * Throws the same errors as {@link setVirtualAction} (unknown action;
2156
+ * non-digital valueType). A gated tap does NOT queue — it must not fire on
2157
+ * a later frame once input becomes active again — and returns
2158
+ * `{delivered: false, reason}`.
2159
+ */
2160
+ tapVirtualAction(action: string): { delivered: boolean; reason?: string } {
2161
+ this.assertActionExists(action, 'tapVirtualAction');
2162
+ this.assertValueType(action, 'digital', 'tapVirtualAction');
2163
+ if (!this.machineInputActive) {
2164
+ return { delivered: false, reason: 'input-gated (input disabled by the editor)' };
2165
+ }
2166
+ this.virtualDigitalTapQueued.add(action);
2167
+ return { delivered: true };
2168
+ }
2169
+
2170
+ /** Clear all virtual-action state — held digitals, queued/active taps, and
2171
+ * scalar/vector2 values. `flushHeldState()` (the same flush
2172
+ * `setEnabled(false)`/real blur apply to held keys/mouse/touch buttons)
2173
+ * shares the underlying wipe, so a bot's held action never survives input
2174
+ * being suspended — but only THIS public entry point (a deliberate "let
2175
+ * go", the mirror of `setVirtualAction(action, false)` below) manufactures
2176
+ * an `isJustReleased` edge for whatever was held; the suspend path stays
2177
+ * silent (see `flushHeldState`'s own doc comment). */
2178
+ clearVirtualActions(): void {
2179
+ this.clearVirtualActionState({ manufactureReleaseEdge: true });
2180
+ }
2181
+
2182
+ /**
2183
+ * D15/T-D15.5 (`docs/D15-DETERMINISM-DESIGN.md` §2.c) — schedule a virtual
2184
+ * actuation for exactly `tick`: applied at the START of that tick's
2185
+ * `poll()` (composing with `runTicks` — a schedule for tick 500 fires
2186
+ * exactly once the sim has been driven through tick 500, regardless of
2187
+ * burst size). A digital `true` produces a genuine `isJustPressed` edge
2188
+ * exactly at the target tick (not merely a held value — see
2189
+ * `applyScheduledActionsForTick`).
2190
+ *
2191
+ * Throws `InputActionError` for an unregistered `action` and the same
2192
+ * valueType-mismatch error every typed getter throws (`assertValueType`) —
2193
+ * both checked EAGERLY, at schedule time, not deferred to the target tick.
2194
+ * Throws `InputTickError` (`code: 'TICK_ALREADY_PASSED'`) when `tick` is
2195
+ * strictly less than the tick `poll()` will service NEXT — scheduling only
2196
+ * ever reaches the current or a future tick. A tick whose input phase
2197
+ * never runs (this world paused/frozen at that point, or a multi-tick gap
2198
+ * — see `poll()`) drops its scheduled entries rather than replaying them
2199
+ * late — each drop emits one `'input.schedule.dropped'` debug event
2200
+ * (`{tick, action}`) via {@link setDebugEmit}'s sink, if one is wired.
2201
+ */
2202
+ scheduleActionAtTick(
2203
+ tick: number,
2204
+ action: string,
2205
+ value: boolean | number | { x: number; y: number },
2206
+ ): void {
2207
+ this.assertActionExists(action, 'scheduleActionAtTick');
2208
+ const expected = InputManager.virtualValueCategory(value);
2209
+ this.assertValueType(action, expected, 'scheduleActionAtTick');
2210
+ if (tick < this.currentTick) {
2211
+ throw new InputTickError(tick, this.currentTick);
2212
+ }
2213
+ const pending = this.scheduledActions.get(tick);
2214
+ if (pending) pending.push({ action, value });
2215
+ else this.scheduledActions.set(tick, [{ action, value }]);
2216
+ }
2217
+
2218
+ /** Wire (or clear, via `null`) the sink `poll()` calls with
2219
+ * `'input.schedule.dropped'` (`{tick, action}`) whenever it discards a
2220
+ * scheduled entry for a tick whose input phase never ran. Called once by
2221
+ * whoever constructs this `InputManager` with a `DebugRegistry` behind it
2222
+ * (`vgai-scene-game-adapter.ts`), the same seed spot as
2223
+ * `setVirtualInputTarget`/`setInputActionsSource` — never called directly
2224
+ * by gameplay code. */
2225
+ setDebugEmit(fn: ((event: string, detail?: unknown) => void) | null): void {
2226
+ this.debugEmit = fn;
2227
+ }
2228
+
2229
+ /** D15/T-D15.5 — start (or restart) recording the post-gate action-delta
2230
+ * trace: clears any prior trace/snapshot, so a fresh recording session
2231
+ * never carries over stale deltas from an earlier one. */
2232
+ startInputRecording(): void {
2233
+ this.inputRecording = true;
2234
+ this.inputTrace = [];
2235
+ this.lastTraceSnapshot.clear();
2236
+ }
2237
+
2238
+ /** D15/T-D15.5 — stop recording (the trace accumulated so far stays
2239
+ * readable via {@link getInputTrace} until the next `startInputRecording`
2240
+ * clears it). Idempotent. */
2241
+ stopInputRecording(): void {
2242
+ this.inputRecording = false;
2243
+ }
2244
+
2245
+ /** D15/T-D15.5 — whether a recording is currently active. */
2246
+ isInputRecording(): boolean {
2247
+ return this.inputRecording;
2248
+ }
2249
+
2250
+ /** D15/T-D15.5 — the recorded trace so far (a defensive copy), versioned
2251
+ * per the design doc's format sketch (§2.c). SP5 (out of scope here) is
2252
+ * the eventual consumer that replays this; this module only ever
2253
+ * RECORDS. */
2254
+ getInputTrace(): {
2255
+ version: 1;
2256
+ ticks: { tick: number; actions: Record<string, boolean | number | { x: number; y: number }> }[];
2257
+ } {
2258
+ return { version: 1, ticks: this.inputTrace.slice() };
2259
+ }
2260
+
413
2261
  /** Request pointer lock for FPS-style mouse control */
414
2262
  requestPointerLock(element: HTMLElement) {
415
2263
  // Idempotent: never stack duplicate click listeners. Rewire if the element
@@ -431,6 +2279,11 @@ export class InputManager {
431
2279
  window.removeEventListener('mousedown', this.onMouseDown);
432
2280
  window.removeEventListener('mouseup', this.onMouseUp);
433
2281
  window.removeEventListener('mousemove', this.onMouseMove);
2282
+ window.removeEventListener('blur', this.onWindowBlur);
2283
+ window.removeEventListener('focus', this.onWindowFocus);
2284
+ if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
2285
+ document.removeEventListener('pointerlockchange', this.onPointerLockChange);
2286
+ }
434
2287
  if (this.pointerLockElement) {
435
2288
  this.pointerLockElement.removeEventListener('click', this.onPointerLockClick);
436
2289
  this.pointerLockElement = null;