@vgai/engine 0.5.2 → 0.5.4

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 (185) hide show
  1. package/README.md +18 -11
  2. package/package.json +10 -6
  3. package/schemas/engine-api.json +1 -68
  4. package/schemas/engine-api.md +1 -32
  5. package/schemas/engine-capabilities.json +30 -42
  6. package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
  7. package/src/adapter/adapter-surface.ts +5 -5
  8. package/src/adapter/authoring.ts +168 -226
  9. package/src/adapter/colyseus-networking-adapter.ts +44 -5
  10. package/src/adapter/first-party-systems.ts +156 -42
  11. package/src/adapter/host-context.ts +177 -46
  12. package/src/adapter/index.ts +47 -51
  13. package/src/adapter/ingest/game-contract.ts +2 -2
  14. package/src/adapter/ingest/scene-capture.ts +18 -19
  15. package/src/adapter/ingest/structural-ids.ts +127 -0
  16. package/src/adapter/ingest/upstream-pin.ts +9 -12
  17. package/src/adapter/loop-gate-report.ts +11 -11
  18. package/src/adapter/rapier-physics-adapter.ts +27 -9
  19. package/src/adapter/root-adapter.ts +217 -0
  20. package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
  21. package/src/adapter/system-adapter.ts +80 -63
  22. package/src/ai/navigation.ts +1 -1
  23. package/src/animation/animation-clock.ts +1 -1
  24. package/src/animation/camera-ownership.ts +1 -2
  25. package/src/animation/cubic-spline-interpolant.ts +132 -0
  26. package/src/animation/theatre-clock-binding.ts +2 -2
  27. package/src/animation/theatre-object-binding.ts +4 -4
  28. package/src/animation/xstate-animation-binding.ts +75 -5
  29. package/src/{scene/schema → asset-formats}/camera.ts +2 -5
  30. package/src/{scene/schema → asset-formats}/collider.ts +2 -5
  31. package/src/asset-formats/index.ts +54 -0
  32. package/src/{scene/schema → asset-formats}/instances.ts +10 -6
  33. package/src/{scene/schema → asset-formats}/light.ts +3 -6
  34. package/src/{scene/schema → asset-formats}/material.ts +4 -7
  35. package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
  36. package/src/asset-formats/parse.ts +39 -0
  37. package/src/{scene/schema → asset-formats}/particles.ts +3 -6
  38. package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
  39. package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
  40. package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
  41. package/src/asset-parse-error.ts +33 -0
  42. package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
  43. package/src/assets.ts +1 -1
  44. package/src/audio/wav-encode.ts +9 -9
  45. package/src/canvas-react/engine-bridge.ts +59 -0
  46. package/src/canvas-react/index.ts +50 -0
  47. package/src/canvas-react/pixi-primitive.tsx +202 -0
  48. package/src/canvas-react/pixi-react-adapter.tsx +290 -0
  49. package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
  50. package/src/canvas-react/world-context.ts +328 -0
  51. package/src/core/frame-pacing.ts +100 -0
  52. package/src/core/game-loop.ts +50 -28
  53. package/src/core/seeded-random.ts +7 -7
  54. package/src/core/sim-clock.ts +388 -0
  55. package/src/core/system-runner.ts +17 -63
  56. package/src/core/types.ts +34 -15
  57. package/src/data/data-asset.ts +3 -3
  58. package/src/data/data-check-core.ts +6 -7
  59. package/src/data/data-ref.ts +11 -11
  60. package/src/data/vite-plugin-data.ts +10 -10
  61. package/src/{scene/defaults.ts → defaults.ts} +18 -40
  62. package/src/dev/render-debug-adapter.ts +1 -1
  63. package/src/dev/webgl-frame-capture.ts +1 -1
  64. package/src/ecs/scene-index.ts +439 -0
  65. package/src/ecs/scene-query.ts +43 -0
  66. package/src/{scene → ecs}/user-data.ts +17 -36
  67. package/src/index.ts +7 -9
  68. package/src/input/input-manager.ts +29 -32
  69. package/src/input/input-types.ts +2 -2
  70. package/src/input/schema.ts +5 -5
  71. package/src/loader.ts +57 -0
  72. package/src/manifest/editor-port.ts +69 -0
  73. package/src/manifest/filename.ts +49 -0
  74. package/src/manifest/index.ts +8 -2
  75. package/src/manifest/load-file.ts +11 -0
  76. package/src/manifest/load.ts +65 -77
  77. package/src/manifest/locate.ts +55 -0
  78. package/src/manifest/schema.ts +400 -233
  79. package/src/{scene → physics}/collider-dimensions.ts +3 -3
  80. package/src/physics/physics-registry.ts +1 -1
  81. package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
  82. package/src/pixi/index.ts +43 -0
  83. package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
  84. package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
  85. package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
  86. package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
  87. package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
  88. package/src/react/unmanaged-root-detector.ts +26 -1
  89. package/src/react/use-data.ts +5 -5
  90. package/src/react/use-selection.tsx +15 -42
  91. package/src/react/{game-state.tsx → world-state.tsx} +44 -46
  92. package/src/render/auto-batcher.ts +1 -2
  93. package/src/{scene → render}/instance-mesh.ts +1 -1
  94. package/src/{scene → render}/light-camera-factory.ts +14 -13
  95. package/src/render/lod.ts +17 -0
  96. package/src/{scene → render}/material-factory.ts +8 -7
  97. package/src/{scene → render}/particles-factory.ts +62 -12
  98. package/src/render/render-batch-system.ts +14 -41
  99. package/src/render/render-features.ts +1 -1
  100. package/src/render/render-settings.ts +1 -2
  101. package/src/render/spark-renderer-lifecycle.ts +1 -1
  102. package/src/runtime/create-runtime.ts +352 -463
  103. package/src/runtime/debug-bridge.ts +148 -93
  104. package/src/runtime/debug-registry.ts +79 -54
  105. package/src/runtime/dev-layers.ts +40 -0
  106. package/src/runtime/frame-selector-cache.ts +4 -4
  107. package/src/runtime/game.ts +618 -369
  108. package/src/runtime/gameplay-rng-trap.ts +6 -7
  109. package/src/runtime/input-router.ts +11 -11
  110. package/src/runtime/mount-game.ts +54 -55
  111. package/src/runtime/mount-manifest.ts +154 -150
  112. package/src/runtime/presentation.ts +141 -0
  113. package/src/runtime/render-audio-control.ts +64 -53
  114. package/src/runtime/render-control.ts +45 -51
  115. package/src/runtime/render-seed.ts +3 -4
  116. package/src/runtime/state-bridge.ts +17 -18
  117. package/src/runtime/types.ts +94 -61
  118. package/src/setup/setup-renderer.ts +11 -6
  119. package/src/world3d-react/engine-bridge.ts +46 -33
  120. package/src/world3d-react/index.ts +31 -26
  121. package/src/world3d-react/r3f-adapter.tsx +211 -96
  122. package/src/world3d-react/r3f-root-factory.tsx +91 -0
  123. package/src/world3d-react/renderer-config.ts +137 -0
  124. package/src/world3d-react/world-context.ts +141 -111
  125. package/schemas/entity2d.schema.json +0 -468
  126. package/schemas/prefab.schema.json +0 -9992
  127. package/schemas/scn2d.schema.json +0 -494
  128. package/schemas/vscn.schema.json +0 -10851
  129. package/src/adapter/game-adapter.ts +0 -164
  130. package/src/adapter/ingest/overlay-applier.ts +0 -207
  131. package/src/adapter/ingest/overlay-apply.ts +0 -168
  132. package/src/adapter/ingest/overlay-file.ts +0 -126
  133. package/src/adapter/ingest/overlay-report.ts +0 -176
  134. package/src/animation/gsap-registration.ts +0 -184
  135. package/src/audio/audio-introspection.ts +0 -290
  136. package/src/audio/index.ts +0 -39
  137. package/src/audio/tone-clock-binding.ts +0 -98
  138. package/src/audio/tone-context.ts +0 -175
  139. package/src/audio/tone-offline-render.ts +0 -167
  140. package/src/ecs/component-manager.ts +0 -814
  141. package/src/ecs/game-component.ts +0 -260
  142. package/src/ecs/hmr-swap-report.ts +0 -65
  143. package/src/physics/trigger-dispatch.ts +0 -97
  144. package/src/react/root-adapter.tsx +0 -49
  145. package/src/scene/asset-paths.ts +0 -121
  146. package/src/scene/asset-ref-check.ts +0 -248
  147. package/src/scene/component-registry.ts +0 -51
  148. package/src/scene/parse.ts +0 -204
  149. package/src/scene/scene-apply.ts +0 -407
  150. package/src/scene/scene-diff-schema.ts +0 -115
  151. package/src/scene/scene-diff-types.ts +0 -29
  152. package/src/scene/scene-loader.ts +0 -1526
  153. package/src/scene/scene-query.ts +0 -63
  154. package/src/scene/scene-types.ts +0 -33
  155. package/src/scene/scene-version.ts +0 -40
  156. package/src/scene/schema/animation.ts +0 -46
  157. package/src/scene/schema/audio.ts +0 -25
  158. package/src/scene/schema/entity-ref.ts +0 -78
  159. package/src/scene/schema/entity.ts +0 -189
  160. package/src/scene/schema/index.ts +0 -51
  161. package/src/scene/schema/joint.ts +0 -26
  162. package/src/scene/schema/physics.ts +0 -49
  163. package/src/scene/schema/scene-file.ts +0 -292
  164. package/src/scene/schema/shadow.ts +0 -24
  165. package/src/scene/schema/spline.ts +0 -21
  166. package/src/world2d/asset-paths2d.ts +0 -44
  167. package/src/world2d/capture-to-scene2d.ts +0 -52
  168. package/src/world2d/collision-2d.ts +0 -99
  169. package/src/world2d/entity2d-asset.ts +0 -22
  170. package/src/world2d/index.ts +0 -91
  171. package/src/world2d/physics2d-transform.ts +0 -173
  172. package/src/world2d/physics2d-units.ts +0 -10
  173. package/src/world2d/pixi-game-adapter.ts +0 -439
  174. package/src/world2d/pixi-surface.ts +0 -78
  175. package/src/world2d/scene2d-identity.ts +0 -49
  176. package/src/world2d/scene2d-loader.ts +0 -433
  177. package/src/world2d/schema/entity2d.ts +0 -163
  178. package/src/world2d/schema/physics2d.ts +0 -64
  179. package/src/world2d/schema/sprite.ts +0 -99
  180. package/src/world2d/schema/tilemap.ts +0 -39
  181. package/src/world2d/schema/tuples2d.ts +0 -25
  182. package/src/world2d/transform-writer-2d.ts +0 -42
  183. package/src/world2d/types.ts +0 -74
  184. package/src/world3d-react/behavior.tsx +0 -146
  185. /package/src/{scene → render}/mesh-shadow.ts +0 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `file#key` refs — the resolvable half (docs/DATA-TOOLS-DESIGN.md §2.2).
