@vgai/engine 0.5.21 → 0.5.22

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 (87) hide show
  1. package/dist/adapter/manifest-interpreter.d.ts +59 -0
  2. package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
  3. package/dist/adapter/manifest-interpreter.js +50 -0
  4. package/dist/adapter/native-debug-module.d.ts +45 -3
  5. package/dist/adapter/native-debug-module.d.ts.map +1 -1
  6. package/dist/adapter/native-debug-module.js +128 -10
  7. package/dist/adapter/renderer-config.d.ts +1 -1
  8. package/dist/adapter/renderer-config.js +1 -1
  9. package/dist/canvas-react/index.d.ts +5 -12
  10. package/dist/canvas-react/index.d.ts.map +1 -1
  11. package/dist/canvas-react/index.js +5 -12
  12. package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
  13. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  14. package/dist/canvas-react/pixi-react-root-factory.js +274 -36
  15. package/dist/core/countdown-timer.d.ts +118 -0
  16. package/dist/core/countdown-timer.d.ts.map +1 -0
  17. package/dist/core/countdown-timer.js +137 -0
  18. package/dist/core/deferred-commands.d.ts +127 -0
  19. package/dist/core/deferred-commands.d.ts.map +1 -0
  20. package/dist/core/deferred-commands.js +132 -0
  21. package/dist/core/sim-clock.d.ts +14 -2
  22. package/dist/core/sim-clock.d.ts.map +1 -1
  23. package/dist/core/sim-clock.js +14 -2
  24. package/dist/dev/instruments.d.ts +38 -11
  25. package/dist/dev/instruments.d.ts.map +1 -1
  26. package/dist/dev/instruments.js +81 -11
  27. package/dist/input/input-manager.d.ts +15 -0
  28. package/dist/input/input-manager.d.ts.map +1 -1
  29. package/dist/input/input-manager.js +15 -0
  30. package/dist/render/directional-shadow-fit.d.ts +88 -0
  31. package/dist/render/directional-shadow-fit.d.ts.map +1 -0
  32. package/dist/render/directional-shadow-fit.js +133 -0
  33. package/dist/render/environment-capture.d.ts +68 -0
  34. package/dist/render/environment-capture.d.ts.map +1 -0
  35. package/dist/render/environment-capture.js +90 -0
  36. package/dist/render/ibl-override-material.d.ts +97 -0
  37. package/dist/render/ibl-override-material.d.ts.map +1 -0
  38. package/dist/render/ibl-override-material.js +114 -0
  39. package/dist/runtime/game-input-seams.d.ts +51 -0
  40. package/dist/runtime/game-input-seams.d.ts.map +1 -0
  41. package/dist/runtime/game-input-seams.js +86 -0
  42. package/dist/runtime/game.d.ts +15 -0
  43. package/dist/runtime/game.d.ts.map +1 -1
  44. package/dist/runtime/game.js +40 -8
  45. package/dist/runtime/mount-game.d.ts.map +1 -1
  46. package/dist/runtime/mount-game.js +18 -2
  47. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  48. package/dist/runtime/mount-manifest.js +4 -2
  49. package/dist/runtime/types.d.ts +1 -1
  50. package/dist/world3d-react/index.d.ts +15 -25
  51. package/dist/world3d-react/index.d.ts.map +1 -1
  52. package/dist/world3d-react/index.js +15 -25
  53. package/dist/world3d-react/r3f-root-factory.d.ts +19 -25
  54. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  55. package/dist/world3d-react/r3f-root-factory.js +396 -50
  56. package/dist/world3d-react/rapier-physics-bridge.d.ts +24 -12
  57. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  58. package/dist/world3d-react/rapier-physics-bridge.js +82 -49
  59. package/package.json +1 -1
  60. package/schemas/engine-capabilities.json +3 -3
  61. package/src/adapter/manifest-interpreter.ts +64 -0
  62. package/src/adapter/native-debug-module.ts +212 -15
  63. package/src/adapter/renderer-config.ts +1 -1
  64. package/src/canvas-react/index.ts +5 -21
  65. package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
  66. package/src/core/countdown-timer.ts +188 -0
  67. package/src/core/deferred-commands.ts +174 -0
  68. package/src/core/sim-clock.ts +14 -2
  69. package/src/dev/instruments.ts +108 -11
  70. package/src/input/input-manager.ts +15 -0
  71. package/src/render/directional-shadow-fit.ts +156 -0
  72. package/src/render/environment-capture.ts +102 -0
  73. package/src/render/ibl-override-material.ts +170 -0
  74. package/src/runtime/game-input-seams.ts +108 -0
  75. package/src/runtime/game.ts +65 -7
  76. package/src/runtime/mount-game.ts +23 -1
  77. package/src/runtime/mount-manifest.ts +4 -2
  78. package/src/runtime/types.ts +1 -1
  79. package/src/world3d-react/index.ts +15 -31
  80. package/src/world3d-react/r3f-root-factory.tsx +444 -53
  81. package/src/world3d-react/rapier-physics-bridge.tsx +88 -49
  82. package/src/canvas-react/engine-bridge.ts +0 -59
  83. package/src/canvas-react/pixi-react-adapter.tsx +0 -356
  84. package/src/canvas-react/world-context.ts +0 -253
  85. package/src/world3d-react/engine-bridge.ts +0 -86
  86. package/src/world3d-react/r3f-adapter.tsx +0 -717
  87. package/src/world3d-react/world-context.ts +0 -358
