@vgai/engine 0.5.15 → 0.5.17

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 (220) hide show
  1. package/dist/adapter/adapter-module.d.ts +229 -33
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +222 -50
  4. package/dist/adapter/authoring-seam-contract.d.ts +530 -0
  5. package/dist/adapter/authoring-seam-contract.d.ts.map +1 -0
  6. package/dist/adapter/authoring-seam-contract.js +162 -0
  7. package/dist/adapter/authoring.d.ts +237 -50
  8. package/dist/adapter/authoring.d.ts.map +1 -1
  9. package/dist/adapter/authoring.js +2 -1
  10. package/dist/adapter/entrypoint-selection-source.d.ts +35 -0
  11. package/dist/adapter/entrypoint-selection-source.d.ts.map +1 -0
  12. package/dist/adapter/entrypoint-selection-source.js +151 -0
  13. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +7 -3
  14. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -1
  15. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +145 -27
  16. package/dist/adapter/first-party-systems.d.ts +13 -3
  17. package/dist/adapter/first-party-systems.d.ts.map +1 -1
  18. package/dist/adapter/first-party-systems.js +10 -3
  19. package/dist/adapter/index.d.ts +7 -2
  20. package/dist/adapter/index.d.ts.map +1 -1
  21. package/dist/adapter/index.js +4 -0
  22. package/dist/adapter/ingest/contract-system-adapters.d.ts +1 -1
  23. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
  24. package/dist/adapter/ingest/contract-system-adapters.js +13 -25
  25. package/dist/adapter/ingest/game-contract-seams.d.ts +214 -0
  26. package/dist/adapter/ingest/game-contract-seams.d.ts.map +1 -0
  27. package/dist/adapter/ingest/game-contract-seams.js +62 -0
  28. package/dist/adapter/ingest/game-contract.d.ts +5 -3
  29. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  30. package/dist/adapter/ingest/game-contract.js +4 -1
  31. package/dist/adapter/ingest/merge-debug-adapters.d.ts +52 -0
  32. package/dist/adapter/ingest/merge-debug-adapters.d.ts.map +1 -0
  33. package/dist/adapter/ingest/merge-debug-adapters.js +141 -0
  34. package/dist/adapter/ingest/observation-debug-adapter.d.ts +56 -0
  35. package/dist/adapter/ingest/observation-debug-adapter.d.ts.map +1 -0
  36. package/dist/adapter/ingest/observation-debug-adapter.js +131 -0
  37. package/dist/adapter/ingest/scene-capture.d.ts +38 -13
  38. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  39. package/dist/adapter/ingest/scene-capture.js +101 -24
  40. package/dist/adapter/ingest/visible-capture-window.d.ts +22 -5
  41. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -1
  42. package/dist/adapter/ingest/visible-capture-window.js +46 -7
  43. package/dist/adapter/native-debug-module.d.ts +43 -0
  44. package/dist/adapter/native-debug-module.d.ts.map +1 -0
  45. package/dist/adapter/native-debug-module.js +230 -0
  46. package/dist/adapter/object3d-authoring-subject.d.ts +32 -0
  47. package/dist/adapter/object3d-authoring-subject.d.ts.map +1 -0
  48. package/dist/adapter/object3d-authoring-subject.js +25 -0
  49. package/dist/adapter/root-adapter.d.ts +25 -56
  50. package/dist/adapter/root-adapter.d.ts.map +1 -1
  51. package/dist/adapter/root-adapter.js +4 -20
  52. package/dist/adapter/root-seam-contract.d.ts +129 -0
  53. package/dist/adapter/root-seam-contract.d.ts.map +1 -0
  54. package/dist/adapter/root-seam-contract.js +37 -0
  55. package/dist/adapter/seam-evidence.d.ts +118 -0
  56. package/dist/adapter/seam-evidence.d.ts.map +1 -0
  57. package/dist/adapter/seam-evidence.js +231 -0
  58. package/dist/adapter/setup-three-root-adapter.d.ts +2 -3
  59. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  60. package/dist/adapter/setup-three-root-adapter.js +11 -12
  61. package/dist/adapter/system-adapter.d.ts +6 -1
  62. package/dist/adapter/system-adapter.d.ts.map +1 -1
  63. package/dist/adapter/system-seam-contract.d.ts +354 -0
  64. package/dist/adapter/system-seam-contract.d.ts.map +1 -0
  65. package/dist/adapter/system-seam-contract.js +95 -0
  66. package/dist/animation/xstate-animation-binding.d.ts +6 -0
  67. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  68. package/dist/animation/xstate-animation-binding.js +142 -4
  69. package/dist/asset-formats/collider.d.ts +1 -1
  70. package/dist/asset-formats/mesh.d.ts +2 -2
  71. package/dist/asset-formats/particles.d.ts +1 -1
  72. package/dist/canvas-react/engine-bridge.d.ts +6 -6
  73. package/dist/canvas-react/engine-bridge.d.ts.map +1 -1
  74. package/dist/canvas-react/engine-bridge.js +2 -2
  75. package/dist/canvas-react/index.d.ts +2 -2
  76. package/dist/canvas-react/index.d.ts.map +1 -1
  77. package/dist/canvas-react/index.js +1 -1
  78. package/dist/canvas-react/pixi-react-adapter.d.ts +15 -9
  79. package/dist/canvas-react/pixi-react-adapter.d.ts.map +1 -1
  80. package/dist/canvas-react/pixi-react-adapter.js +129 -64
  81. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  82. package/dist/canvas-react/pixi-react-root-factory.js +4 -0
  83. package/dist/canvas-react/world-context.d.ts +22 -12
  84. package/dist/canvas-react/world-context.d.ts.map +1 -1
  85. package/dist/canvas-react/world-context.js +12 -51
  86. package/dist/data/curve.d.ts +1 -1
  87. package/dist/data/curve.js +2 -2
  88. package/dist/dev/instruments.d.ts +4 -4
  89. package/dist/dev/instruments.js +4 -4
  90. package/dist/dev/register-render-vitals.d.ts +2 -2
  91. package/dist/dev/register-render-vitals.js +2 -2
  92. package/dist/dev/static-batch-advisor.d.ts +1 -1
  93. package/dist/dev/static-batch-advisor.js +2 -2
  94. package/dist/ecs/user-data.d.ts +5 -0
  95. package/dist/ecs/user-data.d.ts.map +1 -1
  96. package/dist/ecs/user-data.js +3 -0
  97. package/dist/input/input-manager.d.ts +64 -0
  98. package/dist/input/input-manager.d.ts.map +1 -1
  99. package/dist/input/input-manager.js +149 -0
  100. package/dist/manifest/load.d.ts +0 -3
  101. package/dist/manifest/load.d.ts.map +1 -1
  102. package/dist/manifest/load.js +0 -4
  103. package/dist/manifest/schema.d.ts +9 -11
  104. package/dist/manifest/schema.d.ts.map +1 -1
  105. package/dist/manifest/schema.js +2 -23
  106. package/dist/pixi/authoring.d.ts +33 -0
  107. package/dist/pixi/authoring.d.ts.map +1 -1
  108. package/dist/pixi/authoring.js +301 -2
  109. package/dist/pixi/ingest.d.ts +2 -1
  110. package/dist/pixi/ingest.d.ts.map +1 -1
  111. package/dist/pixi/ingest.js +4 -2
  112. package/dist/pixi/scene-capture.d.ts +9 -1
  113. package/dist/pixi/scene-capture.d.ts.map +1 -1
  114. package/dist/pixi/scene-capture.js +9 -6
  115. package/dist/react/world-state.d.ts +12 -0
  116. package/dist/react/world-state.d.ts.map +1 -1
  117. package/dist/react/world-state.js +14 -0
  118. package/dist/render/particles-factory.d.ts +13 -6
  119. package/dist/render/particles-factory.d.ts.map +1 -1
  120. package/dist/render/particles-factory.js +6 -3
  121. package/dist/render/soft-particle-depth.d.ts +3 -3
  122. package/dist/render/soft-particle-depth.d.ts.map +1 -1
  123. package/dist/render/soft-particle-depth.js +8 -13
  124. package/dist/runtime/create-runtime.d.ts +25 -62
  125. package/dist/runtime/create-runtime.d.ts.map +1 -1
  126. package/dist/runtime/create-runtime.js +58 -105
  127. package/dist/runtime/dev-build.d.ts +37 -0
  128. package/dist/runtime/dev-build.d.ts.map +1 -0
  129. package/dist/runtime/dev-build.js +49 -0
  130. package/dist/runtime/game.d.ts +38 -72
  131. package/dist/runtime/game.d.ts.map +1 -1
  132. package/dist/runtime/game.js +40 -55
  133. package/dist/runtime/mount-game.d.ts.map +1 -1
  134. package/dist/runtime/mount-game.js +12 -1
  135. package/dist/runtime/mount-manifest.d.ts +0 -13
  136. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  137. package/dist/runtime/mount-manifest.js +5 -25
  138. package/dist/runtime/types.d.ts +18 -0
  139. package/dist/runtime/types.d.ts.map +1 -1
  140. package/dist/setup/setup-renderer.d.ts +3 -4
  141. package/dist/setup/setup-renderer.d.ts.map +1 -1
  142. package/dist/setup/setup-renderer.js +3 -4
  143. package/dist/world3d-react/engine-bridge.d.ts +1 -1
  144. package/dist/world3d-react/engine-bridge.d.ts.map +1 -1
  145. package/dist/world3d-react/engine-bridge.js +29 -4
  146. package/dist/world3d-react/index.d.ts +1 -1
  147. package/dist/world3d-react/index.js +1 -1
  148. package/dist/world3d-react/r3f-adapter.d.ts +7 -0
  149. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  150. package/dist/world3d-react/r3f-adapter.js +132 -28
  151. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  152. package/dist/world3d-react/r3f-root-factory.js +14 -0
  153. package/dist/world3d-react/rapier-physics-bridge.d.ts +4 -4
  154. package/dist/world3d-react/rapier-physics-bridge.js +3 -3
  155. package/dist/world3d-react/world-context.d.ts +52 -3
  156. package/dist/world3d-react/world-context.d.ts.map +1 -1
  157. package/dist/world3d-react/world-context.js +94 -64
  158. package/dist-config/config.js.map +1 -1
  159. package/package.json +1 -1
  160. package/schemas/engine-capabilities.json +8 -8
  161. package/schemas/vgai-project.schema.json +1 -6
  162. package/src/adapter/adapter-module.ts +405 -72
  163. package/src/adapter/authoring-seam-contract.ts +211 -0
  164. package/src/adapter/authoring.ts +242 -50
  165. package/src/adapter/entrypoint-selection-source.ts +159 -0
  166. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +162 -36
  167. package/src/adapter/first-party-systems.ts +17 -3
  168. package/src/adapter/index.ts +51 -1
  169. package/src/adapter/ingest/contract-system-adapters.ts +26 -30
  170. package/src/adapter/ingest/game-contract-seams.ts +85 -0
  171. package/src/adapter/ingest/game-contract.ts +9 -4
  172. package/src/adapter/ingest/merge-debug-adapters.ts +193 -0
  173. package/src/adapter/ingest/observation-debug-adapter.ts +158 -0
  174. package/src/adapter/ingest/scene-capture.ts +126 -23
  175. package/src/adapter/ingest/visible-capture-window.ts +63 -9
  176. package/src/adapter/native-debug-module.ts +307 -0
  177. package/src/adapter/object3d-authoring-subject.ts +50 -0
  178. package/src/adapter/root-adapter.ts +25 -56
  179. package/src/adapter/root-seam-contract.ts +57 -0
  180. package/src/adapter/seam-evidence.ts +336 -0
  181. package/src/adapter/setup-three-root-adapter.ts +13 -15
  182. package/src/adapter/system-adapter.ts +6 -1
  183. package/src/adapter/system-seam-contract.ts +121 -0
  184. package/src/animation/xstate-animation-binding.ts +151 -4
  185. package/src/canvas-react/engine-bridge.ts +6 -6
  186. package/src/canvas-react/index.ts +2 -1
  187. package/src/canvas-react/pixi-react-adapter.tsx +143 -77
  188. package/src/canvas-react/pixi-react-root-factory.tsx +4 -0
  189. package/src/canvas-react/world-context.ts +26 -101
  190. package/src/data/curve.ts +2 -2
  191. package/src/dev/instruments.ts +4 -4
  192. package/src/dev/register-render-vitals.ts +2 -2
  193. package/src/dev/static-batch-advisor.ts +2 -2
  194. package/src/ecs/user-data.ts +5 -0
  195. package/src/input/input-manager.ts +145 -0
  196. package/src/manifest/load.ts +0 -7
  197. package/src/manifest/schema.ts +2 -25
  198. package/src/pixi/authoring.ts +329 -2
  199. package/src/pixi/ingest.ts +9 -2
  200. package/src/pixi/scene-capture.ts +20 -5
  201. package/src/react/world-state.tsx +15 -0
  202. package/src/render/particles-factory.ts +16 -6
  203. package/src/render/soft-particle-depth.ts +8 -13
  204. package/src/runtime/create-runtime.ts +79 -158
  205. package/src/runtime/dev-build.ts +49 -0
  206. package/src/runtime/game.ts +72 -113
  207. package/src/runtime/mount-game.ts +19 -1
  208. package/src/runtime/mount-manifest.ts +5 -37
  209. package/src/runtime/types.ts +22 -0
  210. package/src/setup/setup-renderer.ts +3 -4
  211. package/src/world3d-react/engine-bridge.ts +29 -4
  212. package/src/world3d-react/index.ts +1 -1
  213. package/src/world3d-react/r3f-adapter.tsx +140 -31
  214. package/src/world3d-react/r3f-root-factory.tsx +14 -0
  215. package/src/world3d-react/rapier-physics-bridge.tsx +4 -4
  216. package/src/world3d-react/world-context.ts +111 -87
  217. package/dist/runtime/dev-layers.d.ts +0 -34
  218. package/dist/runtime/dev-layers.d.ts.map +0 -1
  219. package/dist/runtime/dev-layers.js +0 -46
  220. package/src/runtime/dev-layers.ts +0 -46