2
+ * `file#key` refs — the resolvable half.
3
3
  *
4
4
  * Cross-asset references (`"enemies#goblin"`, `"tuning#economy.slotGrowth"`)
5
5
  * have been DETECTED and VALIDATED since W5 (`data-check-core.ts`'s
@@ -24,13 +24,13 @@
24
24
  * (it reads `target.get()` at CALL time, same doctrine as every other data
25
25
  * read — "read every frame", never cache across frames).
26
26
  *
27
- * Deliberately NOT built (see docs/DATA-TOOLS-DESIGN.md §2.2 and the design
28
- * note this module's history carries): eager resolution inside `defineData`
29
- * (would change `get()`'s shipped output type and entangle HMR — an edit to
30
- * `enemies.data.json` would have to re-notify every OTHER asset that
31
- * references it), and a global `resolveRef(addressString)` (needs a runtime
32
- * stem→handle registry that `src/data/assets.ts` — side-effect-free,
33
- * config-load-time, schemas-only by its own module doc — must not become).
27
+ * Deliberately NOT built (and the design note this module's history carries):
28
+ * eager resolution inside `defineData` (would change `get()`'s shipped output
29
+ * type and entangle HMR — an edit to `enemies.data.json` would have to
30
+ * re-notify every OTHER asset that references it), and a global
31
+ * `resolveRef(addressString)` (needs a runtime stem→handle registry that
32
+ * `src/data/assets.ts` — side-effect-free, config-load-time, schemas-only by
33
+ * its own module doc — must not become).
34
34
  */
35
35
 
36
36
  import { z } from 'zod';
@@ -71,7 +71,7 @@ export function dataRef<Stem extends string>(target: Stem): z.ZodType<DataRef<St
71
71
  .string()
72
72
  .regex(refPattern(target), {
73
73
  message:
74
- `must be a "${target}#<key>" reference (docs/DATA-TOOLS-DESIGN.md §2.2) — ` +
74
+ `must be a "${target}#<key>" reference — ` +
75
75
  `expected the target asset stem "${target}", e.g. "${target}#some-key".`,
76
76
  })
77
77
  .meta({ 'x-vgai-ref': target }) as z.ZodType<DataRef<Stem>>;
@@ -110,7 +110,7 @@ export function getRef<T>(target: DataHandle<Record<string, T>>, ref: string): T
110
110
  const m = DATA_REF_PATTERN.exec(ref);
111
111
  if (!m) {
112
112
  throw new Error(
113
- `getRef: "${ref}" is not a "file#key" reference (docs/DATA-TOOLS-DESIGN.md §2.2) — ` +
113
+ `getRef: "${ref}" is not a "file#key" reference — ` +
114
114
  'expected a string shaped like "some-file#some-key".',
115
115
  );
116
116
  }
@@ -136,7 +136,7 @@ export function getRef<T>(target: DataHandle<Record<string, T>>, ref: string): T
136
136
  throw new Error(
137
137
  `getRef: dangling ref "${ref}" — "${missing}" does not exist` +
138
138
  (targetStem ? ` in ${targetStem}.data.json` : '') +
139
- '. Fix the key or remove the ref (refs are "file#key" strings — docs/DATA-TOOLS-DESIGN.md §2.2).',
139
+ '. Fix the key or remove the ref (refs are "file#key" strings).',
140
140
  );
141
141
  }
142
142
  let node: unknown = root;
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `vgaiDataCheck` — build-path validation for data assets and project tools
3
- * (docs/DATA-TOOLS-DESIGN.md §6.7, the enforcement half of W5). A Vite plugin
4
- * for the PROJECT's own `vite.config.ts` (the template wires it; the editor
5
- * dev server boots Vite from the ENGINE's config and never runs this its
6
- * surface is covered by `vgai doctor` instead). Build-only (`apply: 'build'`):
7
- * in dev, `defineData`'s parse-on-load already fails loud in the console.
2
+ * `vgaiDataCheck` — build-path validation for data assets and project tools (the
3
+ * enforcement half of W5). A Vite plugin for the PROJECT's own `vite.config.ts`
4
+ * (the template wires it; the editor dev server boots Vite from the ENGINE's
5
+ * config and never runs this its surface is covered by `vgai doctor` instead).
6
+ * Build-only (`apply: 'build'`): in dev, `defineData`'s parse-on-load already
7
+ * fails loud in the console.
8
8
  *
9
9
  * Three checks, all failing the build loud:
10
10
  *
@@ -206,7 +206,7 @@ export function collectDataCheckProblems(options: VgaiDataCheckOptions): {
206
206
  `dangling data ref in "src/data/${dangling.inAsset}.data.json" at ${dangling.atPath}: ` +
207
207
  `"${dangling.ref}" — "${dangling.missingSegment}" does not exist in ` +
208
208
  `src/data/${dangling.targetAsset}.data.json. Fix the key or remove the ref ` +
209
- '(refs are "file#key" strings — docs/DATA-TOOLS-DESIGN.md §2.2).',
209
+ '(refs are "file#key" strings).',
210
210
  );
211
211
  }
