@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,1152 @@
1
+ import * as THREE from 'three';
2
+ import type {
3
+ GameAdapter,
4
+ MountedGame,
5
+ MountedReactWorld,
6
+ MountedWorld,
7
+ } from '../adapter/game-adapter';
8
+ import type { HostContext, LoopHandle } from '../adapter/host-context';
9
+ import { type VgaiSceneConfig, VgaiSceneGameAdapter } from '../adapter/vgai-scene-game-adapter';
10
+ import { assertNever } from '../adapter/world-kind';
11
+ import { createAssetCache } from '../assets';
12
+ import { createGameLoop } from '../core/game-loop';
13
+ import { createHostRenderer } from '../setup/setup-renderer';
14
+ // TYPE-ONLY (same rule `runtime/game.ts` documents for its own pixi/world2d
15
+ // imports): `create-runtime.ts` must never value-import `pixi.js` or
16
+ // `world2d/pixi-game-adapter.ts` — a pixijs `WorldMountSpec`'s `adapter` is
17
+ // supplied ALREADY-CONSTRUCTED by the caller (a `PixiSceneGameAdapter` or any
18
+ // structurally-compatible adapter), so the host only ever needs these types.
19
+ import type { MountedGame2D, World2DHost } from '../world2d/pixi-game-adapter';
20
+ import {
21
+ createGame,
22
+ createWorldInstance,
23
+ type Game,
24
+ type GameInternal,
25
+ isFirstPartyMounted,
26
+ type WorldInstance,
27
+ } from './game';
28
+ import { createInputRouter, type RouterWorldEntry, stackOrder } from './input-router';
29
+
30
+ // Re-export the camera-precedence helper from its new home so existing importers
31
+ // (`import { adoptSceneCamera } from '@engine/runtime/create-runtime'`) keep working.
32
+ export { adoptSceneCamera } from '../adapter/vgai-scene-game-adapter';
33
+
34
+ /**
35
+ * Register a threejs world for a freshly-mounted game onto the Game shell.
36
+ * Shared by `createGameRuntime` and every headless test harness
37
+ * (`test/game-root.test.ts`, `test/frame-order.test.ts`,
38
+ * `test/game-two-worlds.test.ts`) so there is exactly one code path for this
39
+ * wiring (T7.1 slice 1, id-generalized in slice 3 for the two-world proof —
40
+ * `docs/GAME-ROOT-DESIGN.md` §8 stage 3) — `physics`/`collisions`/`camera`/
41
+ * `frame` are populated from the mount's first-party `GameContext`/
42
+ * `VgaiMountedGame` when available, left `undefined` otherwise (an external
43
+ * adapter's mount has none of these first-party handles — `frame` in
44
+ * particular is what makes `GameInternal.runFrame` (T7.1 slice 2) fall back
45
+ * to a single opaque `mounted.update` call per substep for such a world).
46
+ */
47
+ function firstPartyExtras(
48
+ mounted: MountedWorld,
49
+ ): Pick<Parameters<typeof createWorldInstance>[0], 'physics' | 'collisions' | 'camera' | 'frame'> {
50
+ if (!isFirstPartyMounted(mounted)) return {};
51
+ return {
52
+ physics: mounted.ctx.physics,
53
+ collisions: mounted.ctx.collisions,
54
+ camera: mounted.ctx.camera,
55
+ frame: mounted.frame,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Register a threejs world onto the Game shell. `opts.id` defaults to
61
+ * `'main'` — the single-world callers (`createGameRuntime`, slice-1/2 tests)
62
+ * are unaffected; a second/third world (T7.1 slice 3's two-world proof, and
63
+ * real multi-world manifests later) pass an explicit id. This is the ONE
64
+ * registration code path for a threejs world, whatever its id.
65
+ */
66
+ export function registerThreeWorld(
67
+ game: GameInternal,
68
+ adapter: GameAdapter,
69
+ mounted: MountedGame,
70
+ opts?: { id?: string | undefined; pausable?: boolean | undefined },
71
+ ): WorldInstance {
72
+ const world = createWorldInstance({
73
+ id: opts?.id ?? 'main',
74
+ kind: 'threejs',
75
+ pausable: opts?.pausable ?? true,
76
+ adapter,
77
+ mounted,
78
+ scene: mounted.scene as THREE.Scene,
79
+ ...firstPartyExtras(mounted),
80
+ });
81
+ // T7.2 slice 2 — one-time world-wiring backfill (closes the KNOWN GAP
82
+ // documented on `resolveWorldInstance` in `ecs/component-manager.ts`):
83
+ // scene-authored components were attached by the scene loader DURING the
84
+ // `mount()` call that just returned `mounted`, before this `WorldInstance`
85
+ // existed — so `instance.world` was left `undefined` for every one of
86
+ // them. `adoptWorld` backfills it now, once, for every already-attached
87
+ // instance still missing a world. Attaches made AFTER this point (e.g.
88
+ // runtime-spawned entities once the game is ticking) still resolve their
89
+ // world directly at attach time, unaffected by this call.
90
+ if (isFirstPartyMounted(mounted)) {
91
+ mounted.ctx.components.adoptWorld(world);
92
+ }
93
+ game.registerWorld(world);
94
+ return world;
95
+ }
96
+
97
+ /** Thin alias kept for existing single-world callers (`createGameRuntime`,
98
+ * `test/game-root.test.ts`, `test/frame-order.test.ts`) — registers the
99
+ * default `'main'` world via the SAME `registerThreeWorld` code path. */
100
+ export function registerDefaultThreeWorld(
101
+ game: GameInternal,
102
+ adapter: GameAdapter,
103
+ mounted: MountedGame,
104
+ ): WorldInstance {
105
+ return registerThreeWorld(game, adapter, mounted);
106
+ }
107
+
108
+ /**
109
+ * Register a pixijs world onto the Game shell — the pixi analog of
110
+ * {@link registerThreeWorld}, mirroring the wiring
111
+ * `world2d/pixi-game-adapter.ts`'s `createWorld2DRuntime` performs for its
112
+ * OWN (separate) Game, and `test/game-three-plus-pixi.test.ts` performs by
113
+ * hand onto a SHARED Game. `createGameRuntime`'s worlds path
114
+ * (T6.1 slice 1) is the first PRODUCTION caller that registers a pixijs
115
+ * world alongside other worlds on one Game; this is its one wiring code
116
+ * path, so a second/third pixijs world (or a future caller) never
117
+ * re-derives it. `adapter` is typed as the real {@link Pixi2DGameAdapter}
118
+ * shape (T7.5 — previously the generic `GameAdapter`, which forced a cast at
119
+ * every call site since a pixi adapter's `mount` takes a `World2DHost`, not
120
+ * `HostContext`); `WorldInstance.adapter` itself only needs `.id`
121
+ * (`AdapterHandle`, `runtime/game.ts`), so this passes through with zero cast.
122
+ *
123
+ * Section 7.4-5 (design-gap catalog), UPDATED by wave 2 (D-Y1,
124
+ * docs/EXTERNAL-REACT-AND-MODULE-DESIGN.md §1/§4 slice S1): this guard used
125
+ * to THROW for a non-first-party mount, reasoning "today this can never
126
+ * actually fire (Pixi2DGameAdapter.mount is typed to always return a
127
+ * MountedGame2D, whose ctx/physics2d/collisions2d/frame are all REQUIRED
128
+ * fields, so there is no foreign-mount shape that could reach here)". Wave 2
129
+ * proved that reasoning stale: a `kind: 'pixijs'` `{ module }` adapter's mount
130
+ * result IS exactly such a foreign shape — `Pixi2DGameAdapter`'s TYPE promises
131
+ * `MountedGame2D`, but `adapter-resolver.ts`'s `resolveAllWorlds` reaches it
132
+ * only via `adapter as unknown as Pixi2DGameAdapter` (a custom module's actual
133
+ * return value is merely `MountedPixiWorld`-shaped, T7.5's cross-kind union),
134
+ * so the "never actually fire" premise no longer holds the moment a custom
135
+ * pixi module world reaches the worlds path. Rather than keep the throw (which
136
+ * would re-create exactly the kind of dead end D-X6 named and D-Y1 exists to
137
+ * remove), this now degrades EXACTLY like `registerThreeWorld`'s own foreign-
138
+ * mount handling above (`firstPartyExtras`): `physics2d`/`collisions2d`/
139
+ * `frame`/component-adoption are omitted for a non-first-party mount instead
140
+ * of read from a `.ctx` that doesn't exist — `stage` (the one field every
141
+ * `MountedPixiWorld`, first-party or not, actually carries) is always real.
142
+ */
143
+ function pixiFirstPartyExtras(
144
+ mounted: MountedGame2D,
145
+ ): Pick<Parameters<typeof createWorldInstance>[0], 'physics2d' | 'collisions2d' | 'frame'> {
146
+ // Same structural check `firstPartyExtras`/`isFirstPartyMounted` use — see
147
+ // this function's own doc comment (above `registerPixiWorld`) for why this
148
+ // stays a plain boolean read rather than a type-guarded `isFirstPartyMounted`
149
+ // call (that guard's threejs-shaped return type would narrow `mounted` to
150
+ // `never` here).
151
+ if ((mounted as { firstParty?: unknown }).firstParty !== true) return {};
152
+ return {
153
+ physics2d: mounted.ctx.physics2d,
154
+ collisions2d: mounted.ctx.collisions2d,
155
+ frame: mounted.frame,
156
+ };
157
+ }
158
+
159
+ export function registerPixiWorld(
160
+ game: GameInternal,
161
+ adapter: Pixi2DGameAdapter,
162
+ mounted: MountedGame2D,
163
+ opts?: { id?: string | undefined; pausable?: boolean | undefined },
164
+ ): WorldInstance {
165
+ const id = opts?.id ?? 'main';
166
+ const mountedIsFirstParty = (mounted as { firstParty?: unknown }).firstParty === true;
167
+ const world = createWorldInstance({
168
+ id,
169
+ kind: 'pixijs',
170
+ pausable: opts?.pausable ?? true,
171
+ adapter,
172
+ mounted,
173
+ stage: mounted.stage,
174
+ ...pixiFirstPartyExtras(mounted),
175
+ });
176
+ // Mirrors `registerThreeWorld`'s adoptWorld backfill above — the pixi
177
+ // world's scene-authored components attach during `mount()`, before this
178
+ // `WorldInstance` exists. Skipped for a non-first-party mount (a foreign
179
+ // `{ module }` adapter's `ComponentManager` doesn't exist to adopt into —
180
+ // same "nothing for a custom adapter to backfill" reasoning
181
+ // `registerReactWorld`'s doc comment records for react).
182
+ if (mountedIsFirstParty) mounted.ctx.components.adoptWorld(world);
183
+ game.registerWorld(world);
184
+ return world;
185
+ }
186
+
187
+ /**
188
+ * Register a react world onto the Game shell (T6.2 slice 1,
189
+ * `docs/REACT-WORLD-DESIGN.md` §1.B) — the react analog of
190
+ * {@link registerThreeWorld}/{@link registerPixiWorld}. A react world has no
191
+ * `ComponentManager` to adopt (D8: react entities host no GameComponents —
192
+ * there is nothing for `adoptWorld` to backfill, unlike the three/pixi
193
+ * siblings above) and no `frame` hooks (react's own `createRoot` schedules
194
+ * its commits; `GameInternal.runFrame` correctly leaves a world with no
195
+ * `frame` untouched by its opaque-`update` fallback too, since
196
+ * `MountedReactGame` declares no `update`). `adapter` is typed as the real
197
+ * {@link ReactWorldAdapter} shape (T7.5, same reasoning as
198
+ * `registerPixiWorld`'s doc comment above) — `WorldInstance.adapter` only
199
+ * needs `.id`, so this passes through with zero cast.
200
+ */
201
+ export function registerReactWorld(
202
+ game: GameInternal,
203
+ adapter: ReactWorldAdapter,
204
+ mounted: MountedReactGame,
205
+ container: HTMLElement,
206
+ opts?: { id?: string | undefined; pausable?: boolean | undefined },
207
+ ): WorldInstance {
208
+ const world = createWorldInstance({
209
+ id: opts?.id ?? 'main',
210
+ kind: 'react',
211
+ pausable: opts?.pausable ?? true,
212
+ adapter,
213
+ mounted,
214
+ container,
215
+ });
216
+ game.registerWorld(world);
217
+ return world;
218
+ }
219
+
220
+ /**
221
+ * Configuration for {@link createGameRuntime}.
222
+ *
223
+ * The host depends on a {@link GameAdapter}. For convenience, the legacy
224
+ * first-party fields (`setup`/`scenePath`/`sceneData`/…) are accepted and wrapped
225
+ * in a {@link VgaiSceneGameAdapter} when no explicit `adapter` is given — so the
226
+ * host's default implementer is first-party, but the host itself only ever
227
+ * mounts "a GameAdapter".
228
+ *
229
+ * This is the LEGACY (single-world) shape — preserved byte-for-byte (T6.1
230
+ * slice 1, docs/MULTI-WORLD-DESIGN.md §1.A): every existing caller and test
231
+ * passes this shape unmodified. See {@link WorldsRuntimeConfig} for the new
232
+ * multi-world alternative and {@link RuntimeConfig} for the union.
233
+ */
234
+ export interface LegacyRuntimeConfig extends VgaiSceneConfig {
235
+ canvas: HTMLCanvasElement;
236
+ width?: number | undefined;
237
+ height?: number | undefined;
238
+ /** An explicit game adapter to mount (first-party OR external). */
239
+ adapter?: GameAdapter | undefined;
240
+ }
241
+
242
+ /**
243
+ * A pixijs-shaped adapter — the structural contract `WorldMountSpec`'s
244
+ * `pixijs` variant requires. `PixiSceneGameAdapter` (`world2d/
245
+ * pixi-game-adapter.ts`) satisfies this today; it is deliberately NOT named
246
+ * here as a concrete type so an external pixi adapter can satisfy the same
247
+ * shape without importing the first-party implementer.
248
+ */
249
+ export interface Pixi2DGameAdapter {
250
+ readonly id: string;
251
+ mount(host: World2DHost): Promise<MountedGame2D>;
252
+ }
253
+
254
+ /**
255
+ * One world to mount in the {@link WorldsRuntimeConfig} worlds path (T6.1
256
+ * slice 1) — the host-facing mirror of `manifest/load.ts`'s
257
+ * `ResolvedWorldEntry` (same `id`/`zOrder`/`pausable`/`loop` fields; the
258
+ * manifest-to-host translation itself is the editor's job, not this file's).
259
+ *
260
+ * `react` (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md`) mounts as a DOM-root
261
+ * layer `<div>` in the SAME stack instead of a canvas — see
262
+ * {@link ReactWorldMountSpec}/{@link ReactWorldAdapter} below.
263
+ */
264
+ export interface WorldMountSpecBase {
265
+ /** Manifest id — must be unique within one `worlds` array. */
266
+ readonly id: string;
267
+ /** Canvas stacking order (COMPOSITION-DESIGN D5 §1); ties broken by array
268
+ * order, mirroring `manifest/load.ts`'s `loadGameManifest` sort. Defaults
269
+ * to `0`. */
270
+ readonly zOrder?: number | undefined;
271
+ /** Whether play-mode pause/step applies to this world (D10). Defaults to `true`. */
272
+ readonly pausable?: boolean | undefined;
273
+ /** `'gated'` (host-driven, default) or `'self-driven'` (this world drives
274
+ * its own loop — D5's "composited, unsynchronized" tier). Carried through
275
+ * for parity with `ResolvedWorldEntry`; T6.1 slice 1 does not yet
276
+ * validate it against the mounted adapter's actual `drivesOwnLoop` (that
277
+ * cross-check, if ever needed, is T7.6's loop-gate surface). */
278
+ readonly loop?: 'gated' | 'self-driven' | undefined;
279
+ /**
280
+ * Optional claim predicate for the delegating input router (D5 §2a), over
281
+ * a point RELATIVE TO THE CONTAINER. Absent means: this world claims only
282
+ * if it ends up the bottom (lowest zOrder) world — see
283
+ * `input-router.ts`'s `resolveClaimingWorld`. A pixijs world with no
284
+ * explicit `hitTest` gets a REAL default derived from its own mounted
285
+ * stage's `EventBoundary.hitTest` (see `derivePixiHitTest` below) — a
286
+ * threejs world with no explicit `hitTest` gets no default (three has no
287
+ * cheap universal "is this pixel interactive" answer).
288
+ */
289
+ readonly hitTest?: ((x: number, y: number) => boolean) | undefined;
290
+ }
291
+
292
+ export interface ThreeWorldMountSpec extends WorldMountSpecBase {
293
+ readonly kind: 'threejs';
294
+ readonly adapter: GameAdapter;
295
+ }
296
+
297
+ export interface PixiWorldMountSpec extends WorldMountSpecBase {
298
+ readonly kind: 'pixijs';
299
+ readonly adapter: Pixi2DGameAdapter;
300
+ }
301
+
302
+ /**
303
+ * The host-facing surface handed to a {@link ReactWorldAdapter}'s `mount`
304
+ * (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md` §1.B) — the react analog of
305
+ * {@link World2DHost}. `container` is the absolutely-positioned, z-ordered
306
+ * DOM-root layer `<div>` the host already created and stacked (same box/
307
+ * z-order rules as a canvas per COMPOSITION-DESIGN D5 §1) — the adapter's
308
+ * `mount` renders its react tree INTO this exact element via `createRoot`;
309
+ * it must never create its own root element (mirrors `HostContext`'s
310
+ * "the world's runtime renders into the surface it is handed" rule for
311
+ * canvases, `docs/GAME-ROOT-DESIGN.md` §2).
312
+ */
313
+ export interface ReactWorldHost {
314
+ readonly container: HTMLElement;
315
+ /**
316
+ * The Game this world is being mounted into — symmetric with
317
+ * `HostContext.game`; what a react adapter hands to `<GameProvider>` so
318
+ * `useGameState` selectors read live state (T7.4 bridge).
319
+ *
320
+ * Optional as of D-V3 (docs/WAVE5-MULTIWORLD-INGEST-DESIGN.md, F24
321
+ * composite): a `default-react` sibling mounted BESIDE an ingest world
322
+ * (`packages/editor/src/ingest-siblings.ts`) has no native `Game` to hand
323
+ * it — there is no first-party `GameContext`/loop for the sibling to join,
324
+ * only the ingested world's own foreign runtime — so its host carries no
325
+ * `game` at all rather than fabricating an empty one (anti-shim rule); the
326
+ * sibling mounts its entry component bare, with no `<GameProvider>` wrap.
327
+ * `mountOneReactWorld` below (the NATIVE multi-world runtime path) still
328
+ * ALWAYS supplies a real `game` — this optionality is reached only by the
329
+ * composite sibling's own hand-built host, never by weakening the native
330
+ * path's guarantee.
331
+ */
332
+ readonly game?: Game;
333
+ }
334
+
335
+ /**
336
+ * A live, mounted react world (T6.2 slice 1) — the react analog of
337
+ * {@link MountedGame2D}. React worlds host no ticking components (D8 —
338
+ * `ecs/component-manager.ts` already throws on any attach to a react-kind
339
+ * manager) and render from game state via the T7.4 bridge instead of a
340
+ * per-frame `update`, so this shape carries no `update`/`fixedUpdate`/
341
+ * `ctx`/`frame` — `drivesOwnLoop` is always `false` (react's `createRoot`
342
+ * schedules its OWN commits; the host's fixed-step loop never drives it,
343
+ * and it is correctly skipped by `GameInternal.runFrame`'s per-world
344
+ * `frame`-hooks/opaque-`update` dispatch — see `registerReactWorld` below,
345
+ * which registers this `WorldInstance` with no `frame`, same as any other
346
+ * opaque mount with nothing to tick).
347
+ *
348
+ * Deliberately carries NO `firstParty: true` brand: that brand specifically
349
+ * means "has a `.ctx: GameContext`" (`isFirstPartyMounted`,
350
+ * `runtime/game.ts`) — a react world has no Rapier/ComponentManager/
351
+ * GameContext at all, so branding it first-party would be a type lie.
352
+ * `Game.registerWorld`'s "no state bridge" console warning
353
+ * (`docs/REACT-STATE-BRIDGE.md` §4) explicitly exempts `kind: 'react'`
354
+ * (§7.1-15): a react world has no `observe` BY DESIGN — that hook is scoped
355
+ * to `useWorldObservation` (the ingested/foreign-world case), not
356
+ * `useGameState` (`ui/game-state.tsx`), which a react world's own mounted
357
+ * tree uses instead — it reads `Game.state` directly (T7.4's actual bridge
358
+ * for first-party-observable state), never a per-world `observe`.
359
+ *
360
+ * `kind`/`container` (T7.5) satisfy `MountedReactWorld` (`adapter/
361
+ * game-adapter.ts`) — `container` is the SAME `ReactWorldHost.container` the
362
+ * adapter's `mount` was handed (identity matters, mirroring `threeRoot()`/
363
+ * `pixiRoot()`'s "same instance the adapter mounted" contract); every
364
+ * `ReactWorldAdapter` implementer echoes it back here so `mounted` alone
365
+ * (with no separately-threaded `container`) satisfies the union
366
+ * `WorldInstanceInit.mounted`/`WorldInstance.mounted` with zero cast.
367
+ */
368
+ export interface MountedReactGame extends MountedReactWorld {
369
+ readonly drivesOwnLoop: false;
370
+ }
371
+
372
+ /**
373
+ * A react-shaped adapter — the structural contract `WorldMountSpec`'s
374
+ * `react` variant requires. Mirrors {@link Pixi2DGameAdapter}: deliberately
375
+ * NOT tied to a concrete implementer here so an editor-resolved adapter (the
376
+ * `default-react` resolver branch, T6.2's editor-side follow-up) can satisfy
377
+ * this shape without this file importing react-dom or any editor code.
378
+ */
379
+ export interface ReactWorldAdapter {
380
+ readonly id: string;
381
+ mount(host: ReactWorldHost): Promise<MountedReactGame>;
382
+ }
383
+
384
+ export interface ReactWorldMountSpec extends WorldMountSpecBase {
385
+ readonly kind: 'react';
386
+ readonly adapter: ReactWorldAdapter;
387
+ }
388
+
389
+ export type WorldMountSpec = ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec;
390
+
391
+ /**
392
+ * The NEW multi-world alternative to {@link LegacyRuntimeConfig} (T6.1 slice
393
+ * 1, docs/MULTI-WORLD-DESIGN.md §1.A; react worlds T6.2 slice 1,
394
+ * docs/REACT-WORLD-DESIGN.md §1.B): mount N worlds (threejs + pixijs + react)
395
+ * on ONE `Game`, stacked in `container` per COMPOSITION-DESIGN D5 §1.
396
+ * Internally the legacy signature maps to a one-element worlds list —
397
+ * `worlds[0]` (by `Game.defaultWorld`'s existing "first threejs world, else
398
+ * first world" rule) is the default world, so
399
+ * `GameSession.scene`/`.camera`/`.mounted` keep aliasing exactly as they do
400
+ * today.
401
+ */
402
+ export interface WorldsRuntimeConfig {
403
+ /** The host creates one absolutely-positioned surface per world inside
404
+ * this element (D5 §1) — a canvas for threejs/pixijs, a DOM-root `<div>`
405
+ * layer for react — plus one shared UI overlay above all of them. */
406
+ container: HTMLElement;
407
+ worlds: WorldMountSpec[];
408
+ width?: number | undefined;
409
+ height?: number | undefined;
410
+ /**
411
+ * Mirrors `HostContext.headless` (Node conformance tests — no GPU): skips
412
+ * real `WebGLRenderer` construction for every threejs world in this
413
+ * session (a stand-in renderer is used instead, exactly as
414
+ * `VgaiSceneGameAdapter.mount` already special-cases `host.headless`
415
+ * internally). Pixijs worlds are unaffected — Pixi already falls back to
416
+ * a 2D canvas renderer with no GPU. Never set `true` in a real host.
417
+ */
418
+ headless?: boolean | undefined;
419
+ }
420
+
421
+ /** The full config union `createGameRuntime` accepts — the legacy
422
+ * single-`{canvas, adapter}` shape, or the new worlds path. */
423
+ export type RuntimeConfig = LegacyRuntimeConfig | WorldsRuntimeConfig;
424
+
425
+ function isWorldsConfig(config: RuntimeConfig): config is WorldsRuntimeConfig {
426
+ return 'worlds' in config;
427
+ }
428
+
429
+ /**
430
+ * Handle returned by createGameRuntime() for controlling the running game.
431
+ *
432
+ * This is the GENERIC host handle — it has no first-party concepts (no
433
+ * `GameContext`, no `GameSetupFn`). First-party features (the live `GameContext`,
434
+ * warm-restart hot reload) are reached by casting `mounted` to `VgaiMountedGame`
435
+ * (the editor does this for HMR/physics-sync — those are inherently first-party).
436
+ *
437
+ * `scene`/`camera`/`mounted` alias the Game's `defaultWorld` (T6.1 slice 1):
438
+ * for the legacy single-world path this is the only world, byte-identical to
439
+ * before; for the worlds path it is the first threejs world (else the first
440
+ * world) by DECLARATION order, per `Game.defaultWorld`'s existing rule —
441
+ * independent of `zOrder`/canvas stacking, which is a rendering-only concern.
442
+ */
443
+ export interface GameSession {
444
+ stop(): void;
445
+ pause(): void;
446
+ resume(): void;
447
+ step(): void;
448
+ resize(width: number, height: number): void;
449
+ readonly scene: THREE.Scene;
450
+ readonly camera: THREE.PerspectiveCamera;
451
+ /** The mounted game (interface surface). Cast to `VgaiMountedGame` for first-party extras. */
452
+ readonly mounted: MountedGame;
453
+ /** The Game root (T6.1 slice 1) — the multi-world entry point
454
+ * (`game.worlds`/`game.world(id)`/`game.queryByComponent`) for callers
455
+ * that need more than the default-world aliases above. */
456
+ readonly game: Game;
457
+ }
458
+
459
+ /**
460
+ * Bootstrap the game host and mount a game adapter.
461
+ *
462
+ * `createGameRuntime` is the HOST: it owns the canvas/renderer(s), the loop,
463
+ * the UI overlay, and the asset cache, and it mounts a {@link GameAdapter}
464
+ * (or several, via the worlds path — T6.1 slice 1) against that context. It
465
+ * knows nothing about `.vscn`, `GameComponent`, Rapier, or `GameSetupFn` —
466
+ * those live inside the first-party {@link VgaiSceneGameAdapter}, which is
467
+ * the default implementer when no explicit `adapter` is supplied on the
468
+ * legacy path. Dispatches on `config`'s shape: {@link WorldsRuntimeConfig}
469
+ * (`worlds` present) goes through {@link createWorldsGameRuntime};
470
+ * everything else is the byte-compatible legacy single-world path.
471
+ */
472
+ export async function createGameRuntime(config: RuntimeConfig): Promise<GameSession> {
473
+ if (isWorldsConfig(config)) {
474
+ return createWorldsGameRuntime(config);
475
+ }
476
+ const { canvas, width, height, adapter, ...sceneConfig } = config;
477
+ const w = width ?? canvas.width;
478
+ const h = height ?? canvas.height;
479
+
480
+ // --- Host primitives (neutral — no first-party assumptions) ---
481
+ const renderer = createHostRenderer(canvas, w, h);
482
+ const assets = createAssetCache();
483
+
484
+ let uiContainer: HTMLDivElement;
485
+ const existingUi = document.getElementById('example-ui');
486
+ if (existingUi instanceof HTMLDivElement) {
487
+ uiContainer = existingUi;
488
+ } else {
489
+ uiContainer = document.createElement('div');
490
+ uiContainer.id = 'example-ui';
491
+ uiContainer.style.cssText =
492
+ 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2;';
493
+ (canvas.parentElement ?? document.getElementById('ui-overlay') ?? document.body).appendChild(
494
+ uiContainer,
495
+ );
496
+ }
497
+
498
+ const extraUpdaters = new Set<(dt: number) => void>();
499
+ const loopHandle: LoopHandle = {
500
+ onUpdate(fn) {
501
+ extraUpdaters.add(fn);
502
+ return () => extraUpdaters.delete(fn);
503
+ },
504
+ };
505
+
506
+ // --- Game root (T7.1 slice 1 + slice 2) ---
507
+ // The loop's update callback used to close directly over `mounted`, which
508
+ // only exists after `game.mount(host)` resolves. The Game shell (and thus
509
+ // its `loop`) must exist BEFORE mount so the mount can see `host.game` — so
510
+ // the callback instead consults a mutable ref, set once mount resolves.
511
+ // Behavior is identical: `loop.start()` still only runs after mount, same
512
+ // pause/step/stop semantics below. `drivesOwnLoop` handling now lives
513
+ // INSIDE `game.runFrame` (T7.1 slice 2) rather than being checked here —
514
+ // for today's single first-party (or single opaque host-driven) world this
515
+ // is byte-identical to the old `if (!mountedRef.drivesOwnLoop) mountedRef
516
+ // .update?.(dt)` guard (see `runFrame`'s doc comment in `runtime/game.ts`).
517
+ // `game` is referenced here before its `const` below only textually — this
518
+ // closure isn't invoked until `loop.start()`, well after `game` exists.
519
+ let mountedRef: MountedGame | null = null;
520
+ const loop = createGameLoop({
521
+ fixedTimestep: 1 / 60,
522
+ maxSubSteps: 8,
523
+ update: (dt) => {
524
+ if (mountedRef) game.runFrame(dt);
525
+ for (const fn of extraUpdaters) fn(dt);
526
+ },
527
+ });
528
+ const game = createGame({ loop, assets });
529
+
530
+ const host: HostContext = {
531
+ three: THREE,
532
+ surface: { canvas, width: w, height: h },
533
+ renderer,
534
+ loop: loopHandle,
535
+ assets,
536
+ ui: uiContainer,
537
+ game,
538
+ requestSystem: () => null,
539
+ };
540
+
541
+ // --- Mount the game adapter (first-party default, or an explicit adapter) ---
542
+ const gameAdapter = adapter ?? new VgaiSceneGameAdapter(sceneConfig);
543
+ const mounted = await gameAdapter.mount(host);
544
+ mountedRef = mounted;
545
+
546
+ // Register the default (and, in this slice, only) threejs world now that
547
+ // mount has produced a MountedGame — the world's `threeRoot()` must return
548
+ // the SAME scene instance the adapter mounted (identity is the contract).
549
+ registerDefaultThreeWorld(game, gameAdapter, mounted);
550
+
551
+ // Expose scene & camera for dev tools / e2e tests
552
+ if (import.meta.env?.DEV) {
553
+ const w = window as unknown as Record<string, unknown>;
554
+ w['__vgaiScene'] = mounted.scene;
555
+ w['__vgaiCamera'] = mounted.camera;
556
+ }
557
+
558
+ loop.start();
559
+
560
+ function fullCleanup(): void {
561
+ loop.stop();
562
+ mounted.dispose();
563
+ renderer.dispose();
564
+ renderer.forceContextLoss();
565
+ // Retract the dev/e2e globals THIS session published (identity-guarded so
566
+ // a newer session's globals are never clobbered by an older session's
567
+ // stop). Leaving them dangling let e2e waits pass vacuously against a
568
+ // stopped game's scene/camera.
569
+ if (import.meta.env?.DEV) {
570
+ const w = window as unknown as Record<string, unknown>;
571
+ if (w['__vgaiScene'] === mounted.scene) delete w['__vgaiScene'];
572
+ if (w['__vgaiCamera'] === mounted.camera) delete w['__vgaiCamera'];
573
+ }
574
+ }
575
+
576
+ return {
577
+ stop: fullCleanup,
578
+ // D10/T7.6: `Game.play` (`runtime/game.ts`) is the real control surface —
579
+ // it fans out over every `pausable` world (just the one default world on
580
+ // this legacy single-world path) via its loop-gate/audio-gate capability,
581
+ // reporting honestly wherever a world can't actually be gated, and keeps
582
+ // `render` running every substep while paused (the old `loop.timeScale =
583
+ // 0` here starved the accumulator entirely — per D1, zero substeps means
584
+ // zero renders too — which is the bug this replaces, not a behavior to
585
+ // preserve).
586
+ pause() {
587
+ game.play.pause();
588
+ },
589
+ resume() {
590
+ game.play.resume();
591
+ },
592
+ step() {
593
+ game.play.step();
594
+ },
595
+ resize(rw: number, rh: number) {
596
+ renderer.setSize(rw, rh);
597
+ mounted.resize?.(rw, rh);
598
+ },
599
+ get scene() {
600
+ return mounted.scene as THREE.Scene;
601
+ },
602
+ get camera() {
603
+ return mounted.camera as THREE.PerspectiveCamera;
604
+ },
605
+ get mounted() {
606
+ return mounted;
607
+ },
608
+ game,
609
+ };
610
+ }
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // Worlds path (T6.1 slice 1 — docs/MULTI-WORLD-DESIGN.md §1.A/B/C)
614
+ // ---------------------------------------------------------------------------
615
+
616
+ /** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) threejs
617
+ * worlds — mirrors `VgaiSceneGameAdapter.mount`'s own `headlessComposer`
618
+ * pattern (`adapter/vgai-scene-game-adapter.ts`): a headless first-party
619
+ * mount never calls a render-phase method on `host.renderer` at all, so
620
+ * this only needs to satisfy the handful of calls THIS file itself makes
621
+ * (`setPixelRatio`/`setClearColor`/`setSize` at mount, `dispose`/
622
+ * `forceContextLoss` at teardown) — never a real GL call. Node conformance
623
+ * tests only; never used when `headless` is left `false`/absent. */
624
+ function createHeadlessRendererStub(): THREE.WebGLRenderer {
625
+ return {
626
+ setPixelRatio() {},
627
+ setClearColor() {},
628
+ setSize() {},
629
+ dispose() {},
630
+ forceContextLoss() {},
631
+ } as unknown as THREE.WebGLRenderer;
632
+ }
633
+
634
+ /**
635
+ * Derive a REAL default `hitTest` for a pixijs world from its own mounted
636
+ * stage (D5 §2a/COMPOSITION-DESIGN.md proof item 2: "pixi `EventBoundary
637
+ * .hitTest`") — used only when the caller's `WorldMountSpec` didn't declare
638
+ * an explicit `hitTest`. `(x, y)` are container-relative pixels, matching
639
+ * the coordinates the pixi surface renders at (the stage itself carries no
640
+ * pan/zoom — that lives on `pixi-surface.ts`'s child `world` container, per
641
+ * its own doc comment), so this is a correct global hit test with no extra
642
+ * coordinate mapping. Duck-typed against `mounted.ctx.app` (never a
643
+ * value-import of `pixi.js`) so this file stays type-only w.r.t. Pixi;
644
+ * returns `undefined` (no default claim) if the mount's `app`/event
645
+ * boundary isn't shaped as expected — degrade to "does not claim" rather
646
+ * than throw. A non-first-party mount (D-Y1, wave 2 — same finding
647
+ * `registerPixiWorld`'s doc comment records: a `{ module }` adapter's mount
648
+ * has no `.ctx` at all) has nothing to duck-type here either, so this checks
649
+ * the SAME `firstParty` brand FIRST and degrades identically — never claims,
650
+ * never throws.
651
+ */
652
+ function derivePixiHitTest(
653
+ mounted: MountedGame2D,
654
+ ): ((x: number, y: number) => boolean) | undefined {
655
+ if ((mounted as { firstParty?: unknown }).firstParty !== true) return undefined;
656
+ const app = (mounted.ctx as unknown as { app?: unknown }).app as
657
+ | { renderer?: { events?: { rootBoundary?: { hitTest?: (x: number, y: number) => unknown } } } }
658
+ | undefined;
659
+ const rootBoundary = app?.renderer?.events?.rootBoundary;
660
+ if (!rootBoundary || typeof rootBoundary.hitTest !== 'function') return undefined;
661
+ return (x: number, y: number) => rootBoundary.hitTest!(x, y) != null;
662
+ }
663
+
664
+ /** One already-mounted world, tracked for disposal + the router. `element`
665
+ * is the world's stacked surface — an `HTMLCanvasElement` for threejs/
666
+ * pixijs, or the DOM-root layer `<div>` for react (T6.2 slice 1) — kept
667
+ * under one field name so `fullCleanup`'s disposal loop stays kind-generic
668
+ * (`container.removeChild(entry.element)` needs no branch). */
669
+ interface MountedWorldEntry {
670
+ readonly id: string;
671
+ readonly kind: 'threejs' | 'pixijs' | 'react';
672
+ readonly element: HTMLElement;
673
+ readonly mounted: MountedWorld;
674
+ /** Only threejs worlds own a renderer this file constructed. */
675
+ readonly renderer: THREE.WebGLRenderer | undefined;
676
+ }
677
+
678
+ interface OneWorldResult {
679
+ readonly mountedEntry: MountedWorldEntry;
680
+ readonly routerEntry: RouterWorldEntry;
681
+ }
682
+
683
+ /** Shared per-world mount inputs, computed once in `createWorldsGameRuntime`'s
684
+ * loop (canvas stacking + one dpr) and threaded into whichever of
685
+ * `mountOneThreeWorld`/`mountOnePixiWorld` this world's `kind` needs — split
686
+ * out so the orchestrating loop itself stays a simple dispatch. */
687
+ interface OneWorldContext {
688
+ readonly game: GameInternal;
689
+ readonly canvas: HTMLCanvasElement;
690
+ readonly w: number;
691
+ readonly h: number;
692
+ readonly dpr: number;
693
+ readonly isBottom: boolean;
694
+ readonly headless: boolean;
695
+ readonly uiContainer: HTMLDivElement;
696
+ readonly loopHandle: LoopHandle;
697
+ readonly assets: ReturnType<typeof createAssetCache>;
698
+ }
699
+
700
+ /** Mount one threejs `WorldMountSpec` (canvas + renderer construction, per
701
+ * D5 §1/§4, then `registerThreeWorld`). Split out of
702
+ * `createWorldsGameRuntime` purely to keep that function's own branching
703
+ * simple — see `mountOnePixiWorld` for the pixijs sibling. */
704
+ async function mountOneThreeWorld(
705
+ spec: ThreeWorldMountSpec,
706
+ ctx: OneWorldContext,
707
+ ): Promise<OneWorldResult> {
708
+ const { game, canvas, w, h, dpr, isBottom, headless, uiContainer, loopHandle, assets } = ctx;
709
+ const renderer = headless
710
+ ? createHeadlessRendererStub()
711
+ : createHostRenderer(canvas, w, h, undefined, {
712
+ alpha: !isBottom,
713
+ preserveDrawingBuffer: true,
714
+ });
715
+ renderer.setSize(w, h, false); // backing resolution only — CSS stacking owns layout size
716
+ // Defect E4.R1 (Fable review): `createHostRenderer`'s OWN construction-time
717
+ // `setSize` call (`setup-renderer.ts`, `updateStyle` defaulting `true` —
718
+ // unchanged there on purpose, see below) stamps `canvas.style.width`/
719
+ // `.height` to literal `${w}px`/`${h}px` the moment the renderer is built,
720
+ // OVERWRITING the container-relative `100%`/`100%` the surface-stacking
721
+ // loop above just set. The `renderer.setSize(w, h, false)` line right above
722
+ // this comment does NOT undo that stamp (updateStyle:false only skips
723
+ // TOUCHING style, it can't un-stamp a previous call) — so without this
724
+ // re-assertion, every worlds-path threejs canvas' on-screen size was
725
+ // permanently pinned to whatever `w`/`h` it happened to mount at (usually
726
+ // the manifest's `resolution`, since standalone builds mount before a real
727
+ // container size is known — see `mount-manifest.ts`). Re-asserting here
728
+ // makes the CSS layout size and the render-buffer resolution fully
729
+ // independent, exactly like the react DOM world layer already is: this
730
+ // fixes the template-standalone bug (broken at any viewport other than the
731
+ // manifest's `resolution`, which is also Playwright's DEFAULT viewport —
732
+ // why it went unnoticed) and, for free, tri-world's pre-existing
733
+ // resize-staleness (the worlds-path `resize()` below always calls
734
+ // `setSize(rw, rh, false)`, so nothing else was ever going to update this
735
+ // canvas' CSS after mount). `createHostRenderer`'s own default
736
+ // (`updateStyle:true`) is intentionally left alone — the LEGACY
737
+ // single-canvas path (editor play-mode's non-multi-world branch) still
738
+ // relies on that construction-time stamp + its own later `updateStyle:true`
739
+ // resizes for byte-identical behavior; this fix touches only the
740
+ // worlds-path canvas, after the fact.
741
+ canvas.style.width = '100%';
742
+ canvas.style.height = '100%';
743
+ renderer.setPixelRatio(dpr);
744
+ if (!isBottom) renderer.setClearColor(0x000000, 0); // D5 §1: alpha-clear above the bottom layer
745
+
746
+ const host: HostContext = {
747
+ three: THREE,
748
+ surface: { canvas, width: w, height: h },
749
+ renderer,
750
+ loop: loopHandle,
751
+ assets,
752
+ ui: uiContainer,
753
+ headless,
754
+ game,
755
+ requestSystem: () => null,
756
+ };
757
+ const mounted = await spec.adapter.mount(host);
758
+ registerThreeWorld(game, spec.adapter, mounted, { id: spec.id, pausable: spec.pausable });
759
+ return {
760
+ mountedEntry: { id: spec.id, kind: 'threejs', element: canvas, mounted, renderer },
761
+ routerEntry: { id: spec.id, zOrder: spec.zOrder ?? 0, canvas, hitTest: spec.hitTest },
762
+ };
763
+ }
764
+
765
+ /** Mount one pixijs `WorldMountSpec` (via its own `World2DHost`, per D5
766
+ * §1/§3/§4, then `registerPixiWorld`) — the pixi sibling of
767
+ * `mountOneThreeWorld` above. Derives a real default `hitTest` from the
768
+ * mounted stage when the spec didn't declare one (see `derivePixiHitTest`).
769
+ *
770
+ * Unlike `mountOneThreeWorld`, this needs no explicit `canvas.style.width`/
771
+ * `.height` re-assertion (E4.R1). `pixi-surface.ts` constructs its
772
+ * `Application` with `autoDensity: true`, which makes PIXI ITSELF re-stamp
773
+ * `canvas.style.width`/`.height` (real CSS px, matching the LOGICAL
774
+ * width/height passed to `resize()`) on every `app.renderer.resize()` call —
775
+ * including the one this world's `mounted.resize?.()` triggers from the
776
+ * worlds-path `resize()` below. So while a pixi world's canvas can start
777
+ * pinned to the mount-time `w`/`h` (same as threejs, until PIXI's own
778
+ * construction-time resize runs), it self-heals the moment ANY real
779
+ * `session.resize(rw, rh)` fires — every shipped standalone entry
780
+ * (`packages/editor/template/src/main.ts`, `examples/tri-world/src/main.ts`)
781
+ * already calls `session.resize()` unconditionally right after mount, so
782
+ * this never surfaces as a lasting bug the way threejs' buffer-only resize
783
+ * did (never self-healing, by design — see above). */
784
+ async function mountOnePixiWorld(
785
+ spec: PixiWorldMountSpec,
786
+ ctx: Omit<OneWorldContext, 'headless' | 'loopHandle' | 'assets'>,
787
+ ): Promise<OneWorldResult> {
788
+ const { game, canvas, w, h, dpr, isBottom, uiContainer } = ctx;
789
+ const pixiHost: World2DHost = {
790
+ canvas,
791
+ width: w,
792
+ height: h,
793
+ ui: uiContainer,
794
+ dpr,
795
+ transparent: !isBottom,
796
+ preserveDrawingBuffer: true,
797
+ };
798
+ const mounted = await spec.adapter.mount(pixiHost);
799
+ registerPixiWorld(game, spec.adapter, mounted, {
800
+ id: spec.id,
801
+ pausable: spec.pausable,
802
+ });
803
+ return {
804
+ mountedEntry: {
805
+ id: spec.id,
806
+ kind: 'pixijs',
807
+ element: canvas,
808
+ mounted,
809
+ renderer: undefined,
810
+ },
811
+ routerEntry: {
812
+ id: spec.id,
813
+ zOrder: spec.zOrder ?? 0,
814
+ canvas,
815
+ hitTest: spec.hitTest ?? derivePixiHitTest(mounted),
816
+ },
817
+ };
818
+ }
819
+
820
+ /**
821
+ * Mount one react `WorldMountSpec` (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md`
822
+ * §1.B/§1.C) — the react sibling of `mountOneThreeWorld`/`mountOnePixiWorld`.
823
+ * Unlike its canvas-backed siblings this returns NO `routerEntry`: a react
824
+ * world's DOM-root layer participates in D5's z-order/box stacking (the
825
+ * caller still creates and positions its `<div>` exactly like a canvas — see
826
+ * `createWorldsGameRuntime`'s stack-building loop) but needs no entry in the
827
+ * delegating router's hit-test loop (§1.C — "DOM layers need no entry in the
828
+ * router's hit-test loop"): the layer's own `pointer-events` discipline
829
+ * (this file sets `none` on the layer by default, mirroring the existing
830
+ * `uiContainer`/HUD rule; the mounted react tree opts specific elements back
831
+ * in with `pointer-events:auto`) is what lets its interactive elements claim
832
+ * events NATIVELY, via the real DOM, with zero router involvement — and lets
833
+ * a click over its non-interactive (transparent) area fall through to the
834
+ * canvas below it via the SAME native DOM hit-testing (a `pointer-events:none`
835
+ * element is invisible to hit-testing entirely, so the click lands on
836
+ * whatever real DOM element is beneath it — the router's normal
837
+ * canvas-vs-canvas forwarding, unaffected by this layer's presence).
838
+ */
839
+ async function mountOneReactWorld(
840
+ spec: ReactWorldMountSpec,
841
+ game: GameInternal,
842
+ /** The ALREADY-created, already-stacked (position/z-index set, appended to
843
+ * `container`) DOM-root layer for this world — see
844
+ * `createWorldsGameRuntime`'s surface-stack loop, which builds a `<div>`
845
+ * for every react-kind spec up front, in the SAME pass that builds every
846
+ * other world's canvas. This function must reuse that exact element (never
847
+ * create its own) so `WorldInstance.reactRoot()` returns the SAME node
848
+ * that is actually positioned in the stack. */
849
+ layer: HTMLElement,
850
+ ): Promise<{ mountedEntry: MountedWorldEntry }> {
851
+ layer.style.pointerEvents = 'none';
852
+ // Size the layer like the shared uiContainer does: an absolutely-positioned
853
+ // layer with no width/height collapses to zero content size, so a child's
854
+ // own position:absolute offsets resolve against a degenerate containing
855
+ // block (found by the T6.2 slice-3 e2e — clicks landed outside the game).
856
+ layer.style.width = '100%';
857
+ layer.style.height = '100%';
858
+ const reactHost: ReactWorldHost = { container: layer, game };
859
+ const mounted = await spec.adapter.mount(reactHost);
860
+ registerReactWorld(game, spec.adapter, mounted, layer, {
861
+ id: spec.id,
862
+ pausable: spec.pausable,
863
+ });
864
+ return {
865
+ mountedEntry: {
866
+ id: spec.id,
867
+ kind: 'react',
868
+ element: layer,
869
+ mounted,
870
+ renderer: undefined,
871
+ },
872
+ };
873
+ }
874
+
875
+ /**
876
+ * The worlds-path implementer behind {@link createGameRuntime} (T6.1 slice
877
+ * 1; react worlds added T6.2 slice 1). Builds ONE surface per world — a
878
+ * canvas for threejs/pixijs, a DOM-root `<div>` layer for react — stacked
879
+ * per COMPOSITION-DESIGN D5 §1, z-order/ties exactly matching
880
+ * `manifest/load.ts`'s sort, ONE `Game`, and registers every world onto it
881
+ * via `registerThreeWorld`/`registerPixiWorld`/`registerReactWorld` — the
882
+ * SAME wiring `test/game-three-plus-pixi.test.ts` proves by hand for the
883
+ * three/pixi pair. Worlds MOUNT in `worlds` ARRAY order (GAME-ROOT-DESIGN §4's
884
+ * "manifest declaration order" — the frame/registration axis), independent of
885
+ * `zOrder` (the canvas-stacking/rendering axis) — the two orders can differ
886
+ * and both are honored correctly.
887
+ */
888
+
889
+ /**
890
+ * Dev/e2e-only `window.__vgaiScene`/`__vgaiCamera` exposure for the worlds
891
+ * path's DEFAULT world (E4) — split out of `createWorldsGameRuntime` purely
892
+ * to keep that function's own cyclomatic complexity down. Mirrors the
893
+ * legacy single-world path's identical exposure (above, in this same file),
894
+ * using the SAME "first threejs world, else none" default-world rule the
895
+ * `GameSession.scene`/`.camera` getters alias (`Game.defaultWorld`): a
896
+ * non-threejs default world (or none at all) publishes neither global,
897
+ * exactly like those getters return `undefined` in that case. Returns a
898
+ * retraction callback — identity-guarded (a newer session's globals must
899
+ * never be clobbered by an older session's stop, same reason the legacy
900
+ * path's own cleanup guards it) — that is a no-op when nothing was
901
+ * published (non-DEV build, or non-threejs default world).
902
+ */
903
+ function installDefaultWorldDevGlobals(game: GameInternal): () => void {
904
+ if (!import.meta.env?.DEV) return () => {};
905
+ const defaultMounted = game.defaultWorld.mounted;
906
+ if (defaultMounted.kind !== 'threejs') return () => {};
907
+ const scene = defaultMounted.scene;
908
+ const camera = defaultMounted.camera as THREE.PerspectiveCamera;
909
+ const w = window as unknown as Record<string, unknown>;
910
+ w['__vgaiScene'] = scene;
911
+ w['__vgaiCamera'] = camera;
912
+ return () => {
913
+ if (w['__vgaiScene'] === scene) delete w['__vgaiScene'];
914
+ if (w['__vgaiCamera'] === camera) delete w['__vgaiCamera'];
915
+ };
916
+ }
917
+
918
+ /** Shared inputs `mountAllWorldSpecs` needs beyond each individual spec —
919
+ * everything `OneWorldContext` needs except the per-world `canvas`/
920
+ * `isBottom`, plus the surface lookup and bottom-id needed to derive them. */
921
+ interface MountAllWorldsInputs extends Omit<OneWorldContext, 'canvas' | 'isBottom'> {
922
+ readonly surfacesById: Map<string, HTMLElement>;
923
+ }
924
+
925
+ interface MountAllWorldsResult {
926
+ readonly mountedEntries: MountedWorldEntry[];
927
+ readonly routerEntries: RouterWorldEntry[];
928
+ }
929
+
930
+ /**
931
+ * Mount + register every world spec, in ARRAY (declaration) order — split
932
+ * out of `createWorldsGameRuntime` purely to keep that function's own
933
+ * cyclomatic complexity down (E4, same reason `mountOneThreeWorld`/
934
+ * `mountOnePixiWorld` are already split out). React worlds contribute NO
935
+ * router entry (docs/REACT-WORLD-DESIGN.md §1.C — "DOM layers need no entry
936
+ * in the router's hit-test loop"): their layer's own `pointer-events`
937
+ * discipline handles claim/fall-through natively, with zero router
938
+ * involvement (see `mountOneReactWorld`'s doc comment).
939
+ */
940
+ async function mountAllWorldSpecs(
941
+ mountSpecs: (ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec)[],
942
+ bottomId: string | undefined,
943
+ inputs: MountAllWorldsInputs,
944
+ ): Promise<MountAllWorldsResult> {
945
+ const { surfacesById, ...shared } = inputs;
946
+ const mountedEntries: MountedWorldEntry[] = [];
947
+ const routerEntries: RouterWorldEntry[] = [];
948
+
949
+ for (const spec of mountSpecs) {
950
+ const isBottom = spec.id === bottomId;
951
+ if (spec.kind === 'react') {
952
+ const layer = surfacesById.get(spec.id)!;
953
+ const { mountedEntry } = await mountOneReactWorld(spec, shared.game, layer);
954
+ mountedEntries.push(mountedEntry);
955
+ continue;
956
+ }
957
+ const canvas = surfacesById.get(spec.id)! as HTMLCanvasElement;
958
+ const oneCtx: OneWorldContext = { ...shared, canvas, isBottom };
959
+ let mountedEntry: MountedWorldEntry;
960
+ let routerEntry: RouterWorldEntry;
961
+ if (spec.kind === 'threejs') {
962
+ ({ mountedEntry, routerEntry } = await mountOneThreeWorld(spec, oneCtx));
963
+ } else if (spec.kind === 'pixijs') {
964
+ ({ mountedEntry, routerEntry } = await mountOnePixiWorld(spec, oneCtx));
965
+ } else {
966
+ // Exhaustiveness guard (§7.4-2): 'react' was already handled by the
967
+ // early `continue` above, so only a hypothetical 4th `WorldKind` can
968
+ // reach here — fail loudly rather than silently defaulting. `spec`
969
+ // itself (not `spec.kind`) is what TS has narrowed to `never`, since
970
+ // `WorldMountSpec` is a discriminated union at the object level.
971
+ assertNever(spec, 'create-runtime mount loop');
972
+ }
973
+ mountedEntries.push(mountedEntry);
974
+ routerEntries.push(routerEntry);
975
+ }
976
+ return { mountedEntries, routerEntries };
977
+ }
978
+
979
+ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<GameSession> {
980
+ const { container, worlds: specs, width, height, headless = false } = config;
981
+ if (specs.length === 0) {
982
+ throw new Error('createGameRuntime: `worlds` must contain at least one world.');
983
+ }
984
+ const mountSpecs = specs as (ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec)[];
985
+
986
+ const w = width ?? (container as HTMLElement & { clientWidth?: number }).clientWidth ?? 0;
987
+ const h = height ?? (container as HTMLElement & { clientHeight?: number }).clientHeight ?? 0;
988
+ const dpr = headless
989
+ ? 1
990
+ : Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2);
991
+
992
+ if (!container.style.position) container.style.position = 'relative';
993
+
994
+ // --- Surface stack (D5 §1): DOM/z-order follows zOrder, ties -> array
995
+ // order — computed FIRST (bottom -> top) so both the z-index assignment
996
+ // below and the router's default-claim rule share one definition. A react
997
+ // world's DOM-root layer shares this SAME stacking pass (T6.2 slice 1,
998
+ // docs/REACT-WORLD-DESIGN.md §1.B — "one stacking model, no special case")
999
+ // even though it is a `<div>`, not a canvas, and carries no `hitTest` (the
1000
+ // router never sees react entries at all — see the dispatch loop below). ---
1001
+ const stacked = stackOrder(
1002
+ mountSpecs.map((spec) => ({ id: spec.id, zOrder: spec.zOrder ?? 0, hitTest: spec.hitTest })),
1003
+ );
1004
+ const bottomId = stacked[0]?.id;
1005
+ const kindById = new Map(mountSpecs.map((spec) => [spec.id, spec.kind] as const));
1006
+
1007
+ const surfacesById = new Map<string, HTMLElement>();
1008
+ stacked.forEach((entry, i) => {
1009
+ const isReact = kindById.get(entry.id) === 'react';
1010
+ const surface = document.createElement(isReact ? 'div' : 'canvas') as HTMLElement;
1011
+ if (!isReact) {
1012
+ const canvas = surface as unknown as HTMLCanvasElement;
1013
+ canvas.width = w;
1014
+ canvas.height = h;
1015
+ }
1016
+ // Defect E4.R1 (Fable review): layout size is ALWAYS container-relative
1017
+ // (`width:100%;height:100%`), fully decoupled from the surface's
1018
+ // intrinsic buffer resolution (`w`/`h`, set on the canvas element's
1019
+ // `width`/`height` ATTRIBUTES above — a device-pixel/render-resolution
1020
+ // concern only). Without this, a canvas with no CSS size falls back to
1021
+ // its `width`/`height` attribute as its CSS layout size too, so whatever
1022
+ // stamped those attributes (`createHostRenderer`'s construction-time
1023
+ // `setSize`, `mountOneThreeWorld` below) pins the ON-SCREEN size — see
1024
+ // that function's doc comment for the concrete bug this caused (template
1025
+ // standalone at any viewport ≠ the manifest resolution). Setting it HERE
1026
+ // too (not just in `mountOneThreeWorld`) means every surface, whatever
1027
+ // kind, starts container-relative from its very first paint, before any
1028
+ // per-kind mount work has even run.
1029
+ surface.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;z-index:${i + 1};`;
1030
+ container.appendChild(surface);
1031
+ surfacesById.set(entry.id, surface);
1032
+ });
1033
+
1034
+ // One shared UI overlay, above every canvas.
1035
+ const uiContainer = document.createElement('div');
1036
+ uiContainer.style.cssText =
1037
+ `position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;` +
1038
+ `z-index:${stacked.length + 1};`;
1039
+ container.appendChild(uiContainer);
1040
+
1041
+ const assets = createAssetCache();
1042
+ const extraUpdaters = new Set<(dt: number) => void>();
1043
+ const loopHandle: LoopHandle = {
1044
+ onUpdate(fn) {
1045
+ extraUpdaters.add(fn);
1046
+ return () => extraUpdaters.delete(fn);
1047
+ },
1048
+ };
1049
+ let started = false;
1050
+ const loop = createGameLoop({
1051
+ fixedTimestep: 1 / 60,
1052
+ maxSubSteps: 8,
1053
+ update: (dt) => {
1054
+ if (started) game.runFrame(dt);
1055
+ for (const fn of extraUpdaters) fn(dt);
1056
+ },
1057
+ });
1058
+ const game = createGame({ loop, assets });
1059
+
1060
+ // --- Mount + register every world, in ARRAY (declaration) order. ---
1061
+ // Split out into its own top-level function purely to keep
1062
+ // `createWorldsGameRuntime`'s own cyclomatic complexity down (E4) — same
1063
+ // reason `mountOneThreeWorld`/`mountOnePixiWorld` are already split out
1064
+ // below.
1065
+ const { mountedEntries, routerEntries } = await mountAllWorldSpecs(mountSpecs, bottomId, {
1066
+ game,
1067
+ surfacesById,
1068
+ w,
1069
+ h,
1070
+ dpr,
1071
+ headless,
1072
+ uiContainer,
1073
+ loopHandle,
1074
+ assets,
1075
+ });
1076
+
1077
+ // --- Delegating input router (D5 §2a) ---
1078
+ const router = createInputRouter(container, routerEntries);
1079
+
1080
+ started = true;
1081
+ loop.start();
1082
+
1083
+ // Expose scene & camera for dev tools / e2e tests — mirrors the legacy
1084
+ // single-world path's identical dev-only exposure above, generalized to
1085
+ // the worlds path's DEFAULT world (E4, docs/unified-world-editor/
1086
+ // 27-visual-react-editing.md §7 E4): `mountGameFromManifest`/
1087
+ // `mountManifestWorlds` route every caller (including a single-threejs-
1088
+ // world scaffold project) through THIS path, so a caller migrating off
1089
+ // the legacy `{canvas, adapter}` call must not silently lose
1090
+ // `window.__vgaiScene`/`__vgaiCamera` — real e2e/dev tooling depends on
1091
+ // them (`packages/editor/e2e/tests/04-standalone-game.spec.ts`). Split
1092
+ // into its own top-level helper (with its retraction counterpart below)
1093
+ // purely to keep this function's own cyclomatic complexity down.
1094
+ const retractDevGlobals = installDefaultWorldDevGlobals(game);
1095
+
1096
+ function fullCleanup(): void {
1097
+ loop.stop();
1098
+ router.dispose();
1099
+ for (const entry of mountedEntries) {
1100
+ entry.mounted.dispose();
1101
+ entry.renderer?.dispose();
1102
+ entry.renderer?.forceContextLoss();
1103
+ container.removeChild(entry.element);
1104
+ }
1105
+ container.removeChild(uiContainer);
1106
+ retractDevGlobals();
1107
+ }
1108
+
1109
+ return {
1110
+ stop: fullCleanup,
1111
+ // D10/T7.6: `Game.play` fans out per-world pausable/loop-gate/audio-gate
1112
+ // semantics itself now (see the legacy path's identical comment above) —
1113
+ // a `pausable: false` world (a menu/HUD world) keeps ticking while every
1114
+ // other world freezes, which the old blind `for (const entry of
1115
+ // mountedEntries) entry.mounted.setPaused?.(true)` fan-out (with no
1116
+ // `pausable` check at all) could never express.
1117
+ pause() {
1118
+ game.play.pause();
1119
+ },
1120
+ resume() {
1121
+ game.play.resume();
1122
+ },
1123
+ step() {
1124
+ game.play.step();
1125
+ },
1126
+ resize(rw: number, rh: number) {
1127
+ for (const entry of mountedEntries) {
1128
+ entry.renderer?.setSize(rw, rh, false);
1129
+ entry.mounted.resize?.(rw, rh);
1130
+ }
1131
+ },
1132
+ // `GameSession.scene`/`.camera`/`.mounted` alias `Game.defaultWorld` (T6.1
1133
+ // slice 1's "first threejs world, else first world" rule) — a legacy
1134
+ // convenience shaped for the threejs-only past. T7.5 narrows the read via
1135
+ // the `kind` discriminant instead of a blind `.scene`/`.camera` cast
1136
+ // through a nonexistent property (identical behavior to before: still
1137
+ // `undefined` for a worlds-path session whose default world isn't
1138
+ // threejs — real per-world surface routing for that case is T7.6's).
1139
+ get scene() {
1140
+ const m = game.defaultWorld.mounted;
1141
+ return (m.kind === 'threejs' ? m.scene : undefined) as THREE.Scene;
1142
+ },
1143
+ get camera() {
1144
+ const m = game.defaultWorld.mounted;
1145
+ return (m.kind === 'threejs' ? m.camera : undefined) as THREE.PerspectiveCamera;
1146
+ },
1147
+ get mounted() {
1148
+ return game.defaultWorld.mounted as MountedGame;
1149
+ },
1150
+ game,
1151
+ };
1152
+ }