@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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/presentation.ts — how a game with a DECLARED resolution is presented in a host panel.
|
|
3
|
+
*
|
|
4
|
+
* `vgai.project.json`'s `resolution` is the game's own LOGICAL size: the coordinate space its
|
|
5
|
+
* cameras frame, its DOM overlay lays out in, and its 2D worlds author positions against. A host
|
|
6
|
+
* panel is whatever size the user dragged it to, and the two are not the same number.
|
|
7
|
+
*
|
|
8
|
+
* The rule, and it is the whole module:
|
|
9
|
+
*
|
|
10
|
+
* **A declared resolution is rendered AT that resolution, and the RESULT is scaled uniformly to
|
|
11
|
+
* fit the panel, centered, letterboxed.**
|
|
12
|
+
*
|
|
13
|
+
* That is presentation scaling — an OS scaling a game window — and deliberately NOT a stretch mode.
|
|
14
|
+
* Nothing here reflows the game, changes a camera's aspect, or re-anchors a HUD: the game renders
|
|
15
|
+
* one shape and the host draws that shape smaller or larger. Every presented root of one project
|
|
16
|
+
* (a three canvas, a pixi canvas, a dom overlay) therefore shares ONE scaled rect, which is what
|
|
17
|
+
* keeps a HUD registered over the world beneath it.
|
|
18
|
+
*
|
|
19
|
+
* A project that declares no `resolution` is unchanged: the game fills the panel and its cameras
|
|
20
|
+
* follow the panel's aspect ({@link PRESENTATION_FILL}).
|
|
21
|
+
*
|
|
22
|
+
* The math is here, alone and pure, because it had already been written twice inline (the Game
|
|
23
|
+
* panel's device-preview branch and the story preview's artboard) and a third copy would have been
|
|
24
|
+
* the one that drifted.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A width/height pair in whatever unit the caller is working in. */
|
|
28
|
+
export interface PresentationSize {
|
|
29
|
+
readonly width: number;
|
|
30
|
+
readonly height: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where a logical rect lands inside a container, scaled uniformly and centered. */
|
|
34
|
+
export interface PresentationRect {
|
|
35
|
+
/** Uniform logical→container scale factor. Always finite and > 0. */
|
|
36
|
+
readonly scale: number;
|
|
37
|
+
/** The scaled rect's size in container units. */
|
|
38
|
+
readonly width: number;
|
|
39
|
+
readonly height: number;
|
|
40
|
+
/** The letterbox margins — how far in from the container's top-left the scaled rect starts. */
|
|
41
|
+
readonly offsetX: number;
|
|
42
|
+
readonly offsetY: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function positiveOrZero(value: number): number {
|
|
46
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fit `logical` inside `container`, preserving aspect and centering the result.
|
|
51
|
+
*
|
|
52
|
+
* `allowUpscale` is the difference between the two callers this replaces and it is not cosmetic.
|
|
53
|
+
* A DEVICE PREVIEW must never upscale — the point is to see the device's own pixels, so a phone
|
|
54
|
+
* frame in a big panel stays phone-sized. A game's DECLARED resolution must upscale, or a 720x540
|
|
55
|
+
* game sits as a postage stamp in the middle of a maximized panel and the feature reads as broken.
|
|
56
|
+
*
|
|
57
|
+
* A degenerate input (zero, negative, NaN — a panel mid-collapse, a manifest with a 0) yields
|
|
58
|
+
* `scale: 1` and a zero-offset rect rather than an Infinity that would propagate into a CSS
|
|
59
|
+
* transform.
|
|
60
|
+
*/
|
|
61
|
+
export function fitPresentation(
|
|
62
|
+
container: PresentationSize,
|
|
63
|
+
logical: PresentationSize,
|
|
64
|
+
options: { readonly allowUpscale?: boolean } = {},
|
|
65
|
+
): PresentationRect {
|
|
66
|
+
const cw = positiveOrZero(container.width);
|
|
67
|
+
const ch = positiveOrZero(container.height);
|
|
68
|
+
const lw = positiveOrZero(logical.width);
|
|
69
|
+
const lh = positiveOrZero(logical.height);
|
|
70
|
+
if (cw === 0 || ch === 0 || lw === 0 || lh === 0) {
|
|
71
|
+
return { scale: 1, width: lw, height: lh, offsetX: 0, offsetY: 0 };
|
|
72
|
+
}
|
|
73
|
+
const raw = Math.min(cw / lw, ch / lh);
|
|
74
|
+
const scale = options.allowUpscale === true ? raw : Math.min(raw, 1);
|
|
75
|
+
const width = lw * scale;
|
|
76
|
+
const height = lh * scale;
|
|
77
|
+
return {
|
|
78
|
+
scale,
|
|
79
|
+
width,
|
|
80
|
+
height,
|
|
81
|
+
offsetX: (cw - width) / 2,
|
|
82
|
+
offsetY: (ch - height) / 2,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The game fills its panel: no declared resolution, no device preset, today's behaviour. */
|
|
87
|
+
export interface PresentationFill {
|
|
88
|
+
readonly kind: 'fill';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The game renders at a fixed logical size and the host scales the result. */
|
|
92
|
+
export interface PresentationLogical {
|
|
93
|
+
readonly kind: 'logical';
|
|
94
|
+
readonly width: number;
|
|
95
|
+
readonly height: number;
|
|
96
|
+
/** `device` — a preview preset the user picked; `manifest` — the project's own `resolution`. */
|
|
97
|
+
readonly source: 'device' | 'manifest';
|
|
98
|
+
/** Device previews never upscale; a declared resolution does. See {@link fitPresentation}. */
|
|
99
|
+
readonly allowUpscale: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type PresentedSize = PresentationFill | PresentationLogical;
|
|
103
|
+
|
|
104
|
+
export const PRESENTATION_FILL: PresentationFill = { kind: 'fill' };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Decide what size a game is presented at, from the two things that can ask for one.
|
|
108
|
+
*
|
|
109
|
+
* PRECEDENCE: an explicitly PICKED device resolution wins over the manifest's declared one. The
|
|
110
|
+
* pick is a live act by whoever is looking at the panel ("show me this on a phone"), and a project
|
|
111
|
+
* setting must not silently override it — otherwise every declared-resolution project would make
|
|
112
|
+
* the device-preview picker do nothing.
|
|
113
|
+
*/
|
|
114
|
+
export function resolvePresentedSize(
|
|
115
|
+
picked: { readonly width: number | null; readonly height: number | null },
|
|
116
|
+
declared?: PresentationSize | undefined,
|
|
117
|
+
): PresentedSize {
|
|
118
|
+
if (picked.width !== null && picked.height !== null) {
|
|
119
|
+
return {
|
|
120
|
+
kind: 'logical',
|
|
121
|
+
width: picked.width,
|
|
122
|
+
height: picked.height,
|
|
123
|
+
source: 'device',
|
|
124
|
+
allowUpscale: false,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
declared !== undefined &&
|
|
129
|
+
positiveOrZero(declared.width) > 0 &&
|
|
130
|
+
positiveOrZero(declared.height) > 0
|
|
131
|
+
) {
|
|
132
|
+
return {
|
|
133
|
+
kind: 'logical',
|
|
134
|
+
width: declared.width,
|
|
135
|
+
height: declared.height,
|
|
136
|
+
source: 'manifest',
|
|
137
|
+
allowUpscale: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return PRESENTATION_FILL;
|
|
141
|
+
}
|
|
@@ -1,39 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Render-mode AUDIO control seam (I6,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* rather than needing a schema field threaded through every fixture.
|
|
2
|
+
* Render-mode AUDIO control seam (I6, "Encode and Mux" + the audio-capture
|
|
3
|
+
* half of §13 G4). This is `render-control.ts`'s sibling for the audio side:
|
|
4
|
+
* where that module publishes `window.__vgaiRender` (exact-time seeks +
|
|
5
|
+
* composited frame passes), THIS module publishes `window.__vgaiRenderAudio` —
|
|
6
|
+
* an OPT-IN surface a render-mode page installs only when it declares a score,
|
|
7
|
+
* so `vgai render-cinematic` (`packages/vgai-cli/src/render-cinematic.ts`) can
|
|
8
|
+
* detect "this cinematic has a deterministic audio track" with a single
|
|
9
|
+
* property check (`__vgaiRenderAudio?.hasAudio === true`) rather than needing
|
|
10
|
+
* a schema field threaded through every fixture.
|
|
12
11
|
*
|
|
13
12
|
* Deliberately a SEPARATE global from `__vgaiRender`/`VgaiRenderHarness`
|
|
14
13
|
* (`render-control.ts`), not an extra method bolted onto that interface:
|
|
15
14
|
* every existing render-mode page (I0's `render-cinematic` fixture, I8's
|
|
16
|
-
* `reference-cinematic`) has no
|
|
15
|
+
* `reference-cinematic`) has no score at all, and this keeps their
|
|
17
16
|
* bundles/tests completely untouched — `render-control.ts` itself is not
|
|
18
17
|
* modified by this unit.
|
|
19
18
|
*
|
|
20
|
-
* ##
|
|
19
|
+
* ## What this module is, and what it deliberately is not
|
|
21
20
|
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
21
|
+
* `window.__vgaiRenderAudio` is a PROTOCOL — the surface the Node-side capture
|
|
22
|
+
* driver reads through `page.evaluate`, the audio counterpart to a debug
|
|
23
|
+
* adapter. That is why it stays in the engine. Producing the samples is not a
|
|
24
|
+
* protocol, so it does not: {@link RenderAudioControlOptions.render} is
|
|
25
|
+
* supplied by the caller and hands back a native `AudioBuffer`. This module
|
|
26
|
+
* owns the render-mode gate, the WAV encoding, and the base64 wire format.
|
|
27
|
+
*
|
|
28
|
+
* It used to call `renderToneOffline` itself, which put `tone` in the engine's
|
|
29
|
+
* dependency list for a page that merely wanted to publish a global. The
|
|
30
|
+
* offline renderer moved to the `music` capability (`src/lib/music/
|
|
31
|
+
* tone-offline-render.ts`); a fixture composes the two in one line, and a page
|
|
32
|
+
* scoring with anything else — a pre-rendered WAV decoded into an
|
|
33
|
+
* `AudioBuffer`, a WebAudio `OfflineAudioContext` graph, another library —
|
|
34
|
+
* satisfies the same contract without touching Tone at all.
|
|
35
|
+
*
|
|
36
|
+
* ## Why `renderAudio` takes `(start, end)` — the offset trap
|
|
37
|
+
*
|
|
38
|
+
* An offline render of `[10, 10.72)` schedules its first event at LOCAL time
|
|
39
|
+
* 0, not absolute time 10. A render-mode page authors its score in
|
|
40
|
+
* ABSOLUTE/canonical time (e.g. "a note at the same t=1.2s the video's
|
|
41
|
+
* Director cut happens"), so whatever the caller's `render` does internally
|
|
42
|
+
* must subtract the requested range's `start` from every scheduled event time.
|
|
43
|
+
* With Tone that means building the `Tone.Offline` callback per call, closed
|
|
44
|
+
* over `start` — see `renderToneOffline`'s own doc. This was the G12 review
|
|
45
|
+
* follow-up named in the unit's build brief ("renderToneOffline gives the
|
|
46
|
+
* composition LOCAL time 0 at `start`, so your composition must close over the
|
|
47
|
+
* range/offset"); it is now the caller's to honour, and the capability's
|
|
48
|
+
* fixture shows it.
|
|
37
49
|
*
|
|
38
50
|
* ## Wire format — why WAV bytes cross as base64, not a raw sample array
|
|
39
51
|
*
|
|
@@ -48,22 +60,27 @@
|
|
|
48
60
|
* logic duplicated on the Node side at all.
|
|
49
61
|
*/
|
|
50
62
|
|
|
51
|
-
import { renderToneOffline, type ToneOfflineComposeFn } from '../audio/tone-offline-render';
|
|
52
63
|
import { encodeWav16, pcmStats } from '../audio/wav-encode';
|
|
53
64
|
import { isRenderModeRequested } from './render-control';
|
|
54
65
|
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
export
|
|
66
|
+
/** What a caller's renderer hands back: real PCM in a native `AudioBuffer`.
|
|
67
|
+
* `AudioBuffer` — not a vgai type — is the point: any offline audio path in
|
|
68
|
+
* the platform already produces one. */
|
|
69
|
+
export interface RenderedAudio {
|
|
70
|
+
readonly buffer: AudioBuffer;
|
|
71
|
+
readonly durationSeconds: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Render `[start, end)` (absolute/canonical seconds) offline and
|
|
75
|
+
* deterministically. The offset trap in the module doc is the renderer's to
|
|
76
|
+
* honour: identical `(start, end)` on an unchanged score must produce
|
|
77
|
+
* identical samples, since the capture driver relies on it. */
|
|
78
|
+
export type OfflineAudioRenderer = (start: number, end: number) => Promise<RenderedAudio>;
|
|
59
79
|
|
|
60
80
|
export interface RenderAudioControlOptions {
|
|
61
|
-
/** See {@link
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
readonly channels?: number;
|
|
65
|
-
/** Output sample rate. Default 44100 — same default as `tone-offline-render.ts`. */
|
|
66
|
-
readonly sampleRate?: number;
|
|
81
|
+
/** See {@link OfflineAudioRenderer}. With the `music` capability this is one
|
|
82
|
+
* line over `renderToneOffline`; nothing here knows or cares that it is. */
|
|
83
|
+
readonly render: OfflineAudioRenderer;
|
|
67
84
|
/** Where to publish the harness. Defaults to the real `window` — override in a unit test. */
|
|
68
85
|
readonly target?: Record<string, unknown>;
|
|
69
86
|
/** Where to read `?vgai-render=1` from. Defaults to `window.location`. */
|
|
@@ -88,17 +105,17 @@ export interface RenderAudioResult {
|
|
|
88
105
|
|
|
89
106
|
/** The `window.__vgaiRenderAudio` surface. Presence of this global (guarded
|
|
90
107
|
* by `hasAudio: true`, never a bare `undefined`/`false` value on a present
|
|
91
|
-
* object) IS the "this cinematic declares a
|
|
108
|
+
* object) IS the "this cinematic declares a score" signal `render-
|
|
92
109
|
* cinematic.ts` checks — see this module's own top doc comment. */
|
|
93
110
|
export interface VgaiRenderAudioHarness {
|
|
94
111
|
readonly hasAudio: true;
|
|
95
112
|
/** Render `[start, end)` (absolute/canonical seconds, exactly the same
|
|
96
|
-
* range `window.__vgaiRender`'s frame walk covers)
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
113
|
+
* range `window.__vgaiRender`'s frame walk covers), returning a complete
|
|
114
|
+
* WAV file. Deterministic: identical `(start, end)` on an unchanged score
|
|
115
|
+
* produces byte-identical `wavBase64` every call — this module adds no
|
|
116
|
+
* source of nondeterminism, so the property is exactly whatever the
|
|
117
|
+
* supplied {@link OfflineAudioRenderer} guarantees (with the `music`
|
|
118
|
+
* capability, the `Tone.Offline` determinism G2 already proves). */
|
|
102
119
|
renderAudio(start: number, end: number): Promise<RenderAudioResult>;
|
|
103
120
|
}
|
|
104
121
|
|
|
@@ -119,18 +136,12 @@ export function installRenderAudioHarness(
|
|
|
119
136
|
if (!isRenderModeRequested(location)) return undefined;
|
|
120
137
|
|
|
121
138
|
const target = opts.target ?? (window as unknown as Record<string, unknown>);
|
|
122
|
-
const {
|
|
139
|
+
const { render } = opts;
|
|
123
140
|
|
|
124
141
|
const harness: VgaiRenderAudioHarness = {
|
|
125
142
|
hasAudio: true,
|
|
126
143
|
async renderAudio(start: number, end: number): Promise<RenderAudioResult> {
|
|
127
|
-
const rendered = await
|
|
128
|
-
start,
|
|
129
|
-
end,
|
|
130
|
-
...(channels !== undefined ? { channels } : {}),
|
|
131
|
-
...(sampleRate !== undefined ? { sampleRate } : {}),
|
|
132
|
-
compose: compose(start),
|
|
133
|
-
});
|
|
144
|
+
const rendered = await render(start, end);
|
|
134
145
|
const channelData: Float32Array[] = [];
|
|
135
146
|
for (let ch = 0; ch < rendered.buffer.numberOfChannels; ch++) {
|
|
136
147
|
channelData.push(rendered.buffer.getChannelData(ch));
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Render-control runtime seam (I2
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
* (`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* that deliberately drives `game.runFrame(fixedDt)` instead).
|
|
2
|
+
* Render-control runtime seam (I2). This is the harness a deterministic-capture host
|
|
3
|
+
* (Playwright/CDP, I0/I3/I4) drives: exact-time seeks against the canonical
|
|
4
|
+
* `AnimationClock` (`animation/animation-clock.ts`), and single `preRender`+`render`
|
|
5
|
+
* phase passes across every world on a `Game` (`runtime/game.ts`) — WITHOUT ever
|
|
6
|
+
* advancing gameplay fixed-step substeps
|
|
7
|
+
* (`input`/`prePhysics`/`physics`/`postPhysics`/`gameLogic`/`animation` are never
|
|
8
|
+
* invoked by this seam; `--simulate` (I5) is a different, later, path that
|
|
9
|
+
* deliberately drives `game.runFrame(fixedDt)` instead).
|
|
11
10
|
*
|
|
12
11
|
* This module does NOT reuse `PlayState.step()` (`Game.play.step()`) — that
|
|
13
12
|
* surface only advances the currently-FROZEN (paused) set of roots and is a
|
|
14
13
|
* whole-call no-op while unpaused (see its doc comment in `runtime/game.ts`);
|
|
15
14
|
* the spec review explicitly flagged it as the wrong seam. `renderOnce`
|
|
16
15
|
* instead walks `game.roots` directly and calls each world's own
|
|
17
|
-
* `
|
|
16
|
+
* `RootFrameHooks.runPhase` for exactly two phases.
|
|
18
17
|
*
|
|
19
18
|
* Also does NOT install anything by default — a caller must explicitly call
|
|
20
19
|
* {@link installRenderControlHarness}, and even then it only actually
|
|
@@ -36,7 +35,7 @@
|
|
|
36
35
|
*/
|
|
37
36
|
|
|
38
37
|
import { SystemPhase } from '../core/types';
|
|
39
|
-
import type { Game, GameInternal,
|
|
38
|
+
import type { Game, GameInternal, RootFrameHooks } from './game';
|
|
40
39
|
import { RENDER_MODE_QUERY_PARAM } from './render-seed';
|
|
41
40
|
|
|
42
41
|
/** Per-subsystem readiness verdict (I2 AC: "Runtime reports asset, shader,
|
|
@@ -193,8 +192,8 @@ export interface VgaiRenderHarness {
|
|
|
193
192
|
* page-declared list (e.g. "live Colyseus networking", "non-seeded ambient
|
|
194
193
|
* audio") this fixture/game names about itself, and (2) any world this
|
|
195
194
|
* seam itself could not frame-gate (a `drivesOwnLoop` world, or a
|
|
196
|
-
* host-driven world with no `
|
|
197
|
-
* {@link renderOnce}'s `
|
|
195
|
+
* host-driven world with no `RootFrameHooks` — the SAME two conditions
|
|
196
|
+
* {@link renderOnce}'s `renderableRoots` skips, described here as policy
|
|
198
197
|
* rather than merely warned about once to the console). Declared entries
|
|
199
198
|
* come first, in declaration order; world-exclusion entries follow, in
|
|
200
199
|
* `game.roots` declaration order.
|
|
@@ -222,7 +221,7 @@ export interface VgaiRenderHarness {
|
|
|
222
221
|
* `PerformanceProfiler` (`dev/performance-profiler.ts`) enabled, sampling
|
|
223
222
|
* it after every frame, then restores the profiler's prior enabled state.
|
|
224
223
|
* Returns per-frame CPU/phase timings + render counters, plus a
|
|
225
|
-
* structural node/entity count of every
|
|
224
|
+
* structural node/entity count of every three/canvas world's live scene
|
|
226
225
|
* graph. NOT a parallel instrumentation layer: every number here comes
|
|
227
226
|
* from the existing profiler (timings, render counters) or the live world
|
|
228
227
|
* roots themselves (counts).
|
|
@@ -257,7 +256,7 @@ export interface PerfFrameSample {
|
|
|
257
256
|
}
|
|
258
257
|
|
|
259
258
|
/** Per-world structural counts from {@link VgaiRenderHarness.perfSample}. */
|
|
260
|
-
export interface
|
|
259
|
+
export interface PerfRootCount {
|
|
261
260
|
readonly id: string;
|
|
262
261
|
readonly kind: string;
|
|
263
262
|
/** Total scene-graph descendants of the world root (exclusive of the root
|
|
@@ -271,9 +270,9 @@ export interface PerfWorldCount {
|
|
|
271
270
|
|
|
272
271
|
export interface PerfSampleReport {
|
|
273
272
|
readonly frames: readonly PerfFrameSample[];
|
|
274
|
-
/**
|
|
273
|
+
/** three/canvas worlds only — a react/DOM world has no scene-graph node
|
|
275
274
|
* count; it is omitted rather than fabricated. */
|
|
276
|
-
readonly worlds: readonly
|
|
275
|
+
readonly worlds: readonly PerfRootCount[];
|
|
277
276
|
}
|
|
278
277
|
|
|
279
278
|
export interface RenderControlHarnessOptions {
|
|
@@ -360,12 +359,12 @@ const RENDER_ONLY_PHASES = [SystemPhase.PRE_RENDER, SystemPhase.RENDER] as const
|
|
|
360
359
|
* than silently omitted, mirroring this file's "fail loudly, never a silent
|
|
361
360
|
* no-op" convention. A host-driven world with no `frame` (an opaque/foreign
|
|
362
361
|
* mount) is skipped the same way — there is no phase hook to call. Both
|
|
363
|
-
* checks happen ONCE, up front (see {@link
|
|
362
|
+
* checks happen ONCE, up front (see {@link renderableRoots}), not per
|
|
364
363
|
* phase — a world unrenderable in `preRender` is unrenderable in `render`
|
|
365
364
|
* too, so warning twice would just be noise.
|
|
366
365
|
*/
|
|
367
366
|
async function renderOnce(game: Game): Promise<void> {
|
|
368
|
-
const roots =
|
|
367
|
+
const roots = renderableRoots(game);
|
|
369
368
|
for (const phase of RENDER_ONLY_PHASES) {
|
|
370
369
|
// Game-scoped systems first, then per-world frame hooks — the SAME order
|
|
371
370
|
// GameInternal.runFrame uses (runtime/game.ts). See doc comment above.
|
|
@@ -377,24 +376,20 @@ async function renderOnce(game: Game): Promise<void> {
|
|
|
377
376
|
await compositionFence();
|
|
378
377
|
}
|
|
379
378
|
|
|
380
|
-
interface
|
|
379
|
+
interface RenderableRoot {
|
|
381
380
|
readonly id: string;
|
|
382
|
-
readonly frame:
|
|
381
|
+
readonly frame: RootFrameHooks;
|
|
383
382
|
}
|
|
384
383
|
|
|
385
384
|
/** Filter `game.roots` down to the ones {@link renderOnce} can actually
|
|
386
|
-
* drive (host-driven, with real `
|
|
385
|
+
* drive (host-driven, with real `RootFrameHooks`) — see that function's
|
|
387
386
|
* doc comment for why an excluded world is warned about loudly, not
|
|
388
387
|
* silently dropped. */
|
|
389
|
-
function
|
|
390
|
-
const result:
|
|
388
|
+
function renderableRoots(game: Game): RenderableRoot[] {
|
|
389
|
+
const result: RenderableRoot[] = [];
|
|
391
390
|
for (const world of game.roots) {
|
|
392
391
|
if (world.mounted.drivesOwnLoop) {
|
|
393
|
-
warnUnrenderable(
|
|
394
|
-
world.id,
|
|
395
|
-
'drives its own loop — renderOnce() cannot frame-gate it ' +
|
|
396
|
-
'(docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §15 I5 names this policy)',
|
|
397
|
-
);
|
|
392
|
+
warnUnrenderable(world.id, 'drives its own loop — renderOnce() cannot frame-gate it');
|
|
398
393
|
continue;
|
|
399
394
|
}
|
|
400
395
|
if (!world.frame) {
|
|
@@ -416,19 +411,19 @@ function warnUnrenderable(worldLabel: string, reason: string): void {
|
|
|
416
411
|
}
|
|
417
412
|
|
|
418
413
|
/**
|
|
419
|
-
* I5 AC 5 policy text for the two conditions {@link
|
|
414
|
+
* I5 AC 5 policy text for the two conditions {@link renderableRoots} skips —
|
|
420
415
|
* shared so {@link VgaiRenderHarness.excludedFromDeterminism} names the SAME
|
|
421
416
|
* roots `renderOnce()` warns about, in the SAME words, rather than
|
|
422
417
|
* maintaining a second, driftable description of the same two checks.
|
|
423
418
|
*/
|
|
424
|
-
function
|
|
419
|
+
function computeRootExclusionReasons(game: Game): string[] {
|
|
425
420
|
const reasons: string[] = [];
|
|
426
421
|
for (const world of game.roots) {
|
|
427
422
|
if (world.mounted.drivesOwnLoop) {
|
|
428
423
|
reasons.push(
|
|
429
424
|
`world "${world.id}" drives its own loop — cannot be frame-gated for deterministic ` +
|
|
430
|
-
'capture (
|
|
431
|
-
'
|
|
425
|
+
'capture (the policy is to FAIL LOUDLY, or be excluded only by an explicit declared ' +
|
|
426
|
+
'policy).',
|
|
432
427
|
);
|
|
433
428
|
continue;
|
|
434
429
|
}
|
|
@@ -442,11 +437,11 @@ function computeWorldExclusionReasons(game: Game): string[] {
|
|
|
442
437
|
return reasons;
|
|
443
438
|
}
|
|
444
439
|
|
|
445
|
-
/** Structural scene-graph walk shared by
|
|
446
|
-
* (`Container`) roots — both expose a `children` array, and
|
|
440
|
+
/** Structural scene-graph walk shared by three (`Object3D`) and canvas
|
|
441
|
+
* (`Container`) roots — both expose a `children` array, and three nodes
|
|
447
442
|
* additionally carry `userData` (where the loader/editor entity tag lives).
|
|
448
443
|
* Deliberately duck-typed so this file keeps its type-only three/pixi rule. */
|
|
449
|
-
function
|
|
444
|
+
function countRootGraph(root: { readonly children?: readonly unknown[] }): {
|
|
450
445
|
nodes: number;
|
|
451
446
|
entities: number;
|
|
452
447
|
} {
|
|
@@ -465,16 +460,16 @@ function countWorldGraph(root: { readonly children?: readonly unknown[] }): {
|
|
|
465
460
|
return { nodes, entities };
|
|
466
461
|
}
|
|
467
462
|
|
|
468
|
-
/** See {@link PerfSampleReport.worlds} —
|
|
463
|
+
/** See {@link PerfSampleReport.worlds} — three/canvas worlds only; a
|
|
469
464
|
* react/DOM world has no scene-graph node count and is omitted, never
|
|
470
465
|
* fabricated. */
|
|
471
|
-
function
|
|
472
|
-
const counts:
|
|
466
|
+
function countRoots(game: Game): PerfRootCount[] {
|
|
467
|
+
const counts: PerfRootCount[] = [];
|
|
473
468
|
for (const world of game.roots) {
|
|
474
|
-
if (world.kind === '
|
|
475
|
-
counts.push({ id: world.id, kind: world.kind, ...
|
|
476
|
-
} else if (world.kind === '
|
|
477
|
-
counts.push({ id: world.id, kind: world.kind, ...
|
|
469
|
+
if (world.kind === 'three') {
|
|
470
|
+
counts.push({ id: world.id, kind: world.kind, ...countRootGraph(world.threeScene()) });
|
|
471
|
+
} else if (world.kind === 'canvas') {
|
|
472
|
+
counts.push({ id: world.id, kind: world.kind, ...countRootGraph(world.pixiStage()) });
|
|
478
473
|
}
|
|
479
474
|
}
|
|
480
475
|
return counts;
|
|
@@ -581,7 +576,7 @@ export function installRenderControlHarness(
|
|
|
581
576
|
}
|
|
582
577
|
},
|
|
583
578
|
excludedFromDeterminism(): string[] {
|
|
584
|
-
return [...declaredExclusions, ...
|
|
579
|
+
return [...declaredExclusions, ...computeRootExclusionReasons(game)];
|
|
585
580
|
},
|
|
586
581
|
simulateFixedDt(): number {
|
|
587
582
|
return simulateFixedDt;
|
|
@@ -606,7 +601,7 @@ export function installRenderControlHarness(
|
|
|
606
601
|
} finally {
|
|
607
602
|
profiler.enabled = wasEnabled;
|
|
608
603
|
}
|
|
609
|
-
return { frames, worlds:
|
|
604
|
+
return { frames, worlds: countRoots(game) };
|
|
610
605
|
},
|
|
611
606
|
};
|
|
612
607
|
|
|
@@ -617,10 +612,9 @@ export function installRenderControlHarness(
|
|
|
617
612
|
/**
|
|
618
613
|
* Defensive assertion for the I2 AC "Pixi roots are pinned to the WebGL
|
|
619
614
|
* renderer (not WebGPU) in render mode". In THIS engine that invariant
|
|
620
|
-
* already holds unconditionally —
|
|
621
|
-
* `
|
|
622
|
-
* `
|
|
623
|
-
* constructs a `WebGPURenderer` for threejs roots either — so there is no
|
|
615
|
+
* already holds unconditionally — no in-repo mount constructs a Pixi
|
|
616
|
+
* `Application` with a WebGPU preference, and `setup/setup-renderer.ts` never
|
|
617
|
+
* constructs a `WebGPURenderer` for three roots either — so there is no
|
|
624
618
|
* live WebGPU code path for a first-party render-mode page to disable.
|
|
625
619
|
* This helper exists for a render-mode installer that mounts a CUSTOM/
|
|
626
620
|
* foreign pixi adapter (`create-runtime.ts`'s `{ module }` pixi path) this
|
|
@@ -631,13 +625,13 @@ export function installRenderControlHarness(
|
|
|
631
625
|
* type-only-pixi rule `runtime/game.ts`/`create-runtime.ts` document for
|
|
632
626
|
* themselves).
|
|
633
627
|
*/
|
|
634
|
-
export function
|
|
628
|
+
export function assertPixiRootIsWebGL(rendererType: unknown, worldId: string): void {
|
|
635
629
|
const isWebGPU =
|
|
636
630
|
rendererType === 2 || (typeof rendererType === 'string' && /webgpu/i.test(rendererType));
|
|
637
631
|
if (isWebGPU) {
|
|
638
632
|
throw new Error(
|
|
639
633
|
`[render-control] world "${worldId}": Pixi renderer is WebGPU in render mode — ` +
|
|
640
|
-
'render-control requires WebGL
|
|
634
|
+
'render-control requires WebGL.',
|
|
641
635
|
);
|
|
642
636
|
}
|
|
643
637
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic `Math.random` seeding for render-control mode (I2 AC
|
|
3
|
-
* `
|
|
4
|
-
*
|
|
5
|
-
* executes").
|
|
2
|
+
* Deterministic `Math.random` seeding for render-control mode (I2 AC:
|
|
3
|
+
* "`Math.random` is deterministically seeded/stubbed page-wide before any
|
|
4
|
+
* game module executes").
|
|
6
5
|
*
|
|
7
6
|
* Deliberately a ZERO-IMPORT module (no `three`, no other engine file) so
|
|
8
7
|
* its own evaluation is instantaneous — a render-mode entry page loads this
|
|
@@ -1,36 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Frame-versioned game state bridge (T7.4 slice 1
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* world's post-step state is readable" guarantee, extended to "after the
|
|
2
|
+
* Frame-versioned game state bridge (T7.4 slice 1, the D7 remainder). Components
|
|
3
|
+
* mutate state on `this` freely during ticks — there is no proxy, no dirty
|
|
4
|
+
* tracking, no event per mutation (Track A: no mirror, no sync layer). So the
|
|
5
|
+
* unit of change a subscriber can observe is the FRAME: `GameInternal.runFrame`
|
|
6
|
+
* bumps `frameVersion` and notifies subscribers at most once, at its tail, AFTER
|
|
7
|
+
* every phase of every world and every world's `endFrame` hook has run (see the
|
|
8
|
+
* wiring in `runtime/game.ts`) — mirroring the D7 "from `gameLogic` onward,
|
|
9
|
+
* every world's post-step state is readable" guarantee, extended to "after the
|
|
11
10
|
* frame, ALL state is readable".
|
|
12
11
|
*
|
|
13
12
|
* This file is plain, react-free TypeScript any consumer could use — no
|
|
14
13
|
* react import here, or anywhere under `runtime/` (the react-facing
|
|
15
|
-
* `
|
|
16
|
-
* `packages/engine/src/react/
|
|
14
|
+
* `useWorldState` hook, which DOES import React, lives at
|
|
15
|
+
* `packages/engine/src/react/world-state.tsx`).
|
|
17
16
|
*
|
|
18
|
-
* SECOND trigger for `bump()` (`Game.
|
|
19
|
-
* `roots` list is also state a `
|
|
17
|
+
* SECOND trigger for `bump()` (`Game.registerRoot`, `runtime/game.ts`): the
|
|
18
|
+
* `roots` list is also state a `useWorldState` selector can read
|
|
20
19
|
* (`g.roots.map(...)`, the T6.2 two-world-compose gate), and it changes
|
|
21
|
-
* OUTSIDE the frame loop — `
|
|
20
|
+
* OUTSIDE the frame loop — `registerRoot` is called as each world finishes
|
|
22
21
|
* mounting, which can happen well before the host's loop has ticked even
|
|
23
22
|
* once. Without an immediate notification here, a selector that first reads
|
|
24
23
|
* `g.roots` before every world has registered stays stuck on that stale
|
|
25
24
|
* snapshot until the next completed frame — which, on a slow/contended host
|
|
26
|
-
* or a backgrounded tab, may be arbitrarily delayed. See `
|
|
25
|
+
* or a backgrounded tab, may be arbitrarily delayed. See `registerRoot`'s
|
|
27
26
|
* own doc comment for the concrete CI failure this closes.
|
|
28
27
|
*/
|
|
29
28
|
|
|
30
|
-
/** Frame-versioned, react-free subscription surface
|
|
29
|
+
/** Frame-versioned, react-free subscription surface. */
|
|
31
30
|
export interface GameStateBridge {
|
|
32
31
|
/** Monotonic; bumped once per completed `runFrame`, AND once per
|
|
33
|
-
* `Game.
|
|
32
|
+
* `Game.registerRoot` call (world-list changes are observable state too
|
|
34
33
|
* — see this file's module doc comment). Starts at 0 before the first of
|
|
35
34
|
* either has happened. Never treat this as a literal frame COUNT — only
|
|
36
35
|
* "did it change" is a contract any consumer may rely on. */
|
|
@@ -41,7 +40,7 @@ export interface GameStateBridge {
|
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
/** Host-internal extension of {@link GameStateBridge}: adds `bump`, called
|
|
44
|
-
* both by `GameInternal.runFrame` at its tail AND by `Game.
|
|
43
|
+
* both by `GameInternal.runFrame` at its tail AND by `Game.registerRoot`
|
|
45
44
|
* when the world list changes (see this file's module doc comment). NOT
|
|
46
45
|
* part of the game-facing `Game.state` surface (typed as the narrower
|
|
47
46
|
* `GameStateBridge` there) — games/components/hooks never call `bump`
|