@vgai/engine 0.2.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 (147) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -0
  3. package/package.json +55 -0
  4. package/src/adapter/authoring.ts +402 -0
  5. package/src/adapter/colyseus-networking-adapter.ts +72 -0
  6. package/src/adapter/first-party-systems.ts +103 -0
  7. package/src/adapter/game-adapter.ts +151 -0
  8. package/src/adapter/host-context.ts +77 -0
  9. package/src/adapter/index.ts +85 -0
  10. package/src/adapter/ingest/game-contract.ts +59 -0
  11. package/src/adapter/ingest/overlay-applier.ts +207 -0
  12. package/src/adapter/ingest/overlay-apply.ts +124 -0
  13. package/src/adapter/ingest/overlay-file.ts +126 -0
  14. package/src/adapter/ingest/overlay-report.ts +176 -0
  15. package/src/adapter/ingest/scene-capture.ts +307 -0
  16. package/src/adapter/ingest/upstream-pin.ts +52 -0
  17. package/src/adapter/loop-gate-report.ts +54 -0
  18. package/src/adapter/rapier-physics-adapter.ts +56 -0
  19. package/src/adapter/system-adapter.ts +154 -0
  20. package/src/adapter/transform.ts +18 -0
  21. package/src/adapter/vgai-scene-game-adapter.ts +886 -0
  22. package/src/adapter/world-kind.ts +34 -0
  23. package/src/ai/navigation.ts +164 -0
  24. package/src/animation/anim-graph-types.ts +56 -0
  25. package/src/animation/anim-graph.ts +406 -0
  26. package/src/animation/anim-system.ts +28 -0
  27. package/src/animation/blend-node.ts +119 -0
  28. package/src/animation/property-track.ts +178 -0
  29. package/src/animation/schema.ts +204 -0
  30. package/src/assets.ts +80 -0
  31. package/src/audio/ambient.ts +300 -0
  32. package/src/audio/impacts.ts +212 -0
  33. package/src/audio/index.ts +7 -0
  34. package/src/audio/movement.ts +140 -0
  35. package/src/audio/musical.ts +200 -0
  36. package/src/audio/ui-sounds.ts +171 -0
  37. package/src/audio/vehicle.ts +235 -0
  38. package/src/audio/weapons.ts +152 -0
  39. package/src/core/game-loop.ts +127 -0
  40. package/src/core/system-runner.ts +298 -0
  41. package/src/core/types.ts +58 -0
  42. package/src/dev/console-bridge.ts +83 -0
  43. package/src/dev/debug-draw.ts +80 -0
  44. package/src/dev/logger.ts +119 -0
  45. package/src/ecs/component-manager.ts +748 -0
  46. package/src/ecs/game-component.ts +147 -0
  47. package/src/ecs/hmr-swap-report.ts +65 -0
  48. package/src/input/input-manager.ts +439 -0
  49. package/src/input/input-types.ts +19 -0
  50. package/src/input/schema.ts +129 -0
  51. package/src/loader.ts +70 -0
  52. package/src/manifest/index.ts +24 -0
  53. package/src/manifest/load-file.ts +16 -0
  54. package/src/manifest/load.ts +378 -0
  55. package/src/manifest/schema.ts +375 -0
  56. package/src/physics/collision-system.ts +76 -0
  57. package/src/physics/physics-registry.ts +83 -0
  58. package/src/physics/transform-writer.ts +41 -0
  59. package/src/physics/trigger-dispatch.ts +97 -0
  60. package/src/react/game-state.tsx +172 -0
  61. package/src/render/auto-batcher.ts +169 -0
  62. package/src/render/render-batch-system.ts +268 -0
  63. package/src/render/render-features.ts +146 -0
  64. package/src/render/render-settings.ts +72 -0
  65. package/src/runtime/create-runtime.ts +1152 -0
  66. package/src/runtime/frame-selector-cache.ts +81 -0
  67. package/src/runtime/game.ts +1003 -0
  68. package/src/runtime/input-router.ts +213 -0
  69. package/src/runtime/mount-game.ts +269 -0
  70. package/src/runtime/mount-manifest.ts +361 -0
  71. package/src/runtime/scene-ui-bridge.ts +86 -0
  72. package/src/runtime/scene-ui-data.ts +119 -0
  73. package/src/runtime/state-bridge.ts +79 -0
  74. package/src/runtime/types.ts +196 -0
  75. package/src/scene/asset-loaders.ts +195 -0
  76. package/src/scene/asset-paths.ts +123 -0
  77. package/src/scene/asset-registry.ts +67 -0
  78. package/src/scene/collider-dimensions.ts +125 -0
  79. package/src/scene/component-registry.ts +40 -0
  80. package/src/scene/defaults.ts +164 -0
  81. package/src/scene/geometries/index.ts +7 -0
  82. package/src/scene/geometries/terrain.ts +42 -0
  83. package/src/scene/geometry-registry.ts +42 -0
  84. package/src/scene/instance-registry.ts +84 -0
  85. package/src/scene/instancers/grid.ts +38 -0
  86. package/src/scene/instancers/index.ts +7 -0
  87. package/src/scene/light-camera-factory.ts +97 -0
  88. package/src/scene/material-factory.ts +211 -0
  89. package/src/scene/material-registry.ts +73 -0
  90. package/src/scene/materials/index.ts +7 -0
  91. package/src/scene/materials/water.ts +56 -0
  92. package/src/scene/parse.ts +71 -0
  93. package/src/scene/particles-factory.ts +383 -0
  94. package/src/scene/scene-apply.ts +356 -0
  95. package/src/scene/scene-diff-schema.ts +115 -0
  96. package/src/scene/scene-diff-types.ts +29 -0
  97. package/src/scene/scene-loader.ts +1533 -0
  98. package/src/scene/scene-query.ts +63 -0
  99. package/src/scene/scene-types.ts +34 -0
  100. package/src/scene/scene-version.ts +40 -0
  101. package/src/scene/schema/animation.ts +95 -0
  102. package/src/scene/schema/audio.ts +25 -0
  103. package/src/scene/schema/camera.ts +21 -0
  104. package/src/scene/schema/collider.ts +69 -0
  105. package/src/scene/schema/entity-ref.ts +78 -0
  106. package/src/scene/schema/entity.ts +169 -0
  107. package/src/scene/schema/environment.ts +384 -0
  108. package/src/scene/schema/index.ts +95 -0
  109. package/src/scene/schema/instances.ts +35 -0
  110. package/src/scene/schema/joint.ts +26 -0
  111. package/src/scene/schema/light.ts +38 -0
  112. package/src/scene/schema/material.ts +113 -0
  113. package/src/scene/schema/mesh.ts +108 -0
  114. package/src/scene/schema/particles.ts +398 -0
  115. package/src/scene/schema/physics.ts +49 -0
  116. package/src/scene/schema/scene-file.ts +299 -0
  117. package/src/scene/schema/shadow.ts +24 -0
  118. package/src/scene/schema/spline.ts +21 -0
  119. package/src/scene/schema/tuples.ts +21 -0
  120. package/src/scene/schema/ui.ts +602 -0
  121. package/src/scene/user-data.ts +203 -0
  122. package/src/setup/setup-audio.ts +60 -0
  123. package/src/setup/setup-particles.ts +23 -0
  124. package/src/setup/setup-physics.ts +67 -0
  125. package/src/setup/setup-renderer.ts +529 -0
  126. package/src/types-n8ao.d.ts +37 -0
  127. package/src/types-realism-effects.d.ts +61 -0
  128. package/src/world2d/authoring-2d.ts +208 -0
  129. package/src/world2d/capture-to-scene2d.ts +52 -0
  130. package/src/world2d/collision-2d.ts +106 -0
  131. package/src/world2d/components-2d.ts +86 -0
  132. package/src/world2d/index.ts +66 -0
  133. package/src/world2d/ingest-iframe-2d.ts +255 -0
  134. package/src/world2d/ingest2d.ts +131 -0
  135. package/src/world2d/physics2d-registry.ts +49 -0
  136. package/src/world2d/pixi-game-adapter.ts +325 -0
  137. package/src/world2d/pixi-surface.ts +78 -0
  138. package/src/world2d/scene-capture-2d.ts +117 -0
  139. package/src/world2d/scene2d-loader.ts +308 -0
  140. package/src/world2d/schema/entity2d.ts +145 -0
  141. package/src/world2d/schema/physics2d.ts +53 -0
  142. package/src/world2d/schema/sprite.ts +71 -0
  143. package/src/world2d/schema/tilemap.ts +22 -0
  144. package/src/world2d/schema/tuples2d.ts +25 -0
  145. package/src/world2d/system-adapters-2d.ts +49 -0
  146. package/src/world2d/transform-writer-2d.ts +24 -0
  147. package/src/world2d/types.ts +55 -0
