@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.
- package/README.md +18 -11
- package/package.json +10 -6
- package/schemas/engine-api.json +1 -68
- package/schemas/engine-api.md +1 -32
- package/schemas/engine-capabilities.json +30 -42
- package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
- package/src/adapter/adapter-surface.ts +5 -5
- package/src/adapter/authoring.ts +168 -226
- package/src/adapter/colyseus-networking-adapter.ts +44 -5
- package/src/adapter/first-party-systems.ts +156 -42
- package/src/adapter/host-context.ts +177 -46
- package/src/adapter/index.ts +47 -51
- package/src/adapter/ingest/game-contract.ts +2 -2
- package/src/adapter/ingest/scene-capture.ts +18 -19
- package/src/adapter/ingest/structural-ids.ts +127 -0
- package/src/adapter/ingest/upstream-pin.ts +9 -12
- package/src/adapter/loop-gate-report.ts +11 -11
- package/src/adapter/rapier-physics-adapter.ts +27 -9
- package/src/adapter/root-adapter.ts +217 -0
- package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
- package/src/adapter/system-adapter.ts +80 -63
- package/src/ai/navigation.ts +1 -1
- package/src/animation/animation-clock.ts +1 -1
- package/src/animation/camera-ownership.ts +1 -2
- package/src/animation/cubic-spline-interpolant.ts +132 -0
- package/src/animation/theatre-clock-binding.ts +2 -2
- package/src/animation/theatre-object-binding.ts +4 -4
- package/src/animation/xstate-animation-binding.ts +75 -5
- package/src/{scene/schema → asset-formats}/camera.ts +2 -5
- package/src/{scene/schema → asset-formats}/collider.ts +2 -5
- package/src/asset-formats/index.ts +54 -0
- package/src/{scene/schema → asset-formats}/instances.ts +10 -6
- package/src/{scene/schema → asset-formats}/light.ts +3 -6
- package/src/{scene/schema → asset-formats}/material.ts +4 -7
- package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
- package/src/asset-formats/parse.ts +39 -0
- package/src/{scene/schema → asset-formats}/particles.ts +3 -6
- package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
- package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
- package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
- package/src/asset-parse-error.ts +33 -0
- package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
- package/src/assets.ts +1 -1
- package/src/audio/wav-encode.ts +9 -9
- package/src/canvas-react/engine-bridge.ts +59 -0
- package/src/canvas-react/index.ts +50 -0
- package/src/canvas-react/pixi-primitive.tsx +202 -0
- package/src/canvas-react/pixi-react-adapter.tsx +290 -0
- package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
- package/src/canvas-react/world-context.ts +328 -0
- package/src/core/frame-pacing.ts +100 -0
- package/src/core/game-loop.ts +50 -28
- package/src/core/seeded-random.ts +7 -7
- package/src/core/sim-clock.ts +388 -0
- package/src/core/system-runner.ts +17 -63
- package/src/core/types.ts +34 -15
- package/src/data/data-asset.ts +3 -3
- package/src/data/data-check-core.ts +6 -7
- package/src/data/data-ref.ts +11 -11
- package/src/data/vite-plugin-data.ts +10 -10
- package/src/{scene/defaults.ts → defaults.ts} +18 -40
- package/src/dev/render-debug-adapter.ts +1 -1
- package/src/dev/webgl-frame-capture.ts +1 -1
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/{scene → ecs}/user-data.ts +17 -36
- package/src/index.ts +7 -9
- package/src/input/input-manager.ts +29 -32
- package/src/input/input-types.ts +2 -2
- package/src/input/schema.ts +5 -5
- package/src/loader.ts +57 -0
- package/src/manifest/editor-port.ts +69 -0
- package/src/manifest/filename.ts +49 -0
- package/src/manifest/index.ts +8 -2
- package/src/manifest/load-file.ts +11 -0
- package/src/manifest/load.ts +65 -77
- package/src/manifest/locate.ts +55 -0
- package/src/manifest/schema.ts +400 -233
- package/src/{scene → physics}/collider-dimensions.ts +3 -3
- package/src/physics/physics-registry.ts +1 -1
- package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
- package/src/pixi/index.ts +43 -0
- package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
- package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
- package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
- package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
- package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
- package/src/react/unmanaged-root-detector.ts +26 -1
- package/src/react/use-data.ts +5 -5
- package/src/react/use-selection.tsx +15 -42
- package/src/react/{game-state.tsx → world-state.tsx} +44 -46
- package/src/render/auto-batcher.ts +1 -2
- package/src/{scene → render}/instance-mesh.ts +1 -1
- package/src/{scene → render}/light-camera-factory.ts +14 -13
- package/src/render/lod.ts +17 -0
- package/src/{scene → render}/material-factory.ts +8 -7
- package/src/{scene → render}/particles-factory.ts +62 -12
- package/src/render/render-batch-system.ts +14 -41
- package/src/render/render-features.ts +1 -1
- package/src/render/render-settings.ts +1 -2
- package/src/render/spark-renderer-lifecycle.ts +1 -1
- package/src/runtime/create-runtime.ts +352 -463
- package/src/runtime/debug-bridge.ts +148 -93
- package/src/runtime/debug-registry.ts +79 -54
- package/src/runtime/dev-layers.ts +40 -0
- package/src/runtime/frame-selector-cache.ts +4 -4
- package/src/runtime/game.ts +618 -369
- package/src/runtime/gameplay-rng-trap.ts +6 -7
- package/src/runtime/input-router.ts +11 -11
- package/src/runtime/mount-game.ts +54 -55
- package/src/runtime/mount-manifest.ts +154 -150
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-audio-control.ts +64 -53
- package/src/runtime/render-control.ts +45 -51
- package/src/runtime/render-seed.ts +3 -4
- package/src/runtime/state-bridge.ts +17 -18
- package/src/runtime/types.ts +94 -61
- package/src/setup/setup-renderer.ts +11 -6
- package/src/world3d-react/engine-bridge.ts +46 -33
- package/src/world3d-react/index.ts +31 -26
- package/src/world3d-react/r3f-adapter.tsx +211 -96
- package/src/world3d-react/r3f-root-factory.tsx +91 -0
- package/src/world3d-react/renderer-config.ts +137 -0
- package/src/world3d-react/world-context.ts +141 -111
- package/schemas/entity2d.schema.json +0 -468
- package/schemas/prefab.schema.json +0 -9992
- package/schemas/scn2d.schema.json +0 -494
- package/schemas/vscn.schema.json +0 -10851
- package/src/adapter/game-adapter.ts +0 -164
- package/src/adapter/ingest/overlay-applier.ts +0 -207
- package/src/adapter/ingest/overlay-apply.ts +0 -168
- package/src/adapter/ingest/overlay-file.ts +0 -126
- package/src/adapter/ingest/overlay-report.ts +0 -176
- package/src/animation/gsap-registration.ts +0 -184
- package/src/audio/audio-introspection.ts +0 -290
- package/src/audio/index.ts +0 -39
- package/src/audio/tone-clock-binding.ts +0 -98
- package/src/audio/tone-context.ts +0 -175
- package/src/audio/tone-offline-render.ts +0 -167
- package/src/ecs/component-manager.ts +0 -814
- package/src/ecs/game-component.ts +0 -260
- package/src/ecs/hmr-swap-report.ts +0 -65
- package/src/physics/trigger-dispatch.ts +0 -97
- package/src/react/root-adapter.tsx +0 -49
- package/src/scene/asset-paths.ts +0 -121
- package/src/scene/asset-ref-check.ts +0 -248
- package/src/scene/component-registry.ts +0 -51
- package/src/scene/parse.ts +0 -204
- package/src/scene/scene-apply.ts +0 -407
- package/src/scene/scene-diff-schema.ts +0 -115
- package/src/scene/scene-diff-types.ts +0 -29
- package/src/scene/scene-loader.ts +0 -1526
- package/src/scene/scene-query.ts +0 -63
- package/src/scene/scene-types.ts +0 -33
- package/src/scene/scene-version.ts +0 -40
- package/src/scene/schema/animation.ts +0 -46
- package/src/scene/schema/audio.ts +0 -25
- package/src/scene/schema/entity-ref.ts +0 -78
- package/src/scene/schema/entity.ts +0 -189
- package/src/scene/schema/index.ts +0 -51
- package/src/scene/schema/joint.ts +0 -26
- package/src/scene/schema/physics.ts +0 -49
- package/src/scene/schema/scene-file.ts +0 -292
- package/src/scene/schema/shadow.ts +0 -24
- package/src/scene/schema/spline.ts +0 -21
- package/src/world2d/asset-paths2d.ts +0 -44
- package/src/world2d/capture-to-scene2d.ts +0 -52
- package/src/world2d/collision-2d.ts +0 -99
- package/src/world2d/entity2d-asset.ts +0 -22
- package/src/world2d/index.ts +0 -91
- package/src/world2d/physics2d-transform.ts +0 -173
- package/src/world2d/physics2d-units.ts +0 -10
- package/src/world2d/pixi-game-adapter.ts +0 -439
- package/src/world2d/pixi-surface.ts +0 -78
- package/src/world2d/scene2d-identity.ts +0 -49
- package/src/world2d/scene2d-loader.ts +0 -433
- package/src/world2d/schema/entity2d.ts +0 -163
- package/src/world2d/schema/physics2d.ts +0 -64
- package/src/world2d/schema/sprite.ts +0 -99
- package/src/world2d/schema/tilemap.ts +0 -39
- package/src/world2d/schema/tuples2d.ts +0 -25
- package/src/world2d/transform-writer-2d.ts +0 -42
- package/src/world2d/types.ts +0 -74
- package/src/world3d-react/behavior.tsx +0 -146
- /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
|
+
};
|