@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.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -0
  3. package/package.json +55 -0
  4. package/src/adapter/authoring.ts +402 -0
  5. package/src/adapter/colyseus-networking-adapter.ts +72 -0
  6. package/src/adapter/first-party-systems.ts +103 -0
  7. package/src/adapter/game-adapter.ts +151 -0
  8. package/src/adapter/host-context.ts +77 -0
  9. package/src/adapter/index.ts +85 -0
  10. package/src/adapter/ingest/game-contract.ts +59 -0
  11. package/src/adapter/ingest/overlay-applier.ts +207 -0
  12. package/src/adapter/ingest/overlay-apply.ts +124 -0
  13. package/src/adapter/ingest/overlay-file.ts +126 -0
  14. package/src/adapter/ingest/overlay-report.ts +176 -0
  15. package/src/adapter/ingest/scene-capture.ts +307 -0
  16. package/src/adapter/ingest/upstream-pin.ts +52 -0
  17. package/src/adapter/loop-gate-report.ts +54 -0
  18. package/src/adapter/rapier-physics-adapter.ts +56 -0
  19. package/src/adapter/system-adapter.ts +154 -0
  20. package/src/adapter/transform.ts +18 -0
  21. package/src/adapter/vgai-scene-game-adapter.ts +886 -0
  22. package/src/adapter/world-kind.ts +34 -0
  23. package/src/ai/navigation.ts +164 -0
  24. package/src/animation/anim-graph-types.ts +56 -0
  25. package/src/animation/anim-graph.ts +406 -0
  26. package/src/animation/anim-system.ts +28 -0
  27. package/src/animation/blend-node.ts +119 -0
  28. package/src/animation/property-track.ts +178 -0
  29. package/src/animation/schema.ts +204 -0
  30. package/src/assets.ts +80 -0
  31. package/src/audio/ambient.ts +300 -0
  32. package/src/audio/impacts.ts +212 -0
  33. package/src/audio/index.ts +7 -0
  34. package/src/audio/movement.ts +140 -0
  35. package/src/audio/musical.ts +200 -0
  36. package/src/audio/ui-sounds.ts +171 -0
  37. package/src/audio/vehicle.ts +235 -0
  38. package/src/audio/weapons.ts +152 -0
  39. package/src/core/game-loop.ts +127 -0
  40. package/src/core/system-runner.ts +298 -0
  41. package/src/core/types.ts +58 -0
  42. package/src/dev/console-bridge.ts +83 -0
  43. package/src/dev/debug-draw.ts +80 -0
  44. package/src/dev/logger.ts +119 -0
  45. package/src/ecs/component-manager.ts +748 -0
  46. package/src/ecs/game-component.ts +147 -0
  47. package/src/ecs/hmr-swap-report.ts +65 -0
  48. package/src/input/input-manager.ts +439 -0
  49. package/src/input/input-types.ts +19 -0
  50. package/src/input/schema.ts +129 -0
  51. package/src/loader.ts +70 -0
  52. package/src/manifest/index.ts +24 -0
  53. package/src/manifest/load-file.ts +16 -0
  54. package/src/manifest/load.ts +378 -0
  55. package/src/manifest/schema.ts +375 -0
  56. package/src/physics/collision-system.ts +76 -0
  57. package/src/physics/physics-registry.ts +83 -0
  58. package/src/physics/transform-writer.ts +41 -0
  59. package/src/physics/trigger-dispatch.ts +97 -0
  60. package/src/react/game-state.tsx +172 -0
  61. package/src/render/auto-batcher.ts +169 -0
  62. package/src/render/render-batch-system.ts +268 -0
  63. package/src/render/render-features.ts +146 -0
  64. package/src/render/render-settings.ts +72 -0
  65. package/src/runtime/create-runtime.ts +1152 -0
  66. package/src/runtime/frame-selector-cache.ts +81 -0
  67. package/src/runtime/game.ts +1003 -0
  68. package/src/runtime/input-router.ts +213 -0
  69. package/src/runtime/mount-game.ts +269 -0
  70. package/src/runtime/mount-manifest.ts +361 -0
  71. package/src/runtime/scene-ui-bridge.ts +86 -0
  72. package/src/runtime/scene-ui-data.ts +119 -0
  73. package/src/runtime/state-bridge.ts +79 -0
  74. package/src/runtime/types.ts +196 -0
  75. package/src/scene/asset-loaders.ts +195 -0
  76. package/src/scene/asset-paths.ts +123 -0
  77. package/src/scene/asset-registry.ts +67 -0
  78. package/src/scene/collider-dimensions.ts +125 -0
  79. package/src/scene/component-registry.ts +40 -0
  80. package/src/scene/defaults.ts +164 -0
  81. package/src/scene/geometries/index.ts +7 -0
  82. package/src/scene/geometries/terrain.ts +42 -0
  83. package/src/scene/geometry-registry.ts +42 -0
  84. package/src/scene/instance-registry.ts +84 -0
  85. package/src/scene/instancers/grid.ts +38 -0
  86. package/src/scene/instancers/index.ts +7 -0
  87. package/src/scene/light-camera-factory.ts +97 -0
  88. package/src/scene/material-factory.ts +211 -0
  89. package/src/scene/material-registry.ts +73 -0
  90. package/src/scene/materials/index.ts +7 -0
  91. package/src/scene/materials/water.ts +56 -0
  92. package/src/scene/parse.ts +71 -0
  93. package/src/scene/particles-factory.ts +383 -0
  94. package/src/scene/scene-apply.ts +356 -0
  95. package/src/scene/scene-diff-schema.ts +115 -0
  96. package/src/scene/scene-diff-types.ts +29 -0
  97. package/src/scene/scene-loader.ts +1533 -0
  98. package/src/scene/scene-query.ts +63 -0
  99. package/src/scene/scene-types.ts +34 -0
  100. package/src/scene/scene-version.ts +40 -0
  101. package/src/scene/schema/animation.ts +95 -0
  102. package/src/scene/schema/audio.ts +25 -0
  103. package/src/scene/schema/camera.ts +21 -0
  104. package/src/scene/schema/collider.ts +69 -0
  105. package/src/scene/schema/entity-ref.ts +78 -0
  106. package/src/scene/schema/entity.ts +169 -0
  107. package/src/scene/schema/environment.ts +384 -0
  108. package/src/scene/schema/index.ts +95 -0
  109. package/src/scene/schema/instances.ts +35 -0
  110. package/src/scene/schema/joint.ts +26 -0
  111. package/src/scene/schema/light.ts +38 -0
  112. package/src/scene/schema/material.ts +113 -0
  113. package/src/scene/schema/mesh.ts +108 -0
  114. package/src/scene/schema/particles.ts +398 -0
  115. package/src/scene/schema/physics.ts +49 -0
  116. package/src/scene/schema/scene-file.ts +299 -0
  117. package/src/scene/schema/shadow.ts +24 -0
  118. package/src/scene/schema/spline.ts +21 -0
  119. package/src/scene/schema/tuples.ts +21 -0
  120. package/src/scene/schema/ui.ts +602 -0
  121. package/src/scene/user-data.ts +203 -0
  122. package/src/setup/setup-audio.ts +60 -0
  123. package/src/setup/setup-particles.ts +23 -0
  124. package/src/setup/setup-physics.ts +67 -0
  125. package/src/setup/setup-renderer.ts +529 -0
  126. package/src/types-n8ao.d.ts +37 -0
  127. package/src/types-realism-effects.d.ts +61 -0
  128. package/src/world2d/authoring-2d.ts +208 -0
  129. package/src/world2d/capture-to-scene2d.ts +52 -0
  130. package/src/world2d/collision-2d.ts +106 -0
  131. package/src/world2d/components-2d.ts +86 -0
  132. package/src/world2d/index.ts +66 -0
  133. package/src/world2d/ingest-iframe-2d.ts +255 -0
  134. package/src/world2d/ingest2d.ts +131 -0
  135. package/src/world2d/physics2d-registry.ts +49 -0
  136. package/src/world2d/pixi-game-adapter.ts +325 -0
  137. package/src/world2d/pixi-surface.ts +78 -0
  138. package/src/world2d/scene-capture-2d.ts +117 -0
  139. package/src/world2d/scene2d-loader.ts +308 -0
  140. package/src/world2d/schema/entity2d.ts +145 -0
  141. package/src/world2d/schema/physics2d.ts +53 -0
  142. package/src/world2d/schema/sprite.ts +71 -0
  143. package/src/world2d/schema/tilemap.ts +22 -0
  144. package/src/world2d/schema/tuples2d.ts +25 -0
  145. package/src/world2d/system-adapters-2d.ts +49 -0
  146. package/src/world2d/transform-writer-2d.ts +24 -0
  147. 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
+ }