@@ -40,18 +40,20 @@
40
40
  * is how a `SystemAdapters` member says "not supported".
41
41
  *
42
42
  * ── RESOURCE OWNERSHIP, STATED ONCE ─────────────────────────────────────────
43
- * OWNER: this component owns exactly ONE `SystemAdapters.physics` registration,
44
- * on the R3F root it is mounted in. It allocates nothing else no timers, no
45
- * subscriptions, no per-frame work (see the phase note below), and no Rapier
46
- * objects: the bodies it edits belong to `<Physics>`.
47
- * SHARERS: every reader of `game.systemAdapters.physics` the
48
- * colliders instrument, the editor's transform coordination. They read; they
49
- * never dispose.
50
- * TEARDOWN: the effect's cleanup, and nothing else — it clears the SAME key it
51
- * set (`registerSystemAdapter('physics', undefined)`, which
52
- * `Game.computeSystemAdapters` skips), so a play stop/start, a hot restart, or
53
- * a world that unmounts `<Physics>` leaves no stale adapter behind. Two
54
- * bridges in one root would fight over that one key; mount exactly one.
43
+ * OWNER: this component owns one physics ADAPTER, published onto the ONE
44
+ * module-scoped slot `rapierPhysicsSystem()` forwards to (GAME-GLOBALthe
45
+ * entry's static `systems.physics` declaration cannot be per-root). It allocates nothing
46
+ * else no timers, no subscriptions, no per-frame work (see the phase note
47
+ * below), and no Rapier objects: the bodies it edits belong to `<Physics>`.
48
+ * SHARERS: every reader of `game.systemAdapters.physics` the colliders
49
+ * instrument, the editor's transform coordination. They read; they never
50
+ * dispose.
51
+ * TEARDOWN: the effect's cleanup, and nothing else — it clears the SAME key
52
+ * and the slot it set (identity-guarded), so a play stop/start, a hot
53
+ * restart, or a world that unmounts `<Physics>` leaves no stale adapter
54
+ * behind. Because the slot is game-global, mount exactly ONE bridge per
55
+ * GAME: with two, the later mount wins the slot and the later unmount clears
56
+ * it while the earlier bridge is still live.
55
57
  *
56
58
  * PHASE ORDERING: none to respect — this component registers pull-only
57
59
  * callbacks and runs no per-frame work. Every method it exposes is invoked
@@ -73,7 +75,6 @@ import type {
73
75
  PhysicsJointSnapshot,
74
76
  PhysicsJointType,
75
77
  } from '../adapter/system-adapter';
76
- import { useGameContext } from './engine-bridge';
77
78
 
78
79
  export interface RapierPhysicsBridgeProps {
79
80
  /**
@@ -123,47 +124,29 @@ function owningBody(
123
124
  * `useRapier()`). Renders nothing.
124
125
  */
125
126
  export function RapierPhysicsBridge({ onDebugChange }: RapierPhysicsBridgeProps): null {
126
- const ctx = useGameContext();
127
+ // This bridge publishes the module slot below, which the entry's
128
+ // `systems.physics` declaration (`rapierPhysicsSystem()`) forwards to —
129
+ // that declaration is the ONE path the host reads this world's physics
130
+ // through.
127
131
  const { colliderStates, rapier, rigidBodyStates, world } = useRapier();
128
- const registerSystemAdapter = ctx.registerSystemAdapter;
129
132
 
130
133
  useEffect(() => {
131
- if (!registerSystemAdapter) return;
132
-
133
134
  /**
134
- * node id → the Rapier body driving it, in two hops the library and the
135
- * engine each already own:
136
- *
137
- * 1. id `Object3D`, through the live scene index `userData.entityId`
138
- * is this stack's identity convention (the editor's R3F authoring
139
- * adapter stamps it; `../adapter/setup-three-root-adapter.ts` resolves
140
- * the same way for the imperative root).
141
- * 2. `Object3D` → body, through `rigidBodyStates` — the library's OWN
142
- * body↔object map, carried on the `RapierContext` `useRapier()`
143
- * returns. Walking ANCESTORS matters: `<RigidBody>` owns a subtree, so
144
- * the selected mesh is usually a descendant of the object the state
145
- * names.
135
+ * node id → the Rapier body driving it, entirely through the library's
136
+ * OWN body↔object map (`rigidBodyStates`, carried on the `RapierContext`
137
+ * `useRapier()` returns): resolve `userData.entityId` — this stack's
138
+ * identity convention, stamped by the editor's R3F authoring adapter
139
+ * inside the body-owned subtrees, then walk ANCESTORS to the owning body
140
+ * (`<RigidBody>` owns a subtree, so the selected mesh is usually a
141
+ * descendant of the object the state names).
146
142
  *
147
- * Both maps are live, so this is computed per call rather than cached —
143
+ * The map is live, so this is computed per call rather than cached —
148
144
  * calls arrive at gesture rate (a gizmo drag start/end, a dev command),
149
- * never per frame. `ctx.sceneIndex` is read AT CALL TIME, never captured:
150
- * the R3F root backfills it once fiber resolves the scene, which can land
151
- * after this component's first effect (`world-context.ts`'s
152
- * `setSceneCamera`), so a destructured copy would be permanently absent.
145
+ * never per frame, and only objects under a `<RigidBody>` can have a body
146
+ * at all, so the body subtrees are the complete search space.
153
147
  */
154
148
  const bodyFor = (nodeId: string): RapierEditableBody | undefined => {
155
- const indexed = ctx.sceneIndex?.byEntityId(nodeId);
156
- const object = indexed ?? lateEntityObject(rigidBodyStates.values(), nodeId);
157
- if (!indexed && object) {
158
- // The editor assigns its stable authoring entity ids AFTER Fiber's
159
- // first commit, while the root's SceneIndex is constructed during
160
- // that commit. Resolve the one late id inside the library-owned body
161
- // subtrees and immediately reindex it; subsequent gestures take the
162
- // normal indexed path. This is the SceneIndex's documented escape
163
- // hatch for a userData id written after construction, not a parallel
164
- // scene mirror.
165
- ctx.sceneIndex?.reindex(object);
166
- }
149
+ const object = lateEntityObject(rigidBodyStates.values(), nodeId);
167
150
  if (!object) return undefined;
168
151
  return owningBody(object, rigidBodyStates.values());
169
152
  };
@@ -367,9 +350,65 @@ export function RapierPhysicsBridge({ onDebugChange }: RapierPhysicsBridgeProps)
367
350
  joints,
368
351
  previewJointAnchor,
369
352
  };
370
- registerSystemAdapter('physics', adapter);
371
- return () => registerSystemAdapter('physics', undefined);
372
- }, [colliderStates, ctx, onDebugChange, rapier, registerSystemAdapter, rigidBodyStates, world]);
353
+ liveAdapter = adapter;
354
+ return () => {
355
+ if (liveAdapter === adapter) liveAdapter = null;
356
+ };
357
+ }, [colliderStates, onDebugChange, rapier, rigidBodyStates, world]);
373
358
 
