@vgai/engine 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +18 -11
  2. package/package.json +10 -6
  3. package/schemas/engine-api.json +1 -68
  4. package/schemas/engine-api.md +1 -32
  5. package/schemas/engine-capabilities.json +30 -42
  6. package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
  7. package/src/adapter/adapter-surface.ts +5 -5
  8. package/src/adapter/authoring.ts +168 -226
  9. package/src/adapter/colyseus-networking-adapter.ts +44 -5
  10. package/src/adapter/first-party-systems.ts +156 -42
  11. package/src/adapter/host-context.ts +177 -46
  12. package/src/adapter/index.ts +47 -51
  13. package/src/adapter/ingest/game-contract.ts +2 -2
  14. package/src/adapter/ingest/scene-capture.ts +18 -19
  15. package/src/adapter/ingest/structural-ids.ts +127 -0
  16. package/src/adapter/ingest/upstream-pin.ts +9 -12
  17. package/src/adapter/loop-gate-report.ts +11 -11
  18. package/src/adapter/rapier-physics-adapter.ts +27 -9
  19. package/src/adapter/root-adapter.ts +217 -0
  20. package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
  21. package/src/adapter/system-adapter.ts +80 -63
  22. package/src/ai/navigation.ts +1 -1
  23. package/src/animation/animation-clock.ts +1 -1
  24. package/src/animation/camera-ownership.ts +1 -2
  25. package/src/animation/cubic-spline-interpolant.ts +132 -0
  26. package/src/animation/theatre-clock-binding.ts +2 -2
  27. package/src/animation/theatre-object-binding.ts +4 -4
  28. package/src/animation/xstate-animation-binding.ts +75 -5
  29. package/src/{scene/schema → asset-formats}/camera.ts +2 -5
  30. package/src/{scene/schema → asset-formats}/collider.ts +2 -5
  31. package/src/asset-formats/index.ts +54 -0
  32. package/src/{scene/schema → asset-formats}/instances.ts +10 -6
  33. package/src/{scene/schema → asset-formats}/light.ts +3 -6
  34. package/src/{scene/schema → asset-formats}/material.ts +4 -7
  35. package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
  36. package/src/asset-formats/parse.ts +39 -0
  37. package/src/{scene/schema → asset-formats}/particles.ts +3 -6
  38. package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
  39. package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
  40. package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
  41. package/src/asset-parse-error.ts +33 -0
  42. package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
  43. package/src/assets.ts +1 -1
  44. package/src/audio/wav-encode.ts +9 -9
  45. package/src/canvas-react/engine-bridge.ts +59 -0
  46. package/src/canvas-react/index.ts +50 -0
  47. package/src/canvas-react/pixi-primitive.tsx +202 -0
  48. package/src/canvas-react/pixi-react-adapter.tsx +290 -0
  49. package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
  50. package/src/canvas-react/world-context.ts +328 -0
  51. package/src/core/frame-pacing.ts +100 -0
  52. package/src/core/game-loop.ts +50 -28
  53. package/src/core/seeded-random.ts +7 -7
  54. package/src/core/sim-clock.ts +388 -0
  55. package/src/core/system-runner.ts +17 -63
  56. package/src/core/types.ts +34 -15
  57. package/src/data/data-asset.ts +3 -3
  58. package/src/data/data-check-core.ts +6 -7
  59. package/src/data/data-ref.ts +11 -11
  60. package/src/data/vite-plugin-data.ts +10 -10
  61. package/src/{scene/defaults.ts → defaults.ts} +18 -40
  62. package/src/dev/render-debug-adapter.ts +1 -1
  63. package/src/dev/webgl-frame-capture.ts +1 -1
  64. package/src/ecs/scene-index.ts +439 -0
  65. package/src/ecs/scene-query.ts +43 -0
  66. package/src/{scene → ecs}/user-data.ts +17 -36
  67. package/src/index.ts +7 -9
  68. package/src/input/input-manager.ts +29 -32
  69. package/src/input/input-types.ts +2 -2
  70. package/src/input/schema.ts +5 -5
  71. package/src/loader.ts +57 -0
  72. package/src/manifest/editor-port.ts +69 -0
  73. package/src/manifest/filename.ts +49 -0
  74. package/src/manifest/index.ts +8 -2
  75. package/src/manifest/load-file.ts +11 -0
  76. package/src/manifest/load.ts +65 -77
  77. package/src/manifest/locate.ts +55 -0
  78. package/src/manifest/schema.ts +400 -233
  79. package/src/{scene → physics}/collider-dimensions.ts +3 -3
  80. package/src/physics/physics-registry.ts +1 -1
  81. package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
  82. package/src/pixi/index.ts +43 -0
  83. package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
  84. package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
  85. package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
  86. package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
  87. package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
  88. package/src/react/unmanaged-root-detector.ts +26 -1
  89. package/src/react/use-data.ts +5 -5
  90. package/src/react/use-selection.tsx +15 -42
  91. package/src/react/{game-state.tsx → world-state.tsx} +44 -46
  92. package/src/render/auto-batcher.ts +1 -2
  93. package/src/{scene → render}/instance-mesh.ts +1 -1
  94. package/src/{scene → render}/light-camera-factory.ts +14 -13
  95. package/src/render/lod.ts +17 -0
  96. package/src/{scene → render}/material-factory.ts +8 -7
  97. package/src/{scene → render}/particles-factory.ts +62 -12
  98. package/src/render/render-batch-system.ts +14 -41
  99. package/src/render/render-features.ts +1 -1
  100. package/src/render/render-settings.ts +1 -2
  101. package/src/render/spark-renderer-lifecycle.ts +1 -1
  102. package/src/runtime/create-runtime.ts +352 -463
  103. package/src/runtime/debug-bridge.ts +148 -93
  104. package/src/runtime/debug-registry.ts +79 -54
  105. package/src/runtime/dev-layers.ts +40 -0
  106. package/src/runtime/frame-selector-cache.ts +4 -4
  107. package/src/runtime/game.ts +618 -369
  108. package/src/runtime/gameplay-rng-trap.ts +6 -7
  109. package/src/runtime/input-router.ts +11 -11
  110. package/src/runtime/mount-game.ts +54 -55
  111. package/src/runtime/mount-manifest.ts +154 -150
  112. package/src/runtime/presentation.ts +141 -0
  113. package/src/runtime/render-audio-control.ts +64 -53
  114. package/src/runtime/render-control.ts +45 -51
  115. package/src/runtime/render-seed.ts +3 -4
  116. package/src/runtime/state-bridge.ts +17 -18
  117. package/src/runtime/types.ts +94 -61
  118. package/src/setup/setup-renderer.ts +11 -6
  119. package/src/world3d-react/engine-bridge.ts +46 -33
  120. package/src/world3d-react/index.ts +31 -26
  121. package/src/world3d-react/r3f-adapter.tsx +211 -96
  122. package/src/world3d-react/r3f-root-factory.tsx +91 -0
  123. package/src/world3d-react/renderer-config.ts +137 -0
  124. package/src/world3d-react/world-context.ts +141 -111
  125. package/schemas/entity2d.schema.json +0 -468
  126. package/schemas/prefab.schema.json +0 -9992
  127. package/schemas/scn2d.schema.json +0 -494
  128. package/schemas/vscn.schema.json +0 -10851
  129. package/src/adapter/game-adapter.ts +0 -164
  130. package/src/adapter/ingest/overlay-applier.ts +0 -207
  131. package/src/adapter/ingest/overlay-apply.ts +0 -168
  132. package/src/adapter/ingest/overlay-file.ts +0 -126
  133. package/src/adapter/ingest/overlay-report.ts +0 -176
  134. package/src/animation/gsap-registration.ts +0 -184
  135. package/src/audio/audio-introspection.ts +0 -290
  136. package/src/audio/index.ts +0 -39
  137. package/src/audio/tone-clock-binding.ts +0 -98
  138. package/src/audio/tone-context.ts +0 -175
  139. package/src/audio/tone-offline-render.ts +0 -167
  140. package/src/ecs/component-manager.ts +0 -814
  141. package/src/ecs/game-component.ts +0 -260
  142. package/src/ecs/hmr-swap-report.ts +0 -65
  143. package/src/physics/trigger-dispatch.ts +0 -97
  144. package/src/react/root-adapter.tsx +0 -49
  145. package/src/scene/asset-paths.ts +0 -121
  146. package/src/scene/asset-ref-check.ts +0 -248
  147. package/src/scene/component-registry.ts +0 -51
  148. package/src/scene/parse.ts +0 -204
  149. package/src/scene/scene-apply.ts +0 -407
  150. package/src/scene/scene-diff-schema.ts +0 -115
  151. package/src/scene/scene-diff-types.ts +0 -29
  152. package/src/scene/scene-loader.ts +0 -1526
  153. package/src/scene/scene-query.ts +0 -63
  154. package/src/scene/scene-types.ts +0 -33
  155. package/src/scene/scene-version.ts +0 -40
  156. package/src/scene/schema/animation.ts +0 -46
  157. package/src/scene/schema/audio.ts +0 -25
  158. package/src/scene/schema/entity-ref.ts +0 -78
  159. package/src/scene/schema/entity.ts +0 -189
  160. package/src/scene/schema/index.ts +0 -51
  161. package/src/scene/schema/joint.ts +0 -26
  162. package/src/scene/schema/physics.ts +0 -49
  163. package/src/scene/schema/scene-file.ts +0 -292
  164. package/src/scene/schema/shadow.ts +0 -24
  165. package/src/scene/schema/spline.ts +0 -21
  166. package/src/world2d/asset-paths2d.ts +0 -44
  167. package/src/world2d/capture-to-scene2d.ts +0 -52
  168. package/src/world2d/collision-2d.ts +0 -99
  169. package/src/world2d/entity2d-asset.ts +0 -22
  170. package/src/world2d/index.ts +0 -91
  171. package/src/world2d/physics2d-transform.ts +0 -173
  172. package/src/world2d/physics2d-units.ts +0 -10
  173. package/src/world2d/pixi-game-adapter.ts +0 -439
  174. package/src/world2d/pixi-surface.ts +0 -78
  175. package/src/world2d/scene2d-identity.ts +0 -49
  176. package/src/world2d/scene2d-loader.ts +0 -433
  177. package/src/world2d/schema/entity2d.ts +0 -163
  178. package/src/world2d/schema/physics2d.ts +0 -64
  179. package/src/world2d/schema/sprite.ts +0 -99
  180. package/src/world2d/schema/tilemap.ts +0 -39
  181. package/src/world2d/schema/tuples2d.ts +0 -25
  182. package/src/world2d/transform-writer-2d.ts +0 -42
  183. package/src/world2d/types.ts +0 -74
  184. package/src/world3d-react/behavior.tsx +0 -146
  185. /package/src/{scene → render}/mesh-shadow.ts +0 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * `<PixiPrimitive>` and `adoptNow` — the canvas surface's seam for an object React did NOT create.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * A first-party canvas world is a `@pixi/react` tree, and every element in one is built by the
