@vgai/engine 0.5.42 → 0.5.44

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 (114) hide show
  1. package/dist/adapter/adapter-module.d.ts +28 -29
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +11 -34
  4. package/dist/adapter/authoring-seam-contract.d.ts +5 -0
  5. package/dist/adapter/authoring-seam-contract.d.ts.map +1 -1
  6. package/dist/adapter/authoring-seam-contract.js +1 -0
  7. package/dist/adapter/authoring.d.ts +16 -0
  8. package/dist/adapter/authoring.d.ts.map +1 -1
  9. package/dist/adapter/finders/finder-result.d.ts +2 -2
  10. package/dist/adapter/finders/finder-result.d.ts.map +1 -1
  11. package/dist/adapter/finders/index.d.ts +15 -2
  12. package/dist/adapter/finders/index.d.ts.map +1 -1
  13. package/dist/adapter/finders/index.js +38 -10
  14. package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -1
  15. package/dist/adapter/finders/registry.d.ts +45 -0
  16. package/dist/adapter/finders/registry.d.ts.map +1 -0
  17. package/dist/adapter/finders/registry.js +56 -0
  18. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  19. package/dist/adapter/ingest/scene-capture.js +50 -20
  20. package/dist/adapter/system-adapter.d.ts +3 -1
  21. package/dist/adapter/system-adapter.d.ts.map +1 -1
  22. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  23. package/dist/canvas-react/pixi-react-root-factory.js +21 -11
  24. package/dist/data/vite-plugin-data.d.ts +1 -1
  25. package/dist/data/vite-plugin-data.d.ts.map +1 -1
  26. package/dist/data/vite-plugin-data.js +2 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/input/host-pointer.d.ts +18 -0
  31. package/dist/input/host-pointer.d.ts.map +1 -1
  32. package/dist/input/host-pointer.js +54 -5
  33. package/dist/input/input-manager.d.ts +83 -1
  34. package/dist/input/input-manager.d.ts.map +1 -1
  35. package/dist/input/input-manager.js +357 -8
  36. package/dist/manifest/configuration-kinds.d.ts +97 -0
  37. package/dist/manifest/configuration-kinds.d.ts.map +1 -0
  38. package/dist/manifest/configuration-kinds.js +148 -0
  39. package/dist/manifest/index.d.ts +2 -1
  40. package/dist/manifest/index.d.ts.map +1 -1
  41. package/dist/manifest/index.js +4 -3
  42. package/dist/manifest/kind-modules.d.ts +18 -0
  43. package/dist/manifest/kind-modules.d.ts.map +1 -0
  44. package/dist/manifest/kind-modules.js +58 -0
  45. package/dist/manifest/load-file.d.ts +3 -3
  46. package/dist/manifest/load-file.d.ts.map +1 -1
  47. package/dist/manifest/load-file.js +4 -4
  48. package/dist/manifest/load.d.ts +17 -12
  49. package/dist/manifest/load.d.ts.map +1 -1
  50. package/dist/manifest/load.js +29 -19
  51. package/dist/manifest/runtime-environment.d.ts +11 -0
  52. package/dist/manifest/runtime-environment.d.ts.map +1 -0
  53. package/dist/manifest/runtime-environment.js +44 -0
  54. package/dist/manifest/schema.d.ts +10 -9
  55. package/dist/manifest/schema.d.ts.map +1 -1
  56. package/dist/manifest/schema.js +32 -27
  57. package/dist/react/use-data.d.ts +1 -1
  58. package/dist/react/use-data.js +1 -1
  59. package/dist/render/matcap-texture.d.ts +5 -0
  60. package/dist/render/matcap-texture.d.ts.map +1 -0
  61. package/dist/render/matcap-texture.js +82 -0
  62. package/dist/render/soft-particle-depth.d.ts +14 -8
  63. package/dist/render/soft-particle-depth.d.ts.map +1 -1
  64. package/dist/render/soft-particle-depth.js +37 -14
  65. package/dist/render/viewport-shading.d.ts +11 -1
  66. package/dist/render/viewport-shading.d.ts.map +1 -1
  67. package/dist/render/viewport-shading.js +25 -0
  68. package/dist/runtime/create-runtime.js +6 -5
  69. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  70. package/dist/runtime/mount-manifest.js +11 -2
  71. package/dist/settings/schema.d.ts +99 -0
  72. package/dist/settings/schema.d.ts.map +1 -0
  73. package/dist/settings/schema.js +110 -0
  74. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  75. package/dist/world3d-react/r3f-root-factory.js +34 -36
  76. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  77. package/dist/world3d-react/rapier-physics-bridge.js +21 -6
  78. package/dist-config/config.js +1 -1
  79. package/dist-config/config.js.map +2 -2
  80. package/package.json +2 -1
  81. package/schemas/engine-capabilities.json +4 -4
  82. package/schemas/vgai-project.schema.json +41 -36
  83. package/schemas/vgai-settings.schema.json +112 -0
  84. package/src/adapter/adapter-module.ts +33 -51
  85. package/src/adapter/authoring-seam-contract.ts +1 -0
  86. package/src/adapter/authoring.ts +16 -0
  87. package/src/adapter/finders/finder-result.ts +2 -2
  88. package/src/adapter/finders/index.ts +68 -12
  89. package/src/adapter/finders/prefabs-from-stories.ts +2 -2
  90. package/src/adapter/finders/registry.ts +95 -0
  91. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +4 -4
  92. package/src/adapter/ingest/scene-capture.ts +48 -19
  93. package/src/adapter/system-adapter.ts +3 -1
  94. package/src/canvas-react/pixi-react-root-factory.tsx +21 -11
  95. package/src/data/vite-plugin-data.ts +4 -2
  96. package/src/index.ts +1 -1
  97. package/src/input/host-pointer.ts +84 -5
  98. package/src/input/input-manager.ts +404 -7
  99. package/src/manifest/configuration-kinds.ts +241 -0
  100. package/src/manifest/index.ts +4 -3
  101. package/src/manifest/kind-modules.ts +58 -0
  102. package/src/manifest/load-file.ts +11 -5
  103. package/src/manifest/load.ts +53 -22
  104. package/src/manifest/runtime-environment.ts +48 -0
  105. package/src/manifest/schema.ts +39 -33
  106. package/src/react/use-data.ts +1 -1
  107. package/src/render/matcap-texture.ts +92 -0
  108. package/src/render/soft-particle-depth.ts +43 -14
  109. package/src/render/viewport-shading.ts +33 -1
  110. package/src/runtime/create-runtime.ts +6 -5
  111. package/src/runtime/mount-manifest.ts +13 -2
  112. package/src/settings/schema.ts +127 -0
  113. package/src/world3d-react/r3f-root-factory.tsx +45 -39
  114. package/src/world3d-react/rapier-physics-bridge.tsx +22 -8