374
359
  return null;
375
360
  }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // The entry-declaration half
364
+ // ---------------------------------------------------------------------------
365
+
366
+ /** The mounted bridge's adapter, while one is live. Module-scoped on purpose:
367
+ * the entry's `systems` declaration is static, the rapier world is not, and
368
+ * this slot is the seam between the two. */
369
+ let liveAdapter: PhysicsAdapter | null = null;
370
+
371
+ function requireLiveAdapter(): PhysicsAdapter {
372
+ if (!liveAdapter) {
373
+ throw new Error(
374
+ 'rapierPhysicsSystem: no <RapierPhysicsBridge> is mounted inside a live <Physics> right ' +
375
+ 'now — the world is not playing, or the scene never mounted the bridge.',
376
+ );
377
+ }
378
+ return liveAdapter;
379
+ }
380
+
381
+ /**
382
+ * The `systems.physics` slot for a default-exported world — declare it from
383
+ * the entry (`export const systems = { physics: rapierPhysicsSystem() }`) and
384
+ * keep `<RapierPhysicsBridge />` mounted inside `<Physics>`. Every call
385
+ * forwards to the LIVE bridge adapter, so the static declaration answers with
386
+ * the running world's physics; while none is mounted, every call refuses by
387
+ * name rather than fabricating.
388
+ */
389
+ export function rapierPhysicsSystem(): PhysicsAdapter {
390
+ // Every forward REQUIRES the live adapter and its member — a silent no-op
391
+ // or fabricated empty answer is the shape the anti-shim rule forbids. The
392
+ // bridge's adapter always carries all five optional members, so the member
393
+ // checks only ever fire against a foreign replacement.
394
+ const requireMember = <K extends keyof PhysicsAdapter>(member: K) => {
395
+ const adapter = requireLiveAdapter();
396
+ const value = adapter[member];
397
+ if (typeof value !== 'function') {
398
+ throw new Error(`rapierPhysicsSystem: the live physics adapter has no ${String(member)}().`);
399
+ }
400
+ return value.bind(adapter) as NonNullable<PhysicsAdapter[K]>;
401
+ };
402
+ return {
403
+ ownerOf: (nodeId) => requireLiveAdapter().ownerOf(nodeId),
404
+ freeze: (nodeId) => requireLiveAdapter().freeze(nodeId),
405
+ commit: (nodeId, t) => requireLiveAdapter().commit(nodeId, t),
406
+ unfreeze: (nodeId) => requireLiveAdapter().unfreeze(nodeId),
407
+ setDebugDrawEnabled: (enabled) => requireMember('setDebugDrawEnabled')(enabled),
408
+ colliders: (nodeId) => requireMember('colliders')(nodeId),
409
+ previewCollider: (colliderId, shape) => requireMember('previewCollider')(colliderId, shape),
410
+ joints: (nodeId) => requireMember('joints')(nodeId),
411
+ previewJointAnchor: (jointId, endpoint, anchor) =>
412
+ requireMember('previewJointAnchor')(jointId, endpoint, anchor),
413
+ };
414
+ }
@@ -1,59 +0,0 @@
1
- /**
2
- * `@engine/canvas-react` — the React context that carries a canvas world's
3
- * engine runtime into its `@pixi/react` tree: the world's `GameContext`, read
4
- * with {@link useGameContext}.
5
- *
6
- * The bridge value is built by `createPixiReactAdapter`'s `mount()`
7
- * (./pixi-react-adapter.tsx), whose `update(dt)` runs this world's phases under
8
- * the HOST loop — so registered systems tick in engine phase order, pause
9
- * included (no update while the world is frozen).
10
- *
11
- * Behavior in a canvas world is components + hooks: `useGameContext` reaches
12
- * engine services, `@pixi/react`'s own `useTick` is the per-frame hook, and
13
- * `useApplication` is the door to the live `Application`.
14
- */
15
-
16
- import { createContext, useContext } from 'react';
17
- import type { CanvasGameContext } from './world-context';
18
-
19
- export interface EngineBridgeValue {
20
- /** This root's `CanvasGameContext` — `input`, `debug`, `random`, `clock`, `game`,
21
- * `roots`, `systems`, `registerSystemAdapter`. Present so ordinary
22
- * components can reach engine services with a HOOK (see
23
- * {@link useGameContext}). */
24
- readonly ctx?: CanvasGameContext | undefined;
25
- }
26
-
27
- export const EngineBridge = createContext<EngineBridgeValue | null>(null);
28
-
29
- /**
30
- * This canvas root's `CanvasGameContext`, for use inside any component in the tree.
31
- *
32
- * An ordinary component does what a model would write:
33
- *
34
- * ```tsx
35
- * function Player({ speed = 220 }) {
36
- * const { input } = useGameContext();
37
- * const ref = useRef<Sprite>(null);
38
- * useTick(({ deltaMS }) => {
39
- * if (input?.isPressed('move_right')) ref.current!.x += speed * (deltaMS / 1000);
40
- * });
41
- * return <pixiSprite ref={ref} texture={Texture.WHITE} />;
42
- * }
43
- * ```
44
- *
45
- * Throws when called outside a canvas root mounted by
46
- * `createPixiReactAdapter` — absent context is a wiring bug, and a silent
47
- * `null` would surface as a mystery `undefined` deep in a tick callback.
48
- */
49
- export function useGameContext(): CanvasGameContext {
50
- const bridge = useContext(EngineBridge);
51
- if (!bridge?.ctx) {
52
- throw new Error(
53
- 'useGameContext(): no engine root context. This hook only works inside a canvas ' +
54
- 'root mounted by createPixiReactAdapter (@engine/canvas-react) — check that the ' +
55
- 'component is rendered inside your world entry, not in a standalone <Application>.',
56
- );
57
- }
58
- return bridge.ctx;
59
- }
@@ -1,356 +0,0 @@
1
- /**
2
- * `createPixiReactAdapter` — wraps a `@pixi/react` tree as a first-party
3
- * `RootAdapter<'canvas'>` so a Pixi world mounts as an ordinary
4
- * `surface: "canvas"` root: the engine hands `mount()` a `CanvasHostContext`
5
- * (its own canvas + size + compositing hints, per `../adapter/host-context.ts`);
6
- * this returns a `MountedPixiRoot` (`../adapter/root-adapter.ts`) whose `stage`
7
- * is the REAL `PIXI.Container` the reconciler renders into.
8
- *
9
- * This is the canvas surface's sibling of `world3d-react`'s
10
- * `createR3FAdapter`, with `@pixi/react` where that one has
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
- *
16
- * ## The loop contract
17
- *
18
- * `Application` is initialized with `autoStart: false` and `sharedTicker:
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
- * `game.waitSimTime` work in a hidden tab (a hidden tab has no rAF; the engine
24
- * loop's own catch-up still calls `update`, and Pixi has no independent clock
25
- * to disagree with it).
26
- *
27
- * ## Component catalogue
28
- *
29
- * `@pixi/react` resolves `<pixiSprite>` etc. through a module-global catalogue
30
- * that starts EMPTY — the first intrinsic in an un-extended tree throws
31
- * "X is not part of the PIXI namespace! Did you forget to extend?". This
32
- * module extends it with the whole `pixi.js` namespace once per mount
33
- * (idempotent `Object.assign`), the same thing `createR3FAdapter` does with
34
- * `extend(host.three)`. A world that adds its own classes (`@pixi/tilemap`,
35
- * a custom `Container` subclass) calls `extend`/`useExtend` itself.
36
- */
37
-
38
- import { createRoot, extend } from '@pixi/react';
39
- import type { Application, ApplicationOptions } from 'pixi.js';
40
- import * as PIXI from 'pixi.js';
41
- import { createElement, Fragment, type ReactNode, useEffect, useLayoutEffect } from 'react';
42
- import type { CanvasHostContext, MountedPixiRoot, RootAdapter } from '../adapter';
43
- import type { SystemAdapters } from '../adapter/system-adapter';
44
- import { WorldProvider } from '../react/world-state';
45
- import { getDebugRegistry } from '../runtime/debug-registry';
46
- import { EngineBridge, type EngineBridgeValue } from './engine-bridge';
47
- import { createCanvasRootContext } from './world-context';
48
-
49
- /** How long `mount()` waits for the tree's first commit before failing loudly
50
- * rather than hanging (and wedging every root declared after this one, since
51
- * roots mount sequentially). */
52
- const FIRST_COMMIT_TIMEOUT_MS = 10_000;
53
-
54
- /**
55
- * `@pixi/react` keys its reconciler roots off the canvas element in a
56
- * module-global map, and its own unmount path (which would clear that key) is
57
- * not exported. So a canvas this lane has already mounted must never be handed
58
- * to a second mount: `createRoot` would silently hand back the FIRST root,
59
- * whose `Application` this lane already destroyed. Every host in this repo
60
- * builds a fresh canvas per mount, so this only ever fires on a genuine bug —
61
- * and it fires as a named error rather than as a dead world.
62
- */
63
- const MOUNTED_CANVASES = new WeakSet<HTMLCanvasElement>();
64
-
65
- /** `@pixi/react`'s reconciler-root handle. Its `Root` type is internal (the
66
- * package exports the FUNCTION, not the type), so name it off the function. */
67
- type PixiReactRoot = ReturnType<typeof createRoot>;
68
-
69
- /** What {@link createPixiReactAdapter} needs to build one `RootAdapter<'canvas'>`. */
70
- export interface CreatePixiReactAdapterOptions {
71
- /** Stable id (telemetry/registry/conformance) — `RootAdapter.id`. */
72
- readonly id: string;
73
- /** The Pixi tree to mount — `useTick` hooks, `useApplication`, Pixi filters
74
- * and any extended component all work unchanged. */
75
- readonly content: ReactNode;
76
- /** Input map loaded through the game-owned `Game.loadInputMap` (load-once
77
- * across roots). Defaults to the conventional
78
- * `/inputmaps/default.inputmap.json`; `null` opts out (a world with no
79
- * actions). A missing/invalid map degrades loudly without failing the
80
- * mount — see `world-context.ts`. */
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;
88
- /**
89
- * Extra `Application.init` options merged UNDER this adapter's own — e.g.
90
- * `{ antialias: false }`, `{ roundPixels: true }`. The loop-critical ones
91
- * (`autoStart`, `sharedTicker`) and the ones the host owns (size, dpr,
92
- * transparency, `preserveDrawingBuffer`) are applied after this and cannot
93
- * be overridden: a world that starts its own ticker or clears an opaque
94
- * background under a stacked sibling breaks the host's contract, not just
95
- * its own frame.
96
- */
97
- readonly applicationOptions?: Partial<ApplicationOptions> | undefined;
98
- }
99
-
100
- /**
101
- * Build a `RootAdapter<'canvas'>` that mounts `options.content` through
102
- * `@pixi/react`, gated entirely by the host's own loop and rendering into the
103
- * host's own canvas — never a second canvas, never a second
104
- * `requestAnimationFrame` loop.
105
- */
106
- export function createPixiReactAdapter(
107
- options: CreatePixiReactAdapterOptions,
108
- ): RootAdapter<'canvas'> {
109
- const { id, content, inputMapPath, engineRuntime = true, applicationOptions } = options;
110
-
111
- return {
112
- id,
113
-
114
- async mount(host: CanvasHostContext): Promise<MountedPixiRoot> {
115
- const runtime = engineRuntime ? createCanvasRootContext(host, { id, inputMapPath }) : null;
116
- const bridge: EngineBridgeValue | null = runtime ? { ctx: runtime.ctx } : null;
117
- // Actions must exist before any component's first tick reads them — wait
118
- // for the (never-rejecting) input-map load before the first commit.
119
- await runtime?.inputMapReady;
120
-
121
- const canvas = host.canvas;
122
- if (MOUNTED_CANVASES.has(canvas)) {
123
- runtime?.dispose();
124
- throw new Error(
125
- `createPixiReactAdapter: root "${id}" was handed a canvas this lane has already ` +
126
- 'mounted. @pixi/react keys its reconciler roots off the canvas element, so re-using ' +
127
- 'one would resurrect a root whose Application is destroyed. Hand each mount a fresh ' +
128
- 'canvas (every vgai host already does).',
129
- );
130
- }
131
- MOUNTED_CANVASES.add(canvas);
132
-
133
- // The catalogue is module-global and starts empty; this is idempotent.
134
- extend(PIXI as unknown as Parameters<typeof extend>[0]);
135
-
136
- let resolveCommitted!: () => void;
137
- const committed = new Promise<void>((resolve) => {
138
- resolveCommitted = resolve;
139
- });
140
- let resolveEffectsReady!: () => void;
141
- const effectsReady = new Promise<void>((resolve) => {
142
- resolveEffectsReady = resolve;
143
- });
144
- let resolveUnmounted!: () => void;
145
- const unmounted = new Promise<void>((resolve) => {
146
- resolveUnmounted = resolve;
147
- });
148
- /**
149
- * Signals the tree's FIRST COMMIT and its LAST. A layout effect,
150
- * deliberately, and rendered as the LAST child, deliberately:
151
- *
152
- * - mount: layout effects run synchronously inside the commit, before
153
- * any passive effect and before any tick can be scheduled;
154
- * - unmount: React commits deletion effects in tree order, each node
155
- * before its own children, so the LAST sibling's cleanup is the last
156
- * to run. That is what makes this a reliable "every component has
157
- * finished tearing down" signal — and it is load-bearing, because
158
- * `useTick`'s own cleanup calls `ticker.remove()`. Destroying the
159
- * Application before that cleanup lands throws inside React's commit
160
- * on every teardown of any world with a tick hook.
161
- */
162
- function Lifecycle(): null {
163
- useLayoutEffect(() => {
164
- resolveCommitted();
165
- return resolveUnmounted;
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, []);
171
- return null;
172
- }
173
-
174
- // The React seam every dom root already has — `useWorldState`,
175
- // `useDebugProvider`/`useDebugCommand`/`useDebugEmit`
176
- // (`../react/world-state`) — works identically inside a canvas root
177
- // because a canvas root IS React. `host.game` is genuinely absent in
178
- // bare/foreign hosts, so the provider is conditional and those hooks
179
- // stay the inert no-ops they already document themselves to be.
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;
200
-
201
- const root: PixiReactRoot = createRoot(canvas);
202
-
203
- // The host owns size, dpr, transparency and capture cost; the loop
204
- // contract owns `autoStart`/`sharedTicker`. Both are applied AFTER the
205
- // caller's own options so neither can be overridden from a world file.
206
- const initOptions: Partial<ApplicationOptions> = {
207
- antialias: true,
208
- ...applicationOptions,
209
- width: host.width,
210
- height: host.height,
211
- resolution: host.dpr ?? globalThis.devicePixelRatio ?? 1,
212
- autoDensity: true,
213
- // Stacked ABOVE a sibling root -> clear with alpha 0 so the layer below
214
- // shows through (COMPOSITION-DESIGN D5 §1).
215
- backgroundAlpha: host.transparent ? 0 : 1,
216
- preserveDrawingBuffer: host.preserveDrawingBuffer ?? false,
217
- autoStart: false,
218
- sharedTicker: false,
219
- };
220
-
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);
246
-
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,
258
- );
259
- });
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;
289
-
290
- // Pin the ticker's clock to GAME time. `Ticker.lastTime` starts at -1, so
291
- // without this the first `update()` would report a 1ms phantom delta on
292
- // top of the real one. From here every `deltaMS` a `useTick` callback
293
- // sees is exactly the `dt` the host handed us — no wall clock anywhere,
294
- // which is what makes a paused world's resume frame ordinary rather than
295
- // one giant catch-up step.
296
- mountedApp.ticker.lastTime = 0;
297
- let elapsedMs = 0;
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
- });
305
-
306
- return {
307
- kind: 'canvas',
308
- // The reconciler's real root container — the same `PIXI.Container`
309
- // `RootInstance.pixiStage()` hands the editor.
310
- stage: mountedApp.stage,
311
- drivesOwnLoop: false,
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,
317
- update(dt: number): void {
318
- if (disposed) return;
319
- elapsedMs += dt * 1000;
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);
325
- },
326
- resize(width: number, height: number): void {
327
- if (disposed) return;
328
- // `autoDensity: true` makes Pixi re-stamp `canvas.style.width`/
329
- // `.height` from this call, so the on-screen box follows the logical
330
- // size with no explicit CSS write from the host.
331
- mountedApp.renderer.resize(width, height);
332
- },
333
- dispose(): void {
334
- if (disposed) return;
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);
343
- // Rendering `null` children is the only unmount path `@pixi/react`
344
- // exposes publicly (its `unmountRoot` helper is internal), and React
345
- // commits that deletion on its own schedule — NOT synchronously. So
346
- // the Application is destroyed on the `Lifecycle` cleanup signal
347
- // instead of inline: `useTick`'s cleanup calls `ticker.remove()`, and
348
- // a ticker destroyed before it lands throws inside React's commit.
349
- // `removeView` stays false — the canvas belongs to the HOST.
350
- void root.render(null, initOptions);
351
- runtime?.dispose();
352
- },
353
- };
354
- },
355
- };
356
- }