@@ -4,7 +4,10 @@ import type { createSystemRunner } from '../core/system-runner';
4
4
  import { deleteUserData, getUserData, setUserData } from '../ecs/user-data';
5
5
  import type { BlendTreeDef } from './anim-graph-types';
6
6
  import { evaluateBlendTree } from './blend-node';
7
- import { attachAnimationRuntimeInspection } from './runtime-inspection';
7
+ import {
8
+ type AnimationPreviewTransport,
9
+ attachAnimationRuntimeInspection,
10
+ } from './runtime-inspection';
8
11
  import {
9
12
  type AnimationBoneMask,
10
13
  type AnimationMetaStateNodeLike,
@@ -109,6 +112,12 @@ export interface XStateAnimationBindingOptions {
109
112
  * omitted, the caller owns ticking (useful for standalone Three tests).
110
113
  */
111
114
  systems?: Pick<ReturnType<typeof createSystemRunner>, 'add' | 'remove'>;
115
+ /**
116
+ * Publish a seekable transport for an isolated design-time mount such as a
117
+ * CSF story. Never enable this on the live game binding: runtime inspection
118
+ * stays read-only there, while the story deliberately owns this preview.
119
+ */
120
+ previewTransport?: boolean;
112
121
  }
113
122
 
114
123
  export interface XStateAnimationLayerState {
@@ -368,6 +377,89 @@ export function bindXStateAnimation(
368
377
  const activeLayers = new Map<string, ActiveState>();
369
378
  let disposed = false;
370
379
  let detachRuntimeInspection = () => {};
380
+ let previewClip: string | null = null;
381
+ let previewAction: THREE.AnimationAction | null = null;
382
+ let previewPlaying = true;
383
+ let previewLoop = true;
384
+ let previewSpeed = 1;
385
+
386
+ function controllerPreviewSubject(): {
387
+ clip: string;
388
+ action: THREE.AnimationAction | null;
389
+ } {
390
+ const layers = [activeLayers.get('base'), ...activeLayers.values()].filter(
391
+ (active, index, all): active is ActiveState =>
392
+ Boolean(active) && all.indexOf(active) === index,
393
+ );
394
+ for (const active of layers) {
395
+ const weighted = [...active.natural].sort((left, right) => {
396
+ const leftWeight = actionCache.get(left.actionKey)?.action.getEffectiveWeight() ?? 0;
397
+ const rightWeight = actionCache.get(right.actionKey)?.action.getEffectiveWeight() ?? 0;
398
+ return rightWeight - leftWeight;
399
+ })[0];
400
+ if (weighted) {
401
+ return {
402
+ clip: weighted.clip,
403
+ action: actionCache.get(weighted.actionKey)?.action ?? null,
404
+ };
405
+ }
406
+ }
407
+ return { clip: clips.keys().next().value ?? '', action: null };
408
+ }
409
+
410
+ function actionIsControllerOwned(action: THREE.AnimationAction): boolean {
411
+ for (const cached of actionCache.values()) {
412
+ if (cached.action === action) return true;
413
+ }
414
+ return false;
415
+ }
416
+
417
+ function releasePreviewAction(): void {
418
+ const action = previewAction;
419
+ if (!action) return;
420
+ action.stop();
421
+ if (!actionIsControllerOwned(action)) {
422
+ mixer.uncacheAction(action.getClip(), action.getRoot());
423
+ }
424
+ previewAction = null;
425
+ }
426
+
427
+ function enterClipPreview(name: string): void {
428
+ const clip = clips.get(name);
429
+ if (!clip) return;
430
+ releasePreviewAction();
431
+ previewClip = name;
432
+ for (const cached of actionCache.values()) cached.action.setEffectiveWeight(0);
433
+ const action = mixer.clipAction(clip).reset().play();
434
+ action.setEffectiveWeight(1);
435
+ action.setEffectiveTimeScale(1);
436
+ action.setLoop(previewLoop ? THREE.LoopRepeat : THREE.LoopOnce, previewLoop ? Infinity : 1);
437
+ action.clampWhenFinished = !previewLoop;
438
+ previewAction = action;
439
+ mixer.update(0);
440
+ }
441
+
442
+ function restoreControllerPreview(): void {
443
+ releasePreviewAction();
444
+ previewClip = null;
445
+ const snapshot = actor.getSnapshot();
446
+ reconcile(snapshot);
447
+ const context = snapshot.context;
448
+ for (const cached of actionCache.values()) cached.action.setEffectiveWeight(0);
449
+ for (const active of activeLayers.values()) {
450
+ active.crossfadeRemaining = 0;
451
+ active.natural = weightsOf(active.stateId, active.meta, context);
452
+ const loop = isBlendTreeAnimationMeta(active.meta) ? true : active.meta.loop;
453
+ const speed = isBlendTreeAnimationMeta(active.meta) ? 1 : active.meta.speed;
454
+ const layerWeight = layerWeightOf(active.meta);
455
+ for (const weighted of active.natural) {
456
+ ensureAction(weighted.actionKey, loop, speed, layerWeight)
457
+ .play()
458
+ .setEffectiveWeight(weighted.weight * layerWeight);
459
+ }
460
+ }
461
+ mixer.update(0);
462
+ }
371
463
 
372
464
  function silence(weights: WeightedClip[], except: string | undefined, duration: number): void {
373
465
  for (const weighted of weights) {
@@ -465,23 +557,76 @@ export function bindXStateAnimation(
465
557
  const initialSnapshot = actor.getSnapshot();
466
558
  reconcile(initialSnapshot);
467
559
  const subscription = actor.subscribe((snapshot) => {
468
- if (!disposed) reconcile(snapshot);
560
+ // Isolated clip review deliberately holds behavior output. The actor may
561
+ // keep receiving story args, but its latest state is reconciled only when
562
+ // the user returns to Controller.
563
+ if (!disposed && !previewClip) reconcile(snapshot);
469
564
  });
470
565
 
471
566
  function tick(dt: number): void {
472
567
  if (disposed) return;
568
+ if (options.previewTransport && !previewPlaying) return;
569
+ const elapsed = dt * (options.previewTransport ? previewSpeed : 1);
570
+ if (options.previewTransport && previewClip && previewAction) {
571
+ mixer.update(elapsed);
572
+ const clip = clips.get(previewClip);
573
+ if (!previewLoop && clip && previewAction.time >= clip.duration) previewPlaying = false;
574
+ return;
575
+ }
473
576
  const context = actor.getSnapshot().context;
474
577
  for (const active of activeLayers.values()) {
475
578
  if (active.crossfadeRemaining > 0) {
476
- active.crossfadeRemaining = Math.max(0, active.crossfadeRemaining - dt);
579
+ active.crossfadeRemaining = Math.max(0, active.crossfadeRemaining - elapsed);
477
580
  if (active.crossfadeRemaining <= 0) applyLiveBlendWeights(active, context);
478
581
  } else {
479
582
  applyLiveBlendWeights(active, context);
480
583
  }
481
584
  }
482
- mixer.update(dt);
585
+ mixer.update(elapsed);
483
586
  }
484
587
 
588
+ const previewTransport: AnimationPreviewTransport | undefined = options.previewTransport
589
+ ? {
590
+ getSnapshot: () => {
591
+ const controller = controllerPreviewSubject();
592
+ return {
593
+ clip: previewClip ?? controller.clip,
594
+ time: previewClip ? (previewAction?.time ?? 0) : (controller.action?.time ?? 0),
595
+ playing: previewPlaying,
596
+ loop: previewLoop,
597
+ speed: previewSpeed,
598
+ mode: previewClip ? 'clip' : 'controller',
599
+ };
600
+ },
601
+ setPlaying(playing) {
602
+ previewPlaying = playing;
603
+ },
604
+ setLoop(loop) {
605
+ previewLoop = loop;
606
+ if (!previewClip) enterClipPreview(controllerPreviewSubject().clip);
607
+ previewAction?.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
608
+ if (previewAction) previewAction.clampWhenFinished = !loop;
609
+ },
610
+ setSpeed(speed) {
611
+ previewSpeed = THREE.MathUtils.clamp(speed, 0.05, 4);
612
+ },
613
+ seek(time) {
614
+ if (!previewClip) enterClipPreview(controllerPreviewSubject().clip);
615
+ const clip = previewClip ? clips.get(previewClip) : undefined;
616
+ if (!clip || !previewAction) return;
617
+ previewAction.time = THREE.MathUtils.clamp(time, 0, Math.max(clip.duration, 0));
618
+ mixer.update(0);
619
+ },
620
+ setClip(name) {
621
+ if (name === null) {
622
+ if (previewClip) restoreControllerPreview();
623
+ return;
624
+ }
625
+ enterClipPreview(name);
626
+ },
627
+ }
628
+ : undefined;
629
+
485
630
  const binding: XStateAnimationBinding = {
486
631
  tick,
487
632
  actor,
@@ -503,6 +648,7 @@ export function bindXStateAnimation(
503
648
  if (disposed) return;
504
649
  disposed = true;
505
650
  detachRuntimeInspection();
651
+ releasePreviewAction();
506
652
  options.systems?.remove('animation', tick);
507
653
  subscription.unsubscribe();
508
654
  for (const { action, clip } of actionCache.values()) {
@@ -520,6 +666,7 @@ export function bindXStateAnimation(
520
666
  detachRuntimeInspection = attachAnimationRuntimeInspection(owner, {
521
667
  mixer,
522
668
  clips,
669
+ ...(previewTransport ? { previewTransport } : {}),
523
670
  getSnapshot: () => ({
524
671
  layers: [...activeLayers.values()].map((active) => {
525
672
  const parameters = readParameters(actor.getSnapshot().context);
@@ -14,20 +14,20 @@
14
14
  */
15
15
 
16
16
  import { createContext, useContext } from 'react';
17
- import type { GameContext } from '../runtime/types';
17
+ import type { CanvasGameContext } from './world-context';
18
18
 
19
19
  export interface EngineBridgeValue {
20
- /** This root's `GameContext` — `input`, `debug`, `random`, `clock`, `game`,
20
+ /** This root's `CanvasGameContext` — `input`, `debug`, `random`, `clock`, `game`,
21
21
  * `roots`, `systems`, `registerSystemAdapter`. Present so ordinary
22
22
  * components can reach engine services with a HOOK (see
23
23
  * {@link useGameContext}). */
24
- readonly ctx?: GameContext | undefined;
24
+ readonly ctx?: CanvasGameContext | undefined;
25
25
  }
26
26
 
27
27
  export const EngineBridge = createContext<EngineBridgeValue | null>(null);
28
28
 
29
29
  /**
30
- * This canvas root's `GameContext`, for use inside any component in the tree.
30
+ * This canvas root's `CanvasGameContext`, for use inside any component in the tree.
31
31
  *
32
32
  * An ordinary component does what a model would write:
33
33
  *
@@ -36,7 +36,7 @@ export const EngineBridge = createContext<EngineBridgeValue | null>(null);
36
36
  * const { input } = useGameContext();
37
37
  * const ref = useRef<Sprite>(null);
38
38
  * useTick(({ deltaMS }) => {
39
- * if (input.isPressed('move_right')) ref.current!.x += speed * (deltaMS / 1000);
39
+ * if (input?.isPressed('move_right')) ref.current!.x += speed * (deltaMS / 1000);
40
40
  * });
41
41
  * return <pixiSprite ref={ref} texture={Texture.WHITE} />;
42
42
  * }
@@ -46,7 +46,7 @@ export const EngineBridge = createContext<EngineBridgeValue | null>(null);
46
46
  * `createPixiReactAdapter` — absent context is a wiring bug, and a silent
47
47
  * `null` would surface as a mystery `undefined` deep in a tick callback.
48
48
  */
49
- export function useGameContext(): GameContext {
49
+ export function useGameContext(): CanvasGameContext {
50
50
  const bridge = useContext(EngineBridge);
51
51
  if (!bridge?.ctx) {
52
52
  throw new Error(
@@ -22,7 +22,7 @@
22
22
  * - `PixiPrimitive` / `adoptNow` — the canvas surface's `<primitive object={…}>`:
23
23
  * render a container the game already owns, and place a spawn in the
24
24
  * display tree ahead of the commit that renders it.
25
- * - `useGameContext()` — this root's `GameContext` (`input`, `debug`,
25
+ * - `useGameContext()` — this root's `CanvasGameContext` (`input`, `debug`,
26
26
  * `random`, `clock`, `game`, `roots`) from inside any component in the tree.
27
27
  * Behavior in a canvas world is components + hooks, never a class under a
28
28
  * string key.
@@ -43,6 +43,7 @@ export {
43
43
  } from './pixi-react-adapter';
44
44
  export { pixiReactRootFactory, resolveCanvasEntryAdapter } from './pixi-react-root-factory';
45
45
  export {
46
+ type CanvasGameContext,
46
47
  type CanvasRootContextOptions,
47
48
  type CanvasRootRuntime,
48
49
  createCanvasRootContext,
@@ -6,20 +6,20 @@
6
6
  * this returns a `MountedPixiRoot` (`../adapter/root-adapter.ts`) whose `stage`
7
7
  * is the REAL `PIXI.Container` the reconciler renders into.
8
8
  *
9
- * This is the canvas surface's exact sibling of `world3d-react`'s
9
+ * This is the canvas surface's sibling of `world3d-react`'s
10
10
  * `createR3FAdapter`, with `@pixi/react` where that one has
11
- * react-three-fiber. Everything structural is the same, because the seam is
12
- * the same: one loop (the host's), one canvas (the host's), a world-local
13
- * `SystemRunner` ticked in engine phase order before the renderer advances,
14
- * and a `GameContext` threaded into the tree through `EngineBridge`.
11
+ * react-three-fiber. The explicit factory still supplies the engine context
12
+ * used by the current first-party Canvas examples while their app-owned input
13
+ * and debugger migration remains tracked in `docs/WORK.md`; `useTick` hooks
14
+ * stay registered on Pixi's real ticker throughout that migration.
15
15
  *
16
16
  * ## The loop contract
17
17
  *
18
18
  * `Application` is initialized with `autoStart: false` and `sharedTicker:
19
- * false`, so it owns a ticker that is NEVER started and therefore never
20
- * requests an animation frame. `mounted.update(dt)` is the only thing that
21
- * advances it, and it feeds GAME time, not wall time — which is what makes the
22
- * world freeze exactly when the host freezes it, and what makes
19
+ * false`, so it owns a native Pixi ticker that is NEVER started and therefore
20
+ * never requests an animation frame. `mounted.update(dt)` is the only thing
21
+ * that advances that ticker, and it feeds GAME time, not wall time — which is
22
+ * what makes the world freeze exactly when the host freezes it, and what makes
23
23
  * `game.waitSimTime` work in a hidden tab (a hidden tab has no rAF; the engine
24
24
  * loop's own catch-up still calls `update`, and Pixi has no independent clock
25
25
  * to disagree with it).
@@ -38,9 +38,11 @@
38
38
  import { createRoot, extend } from '@pixi/react';
39
39
  import type { Application, ApplicationOptions } from 'pixi.js';
40
40
  import * as PIXI from 'pixi.js';
41
- import { createElement, type ReactNode, useLayoutEffect } from 'react';
41
+ import { createElement, Fragment, type ReactNode, useEffect, useLayoutEffect } from 'react';
42
42
  import type { CanvasHostContext, MountedPixiRoot, RootAdapter } from '../adapter';
43
+ import type { SystemAdapters } from '../adapter/system-adapter';
43
44
  import { WorldProvider } from '../react/world-state';
45
+ import { getDebugRegistry } from '../runtime/debug-registry';
44
46
  import { EngineBridge, type EngineBridgeValue } from './engine-bridge';
45
47
  import { createCanvasRootContext } from './world-context';
46
48
 
@@ -77,6 +79,12 @@ export interface CreatePixiReactAdapterOptions {
77
79
  * actions). A missing/invalid map degrades loudly without failing the
78
80
  * mount — see `world-context.ts`. */
79
81
  readonly inputMapPath?: string | null | undefined;
82
+ /**
83
+ * Transitional compatibility for component trees that still consume the
84
+ * engine context. Native trees opt out once their input, observation and
85
+ * debugger state are application-owned.
86
+ */
87
+ readonly engineRuntime?: boolean | undefined;
80
88
  /**
81
89
  * Extra `Application.init` options merged UNDER this adapter's own — e.g.
82
90
  * `{ antialias: false }`, `{ roundPixels: true }`. The loop-critical ones
@@ -98,21 +106,21 @@ export interface CreatePixiReactAdapterOptions {
98
106
  export function createPixiReactAdapter(
99
107
  options: CreatePixiReactAdapterOptions,
100
108
  ): RootAdapter<'canvas'> {
101
- const { id, content, inputMapPath, applicationOptions } = options;
109
+ const { id, content, inputMapPath, engineRuntime = true, applicationOptions } = options;
102
110
 
103
111
  return {
104
112
  id,
105
113
 
106
114
  async mount(host: CanvasHostContext): Promise<MountedPixiRoot> {
107
- const runtime = createCanvasRootContext(host, { id, inputMapPath });
108
- const bridge: EngineBridgeValue = { ctx: runtime.ctx };
115
+ const runtime = engineRuntime ? createCanvasRootContext(host, { id, inputMapPath }) : null;
116
+ const bridge: EngineBridgeValue | null = runtime ? { ctx: runtime.ctx } : null;
109
117
  // Actions must exist before any component's first tick reads them — wait
110
118
  // for the (never-rejecting) input-map load before the first commit.
111
- await runtime.inputMapReady;
119
+ await runtime?.inputMapReady;
112
120
 
113
121
  const canvas = host.canvas;
114
122
  if (MOUNTED_CANVASES.has(canvas)) {
115
- runtime.dispose();
123
+ runtime?.dispose();
116
124
  throw new Error(
117
125
  `createPixiReactAdapter: root "${id}" was handed a canvas this lane has already ` +
118
126
  'mounted. @pixi/react keys its reconciler roots off the canvas element, so re-using ' +
@@ -129,6 +137,10 @@ export function createPixiReactAdapter(
129
137
  const committed = new Promise<void>((resolve) => {
130
138
  resolveCommitted = resolve;
131
139
  });
140
+ let resolveEffectsReady!: () => void;
141
+ const effectsReady = new Promise<void>((resolve) => {
142
+ resolveEffectsReady = resolve;
143
+ });
132
144
  let resolveUnmounted!: () => void;
133
145
  const unmounted = new Promise<void>((resolve) => {
134
146
  resolveUnmounted = resolve;
@@ -152,22 +164,39 @@ export function createPixiReactAdapter(
152
164
  resolveCommitted();
153
165
  return resolveUnmounted;
154
166
  }, []);
167
+ // This component is the last sibling. Reaching its passive effect
168
+ // means the initial tree's useDebugProvider/useDebugCommand effects
169
+ // have registered, so mount() can truthfully back Play's ack.
170
+ useEffect(resolveEffectsReady, []);
155
171
  return null;
156
172
  }
157
173
 
158
- const game = runtime.ctx.game;
159
174
  // The React seam every dom root already has — `useWorldState`,
160
175
  // `useDebugProvider`/`useDebugCommand`/`useDebugEmit`
161
176
  // (`../react/world-state`) — works identically inside a canvas root
162
177
  // because a canvas root IS React. `host.game` is genuinely absent in
163
178
  // bare/foreign hosts, so the provider is conditional and those hooks
164
179
  // stay the inert no-ops they already document themselves to be.
165
- const element = createElement(
166
- EngineBridge.Provider,
167
- { value: bridge },
168
- game ? createElement(WorldProvider, { game }, content) : content,
169
- createElement(Lifecycle),
170
- );
180
+ //
181
+ // READ FROM `host.game`, NEVER FROM `runtime` — the same rule, and the
182
+ // same reasoning, as the three lane's (`../world3d-react/r3f-adapter.tsx`,
183
+ // beside its own `const game = host.game`). The two providers answer
184
+ // different questions: `EngineBridge` rides `bridge` (the vgai runtime
185
+ // CONTEXT, which must not enter an ecosystem-native tree), `WorldProvider`
186
+ // rides the Game handle the host already holds. Keyed off `runtime`, a
187
+ // world mounted with `engineRuntime: false` gets no `WorldProvider` at
188
+ // all and every `useDebugProvider` in it no-ops IN SILENCE — no error, and
189
+ // `vgai eval 'await game.providers()'` lists only the built-ins. That is
190
+ // exactly what happened on the three lane. It has never fired here only
191
+ // because `pixi-react-root-factory.tsx` still leaves the runtime on;
192
+ // that flag is the tracked component-root migration's to flip, and this
193
+ // seam must not be waiting for it.
194
+ const game = host.game;
195
+ const world = createElement(Fragment, null, content, createElement(Lifecycle));
196
+ const provided = game ? createElement(WorldProvider, { game }, world) : world;
197
+ const element = bridge
198
+ ? createElement(EngineBridge.Provider, { value: bridge }, provided)
199
+ : provided;
171
200
 
172
201
  const root: PixiReactRoot = createRoot(canvas);
173
202
 
@@ -189,50 +218,74 @@ export function createPixiReactAdapter(
189
218
  sharedTicker: false,
190
219
  };
191
220
 
192
- // `root.render` is async: it awaits `Application.init()` (which is what
193
- // creates the renderer) and only then hands the tree to the reconciler.
194
- // A crash inside init rejects here, which is the loud failure we want.
195
- const app: Application = await root.render(element, initOptions);
196
- runtime.setApplication(app);
221
+ // Install the reconcile-error listener BEFORE `root.render`: Pixi hands
222
+ // the Application back after `init()` but React can already have thrown
223
+ // during that same turn. Attaching after the await loses the real error
224
+ // and turns it into a misleading ten-second timeout.
225
+ let reportReconcileFailure!: (why: string) => void;
226
+ const reconcileFailure = new Promise<string>((resolve) => {
227
+ reportReconcileFailure = resolve;
228
+ });
229
+ const onError = (event: ErrorEvent): void => {
230
+ reportReconcileFailure(
231
+ `the Pixi tree crashed before its first commit — ${event.message} ` +
232
+ '(mount() fails loudly instead of hanging)',
233
+ );
234
+ };
235
+ globalThis.addEventListener?.('error', onError);
236
+
237
+ let app: Application | undefined;
238
+ let timer: ReturnType<typeof setTimeout> | undefined;
239
+ try {
240
+ // `root.render` is async: it awaits `Application.init()` (which is what
241
+ // creates the renderer) and only then hands the tree to the reconciler.
242
+ // A crash inside init rejects here, which is the loud failure we want.
243
+ const renderedApp = await root.render(element, initOptions);
244
+ app = renderedApp;
245
+ runtime?.setApplication(renderedApp);
197
246
 
198
- // A reconcile-time crash (e.g. an un-extended component) surfaces as an
199
- // uncaught window error and the first commit never lands without this
200
- // guard `mount()` would await `committed` forever. Convert that class of
201
- // failure into a loud mount error.
202
- await new Promise<void>((resolve, reject) => {
203
- const fail = (why: string) => {
204
- cleanup();
205
- try {
206
- app.destroy(false, { children: true });
207
- } catch {
208
- /* the world never fully came up; teardown failure must not mask why */
209
- }
210
- runtime.dispose();
211
- reject(new Error(`createPixiReactAdapter: root "${id}" — ${why}`));
212
- };
213
- const onError = (event: ErrorEvent) =>
214
- fail(
215
- `the Pixi tree crashed before its first commit — ${event.message} ` +
216
- '(mount() fails loudly instead of hanging)',
247
+ // A reconcile-time crash (e.g. an un-extended component) surfaces as
248
+ // an uncaught window error and the first commit never lands. Race that
249
+ // exact signal against the commit and the last-resort ceiling.
250
+ const timeout = new Promise<string>((resolve) => {
251
+ timer = setTimeout(
252
+ () =>
253
+ resolve(
254
+ `no first commit within ${FIRST_COMMIT_TIMEOUT_MS / 1000}s ` +
255
+ '(mount() fails loudly instead of hanging)',
256
+ ),
257
+ FIRST_COMMIT_TIMEOUT_MS,
217
258
  );
218
- const timer = setTimeout(
219
- () =>
220
- fail(
221
- `no first commit within ${FIRST_COMMIT_TIMEOUT_MS / 1000}s ` +
222
- '(mount() fails loudly instead of hanging)',
223
- ),
224
- FIRST_COMMIT_TIMEOUT_MS,
225
- );
226
- const cleanup = () => {
227
- clearTimeout(timer);
228
- globalThis.removeEventListener?.('error', onError);
229
- };
230
- globalThis.addEventListener?.('error', onError);
231
- void committed.then(() => {
232
- cleanup();
233
- resolve();
234
259
  });
235
- });
260
+ const failure = await Promise.race([
261
+ Promise.all([committed, effectsReady]).then(() => null),
262
+ reconcileFailure,
263
+ timeout,
264
+ ]);
265
+ if (failure !== null) {
266
+ throw new Error(`createPixiReactAdapter: root "${id}" — ${failure}`);
267
+ }
268
+ } catch (error) {
269
+ try {
270
+ app?.destroy(false, { children: true });
271
+ } catch {
272
+ /* the world never fully came up; teardown failure must not mask why */
273
+ }
274
+ runtime?.dispose();
275
+ throw error;
276
+ } finally {
277
+ if (timer !== undefined) clearTimeout(timer);
278
+ globalThis.removeEventListener?.('error', onError);
279
+ }
280
+
281
+ // `app` is assigned before the first-commit race. Reaching here proves
282
+ // both initialization and commit succeeded, so narrow once for the live
283
+ // mounted-root methods below.
284
+ if (app === undefined) {
285
+ runtime?.dispose();
286
+ throw new Error(`createPixiReactAdapter: root "${id}" initialized without an Application`);
287
+ }
288
+ const mountedApp = app;
236
289
 
237
290
  // Pin the ticker's clock to GAME time. `Ticker.lastTime` starts at -1, so
238
291
  // without this the first `update()` would report a 1ms phantom delta on
@@ -240,39 +293,53 @@ export function createPixiReactAdapter(
240
293
  // sees is exactly the `dt` the host handed us — no wall clock anywhere,
241
294
  // which is what makes a paused world's resume frame ordinary rather than
242
295
  // one giant catch-up step.
243
- app.ticker.lastTime = 0;
296
+ mountedApp.ticker.lastTime = 0;
244
297
  let elapsedMs = 0;
245
298
  let disposed = false;
299
+ const registry = host.game ? getDebugRegistry(host.game) : null;
300
+ const systemAdapters: SystemAdapters =
301
+ runtime?.systemAdapters ?? (registry ? { debug: registry.adapter } : {});
302
+ const disposeComplete = unmounted.then(() => {
303
+ mountedApp.destroy(false, { children: true });
304
+ });
246
305
 
247
306
  return {
248
307
  kind: 'canvas',
249
308
  // The reconciler's real root container — the same `PIXI.Container`
250
309
  // `RootInstance.pixiStage()` hands the editor.
251
- stage: app.stage,
310
+ stage: mountedApp.stage,
252
311
  drivesOwnLoop: false,
253
- // Adapter surface: `debug` pre-seeded (the shared game registry's
254
- // adapter); game code adds capabilities via `ctx.registerSystemAdapter`.
255
- systems: runtime.systemAdapters,
312
+ // Adapter surface: the host's shared debug registry. A native component
313
+ // tree registers nothing; its entry-module `debug` export is projected
314
+ // after mount by the shared native adapter.
315
+ systems: systemAdapters,
316
+ disposeComplete,
256
317
  update(dt: number): void {
257
318
  if (disposed) return;
258
319
  elapsedMs += dt * 1000;
259
- // Engine phases FIRST (registered systems mutate transforms), then
260
- // the ticker so `useTick` callbacks and the render that follows
261
- // them at `UPDATE_PRIORITY.LOW` both see the fresh state. Both are
262
- // host-gated: neither runs while this world is frozen.
263
- runtime.systems.run(dt);
264
- app.ticker.update(elapsedMs);
320
+ // The optional legacy runtime drains first when explicitly enabled;
321
+ // the native path has none. The real Pixi ticker remains the one
322
+ // scheduler `useTick` observes, and the host only gates/advances it.
323
+ runtime?.systems.run(dt);
324
+ mountedApp.ticker.update(elapsedMs);
265
325
  },
266
326
  resize(width: number, height: number): void {
267
327
  if (disposed) return;
268
328
  // `autoDensity: true` makes Pixi re-stamp `canvas.style.width`/
269
329
  // `.height` from this call, so the on-screen box follows the logical
270
330
  // size with no explicit CSS write from the host.
271
- app.renderer.resize(width, height);
331
+ mountedApp.renderer.resize(width, height);
272
332
  },
273
333
  dispose(): void {
274
334
  if (disposed) return;
275
335
  disposed = true;
336
+ // Pixi's EventSystem owns document.pointermove + window.pointerup
337
+ // (and its event ticker's global listener). React's deletion commit
338
+ // is asynchronous, so waiting to destroy the Application leaves
339
+ // those listeners live when the host audits this game realm. Detach
340
+ // the input target synchronously; the later Application.destroy()
341
+ // repeats this idempotently after every useTick cleanup has landed.
342
+ mountedApp.renderer.events.setTargetElement(null as unknown as HTMLElement);
276
343
  // Rendering `null` children is the only unmount path `@pixi/react`
277
344
  // exposes publicly (its `unmountRoot` helper is internal), and React
278
345
  // commits that deletion on its own schedule — NOT synchronously. So
@@ -281,8 +348,7 @@ export function createPixiReactAdapter(
281
348
  // a ticker destroyed before it lands throws inside React's commit.
282
349
  // `removeView` stays false — the canvas belongs to the HOST.
283
350
  void root.render(null, initOptions);
284
- void unmounted.then(() => app.destroy(false, { children: true }));
285
- runtime.dispose();
351
+ runtime?.dispose();
286
352
  },
287
353
  };
288
354
  },
@@ -66,6 +66,10 @@ export function resolveCanvasEntryAdapter(
66
66
  return createPixiReactAdapter({
67
67
  id: rootId,
68
68
  content: createElement(mod.default),
69
+ // The supported Canvas portfolio still consumes the compatibility
70
+ // context. Removing it belongs to the tracked component-root migration;
71
+ // disabling it here first made every current default-exported game fail
72
+ // to mount before that migration had happened.
69
73
  });
70
74
  }
71
75
  return null;