212
212
 
@@ -263,8 +263,8 @@ export function vgaiDataCheck(options: VgaiDataCheckOptions): Plugin {
263
263
  // ONE error carrying every problem — fail loud with the full picture,
264
264
  // not a fix-one-rebuild-see-the-next loop.
265
265
  this.error(
266
- `data-asset validation failed (${errors.length} problem${errors.length === 1 ? '' : 's'} ` +
267
- `— docs/DATA-TOOLS-DESIGN.md §6.7):\n${errors.map((e) => ` - ${e}`).join('\n')}`,
266
+ `data-asset validation failed (${errors.length} problem${errors.length === 1 ? '' : 's'}` +
267
+ `):\n${errors.map((e) => ` - ${e}`).join('\n')}`,
268
268
  );
269
269
  }
270
270
  },
@@ -279,7 +279,7 @@ export function vgaiDataCheck(options: VgaiDataCheckOptions): Plugin {
279
279
  if (leaked.size > 0) {
280
280
  this.error(
281
281
  'project tool code reached the game build — tools are editor-only and must never ship ' +
282
- 'to players (docs/DATA-TOOLS-DESIGN.md §4). Remove every game-code import of these ' +
282
+ 'to players. Remove every game-code import of these ' +
283
283
  `modules (tools may import game code, never the reverse):\n${[...leaked]
284
284
  .map((id) => ` - ${id}`)
285
285
  .join('\n')}`,
@@ -1,17 +1,22 @@
1
- /** Single source of truth for all scene default values. */
1
+ /**
2
+ * Single source of truth for default values the engine's factories read.
3
+ *
4
+ * WO-8 deleted SEVEN groups — `transform`, `entity`, `audio`, `shadow`,
5
+ * `animation`, `fog`, `environment` — because every one of their readers was a
6
+ * `.vscn.json` consumer (`entity-factory.ts`, `scene-loader.ts`,
7
+ * `scene-sync.ts`, `scene-serializer.ts`, the per-section inspectors) and
8
+ * `defaults-source-scan.test.ts` reported them with ZERO `DEFAULTS.<group>`
9
+ * readers anywhere in engine/editor src or tests. That guard offers two remedies
10
+ * — wire the group to the factory that reads it, or delete it if truly unused —
11
+ * and there is no surviving factory to wire these to: `transform` restated
12
+ * THREE's own Object3D constructor values, `entity.locked`/ `pivot` were
13
+ * descriptor-only fields, and `fog`/`environment.envMapIntensity`/ `shadow` were
14
+ * the `.vscn` `environment` block that `render-env.ts` explicitly did NOT carry
15
+ * forward. Keeping them would have been exactly the dead documentation T1.13
16
+ * created this guard to prevent.
17
+ *
18
+ */
2
19
  export const DEFAULTS = {
3
- transform: {
4
- position: [0, 0, 0] as [number, number, number],
5
- rotation: [0, 0, 0, 1] as [number, number, number, number],
6
- scale: [1, 1, 1] as [number, number, number],
7
- },
8
-
9
- entity: {
10
- visible: true,
11
- locked: false,
12
- pivot: [0, 0, 0] as [number, number, number],
13
- },
14
-
15
20
  material: {
16
21
  type: 'standard' as const,
17
22
  color: '#888888',
@@ -57,39 +62,12 @@ export const DEFAULTS = {
57
62
  height: 1080,
58
63
  },
59
64
 
60
- audio: {
61
- spatial: true,
62
- volume: 1,
63
- refDistance: 1,
64
- rolloffFactor: 1,
65
- maxDistance: 10000,
66
- loop: false,
67
- autoplay: false,
68
- },
69
-
70
65
  collider: {
71
66
  cuboid: { halfExtents: [0.5, 0.5, 0.5] as [number, number, number] },
72
67
  ball: { radius: 0.5 },
73
68
  capsule: { radius: 0.25, halfHeight: 0.5 },
74
69
  },
75
70
 
76
- shadow: {
77
- mapSize: 1024,
78
- },
79
-
80
- animation: {
81
- loop: true,
82
- },
83
-
84
- fog: {
85
- linear: { near: 1, far: 100 },
86
- exponential: { density: 0.01 },
87
- },
88
-
89
- environment: {
90
- envMapIntensity: 1.0,
91
- },
92
-
93
71
  toneMapping: {
94
72
  // T1.13: the effective renderer default is ACES (see
95
73
  // setup-renderer.ts's `createHostRenderer` + `applyScenePostProcessing`,
@@ -3,7 +3,7 @@
3
3
  * the WebGL2 frame capture (`webgl-frame-capture.ts`), a live three.js scene,
4
4
  * and the render adapter's per-frame render pass.
5
5
  *
6
- * Split OUT of `vgai-scene-game-adapter.ts` so the capture/attribution/restore
6
+ * Split OUT of `setup-three-root-adapter.ts` so the capture/attribution/restore
7
7
  * flow is unit-testable in a headless Node test with a mock GL context and a
8
8
  * plain scene — a real non-headless GPU mount (EffectComposer + WebGLRenderer)
9
9
  * cannot run under vitest here, the same reason the adapter suite drives
@@ -4,7 +4,7 @@
4
4
  * WHY FIRST-PARTY, NOT spectorjs (recorded per the "use libraries directly, no
5
5
  * wrappers" rule): the capture seam we need is the WebGL2 context this engine
6
6
  * ALREADY owns end-to-end (`renderer.getContext()` in
7
- * `vgai-scene-game-adapter.ts`). spectorjs is absent from node_modules, and its
7
+ * `setup-three-root-adapter.ts`). spectorjs is absent from node_modules, and its
8
8
  * actual value is a bundled inspector UI we would discard — adopting it imports
9
9
  * ~2MB of library to keep ~10% of it, and it wraps the context with its own
10
10
  * global patching model rather than the instance-shadow-and-restore discipline
@@ -0,0 +1,439 @@
1
+ /**
2
+ * `createSceneIndex` — the LIVE scene index (punchlist P2, `observe`).
3
+ *
4
+ * `scene-query.ts` answers "which objects carry tag X?" by walking the whole
5
+ * graph on every call, and it tells nobody when the answer changes. This
6
+ * module is the notified half: a tag/entity-id index kept current as objects
7
+ * join and leave the scene, plus the change signals a game needs so it never
8
+ * has to poll.
9
+ *
10
+ * ## The one event primitive is `EventTarget`
11
+ *
12
+ * There is no bus, no emitter class, no `subscribe()` returning a closure.
13
+ * `index.events` is a plain `EventTarget` dispatching `CustomEvent`s, and
14
+ * **unsubscribe is `AbortSignal`** — the platform's own answer:
15
+ *
16
+ * ```ts
17
+ * const ac = new AbortController();
18
+ * ctx.sceneIndex.events.addEventListener(
19
+ * 'tagadded',
20
+ * (e) => console.log((e as SceneIndexEvent<'tagadded'>).detail.object.name),
21
+ * { signal: ac.signal },
22
+ * );
23
+ * ac.abort(); // done — nothing to remember, nothing to leak
24
+ * ```
25
+ *
26
+ * {@link onSceneIndexEvent} is the same call with the detail typed for you.
27
+ * `index.eventsFor(obj)` is the per-object target (Roblox's `inst.Changed`):
28
+ * the same events, already filtered to one object, so a listener that only
29
+ * cares about one entity does not filter by hand.
30
+ *
31
+ * ## How the index stays live: three's own parent events, not a monkeypatch
32
+ *
33
+ * three r155+ dispatches `childadded`/`childremoved` on the **parent** from
34
+ * `Object3D.add`/`remove` (and therefore from `attach`, `clear`,
35
+ * `removeFromParent`, and R3F's reconciler, which all go through those two).
36
+ * The index subscribes to those two events on every object it indexes; on
37
+ * `childadded` it walks the new subtree, indexes it, and subscribes each
38
+ * node, and on `childremoved` it does the inverse. `Object3D.prototype` is
39
+ * NOT patched — nothing in this repo patches a library prototype.
40
+ *
41
+ * Consequences worth knowing:
42
+ *
43
+ * - **The quiet-frame cost is zero.** The index registers no system and runs
44
+ * no per-frame reconcile; it does work only when three tells it something
45
+ * moved. Nothing here ticks.
46
+ * - **Reparenting nets out to one entry.** `parent.add(obj)` removes `obj`
47
+ * from its old parent first, so a move inside the indexed tree fires
48
+ * `childremoved` then `childadded` — unindex, then index. The object ends
49
+ * up indexed exactly once (and a listener sees an `entityremoved` followed
50
+ * by an `entityadded`, which is the truth about what happened).
51
+ * - **Splicing `parent.children` by hand is invisible to the index**, because
52
+ * it is invisible to three as well. Use `add`/`remove`.
53
+ *
54
+ * ## Tags are stored where they always were
55
+ *
56
+ * `addTag`/`removeTag` write the existing `userData['tags']` array, so
57
+ * `queryByTag` keeps returning the truth and nothing has to be migrated. The
58
+ * index is the fast, notified path over the same storage. A game that writes
59
+ * `userData['tags']` by hand gets a stale index — that is not prevented, it is
60
+ * repaired: call {@link SceneIndex.reindex} on the object.
61
+ *
62
+ * ## Attributes
63
+ *
64
+ * `getAttribute`/`setAttribute`/`deleteAttribute` are typed accessors over ONE
65
+ * new reserved userData key, `attributes` (registered in `ecs/user-data.ts`,
66
+ * `Record<string, string | number | boolean>`). They emit `attributechanged`
67
+ * only when the value actually changes, so a re-set of the same value is
68
+ * silent.
69
+ *
70
+ * ## What this deliberately does NOT do
71
+ *
72
+ * - **No property-level `Changed` on transforms.** three does not notify on
73
+ * `position.set(...)`; faking it means diffing every transform every frame,
74
+ * and a signal that is really a per-frame poll is worse than no signal —
75
+ * it hides its own cost. Read the transform when you need it, or drive the
76
+ * change from the code that made it.
77
+ * - **No `Touched`.** Contact/trigger events are Rapier's, dispatched through
78
+ * `ctx.collisions.onCollision`. A second path would be a second truth.
79
+ * - **No 2D index.** This module indexes a three graph only.
80
+ */
81
+
82
+ import type * as THREE from 'three';
83
+ import { deleteUserData, getUserData, setUserData } from './user-data';
84
+
85
+ /** The value types an attribute may hold — deliberately JSON-simple, so an
86
+ * attribute survives serialization and network replication unchanged. */
87
+ export type AttributeValue = string | number | boolean;
88
+
89
+ /** The `detail` payload of every scene-index event, keyed by event type.
90
+ * Every detail carries `object`, so a scene-wide listener filters without a
91
+ * second lookup. */
92
+ export interface SceneIndexEventDetails {
93
+ /** An object (and, one event each, its descendants) joined the indexed tree. */
94
+ entityadded: { object: THREE.Object3D };
95
+ /** An object (and, one event each, its descendants) left the indexed tree. */
96
+ entityremoved: { object: THREE.Object3D };
97
+ /** A tag was added via {@link SceneIndex.addTag}. */
98
+ tagadded: { object: THREE.Object3D; tag: string };
99
+ /** A tag was removed via {@link SceneIndex.removeTag}. */
100
+ tagremoved: { object: THREE.Object3D; tag: string };
101
+ /** An attribute's value changed (`value` is `undefined` after a delete;
102
+ * `previous` is `undefined` when it was previously unset). */
103
+ attributechanged: {
104
+ object: THREE.Object3D;
105
+ name: string;
106
+ value: AttributeValue | undefined;
107
+ previous: AttributeValue | undefined;
108
+ };
109
+ }
110
+
111
+ /** Every event type `index.events` / `index.eventsFor(obj)` dispatches. */
112
+ export type SceneIndexEventType = keyof SceneIndexEventDetails;
113
+
114
+ /** The `CustomEvent` a given scene-index event type dispatches. */
115
+ export type SceneIndexEvent<K extends SceneIndexEventType = SceneIndexEventType> = CustomEvent<
116
+ SceneIndexEventDetails[K]
117
+ >;
118
+
119
+ /** Event-type → event, for callers that want a typed `addEventListener` map. */
120
+ export type SceneIndexEventMap = { [K in SceneIndexEventType]: SceneIndexEvent<K> };
121
+
122
+ /**
123
+ * `addEventListener` with the detail typed — the ONLY convenience over the
124
+ * platform call, and it returns nothing on purpose: unsubscribe stays
125
+ * `AbortSignal` (`{ signal }`), never a returned closure.
126
+ */
127
+ export function onSceneIndexEvent<K extends SceneIndexEventType>(
128
+ target: EventTarget,
129
+ type: K,
130
+ listener: (detail: SceneIndexEventDetails[K], event: SceneIndexEvent<K>) => void,
131
+ options?: { signal?: AbortSignal; once?: boolean },
132
+ ): void {
133
+ target.addEventListener(
134
+ type,
135
+ (event) => {
136
+ const custom = event as SceneIndexEvent<K>;
137
+ listener(custom.detail, custom);
138
+ },
139
+ options,
140
+ );
141
+ }
142
+
143
+ /** The live scene index — see this module's header. */
144
+ export interface SceneIndex {
145
+ /** The scene-wide signal target. Listen with `addEventListener(type, fn,
146
+ * { signal })`, or {@link onSceneIndexEvent} for a typed `detail`. */
147
+ readonly events: EventTarget;
148
+ /** The per-object signal target (Roblox's `inst.Changed`): the same events,
149
+ * filtered to `obj`. Created lazily and held weakly. */
150
+ eventsFor(obj: THREE.Object3D): EventTarget;
151
+
152
+ /** Every indexed object carrying `tag`, in index order. Never traverses. */
153
+ byTag(tag: string): readonly THREE.Object3D[];
154
+ /** The indexed object whose `userData['entityId']` is `id` (the first, in
155
+ * index order, if a project has minted the id twice). Never traverses. */
156
+ byEntityId(id: string): THREE.Object3D | undefined;
157
+ /** True when `obj` is currently in the indexed tree. */
158
+ has(obj: THREE.Object3D): boolean;
159
+
160
+ /** Add `tag` to `obj`'s `userData['tags']` and the index; emits `tagadded`.
161
+ * A no-op (and silent) when the tag is already present. Legal on an object
162
+ * that has not joined the scene yet — the tag is stored, and the index
163
+ * picks it up when the object is added. */
164
+ addTag(obj: THREE.Object3D, tag: string): void;
165
+ /** Remove `tag`; emits `tagremoved`. A no-op (and silent) when absent. */
166
+ removeTag(obj: THREE.Object3D, tag: string): void;
167
+ /** True when `obj` carries `tag` (reads `userData['tags']`, so it is
168
+ * truthful even for an object that is not in the scene yet). */
169
+ hasTag(obj: THREE.Object3D, tag: string): boolean;
170
+
171
+ /** Read one attribute (`undefined` when unset). */
172
+ getAttribute(obj: THREE.Object3D, name: string): AttributeValue | undefined;
173
+ /** Write one attribute; emits `attributechanged` only if the value changed. */
174
+ setAttribute(obj: THREE.Object3D, name: string, value: AttributeValue): void;
175
+ /** Delete one attribute; emits `attributechanged` with `value: undefined`. */
176
+ deleteAttribute(obj: THREE.Object3D, name: string): void;
177
+
178
+ /** Re-read `obj`'s subtree from `userData` — the escape hatch for code that
179
+ * wrote `userData['tags']`/`userData['entityId']` by hand. Indexes anything
180
+ * in the subtree that is not indexed yet (emitting `entityadded`) and
181
+ * re-syncs the tag/id entries of everything that is. */
182
+ reindex(obj: THREE.Object3D): void;
183
+
184
+ /** Drop every listener this index installed and forget everything it knows.
185
+ * Called from the owning root's teardown; safe to call twice. */
186
+ dispose(): void;
187
+ }
188
+
189
+ function readTags(obj: THREE.Object3D): string[] {
190
+ const tags = getUserData(obj, 'tags');
191
+ return Array.isArray(tags) ? tags : [];
192
+ }
193
+
194
+ function readAttributes(obj: THREE.Object3D): Record<string, AttributeValue> | undefined {
195
+ const attributes = getUserData(obj, 'attributes');
196
+ return attributes && typeof attributes === 'object' ? attributes : undefined;
197
+ }
198
+
199
+ /**
200
+ * Build a live index over `root` and everything currently under it. One index
201
+ * per three root; the root adapter creates it and exposes it as
202
+ * `ctx.sceneIndex`.
203
+ */
204
+ export function createSceneIndex(root: THREE.Object3D): SceneIndex {
205
+ const events = new EventTarget();
206
+ // Weak on purpose: the per-object target exists only while something else
207
+ // still references the object. The index keeps NO map from target back to
208
+ // object, so a removed-and-forgotten object can be collected with its target.
209
+ const objectTargets = new WeakMap<THREE.Object3D, EventTarget>();
210
+
211
+ const indexed = new Set<THREE.Object3D>();
212
+ const tagged = new Map<string, Set<THREE.Object3D>>();
213
+ // Arrays, not single values: a project that mints the same entityId twice
214
+ // must not blind `byEntityId` for the survivor when one of them is removed.
215
+ const byId = new Map<string, THREE.Object3D[]>();
216
+ // The tag/id snapshot each object was indexed under, so unindexing (and
217
+ // `reindex`) can undo exactly what indexing did even if `userData` has been
218
+ // rewritten by hand in between.
219
+ const indexedTags = new WeakMap<THREE.Object3D, string[]>();
220
+ const indexedIds = new WeakMap<THREE.Object3D, string>();
221
+ let disposed = false;
222
+
223
+ function emit<K extends SceneIndexEventType>(type: K, detail: SceneIndexEventDetails[K]): void {
224
+ // Two distinct CustomEvent instances: one Event object cannot carry two
225
+ // `target`s, and re-dispatching a dispatched event is a spec footgun.
226
+ events.dispatchEvent(new CustomEvent(type, { detail }));
227
+ const perObject = objectTargets.get(detail.object);
228
+ if (perObject) perObject.dispatchEvent(new CustomEvent(type, { detail }));
229
+ }
230
+
231
+ function addToTagIndex(obj: THREE.Object3D, tag: string): void {
232
+ let set = tagged.get(tag);
233
+ if (!set) {
234
+ set = new Set();
235
+ tagged.set(tag, set);
236
+ }
237
+ set.add(obj);
238
+ }
239
+
240
+ function removeFromTagIndex(obj: THREE.Object3D, tag: string): void {
241
+ const set = tagged.get(tag);
242
+ if (!set) return;
243
+ set.delete(obj);
244
+ if (set.size === 0) tagged.delete(tag);
245
+ }
246
+
247
+ function addToIdIndex(obj: THREE.Object3D, id: string): void {
248
+ const list = byId.get(id);
249
+ if (list) {
250
+ if (!list.includes(obj)) list.push(obj);
251
+ } else {
252
+ byId.set(id, [obj]);
253
+ }
254
+ }
255
+
256
+ function removeFromIdIndex(obj: THREE.Object3D, id: string): void {
257
+ const list = byId.get(id);
258
+ if (!list) return;
259
+ const at = list.indexOf(obj);
260
+ if (at !== -1) list.splice(at, 1);
261
+ if (list.length === 0) byId.delete(id);
262
+ }
263
+
264
+ const onChildAdded = (event: { child: THREE.Object3D }): void => {
265
+ indexSubtree(event.child);
266
+ };
267
+ const onChildRemoved = (event: { child: THREE.Object3D }): void => {
268
+ unindexSubtree(event.child);
269
+ };
270
+
271
+ /** Point the tag entries at whatever `userData['tags']` says right now,
272
+ * undoing whatever the object was previously indexed under. */
273
+ function syncTags(obj: THREE.Object3D): void {
274
+ for (const tag of indexedTags.get(obj) ?? []) removeFromTagIndex(obj, tag);
275
+ const tags = [...new Set(readTags(obj))];
276
+ indexedTags.set(obj, tags);
277
+ for (const tag of tags) addToTagIndex(obj, tag);
278
+ }
279
+
280
+ /** Same, for the entity-id entry. */
281
+ function syncId(obj: THREE.Object3D): void {
282
+ const previous = indexedIds.get(obj);
283
+ if (previous !== undefined) removeFromIdIndex(obj, previous);
284
+ indexedIds.delete(obj);
285
+ const id = getUserData(obj, 'entityId');
286
+ if (typeof id === 'string' && id !== '') {
287
+ indexedIds.set(obj, id);
288
+ addToIdIndex(obj, id);
289
+ }
290
+ }
291
+
292
+ function indexOne(obj: THREE.Object3D): void {
293
+ if (indexed.has(obj)) return;
294
+ indexed.add(obj);
295
+ // three's EventDispatcher de-dupes an identical (type, listener) pair, so
296
+ // this is idempotent even if an object somehow arrives twice.
297
+ obj.addEventListener('childadded', onChildAdded);
298
+ obj.addEventListener('childremoved', onChildRemoved);
299
+ syncTags(obj);
300
+ syncId(obj);
301
+ emit('entityadded', { object: obj });
302
+ }
303
+
304
+ function unindexOne(obj: THREE.Object3D): void {
305
+ if (!indexed.has(obj)) return;
306
+ indexed.delete(obj);
307
+ obj.removeEventListener('childadded', onChildAdded);
308
+ obj.removeEventListener('childremoved', onChildRemoved);
309
+
310
+ for (const tag of indexedTags.get(obj) ?? []) removeFromTagIndex(obj, tag);
311
+ indexedTags.delete(obj);
312
+ const id = indexedIds.get(obj);
313
+ if (id !== undefined) {
314
+ removeFromIdIndex(obj, id);
315
+ indexedIds.delete(obj);
316
+ }
317
+
318
+ emit('entityremoved', { object: obj });
319
+ }
320
+
321
+ function indexSubtree(subtreeRoot: THREE.Object3D): void {
322
+ if (disposed) return;
323
+ subtreeRoot.traverse(indexOne);
324
+ }
325
+
326
+ function unindexSubtree(subtreeRoot: THREE.Object3D): void {
327
+ if (disposed) return;
328
+ // `traverse` still works on a detached subtree: `remove` only broke the
329
+ // link to the parent, the children below are intact.
330
+ subtreeRoot.traverse(unindexOne);
331
+ }
332
+
333
+ indexSubtree(root);
334
+
335
+ return {
336
+ events,
337
+
338
+ eventsFor(obj) {
339
+ let target = objectTargets.get(obj);
340
+ if (!target) {
341
+ target = new EventTarget();
342
+ objectTargets.set(obj, target);
343
+ }
344
+ return target;
345
+ },
346
+
347
+ byTag(tag) {
348
+ const set = tagged.get(tag);
349
+ return set ? [...set] : [];
350
+ },
351
+
352
+ byEntityId(id) {
353
+ return byId.get(id)?.[0];
354
+ },
355
+
356
+ has(obj) {
357
+ return indexed.has(obj);
358
+ },
359
+
360
+ addTag(obj, tag) {
361
+ const tags = readTags(obj);
362
+ if (tags.includes(tag)) return;
363
+ setUserData(obj, 'tags', [...tags, tag]);
364
+ if (indexed.has(obj)) {
365
+ indexedTags.set(obj, [...(indexedTags.get(obj) ?? []), tag]);
366
+ addToTagIndex(obj, tag);
367
+ }
368
+ emit('tagadded', { object: obj, tag });
369
+ },
370
+
371
+ removeTag(obj, tag) {
372
+ const tags = readTags(obj);
373
+ if (!tags.includes(tag)) return;
374
+ setUserData(
375
+ obj,
376
+ 'tags',
377
+ tags.filter((t) => t !== tag),
378
+ );
379
+ if (indexed.has(obj)) {
380
+ indexedTags.set(
381
+ obj,
382
+ (indexedTags.get(obj) ?? []).filter((t) => t !== tag),
383
+ );
384
+ removeFromTagIndex(obj, tag);
385
+ }
386
+ emit('tagremoved', { object: obj, tag });
387
+ },
388
+
389
+ hasTag(obj, tag) {
390
+ return readTags(obj).includes(tag);
391
+ },
392
+
393
+ getAttribute(obj, name) {
394
+ return readAttributes(obj)?.[name];
395
+ },
396
+
397
+ setAttribute(obj, name, value) {
398
+ const attributes = readAttributes(obj);
399
+ const previous = attributes?.[name];
400
+ if (previous === value) return;
401
+ if (attributes) attributes[name] = value;
402
+ else setUserData(obj, 'attributes', { [name]: value });
403
+ emit('attributechanged', { object: obj, name, value, previous });
404
+ },
405
+
406
+ deleteAttribute(obj, name) {
407
+ const attributes = readAttributes(obj);
408
+ if (!attributes || !(name in attributes)) return;
409
+ const previous = attributes[name];
410
+ delete attributes[name];
411
+ if (Object.keys(attributes).length === 0) deleteUserData(obj, 'attributes');
412
+ emit('attributechanged', { object: obj, name, value: undefined, previous });
413
+ },
414
+
415
+ reindex(obj) {
416
+ if (disposed) return;
417
+ obj.traverse((node) => {
418
+ if (indexed.has(node)) {
419
+ syncTags(node);
420
+ syncId(node);
421
+ } else {
422
+ indexOne(node);
423
+ }
424
+ });
425
+ },
426
+
427
+ dispose() {
428
+ if (disposed) return;
429
+ disposed = true;
430
+ for (const obj of indexed) {
431
+ obj.removeEventListener('childadded', onChildAdded);
432
+ obj.removeEventListener('childremoved', onChildRemoved);
433
+ }
434
+ indexed.clear();
435
+ tagged.clear();
436
+ byId.clear();
437
+ },
438
+ };
439
+ }
@@ -0,0 +1,43 @@
1
+ import type * as THREE from 'three';
2
+ import { getUserData } from './user-data';
3
+
4
+ /**
5
+ * Runtime queries over a loaded THREE scene graph (P1.6d).
6
+ *
7
+ * These helpers walk a THREE.Object3D graph and read entity metadata off
8
+ * `userData`, so gameplay code can find objects by tag or name without
9
+ * bookkeeping its own indexes.
10
+ *
11
+ * Canonical metadata locations:
12
+ * - tags: `userData['tags']` (string[])
13
+ * - name: `Object3D.name`
14
+ *
15
+ * These are one-shot walks that notify nobody. For a repeated query, or for a
16
+ * signal when the answer changes, use the LIVE index instead —
17
+ * `ctx.sceneIndex` (`./scene-index.ts`): it reads the same `userData['tags']`
18
+ * storage, answers without traversing, and dispatches `entityadded`/
19
+ * `entityremoved`/`tagadded`/`tagremoved` on an `EventTarget`.
20
+ */
21
+
22
+ function getTags(obj: THREE.Object3D): string[] {
23
+ const direct = getUserData(obj, 'tags');
24
+ return Array.isArray(direct) ? direct : [];
25
+ }
26
+
27
+ /** All objects in the graph carrying the given tag (depth-first, document order). */
28
+ export function queryByTag(root: THREE.Object3D, tag: string): THREE.Object3D[] {
29
+ const matches: THREE.Object3D[] = [];
30
+ root.traverse((obj) => {
31
+ if (getTags(obj).includes(tag)) matches.push(obj);
32
+ });
33
+ return matches;
34
+ }
35
+
36
+ /** All objects in the graph with the given name (depth-first, document order). */
37
+ export function queryByName(root: THREE.Object3D, name: string): THREE.Object3D[] {
38
+ const matches: THREE.Object3D[] = [];
39
+ root.traverse((obj) => {
40
+ if (obj.name === name) matches.push(obj);
41
+ });
42
+ return matches;
43
+ }