package/src/index.ts CHANGED
@@ -32,7 +32,7 @@ export type {
32
32
  ResolvedGameManifest,
33
33
  RootAdapter,
34
34
  } from './manifest';
35
- export { DEFAULT_SERVER_MODULE, loadGameManifest } from './manifest';
35
+ export { loadGameManifest } from './manifest';
36
36
  export type { GameSession } from './runtime/create-runtime';
37
37
  export type {
38
38
  MountEntry,
@@ -72,6 +72,8 @@ export interface HostPointerTouch {
72
72
  * differently is holding a button nothing will ever release.
73
73
  */
74
74
  readonly phase: 'start' | 'move' | 'end';
75
+ /** True only when the browser ended this contact with `pointercancel`. */
76
+ readonly canceled: boolean;
75
77
  /**
76
78
  * DENSE finger index — the lowest slot free when this finger went down, and
77
79
  * free again the moment it lifts. NOT the browser's `pointerId`, which never
@@ -80,6 +82,22 @@ export interface HostPointerTouch {
80
82
  readonly index: number;
81
83
  /** Position in the design space (see {@link HostPointerOptions.designSize}). */
82
84
  readonly position: Vector2;
85
+ /** Position before design-resolution scaling, in surface CSS pixels. */
86
+ readonly screenPosition: Vector2;
87
+ /** Delta since this contact's preceding sample, in design pixels. */
88
+ readonly relative: Vector2;
89
+ /** Delta since this contact's preceding sample, in surface CSS pixels. */
90
+ readonly screenRelative: Vector2;
91
+ /** Design-pixel velocity derived from the browser event timestamps. */
92
+ readonly velocity: Vector2;
93
+ /** Surface CSS-pixel velocity derived from the browser event timestamps. */
94
+ readonly screenVelocity: Vector2;
95
+ /** Native PointerEvent pressure in the normalized [0, 1] range. */
96
+ readonly pressure: number;
97
+ /** Native PointerEvent tilt, in degrees. Touch contacts normally report zero. */
98
+ readonly tilt: Vector2;
99
+ /** Touch pointers are never an inverted pen; retained for Godot's event value contract. */
100
+ readonly penInverted: false;
83
101
  }
84
102
 
85
103
  /** What {@link createHostPointer} needs. */
@@ -141,6 +159,14 @@ export function createHostPointer(options: HostPointerOptions): HostPointer {
141
159
  const touches: HostPointerTouch[] = [];
142
160
  /** Browser `pointerId` -> dense finger index, for fingers currently down. */
143
161
  const fingerOfPointer = new Map<number, number>();
162
+ const sampleOfPointer = new Map<
163
+ number,
164
+ {
165
+ readonly position: Vector2;
166
+ readonly screenPosition: Vector2;
167
+ readonly timestamp: number;
168
+ }
169
+ >();
144
170
  const freeFinger = (): number => {
145
171
  const taken = new Set(fingerOfPointer.values());
146
172
  let index = 0;
@@ -152,11 +178,52 @@ export function createHostPointer(options: HostPointerOptions): HostPointer {
152
178
  if (touches.length > TOUCH_QUEUE_MAX) touches.shift();
153
179
  };
154
180
 
155
- const queueTouch = (event: PointerEvent, point: Vector2): void => {
181
+ const queueTouch = (event: PointerEvent, point: Vector2, screenPoint: Vector2): void => {
182
+ const previous = sampleOfPointer.get(event.pointerId);
183
+ const relative =
184
+ previous === undefined
185
+ ? { x: 0, y: 0 }
186
+ : { x: point.x - previous.position.x, y: point.y - previous.position.y };
187
+ const screenRelative =
188
+ previous === undefined
189
+ ? { x: 0, y: 0 }
190
+ : {
191
+ x: screenPoint.x - previous.screenPosition.x,
192
+ y: screenPoint.y - previous.screenPosition.y,
193
+ };
194
+ const elapsedSeconds =
195
+ previous === undefined ? 0 : Math.max(0, event.timeStamp - previous.timestamp) / 1000;
196
+ const velocity =
197
+ elapsedSeconds > 0
198
+ ? { x: relative.x / elapsedSeconds, y: relative.y / elapsedSeconds }
199
+ : { x: 0, y: 0 };
200
+ const screenVelocity =
201
+ elapsedSeconds > 0
202
+ ? { x: screenRelative.x / elapsedSeconds, y: screenRelative.y / elapsedSeconds }
203
+ : { x: 0, y: 0 };
204
+ const common = {
205
+ position: point,
206
+ screenPosition: screenPoint,
207
+ relative,
208
+ screenRelative,
209
+ velocity,
210
+ screenVelocity,
211
+ pressure: Number.isFinite(event.pressure) ? Math.max(0, Math.min(1, event.pressure)) : 0,
212
+ tilt: {
213
+ x: Number.isFinite(event.tiltX) ? event.tiltX : 0,
214
+ y: Number.isFinite(event.tiltY) ? event.tiltY : 0,
215
+ },
216
+ penInverted: false as const,
217
+ };
156
218
  if (event.type === 'pointerdown') {
157
219
  const index = freeFinger();
158
220
  fingerOfPointer.set(event.pointerId, index);
159
- queue({ phase: 'start', index, position: point });
221
+ sampleOfPointer.set(event.pointerId, {
222
+ position: point,
223
+ screenPosition: screenPoint,
224
+ timestamp: event.timeStamp,
225
+ });
226
+ queue({ phase: 'start', canceled: false, index, ...common });
160
227
  return;
161
228
  }
162
229
  // A move or a release from a pointer that never went down on this surface
@@ -165,10 +232,21 @@ export function createHostPointer(options: HostPointerOptions): HostPointer {
165
232
  if (index === undefined) return;
166
233
  if (event.type === 'pointerup' || event.type === 'pointercancel') {
167
234
  fingerOfPointer.delete(event.pointerId);
168
- queue({ phase: 'end', index, position: point });
235
+ sampleOfPointer.delete(event.pointerId);
236
+ queue({
237
+ phase: 'end',
238
+ canceled: event.type === 'pointercancel',
239
+ index,
240
+ ...common,
241
+ });
169
242
  return;
170
243
  }
171
- queue({ phase: 'move', index, position: point });
244
+ sampleOfPointer.set(event.pointerId, {
245
+ position: point,
246
+ screenPosition: screenPoint,
247
+ timestamp: event.timeStamp,
248
+ });
249
+ queue({ phase: 'move', canceled: false, index, ...common });
172
250
  };
173
251
 
174
252
  /** A client-space event as a point in the design space, via the surface rect
@@ -188,6 +266,7 @@ export function createHostPointer(options: HostPointerOptions): HostPointer {
188
266
  if (surface === null) return;
189
267
  const rect = surface.getBoundingClientRect();
190
268
  if (rect.width <= 0 || rect.height <= 0) return;
269
+ const screenPoint = { x: event.clientX - rect.left, y: event.clientY - rect.top };
191
270
  const point = toDesignSpace(event, rect);
192
271
 
193
272
  if (event.type === 'pointermove') {
@@ -199,7 +278,7 @@ export function createHostPointer(options: HostPointerOptions): HostPointer {
199
278
  }
200
279
  // A mouse or a pen is not a finger. A control that answered them here would
201
280
  // steal the clicks of a player who cannot see it.
202
- if (event.pointerType === 'touch') queueTouch(event, point);
281
+ if (event.pointerType === 'touch') queueTouch(event, point, screenPoint);
203
282
  };
204
283
 
205
284
  const KINDS = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'] as const;
@@ -81,6 +81,41 @@ export class InputTickError extends Error {
81
81
  }
82
82
  }
83
83
 
84
+ /** One named digital-pair axis whose interpolation is owned by {@link InputManager}.
85
+ *
86
+ * Foreign runtimes translate their authored axis table into this neutral carrier, then keep their
87
+ * public API at the compat boundary. The manager owns the live values because the values are a
88
+ * derived view of its action state, not a second device/input store. */
89
+ export interface SmoothedInputAxisDefinition {
90
+ readonly name: string;
91
+ readonly negativeAction: string;
92
+ readonly positiveAction: string;
93
+ /** Units per second when returning to zero. */
94
+ readonly gravity: number;
95
+ /** Magnitudes below this threshold read as zero without changing retained interpolation. */
96
+ readonly dead: number;
97
+ /** Units per second when approaching a pressed direction. */
98
+ readonly sensitivity: number;
99
+ /** Reset to zero before accelerating when the newly pressed direction opposes the old value. */
100
+ readonly snap: boolean;
101
+ readonly invert: boolean;
102
+ }
103
+
104
+ /** Scene-owned handle over an InputManager-owned set of smoothed axes. */
105
+ export interface SmoothedInputAxisSet {
106
+ advance(deltaTime: number): void;
107
+ getAxis(name: string): number;
108
+ getAxisRaw(name: string): number;
109
+ snapshot(): Record<string, number>;
110
+ dispose(): void;
111
+ }
112
+
113
+ interface SmoothedInputAxisSetState {
114
+ readonly definitions: ReadonlyMap<string, SmoothedInputAxisDefinition>;
115
+ readonly values: Map<string, number>;
116
+ disposed: boolean;
117
+ }
118
+
84
119
  /** Shallow equality for a resolved action value (boolean/number/Vector2) —
85
120
  * D15/T-D15.5's recording tap uses this to emit DELTAS only (the trace
86
121
  * format's "deltas only" line), never a full snapshot every tick. `prev`
@@ -415,6 +450,8 @@ export class InputManager {
415
450
  // level values feeding the same max-magnitude/sum combine every real
416
451
  // binding uses (collectScalarContributions/collectVector2Contributions).
417
452
  private virtualDigitalHeld = new Set<string>();
453
+ /** Processed strength attached to a held virtual digital action. */
454
+ private virtualDigitalStrength = new Map<string, number>();
418
455
  private virtualDigitalTapQueued = new Set<string>();
419
456
  private virtualDigitalTapActive = new Set<string>();
420
457
  /** pulse-runner friction #4 — the release-edge mirror of
@@ -428,6 +465,9 @@ export class InputManager {
428
465
  private virtualDigitalJustReleased = new Set<string>();
429
466
  private virtualScalarValues = new Map<string, number>();
430
467
  private virtualVector2Values = new Map<string, Vector2>();
468
+ /** Derived digital-pair axes owned by mounted foreign runtimes. Their public GetAxis-shaped
469
+ * calls remain in compat; only interpolation state and action reads live beside their source. */
470
+ private smoothedAxisSets = new Set<SmoothedInputAxisSetState>();
431
471
  // D15/T-D15.5 — tick-indexed input scheduling + post-gate recording.
432
472
  //
433
473
  // `currentTick` is a FALLBACK, self-incrementing counter, used only when a
@@ -524,8 +564,61 @@ export class InputManager {
524
564
  private pointerLocked = false;
525
565
  /** Element currently wired for click-to-pointer-lock, and its listener. */
526
566
  private pointerLockElement: HTMLElement | null = null;
567
+ /** One report per refusal reason: a refused lock is the whole story of
568
+ * "the mouse doesn't aim", and it was silent. */
569
+ private reportedPointerLockRefusals = new Set<string>();
570
+ private reportPointerLockRefused(reason: string): void {
571
+ if (this.reportedPointerLockRefusals.has(reason)) return;
572
+ this.reportedPointerLockRefusals.add(reason);
573
+ // biome-ignore lint/suspicious/noConsole: the game's own input intent failed in the browser — an error the editor console and a tester's evidence must both carry
574
+ console.error(
575
+ `Pointer lock was refused, so mouse look and mouse aim cannot engage: ${reason}. ` +
576
+ 'The browser grants it only to a user gesture on a page it trusts — a page inside a ' +
577
+ 'frame needs allow="pointer-lock", and some extensions block it.',
578
+ );
579
+ }
580
+ /** A retry already scheduled for Chrome's post-exit cooldown, if any. */
581
+ private pointerLockRetry: ReturnType<typeof setTimeout> | null = null;
527
582
  private onPointerLockClick = () => {
528
- this.pointerLockElement?.requestPointerLock();
583
+ // Chrome answers with a promise that REJECTS when the lock is refused (a
584
+ // sandboxed/automated document, a policy, a frame without the permission).
585
+ // Three testers in a row reported "the mouse doesn't aim" on the hosted
586
+ // editor while automation could not lock at all — and the refusal was
587
+ // caught silently (runhuman passes 122–126). It is reported now, once
588
+ // per reason, as the error it is.
589
+ const request = this.pointerLockElement?.requestPointerLock() as unknown;
590
+ if (request instanceof Promise) {
591
+ request.catch((error: unknown) => {
592
+ const reason = error instanceof Error ? error.message : String(error);
593
+ // CHROME'S COOLDOWN IS NOT A REFUSAL. After Escape ends a lock, a
594
+ // request in the next ~1.25 s is rejected with "cannot be acquired
595
+ // immediately after the user has exited the lock" — and a player who
596
+ // clicks back into the game right away gets no aim and no second
597
+ // chance (runhuman pass 130: "I can't move the camera with the
598
+ // mouse"). The click's activation still stands for a few seconds, so
599
+ // the request is retried once when the cooldown has passed.
600
+ if (/exited the lock/i.test(reason) && this.pointerLockRetry === null) {
601
+ this.pointerLockRetry = setTimeout(() => {
602
+ this.pointerLockRetry = null;
603
+ const doc = typeof document !== 'undefined' ? document : null;
604
+ const activation =
605
+ typeof navigator !== 'undefined' ? navigator.userActivation : undefined;
606
+ if (!this.pointerLockElement || doc?.pointerLockElement === this.pointerLockElement) {
607
+ return;
608
+ }
609
+ if (activation?.isActive) {
610
+ this.onPointerLockClick();
611
+ } else {
612
+ // The cooldown outlived the click's activation: the retry
613
+ // cannot run, and a silent end here is "mouse dead, no error".
614
+ this.reportPointerLockRefused(`${reason} (the retry found no user activation left)`);
615
+ }
616
+ }, 1_300);
617
+ return;
618
+ }
619
+ this.reportPointerLockRefused(reason);
620
+ });
621
+ }
529
622
  };
530
623
  private onPointerLockChange = () => {
531
624
  const doc = typeof document !== 'undefined' ? document : null;
@@ -654,6 +747,7 @@ export class InputManager {
654
747
  }
655
748
  }
656
749
  this.virtualDigitalHeld.clear();
750
+ this.virtualDigitalStrength.clear();
657
751
  this.virtualDigitalTapQueued.clear();
658
752
  this.virtualDigitalTapActive.clear();
659
753
  this.virtualScalarValues.clear();
@@ -799,6 +893,116 @@ export class InputManager {
799
893
  return [...this.actions.keys()];
800
894
  }
801
895
 
896
+ /**
897
+ * Create a retained, action-backed set of digital axes. Foreign runtimes keep their public
898
+ * GetAxis-shaped API in compat, while interpolation and the live action reads stay in the input
899
+ * owner instead of becoming a parallel compat input store.
900
+ */
901
+ createSmoothedAxisSet(definitions: readonly SmoothedInputAxisDefinition[]): SmoothedInputAxisSet {
902
+ const byName = new Map<string, SmoothedInputAxisDefinition>();
903
+ for (const source of definitions) {
904
+ const definition = { ...source };
905
+ if (definition.name.length === 0) {
906
+ throw new Error('InputManager.createSmoothedAxisSet: axis names must not be empty');
907
+ }
908
+ if (byName.has(definition.name)) {
909
+ throw new Error(
910
+ `InputManager.createSmoothedAxisSet: duplicate axis ${JSON.stringify(definition.name)}`,
911
+ );
912
+ }
913
+ if (definition.negativeAction.length === 0 || definition.positiveAction.length === 0) {
914
+ throw new Error(
915
+ `InputManager.createSmoothedAxisSet: axis ${JSON.stringify(definition.name)} requires ` +
916
+ 'both negative and positive actions',
917
+ );
918
+ }
919
+ for (const [field, value] of [
920
+ ['gravity', definition.gravity],
921
+ ['dead', definition.dead],
922
+ ['sensitivity', definition.sensitivity],
923
+ ] as const) {
924
+ if (!Number.isFinite(value) || value < 0) {
925
+ throw new Error(
926
+ `InputManager.createSmoothedAxisSet: ${definition.name}.${field} must be a finite ` +
927
+ `non-negative number, received ${String(value)}`,
928
+ );
929
+ }
930
+ }
931
+ byName.set(definition.name, Object.freeze(definition));
932
+ }
933
+
934
+ const state: SmoothedInputAxisSetState = {
935
+ definitions: byName,
936
+ values: new Map(),
937
+ disposed: false,
938
+ };
939
+ this.smoothedAxisSets.add(state);
940
+
941
+ const assertLive = (): void => {
942
+ if (state.disposed || this.disposed) {
943
+ throw new Error('InputManager smoothed axis set has been disposed');
944
+ }
945
+ };
946
+ const definitionOf = (name: string): SmoothedInputAxisDefinition => {
947
+ assertLive();
948
+ const definition = state.definitions.get(name);
949
+ if (definition === undefined) {
950
+ throw new Error(`InputManager smoothed axis set has no axis ${JSON.stringify(name)}`);
951
+ }
952
+ return definition;
953
+ };
954
+ const rawValue = (definition: SmoothedInputAxisDefinition): number => {
955
+ const negative = this.isPressed(definition.negativeAction) ? -1 : 0;
956
+ const positive = this.isPressed(definition.positiveAction) ? 1 : 0;
957
+ return (negative + positive) * (definition.invert ? -1 : 1);
958
+ };
959
+ const getAxis = (name: string): number => {
960
+ const definition = definitionOf(name);
961
+ const value = state.values.get(name) ?? 0;
962
+ return Math.abs(value) < definition.dead ? 0 : value;
963
+ };
964
+
965
+ return {
966
+ advance: (deltaTime) => {
967
+ assertLive();
968
+ if (!Number.isFinite(deltaTime) || deltaTime < 0) {
969
+ throw new Error(
970
+ `InputManager smoothed axis deltaTime must be finite and non-negative, received ` +
971
+ String(deltaTime),
972
+ );
973
+ }
974
+ for (const definition of state.definitions.values()) {
975
+ const target = rawValue(definition);
976
+ const current = state.values.get(definition.name) ?? 0;
977
+ let next: number;
978
+ if (target === 0) {
979
+ const step = definition.gravity * deltaTime;
980
+ next = current > 0 ? Math.max(0, current - step) : Math.min(0, current + step);
981
+ } else {
982
+ const from = definition.snap && current * target < 0 ? 0 : current;
983
+ const step = definition.sensitivity * deltaTime;
984
+ next = target > 0 ? Math.min(target, from + step) : Math.max(target, from - step);
985
+ }
986
+ state.values.set(definition.name, next);
987
+ }
988
+ },
989
+ getAxis,
990
+ getAxisRaw: (name) => rawValue(definitionOf(name)),
991
+ snapshot: () => {
992
+ assertLive();
993
+ const snapshot: Record<string, number> = {};
994
+ for (const name of state.definitions.keys()) snapshot[name] = getAxis(name);
995
+ return snapshot;
996
+ },
997
+ dispose: () => {
998
+ if (state.disposed) return;
999
+ state.disposed = true;
1000
+ state.values.clear();
1001
+ this.smoothedAxisSets.delete(state);
1002
+ },
1003
+ };
1004
+ }
1005
+
802
1006
  /**
803
1007
  * Poll gamepads and virtual look stick (call at start of frame).
804
1008
  *
@@ -1014,9 +1218,11 @@ export class InputManager {
1014
1218
  if (typeof entry.value === 'boolean') {
1015
1219
  if (entry.value) {
1016
1220
  this.virtualDigitalHeld.add(entry.action);
1221
+ this.virtualDigitalStrength.set(entry.action, 1);
1017
1222
  this.virtualDigitalTapActive.add(entry.action);
1018
1223
  } else {
1019
1224
  this.virtualDigitalHeld.delete(entry.action);
1225
+ this.virtualDigitalStrength.delete(entry.action);
1020
1226
  }
1021
1227
  } else {
1022
1228
  this.applyVirtualActionValue(entry.action, entry.value);
@@ -1114,6 +1320,110 @@ export class InputManager {
1114
1320
  return false;
1115
1321
  }
1116
1322
 
1323
+ /**
1324
+ * Processed strength for a declared digital action without creating a
1325
+ * second action-state store. Digital keys/buttons/touches contribute 1,
1326
+ * while a directional gamepad axis contributes Godot's deadzone-rescaled
1327
+ * magnitude. Multiple bindings combine by greatest strength, matching the
1328
+ * action state that {@link isPressed} reads as a boolean.
1329
+ */
1330
+ getDigitalStrength(actionName: string): number {
1331
+ this.assertValueType(actionName, 'digital', 'getDigitalStrength');
1332
+ if (
1333
+ this.machineInputActive &&
1334
+ (this.virtualDigitalHeld.has(actionName) || this.virtualDigitalTapActive.has(actionName))
1335
+ ) {
1336
+ return this.virtualDigitalStrength.get(actionName) ?? 1;
1337
+ }
1338
+ if (!this.inputActive) return 0;
1339
+ const bindings = this.actions.get(actionName);
1340
+ if (!bindings) return 0;
1341
+
1342
+ let strength = 0;
1343
+ for (const binding of bindings) {
1344
+ switch (binding.type) {
1345
+ case 'key':
1346
+ if (this.keysDown.has(binding.code)) return 1;
1347
+ break;
1348
+ case 'mouse_button':
1349
+ if (this.mouseButtons.has(binding.button)) return 1;
1350
+ break;
1351
+ case 'gamepad_button':
1352
+ for (const gp of this.gamepads) {
1353
+ const button = gp?.buttons[binding.button];
1354
+ // Pinned InputEventJoypadButton::action_match reports 1 for a
1355
+ // pressed button and 0 otherwise; GamepadButton.value is not its
1356
+ // action strength.
1357
+ if (button?.pressed) return 1;
1358
+ }
1359
+ break;
1360
+ case 'gamepad_axis': {
1361
+ const deadzone = binding.deadzone ?? GAMEPAD_AXIS_DEFAULT_DEADZONE;
1362
+ for (const gp of this.gamepads) {
1363
+ if (!gp) continue;
1364
+ const raw = gp.axes[binding.axis] ?? 0;
1365
+ const directional = binding.direction === 'positive' ? raw : -raw;
1366
+ if (directional < deadzone) continue;
1367
+ strength = Math.max(
1368
+ strength,
1369
+ deadzone === 1
1370
+ ? 1
1371
+ : Math.min(1, Math.max(0, (directional - deadzone) / (1 - deadzone))),
1372
+ );
1373
+ }
1374
+ break;
1375
+ }
1376
+ case 'touch_button':
1377
+ if (this.touchButtonsDown.has(binding.sourceId)) return 1;
1378
+ break;
1379
+ }
1380
+ }
1381
+ return strength;
1382
+ }
1383
+
1384
+ /** Raw (pre-deadzone) strength for a declared digital action. */
1385
+ getDigitalRawStrength(actionName: string): number {
1386
+ this.assertValueType(actionName, 'digital', 'getDigitalRawStrength');
1387
+ if (
1388
+ this.machineInputActive &&
1389
+ (this.virtualDigitalHeld.has(actionName) || this.virtualDigitalTapActive.has(actionName))
1390
+ ) {
1391
+ return this.virtualDigitalStrength.get(actionName) ?? 1;
1392
+ }
1393
+ if (!this.inputActive) return 0;
1394
+ const bindings = this.actions.get(actionName);
1395
+ if (!bindings) return 0;
1396
+
1397
+ let strength = 0;
1398
+ for (const binding of bindings) {
1399
+ switch (binding.type) {
1400
+ case 'key':
1401
+ if (this.keysDown.has(binding.code)) return 1;
1402
+ break;
1403
+ case 'mouse_button':
1404
+ if (this.mouseButtons.has(binding.button)) return 1;
1405
+ break;
1406
+ case 'gamepad_button':
1407
+ if (this.gamepads.some((gp) => gp?.buttons[binding.button]?.pressed)) return 1;
1408
+ break;
1409
+ case 'gamepad_axis':
1410
+ for (const gp of this.gamepads) {
1411
+ if (!gp) continue;
1412
+ const raw = gp.axes[binding.axis] ?? 0;
1413
+ strength = Math.max(
1414
+ strength,
1415
+ Math.min(1, Math.max(0, binding.direction === 'positive' ? raw : -raw)),
1416
+ );
1417
+ }
1418
+ break;
1419
+ case 'touch_button':
1420
+ if (this.touchButtonsDown.has(binding.sourceId)) return 1;
1421
+ break;
1422
+ }
1423
+ }
1424
+ return strength;
1425
+ }
1426
+
1117
1427
  /** Was an action pressed this frame (not held from previous)? */
1118
1428
  isJustPressed(actionName: string): boolean {
1119
1429
  this.assertValueType(actionName, 'digital', 'isJustPressed');
@@ -1798,6 +2108,17 @@ export class InputManager {
1798
2108
  return this.gamepads.some((pad) => pad !== null);
1799
2109
  }
1800
2110
 
2111
+ /** Connected W3C Gamepad indices in native slot order, gated by the same play/focus state as
2112
+ * every other raw device poll. Foreign-engine adapters use their own collection value type. */
2113
+ getConnectedGamepadIndices(): number[] {
2114
+ if (!this.inputActive) return [];
2115
+ const indices: number[] = [];
2116
+ for (let index = 0; index < this.gamepads.length; index += 1) {
2117
+ if (this.gamepads[index] !== null) indices.push(index);
2118
+ }
2119
+ return indices;
2120
+ }
2121
+
1801
2122
  isDeviceGamepadButtonPressed(button: number): boolean {
1802
2123
  if (!this.inputActive) return false;
1803
2124
  const index = this.firstConnectedGamepadIndex();
@@ -1805,6 +2126,15 @@ export class InputManager {
1805
2126
  return this.gamepadButtonsDown.has(`${index}:${button}`);
1806
2127
  }
1807
2128
 
2129
+ /** Raw button state for one explicit native Gamepad slot. */
2130
+ isDeviceGamepadButtonPressedAt(index: number, button: number): boolean {
2131
+ if (!this.inputActive) return false;
2132
+ if (!Number.isSafeInteger(index) || index < 0 || !Number.isSafeInteger(button) || button < 0) {
2133
+ return false;
2134
+ }
2135
+ return this.gamepads[index]?.buttons[button]?.pressed ?? false;
2136
+ }
2137
+
1808
2138
  isDeviceGamepadButtonJustPressed(button: number): boolean {
1809
2139
  if (!this.inputActive) return false;
1810
2140
  const index = this.firstConnectedGamepadIndex();
@@ -1841,6 +2171,15 @@ export class InputManager {
1841
2171
  return this.gamepads[index]?.axes[axis] ?? 0;
1842
2172
  }
1843
2173
 
2174
+ /** Raw axis value for one explicit native Gamepad slot. */
2175
+ getDeviceGamepadAxisAt(index: number, axis: number): number {
2176
+ if (!this.inputActive) return 0;
2177
+ if (!Number.isSafeInteger(index) || index < 0 || !Number.isSafeInteger(axis) || axis < 0) {
2178
+ return 0;
2179
+ }
2180
+ return this.gamepads[index]?.axes[axis] ?? 0;
2181
+ }
2182
+
1844
2183
  private firstConnectedGamepadIndex(): number {
1845
2184
  for (let i = 0; i < this.gamepads.length; i++) {
1846
2185
  if (this.gamepads[i] !== null) return i;
@@ -2266,6 +2605,31 @@ export class InputManager {
2266
2605
  return { delivered: true };
2267
2606
  }
2268
2607
 
2608
+ /**
2609
+ * Godot's `Input.action_press(action, strength)` door for a digital action.
2610
+ * This extends the existing virtual held/edge state with its clamped
2611
+ * processed strength instead of maintaining a parallel Godot action map.
2612
+ */
2613
+ setVirtualDigitalStrength(
2614
+ action: string,
2615
+ strength: number,
2616
+ ): { delivered: boolean; reason?: string } {
2617
+ this.assertActionExists(action, 'setVirtualDigitalStrength');
2618
+ this.assertValueType(action, 'digital', 'setVirtualDigitalStrength');
2619
+ if (!Number.isFinite(strength)) {
2620
+ throw new Error('InputManager.setVirtualDigitalStrength: strength must be finite.');
2621
+ }
2622
+ if (!this.machineInputActive) {
2623
+ return { delivered: false, reason: 'input-gated (input disabled by the editor)' };
2624
+ }
2625
+ const clamped = Math.min(1, Math.max(0, strength));
2626
+ // `Input::action_press` sets api_pressed=true even when its clamped
2627
+ // strength is zero; only `action_release` clears the pressed state.
2628
+ this.applyVirtualActionValue(action, true);
2629
+ this.virtualDigitalStrength.set(action, clamped);
2630
+ return { delivered: true };
2631
+ }
2632
+
2269
2633
  /**
2270
2634
  * The write half of {@link setVirtualAction} — extracted so D15/T-D15.5's
2271
2635
  * `scheduleActionAtTick` can apply a non-boolean (scalar/vector2) queued
@@ -2315,6 +2679,7 @@ export class InputManager {
2315
2679
  } else {
2316
2680
  if (this.virtualDigitalHeld.has(action)) this.virtualDigitalJustReleased.add(action);
2317
2681
  this.virtualDigitalHeld.delete(action);
2682
+ this.virtualDigitalStrength.delete(action);
2318
2683
  }
2319
2684
  break;
2320
2685
  case 'scalar':
@@ -2440,16 +2805,34 @@ export class InputManager {
2440
2805
  return { version: 1, ticks: this.inputTrace.slice() };
2441
2806
  }
2442
2807
 
2443
- /** Request pointer lock for FPS-style mouse control */
2808
+ /**
2809
+ * Request pointer lock for FPS-style mouse control.
2810
+ *
2811
+ * Wires `element` for click-to-lock (so a lock released by Escape comes back
2812
+ * on the next click) AND, when the call itself is inside a user gesture,
2813
+ * requests the lock right now. Games call this FROM a click handler
2814
+ * (third-person's Player: canvas click → `requestPointerLock(canvas)`), and
2815
+ * with only the listener wired that first click did nothing — the lock
2816
+ * arrived one click late, with no feedback, which every hosted-editor tester
2817
+ * read as "the mouse doesn't work" (runhuman passes 113–116). Outside a
2818
+ * gesture the browser would refuse and log, so the immediate request is
2819
+ * gated on `navigator.userActivation`.
2820
+ */
2444
2821
  requestPointerLock(element: HTMLElement) {
2445
2822
  // Idempotent: never stack duplicate click listeners. Rewire if the element
2446
2823
  // changed, and track it so dispose() can remove the listener.
2447
- if (this.pointerLockElement === element) return;
2448
- if (this.pointerLockElement) {
2449
- this.pointerLockElement.removeEventListener('click', this.onPointerLockClick);
2824
+ if (this.pointerLockElement !== element) {
2825
+ if (this.pointerLockElement) {
2826
+ this.pointerLockElement.removeEventListener('click', this.onPointerLockClick);
2827
+ }
2828
+ this.pointerLockElement = element;
2829
+ element.addEventListener('click', this.onPointerLockClick);
2830
+ }
2831
+ const doc = typeof document !== 'undefined' ? document : null;
2832
+ const activation = typeof navigator !== 'undefined' ? navigator.userActivation : undefined;
2833
+ if (doc && doc.pointerLockElement !== element && activation?.isActive) {
2834
+ this.onPointerLockClick();
2450
2835
  }
2451
- this.pointerLockElement = element;
2452
- element.addEventListener('click', this.onPointerLockClick);
2453
2836
  }
2454
2837
 
2455
2838
  /**
@@ -2465,6 +2848,10 @@ export class InputManager {
2465
2848
  * holding lock on a different element is not ours to drop.
2466
2849
  */
2467
2850
  exitPointerLock() {
2851
+ if (this.pointerLockRetry !== null) {
2852
+ clearTimeout(this.pointerLockRetry);
2853
+ this.pointerLockRetry = null;
2854
+ }
2468
2855
  const el = this.pointerLockElement;
2469
2856
  if (el) {
2470
2857
  el.removeEventListener('click', this.onPointerLockClick);
@@ -2480,6 +2867,16 @@ export class InputManager {
2480
2867
  dispose() {
2481
2868
  if (this.disposed) return;
2482
2869
  this.disposed = true;
2870
+ // A manager torn down with a key under the hand (Stop while W is held)
2871
+ // kept that key in `keysDown` for good — its listeners went away before
2872
+ // the release arrived, and nothing else clears it. A disposed manager
2873
+ // holds nothing.
2874
+ this.flushHeldState();
2875
+ for (const axes of this.smoothedAxisSets) {
2876
+ axes.disposed = true;
2877
+ axes.values.clear();
2878
+ }
2879
+ this.smoothedAxisSets.clear();
2483
2880
  if (typeof window !== 'undefined') {
2484
2881
  window.removeEventListener('keydown', this.onKeyDown);
2485
2882
  window.removeEventListener('keyup', this.onKeyUp);