@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
@@ -15,10 +15,14 @@
15
15
  import RAPIER from '@dimforge/rapier3d-compat';
16
16
  import type { EffectComposer } from 'postprocessing';
17
17
  import * as THREE from 'three';
18
- import type { AnimGraph } from '../animation/anim-graph';
19
- import { animationSystem } from '../animation/anim-system';
18
+ import {
19
+ createSeededRandom,
20
+ DEFAULT_SEEDED_RANDOM_SEED,
21
+ getSeededRandom,
22
+ } from '../core/seeded-random';
20
23
  import { createSystemRunner } from '../core/system-runner';
21
24
  import { createDebugDraw } from '../dev/debug-draw';
25
+ import { createWebGLGpuTimer } from '../dev/webgl-gpu-timer';
22
26
  import { createComponentManager } from '../ecs/component-manager';
23
27
  import { InputManager } from '../input/input-manager';
24
28
  import { setAssetPrefix } from '../loader';
@@ -28,14 +32,12 @@ import { createTransformWriter } from '../physics/transform-writer';
28
32
  import { createTriggerDispatch } from '../physics/trigger-dispatch';
29
33
  import { RenderBatchSystem } from '../render/render-batch-system';
30
34
  import { type RenderScope, resolveRenderSettings } from '../render/render-settings';
31
- import type { WorldFrameHooks } from '../runtime/game';
32
35
  import {
33
- renderSceneUI,
34
- type SceneUIDataSourceLike,
35
- type SceneUIGameServices,
36
- type SceneUIHandle,
37
- } from '../runtime/scene-ui-bridge';
38
- import { gameStateDataSource } from '../runtime/scene-ui-data';
36
+ createDebugRegistry,
37
+ type DebugRegistry,
38
+ getDebugRegistry,
39
+ } from '../runtime/debug-registry';
40
+ import type { WorldFrameHooks } from '../runtime/game';
39
41
  import type { EditorPreview, GameCleanup, GameContext, GameSetupFn } from '../runtime/types';
40
42
  import type { ComponentRegistry } from '../scene/component-registry';
41
43
  import { tickCustomMaterials } from '../scene/material-registry';
@@ -57,7 +59,6 @@ import {
57
59
  createSceneView,
58
60
  } from '../setup/setup-renderer';