@@ -0,0 +1,147 @@
1
+ import type RAPIER2D from '@dimforge/rapier2d-compat';
2
+ import type RAPIER3D from '@dimforge/rapier3d-compat';
3
+ import type * as PIXI from 'pixi.js';
4
+ import type * as THREE from 'three';
5
+ import type { z } from 'zod';
6
+ import type { SystemPhaseName } from '../core/types';
7
+ import type { WorldInstance, WorldKind } from '../runtime/game';
8
+ import type { GameContext } from '../runtime/types';
9
+
10
+ /**
11
+ * Map a {@link WorldKind} to its native world node type (T7.2, D8 —
12
+ * `docs/GAME-COMPONENT-GENERALIZATION.md` §2). `react` maps to `never`: react
13
+ * entities host no GameComponents (they render from game state via the T7.4
14
+ * state bridge instead).
15
+ */
16
+ export type NodeOf<K extends WorldKind> = K extends 'threejs'
17
+ ? THREE.Object3D
18
+ : K extends 'pixijs'
19
+ ? PIXI.Container
20
+ : never;
21
+
22
+ /** Map a {@link WorldKind} to its native Rapier rigid-body type. */
23
+ export type BodyOf<K extends WorldKind> = K extends 'threejs'
24
+ ? RAPIER3D.RigidBody
25
+ : K extends 'pixijs'
26
+ ? RAPIER2D.RigidBody
27
+ : never;
28
+
29
+ /** Map a {@link WorldKind} to its native Rapier collider type. */
30
+ export type ColliderOf<K extends WorldKind> = K extends 'threejs'
31
+ ? RAPIER3D.Collider
32
+ : K extends 'pixijs'
33
+ ? RAPIER2D.Collider
34
+ : never;
35
+
36
+ /**
37
+ * Base class for game components attached to entities.
38
+ *
39
+ * Generalized (T7.2, D8 — `docs/GAME-COMPONENT-GENERALIZATION.md`) over the
40
+ * world kind `K` (default `'threejs'`, so every existing component — all of
41
+ * which extend the bare `GameComponent` — compiles and runs unchanged). The
42
+ * world's native node IS the entity — there is no separate entity id and no
43
+ * mirror/sync layer (Track A). Each component instance holds a direct
44
+ * reference to its node and (if any) its Rapier rigid body/collider. The
45
+ * ComponentManager wires these up in `attach()`.
46
+ *
47
+ * Similar to Unity's MonoBehaviour or Godot's Node scripts:
48
+ * - `init()` — called once after the entity is fully constructed
49
+ * - `update(dt, ctx)` — called every fixed timestep, in the component's phase
50
+ * - `dispose(ctx)` — called when the entity is destroyed
51
+ * - `onTriggerEnter/onTriggerExit` — called when a sensor collider overlap
52
+ * starts/stops, with the other party's native node
53
+ *
54
+ * State lives on `this` (instance properties). On HMR, the engine swaps the
55
+ * prototype via Object.setPrototypeOf — instance state survives, method bodies
56
+ * update to the new code.
57
+ */
58
+ export abstract class GameComponent<K extends WorldKind = 'threejs'> {
59
+ /** Which phase this component's update runs in. Override in subclasses. */
60
+ static phase: SystemPhaseName = 'gameLogic';
61
+
62
+ /**
63
+ * Runtime-checkable declaration of which {@link WorldKind} this component
64
+ * is written for (T7.2, D8 §3 — `docs/GAME-COMPONENT-GENERALIZATION.md`).
65
+ * TS's `K` type parameter is erased at runtime, so this static is what the
66
+ * ComponentManager checks at attach time: a node whose world's kind
67
+ * doesn't match `declaredKind` THROWS at attach (a `GameComponent<'pixijs'>`
68
+ * attached to a threejs entity, or vice versa). Kind-agnostic components —
69
+ * written only against `node`/`this.world`, never the typed `object3D`
70
+ * accessor or a kind-specific field — may declare `'any'` and attach in
71
+ * any world components are legal in (every world except `'react'`, which
72
+ * always throws regardless of `declaredKind` — see the ComponentManager).
73
+ */
74
+ static declaredKind: WorldKind | 'any' = 'threejs';
75
+
76
+ /**
77
+ * Optional Zod object schema for this component's authored fields. When set,
78
+ * scene data is validated + defaulted through `schema.parse(data)` before being
79
+ * assigned onto the instance. Also powers (later) the editor's schema-driven
80
+ * inspector and HMR defaults.
81
+ */
82
+ static schema?: z.ZodObject<z.ZodRawShape>;
83
+
84
+ /** The native world node. The node IS the entity. */
85
+ node!: NodeOf<K>;
86
+
87
+ /** The world instance this component's entity lives in (wired at attach). */
88
+ world!: WorldInstance;
89
+
90
+ /**
91
+ * Typed accessor, threejs worlds only — kept for compatibility. On a
92
+ * `GameComponent` (default `K`) this types as `THREE.Object3D`, so every
93
+ * existing component compiles unchanged. On a non-threejs instance it
94
+ * THROWS a descriptive error rather than ever returning a wrong-kind node —
95
+ * use `this.node` (or `this.world.kind` to check first) instead.
96
+ */
97
+ get object3D(): THREE.Object3D {
98
+ const kind = this.world?.kind;
99
+ if (kind !== undefined && kind !== 'threejs') {
100
+ throw new Error(
101
+ `${this.constructor.name}.object3D: this component's world (kind: "${kind}") is not a ` +
102
+ 'threejs world — `object3D` only ever returns a THREE.Object3D; use `this.node` instead.',
103
+ );
104
+ }
105
+ // When the world isn't wired yet (silo-attached 2D components until
106
+ // T7.3; bare instances), fall back to the static declaration so a
107
+ // GameComponent<'pixijs'> can never silently hand out its Container
108
+ // typed as an Object3D.
109
+ if (kind === undefined) {
110
+ const declared = (this.constructor as typeof GameComponent).declaredKind;
111
+ if (declared !== 'threejs' && declared !== 'any') {
112
+ throw new Error(
113
+ `${this.constructor.name}.object3D: this component declares kind "${declared}" — ` +
114
+ '`object3D` only ever returns a THREE.Object3D; use `this.node` instead.',
115
+ );
116
+ }
117
+ }
118
+ return this.node as unknown as THREE.Object3D;
119
+ }
120
+
121
+ /** The entity's native rigid body, if it has physics. */
122
+ rigidBody: BodyOf<K> | null = null;
123
+
124
+ /** The entity's native collider, if it has physics. */
125
+ collider: ColliderOf<K> | null = null;
126
+
127
+ /** Called once after the entity is fully constructed (node, physics, etc.). */
128
+ init?(ctx: GameContext): void | Promise<void>;
129
+
130
+ /** Called every fixed timestep. */
131
+ abstract update(dt: number, ctx: GameContext): void;
132
+
133
+ /** Called when the entity is destroyed or the component is removed. */
134
+ dispose?(ctx: GameContext): void;
135
+
136
+ /** Called when a sensor overlap with `other` begins. */
137
+ onTriggerEnter?(other: NodeOf<K>, ctx: GameContext): void;
138
+
139
+ /** Called when a sensor overlap with `other` ends. */
140
+ onTriggerExit?(other: NodeOf<K>, ctx: GameContext): void;
141
+ }
142
+
143
+ /** Constructor type for GameComponent subclasses (carries static phase/schema). */
144
+ export type GameComponentClass = (new () => GameComponent) & {
145
+ phase?: SystemPhaseName;
146
+ schema?: z.ZodObject<z.ZodRawShape>;
147
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * HMR swap-miss report — T5.4 (docs/BACKBONE-TASKS.md's Track 5 table:
3
+ * "Deterministic HMR: registry-keyed hotSwap (not `constructor.name`), schema
4
+ * re-parse on swap, loud swap-miss warnings"). `ComponentManager.hotSwap` keys
5
+ * live instances by the `componentRegistry` name recorded on them at attach
6
+ * (see `component-manager.ts`'s `attach`/`hotSwap`), NOT the live
7
+ * `constructor.name` — fragile under minification, class renames, or two
8
+ * classes sharing one name. This module is the structured, greppable
9
+ * `console.warn` a swap-miss warrants, mirrored on the established
10
+ * prefix + JSON-payload pattern (`packages/editor/src/achieved-tier.ts`'s
11
+ * `TIER_SHORTFALL_PREFIX`, `packages/editor/src/authoring/overlay-report.ts`'s
12
+ * `OVERLAY_REPORT_PREFIX`) — kept dependency-free (no `window`/editor-console
13
+ * import) so it stays importable from a headless engine/runtime context,
14
+ * same discipline as those two files.
15
+ *
16
+ * Two independent miss reasons, per the BACKBONE-TASKS detail note:
17
+ * - `'no-live-instance'` — the incoming hot update's key (the
18
+ * `componentRegistry` name the recompiled module was re-exported under)
19
+ * matches no currently-attached instance's recorded registry key (nor,
20
+ * for an instance attached WITHOUT a registry key — an ad-hoc,
21
+ * code-attached component — its `constructor.name`). This is the
22
+ * "renamed class → loud miss" case: a class rename changes the export
23
+ * key a hot update arrives under, so no live instance is found under the
24
+ * NEW key — the OLD prototype is simply left in place (never silently
25
+ * dropped, never crashed).
26
+ * - `'schema-reparse-failed'` — at least one matched instance's swap
27
+ * succeeded (prototype replaced), but re-validating its authored props
28
+ * against the NEW class's `static schema` threw; that instance's fields
29
+ * are left exactly as they were (last-good props), never partially or
30
+ * invalidly overwritten.
31
+ */
32
+
33
+ /** Greppable prefix shared by every swap-miss log line this module produces. */
34
+ export const HMR_SWAP_MISS_PREFIX = 'hmr-swap-miss';
35
+
36
+ export type HmrSwapMissReport =
37
+ | { key: string; reason: 'no-live-instance' }
38
+ | { key: string; reason: 'schema-reparse-failed'; error: string };
39
+
40
+ /**
41
+ * The one shared message shape — greppable on the prefix, JSON-parseable on
42
+ * the trailing payload (the LAST `{...}` in the string — the human-readable
43
+ * `detail` clause never itself contains a brace, even for the
44
+ * `'schema-reparse-failed'` reason, whose full Zod error text — itself a
45
+ * JSON-issues blob, braces included — is carried only in `report.error`
46
+ * inside that trailing payload, never inlined into `detail`).
47
+ */
48
+ export function formatHmrSwapMissMessage(report: HmrSwapMissReport): string {
49
+ const detail =
50
+ report.reason === 'no-live-instance'
51
+ ? `no live instance's registry key (or, for an ad-hoc/unregistered instance, its class name) ` +
52
+ `matches "${report.key}" — the hot update had nothing to swap (old class, if any, left in place)`
53
+ : `schema re-parse failed for "${report.key}" after swap — keeping the instance's last-good ` +
54
+ `props (Zod error in the trailing payload's "error" field)`;
55
+ return (
56
+ `${HMR_SWAP_MISS_PREFIX} ${detail} (T5.4, docs/BACKBONE-TASKS.md). ` +
57
+ `${HMR_SWAP_MISS_PREFIX} ${JSON.stringify(report)}`
58
+ );
59
+ }
60
+
61
+ /** Emit the ONE structured `console.warn` a swap-miss warrants — never a silent no-op. */
62
+ export function logHmrSwapMiss(report: HmrSwapMissReport): void {
63
+ // biome-ignore lint/suspicious/noConsole: structured, greppable swap-miss warning (T5.4) — mirrors achieved-tier.ts/overlay-report.ts's deliberate direct console.warn
64
+ console.warn(formatHmrSwapMissMessage(report));
65
+ }
@@ -0,0 +1,439 @@
1
+ import { resolveUrl } from '../loader';
2
+ import { SceneParseError } from '../scene/parse';
3
+ import type { InputBinding, InputMapFile } from './input-types';
4
+ import { InputMapFileSchema } from './schema';
5
+
6
+ /**
7
+ * True when a text-entry element is focused, so game input shouldn't also fire
8
+ * (e.g. typing into an editor inspector field / renaming an entity). Cheap guard
9
+ * that holds even when the game viewport is the active tab.
10
+ */
11
+ function isTextEntryFocused(): boolean {
12
+ const el = typeof document !== 'undefined' ? document.activeElement : null;
13
+ if (!el) return false;
14
+ const tag = el.tagName;
15
+ return (
16
+ tag === 'INPUT' ||
17
+ tag === 'TEXTAREA' ||
18
+ tag === 'SELECT' ||
19
+ (el as HTMLElement).isContentEditable === true
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Maps raw keyboard/mouse/gamepad events → named actions.
25
+ *
26
+ * Usage:
27
+ * const input = new InputManager();
28
+ * await input.loadMap('inputmaps/default.inputmap.json');
29
+ * // In game loop:
30
+ * input.poll(); // call at start of frame
31
+ * if (input.isPressed('jump')) { ... }
32
+ * if (input.isJustPressed('attack')) { ... }
33
+ * input.endFrame(); // call at end of frame
34
+ */
35
+ export class InputManager {
36
+ private actions = new Map<string, InputBinding[]>();
37
+ private keysDown = new Set<string>();
38
+ private keysJustDown = new Set<string>();
39
+ private keysJustUp = new Set<string>();
40
+ private mouseButtons = new Set<number>();
41
+ private mouseButtonsJustDown = new Set<number>();
42
+ private mouseButtonsJustUp = new Set<number>();
43
+ private mouseDeltaX = 0;
44
+ private mouseDeltaY = 0;
45
+ private gamepads: (Gamepad | null)[] = [];
46
+ // Gamepad button edge tracking (P1.6c). Keys are `${gamepadIndex}:${buttonIndex}`.
47
+ // `Prev` is last frame's pressed set; `Down` is this frame's; the just-down/up
48
+ // sets are the per-frame diff computed in poll() and cleared in endFrame().
49
+ private gamepadButtonsDown = new Set<string>();
50
+ private gamepadButtonsPrev = new Set<string>();
51
+ private gamepadButtonsJustDown = new Set<string>();
52
+ private gamepadButtonsJustUp = new Set<string>();
53
+ // Gamepad axis edge tracking: raw per-(gamepad,axis) values, this frame vs
54
+ // last frame, so `gamepad_axis` bindings can compute a thresholded
55
+ // (direction + deadzone) boolean for THIS frame and compare it against the
56
+ // same threshold applied to last frame's raw value — giving real
57
+ // isJustPressed/isJustReleased edges for an otherwise-continuous input.
58
+ // Keys are `${gamepadIndex}:${axisIndex}`; `Prev` is carried forward in
59
+ // endFrame(), mirroring the button edge-tracking above.
60
+ private gamepadAxesCurrent = new Map<string, number>();
61
+ private gamepadAxesPrev = new Map<string, number>();
62
+ private lookStickX = 0;
63
+ private lookStickY = 0;
64
+ private disposed = false;
65
+ /**
66
+ * When false, all raw input is ignored and held/transient state is cleared.
67
+ * The editor host drives this (T6.3, `packages/editor/src/play-mode.ts`) so
68
+ * the running game only receives input while its viewport is the active,
69
+ * focused surface — keystrokes typed into the editor (Scene tab, inspector
70
+ * fields) must not leak into the game. A standalone game leaves this true
71
+ * for its whole lifetime.
72
+ */
73
+ private enabled = true;
74
+ /** Element currently wired for click-to-pointer-lock, and its listener. */
75
+ private pointerLockElement: HTMLElement | null = null;
76
+ private onPointerLockClick = () => {
77
+ this.pointerLockElement?.requestPointerLock();
78
+ };
79
+
80
+ 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;
84
+ if (!this.keysDown.has(e.code)) {
85
+ this.keysJustDown.add(e.code);
86
+ }
87
+ this.keysDown.add(e.code);
88
+ };
89
+
90
+ private onKeyUp = (e: KeyboardEvent) => {
91
+ if (!this.enabled) return;
92
+ this.keysDown.delete(e.code);
93
+ this.keysJustUp.add(e.code);
94
+ };
95
+
96
+ private onMouseDown = (e: MouseEvent) => {
97
+ if (!this.enabled) return;
98
+ this.mouseButtons.add(e.button);
99
+ this.mouseButtonsJustDown.add(e.button);
100
+ };
101
+
102
+ private onMouseUp = (e: MouseEvent) => {
103
+ if (!this.enabled) return;
104
+ this.mouseButtons.delete(e.button);
105
+ this.mouseButtonsJustUp.add(e.button);
106
+ };
107
+
108
+ private onMouseMove = (e: MouseEvent) => {
109
+ if (!this.enabled) return;
110
+ this.mouseDeltaX += e.movementX;
111
+ this.mouseDeltaY += e.movementY;
112
+ };
113
+
114
+ constructor() {
115
+ window.addEventListener('keydown', this.onKeyDown);
116
+ window.addEventListener('keyup', this.onKeyUp);
117
+ window.addEventListener('mousedown', this.onMouseDown);
118
+ window.addEventListener('mouseup', this.onMouseUp);
119
+ window.addEventListener('mousemove', this.onMouseMove);
120
+ }
121
+
122
+ /**
123
+ * Load action map from a .inputmap.json file. Validated via
124
+ * `InputMapFileSchema` (T4.6) — a malformed input map throws a
125
+ * `SceneParseError` naming the file, not a deep TypeError once the bad
126
+ * data reaches `isPressed`/`isJustPressed`/`isJustReleased`.
127
+ */
128
+ async loadMap(url: string) {
129
+ let data: InputMapFile;
130
+ try {
131
+ const res = await fetch(resolveUrl(url));
132
+ if (!res.ok) {
133
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
134
+ }
135
+ const json = await res.json();
136
+ const result = InputMapFileSchema.safeParse(json);
137
+ if (!result.success) throw new SceneParseError(result.error.issues, url);
138
+ data = result.data;
139
+ } catch (err) {
140
+ const message = `InputManager.loadMap: failed to load input map "${url}": ${
141
+ err instanceof Error ? err.message : String(err)
142
+ }`;
143
+ console.error(message);
144
+ throw err instanceof SceneParseError ? err : new Error(message);
145
+ }
146
+ for (const [name, action] of Object.entries(data.actions)) {
147
+ this.actions.set(name, action.bindings);
148
+ }
149
+ }
150
+
151
+ /** Register an action programmatically */
152
+ registerAction(name: string, bindings: InputBinding[]) {
153
+ this.actions.set(name, bindings);
154
+ }
155
+
156
+ /** The names of all registered actions (for the InputAdapter to enumerate). */
157
+ actionNames(): string[] {
158
+ return [...this.actions.keys()];
159
+ }
160
+
161
+ /** Poll gamepads and virtual look stick (call at start of frame) */
162
+ poll() {
163
+ 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
170
+ // (gamepadButtons{Prev,Down,JustDown,JustUp}, gamepadAxes{Current,Prev})
171
+ // untouched — they freeze at their last real values while disabled and
172
+ // resume diffing against genuinely-last-observed hardware state once
173
+ // re-enabled, so there's no phantom edge on re-enable (mirrors the
174
+ // "flush held state, don't corrupt history" intent of setEnabled below).
175
+ this.gamepads = [];
176
+ return;
177
+ }
178
+ this.gamepads = navigator.getGamepads ? [...navigator.getGamepads()] : [];
179
+
180
+ // Gamepad button edge detection: diff this frame's pressed set against the
181
+ // previous frame's so isJustPressed/isJustReleased work for gamepads too.
182
+ const current = new Set<string>();
183
+ for (let i = 0; i < this.gamepads.length; i++) {
184
+ const gp = this.gamepads[i];
185
+ if (!gp) continue;
186
+ for (let b = 0; b < gp.buttons.length; b++) {
187
+ if (gp.buttons[b]?.pressed) current.add(`${i}:${b}`);
188
+ }
189
+ }
190
+ for (const key of current) {
191
+ if (!this.gamepadButtonsPrev.has(key)) this.gamepadButtonsJustDown.add(key);
192
+ }
193
+ for (const key of this.gamepadButtonsPrev) {
194
+ if (!current.has(key)) this.gamepadButtonsJustUp.add(key);
195
+ }
196
+ this.gamepadButtonsDown = current;
197
+
198
+ // Snapshot this frame's raw axis values per (gamepad, axis) — independent
199
+ // of any binding's direction/deadzone, mirroring the button snapshot
200
+ // above. isPressed/isJustPressed/isJustReleased apply a binding's own
201
+ // threshold to these raw values (current vs. `gamepadAxesPrev`) on read.
202
+ const currentAxes = new Map<string, number>();
203
+ for (let i = 0; i < this.gamepads.length; i++) {
204
+ const gp = this.gamepads[i];
205
+ if (!gp) continue;
206
+ for (let a = 0; a < gp.axes.length; a++) {
207
+ currentAxes.set(`${i}:${a}`, gp.axes[a] ?? 0);
208
+ }
209
+ }
210
+ this.gamepadAxesCurrent = currentAxes;
211
+
212
+ // Inject look stick as continuous mouse delta (position → rate-of-rotation)
213
+ if (this.lookStickX !== 0 || this.lookStickY !== 0) {
214
+ const scale = 8;
215
+ this.mouseDeltaX += this.lookStickX * scale;
216
+ this.mouseDeltaY += this.lookStickY * scale;
217
+ }
218
+ }
219
+
220
+ /** Has a `gamepad_axis` binding's raw value crossed its direction+deadzone
221
+ * threshold (positive: `value > deadzone`; negative: `value < -deadzone`)? */
222
+ private static axisThresholdMet(
223
+ value: number,
224
+ direction: 'positive' | 'negative',
225
+ deadzone: number,
226
+ ): boolean {
227
+ return direction === 'positive' ? value > deadzone : value < -deadzone;
228
+ }
229
+
230
+ /** Is an action currently held down? */
231
+ isPressed(actionName: string): boolean {
232
+ const bindings = this.actions.get(actionName);
233
+ if (!bindings) return false;
234
+
235
+ for (const binding of bindings) {
236
+ switch (binding.type) {
237
+ case 'key':
238
+ if (this.keysDown.has(binding.code)) return true;
239
+ break;
240
+ case 'mouse_button':
241
+ if (this.mouseButtons.has(binding.button)) return true;
242
+ break;
243
+ case 'gamepad_button':
244
+ for (const gp of this.gamepads) {
245
+ if (gp?.buttons[binding.button]?.pressed) return true;
246
+ }
247
+ break;
248
+ case 'gamepad_axis': {
249
+ const dz = binding.deadzone ?? 0.15;
250
+ for (const gp of this.gamepads) {
251
+ if (!gp) continue;
252
+ const val = gp.axes[binding.axis] ?? 0;
253
+ if (InputManager.axisThresholdMet(val, binding.direction, dz)) return true;
254
+ }
255
+ break;
256
+ }
257
+ }
258
+ }
259
+ return false;
260
+ }
261
+
262
+ /** Was an action pressed this frame (not held from previous)? */
263
+ isJustPressed(actionName: string): boolean {
264
+ const bindings = this.actions.get(actionName);
265
+ if (!bindings) return false;
266
+
267
+ for (const binding of bindings) {
268
+ switch (binding.type) {
269
+ case 'key':
270
+ if (this.keysJustDown.has(binding.code)) return true;
271
+ break;
272
+ case 'mouse_button':
273
+ if (this.mouseButtonsJustDown.has(binding.button)) return true;
274
+ break;
275
+ case 'gamepad_button':
276
+ // Edge-tracked in poll(): true only on the first frame the button
277
+ // transitions from up→down (across any connected gamepad).
278
+ for (let i = 0; i < this.gamepads.length; i++) {
279
+ if (this.gamepadButtonsJustDown.has(`${i}:${binding.button}`)) return true;
280
+ }
281
+ break;
282
+ case 'gamepad_axis': {
283
+ // Edge-tracked via raw axis snapshots (poll()): true only on the
284
+ // first frame the thresholded (direction+deadzone) boolean flips
285
+ // from off→on (across any connected gamepad).
286
+ const dz = binding.deadzone ?? 0.15;
287
+ for (let i = 0; i < this.gamepads.length; i++) {
288
+ const key = `${i}:${binding.axis}`;
289
+ const now = this.gamepadAxesCurrent.get(key) ?? 0;
290
+ const prev = this.gamepadAxesPrev.get(key) ?? 0;
291
+ const nowOn = InputManager.axisThresholdMet(now, binding.direction, dz);
292
+ const prevOn = InputManager.axisThresholdMet(prev, binding.direction, dz);
293
+ if (nowOn && !prevOn) return true;
294
+ }
295
+ break;
296
+ }
297
+ }
298
+ }
299
+ return false;
300
+ }
301
+
302
+ /** Was an action released this frame (held last frame, up now)? */
303
+ isJustReleased(actionName: string): boolean {
304
+ const bindings = this.actions.get(actionName);
305
+ if (!bindings) return false;
306
+
307
+ for (const binding of bindings) {
308
+ switch (binding.type) {
309
+ case 'key':
310
+ if (this.keysJustUp.has(binding.code)) return true;
311
+ break;
312
+ case 'mouse_button':
313
+ if (this.mouseButtonsJustUp.has(binding.button)) return true;
314
+ break;
315
+ case 'gamepad_button':
316
+ for (let i = 0; i < this.gamepads.length; i++) {
317
+ if (this.gamepadButtonsJustUp.has(`${i}:${binding.button}`)) return true;
318
+ }
319
+ break;
320
+ case 'gamepad_axis': {
321
+ // The mirror of isJustPressed: true only on the first frame the
322
+ // thresholded boolean flips from on→off.
323
+ const dz = binding.deadzone ?? 0.15;
324
+ for (let i = 0; i < this.gamepads.length; i++) {
325
+ const key = `${i}:${binding.axis}`;
326
+ const now = this.gamepadAxesCurrent.get(key) ?? 0;
327
+ const prev = this.gamepadAxesPrev.get(key) ?? 0;
328
+ const nowOn = InputManager.axisThresholdMet(now, binding.direction, dz);
329
+ const prevOn = InputManager.axisThresholdMet(prev, binding.direction, dz);
330
+ if (!nowOn && prevOn) return true;
331
+ }
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ return false;
337
+ }
338
+
339
+ /** Whether input capture is currently enabled (see {@link setEnabled}). */
340
+ isEnabled(): boolean {
341
+ return this.enabled;
342
+ }
343
+
344
+ /**
345
+ * Enable/disable input capture. The editor host disables the running game's
346
+ * 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).
351
+ */
352
+ setEnabled(enabled: boolean) {
353
+ if (this.enabled === enabled) return;
354
+ 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
+ }
365
+ }
366
+
367
+ /** Get mouse movement delta this frame */
368
+ getMouseDelta(): { x: number; y: number } {
369
+ return { x: this.mouseDeltaX, y: this.mouseDeltaY };
370
+ }
371
+
372
+ /** Call at end of frame to clear per-frame state */
373
+ endFrame() {
374
+ this.keysJustDown.clear();
375
+ this.keysJustUp.clear();
376
+ this.mouseButtonsJustDown.clear();
377
+ this.mouseButtonsJustUp.clear();
378
+ // Carry this frame's gamepad pressed set forward as next frame's baseline,
379
+ // then clear the per-frame edge sets.
380
+ this.gamepadButtonsPrev = this.gamepadButtonsDown;
381
+ this.gamepadButtonsJustDown.clear();
382
+ this.gamepadButtonsJustUp.clear();
383
+ // Carry this frame's raw axis snapshot forward as next frame's baseline
384
+ // for gamepad_axis edge detection (isJustPressed/isJustReleased).
385
+ this.gamepadAxesPrev = this.gamepadAxesCurrent;
386
+ this.mouseDeltaX = 0;
387
+ this.mouseDeltaY = 0;
388
+ }
389
+
390
+ /** Inject synthetic key state from touch controls */
391
+ setVirtualKey(code: string, pressed: boolean) {
392
+ if (pressed) {
393
+ if (!this.keysDown.has(code)) this.keysJustDown.add(code);
394
+ this.keysDown.add(code);
395
+ } else {
396
+ if (this.keysDown.has(code)) this.keysJustUp.add(code);
397
+ this.keysDown.delete(code);
398
+ }
399
+ }
400
+
401
+ /** Set virtual look stick position (-1..1). Injected as mouse delta during poll(). */
402
+ setLookStick(x: number, y: number) {
403
+ this.lookStickX = x;
404
+ this.lookStickY = y;
405
+ }
406
+
407
+ /** Inject synthetic mouse delta (e.g. from touch controls) */
408
+ addMouseDelta(dx: number, dy: number) {
409
+ this.mouseDeltaX += dx;
410
+ this.mouseDeltaY += dy;
411
+ }
412
+
413
+ /** Request pointer lock for FPS-style mouse control */
414
+ requestPointerLock(element: HTMLElement) {
415
+ // Idempotent: never stack duplicate click listeners. Rewire if the element
416
+ // changed, and track it so dispose() can remove the listener.
417
+ if (this.pointerLockElement === element) return;
418
+ if (this.pointerLockElement) {
419
+ this.pointerLockElement.removeEventListener('click', this.onPointerLockClick);
420
+ }
421
+ this.pointerLockElement = element;
422
+ element.addEventListener('click', this.onPointerLockClick);
423
+ }
424
+
425
+ /** Clean up event listeners */
426
+ dispose() {
427
+ if (this.disposed) return;
428
+ this.disposed = true;
429
+ window.removeEventListener('keydown', this.onKeyDown);
430
+ window.removeEventListener('keyup', this.onKeyUp);
431
+ window.removeEventListener('mousedown', this.onMouseDown);
432
+ window.removeEventListener('mouseup', this.onMouseUp);
433
+ window.removeEventListener('mousemove', this.onMouseMove);
434
+ if (this.pointerLockElement) {
435
+ this.pointerLockElement.removeEventListener('click', this.onPointerLockClick);
436
+ this.pointerLockElement = null;
437
+ }
438
+ }
439
+ }
@@ -0,0 +1,19 @@
1
+ /** A single binding for an action */
2
+ export type InputBinding =
3
+ | { type: 'key'; code: string }
4
+ | { type: 'mouse_button'; button: number }
5
+ | { type: 'mouse_move' }
6
+ | { type: 'gamepad_button'; button: number }
7
+ | { type: 'gamepad_axis'; axis: number; direction: 'positive' | 'negative'; deadzone?: number }
8
+ | { type: 'gamepad_axis_pair'; xAxis: number; yAxis: number; deadzone?: number };
9
+
10
+ /** An action has a name and one or more bindings */
11
+ export interface InputAction {
12
+ bindings: InputBinding[];
13
+ }
14
+
15
+ /** The .inputmap.json file format */
16
+ export interface InputMapFile {
17
+ version: number;
18
+ actions: Record<string, InputAction>;
19
+ }