@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -23,7 +23,138 @@ export class SceneParseError extends Error {
23
23
  }
24
24
  }
25
25
 
26
+ // ---------------------------------------------------------------------------
27
+ // D8 — removed-format migration guard: declarative property tracks
28
+ // (`animation.tracks`) were deleted (spec §3.2/§10 D8; Theatre — `@theatre/core`
29
+ // — is the sole authored continuous-animation source now). `SceneAnimationSchema`
30
+ // no longer declares a `tracks` field at all (so the generated JSON schema stays
31
+ // clean), which means Zod's default object mode would otherwise silently STRIP
32
+ // an authored `animation.tracks` rather than reporting it — violating Global AC
33
+ // §6 ("Removed formats fail with a concise migration/removal error rather than
34
+ // being partially read"). This walks the RAW pre-Zod JSON (Zod would already
35
+ // have stripped `tracks` by the time any schema-level check could see it) and
36
+ // throws a `SceneParseError` before Zod ever parses the data. Matches on the
37
+ // literal key `animation` (singular, an object) carrying a `tracks` property —
38
+ // this is deliberately narrower than Scene UI's unrelated, RETAINED
39
+ // `ui[].animations[].tracks` (plural `animations`), which this guard must not
40
+ // trip on.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** True when `value` is a plain object (not an array) carrying a `tracks` property — the shape
44
+ * `SceneAnimationSchema` used to declare before D8 deleted it. */
45
+ function isRemovedTracksAnimation(value: unknown): boolean {
46
+ return !!value && typeof value === 'object' && !Array.isArray(value) && 'tracks' in value;
47
+ }
48
+
49
+ function findRemovedPropertyTrackPathInArray(node: unknown[], path: string): string | undefined {
50
+ for (let i = 0; i < node.length; i++) {
51
+ const hit = findRemovedPropertyTrackPath(node[i], `${path}[${i}]`);
52
+ if (hit) return hit;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ function findRemovedPropertyTrackPathInObject(
58
+ obj: Record<string, unknown>,
59
+ path: string,
60
+ ): string | undefined {
61
+ if (isRemovedTracksAnimation(obj['animation'])) {
62
+ return `${path ? `${path}.` : ''}animation.tracks`;
63
+ }
64
+ for (const [key, value] of Object.entries(obj)) {
65
+ const hit = findRemovedPropertyTrackPath(value, path ? `${path}.${key}` : key);
66
+ if (hit) return hit;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function findRemovedPropertyTrackPath(node: unknown, path: string): string | undefined {
72
+ if (Array.isArray(node)) return findRemovedPropertyTrackPathInArray(node, path);
73
+ if (node && typeof node === 'object') {
74
+ return findRemovedPropertyTrackPathInObject(node as Record<string, unknown>, path);
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ function assertNoRemovedPropertyTracks(json: unknown, filePath?: string): void {
80
+ const hit = findRemovedPropertyTrackPath(json, '');
81
+ if (!hit) return;
82
+ const issue: ZodIssue = {
83
+ code: 'custom',
84
+ message:
85
+ 'property tracks were removed; author continuous animation with Theatre (@theatre/core) ' +
86
+ 'instead — see docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §3.2/§10 D8.',
87
+ path: hit.split('.'),
88
+ };
89
+ throw new SceneParseError([issue], filePath);
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // E5 — removed-format migration guard: the AnimGraph state-machine runtime,
94
+ // `.animgraph.json` format, and `SceneAnimationSchema.animGraph` field were
95
+ // deleted (spec §3.3/§11 E5; XState + native Three `AnimationMixer` — see
96
+ // `xstate-animation-binding.ts` — replace it). `SceneAnimationSchema` no
97
+ // longer declares an `animGraph` field at all (so it carries zero trace in
98
+ // the generated JSON schema), which means Zod's default object mode would
99
+ // otherwise silently STRIP an authored `animation.animGraph` rather than
100
+ // reporting it — violating Global AC §6. Mirrors
101
+ // `assertNoRemovedPropertyTracks` above exactly: walks the RAW pre-Zod JSON
102
+ // and throws a `SceneParseError` before Zod ever parses the data.
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /** True when `value` is a plain object (not an array) carrying an `animGraph` property — the
106
+ * shape `SceneAnimationSchema` used to declare before E5 deleted it. */
107
+ function isRemovedAnimGraphAnimation(value: unknown): boolean {
108
+ return !!value && typeof value === 'object' && !Array.isArray(value) && 'animGraph' in value;
109
+ }
110
+
111
+ function findRemovedAnimGraphPathInArray(node: unknown[], path: string): string | undefined {
112
+ for (let i = 0; i < node.length; i++) {
113
+ const hit = findRemovedAnimGraphPath(node[i], `${path}[${i}]`);
114
+ if (hit) return hit;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function findRemovedAnimGraphPathInObject(
120
+ obj: Record<string, unknown>,
121
+ path: string,
122
+ ): string | undefined {
123
+ if (isRemovedAnimGraphAnimation(obj['animation'])) {
124
+ return `${path ? `${path}.` : ''}animation.animGraph`;
125
+ }
126
+ for (const [key, value] of Object.entries(obj)) {
127
+ const hit = findRemovedAnimGraphPath(value, path ? `${path}.${key}` : key);
128
+ if (hit) return hit;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ function findRemovedAnimGraphPath(node: unknown, path: string): string | undefined {
134
+ if (Array.isArray(node)) return findRemovedAnimGraphPathInArray(node, path);
135
+ if (node && typeof node === 'object') {
136
+ return findRemovedAnimGraphPathInObject(node as Record<string, unknown>, path);
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ function assertNoRemovedAnimGraph(json: unknown, filePath?: string): void {
142
+ const hit = findRemovedAnimGraphPath(json, '');
143
+ if (!hit) return;
144
+ const issue: ZodIssue = {
145
+ code: 'custom',
146
+ message:
147
+ 'AnimGraph was removed; drive character animation with an XState animation machine instead ' +
148
+ '— see docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §3.3/§5.6/§11 E5 and ' +
149
+ 'packages/engine/src/animation/xstate-animation-binding.ts.',
150
+ path: hit.split('.'),
151
+ };
152
+ throw new SceneParseError([issue], filePath);
153
+ }
154
+
26
155
  export function parseSceneFile(json: unknown, filePath?: string): SceneFile {
156
+ assertNoRemovedPropertyTracks(json, filePath);
157
+ assertNoRemovedAnimGraph(json, filePath);
27
158
  const result = SceneFileSchema.safeParse(json);
28
159
  if (!result.success) {
29
160
  throw new SceneParseError(result.error.issues, filePath);
@@ -36,6 +167,8 @@ export function parseSceneFile(json: unknown, filePath?: string): SceneFile {
36
167
  }
37
168
 
38
169
  export function parsePrefabFile(json: unknown, filePath?: string): PrefabFile {
170
+ assertNoRemovedPropertyTracks(json, filePath);
171
+ assertNoRemovedAnimGraph(json, filePath);
39
172
  const result = PrefabFileSchema.safeParse(json);
40
173
  if (!result.success) {
41
174
  throw new SceneParseError(result.error.issues, filePath);
@@ -114,6 +114,49 @@ function cloneEntity(e: SceneEntity): SceneEntity {
114
114
  return JSON.parse(JSON.stringify(e)) as SceneEntity;
115
115
  }
116
116
 
117
+ /** True iff at least one entity in the tree (at any depth) carries a persisted `id`. */
118
+ function hasAnyPersistedId(entities: SceneEntity[]): boolean {
119
+ for (const e of entities) {
120
+ if (e.id) return true;
121
+ if (e.children && hasAnyPersistedId(e.children)) return true;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * The id-persistence trap, made honest.
128
+ *
129
+ * `id` is `z.string().optional()` in the entity schema ("auto-generated if
130
+ * omitted"), and the ids an agent can actually DISCOVER — `vgai status`' —
131
+ * are the RUNTIME uuids a live editor session assigns to id-less entities on
132
+ * load (`editor-store.ts`'s `assignMissingIds`). `applyDiff` resolves ops
133
+ * against the FILE. So for any scene authored the normal way (hand-written,
134
+ * or scaffolded — none of which shipped with ids), the only ids on offer
135
+ * could never resolve here, and the failure read `entity not found` — as if
136
+ * the CALLER had typo'd the id. It is not a typo: NOTHING in that file could
137
+ * ever have matched.
138
+ *
139
+ * The durable half of the fix makes ids real (the editor now persists
140
+ * auto-assigned ids back to the scene file on load — see `loadDocument`), so
141
+ * this message is the safety net for a file that has not been through that
142
+ * path yet. It fires ONLY when the scene has zero persisted ids at all — a
143
+ * genuine bad-id typo against an id-carrying scene still gets the plain,
144
+ * unpadded message.
145
+ */
146
+ const NO_IDS_HINT =
147
+ ' — and this scene file has NO persisted entity ids AT ALL, so nothing in it could have ' +
148
+ 'matched, whatever id the op named. This is almost certainly not a typo: `vgai status` ' +
149
+ 'reports RUNTIME ids a live editor session assigns to id-less entities on load, and those ' +
150
+ 'only exist in the FILE once the editor has written them back (it now does that ' +
151
+ 'automatically, via autosave, shortly after the scene loads). Open this scene in the editor ' +
152
+ '(`vgai edit`), let it autosave, then re-read the ids with `vgai status` and retry. Do not ' +
153
+ 'hand-write ids into the file.';
154
+
155
+ /** Append the no-ids explanation to a not-found message when (and only when) the file has no ids. */
156
+ function notFound(message: string, sceneHasIds: boolean): string {
157
+ return sceneHasIds ? message : message + NO_IDS_HINT;
158
+ }
159
+
117
160
  /** Recursively search `list` (and descendants) for `id`; if found, splice it out (subtree intact) and return it. */
118
161
  function detachById(list: SceneEntity[], id: string): SceneEntity | undefined {
119
162
  for (let i = 0; i < list.length; i++) {
@@ -168,6 +211,9 @@ interface Placement {
168
211
  export function applyDiff(scene: SceneFile, diff: SceneDiff): SceneFile {
169
212
  const working: SceneFile = JSON.parse(JSON.stringify(scene)) as SceneFile;
170
213
  const ops = diff.ops;
214
+ // Captured from the INPUT scene, before any op mutates `working` — see
215
+ // `NO_IDS_HINT` above for what this distinguishes and why.
216
+ const sceneHasIds = hasAnyPersistedId(scene.entities);
171
217
 
172
218
  // --- Metadata / environment (independent of entity ops) ---
173
219
  if (diff.metaChanged && diff.newName !== undefined) {
@@ -227,7 +273,12 @@ export function applyDiff(scene: SceneFile, diff: SceneDiff): SceneFile {
227
273
  if (pendingMoves.size > 0) {
228
274
  const i = [...pendingMoves][0]!;
229
275
  const op = ops[i] as Extract<DiffOp, { type: 'move' }>;
230
- throw new ApplyDiffError('entity not found (nothing to move)', i, 'move', op.id);
276
+ throw new ApplyDiffError(
277
+ notFound('entity not found (nothing to move)', sceneHasIds),
278
+ i,
279
+ 'move',
280
+ op.id,
281
+ );
231
282
  }
232
283
 
233
284
  // --- Phase 2: removes (a not-found target is fine if it was already swept
@@ -243,7 +294,7 @@ export function applyDiff(scene: SceneFile, diff: SceneDiff): SceneFile {
243
294
  }
244
295
  if (sweptIds.has(op.id)) return;
245
296
  throw new ApplyDiffError(
246
- 'entity not found (already removed via an ancestor, or an invalid id)',
297
+ notFound('entity not found (already removed via an ancestor, or an invalid id)', sceneHasIds),
247
298
  i,
248
299
  'remove',
249
300
  op.id,
@@ -311,7 +362,7 @@ export function applyDiff(scene: SceneFile, diff: SceneDiff): SceneFile {
311
362
  if (!parentNode) {
312
363
  const bad = placements[0]!;
313
364
  throw new ApplyDiffError(
314
- `parent entity "${parentId}" not found`,
365
+ notFound(`parent entity "${parentId}" not found`, sceneHasIds),
315
366
  bad.opIndex,
316
367
  bad.opType,
317
368
  bad.entityId,
@@ -341,7 +392,7 @@ export function applyDiff(scene: SceneFile, diff: SceneDiff): SceneFile {
341
392
  if (op.type !== 'update') return;
342
393
  const node = byId.get(op.id);
343
394
  if (!node) {
344
- throw new ApplyDiffError('entity not found', i, 'update', op.id);
395
+ throw new ApplyDiffError(notFound('entity not found', sceneHasIds), i, 'update', op.id);
345
396
  }
346
397
  const { id: _id, children: _children, ...fields } = op.entity;
347
398
  for (const key of Object.keys(node) as (keyof SceneEntity)[]) {
@@ -3,8 +3,7 @@ import * as THREE from 'three';
3
3
  import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
4
4
  import type { BatchedRenderer } from 'three.quarks';
5
5
  import { initNavigation, NavMeshManager } from '../ai/navigation';
6
- import { AnimGraph } from '../animation/anim-graph';
7
- import { PropertyTrackRunner } from '../animation/property-track';
6
+ import { buildClipMap } from '../animation/clip-map';
8
7
  import { log } from '../dev/logger';
9
8
  import type { ComponentManager } from '../ecs/component-manager';
10
9
  import type { GameComponentClass } from '../ecs/game-component';
@@ -14,11 +13,10 @@ import { type RenderScope, resolveRenderSettings } from '../render/render-settin
14
13
  import {
15
14
  assetCacheSizes,
16
15
  clearAssetCaches as clearSharedAssetCaches,
17
- loadAnimGraphData,
18
16
  loadGLTF,
19
17
  resolveGltfNode,
20
18
  } from './asset-loaders';
21
- import { getAssetMeta, loadRegistry } from './asset-registry';
19
+ import { applyImportCorrection, getAssetMeta, loadRegistry } from './asset-registry';
22
20
  import { computeColliderWorldDimensions, computeColliderWorldOffset } from './collider-dimensions';
23
21
  import { applyComponents, type ComponentRegistry } from './component-registry';
24
22
  import { DEFAULTS } from './defaults';
@@ -41,23 +39,22 @@ import {
41
39
  type SceneMaterial,
42
40
  type ScenePhysics,
43
41
  type SceneSpline,
44
- type UIRoot,
45
42
  } from './scene-types';
46
43
  import { isEntityRefSchema } from './schema/entity-ref';
47
44
  import { getUserData, setUserData } from './user-data';
48
45
 
49
- // GLTF / texture / animgraph load+cache logic lives in the shared ./asset-loaders
50
- // module (A2) so the editor and runtime share one cache + one clone-safety
51
- // contract. Only the IBL/skybox env-map cache stays here — it needs a
52
- // WebGLRenderer (PMREM), which the shared module deliberately doesn't depend on.
46
+ // GLTF / texture load+cache logic lives in the shared ./asset-loaders module
47
+ // (A2) so the editor and runtime share one cache + one clone-safety contract.
48
+ // Only the IBL/skybox env-map cache stays here — it needs a WebGLRenderer
49
+ // (PMREM), which the shared module deliberately doesn't depend on.
53
50
  const envTextureCache = new Map<string, THREE.Texture>();
54
51
  let pmremGenerator: THREE.PMREMGenerator | null = null;
55
52
 
56
53
  /**
57
- * Clear module-level asset caches (textures, GLTF scenes, anim graphs, IBL env
58
- * maps). Call on full runtime teardown to release cached GPU resources and avoid
59
- * leaking across editor Play sessions. Delegates the GLTF/texture/animgraph
60
- * caches to {@link clearSharedAssetCaches} and additionally frees the local
54
+ * Clear module-level asset caches (textures, GLTF scenes, IBL env maps). Call
55
+ * on full runtime teardown to release cached GPU resources and avoid leaking
56
+ * across editor Play sessions. Delegates the GLTF/texture caches to
57
+ * {@link clearSharedAssetCaches} and additionally frees the local
61
58
  * IBL env-map cache + PMREM generator.
62
59
  *
63
60
  * Lifetime / P1.8: these caches are intentionally module-level and persist
@@ -99,14 +96,12 @@ export function updateSceneLODs(scene: THREE.Scene, camera: THREE.Camera): void
99
96
  export function __assetCacheSizesForTest(): {
100
97
  textures: number;
101
98
  gltf: number;
102
- animGraphs: number;
103
99
  env: number;
104
100
  } {
105
101
  const sizes = assetCacheSizes();
106
102
  return {
107
103
  textures: sizes.textures,
108
104
  gltf: sizes.gltf,
109
- animGraphs: sizes.animGraphs,
110
105
  env: envTextureCache.size,
111
106
  };
112
107
  }
@@ -176,19 +171,6 @@ function createCurveFromSpline(spline: SceneSpline): THREE.CatmullRomCurve3 | nu
176
171
  );
177
172
  }
178
173
 
179
- function buildClipMap(
180
- animations: THREE.AnimationClip[],
181
- aliases?: Record<string, string>,
182
- ): Map<string, THREE.AnimationClip> {
183
- const clips = new Map<string, THREE.AnimationClip>();
184
- for (const clip of animations) {
185
- const raw = clip.name.split('|').pop()?.toLowerCase() ?? clip.name;
186
- const name = aliases?.[raw] ?? raw;
187
- clips.set(name, clip);
188
- }
189
- return clips;
190
- }
191
-
192
174
  export interface SceneLoadContext {
193
175
  scene: THREE.Scene;
194
176
  rapierWorld: RAPIER.World;
@@ -205,10 +187,6 @@ export interface SceneLoadContext {
205
187
  instancesFileCache?: Map<string, InstancesFile>;
206
188
  /** Supply an AudioListener (attached to camera) to enable audio entity spawning. */
207
189
  audioListener?: THREE.AudioListener;
208
- /** AnimGraph registry keyed by Object3D. If provided, loader registers created
209
- * AnimGraphs here (and skips tickFns to avoid double-ticking with the global
210
- * animationSystem). */
211
- animGraphs?: Map<THREE.Object3D, AnimGraph>;
212
190
  /** ComponentManager for instantiating GameComponent classes from scene data. */
213
191
  componentManager?: ComponentManager;
214
192
  /** three.quarks BatchedRenderer. If provided, particle systems declared on
@@ -234,16 +212,6 @@ export interface SceneInstance {
234
212
  /** Parsed scene environment, if any — lets the runtime apply post-processing
235
213
  * (which needs the composer/camera the loader doesn't have). */
236
214
  environment?: SceneEnvironment | undefined;
237
- /**
238
- * The scene file's top-level `ui` tree (React UI roots/canvases, A1),
239
- * carried through verbatim from the parsed `SceneFile` by BOTH `loadScene`
240
- * and `loadSceneFromData` (single source of truth — R8 item 7 / B). Opaque
241
- * to the loader itself; the adapter passes it to `renderSceneUI` (the
242
- * injected React renderer) so scenePath and sceneData mounts render the
243
- * SAME scene-authored UI instead of only the sceneData branch special-
244
- * casing it. `undefined` when the scene file has no `ui` field.
245
- */
246
- ui?: UIRoot[] | undefined;
247
215
  /**
248
216
  * Authored entity id -> spawned Object3D (design/24-scene-ui.md D5). The
249
217
  * SAME map `spawnEntity`/`createSceneJoints` build internally (id-less
@@ -351,15 +319,15 @@ function entityLabel(def: SceneEntity): string {
351
319
  // ---------------------------------------------------------------------------
352
320
  // T3.9 — bounded-concurrency asset prefetch.
353
321
  //
354
- // Child/asset fetches (prefab files, materials, GLTF models, animgraphs) used
355
- // to only ever happen inline, one at a time, inside the fully-sequential
322
+ // Child/asset fetches (prefab files, materials, GLTF models) used to only
323
+ // ever happen inline, one at a time, inside the fully-sequential
356
324
  // `spawnEntity` recursion — fine at ~20 assets, product-breaking at hundreds
357
325
  // (every fetch's network round-trip is paid serially). Rather than restructure
358
326
  // the deterministic sequential spawn walk itself (which authored array order,
359
327
  // tickFns/mixers order, and GameComponent registration order all depend on),
360
328
  // this prefetch pass walks the WHOLE entity tree up front and warms the
361
- // existing URL-keyed caches (asset-loaders.ts's module-level GLTF/animgraph
362
- // caches, and this module's ctx-level prefab/material file caches) with a
329
+ // existing URL-keyed caches (asset-loaders.ts's module-level GLTF cache, and
330
+ // this module's ctx-level prefab/material file caches) with a
363
331
  // small concurrency limiter. The sequential spawn pass below is completely
364
332
  // unchanged — its `await`s just resolve instantly against a warm cache, so
365
333
  // wall-clock time is bounded by ceil(assetCount / limit) round-trips instead
@@ -416,10 +384,6 @@ function collectPrefetchTasks(
416
384
  const ref = def.materialRef;
417
385
  tasks.push(() => loadMaterialFile(ref, ctx));
418
386
  }
419
- if (def.animation?.animGraph) {
420
- const graph = def.animation.animGraph;
421
- tasks.push(() => loadAnimGraphData(graph));
422
- }
423
387
  if (def.prefab && !ctx.prefabs?.has(def.prefab) && !seenPrefabUrls.has(def.prefab)) {
424
388
  const prefabUrl = def.prefab;
425
389
  seenPrefabUrls.add(prefabUrl);
@@ -502,6 +466,12 @@ async function instantiateScene(
502
466
  ctx.scene.add(obj);
503
467
  }
504
468
 
469
+ // Pass 1.5: animated attachments must resolve only after every entity and
470
+ // GLTF skeleton has spawned. Reparenting preserves the authored transform
471
+ // as the local socket offset; gameplay components remain on the same
472
+ // Object3D and therefore require no mirror or adapter entity.
473
+ resolveBoneAttachments(data.entities, idMap);
474
+
505
475
  // Pass 2: create joints between physics bodies
506
476
  createSceneJoints(data.entities, idMap, ctx);
507
477
 
@@ -545,7 +515,6 @@ async function instantiateScene(
545
515
  navMesh,
546
516
  cameras,
547
517
  environment: data.environment,
548
- ui: data.ui,
549
518
  entities: idMap,
550
519
  update(dt: number) {
551
520
  for (const fn of tickFns) fn(dt);
@@ -693,20 +662,7 @@ async function spawnEntity(
693
662
  }
694
663
 
695
664
  // Apply import correction from asset registry
696
- const meta = getAssetMeta(def.mesh.src);
697
- if (meta?.importCorrection) {
698
- const ic = meta.importCorrection;
699
- if (ic.scale !== undefined) {
700
- content.scale.multiplyScalar(ic.scale);
701
- }
702
- if (ic.rotation) {
703
- content.rotation.set(
704
- THREE.MathUtils.degToRad(ic.rotation[0]),
705
- THREE.MathUtils.degToRad(ic.rotation[1]),
706
- THREE.MathUtils.degToRad(ic.rotation[2]),
707
- );
708
- }
709
- }
665
+ applyImportCorrection(content, getAssetMeta(def.mesh.src)?.importCorrection);
710
666
 
711
667
  // Wrap in a group so entity transform and import correction are separated.
712
668
  // `content` carries import correction; wrapper carries entity transform.
@@ -714,40 +670,33 @@ async function spawnEntity(
714
670
  wrapper.add(content);
715
671
  object3d = wrapper;
716
672
 
717
- // Set up animation if defined
673
+ // Set up animation if defined. Always build the mixer + clip map (and
674
+ // expose both via userData) whenever the entity has clips — not just
675
+ // when `autoplay` is set — so a GameComponent (e.g. an XState-driven
676
+ // character controller, E5's replacement for the removed AnimGraph) can
677
+ // pick up `_animMixer`/`_animClips` in its own `init()` and drive the
678
+ // SAME mixer via `bindXStateAnimation` (xstate-animation-binding.ts).
679
+ // Only `autoplay` self-ticks here (`tickFns`) — a component driving its
680
+ // own binding calls `mixer.update(dt)` itself (inside the binding's
681
+ // `tick`), so auto-ticking unconditionally would double-advance it.
718
682
  if (def.animation && animations.length > 0) {
719
683
  const clipMap = buildClipMap(animations, def.animation.clipAliases);
684
+ const mixer = new THREE.AnimationMixer(gltfScene);
685
+ setUserData(object3d, '_animMixer', mixer);
686
+ setUserData(object3d, '_animClips', clipMap);
687
+ setUserData(object3d, '_availableClips', [...clipMap.keys()]);
688
+ mixers.push(mixer);
720
689
 
721
- if (def.animation.animGraph) {
722
- // Full AnimGraph state machine
723
- const graphData = await loadAnimGraphData(def.animation.animGraph);
724
- const mixer = new THREE.AnimationMixer(gltfScene);
725
- const graph = new AnimGraph(mixer, graphData, clipMap);
726
- if (def.animation.parameters) {
727
- for (const [key, value] of Object.entries(def.animation.parameters)) {
728
- graph.setParameter(key, value);
729
- }
730
- }
731
- if (ctx.animGraphs) {
732
- // Register in global map (keyed by Object3D); animationSystem ticks it.
733
- ctx.animGraphs.set(object3d, graph);
734
- } else {
735
- // Standalone: tick via scene instance update
736
- tickFns.push((dt) => graph.update(dt));
737
- }
738
- mixers.push(mixer);
739
- } else if (def.animation.autoplay) {
690
+ if (def.animation.autoplay) {
740
691
  // Simple clip autoplay
741
692
  const clip = clipMap.get(def.animation.autoplay);
742
693
  if (clip) {
743
- const mixer = new THREE.AnimationMixer(gltfScene);
744
694
  const action = mixer.clipAction(clip);
745
695
  const loop = def.animation.loop ?? DEFAULTS.animation.loop;
746
696
  action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
747
697
  action.clampWhenFinished = !loop;
748
698
  action.play();
749
699
  tickFns.push((dt) => mixer.update(dt));
750
- mixers.push(mixer);
751
700
  }
752
701
  }
753
702
  }
@@ -914,17 +863,6 @@ async function spawnEntity(
914
863
  // Visibility (mirrors editor: visible unless explicitly false)
915
864
  object3d.visible = def.visible ?? DEFAULTS.entity.visible;
916
865
 
917
- // F5 — declarative property tracks (docs/VSCN-STRUCTURAL-GAPS-DESIGN.md).
918
- // Unlike gltf autoplay/animGraph above (clip-driven, gltf-only), tracks work
919
- // on ANY entity — a box, a light, no gltf/clips required — so this lives
920
- // OUTSIDE the gltf branch. Registered in the same tickFns list the
921
- // AnimationMixers use, so it ticks on the same deterministic dt (honors
922
- // pause/timeScale for free — no new clock).
923
- if (def.animation?.tracks?.length) {
924
- const runner = new PropertyTrackRunner(object3d, def.animation.tracks);
925
- tickFns.push((dt) => runner.update(dt));
926
- }
927
-
928
866
  // Physics — stores {body, collider} in the physics registry keyed by object3d.
929
867
  // T1.1: the body is created at the COMPOSED WORLD transform (ancestorCtx's
930
868
  // world matrix × this entity's own local transform), not the raw authored
@@ -1135,6 +1073,14 @@ function createPhysicsBody(
1135
1073
  }
1136
1074
  if (col.isSensor) {
1137
1075
  colliderDesc.setSensor(true);
1076
+ // Rapier's DEFAULT omits every non-dynamic pair. Player characters are
1077
+ // commonly position-based kinematic bodies and authored pickups commonly
1078
+ // use fixed sensors, so DEFAULT makes the pair invisible to the physics
1079
+ // event queue even after the KCC correctly excludes sensors from movement
1080
+ // blocking. A sensor's contract is observation, not contact response:
1081
+ // enable every body-type pair so real overlaps consistently reach
1082
+ // onTriggerEnter/Exit.
1083
+ colliderDesc.setActiveCollisionTypes(rapier.ActiveCollisionTypes.ALL);
1138
1084
  }
1139
1085
  // All scene-loaded colliders opt into collision events — sensors need them for
1140
1086
  // onTriggerEnter/onTriggerExit, and solid colliders need them for onCollision
@@ -1161,6 +1107,42 @@ function walkEntities(entities: SceneEntity[], fn: (e: SceneEntity) => void): vo
1161
1107
  }
1162
1108
  }
1163
1109
 
1110
+ /** Reparent scene-authored entities to named bones/sockets in loaded GLTF skeletons. */
1111
+ function resolveBoneAttachments(entities: SceneEntity[], idMap: Map<string, THREE.Object3D>): void {
1112
+ walkEntities(entities, (def) => {
1113
+ const attachment = def.boneAttachment;
1114
+ if (!attachment || !def.id) return;
1115
+
1116
+ const object = idMap.get(def.id);
1117
+ if (!object) return;
1118
+ const target = idMap.get(attachment.target);
1119
+ if (!target) {
1120
+ throw new Error(
1121
+ `Bone attachment target "${attachment.target}" not found for entity "${entityLabel(def)}".`,
1122
+ );
1123
+ }
1124
+ if (target === object) {
1125
+ throw new Error(`Entity "${entityLabel(def)}" cannot attach to its own skeleton.`);
1126
+ }
1127
+
1128
+ const socket = target.getObjectByName(attachment.bone);
1129
+ if (!socket) {
1130
+ const available: string[] = [];
1131
+ target.traverse((node) => {
1132
+ if (node.name && node instanceof THREE.Bone) {
1133
+ available.push(node.name);
1134
+ }
1135
+ });
1136
+ const suffix = available.length > 0 ? ` Available bones: ${available.sort().join(', ')}` : '';
1137
+ throw new Error(
1138
+ `Bone/socket "${attachment.bone}" was not found on target "${attachment.target}" for entity "${entityLabel(def)}".${suffix}`,
1139
+ );
1140
+ }
1141
+
1142
+ socket.add(object);
1143
+ });
1144
+ }
1145
+
1164
1146
  /** Create Rapier joints for all entities that define them. */
1165
1147
  function createSceneJoints(
1166
1148
  entities: SceneEntity[],
@@ -1370,10 +1352,10 @@ export interface SpawnedPrefab {
1370
1352
  /** The Object3D of the spawned prefab root (added to the scene). */
1371
1353
  object3D: THREE.Object3D;
1372
1354
  /**
1373
- * Tick standalone (non-animGraph) animations for this prefab — e.g. a simple
1374
- * `animation.autoplay` clip. AnimGraphs are registered into `ctx.animGraphs`
1375
- * (when provided) and ticked by the global animationSystem instead, so this
1376
- * is a no-op for those. Call once per frame, mirroring SceneInstance.update.
1355
+ * Tick standalone `animation.autoplay` clips for this prefab. A GameComponent
1356
+ * that attaches its own XState-driven animation binding (E5 — see
1357
+ * `xstate-animation-binding.ts`) ticks that binding itself, so this is a
1358
+ * no-op for those. Call once per frame, mirroring SceneInstance.update.
1377
1359
  */
1378
1360
  update(dt: number): void;
1379
1361
  /** Stop any mixers this prefab created. */
@@ -1385,16 +1367,16 @@ export interface SpawnedPrefab {
1385
1367
  *
1386
1368
  * Returns a {@link SpawnedPrefab}. The previous version passed throwaway
1387
1369
  * tickFns/mixers arrays, so a runtime-spawned prefab's `animation.autoplay`
1388
- * (or an animGraph with no `ctx.animGraphs`) never advanced. Now the tick
1389
- * functions are captured and exposed via `update()`, consistent with how
1390
- * loadScene's SceneInstance.update drives animations.
1370
+ * never advanced. Now the tick functions are captured and exposed via
1371
+ * `update()`, consistent with how loadScene's SceneInstance.update drives
1372
+ * animations.
1391
1373
  *
1392
1374
  * Usage:
1393
1375
  * const crate = await spawnPrefab('/data/prefabs/crate.prefab.json', ctx, {
1394
1376
  * transform: { position: [x, y, z] },
1395
1377
  * components: { Health: { current: 50 } },
1396
1378
  * });
1397
- * // each frame (only needed for autoplay clips; animGraphs tick globally):
1379
+ * // each frame (only needed for autoplay clips):
1398
1380
  * crate.update(dt);
1399
1381
  */
1400
1382
  export async function spawnPrefab(
@@ -1445,13 +1427,8 @@ export async function spawnPrefab(
1445
1427
  // - mixers (THREE.AnimationMixer, simple `animation.autoplay` clips)
1446
1428
  // DISPOSED — stopAllAction() on every mixer this spawn created
1447
1429
  // (pre-existing).
1448
- // - tickFns (standalone animGraph/mixer tick closures, only used when
1449
- // `ctx.animGraphs` is NOT provided) DISPOSED — array cleared so
1450
- // update() becomes a no-op (pre-existing).
1451
- // - ctx.animGraphs (Object3D -> AnimGraph, when provided)
1452
- // NEWLY-DISPOSED — every entry whose key is inside the spawned
1453
- // subtree is deleted, so the global animationSystem stops ticking a
1454
- // despawned graph (probe1: previously leaked forever).
1430
+ // - tickFns (standalone `animation.autoplay` mixer tick closures)
1431
+ // DISPOSED — array cleared so update() becomes a no-op (pre-existing).
1455
1432
  // - ctx.particleRenderer (three.quarks BatchedRenderer, when provided)
1456
1433
  // NEWLY-DISPOSED — every particle system tagged via the
1457
1434
  // `_particleSystem` userData key (mirrors editor scene-sync's own
@@ -1469,7 +1446,7 @@ export async function spawnPrefab(
1469
1446
  // tags on Object3Ds that get garbage-collected with the subtree once
1470
1447
  // `ctx.scene.remove(object3D)` drops the last reference; there is no
1471
1448
  // separate ctx-level registry entry to leak.
1472
- // - GLTF/texture/animgraph asset caches (asset-loaders.ts, module-level)
1449
+ // - GLTF/texture asset caches (asset-loaders.ts, module-level)
1473
1450
  // DELIBERATELY NOT DISPOSED — these are shared, URL-keyed caches
1474
1451
  // across the whole runtime session (P1.8 contract: repeated
1475
1452
  // spawns/loads of the same asset must NOT re-fetch or re-parse), not
@@ -1492,15 +1469,6 @@ export async function spawnPrefab(
1492
1469
  ctx.rapierWorld.removeRigidBody(refs.body);
1493
1470
  ctx.physics.remove(node);
1494
1471
  });
1495
- // Unregister any AnimGraphs this subtree registered into the shared,
1496
- // Object3D-keyed ctx.animGraphs map — otherwise the global
1497
- // animationSystem keeps ticking a despawned graph forever.
1498
- if (ctx.animGraphs) {
1499
- const animGraphs = ctx.animGraphs;
1500
- object3D.traverse((node) => {
1501
- animGraphs.delete(node);
1502
- });
1503
- }
1504
1472
  // Unregister any particle systems this subtree registered with the
1505
1473
  // shared BatchedRenderer — otherwise they keep simulating/rendering
1506
1474
  // after the visual object is gone. `_particleSystem` is the same
@@ -28,7 +28,6 @@ export type {
28
28
  ScenePostProcessing,
29
29
  SceneSpline,
30
30
  SceneToneMapping,
31
- UIRoot,
32
31
  } from './schema';
33
32
 
34
33
  export { mergePrefabInstance } from './schema';