59
61
  import {
60
- createAnimationAdapter,
61
62
  createAudioSystemAdapter,
62
63
  createInputManagerAdapter,
63
64
  createNavigationAdapter,
@@ -91,24 +92,6 @@ export interface VgaiSceneConfig {
91
92
  * that sets this today).
92
93
  */
93
94
  componentRegistry?: ComponentRegistry | undefined;
94
- /**
95
- * The project's scene-UI registry (design/24-scene-ui.md D9/D11) —
96
- * `components`/`transforms`/`strings` only, opaque records engine-side (the
97
- * engine never imports `@vgai/scene-ui`'s real `ProjectUIRegistry` type;
98
- * they're React things it never inspects). Threaded per-mount into the
99
- * services `renderSceneUI` receives, mirroring `componentRegistry` above:
100
- * the editor's default-three adapter resolver passes its cached project
101
- * registry here for the editor-play path (D11); a standalone host that
102
- * already registered a static registry via `registerSceneUIRenderer`
103
- * typically omits this (registration-time registry is enough there).
104
- */
105
- uiRegistry?:
106
- | {
107
- components?: Record<string, unknown>;
108
- transforms?: Record<string, unknown>;
109
- strings?: Record<string, Record<string, string>>;
110
- }
111
- | undefined;
112
95
  /** Input map JSON path. Defaults to 'inputmaps/default.inputmap.json'. */
113
96
  inputMapPath?: string | undefined;
114
97
  /** URL prefix for relative asset paths. Defaults to '/'. */
@@ -142,10 +125,10 @@ export interface VgaiMountedGame extends MountedGame {
142
125
  /**
143
126
  * Phase-partitioned frame entry point (T7.1 slice 2,
144
127
  * `docs/GAME-ROOT-DESIGN.md` §4) — wired onto this world's `WorldInstance`
145
- * by `registerDefaultThreeWorld` (`create-runtime.ts`). `Game.runFrame` is
128
+ * by `registerThreeWorld` (`create-runtime.ts`). `Game.runFrame` is
146
129
  * the CANONICAL driver going forward: it calls `frame.runPhase` once per
147
130
  * (phase, substep) for every world, then `frame.endFrame` once per
148
- * substep after all worlds finish all phases. `update` (below) remains
131
+ * substep after all roots finish all phases. `update` (below) remains
149
132
  * the legacy/direct entry point to the exact same `SystemRunner` — direct
150
133
  * callers (editor HMR, adapter conformance tests) keep using it unchanged.
151
134
  */
@@ -188,7 +171,6 @@ export class VgaiSceneGameAdapter implements GameAdapter {
188
171
  scenePath,
189
172
  sceneData,
190
173
  componentRegistry,
191
- uiRegistry,
192
174
  inputMapPath = '/inputmaps/default.inputmap.json',
193
175
  assetPrefix = '/',
194
176
  } = this.config;
@@ -237,33 +219,69 @@ export class VgaiSceneGameAdapter implements GameAdapter {
237
219
  // --- Particles (three.quarks BatchedRenderer) ---
238
220
  const particles = setupParticles(scene);
239
221
 
240
- // --- UI container (host-provided) ---
241
- const uiContainer = host.ui as HTMLDivElement;
242
-
243
- // --- Debug draw / assets / anim graphs ---
222
+ // --- Debug draw / assets ---
244
223
  const debugDraw = createDebugDraw(scene);
245
224
  const assets = host.assets;
246
- const animGraphs = new Map<THREE.Object3D, AnimGraph>();
225
+ // Test/headless adapters may provide the renderer surface needed by the
226
+ // runtime without a real WebGL context. GPU timing is optional diagnostics:
227
+ // degrade honestly when the renderer/context cannot expose extensions.
228
+ const rendererContext =
229
+ typeof renderer.getContext === 'function' ? renderer.getContext() : undefined;
230
+ const gpuTimer =
231
+ !headless && rendererContext && typeof rendererContext.getExtension === 'function'
232
+ ? createWebGLGpuTimer(rendererContext)
233
+ : null;
247
234
 
248
235
  // --- System runner (engine-level systems) ---
249
- const systems = createSystemRunner();
250
- systems.add('input', () => input.poll());
251
- systems.add('physics', () => physics.rapierWorld.step(physics.eventQueue));
252
- systems.add('postPhysics', () => {
253
- collisions.drain();
254
- writeTransforms();
236
+ const systems = createSystemRunner(host.game?.profiler.systemObserver, 'threejs');
237
+ // D15/T-D15.3/.5: pass the LIVE shared game tick (not a locally-counted
238
+ // one) into every `poll()` call — `debugRegistry` is declared further
239
+ // down in this same `mount()` (it needs `ctx`/`systemAdapters` to exist
240
+ // first), but this callback only ever executes during real frame
241
+ // ticks, long after `mount()` has fully returned and `debugRegistry` is
242
+ // initialized (a safe closure-over-a-later-const, not a genuine
243
+ // use-before-init). `host.game` gates it: a bare mount with no `Game`
244
+ // shell behind it (test harnesses predating T7.1) has no shared tick to
245
+ // key off, so `InputManager.poll()` falls back to its own internal
246
+ // self-incrementing counter instead (unchanged pre-D15 behavior) rather
247
+ // than being pinned to a constant `0` forever.
248
+ systems.add('input', () => input.poll(host.game ? debugRegistry.getGameTick() : undefined), {
249
+ name: 'input.poll',
255
250
  });
256
- systems.add('animation', (dt) => animationSystem(animGraphs, dt));
257
- systems.add('preRender', (dt) => {
258
- particles.batchedRenderer.update(dt);
259
- tickCustomMaterials(scene, dt);
260
- updateSceneLODs(scene, camera);
251
+ systems.add('physics', () => physics.rapierWorld.step(physics.eventQueue), {
252
+ name: 'physics.step',
261
253
  });
254
+ systems.add(
255
+ 'postPhysics',
256
+ () => {
257
+ collisions.drain();
258
+ writeTransforms();
259
+ },
260
+ { name: 'physics.sync' },
261
+ );
262
+ systems.add(
263
+ 'preRender',
264
+ (dt) => {
265
+ particles.batchedRenderer.update(dt);
266
+ tickCustomMaterials(scene, dt);
267
+ updateSceneLODs(scene, camera);
268
+ },
269
+ { name: 'materials-and-particles' },
270
+ );
262
271
  if (!headless) {
263
- systems.add('render', (dt) => {
264
- composer.render(dt);
265
- if (physics.debugEnabled) updatePhysicsDebug(physics.rapierWorld, physics.debugMesh);
266
- });
272
+ systems.add(
273
+ 'render',
274
+ (dt) => {
275
+ if (host.game?.profiler.enabled) gpuTimer?.begin();
276
+ try {
277
+ composer.render(dt);
278
+ } finally {
279
+ gpuTimer?.end();
280
+ }
281
+ if (physics.debugEnabled) updatePhysicsDebug(physics.rapierWorld, physics.debugMesh);
282
+ },
283
+ { name: 'three.render' },
284
+ );
267
285
  }
268
286
 
269
287
  // --- Debug toggle (browser only) ---
@@ -275,7 +293,18 @@ export class VgaiSceneGameAdapter implements GameAdapter {
275
293
  };
276
294
  if (!headless) window.addEventListener('keydown', onDebugToggle);
277
295
 
278
- const postFrame = () => input.endFrame();
296
+ const postFrame = () => {
297
+ input.endFrame();
298
+ if (host.game?.profiler.enabled) {
299
+ host.game.profiler.reportRender({
300
+ gpuMs: gpuTimer?.poll() ?? null,
301
+ drawCalls: renderer.info.render.calls,
302
+ triangles: renderer.info.render.triangles,
303
+ geometries: renderer.info.memory.geometries,
304
+ textures: renderer.info.memory.textures,
305
+ });
306
+ }
307
+ };
279
308
 
280
309
  // The mounted game's adapter surface (`mounted.systems`). Declared before
281
310
  // ctx so `ctx.registerSystemAdapter` can close over it: the engine fills
@@ -285,17 +314,6 @@ export class VgaiSceneGameAdapter implements GameAdapter {
285
314
  // late registrations are visible to its panels at read time.
286
315
  const systemAdapters: SystemAdapters = {};
287
316
 
288
- // Game-registered scene-UI services (design/24-scene-ui.md D2/D7) — a `let`
289
- // bag mutated by `ctx.setSceneUIServices`, declared here (BEFORE `ctx` is
290
- // built) so the closure below has something to capture: a GameComponent
291
- // calling `ctx.setSceneUIServices` from its `init()` runs DURING
292
- // `componentManager.initAll()` (scene-loader.ts), which is awaited before
293
- // `loadScene`/`loadFromData` return below — well before `sceneUIHandle`
294
- // exists — so early calls just accumulate here and are folded into the
295
- // FIRST render (`buildSceneUIServices`, below) rather than routed through
296
- // the late-update path.
297
- let sceneUIServices: SceneUIGameServices = {};
298
-
299
317
  // --- GameContext (the first-party runtime surface) ---
300
318
  const ctx: GameContext = {
301
319
  scene,
@@ -309,42 +327,23 @@ export class VgaiSceneGameAdapter implements GameAdapter {
309
327
  audio,
310
328
  particles,
311
329
  debugDraw,
312
- animGraphs,
313
330
  assets,
314
331
  systems,
315
332
  components: null!,
316
- uiContainer,
317
333
  registerSystemAdapter: (kind, adapter) => {
318
334
  systemAdapters[kind] = adapter;
319
335
  },
320
- setSceneUIServices: (incoming) => {
321
- sceneUIServices = { ...sceneUIServices, ...incoming };
322
- // Late call (after the initial mount already resolved a sceneInstance
323
- // and rendered it): route to the live handle instead of just banking
324
- // the services for a mount that already happened (D7).
325
- if (sceneUIHandle && sceneInstance) {
326
- const merged = buildSceneUIServices(sceneInstance);
327
- if (sceneUIHandle.update) {
328
- sceneUIHandle.update(merged);
329
- } else {
330
- sceneUIHandle.dispose();
331
- sceneUIHandle = sceneInstance.ui
332
- ? renderSceneUI(uiContainer, sceneInstance.ui, merged)
333
- : null;
334
- }
335
- }
336
- },
337
336
  };
338
337
  // Game root (T7.1 slice 1): only present once the host has constructed a
339
338
  // Game shell (createGameRuntime does this before mount; headless/foreign
340
- // hosts may omit it — ctx.game/ctx.worlds simply stay undefined, which is
341
- // the documented zero-break behavior). `ctx.worlds` is assigned the SAME
339
+ // hosts may omit it — ctx.game/ctx.roots simply stay undefined, which is
340
+ // the documented zero-break behavior). `ctx.roots` is assigned the SAME
342
341
  // live array `host.game` mutates via `registerWorld` (not a copy) — the
343
342
  // default world is registered AFTER mount returns, and this reference
344
343
  // must observe that later push.
345
344
  if (host.game) {
346
345
  ctx.game = host.game;
347
- ctx.worlds = host.game.worlds;
346
+ ctx.roots = host.game.roots;
348
347
  }
349
348
  ctx.components = createComponentManager(ctx, physicsRegistry);
350
349
  collisions.onCollision(
@@ -372,11 +371,89 @@ export class VgaiSceneGameAdapter implements GameAdapter {
372
371
  systemAdapters.physics = createRapierPhysicsAdapter(physicsRegistry, physics.debugMesh);
373
372
  systemAdapters.input = createInputManagerAdapter(input);
374
373
  systemAdapters.assets = createVgaiAssetAdapter();
375
- systemAdapters.animation = createAnimationAdapter(animGraphs);
376
374
  // D10/T7.6: the audio seam `Game.play.pause()` silences on pause. Works
377
375
  // against the headless stand-in too (`headlessAudio()`'s plain
378
376
  // `masterGain.gain` object) — this world's `pause()` mutes it harmlessly.
379
377
  systemAdapters.audio = createAudioSystemAdapter(audio);
378
+ // Debug/synthetic-player seam (T1.1, docs/SYNTHETIC-PLAYER-SPEC.md §3.1):
379
+ // ONE registry per Game root, shared across every world that mounts onto
380
+ // it — `getDebugRegistry(host.game)` reaches the SAME accumulator
381
+ // `createGame` filed, so a second/third world's `ctx.debug` feeds it too
382
+ // (see debug-registry.ts's module doc for the provenance/id note). A
383
+ // mount with no Game shell at all (bare test harnesses predating T7.1)
384
+ // gets a private, mount-local registry instead — same absence precedent
385
+ // as `ctx.game`/`ctx.roots` above.
386
+ const debugRegistry: DebugRegistry =
387
+ (host.game ? getDebugRegistry(host.game) : null) ??
388
+ createDebugRegistry({ getTick: () => 0, getSimT: () => 0 });
389
+ systemAdapters.debug = debugRegistry.adapter;
390
+ ctx.debug = debugRegistry.forWorld(this.id);
391
+ // T1.2: the InputManager is a first-party per-world handle the registry
392
+ // has no other way to reach — wired as a lazy supplier (not a direct
393
+ // read here), scoped to THIS world's id, so the built-in `input.actions`
394
+ // provider always reflects the CURRENT action set, not a snapshot taken
395
+ // at mount time. Per-world (not a single last-writer-wins slot) since
396
+ // D15/T-D15.5 — see `debug-registry.ts`'s `setInputActionsSource` doc.
397
+ debugRegistry.setInputActionsSource(this.id, () =>
398
+ typeof input.actionNames === 'function'
399
+ ? input.actionNames().map((name) => ({ name, valueType: input.getActionValueType(name) }))
400
+ : [],
401
+ );
402
+ // D15/T-D15.5 — same lazy-supplier pattern (and same headless-stand-in
403
+ // guard) as `setInputActionsSource` immediately above: the built-in
404
+ // `input.trace` provider always reads the CURRENT recorded trace, never
405
+ // a snapshot taken at mount time. `seed`/`fixedDt` are assembled here
406
+ // (not inside `InputManager`, which has no business knowing about
407
+ // `ctx.random` or the loop) — the two replay-critical metadata fields
408
+ // the design doc's format sketch (§2.c) calls for beside the raw
409
+ // per-tick deltas; `null` only absent a Game/ctx.random behind this
410
+ // mount, matching every other "no Game" fallback in this file.
411
+ debugRegistry.setInputTraceSource(this.id, () => {
412
+ const raw: { version: 1; ticks: unknown[] } =
413
+ typeof input.getInputTrace === 'function'
414
+ ? input.getInputTrace()
415
+ : { version: 1, ticks: [] };
416
+ const seed = (host.game ? getSeededRandom(host.game) : null)?.seed ?? null;
417
+ const fixedDt = host.game?.loop.fixedDt ?? null;
418
+ return { version: raw.version, seed, fixedDt, ticks: raw.ticks };
419
+ });
420
+ // Task 2.1 — the same InputManager instance, wired as the debug bridge's
421
+ // actuation target (`runtime/debug-bridge.ts`'s `input.*` methods) at the
422
+ // same seed spot as `setInputActionsSource` above. Per-world (D15 review
423
+ // objection 2 fix) — see `debug-registry.ts`'s `setVirtualInputTarget`
424
+ // doc for why this used to be a single last-writer-wins slot.
425
+ debugRegistry.setVirtualInputTarget(this.id, {
426
+ setVirtualAction: (action, value) => input.setVirtualAction(action, value),
427
+ tapVirtualAction: (action) => input.tapVirtualAction(action),
428
+ clearVirtualActions: () => input.clearVirtualActions(),
429
+ scheduleActionAtTick: (tick, action, value) =>
430
+ input.scheduleActionAtTick(tick, action, value),
431
+ startInputRecording: () => input.startInputRecording(),
432
+ stopInputRecording: () => input.stopInputRecording(),
433
+ isInputRecording: () => input.isInputRecording(),
434
+ injectAxis: (sourceId, value) => input.injectAxis(sourceId, value),
435
+ injectVector2: (sourceId, value) => input.injectVector2(sourceId, value),
436
+ injectPointerDelta: (sourceId, delta) => input.injectPointerDelta(sourceId, delta),
437
+ injectPointerPosition: (sourceId, value) => input.injectPointerPosition(sourceId, value),
438
+ });
439
+ // R4-class fix — wire the InputManager's `'input.schedule.dropped'` sink
440
+ // to THIS world's debug registry, same seed spot/precedent as
441
+ // `setInputActionsSource`/`setVirtualInputTarget` above (typeof-guarded:
442
+ // a headless `InputManager` stand-in may have no `setDebugEmit`).
443
+ if (typeof input.setDebugEmit === 'function') {
444
+ input.setDebugEmit((event, detail) => ctx.debug?.emit(event, detail));
445
+ }
446
+ // D15 (T-D15.1) — same "ONE per Game root, shared across every world"
447
+ // pattern as the debug registry immediately above: `getSeededRandom
448
+ // (host.game)` reaches the SAME `SeededRandom` `createGame` filed (boot-
449
+ // seeded by the manifest-aware mount path, `mount-manifest.ts`, before
450
+ // ANY world's `mount()`/`setup()` runs — see `createGame`'s own doc
451
+ // comment), so every world's `ctx.random` is the identical instance, not
452
+ // a per-world copy. Same absence precedent as `ctx.debug`: a mount with
453
+ // no Game shell at all gets a private, mount-local surface instead.
454
+ ctx.random =
455
+ (host.game ? getSeededRandom(host.game) : null) ??
456
+ createSeededRandom(DEFAULT_SEEDED_RANDOM_SEED);
380
457
 
381
458
  // Snapshot of the engine-owned adapter kinds, taken right after seeding
382
459
  // and before any setup() has had a chance to run. `hotReload`/`disposeGame`
@@ -403,13 +480,7 @@ export class VgaiSceneGameAdapter implements GameAdapter {
403
480
  c.traverse((n) => setUserData(n, 'engineInternal', true));
404
481
  }
405
482
  let currentCleanup: GameCleanup | null = null;
406
- let sceneUIHandle: SceneUIHandle | null = null;
407
483
  let batchSystem: RenderBatchSystem | null = null;
408
- // The scenePath/sceneData branch's resolved SceneInstance, if either ran
409
- // (stays undefined for a `setup`-based mount — see the `sceneUI` deriva-
410
- // tion below). Single source of truth for both the scene-graph `ui` tree
411
- // and (potentially, in future) other instance-derived wiring.
412
- let sceneInstance: SceneInstance | undefined;
413
484
 
414
485
  // Apply scene-level render settings + transparent auto-batching after a scene
415
486
  // loads (GPU host only). Gated on the resolved `environment.rendering` cascade
@@ -448,61 +519,6 @@ export class VgaiSceneGameAdapter implements GameAdapter {
448
519
  }
449
520
  };
450
521
 
451
- // Scratch vector for the D5 default worldProjector — reused across calls
452
- // (one per bound world-tracked canvas per render), never allocated per-frame.
453
- const _sceneUIProjectorTmp = new THREE.Vector3();
454
-
455
- // The D4 default data source subscribes to game.state for its whole
456
- // lifetime with no unsubscribe path, so it must be built at most ONCE per
457
- // adapter mount — buildSceneUIServices runs again on every late
458
- // setSceneUIServices call, and rebuilding it there would both leak a
459
- // subscription and force every bound node to resubscribe to a fresh store.
460
- let defaultSceneUIData: SceneUIDataSourceLike | undefined;
461
-
462
- /**
463
- * Assemble the services `renderSceneUI` receives (design/24-scene-ui.md
464
- * D2): engine defaults (D4 data source, D5 worldProjector, D6 onEvent) <-
465
- * the project's per-mount UI registry (D11, editor-play threading) <-
466
- * game-registered services (`ctx.setSceneUIServices`) — per key, each
467
- * later layer winning over the one before it.
468
- */
469
- const buildSceneUIServices = (inst: SceneInstance): SceneUIGameServices => {
470
- const defaults: SceneUIGameServices = {
471
- onEvent: (handlerId) => {
472
- console.warn(
473
- `scene-ui: unhandled UI event "${handlerId}" — register onEvent via ctx.setSceneUIServices`,
474
- );
475
- },
476
- worldProjector: (id) => {
477
- const obj = inst.entities.get(id);
478
- if (!obj) return null;
479
- const projected = obj.getWorldPosition(_sceneUIProjectorTmp).project(camera);
480
- if (projected.z > 1) return null; // behind the camera -> hidden
481
- const w = uiContainer.clientWidth;
482
- const h = uiContainer.clientHeight;
483
- // Rounded (not sub-pixel) — matches CrtScreen's integer-rounding
484
- // (crt-screen.ts), which exists specifically to stop an asymptotic
485
- // camera lerp from shimmering a tracked canvas every frame.
486
- return {
487
- x: Math.round(((projected.x + 1) / 2) * w),
488
- y: Math.round(((1 - projected.y) / 2) * h),
489
- };
490
- },
491
- };
492
- // Lazily and at-most-once; skipped entirely while the game supplies its
493
- // own `data` service (no point subscribing a default nobody reads).
494
- if (!sceneUIServices.data && ctx.game && componentRegistry) {
495
- defaultSceneUIData ??= gameStateDataSource(ctx.game, componentRegistry);
496
- defaults.data = defaultSceneUIData;
497
- }
498
- const registryPortion: SceneUIGameServices = {
499
- ...(uiRegistry?.components ? { components: uiRegistry.components } : {}),
500
- ...(uiRegistry?.transforms ? { transforms: uiRegistry.transforms } : {}),
501
- ...(uiRegistry?.strings ? { strings: uiRegistry.strings } : {}),
502
- };
503
- return { ...defaults, ...registryPortion, ...sceneUIServices };
504
- };
505
-
506
522
  const loadFromData = async (data: SceneFile): Promise<SceneInstance> => {
507
523
  const inst = await loadSceneFromData(data, {
508
524
  scene,
@@ -510,7 +526,6 @@ export class VgaiSceneGameAdapter implements GameAdapter {
510
526
  rapier: ctx.rapier,
511
527
  physics: ctx.physics,
512
528
  audioListener: ctx.audio.listener,
513
- animGraphs: ctx.animGraphs,
514
529
  ...(componentRegistry ? { componentRegistry } : {}),
515
530
  componentManager: ctx.components,
516
531
  particleRenderer: ctx.particles.batchedRenderer,
@@ -526,23 +541,24 @@ export class VgaiSceneGameAdapter implements GameAdapter {
526
541
  if (headless) throw new Error('VgaiSceneGameAdapter: setup-based games require a GPU host');
527
542
  currentCleanup = await setup(ctx, editorPreview);
528
543
  } else if (scenePath) {
544
+ // #139: captured BEFORE load so adoptSceneCamera can tell whether a
545
+ // component (e.g. SceneCamera.init) already framed the camera.
546
+ const preLoadPose = captureCameraPose(camera);
529
547
  const inst = await loadScene(scenePath, {
530
548
  scene,
531
549
  rapierWorld: ctx.rapierWorld,
532
550
  rapier: ctx.rapier,
533
551
  physics: ctx.physics,
534
552
  audioListener: ctx.audio.listener,
535
- animGraphs: ctx.animGraphs,
536
553
  ...(componentRegistry ? { componentRegistry } : {}),
537
554
  componentManager: ctx.components,
538
555
  particleRenderer: ctx.particles.batchedRenderer,
539
556
  ...(headless ? {} : { renderer }),
540
557
  });
541
- applyEditorCamera(editorPreview, camera) || adoptSceneCamera(inst, camera);
558
+ applyEditorCamera(editorPreview, camera) || adoptSceneCamera(inst, camera, preLoadPose);
542
559
  applySceneRendering(inst);
543
560
  applySceneNavigation(inst);
544
561
  ctx.systems.add('postPhysics', (dt) => inst.update(dt));
545
- sceneInstance = inst;
546
562
  // A pre-baked navmesh (sibling .navmesh file — see scene-loader.ts) holds
547
563
  // live recast-navigation WASM handles that leak unless destroyed
548
564
  // explicitly; dispose it alongside the scene instance on teardown.
@@ -553,9 +569,9 @@ export class VgaiSceneGameAdapter implements GameAdapter {
553
569
  },
554
570
  };
555
571
  } else if (sceneData) {
572
+ const preLoadPose = captureCameraPose(camera);
556
573
  const inst = await loadFromData(sceneData);
557
- applyEditorCamera(editorPreview, camera) || adoptSceneCamera(inst, camera);
558
- sceneInstance = inst;
574
+ applyEditorCamera(editorPreview, camera) || adoptSceneCamera(inst, camera, preLoadPose);
559
575
  currentCleanup = {
560
576
  dispose: () => {
561
577
  inst.navMesh?.dispose(scene);
@@ -564,19 +580,6 @@ export class VgaiSceneGameAdapter implements GameAdapter {
564
580
  };
565
581
  }
566
582
 
567
- // Render scene-graph UI (the `ui` tree) into the overlay via the injected React
568
- // renderer (B1). Sourced from the loaded `SceneInstance` — the single source of
569
- // truth for BOTH the scenePath and sceneData branches (R8 item 7 / B: previously
570
- // only sceneData's raw `SceneFile.ui` was read via a `resolvedSceneData` special-
571
- // case, so a scenePath mount's `ui` tree was silently dropped even though
572
- // `loadScene` fully parses it). A `setup`-based mount has no `sceneInstance` at
573
- // all — its game owns any UI mounting itself. No-op if no UI or no renderer
574
- // registered. Engine stays React-free.
575
- const sceneUI = sceneInstance?.ui;
576
- if (sceneUI && sceneInstance && !headless) {
577
- sceneUIHandle = renderSceneUI(uiContainer, sceneUI, buildSceneUIServices(sceneInstance));
578
- }
579
-
580
583
  // Idempotency guard for disposeGame — see below. Declared here (not inside
581
584
  // disposeGame) so hotReload can also refuse to run on an already-disposed
582
585
  // adapter.
@@ -663,14 +666,6 @@ export class VgaiSceneGameAdapter implements GameAdapter {
663
666
  });
664
667
  safeStep('clearAssetCaches', () => clearAssetCaches());
665
668
  safeStep('debugDraw.clear', () => debugDraw.clear());
666
- safeStep('animGraphs.clear', () => animGraphs.clear());
667
- safeStep('sceneUIHandle.dispose', () => {
668
- sceneUIHandle?.dispose();
669
- sceneUIHandle = null;
670
- });
671
- safeStep('uiContainer reset', () => {
672
- uiContainer.innerHTML = '';
673
- });
674
669
 
675
670
  // R8 item 7 (C) — disposeGame (Stop) used to skip both of these, unlike
676
671
  // hotReload (warm restart), which already ran them: a game lifecycle
@@ -679,7 +674,7 @@ export class VgaiSceneGameAdapter implements GameAdapter {
679
674
  // systemAdapters kind (e.g. a `setup`-registered 'networking') survived
680
675
  // a Stop and would be visible on a STALE `mounted.systems` even though
681
676
  // the game that owned it is gone. Same two steps, same relative
682
- // ordering as hotReload (right after `uiContainer reset`, before the
677
+ // ordering as hotReload (before the
683
678
  // engine-owned audio/input teardown below, which hotReload doesn't
684
679
  // touch at all since it warm-restarts rather than tearing engine
685
680
  // subsystems down).
@@ -689,6 +684,12 @@ export class VgaiSceneGameAdapter implements GameAdapter {
689
684
  if (!engineAdapterKinds.has(kind)) delete systemAdapters[kind as keyof SystemAdapters];
690
685
  }
691
686
  });
687
+ // Defect 2 fix: a full Stop means the whole GAME is going away (every
688
+ // world it mounted, plus any react-door registrations from its HUDs)
689
+ // — unlike hotReload below (a SINGLE mount warm-restarting), so this is
690
+ // the one call site that legitimately wants strip()'s global, no-id
691
+ // form rather than scoping to `this.id`.
692
+ safeStep('debugRegistry.strip', () => debugRegistry.strip());
692
693
 
693
694
  if (!headless) {
694
695
  safeStep('audio teardown', () => {
@@ -699,6 +700,7 @@ export class VgaiSceneGameAdapter implements GameAdapter {
699
700
  });
700
701
  }
701
702
  safeStep('input.dispose', () => input.dispose());
703
+ safeStep('gpuTimer.dispose', () => gpuTimer?.dispose());
702
704
  };
703
705
 
704
706
  const hotReload = async (
@@ -764,21 +766,6 @@ export class VgaiSceneGameAdapter implements GameAdapter {
764
766
  scene.environment = null;
765
767
  });
766
768
  safeStep('debugDraw.clear', () => debugDraw.clear());
767
- safeStep('animGraphs.clear', () => animGraphs.clear());
768
- safeStep('sceneUIHandle.dispose', () => {
769
- sceneUIHandle?.dispose();
770
- sceneUIHandle = null;
771
- });
772
- // Strip game-registered scene-UI services back to none before the
773
- // incoming game's setup/init runs (D7) — same contract as the
774
- // systemAdapters strip below: a disposed game's onEvent/data/
775
- // worldProjector must never leak onto the next mount.
776
- safeStep('reset sceneUIServices', () => {
777
- sceneUIServices = {};
778
- });
779
- safeStep('uiContainer reset', () => {
780
- uiContainer.innerHTML = '';
781
- });
782
769
 
783
770
  // Bulk-remove every system the outgoing game registered (this restart's
784
771
  // predecessor) so warm restarts never accumulate duplicate systems —
@@ -798,6 +785,16 @@ export class VgaiSceneGameAdapter implements GameAdapter {
798
785
  if (!engineAdapterKinds.has(kind)) delete systemAdapters[kind as keyof SystemAdapters];
799
786
  }
800
787
  });
788
+ // Hot-reload re-seed (spec §3.1): the incoming game's setup (below)
789
+ // re-registers its providers/commands under the SAME worldId — strip()
790
+ // clears the outgoing game's registrations first so that re-seed is
791
+ // silent (no stale duplicate-name warning) rather than "replacing" a
792
+ // dead game's entries. Defect 2 fix: scoped to THIS mount's worldId
793
+ // (`this.id`) — a blind global strip() here wiped every OTHER live
794
+ // world's registrations on every warm restart of just this one, plus
795
+ // every react-door registration (whose `useEffect` cleanup never
796
+ // re-fires to restore them after someone else's strip).
797
+ safeStep('debugRegistry.strip', () => debugRegistry.strip(this.id));
801
798
 
802
799
  currentCleanup = await newSetup(ctx, newEditorPreview);
803
800
  };
@@ -813,8 +810,10 @@ export class VgaiSceneGameAdapter implements GameAdapter {
813
810
  postFrame();
814
811
  },
815
812
  resize: (w: number, h: number) => {
816
- composer.setSize(w, h);
817
- camera.aspect = w / h;
813
+ const safeWidth = Math.max(1, w);
814
+ const safeHeight = Math.max(1, h);
815
+ composer.setSize(safeWidth, safeHeight);
816
+ camera.aspect = safeWidth / safeHeight;
818
817
  camera.updateProjectionMatrix();
819
818
  },
820
819
  dispose: disposeGame,
@@ -842,17 +841,68 @@ function applyEditorCamera(
842
841
  return true;
843
842
  }
844
843
 
844
+ /** The pose `adoptSceneCamera` compares against to detect that a component
845
+ * already framed the runtime camera during scene load (#139). Capture with
846
+ * `captureCameraPose(camera)` immediately BEFORE `loadScene`. */
847
+ export interface CameraPose {
848
+ position: THREE.Vector3;
849
+ quaternion: THREE.Quaternion;
850
+ }
851
+
852
+ export function captureCameraPose(camera: THREE.PerspectiveCamera): CameraPose {
853
+ return { position: camera.position.clone(), quaternion: camera.quaternion.clone() };
854
+ }
855
+
845
856
  export function adoptSceneCamera(
846
857
  sceneInstance: SceneInstance,
847
858
  runtimeCamera: THREE.PerspectiveCamera,
859
+ preLoadPose?: CameraPose,
848
860
  ): boolean {
849
861
  const sceneCam = sceneInstance.cameras[0];
850
862
  if (!sceneCam) return false;
863
+ // #133 (blind run #4): `camera.type: 'orthographic'` builds a real
864
+ // OrthographicCamera in the scene graph (`createCamera`), but the RUNTIME
865
+ // renders through a hardcoded PerspectiveCamera — the authored projection
866
+ // silently never reached the screen. Until ortho support is ranked
867
+ // (implement-vs-reject, owner call on #133), say so loudly instead of
868
+ // letting a coplanar game render in unexplained perspective.
869
+ if ((sceneCam as THREE.Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera) {
870
+ console.warn(
871
+ "adoptSceneCamera (#133): this scene's camera is ORTHOGRAPHIC, but the runtime camera is " +
872
+ 'perspective-only — the orthographic projection is NOT applied (only the pose is). ' +
873
+ 'Use type: "perspective" (a long-lens fake: far position + small fov) until #133 lands.',
874
+ );
875
+ }
876
+ // #139 (blind run #5): if a GameComponent already positioned/oriented the
877
+ // runtime camera during scene load (`SceneCamera.init`'s
878
+ // `ctx.camera.position.set` + `lookAt` is the blessed shape), the camera
879
+ // ENTITY's transform must not stomp it — an unauthored `transform.rotation`
880
+ // is identity ("stare down −Z"), which silently blanked an entire correct
881
+ // game while every state-based probe spec passed. Components win the pose;
882
+ // the entity still contributes the LENS (fov/near/far below), which
883
+ // `SceneCamera` never sets.
884
+ const componentFramedCamera =
885
+ preLoadPose !== undefined &&
886
+ (!runtimeCamera.position.equals(preLoadPose.position) ||
887
+ !runtimeCamera.quaternion.equals(preLoadPose.quaternion));
851
888
  const parent = sceneCam.parent;
852
- if (parent) {
889
+ if (parent && !componentFramedCamera) {
853
890
  parent.updateMatrixWorld(true);
854
891
  runtimeCamera.position.copy(parent.getWorldPosition(new THREE.Vector3()));
855
- runtimeCamera.quaternion.copy(parent.getWorldQuaternion(new THREE.Quaternion()));
892
+ const worldQuat = parent.getWorldQuaternion(new THREE.Quaternion());
893
+ runtimeCamera.quaternion.copy(worldQuat);
894
+ // Identity orientation on an adopted scene camera is almost never intent
895
+ // (it means "look at the horizon down −Z", not "look at my scene") — the
896
+ // exact silent-blank-viewport footgun above. Adopt it anyway (authored
897
+ // data is truth) but say so loudly, with the two blessed remedies.
898
+ if (Math.abs(1 - Math.abs(worldQuat.w)) < 1e-6) {
899
+ console.warn(
900
+ 'adoptSceneCamera (#139): the scene camera entity has no rotation — the runtime camera ' +
901
+ 'will stare horizontally down −Z and may show nothing. Either attach the SceneCamera ' +
902
+ 'component (position + lookAt, no quaternion math) or author transform.rotation on ' +
903
+ 'the camera entity.',
904
+ );
905
+ }
856
906
  }
857
907
  if (sceneCam instanceof THREE.PerspectiveCamera) {
858
908
  runtimeCamera.fov = sceneCam.fov;