@vgai/engine 0.2.0
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/LICENSE +202 -0
- package/README.md +35 -0
- package/package.json +55 -0
- package/src/adapter/authoring.ts +402 -0
- package/src/adapter/colyseus-networking-adapter.ts +72 -0
- package/src/adapter/first-party-systems.ts +103 -0
- package/src/adapter/game-adapter.ts +151 -0
- package/src/adapter/host-context.ts +77 -0
- package/src/adapter/index.ts +85 -0
- package/src/adapter/ingest/game-contract.ts +59 -0
- package/src/adapter/ingest/overlay-applier.ts +207 -0
- package/src/adapter/ingest/overlay-apply.ts +124 -0
- package/src/adapter/ingest/overlay-file.ts +126 -0
- package/src/adapter/ingest/overlay-report.ts +176 -0
- package/src/adapter/ingest/scene-capture.ts +307 -0
- package/src/adapter/ingest/upstream-pin.ts +52 -0
- package/src/adapter/loop-gate-report.ts +54 -0
- package/src/adapter/rapier-physics-adapter.ts +56 -0
- package/src/adapter/system-adapter.ts +154 -0
- package/src/adapter/transform.ts +18 -0
- package/src/adapter/vgai-scene-game-adapter.ts +886 -0
- package/src/adapter/world-kind.ts +34 -0
- package/src/ai/navigation.ts +164 -0
- package/src/animation/anim-graph-types.ts +56 -0
- package/src/animation/anim-graph.ts +406 -0
- package/src/animation/anim-system.ts +28 -0
- package/src/animation/blend-node.ts +119 -0
- package/src/animation/property-track.ts +178 -0
- package/src/animation/schema.ts +204 -0
- package/src/assets.ts +80 -0
- package/src/audio/ambient.ts +300 -0
- package/src/audio/impacts.ts +212 -0
- package/src/audio/index.ts +7 -0
- package/src/audio/movement.ts +140 -0
- package/src/audio/musical.ts +200 -0
- package/src/audio/ui-sounds.ts +171 -0
- package/src/audio/vehicle.ts +235 -0
- package/src/audio/weapons.ts +152 -0
- package/src/core/game-loop.ts +127 -0
- package/src/core/system-runner.ts +298 -0
- package/src/core/types.ts +58 -0
- package/src/dev/console-bridge.ts +83 -0
- package/src/dev/debug-draw.ts +80 -0
- package/src/dev/logger.ts +119 -0
- package/src/ecs/component-manager.ts +748 -0
- package/src/ecs/game-component.ts +147 -0
- package/src/ecs/hmr-swap-report.ts +65 -0
- package/src/input/input-manager.ts +439 -0
- package/src/input/input-types.ts +19 -0
- package/src/input/schema.ts +129 -0
- package/src/loader.ts +70 -0
- package/src/manifest/index.ts +24 -0
- package/src/manifest/load-file.ts +16 -0
- package/src/manifest/load.ts +378 -0
- package/src/manifest/schema.ts +375 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/physics/trigger-dispatch.ts +97 -0
- package/src/react/game-state.tsx +172 -0
- package/src/render/auto-batcher.ts +169 -0
- package/src/render/render-batch-system.ts +268 -0
- package/src/render/render-features.ts +146 -0
- package/src/render/render-settings.ts +72 -0
- package/src/runtime/create-runtime.ts +1152 -0
- package/src/runtime/frame-selector-cache.ts +81 -0
- package/src/runtime/game.ts +1003 -0
- package/src/runtime/input-router.ts +213 -0
- package/src/runtime/mount-game.ts +269 -0
- package/src/runtime/mount-manifest.ts +361 -0
- package/src/runtime/scene-ui-bridge.ts +86 -0
- package/src/runtime/scene-ui-data.ts +119 -0
- package/src/runtime/state-bridge.ts +79 -0
- package/src/runtime/types.ts +196 -0
- package/src/scene/asset-loaders.ts +195 -0
- package/src/scene/asset-paths.ts +123 -0
- package/src/scene/asset-registry.ts +67 -0
- package/src/scene/collider-dimensions.ts +125 -0
- package/src/scene/component-registry.ts +40 -0
- package/src/scene/defaults.ts +164 -0
- package/src/scene/geometries/index.ts +7 -0
- package/src/scene/geometries/terrain.ts +42 -0
- package/src/scene/geometry-registry.ts +42 -0
- package/src/scene/instance-registry.ts +84 -0
- package/src/scene/instancers/grid.ts +38 -0
- package/src/scene/instancers/index.ts +7 -0
- package/src/scene/light-camera-factory.ts +97 -0
- package/src/scene/material-factory.ts +211 -0
- package/src/scene/material-registry.ts +73 -0
- package/src/scene/materials/index.ts +7 -0
- package/src/scene/materials/water.ts +56 -0
- package/src/scene/parse.ts +71 -0
- package/src/scene/particles-factory.ts +383 -0
- package/src/scene/scene-apply.ts +356 -0
- package/src/scene/scene-diff-schema.ts +115 -0
- package/src/scene/scene-diff-types.ts +29 -0
- package/src/scene/scene-loader.ts +1533 -0
- package/src/scene/scene-query.ts +63 -0
- package/src/scene/scene-types.ts +34 -0
- package/src/scene/scene-version.ts +40 -0
- package/src/scene/schema/animation.ts +95 -0
- package/src/scene/schema/audio.ts +25 -0
- package/src/scene/schema/camera.ts +21 -0
- package/src/scene/schema/collider.ts +69 -0
- package/src/scene/schema/entity-ref.ts +78 -0
- package/src/scene/schema/entity.ts +169 -0
- package/src/scene/schema/environment.ts +384 -0
- package/src/scene/schema/index.ts +95 -0
- package/src/scene/schema/instances.ts +35 -0
- package/src/scene/schema/joint.ts +26 -0
- package/src/scene/schema/light.ts +38 -0
- package/src/scene/schema/material.ts +113 -0
- package/src/scene/schema/mesh.ts +108 -0
- package/src/scene/schema/particles.ts +398 -0
- package/src/scene/schema/physics.ts +49 -0
- package/src/scene/schema/scene-file.ts +299 -0
- package/src/scene/schema/shadow.ts +24 -0
- package/src/scene/schema/spline.ts +21 -0
- package/src/scene/schema/tuples.ts +21 -0
- package/src/scene/schema/ui.ts +602 -0
- package/src/scene/user-data.ts +203 -0
- package/src/setup/setup-audio.ts +60 -0
- package/src/setup/setup-particles.ts +23 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +529 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/src/world2d/authoring-2d.ts +208 -0
- package/src/world2d/capture-to-scene2d.ts +52 -0
- package/src/world2d/collision-2d.ts +106 -0
- package/src/world2d/components-2d.ts +86 -0
- package/src/world2d/index.ts +66 -0
- package/src/world2d/ingest-iframe-2d.ts +255 -0
- package/src/world2d/ingest2d.ts +131 -0
- package/src/world2d/physics2d-registry.ts +49 -0
- package/src/world2d/pixi-game-adapter.ts +325 -0
- package/src/world2d/pixi-surface.ts +78 -0
- package/src/world2d/scene-capture-2d.ts +117 -0
- package/src/world2d/scene2d-loader.ts +308 -0
- package/src/world2d/schema/entity2d.ts +145 -0
- package/src/world2d/schema/physics2d.ts +53 -0
- package/src/world2d/schema/sprite.ts +71 -0
- package/src/world2d/schema/tilemap.ts +22 -0
- package/src/world2d/schema/tuples2d.ts +25 -0
- package/src/world2d/system-adapters-2d.ts +49 -0
- package/src/world2d/transform-writer-2d.ts +24 -0
- package/src/world2d/types.ts +55 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// D-Z2 (docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md) — the standalone manifest-mount
|
|
2
|
+
// helper: an ENGINE-side generalization of `examples/tri-world/src/main.ts`'s
|
|
3
|
+
// hand-built `WorldMountSpec[]`.
|
|
4
|
+
//
|
|
5
|
+
// `resolveAllWorlds`/`resolveWorldAdapter` (`packages/editor/src/
|
|
6
|
+
// adapter-resolver.ts`) already do this translation for the EDITOR, but they
|
|
7
|
+
// are Vite-coupled by construction (`/@fs/` dynamic imports of project files
|
|
8
|
+
// through the dev server, §0.1/§0.5 of the design doc) — unusable from a
|
|
9
|
+
// plain static build. This module mirrors their per-kind dispatch
|
|
10
|
+
// (`default-three`/`default-pixi`/`default-react`, `{ module }`, `{ ingest }`)
|
|
11
|
+
// but never imports a project file itself: the caller's OWN bundler already
|
|
12
|
+
// resolved whatever module graph the manifest's worlds need, and hands the
|
|
13
|
+
// already-built pieces in via `entries` (keyed by world id) — no `/@fs/`, no
|
|
14
|
+
// dev server, no editor import.
|
|
15
|
+
//
|
|
16
|
+
// Engine-core react/pixi-free discipline (§0.6, mirrored from
|
|
17
|
+
// `create-runtime.ts`'s own documented rule for its `Pixi2DGameAdapter`/
|
|
18
|
+
// `ReactWorldAdapter` type-only imports): this file never value-imports
|
|
19
|
+
// `pixi.js`, `world2d/pixi-game-adapter.ts`, `react`, or `react-dom` — a
|
|
20
|
+
// pixijs or react world's adapter is ALWAYS supplied already-constructed via
|
|
21
|
+
// `entries[id].adapter` (the caller's own module graph built it, exactly like
|
|
22
|
+
// `tri-world`'s original hand-mount built `new PixiSceneGameAdapter(...)` and
|
|
23
|
+
// a hand-rolled `ReactWorldAdapter` itself). Threejs is different: `three`/
|
|
24
|
+
// `VgaiSceneGameAdapter` are already unconditional dependencies of every
|
|
25
|
+
// caller of `createGameRuntime`'s legacy path (this same file's sibling,
|
|
26
|
+
// immediately below in this directory), so building a `VgaiSceneGameAdapter`
|
|
27
|
+
// here for the common scene-driven/`setup`-driven cases adds no NEW bundle
|
|
28
|
+
// weight — only pixi/react are avoided.
|
|
29
|
+
|
|
30
|
+
import { fromSetup, VgaiSceneGameAdapter } from '../adapter';
|
|
31
|
+
import type { GameAdapter } from '../adapter/game-adapter';
|
|
32
|
+
import { assertNever } from '../adapter/world-kind';
|
|
33
|
+
import {
|
|
34
|
+
loadGameManifest,
|
|
35
|
+
type ResolvedGameManifest,
|
|
36
|
+
type ResolvedWorldEntry,
|
|
37
|
+
} from '../manifest/load';
|
|
38
|
+
import type { ComponentRegistry } from '../scene/component-registry';
|
|
39
|
+
import {
|
|
40
|
+
createGameRuntime,
|
|
41
|
+
type GameSession,
|
|
42
|
+
type Pixi2DGameAdapter,
|
|
43
|
+
type ReactWorldAdapter,
|
|
44
|
+
type WorldMountSpec,
|
|
45
|
+
} from './create-runtime';
|
|
46
|
+
import type { GameSetupFn } from './types';
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// The `entries` contract
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A caller-supplied entry for a `kind: 'threejs'` world — three ways to
|
|
54
|
+
* satisfy it, matching exactly what `tri-world`'s manifest actually declares
|
|
55
|
+
* (a scene-driven world with a project component registry) plus the two
|
|
56
|
+
* other shapes `resolveDefaultThreeAdapter` supports (an `entry`-module
|
|
57
|
+
* `setup`, or full caller control via a pre-built adapter):
|
|
58
|
+
*
|
|
59
|
+
* - `{ adapter }` — a fully-constructed {@link GameAdapter} (first-party or
|
|
60
|
+
* not). Always wins if present, regardless of the world's `scene`/`entry`
|
|
61
|
+
* fields — full caller control, including for a `{ module }`-adapter world
|
|
62
|
+
* (mountManifestWorlds cannot import an arbitrary module path itself).
|
|
63
|
+
* - `{ setup }` — for an `entry`-declaring world: the project's own
|
|
64
|
+
* `entry` module's exported `setup` function (the same `GameSetupFn`
|
|
65
|
+
* shape `loadProjectScripts` extracts editor-side). Wrapped in a
|
|
66
|
+
* `VgaiSceneGameAdapter` here, mirroring `resolveDefaultThreeAdapter`'s
|
|
67
|
+
* entry branch exactly.
|
|
68
|
+
* - `{ componentRegistry }` (or nothing at all) — for a `scene`-declaring
|
|
69
|
+
* world: mirrors `resolveDefaultThreeAdapter`'s scene branch, fetching
|
|
70
|
+
* `world.scene` itself via `VgaiSceneGameAdapter`'s own `scenePath`
|
|
71
|
+
* support. `componentRegistry` is optional — omit it if the scene
|
|
72
|
+
* authors no named `components:` entries.
|
|
73
|
+
*/
|
|
74
|
+
export interface ThreeMountEntry {
|
|
75
|
+
readonly kind: 'threejs';
|
|
76
|
+
readonly adapter?: GameAdapter | undefined;
|
|
77
|
+
readonly setup?: GameSetupFn | undefined;
|
|
78
|
+
readonly componentRegistry?: ComponentRegistry | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A caller-supplied entry for a `kind: 'pixijs'` world — ALWAYS a
|
|
83
|
+
* fully-constructed {@link Pixi2DGameAdapter} (e.g. `new
|
|
84
|
+
* PixiSceneGameAdapter({ id, sceneData })`, built exactly like `tri-world`'s
|
|
85
|
+
* original hand-mount built it — fetch+parse the `.scn2d.json` yourself,
|
|
86
|
+
* mirroring `resolveDefaultPixiAdapter`'s `fetchScene2DFile` helper, if the
|
|
87
|
+
* world declares `scene`). There is no "mountManifestWorlds builds it for
|
|
88
|
+
* you" branch here — doing so would require this file to value-import
|
|
89
|
+
* `pixi.js`, which it deliberately never does (see this file's header
|
|
90
|
+
* comment).
|
|
91
|
+
*/
|
|
92
|
+
export interface PixiMountEntry {
|
|
93
|
+
readonly kind: 'pixijs';
|
|
94
|
+
readonly adapter: Pixi2DGameAdapter;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A caller-supplied entry for a `kind: 'react'` world — ALWAYS a
|
|
99
|
+
* fully-constructed {@link ReactWorldAdapter} (the caller's own module graph
|
|
100
|
+
* calls `createRoot(host.container).render(<GameProvider game={host.game}>
|
|
101
|
+
* <Entry/></GameProvider>)` itself, exactly like `tri-world`'s original
|
|
102
|
+
* hand-mount did). A standalone build has ONE module graph, so there is no
|
|
103
|
+
* context-identity hazard the editor's per-project `GameProvider`
|
|
104
|
+
* indirection exists to solve (`docs/REACT-STATE-BRIDGE.md` §2) — the
|
|
105
|
+
* caller's own `<GameProvider>` import is already the single canonical
|
|
106
|
+
* instance its `Entry` component's own hooks resolve against. This file
|
|
107
|
+
* never value-imports `react`/`react-dom` itself (see this file's header
|
|
108
|
+
* comment) — building the adapter is entirely the caller's job.
|
|
109
|
+
*/
|
|
110
|
+
export interface ReactMountEntry {
|
|
111
|
+
readonly kind: 'react';
|
|
112
|
+
readonly adapter: ReactWorldAdapter;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type MountEntry = ThreeMountEntry | PixiMountEntry | ReactMountEntry;
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Options / return shape
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export interface MountManifestOptions {
|
|
122
|
+
/**
|
|
123
|
+
* A raw (unparsed) manifest value OR an already-`loadGameManifest`d
|
|
124
|
+
* {@link ResolvedGameManifest}. `mountManifestWorlds` NEVER fetches or
|
|
125
|
+
* fs-reads the manifest itself (stays environment-neutral — Node CLI,
|
|
126
|
+
* browser, or headless test alike): the caller owns getting the JSON off
|
|
127
|
+
* disk/network (`loadGameManifestFile` for Node, a plain `fetch()` for a
|
|
128
|
+
* browser build) and may pass either the raw parsed JSON (run through the
|
|
129
|
+
* pure `loadGameManifest` here) or its own already-resolved manifest.
|
|
130
|
+
*/
|
|
131
|
+
readonly manifest: unknown;
|
|
132
|
+
/** The host creates one absolutely-positioned surface per world inside
|
|
133
|
+
* this element — see `WorldsRuntimeConfig.container`. */
|
|
134
|
+
readonly container: HTMLElement;
|
|
135
|
+
/** Caller-supplied, already-imported entries keyed by manifest world id.
|
|
136
|
+
* Optional overall — a manifest whose every world is scene-driven
|
|
137
|
+
* threejs with no custom component registry needs none at all. */
|
|
138
|
+
readonly entries?: Readonly<Record<string, MountEntry>> | undefined;
|
|
139
|
+
/** Defaults to the manifest's own `resolution` field if declared, else
|
|
140
|
+
* the container's own size (`WorldsRuntimeConfig`'s existing default). */
|
|
141
|
+
readonly width?: number | undefined;
|
|
142
|
+
readonly height?: number | undefined;
|
|
143
|
+
/** Forwarded to `createGameRuntime` — Node/headless test harnesses only,
|
|
144
|
+
* never a real host. See `WorldsRuntimeConfig.headless`. */
|
|
145
|
+
readonly headless?: boolean | undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// `mountManifestWorlds` returns the SAME `GameSession` shape
|
|
149
|
+
// `createGameRuntime` returns (D-Z2: "the same session shape... or a thin
|
|
150
|
+
// superset") — callers keep exactly one teardown path (`session.stop()`)
|
|
151
|
+
// whether they mounted through `createGameRuntime` directly or through this
|
|
152
|
+
// helper.
|
|
153
|
+
export type MountedManifestSession = GameSession;
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Raw-vs-resolved manifest detection
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Distinguish an already-`loadGameManifest`d {@link ResolvedGameManifest}
|
|
161
|
+
* from a raw (pre-Zod-parse) manifest value. The two shapes differ in
|
|
162
|
+
* exactly one load-bearing way for this check: a `ResolvedWorldEntry.adapter`
|
|
163
|
+
* is `{ type: 'default' | 'module' | 'ingest', identity, ... }`, while a raw
|
|
164
|
+
* `WorldEntry.adapter` is the literal string `'default'` or a bare `{module}`/
|
|
165
|
+
* `{ingest}` object with NO `type`/`identity` fields (`schema.ts`'s
|
|
166
|
+
* `WorldAdapterSchema`). An empty `worlds` array is treated as "not resolved"
|
|
167
|
+
* (falls through to `loadGameManifest`, whose own Zod validation reports the
|
|
168
|
+
* empty-array error, if any, more precisely than a guess here could).
|
|
169
|
+
*/
|
|
170
|
+
function looksAlreadyResolved(value: unknown): value is ResolvedGameManifest {
|
|
171
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
172
|
+
const worlds = (value as { worlds?: unknown }).worlds;
|
|
173
|
+
if (!Array.isArray(worlds) || worlds.length === 0) return false;
|
|
174
|
+
return worlds.every((world) => {
|
|
175
|
+
if (typeof world !== 'object' || world === null) return false;
|
|
176
|
+
const adapter = (world as { adapter?: unknown }).adapter;
|
|
177
|
+
if (typeof adapter !== 'object' || adapter === null) return false;
|
|
178
|
+
const type = (adapter as { type?: unknown }).type;
|
|
179
|
+
return type === 'default' || type === 'module' || type === 'ingest';
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Exported (E4, docs/unified-world-editor/27-visual-react-editing.md §7) so
|
|
185
|
+
* `mount-game.ts`'s `mountGameFromManifest` composer can share this exact
|
|
186
|
+
* raw-vs-resolved detection instead of re-implementing it — the two modules
|
|
187
|
+
* must always agree on what "already resolved" means.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveManifest(raw: unknown): ResolvedGameManifest {
|
|
190
|
+
if (looksAlreadyResolved(raw)) return raw;
|
|
191
|
+
return loadGameManifest(raw);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Per-kind entry -> adapter resolution (mirrors `resolveWorldAdapter`'s
|
|
196
|
+
// per-identity dispatch, `packages/editor/src/adapter-resolver.ts`)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function resolveThreeAdapter(
|
|
200
|
+
world: ResolvedWorldEntry,
|
|
201
|
+
entry: ThreeMountEntry | undefined,
|
|
202
|
+
): GameAdapter {
|
|
203
|
+
if (entry?.adapter) return entry.adapter;
|
|
204
|
+
|
|
205
|
+
if (world.adapter.type === 'module') {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`mountManifestWorlds: world "${world.id}" (threejs) declares a { module } adapter ` +
|
|
208
|
+
`("${world.adapter.module}") — mountManifestWorlds never imports an arbitrary module ` +
|
|
209
|
+
'path itself (no `/@fs/`, no dev server). Import the module yourself and supply the ' +
|
|
210
|
+
`constructed adapter via entries["${world.id}"] = { kind: 'threejs', adapter }.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (world.adapter.type === 'ingest') {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`mountManifestWorlds: world "${world.id}" (threejs) declares an { ingest } adapter — ` +
|
|
216
|
+
"ingest worlds require the editor's dev-server-backed mount machinery (an EditorStore " +
|
|
217
|
+
"plus iframe/DOM capture, see adapter-resolver.ts's resolveIngestThreeAdapter) and are " +
|
|
218
|
+
'not supported by mountManifestWorlds (docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md D-Z7 — no ' +
|
|
219
|
+
'porting aids, no hosted ingest routes).',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// type === 'default'
|
|
224
|
+
if (world.entry !== undefined) {
|
|
225
|
+
if (!entry?.setup) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`mountManifestWorlds: world "${world.id}" (threejs) declares \`entry\` "${world.entry}" ` +
|
|
228
|
+
`— supply entries["${world.id}"] = { kind: 'threejs', setup } (the entry module's ` +
|
|
229
|
+
"exported `setup` function) or { kind: 'threejs', adapter } for full control. Got " +
|
|
230
|
+
`${entry === undefined ? 'no entry at all' : 'an entry with neither `setup` nor `adapter`'}.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return fromSetup(world.id, entry.setup);
|
|
234
|
+
}
|
|
235
|
+
if (world.scene !== undefined) {
|
|
236
|
+
return new VgaiSceneGameAdapter({
|
|
237
|
+
id: world.id,
|
|
238
|
+
scenePath: world.scene,
|
|
239
|
+
componentRegistry: entry?.componentRegistry,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// Unreachable in practice: `load.ts`'s `checkAdapterSceneEntryRules`
|
|
243
|
+
// requires `scene` or `entry` for every 'default' adapter world at
|
|
244
|
+
// manifest-load time — see `resolveDefaultThreeAdapter`'s identical guard.
|
|
245
|
+
throw new Error(
|
|
246
|
+
`mountManifestWorlds: world "${world.id}" is default-three with neither \`scene\` nor ` +
|
|
247
|
+
'`entry` — this should have been rejected by manifest validation.',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function resolvePixiAdapter(
|
|
252
|
+
world: ResolvedWorldEntry,
|
|
253
|
+
entry: PixiMountEntry | undefined,
|
|
254
|
+
): Pixi2DGameAdapter {
|
|
255
|
+
if (entry?.adapter) return entry.adapter;
|
|
256
|
+
throw new Error(
|
|
257
|
+
`mountManifestWorlds: world "${world.id}" (pixijs) has no entries["${world.id}"] — a pixijs ` +
|
|
258
|
+
'world always needs a caller-supplied, already-constructed adapter ' +
|
|
259
|
+
`(entries["${world.id}"] = { kind: 'pixijs', adapter }, e.g. \`new PixiSceneGameAdapter(...)\` ` +
|
|
260
|
+
"built from your own already-imported '@engine/world2d') — mountManifestWorlds never " +
|
|
261
|
+
"value-imports pixi.js (mirrors create-runtime.ts's own pixi-free-core discipline).",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveReactAdapter(
|
|
266
|
+
world: ResolvedWorldEntry,
|
|
267
|
+
entry: ReactMountEntry | undefined,
|
|
268
|
+
): ReactWorldAdapter {
|
|
269
|
+
if (entry?.adapter) return entry.adapter;
|
|
270
|
+
throw new Error(
|
|
271
|
+
`mountManifestWorlds: world "${world.id}" (react) has no entries["${world.id}"] — a react ` +
|
|
272
|
+
'world always needs a caller-supplied, already-constructed `ReactWorldAdapter` ' +
|
|
273
|
+
`(entries["${world.id}"] = { kind: 'react', adapter }, mounting via your own already-imported ` +
|
|
274
|
+
'react-dom `createRoot` + your own `<GameProvider>`) — mountManifestWorlds never ' +
|
|
275
|
+
"value-imports react/react-dom (mirrors create-runtime.ts's own react-free-core discipline; " +
|
|
276
|
+
'see docs/REACT-STATE-BRIDGE.md §2-3).',
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildWorldMountSpec(
|
|
281
|
+
world: ResolvedWorldEntry,
|
|
282
|
+
entry: MountEntry | undefined,
|
|
283
|
+
): WorldMountSpec {
|
|
284
|
+
if (entry !== undefined && entry.kind !== world.kind) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`mountManifestWorlds: entries["${world.id}"] declares kind "${entry.kind}" but the ` +
|
|
287
|
+
`manifest's world "${world.id}" is kind "${world.kind}" — fix the entries key (or the ` +
|
|
288
|
+
'manifest) so the two agree.',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const base = {
|
|
293
|
+
id: world.id,
|
|
294
|
+
zOrder: world.zOrder,
|
|
295
|
+
pausable: world.pausable,
|
|
296
|
+
loop: world.loop,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// `entry`'s `kind` is guaranteed to agree with `world.kind` past the guard
|
|
300
|
+
// above (or `entry` is `undefined`) — TS can't narrow a `Record` lookup
|
|
301
|
+
// through that runtime check, so each branch casts to its own entry shape;
|
|
302
|
+
// the actual safety comes from the mismatch guard, not from the cast.
|
|
303
|
+
if (world.kind === 'threejs') {
|
|
304
|
+
const threeEntry = entry as ThreeMountEntry | undefined;
|
|
305
|
+
return { ...base, kind: 'threejs', adapter: resolveThreeAdapter(world, threeEntry) };
|
|
306
|
+
}
|
|
307
|
+
if (world.kind === 'pixijs') {
|
|
308
|
+
const pixiEntry = entry as PixiMountEntry | undefined;
|
|
309
|
+
return { ...base, kind: 'pixijs', adapter: resolvePixiAdapter(world, pixiEntry) };
|
|
310
|
+
}
|
|
311
|
+
if (world.kind === 'react') {
|
|
312
|
+
const reactEntry = entry as ReactMountEntry | undefined;
|
|
313
|
+
return { ...base, kind: 'react', adapter: resolveReactAdapter(world, reactEntry) };
|
|
314
|
+
}
|
|
315
|
+
// Exhaustiveness guard (§7.4-2, same idiom as `resolveAllWorlds`'s own
|
|
316
|
+
// dispatch loop): `world.kind` is the closed `WorldEntry['kind']` union
|
|
317
|
+
// (`z.enum(['threejs','pixijs','react'])`), so a hypothetical 4th kind
|
|
318
|
+
// must fail to compile here, not silently fall through.
|
|
319
|
+
return assertNever(world.kind, 'mountManifestWorlds');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Entry point
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Mount every world declared by a `vgai.game.json` manifest onto `container`,
|
|
328
|
+
* standalone — no editor, no dev server. The generalization of
|
|
329
|
+
* `examples/tri-world/src/main.ts`'s hand-built `WorldMountSpec[]` (D-Z2,
|
|
330
|
+
* docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md). Reuses the pure `loadGameManifest`
|
|
331
|
+
* + `createGameRuntime({ worlds })` and mirrors `resolveAllWorlds`'s per-kind
|
|
332
|
+
* dispatch — but every entry the manifest needs beyond what a `default`-
|
|
333
|
+
* adapter scene/entry can express on its own comes from the caller's OWN
|
|
334
|
+
* already-imported module graph (`opts.entries`), never a dynamic import
|
|
335
|
+
* this file performs itself.
|
|
336
|
+
*
|
|
337
|
+
* Degrades loudly (docs/WAVE3-ADAPTER-PLUMBING-DESIGN.md D-Z2): a missing
|
|
338
|
+
* entry, a kind/shape mismatch, a `{ module }`/`{ ingest }` adapter with no
|
|
339
|
+
* caller-supplied adapter, or an empty manifest all throw a named `Error`
|
|
340
|
+
* identifying the world id, its kind, and what was expected — never a silent
|
|
341
|
+
* skip or a partially-mounted session.
|
|
342
|
+
*/
|
|
343
|
+
export async function mountManifestWorlds(opts: MountManifestOptions): Promise<GameSession> {
|
|
344
|
+
const manifest = resolveManifest(opts.manifest);
|
|
345
|
+
if (manifest.worlds.length === 0) {
|
|
346
|
+
throw new Error('mountManifestWorlds: manifest declares no worlds — nothing to mount.');
|
|
347
|
+
}
|
|
348
|
+
const entries = opts.entries ?? {};
|
|
349
|
+
|
|
350
|
+
const worlds: WorldMountSpec[] = manifest.worlds.map((world) =>
|
|
351
|
+
buildWorldMountSpec(world, entries[world.id]),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
return createGameRuntime({
|
|
355
|
+
container: opts.container,
|
|
356
|
+
worlds,
|
|
357
|
+
width: opts.width ?? manifest.resolution?.width,
|
|
358
|
+
height: opts.height ?? manifest.resolution?.height,
|
|
359
|
+
headless: opts.headless,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene-UI render bridge (B1, widened design/24-scene-ui.md D3) — the injection
|
|
3
|
+
* seam that keeps the engine React-free.
|
|
4
|
+
*
|
|
5
|
+
* The engine knows the scene carries a `ui` tree (data), but it does NOT know how to
|
|
6
|
+
* render React. An editor-side module registers a renderer here (a thin wrapper over
|
|
7
|
+
* the React `mountSceneUI`); the first-party scene adapter calls it at game-time to
|
|
8
|
+
* mount the scene's UI into `host.ui` and disposes it on teardown. Fully decoupled:
|
|
9
|
+
* the engine treats `sceneUI` and the handle as opaque. The optional third `services`
|
|
10
|
+
* argument carries game-sourced data/components/events/projector (D2-D7) — still
|
|
11
|
+
* opaque to the engine, still zero React import here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Structural match for `@vgai/scene-ui`'s `UIDataSource` — engine stays React-free
|
|
16
|
+
* (this interface is duck-typed against the real one, not imported from it).
|
|
17
|
+
*/
|
|
18
|
+
export interface SceneUIDataSourceLike {
|
|
19
|
+
get(path: string): unknown;
|
|
20
|
+
set(path: string, value: unknown): void;
|
|
21
|
+
subscribe(listener: () => void): () => void;
|
|
22
|
+
version(): number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Game-time services fed into a scene's UI mount (D2's "dynamic" layer). Every
|
|
27
|
+
* field is optional: a renderer falls back to its own registration-time registry
|
|
28
|
+
* (D2 merge rule) or built-in default when a field is absent. `components`/
|
|
29
|
+
* `transforms`/`strings` stay `unknown`-valued here — they're React things the
|
|
30
|
+
* scene-ui renderer casts to its real `UIComponentRegistry`/`TransformRegistry`/
|
|
31
|
+
* `StringTables`.
|
|
32
|
+
*/
|
|
33
|
+
export interface SceneUIGameServices {
|
|
34
|
+
/** Reactive game state the UI binds against (D4's `gameStateDataSource`, or a
|
|
35
|
+
* game's own bus). Absent -> the renderer falls back to an empty static store. */
|
|
36
|
+
data?: SceneUIDataSourceLike;
|
|
37
|
+
/** `kind:"component"` leaf implementations, keyed by the name authored in the scene. */
|
|
38
|
+
components?: Record<string, unknown>;
|
|
39
|
+
/** Named binding transforms (`$bind.transform`). */
|
|
40
|
+
transforms?: Record<string, unknown>;
|
|
41
|
+
/** Localized string tables, keyed by locale then textKey. */
|
|
42
|
+
strings?: Record<string, Record<string, string>>;
|
|
43
|
+
locale?: string;
|
|
44
|
+
/** Routes a `kind:"button"` (etc.) `events.onClick` handler id to the game.
|
|
45
|
+
* Absent -> a loud console.warn per event (D6 — no magic engine event bus). */
|
|
46
|
+
onEvent?: (handlerId: string, payload?: unknown) => void;
|
|
47
|
+
/** Projects an entity id to a screen-space point for world-tracked canvases
|
|
48
|
+
* (D5). Returns null when the entity is unknown or behind the camera. */
|
|
49
|
+
worldProjector?: (entityId: string) => { x: number; y: number } | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SceneUIHandle {
|
|
53
|
+
dispose(): void;
|
|
54
|
+
/** Re-render with new services without a remount (a late `setSceneUIServices`
|
|
55
|
+
* call after the initial mount, D7). Optional — a renderer that can't update
|
|
56
|
+
* in place may omit it; callers dispose+remount instead. */
|
|
57
|
+
update?(services: SceneUIGameServices): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Renderer signature — `sceneUI` is the scene's `ui` array (opaque to the engine).
|
|
62
|
+
* `services` is optional so existing 2-arg renderer functions keep working
|
|
63
|
+
* unchanged (back-compat, D3/D5 §5).
|
|
64
|
+
*/
|
|
65
|
+
export type SceneUIRenderer = (
|
|
66
|
+
container: HTMLElement,
|
|
67
|
+
sceneUI: unknown,
|
|
68
|
+
services?: SceneUIGameServices,
|
|
69
|
+
) => SceneUIHandle;
|
|
70
|
+
|
|
71
|
+
let _renderer: SceneUIRenderer | null = null;
|
|
72
|
+
|
|
73
|
+
/** Register the React renderer (called once by the editor/runtime host). */
|
|
74
|
+
export function setSceneUIRenderer(renderer: SceneUIRenderer | null): void {
|
|
75
|
+
_renderer = renderer;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Render a scene's UI into `container` if a renderer is registered, else no-op. */
|
|
79
|
+
export function renderSceneUI(
|
|
80
|
+
container: HTMLElement,
|
|
81
|
+
sceneUI: unknown,
|
|
82
|
+
services?: SceneUIGameServices,
|
|
83
|
+
): SceneUIHandle | null {
|
|
84
|
+
if (!_renderer || sceneUI == null) return null;
|
|
85
|
+
return _renderer(container, sceneUI, services);
|
|
86
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gameStateDataSource (design/24-scene-ui.md D4) — the default scene-UI data
|
|
3
|
+
* source: binds a scene's authored UI directly against live `GameComponent`
|
|
4
|
+
* instance fields, with zero game-side wiring.
|
|
5
|
+
*
|
|
6
|
+
* Path grammar: `"<ComponentName>.<field.path>"` — the first segment resolves
|
|
7
|
+
* through the SAME `ComponentRegistry` that resolves a scene's `components:`
|
|
8
|
+
* entries (name -> class) to `game.queryByComponent(Class)[0]`, the rest walks
|
|
9
|
+
* the live instance's own fields (components mutate `this` freely — the
|
|
10
|
+
* state-bridge contract, `runtime/state-bridge.ts`). A text node bound to
|
|
11
|
+
* `S1Session.wallet.money` just works.
|
|
12
|
+
*
|
|
13
|
+
* React-free, plain TS — `SceneUIDataSourceLike` is a structural (duck-typed)
|
|
14
|
+
* match for `@vgai/scene-ui`'s real `UIDataSource`, so the engine never
|
|
15
|
+
* imports the runtime that consumes this.
|
|
16
|
+
*/
|
|
17
|
+
import type { GameComponent, GameComponentClass } from '../ecs/game-component';
|
|
18
|
+
import type { ComponentRegistry } from '../scene/component-registry';
|
|
19
|
+
import type { Game } from './game';
|
|
20
|
+
import type { SceneUIDataSourceLike } from './scene-ui-bridge';
|
|
21
|
+
|
|
22
|
+
function splitRoot(path: string): [root: string, rest: string] {
|
|
23
|
+
const dot = path.indexOf('.');
|
|
24
|
+
return dot === -1 ? [path, ''] : [path.slice(0, dot), path.slice(dot + 1)];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readRest(obj: unknown, rest: string): unknown {
|
|
28
|
+
if (!rest) return obj;
|
|
29
|
+
let cur: unknown = obj;
|
|
30
|
+
for (const key of rest.split('.')) {
|
|
31
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
32
|
+
cur = (cur as Record<string, unknown>)[key];
|
|
33
|
+
}
|
|
34
|
+
return cur;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the default `SceneUIDataSourceLike` over a game's live components.
|
|
39
|
+
*
|
|
40
|
+
* `subscribe`/`version` are driven by `game.state` (bumps once per completed
|
|
41
|
+
* frame, after every world's phases — see `state-bridge.ts`) PLUS a local
|
|
42
|
+
* bump on `set()`, so a two-way control's own write echoes immediately
|
|
43
|
+
* instead of waiting for the next frame.
|
|
44
|
+
*/
|
|
45
|
+
export function gameStateDataSource(
|
|
46
|
+
game: Game,
|
|
47
|
+
registry: ComponentRegistry,
|
|
48
|
+
): SceneUIDataSourceLike {
|
|
49
|
+
let version = 0;
|
|
50
|
+
const listeners = new Set<() => void>();
|
|
51
|
+
// Warn-once-per-root-segment (D4/§9 review checklist): a scene author
|
|
52
|
+
// typo'ing a component name in a binding should be loud exactly once, not
|
|
53
|
+
// on every `get()` (a bound node re-reads its path every render).
|
|
54
|
+
const warnedRoots = new Set<string>();
|
|
55
|
+
|
|
56
|
+
const notify = (): void => {
|
|
57
|
+
for (const listener of listeners) listener();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Resolves a root segment to its live component instance, or undefined. */
|
|
61
|
+
const resolveInstance = (root: string): GameComponent | undefined => {
|
|
62
|
+
const Klass: GameComponentClass | undefined = registry[root];
|
|
63
|
+
if (!Klass) {
|
|
64
|
+
if (!warnedRoots.has(root)) {
|
|
65
|
+
warnedRoots.add(root);
|
|
66
|
+
console.warn(
|
|
67
|
+
`scene-ui: data binding root "${root}" is not a registered component name — ` +
|
|
68
|
+
'check the binding source against src/scripts/registry.ts.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
// Registered but zero live instances (e.g. not spawned yet this scene) is
|
|
74
|
+
// an ordinary, expected transient state — stays undefined, no warning.
|
|
75
|
+
return game.queryByComponent(Klass)[0];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ONE subscription to the frame-versioned state bridge for this data
|
|
79
|
+
// source's whole lifetime, fanning out to however many external
|
|
80
|
+
// subscribers this scene-UI mount has.
|
|
81
|
+
game.state.subscribe(() => {
|
|
82
|
+
version++;
|
|
83
|
+
notify();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
get(path: string): unknown {
|
|
88
|
+
const [root, rest] = splitRoot(path);
|
|
89
|
+
const inst = resolveInstance(root);
|
|
90
|
+
return inst === undefined ? undefined : readRest(inst, rest);
|
|
91
|
+
},
|
|
92
|
+
set(path: string, value: unknown): void {
|
|
93
|
+
const [root, rest] = splitRoot(path);
|
|
94
|
+
const inst = resolveInstance(root);
|
|
95
|
+
if (inst === undefined || !rest) return;
|
|
96
|
+
const keys = rest.split('.');
|
|
97
|
+
let cur: Record<string, unknown> = inst as unknown as Record<string, unknown>;
|
|
98
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
99
|
+
const key = keys[i] as string;
|
|
100
|
+
const next = cur[key];
|
|
101
|
+
if (next == null || typeof next !== 'object') return; // don't invent nested fields on a live instance
|
|
102
|
+
cur = next as Record<string, unknown>;
|
|
103
|
+
}
|
|
104
|
+
cur[keys[keys.length - 1] as string] = value;
|
|
105
|
+
// Two-way writes echo immediately, not on the next frame (D4).
|
|
106
|
+
version++;
|
|
107
|
+
notify();
|
|
108
|
+
},
|
|
109
|
+
subscribe(listener: () => void): () => void {
|
|
110
|
+
listeners.add(listener);
|
|
111
|
+
return () => {
|
|
112
|
+
listeners.delete(listener);
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
version(): number {
|
|
116
|
+
return version;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame-versioned game state bridge (T7.4 slice 1 — `docs/REACT-STATE-BRIDGE.md`
|
|
3
|
+
* §2, the D7 remainder). Components mutate state on `this` freely during
|
|
4
|
+
* ticks — there is no proxy, no dirty tracking, no event per mutation (Track
|
|
5
|
+
* A: no mirror, no sync layer). So the unit of change a subscriber can
|
|
6
|
+
* observe is the FRAME: `GameInternal.runFrame` bumps `frameVersion` and
|
|
7
|
+
* notifies subscribers at most once, at its tail, AFTER every phase of every
|
|
8
|
+
* world and every world's `endFrame` hook has run (see the wiring in
|
|
9
|
+
* `runtime/game.ts`) — mirroring the D7 "from `gameLogic` onward, every
|
|
10
|
+
* world's post-step state is readable" guarantee, extended to "after the
|
|
11
|
+
* frame, ALL state is readable".
|
|
12
|
+
*
|
|
13
|
+
* This file is plain, react-free TypeScript any consumer could use — no
|
|
14
|
+
* react import here, or anywhere under `runtime/` (the react-facing
|
|
15
|
+
* `useGameState` hook, which DOES import react, is deliberately colocated
|
|
16
|
+
* with the `mountUI` seam instead — see `packages/editor/template/src/
|
|
17
|
+
* ui/game-state.tsx`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Frame-versioned, react-free subscription surface — `docs/REACT-STATE-BRIDGE.md` §2. */
|
|
21
|
+
export interface GameStateBridge {
|
|
22
|
+
/** Monotonic; bumped once per completed `runFrame`. Starts at 0 before the
|
|
23
|
+
* first frame has ever completed. */
|
|
24
|
+
readonly frameVersion: number;
|
|
25
|
+
/** Notified at most once per frame, after ALL phases of ALL worlds (and
|
|
26
|
+
* their `endFrame` hooks) have run. Returns an unsubscribe function. */
|
|
27
|
+
subscribe(onFrame: () => void): () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Host-internal extension of {@link GameStateBridge}: adds `bump`, the
|
|
31
|
+
* method `GameInternal.runFrame` calls at its tail. NOT part of the
|
|
32
|
+
* game-facing `Game.state` surface (typed as the narrower
|
|
33
|
+
* `GameStateBridge` there) — games/components/hooks never call `bump`
|
|
34
|
+
* directly; only the frame executor does. */
|
|
35
|
+
export interface GameStateBridgeInternal extends GameStateBridge {
|
|
36
|
+
/** Bump `frameVersion` by one and notify every current subscriber, in
|
|
37
|
+
* subscription order. Subscriber errors are isolated (per-subscriber
|
|
38
|
+
* try/catch, loud `console.error`, never swallowed silently) — mirrors
|
|
39
|
+
* the error-isolation idiom in `ecs/component-manager.ts`'s per-component
|
|
40
|
+
* `update()` calls: one offending subscriber must not stop its siblings
|
|
41
|
+
* from being notified, and must not break the frame loop. */
|
|
42
|
+
bump(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Construct a fresh bridge. One per `Game` (created alongside the other
|
|
47
|
+
* game-scoped state in `createGame`, `runtime/game.ts`).
|
|
48
|
+
*/
|
|
49
|
+
export function createStateBridge(): GameStateBridgeInternal {
|
|
50
|
+
let frameVersion = 0;
|
|
51
|
+
const subscribers = new Set<() => void>();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
get frameVersion() {
|
|
55
|
+
return frameVersion;
|
|
56
|
+
},
|
|
57
|
+
subscribe(onFrame: () => void): () => void {
|
|
58
|
+
subscribers.add(onFrame);
|
|
59
|
+
return () => {
|
|
60
|
+
subscribers.delete(onFrame);
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
bump(): void {
|
|
64
|
+
frameVersion++;
|
|
65
|
+
// Snapshot before iterating (same idiom as ComponentManager's
|
|
66
|
+
// per-phase `list = byPhase.get(phase)!.slice()`): a subscriber that
|
|
67
|
+
// subscribes/unsubscribes from WITHIN a notification must not affect
|
|
68
|
+
// which of ITS SIBLINGS get notified this same bump.
|
|
69
|
+
const notified = Array.from(subscribers);
|
|
70
|
+
for (const onFrame of notified) {
|
|
71
|
+
try {
|
|
72
|
+
onFrame();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error('[state-bridge] subscriber threw:', err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|