7
+ * reconciler: `createInstance` does `new PixiComponent(props)` and there is no escape hatch for an
8
+ * existing `Container`. That is fine until something OUTSIDE React owns an object the tree has to
9
+ * render — which is the ordinary case the moment a world has a runtime, not just a picture:
10
+ *
11
+ * - a translated Godot scene's own root, written to by a script (`mob.position = …`) in the same
12
+ * statement sequence that constructs it, BEFORE anything has rendered;
13
+ * - a container a game's own module or an asset pipeline handed over whole.
14
+ *
15
+ * three's lane answers this with `<primitive object={…}>`. This is that element for Pixi, and the
16
+ * three properties it must have are the three that make it not a mirror: the props go on the SAME
17
+ * object, the children go UNDER the same object, and unmount DETACHES it rather than destroying it
18
+ * — because React never owned it.
19
+ *
20
+ * ## How it is wired, and what @pixi/react actually offers
21
+ *
22
+ * Through `extend()`, which is @pixi/react's own documented extension point — the catalogue it
23
+ * resolves an intrinsic tag against. A registered class is constructed with the element's props, and
24
+ * a JS constructor that RETURNS an object hands back that object instead of the one `new` made: so
25
+ * the element's instance IS the adopted container, and from there every host-config path is the
26
+ * library's own (`appendChild` -> `parent.addChild`, `applyProps` -> the same property writes every
27
+ * other element gets, `insertBefore` -> `addChildAt`). No parallel reconciler, no re-implemented
28
+ * host config, no patched package.
29
+ *
30
+ * ONE path could not be wired through: `removeChild` unconditionally calls `childInstance.destroy()`
31
+ * (`@pixi/react` 8.0.5, `lib/helpers/removeChild.mjs`), there is no per-instance opt-out, and the
32
+ * host config is module-private (the package's `exports` map has only `"."`, so its
33
+ * `lib/core/reconciler` cannot be imported). {@link PixiPrimitive} therefore arms a ONE-SHOT
34
+ * `destroy` on the object in its own layout-effect cleanup — which React runs, synchronously,
35
+ * immediately before the `removeChild` that would destroy it — and that override detaches, restores
36
+ * the real method and is gone. It is disarmed on the next microtask regardless, so a deletion that
37
+ * never reaches this node (React calls `removeChild` only for the OUTERMOST host node of a deleted
38
+ * subtree) cannot leave the override installed.
39
+ *
40
+ * ## What `adoptNow` is for
41
+ *
42
+ * @pixi/react exports no reconciler `flushSync` — every `react-reconciler` instance closes over its
43
+ * own scheduler state (`module.exports = function ($$$config) { … }`), so no second instance and no
44
+ * other renderer can flush this one's work. A state update made from inside a frame therefore
45
+ * commits on React's own schedule rather than inside the call that made it.
46
+ *
47
+ * {@link adoptNow} is what the seam CAN offer for that: the half of the guarantee that is about the
48
+ * DISPLAY TREE rather than about React. A caller that has just created an object and asked React to
49
+ * render it places it now, and the commit that follows is an idempotent re-adoption of the same
50
+ * object under the same parent — `<PixiPrimitive>` renders the object the caller already placed, so
51
+ * there is one object and one tree, never a second insertion. What it does not do is run the
52
+ * object's own mount work early: whatever the rendering component does in its layout effect still
53
+ * happens when React commits, and a caller that needs that ordering must say so.
54
+ */
55
+
56
+ import { extend } from '@pixi/react';
57
+ import { Container } from 'pixi.js';
58
+ import {
59
+ createElement,
60
+ type FunctionComponent,
61
+ type ReactNode,
62
+ useLayoutEffect,
63
+ useRef,
64
+ } from 'react';
65
+
66
+ /** The prop the adoption element carries its object on. */
67
+ const OBJECT_PROP = 'object';
68
+
69
+ /**
70
+ * The catalogue entry, and the whole of the adoption mechanism.
71
+ *
72
+ * It is never instantiated: a constructor that returns an object hands that object back to the
73
+ * caller, so `new AdoptedPixiObject({ object })` IS `object`. That is the one thing @pixi/react's
74
+ * `createInstance` cannot do on its own and the only thing this class exists for.
75
+ */
76
+ class AdoptedPixiObject {
77
+ constructor(props: { readonly [OBJECT_PROP]?: unknown }) {
78
+ const object = props[OBJECT_PROP];
79
+ if (!(object instanceof Container)) {
80
+ throw new Error(
81
+ 'PixiPrimitive: `object` must be a PIXI.Container (got ' +
82
+ `${object === null ? 'null' : typeof object}). This element adopts an object the game ` +
83
+ 'already owns; it does not create one.',
84
+ );
85
+ }
86
+ // `applyProps` writes every prop it is given onto the instance, and the instance here is the
87
+ // GAME'S object. A non-enumerable accessor answers with the object and swallows that write, so
88
+ // adoption never plants a self-reference on something the game holds.
89
+ Object.defineProperty(object, OBJECT_PROP, {
90
+ configurable: true,
91
+ enumerable: false,
92
+ get: () => object,
93
+ set: () => {},
94
+ });
95
+ // The returned object IS the mechanism — see this module's header. @pixi/react's
96
+ // `createInstance` has exactly one way to produce an element's instance
97
+ // (`new PixiComponent(props)`), so a constructor that hands back an existing object is the only
98
+ // door an existing one can be adopted through, and this class exists for nothing else. Nobody
99
+ // ever reads an `AdoptedPixiObject`; the catalogue is its only caller.
100
+ // biome-ignore lint/correctness/noConstructorReturn: adoption is exactly this return
101
+ return object;
102
+ }
103
+ }
104
+
105
+ extend({ VgaiAdopted: AdoptedPixiObject });
106
+
107
+ /** The intrinsic `extend` above registered. Typed as a component so this module is the ONE place
108
+ * that knows the tag; nothing else in the repo, and no game, ever spells it. */
109
+ const ADOPTION_ELEMENT = 'pixiVgaiAdopted' as unknown as FunctionComponent<Record<string, unknown>>;
110
+
111
+ export interface PixiPrimitiveProps {
112
+ /** The container to adopt. It must not change for the life of one element — see below. */
113
+ readonly object: Container;
114
+ readonly children?: ReactNode;
115
+ /** Everything else is an ordinary `@pixi/react` prop, applied to {@link object} itself. */
116
+ readonly [prop: string]: unknown;
117
+ }
118
+
119
+ /**
120
+ * Render an EXISTING `PIXI.Container` as an element of this tree.
121
+ *
122
+ * ```tsx
123
+ * <PixiPrimitive object={scene.node} label={'Main'} x={0} y={0}>
124
+ * <pixiSprite label={'Background'} texture={background} />
125
+ * </PixiPrimitive>
126
+ * ```
127
+ *
128
+ * The props land on `scene.node`, the children become its children, and when this unmounts
129
+ * `scene.node` leaves the display tree intact — its lifetime belongs to whoever made it.
130
+ */
131
+ export function PixiPrimitive(props: PixiPrimitiveProps): React.JSX.Element {
132
+ const { object, children, ...rest } = props;
133
+ // The adopted object is the element's INSTANCE, and an instance is fixed for the life of an
134
+ // element — a changed `object` would silently keep rendering the old one. Refuse by name instead.
135
+ const adopted = useRef(object);
136
+ if (adopted.current !== object) {
137
+ throw new Error(
138
+ 'PixiPrimitive: `object` changed on a mounted element. The adopted container IS this ' +
139
+ "element's instance, so it cannot be swapped; give the element a `key` derived from the " +
140
+ 'object instead, so React remounts it.',
141
+ );
142
+ }
143
+ useLayoutEffect(() => {
144
+ // React runs this cleanup, synchronously, just before the `removeChild` that would destroy the
145
+ // object — see this module's header.
146
+ return () => armDetach(object);
147
+ // `object` is invariant for this element (asserted above), so this is once per mount.
148
+ }, [object]);
149
+ // An `undefined` prop is DROPPED, so absent means "keep what the game set". @pixi/react's mount
150
+ // diff compares element props against the instance's OWN-enumerable snapshot, and for an own
151
+ // property (a v8 `Container`'s `label`) that snapshot shows the live value — so a forwarded-but-
152
+ // unset prop (`label={props.label}` on a spawned scene root) would diff `'Mob' -> undefined` and
153
+ // write `undefined` onto the game's object. Dropping the key instead also gives a later
154
+ // defined -> undefined transition the library's own REMOVAL semantics (reset to the class
155
+ // default) rather than a literal `undefined` write.
156
+ const applied: Record<string, unknown> = { [OBJECT_PROP]: object };
157
+ for (const [key, value] of Object.entries(rest)) {
158
+ if (value !== undefined) applied[key] = value;
159
+ }
160
+ return createElement(ADOPTION_ELEMENT, applied, children);
161
+ }
162
+
163
+ /**
164
+ * Turn the next `destroy()` on `object` into a detach, then restore the real method.
165
+ *
166
+ * One shot and self-disarming: the override removes itself before doing anything, and a microtask
167
+ * removes it in any case, so the window in which it can answer is exactly React's own (synchronous)
168
+ * commit.
169
+ */
170
+ function armDetach(object: Container): void {
171
+ const target = object as unknown as { destroy: Container['destroy'] };
172
+ const previous = Object.getOwnPropertyDescriptor(object, 'destroy');
173
+ let armed = true;
174
+ const disarm = (): void => {
175
+ if (!armed) return;
176
+ armed = false;
177
+ if (previous === undefined) delete (target as Partial<typeof target>).destroy;
178
+ else Object.defineProperty(object, 'destroy', previous);
179
+ };
180
+ Object.defineProperty(object, 'destroy', {
181
+ configurable: true,
182
+ enumerable: false,
183
+ writable: true,
184
+ value: function detachInsteadOfDestroy(this: Container): void {
185
+ disarm();
186
+ this.removeFromParent();
187
+ },
188
+ });
189
+ queueMicrotask(disarm);
190
+ }
191
+
192
+ /**
193
+ * Put `child` under `parent` NOW, ahead of the commit that will render it.
194
+ *
195
+ * The canvas surface's answer to a reconciler `flushSync`, which @pixi/react does not expose (see
196
+ * this module's header). Idempotent with {@link PixiPrimitive}: re-adopting the same object under
197
+ * the same parent is what the commit does, and Pixi's own `addChild` treats it as a no-op reorder.
198
+ */
199
+ export function adoptNow(parent: Container, child: Container): void {
200
+ if (child.parent === parent) return;
201
+ parent.addChild(child);
202
+ }
@@ -0,0 +1,290 @@
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 exact sibling of `world3d-react`'s
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`.
15
+ *
16
+ * ## The loop contract
17
+ *
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
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, type ReactNode, useLayoutEffect } from 'react';
42
+ import type { CanvasHostContext, MountedPixiRoot, RootAdapter } from '../adapter';
43
+ import { WorldProvider } from '../react/world-state';
44
+ import { EngineBridge, type EngineBridgeValue } from './engine-bridge';
45
+ import { createCanvasRootContext } from './world-context';
46
+
47
+ /** How long `mount()` waits for the tree's first commit before failing loudly
48
+ * rather than hanging (and wedging every root declared after this one, since
49
+ * roots mount sequentially). */
50
+ const FIRST_COMMIT_TIMEOUT_MS = 10_000;
51
+
52
+ /**
53
+ * `@pixi/react` keys its reconciler roots off the canvas element in a
54
+ * module-global map, and its own unmount path (which would clear that key) is
55
+ * not exported. So a canvas this lane has already mounted must never be handed
56
+ * to a second mount: `createRoot` would silently hand back the FIRST root,
57
+ * whose `Application` this lane already destroyed. Every host in this repo
58
+ * builds a fresh canvas per mount, so this only ever fires on a genuine bug —
59
+ * and it fires as a named error rather than as a dead world.
60
+ */
61
+ const MOUNTED_CANVASES = new WeakSet<HTMLCanvasElement>();
62
+
63
+ /** `@pixi/react`'s reconciler-root handle. Its `Root` type is internal (the
64
+ * package exports the FUNCTION, not the type), so name it off the function. */
65
+ type PixiReactRoot = ReturnType<typeof createRoot>;
66
+
67
+ /** What {@link createPixiReactAdapter} needs to build one `RootAdapter<'canvas'>`. */
68
+ export interface CreatePixiReactAdapterOptions {
69
+ /** Stable id (telemetry/registry/conformance) — `RootAdapter.id`. */
70
+ readonly id: string;
71
+ /** The Pixi tree to mount — `useTick` hooks, `useApplication`, Pixi filters
72
+ * and any extended component all work unchanged. */
73
+ readonly content: ReactNode;
74
+ /** Input map loaded through the game-owned `Game.loadInputMap` (load-once
75
+ * across roots). Defaults to the conventional
76
+ * `/inputmaps/default.inputmap.json`; `null` opts out (a world with no
77
+ * actions). A missing/invalid map degrades loudly without failing the
78
+ * mount — see `world-context.ts`. */
79
+ readonly inputMapPath?: string | null | undefined;
80
+ /**
81
+ * Extra `Application.init` options merged UNDER this adapter's own — e.g.
82
+ * `{ antialias: false }`, `{ roundPixels: true }`. The loop-critical ones
83
+ * (`autoStart`, `sharedTicker`) and the ones the host owns (size, dpr,
84
+ * transparency, `preserveDrawingBuffer`) are applied after this and cannot
85
+ * be overridden: a world that starts its own ticker or clears an opaque
86
+ * background under a stacked sibling breaks the host's contract, not just
87
+ * its own frame.
88
+ */
89
+ readonly applicationOptions?: Partial<ApplicationOptions> | undefined;
90
+ }
91
+
92
+ /**
93
+ * Build a `RootAdapter<'canvas'>` that mounts `options.content` through
94
+ * `@pixi/react`, gated entirely by the host's own loop and rendering into the
95
+ * host's own canvas — never a second canvas, never a second
96
+ * `requestAnimationFrame` loop.
97
+ */
98
+ export function createPixiReactAdapter(
99
+ options: CreatePixiReactAdapterOptions,
100
+ ): RootAdapter<'canvas'> {
101
+ const { id, content, inputMapPath, applicationOptions } = options;
102
+
103
+ return {
104
+ id,
105
+
106
+ async mount(host: CanvasHostContext): Promise<MountedPixiRoot> {
107
+ const runtime = createCanvasRootContext(host, { id, inputMapPath });
108
+ const bridge: EngineBridgeValue = { ctx: runtime.ctx };
109
+ // Actions must exist before any component's first tick reads them — wait
110
+ // for the (never-rejecting) input-map load before the first commit.
111
+ await runtime.inputMapReady;
112
+
113
+ const canvas = host.canvas;
114
+ if (MOUNTED_CANVASES.has(canvas)) {
115
+ runtime.dispose();
116
+ throw new Error(
117
+ `createPixiReactAdapter: root "${id}" was handed a canvas this lane has already ` +
118
+ 'mounted. @pixi/react keys its reconciler roots off the canvas element, so re-using ' +
119
+ 'one would resurrect a root whose Application is destroyed. Hand each mount a fresh ' +
120
+ 'canvas (every vgai host already does).',
121
+ );
122
+ }
123
+ MOUNTED_CANVASES.add(canvas);
124
+
125
+ // The catalogue is module-global and starts empty; this is idempotent.
126
+ extend(PIXI as unknown as Parameters<typeof extend>[0]);
127
+
128
+ let resolveCommitted!: () => void;
129
+ const committed = new Promise<void>((resolve) => {
130
+ resolveCommitted = resolve;
131
+ });
132
+ let resolveUnmounted!: () => void;
133
+ const unmounted = new Promise<void>((resolve) => {
134
+ resolveUnmounted = resolve;
135
+ });
136
+ /**
137
+ * Signals the tree's FIRST COMMIT and its LAST. A layout effect,
138
+ * deliberately, and rendered as the LAST child, deliberately:
139
+ *
140
+ * - mount: layout effects run synchronously inside the commit, before
141
+ * any passive effect and before any tick can be scheduled;
142
+ * - unmount: React commits deletion effects in tree order, each node
143
+ * before its own children, so the LAST sibling's cleanup is the last
144
+ * to run. That is what makes this a reliable "every component has
145
+ * finished tearing down" signal — and it is load-bearing, because
146
+ * `useTick`'s own cleanup calls `ticker.remove()`. Destroying the
147
+ * Application before that cleanup lands throws inside React's commit
148
+ * on every teardown of any world with a tick hook.
149
+ */
150
+ function Lifecycle(): null {
151
+ useLayoutEffect(() => {
152
+ resolveCommitted();
153
+ return resolveUnmounted;
154
+ }, []);
155
+ return null;
156
+ }
157
+
158
+ const game = runtime.ctx.game;
159
+ // The React seam every dom root already has — `useWorldState`,
160
+ // `useDebugProvider`/`useDebugCommand`/`useDebugEmit`
161
+ // (`../react/world-state`) — works identically inside a canvas root
162
+ // because a canvas root IS React. `host.game` is genuinely absent in
163
+ // bare/foreign hosts, so the provider is conditional and those hooks
164
+ // 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
+ );
171
+
172
+ const root: PixiReactRoot = createRoot(canvas);
173
+
174
+ // The host owns size, dpr, transparency and capture cost; the loop
175
+ // contract owns `autoStart`/`sharedTicker`. Both are applied AFTER the
176
+ // caller's own options so neither can be overridden from a world file.
177
+ const initOptions: Partial<ApplicationOptions> = {
178
+ antialias: true,
179
+ ...applicationOptions,
180
+ width: host.width,
181
+ height: host.height,
182
+ resolution: host.dpr ?? globalThis.devicePixelRatio ?? 1,
183
+ autoDensity: true,
184
+ // Stacked ABOVE a sibling root -> clear with alpha 0 so the layer below
185
+ // shows through (COMPOSITION-DESIGN D5 §1).
186
+ backgroundAlpha: host.transparent ? 0 : 1,
187
+ preserveDrawingBuffer: host.preserveDrawingBuffer ?? false,
188
+ autoStart: false,
189
+ sharedTicker: false,
190
+ };
191
+
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);
197
+
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)',
217
+ );
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
+ });
235
+ });
236
+
237
+ // Pin the ticker's clock to GAME time. `Ticker.lastTime` starts at -1, so
238
+ // without this the first `update()` would report a 1ms phantom delta on
239
+ // top of the real one. From here every `deltaMS` a `useTick` callback
240
+ // sees is exactly the `dt` the host handed us — no wall clock anywhere,
241
+ // which is what makes a paused world's resume frame ordinary rather than
242
+ // one giant catch-up step.
243
+ app.ticker.lastTime = 0;
244
+ let elapsedMs = 0;
245
+ let disposed = false;
246
+
247
+ return {
248
+ kind: 'canvas',
249
+ // The reconciler's real root container — the same `PIXI.Container`
250
+ // `RootInstance.pixiStage()` hands the editor.
251
+ stage: app.stage,
252
+ 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,
256
+ update(dt: number): void {
257
+ if (disposed) return;
258
+ 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);
265
+ },
266
+ resize(width: number, height: number): void {
267
+ if (disposed) return;
268
+ // `autoDensity: true` makes Pixi re-stamp `canvas.style.width`/
269
+ // `.height` from this call, so the on-screen box follows the logical
270
+ // size with no explicit CSS write from the host.
271
+ app.renderer.resize(width, height);
272
+ },
273
+ dispose(): void {
274
+ if (disposed) return;
275
+ disposed = true;
276
+ // Rendering `null` children is the only unmount path `@pixi/react`
277
+ // exposes publicly (its `unmountRoot` helper is internal), and React
278
+ // commits that deletion on its own schedule — NOT synchronously. So
279
+ // the Application is destroyed on the `Lifecycle` cleanup signal
280
+ // instead of inline: `useTick`'s cleanup calls `ticker.remove()`, and
281
+ // a ticker destroyed before it lands throws inside React's commit.
282
+ // `removeView` stays false — the canvas belongs to the HOST.
283
+ void root.render(null, initOptions);
284
+ void unmounted.then(() => app.destroy(false, { children: true }));
285
+ runtime.dispose();
286
+ },
287
+ };
288
+ },
289
+ };
290
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `pixiReactRootFactory` — mount a `canvas` root whose entry module
3
+ * DEFAULT-EXPORTS a React component.
4
+ *
5
+ * A model asked for a 2D world writes:
6
+ *
7
+ * ```tsx
8
+ * export default function World() {
9
+ * return <pixiContainer><pixiSprite texture={Texture.WHITE} /></pixiContainer>;
10
+ * }
11
+ * ```
12
+ *
13
+ * It does NOT write `export const adapter = createPixiReactAdapter({ id, content })`.
14
+ * That export is vgai ceremony wrapped around a component the author already
15
+ * wrote. The `three` and `dom` roots both settled this convention — read
16
+ * `entryModule.default` and wrap it — and this is the same contract for canvas
17
+ * roots, so all three surfaces answer "what do I export?" the same way.
18
+ *
19
+ * ## Why this is NOT in `mount-game.ts`
20
+ *
21
+ * The kind registry beside `mount-game.ts` is deliberately dependency-free:
22
+ * registering a canvas factory there would make `pixi.js` + `@pixi/react`
23
+ * unconditional for every game, including three-only ones. `canvas-react` is
24
+ * the opt-in module that owns those dependencies, so the factory lives here and
25
+ * a project opts in with `registerAdapter('canvas', pixiReactRootFactory)`.
26
+ *
27
+ * ## Compatibility
28
+ *
29
+ * An `adapter` export still wins, so a game needing full control over its own
30
+ * `RootAdapter<'canvas'>` keeps it.
31
+ */
32
+
33
+ import { type ComponentType, createElement } from 'react';
34
+ import type { RootAdapter } from '../adapter/root-adapter';
35
+ import type { AdapterSurfaceFactory } from '../runtime/mount-game';
36
+ import { createPixiReactAdapter } from './pixi-react-adapter';
37
+
38
+ interface CanvasEntryModuleExports {
39
+ /** The idiomatic shape: the world IS a component. */
40
+ readonly default?: ComponentType;
41
+ /** Full control, and still the winner when present. */
42
+ readonly adapter?: RootAdapter<'canvas'>;
43
+ }
44
+
45
+ /**
46
+ * What a canvas entry module MEANS, in one place.
47
+ *
48
+ * Two callers need this answer and must never disagree about it: the runtime
49
+ * mount ({@link pixiReactRootFactory}, below) and the EDITOR, which resolves
50
+ * the same entry for play mode and for the design-time layer. When only one
51
+ * side knows about the default-export shape, a world plays fine and shows
52
+ * "(no scene loaded)" in edit mode — the exact split the three lane already
53
+ * paid for.
54
+ *
55
+ * Returns `null` when the module is neither shape — the callers differ on what
56
+ * to do about that (the factory throws; the editor reports a mount failure on
57
+ * that root's boundary node and leaves every sibling working).
58
+ */
59
+ export function resolveCanvasEntryAdapter(
60
+ entryModule: unknown,
61
+ rootId: string,
62
+ ): RootAdapter<'canvas'> | null {
63
+ const mod = entryModule as CanvasEntryModuleExports | undefined;
64
+ if (mod?.adapter) return mod.adapter;
65
+ if (typeof mod?.default === 'function') {
66
+ return createPixiReactAdapter({
67
+ id: rootId,
68
+ content: createElement(mod.default),
69
+ });
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Register with `registerAdapter('canvas', pixiReactRootFactory)`.
76
+ *
77
+ * Resolution order mirrors the three lane's (explicit adapter wins) and then
78
+ * accepts the default-exported component.
79
+ */
80
+ export const pixiReactRootFactory: AdapterSurfaceFactory = (root, ctx) => {
81
+ const adapter = resolveCanvasEntryAdapter(ctx.entryModule, root.id);
82
+ if (adapter) return { kind: 'canvas', adapter };
83
+ throw new Error(
84
+ `pixiReactRootFactory: entry module "${root.entry ?? '(none)'}" for root "${root.id}" must ` +
85
+ 'default-export a React component (`export default function World() { … }`) — or export ' +
86
+ 'an `adapter` for full control.',
87
+ );
88
+ };