@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +24 -4
- package/schemas/engine-api.json +124 -0
- package/schemas/engine-api.md +53 -0
- package/schemas/engine-capabilities.json +124 -0
- package/schemas/inputmap.schema.json +314 -0
- package/schemas/mat.schema.json +286 -0
- package/schemas/prefab.schema.json +10148 -0
- package/schemas/scn2d.schema.json +475 -0
- package/schemas/vgai-game.schema.json +383 -0
- package/schemas/vscn.schema.json +11007 -0
- package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
- package/src/adapter/authoring.ts +77 -0
- package/src/adapter/first-party-systems.ts +23 -34
- package/src/adapter/game-adapter.ts +8 -8
- package/src/adapter/host-context.ts +2 -4
- package/src/adapter/index.ts +4 -4
- package/src/adapter/system-adapter.ts +88 -22
- package/src/adapter/vgai-scene-game-adapter.ts +244 -194
- package/src/animation/anim-graph-types.ts +12 -43
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/camera-ownership.ts +467 -0
- package/src/animation/cinematic-cues.ts +451 -0
- package/src/animation/clip-map.ts +41 -0
- package/src/animation/gsap-registration.ts +184 -0
- package/src/animation/theatre-clock-binding.ts +111 -0
- package/src/animation/theatre-director.ts +347 -0
- package/src/animation/theatre-object-binding.ts +661 -0
- package/src/animation/xstate-animation-binding.ts +436 -0
- package/src/animation/xstate-animation-meta.ts +319 -0
- package/src/audio/index.ts +39 -7
- package/src/audio/tone-clock-binding.ts +98 -0
- package/src/audio/tone-context.ts +129 -0
- package/src/audio/tone-offline-render.ts +167 -0
- package/src/audio/wav-encode.ts +119 -0
- package/src/character/cloth-sim.ts +533 -0
- package/src/character/spring-chain.ts +307 -0
- package/src/core/game-loop.ts +57 -2
- package/src/core/seeded-random.ts +161 -0
- package/src/core/system-runner.ts +20 -3
- package/src/core/types.ts +50 -0
- package/src/data/data-asset.ts +167 -0
- package/src/data/data-check-core.ts +242 -0
- package/src/data/data-ref.ts +145 -0
- package/src/data/vite-plugin-data.ts +290 -0
- package/src/dev/performance-profiler.ts +213 -0
- package/src/dev/webgl-gpu-timer.ts +53 -0
- package/src/ecs/component-manager.ts +45 -12
- package/src/ecs/game-component.ts +95 -11
- package/src/humanoid/bake.operation.ts +326 -0
- package/src/humanoid/body.ts +663 -0
- package/src/humanoid/clips.ts +149 -0
- package/src/humanoid/compose.ts +209 -0
- package/src/humanoid/generate.ts +189 -0
- package/src/humanoid/index.ts +36 -0
- package/src/humanoid/schema.ts +108 -0
- package/src/humanoid/skeleton.ts +345 -0
- package/src/index.ts +48 -0
- package/src/input/input-manager.ts +1886 -33
- package/src/input/input-types.ts +158 -3
- package/src/input/prompt-labels.ts +122 -0
- package/src/input/rebind-controller.ts +105 -0
- package/src/input/schema.ts +206 -52
- package/src/manifest/index.ts +5 -5
- package/src/manifest/load.ts +125 -72
- package/src/manifest/schema.ts +362 -255
- package/src/react/game-state.tsx +135 -32
- package/src/react/root-adapter.tsx +49 -0
- package/src/react/unmanaged-root-detector.ts +66 -0
- package/src/react/use-data.ts +124 -0
- package/src/react/use-selection.tsx +135 -0
- package/src/runtime/create-runtime.ts +112 -273
- package/src/runtime/debug-bridge.ts +483 -0
- package/src/runtime/debug-registry.ts +856 -0
- package/src/runtime/game.ts +342 -93
- package/src/runtime/gameplay-rng-trap.ts +134 -0
- package/src/runtime/input-router.ts +7 -7
- package/src/runtime/mount-game.ts +40 -38
- package/src/runtime/mount-manifest.ts +169 -37
- package/src/runtime/render-audio-control.ts +168 -0
- package/src/runtime/render-control.ts +522 -0
- package/src/runtime/render-seed.ts +79 -0
- package/src/runtime/state-bridge.ts +24 -10
- package/src/runtime/types.ts +110 -33
- package/src/scene/asset-loaders.ts +10 -36
- package/src/scene/asset-paths.ts +0 -2
- package/src/scene/asset-ref-check.ts +248 -0
- package/src/scene/asset-registry.ts +22 -0
- package/src/scene/component-registry.ts +14 -3
- package/src/scene/defaults.ts +1 -0
- package/src/scene/light-camera-factory.ts +11 -3
- package/src/scene/parse.ts +133 -0
- package/src/scene/scene-apply.ts +55 -4
- package/src/scene/scene-loader.ts +91 -123
- package/src/scene/scene-types.ts +0 -1
- package/src/scene/schema/animation.ts +30 -79
- package/src/scene/schema/entity.ts +20 -0
- package/src/scene/schema/index.ts +2 -46
- package/src/scene/schema/light.ts +16 -1
- package/src/scene/schema/material.ts +96 -91
- package/src/scene/schema/scene-file.ts +1 -7
- package/src/scene/user-data.ts +22 -10
- package/src/setup/setup-renderer.ts +10 -3
- package/src/tools/define-tool.ts +191 -0
- package/src/world2d/authoring-2d.ts +17 -1
- package/src/world2d/collision-2d.ts +1 -1
- package/src/world2d/pixi-game-adapter.ts +19 -17
- package/src/world2d/scene2d-loader.ts +1 -0
- package/src/world2d/types.ts +8 -2
- package/src/animation/anim-graph.ts +0 -406
- package/src/animation/anim-system.ts +0 -28
- package/src/animation/property-track.ts +0 -178
- package/src/animation/schema.ts +0 -204
- package/src/audio/ambient.ts +0 -300
- package/src/audio/impacts.ts +0 -212
- package/src/audio/movement.ts +0 -140
- package/src/audio/musical.ts +0 -200
- package/src/audio/ui-sounds.ts +0 -171
- package/src/audio/vehicle.ts +0 -235
- package/src/audio/weapons.ts +0 -152
- package/src/runtime/scene-ui-bridge.ts +0 -86
- package/src/runtime/scene-ui-data.ts +0 -119
- package/src/scene/schema/ui.ts +0 -602
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
|
+
import { assertNever } from '../adapter/adapter-surface';
|
|
2
3
|
import type {
|
|
3
4
|
GameAdapter,
|
|
4
5
|
MountedGame,
|
|
@@ -6,8 +7,6 @@ import type {
|
|
|
6
7
|
MountedWorld,
|
|
7
8
|
} from '../adapter/game-adapter';
|
|
8
9
|
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
10
|
import { createAssetCache } from '../assets';
|
|
12
11
|
import { createGameLoop } from '../core/game-loop';
|
|
13
12
|
import { createHostRenderer } from '../setup/setup-renderer';
|
|
@@ -25,7 +24,7 @@ import {
|
|
|
25
24
|
isFirstPartyMounted,
|
|
26
25
|
type WorldInstance,
|
|
27
26
|
} from './game';
|
|
28
|
-
import { createInputRouter, type
|
|
27
|
+
import { createInputRouter, type RouterAdapterRoot, stackOrder } from './input-router';
|
|
29
28
|
|
|
30
29
|
// Re-export the camera-precedence helper from its new home so existing importers
|
|
31
30
|
// (`import { adoptSceneCamera } from '@engine/runtime/create-runtime'`) keep working.
|
|
@@ -35,7 +34,7 @@ export { adoptSceneCamera } from '../adapter/vgai-scene-game-adapter';
|
|
|
35
34
|
* Register a threejs world for a freshly-mounted game onto the Game shell.
|
|
36
35
|
* Shared by `createGameRuntime` and every headless test harness
|
|
37
36
|
* (`test/game-root.test.ts`, `test/frame-order.test.ts`,
|
|
38
|
-
* `test/game-two-
|
|
37
|
+
* `test/game-two-roots.test.ts`) so there is exactly one code path for this
|
|
39
38
|
* wiring (T7.1 slice 1, id-generalized in slice 3 for the two-world proof —
|
|
40
39
|
* `docs/GAME-ROOT-DESIGN.md` §8 stage 3) — `physics`/`collisions`/`camera`/
|
|
41
40
|
* `frame` are populated from the mount's first-party `GameContext`/
|
|
@@ -94,25 +93,14 @@ export function registerThreeWorld(
|
|
|
94
93
|
return world;
|
|
95
94
|
}
|
|
96
95
|
|
|
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
96
|
/**
|
|
109
97
|
* Register a pixijs world onto the Game shell — the pixi analog of
|
|
110
98
|
* {@link registerThreeWorld}, mirroring the wiring
|
|
111
99
|
* `world2d/pixi-game-adapter.ts`'s `createWorld2DRuntime` performs for its
|
|
112
100
|
* OWN (separate) Game, and `test/game-three-plus-pixi.test.ts` performs by
|
|
113
|
-
* hand onto a SHARED Game. `createGameRuntime`'s
|
|
101
|
+
* hand onto a SHARED Game. `createGameRuntime`'s roots path
|
|
114
102
|
* (T6.1 slice 1) is the first PRODUCTION caller that registers a pixijs
|
|
115
|
-
* world alongside other
|
|
103
|
+
* world alongside other roots on one Game; this is its one wiring code
|
|
116
104
|
* path, so a second/third pixijs world (or a future caller) never
|
|
117
105
|
* re-derives it. `adapter` is typed as the real {@link Pixi2DGameAdapter}
|
|
118
106
|
* shape (T7.5 — previously the generic `GameAdapter`, which forced a cast at
|
|
@@ -132,7 +120,7 @@ export function registerDefaultThreeWorld(
|
|
|
132
120
|
* only via `adapter as unknown as Pixi2DGameAdapter` (a custom module's actual
|
|
133
121
|
* return value is merely `MountedPixiWorld`-shaped, T7.5's cross-kind union),
|
|
134
122
|
* so the "never actually fire" premise no longer holds the moment a custom
|
|
135
|
-
* pixi module world reaches the
|
|
123
|
+
* pixi module world reaches the roots path. Rather than keep the throw (which
|
|
136
124
|
* would re-create exactly the kind of dead end D-X6 named and D-Y1 exists to
|
|
137
125
|
* remove), this now degrades EXACTLY like `registerThreeWorld`'s own foreign-
|
|
138
126
|
* mount handling above (`firstPartyExtras`): `physics2d`/`collisions2d`/
|
|
@@ -194,13 +182,13 @@ export function registerPixiWorld(
|
|
|
194
182
|
* its commits; `GameInternal.runFrame` correctly leaves a world with no
|
|
195
183
|
* `frame` untouched by its opaque-`update` fallback too, since
|
|
196
184
|
* `MountedReactGame` declares no `update`). `adapter` is typed as the real
|
|
197
|
-
* {@link
|
|
185
|
+
* {@link ReactRootAdapter} shape (T7.5, same reasoning as
|
|
198
186
|
* `registerPixiWorld`'s doc comment above) — `WorldInstance.adapter` only
|
|
199
187
|
* needs `.id`, so this passes through with zero cast.
|
|
200
188
|
*/
|
|
201
189
|
export function registerReactWorld(
|
|
202
190
|
game: GameInternal,
|
|
203
|
-
adapter:
|
|
191
|
+
adapter: ReactRootAdapter,
|
|
204
192
|
mounted: MountedReactGame,
|
|
205
193
|
container: HTMLElement,
|
|
206
194
|
opts?: { id?: string | undefined; pausable?: boolean | undefined },
|
|
@@ -217,28 +205,6 @@ export function registerReactWorld(
|
|
|
217
205
|
return world;
|
|
218
206
|
}
|
|
219
207
|
|
|
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
208
|
/**
|
|
243
209
|
* A pixijs-shaped adapter — the structural contract `WorldMountSpec`'s
|
|
244
210
|
* `pixijs` variant requires. `PixiSceneGameAdapter` (`world2d/
|
|
@@ -252,17 +218,17 @@ export interface Pixi2DGameAdapter {
|
|
|
252
218
|
}
|
|
253
219
|
|
|
254
220
|
/**
|
|
255
|
-
* One world to mount in the {@link WorldsRuntimeConfig}
|
|
221
|
+
* One world to mount in the {@link WorldsRuntimeConfig} roots path (T6.1
|
|
256
222
|
* slice 1) — the host-facing mirror of `manifest/load.ts`'s
|
|
257
|
-
* `
|
|
223
|
+
* `ResolvedAdapterRoot` (same `id`/`zOrder`/`pausable`/`loop` fields; the
|
|
258
224
|
* manifest-to-host translation itself is the editor's job, not this file's).
|
|
259
225
|
*
|
|
260
226
|
* `react` (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md`) mounts as a DOM-root
|
|
261
227
|
* layer `<div>` in the SAME stack instead of a canvas — see
|
|
262
|
-
* {@link ReactWorldMountSpec}/{@link
|
|
228
|
+
* {@link ReactWorldMountSpec}/{@link ReactRootAdapter} below.
|
|
263
229
|
*/
|
|
264
230
|
export interface WorldMountSpecBase {
|
|
265
|
-
/** Manifest id — must be unique within one `
|
|
231
|
+
/** Manifest id — must be unique within one `roots` array. */
|
|
266
232
|
readonly id: string;
|
|
267
233
|
/** Canvas stacking order (COMPOSITION-DESIGN D5 §1); ties broken by array
|
|
268
234
|
* order, mirroring `manifest/load.ts`'s `loadGameManifest` sort. Defaults
|
|
@@ -272,7 +238,7 @@ export interface WorldMountSpecBase {
|
|
|
272
238
|
readonly pausable?: boolean | undefined;
|
|
273
239
|
/** `'gated'` (host-driven, default) or `'self-driven'` (this world drives
|
|
274
240
|
* its own loop — D5's "composited, unsynchronized" tier). Carried through
|
|
275
|
-
* for parity with `
|
|
241
|
+
* for parity with `ResolvedAdapterRoot`; T6.1 slice 1 does not yet
|
|
276
242
|
* validate it against the mounted adapter's actual `drivesOwnLoop` (that
|
|
277
243
|
* cross-check, if ever needed, is T7.6's loop-gate surface). */
|
|
278
244
|
readonly loop?: 'gated' | 'self-driven' | undefined;
|
|
@@ -300,7 +266,7 @@ export interface PixiWorldMountSpec extends WorldMountSpecBase {
|
|
|
300
266
|
}
|
|
301
267
|
|
|
302
268
|
/**
|
|
303
|
-
* The host-facing surface handed to a {@link
|
|
269
|
+
* The host-facing surface handed to a {@link ReactRootAdapter}'s `mount`
|
|
304
270
|
* (T6.2 slice 1, `docs/REACT-WORLD-DESIGN.md` §1.B) — the react analog of
|
|
305
271
|
* {@link World2DHost}. `container` is the absolutely-positioned, z-ordered
|
|
306
272
|
* DOM-root layer `<div>` the host already created and stacked (same box/
|
|
@@ -334,7 +300,7 @@ export interface ReactWorldHost {
|
|
|
334
300
|
|
|
335
301
|
/**
|
|
336
302
|
* A live, mounted react world (T6.2 slice 1) — the react analog of
|
|
337
|
-
* {@link MountedGame2D}. React
|
|
303
|
+
* {@link MountedGame2D}. React roots host no ticking components (D8 —
|
|
338
304
|
* `ecs/component-manager.ts` already throws on any attach to a react-kind
|
|
339
305
|
* manager) and render from game state via the T7.4 bridge instead of a
|
|
340
306
|
* per-frame `update`, so this shape carries no `update`/`fixedUpdate`/
|
|
@@ -361,7 +327,7 @@ export interface ReactWorldHost {
|
|
|
361
327
|
* game-adapter.ts`) — `container` is the SAME `ReactWorldHost.container` the
|
|
362
328
|
* adapter's `mount` was handed (identity matters, mirroring `threeRoot()`/
|
|
363
329
|
* `pixiRoot()`'s "same instance the adapter mounted" contract); every
|
|
364
|
-
* `
|
|
330
|
+
* `ReactRootAdapter` implementer echoes it back here so `mounted` alone
|
|
365
331
|
* (with no separately-threaded `container`) satisfies the union
|
|
366
332
|
* `WorldInstanceInit.mounted`/`WorldInstance.mounted` with zero cast.
|
|
367
333
|
*/
|
|
@@ -376,35 +342,30 @@ export interface MountedReactGame extends MountedReactWorld {
|
|
|
376
342
|
* `default-react` resolver branch, T6.2's editor-side follow-up) can satisfy
|
|
377
343
|
* this shape without this file importing react-dom or any editor code.
|
|
378
344
|
*/
|
|
379
|
-
export interface
|
|
345
|
+
export interface ReactRootAdapter {
|
|
380
346
|
readonly id: string;
|
|
381
347
|
mount(host: ReactWorldHost): Promise<MountedReactGame>;
|
|
382
348
|
}
|
|
383
349
|
|
|
384
350
|
export interface ReactWorldMountSpec extends WorldMountSpecBase {
|
|
385
351
|
readonly kind: 'react';
|
|
386
|
-
readonly adapter:
|
|
352
|
+
readonly adapter: ReactRootAdapter;
|
|
387
353
|
}
|
|
388
354
|
|
|
389
355
|
export type WorldMountSpec = ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec;
|
|
390
356
|
|
|
391
357
|
/**
|
|
392
|
-
*
|
|
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)
|
|
358
|
+
* Mount N adapter roots (threejs + pixijs + react)
|
|
395
359
|
* on ONE `Game`, stacked in `container` per COMPOSITION-DESIGN D5 §1.
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
* first world" rule) is the default world, so
|
|
399
|
-
* `GameSession.scene`/`.camera`/`.mounted` keep aliasing exactly as they do
|
|
400
|
-
* today.
|
|
360
|
+
* `roots[0]` participates in `Game.defaultWorld`'s existing "first threejs
|
|
361
|
+
* root, else first root" rule.
|
|
401
362
|
*/
|
|
402
363
|
export interface WorldsRuntimeConfig {
|
|
403
364
|
/** The host creates one absolutely-positioned surface per world inside
|
|
404
365
|
* this element (D5 §1) — a canvas for threejs/pixijs, a DOM-root `<div>`
|
|
405
366
|
* layer for react — plus one shared UI overlay above all of them. */
|
|
406
367
|
container: HTMLElement;
|
|
407
|
-
|
|
368
|
+
roots: WorldMountSpec[];
|
|
408
369
|
width?: number | undefined;
|
|
409
370
|
height?: number | undefined;
|
|
410
371
|
/**
|
|
@@ -412,18 +373,20 @@ export interface WorldsRuntimeConfig {
|
|
|
412
373
|
* real `WebGLRenderer` construction for every threejs world in this
|
|
413
374
|
* session (a stand-in renderer is used instead, exactly as
|
|
414
375
|
* `VgaiSceneGameAdapter.mount` already special-cases `host.headless`
|
|
415
|
-
* internally). Pixijs
|
|
376
|
+
* internally). Pixijs roots are unaffected — Pixi already falls back to
|
|
416
377
|
* a 2D canvas renderer with no GPU. Never set `true` in a real host.
|
|
417
378
|
*/
|
|
418
379
|
headless?: boolean | undefined;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
380
|
+
/** D15 (T-D15.1) — the root seed `ctx.random` boots from on every world
|
|
381
|
+
* mounted onto this session's Game, forwarded to `createGame` BEFORE any
|
|
382
|
+
* world's `mount()`/`setup()` runs (this function constructs the Game
|
|
383
|
+
* first — see `createWorldsGameRuntime`). The manifest-aware boot path
|
|
384
|
+
* (`mount-manifest.ts`'s `mountManifestWorlds`) is the real caller that
|
|
385
|
+
* resolves this from `manifest.determinism`/`?vgai-seed=`/its own
|
|
386
|
+
* explicit-config leg; a caller building `WorldsRuntimeConfig` by hand
|
|
387
|
+
* (a test, a bespoke host) may also set it directly. Omitting it falls
|
|
388
|
+
* back to `createGame`'s own fixed default. */
|
|
389
|
+
seed?: number | undefined;
|
|
427
390
|
}
|
|
428
391
|
|
|
429
392
|
/**
|
|
@@ -434,9 +397,8 @@ function isWorldsConfig(config: RuntimeConfig): config is WorldsRuntimeConfig {
|
|
|
434
397
|
* warm-restart hot reload) are reached by casting `mounted` to `VgaiMountedGame`
|
|
435
398
|
* (the editor does this for HMR/physics-sync — those are inherently first-party).
|
|
436
399
|
*
|
|
437
|
-
* `scene`/`camera`/`mounted` alias the Game's `defaultWorld
|
|
438
|
-
*
|
|
439
|
-
* before; for the worlds path it is the first threejs world (else the first
|
|
400
|
+
* `scene`/`camera`/`mounted` alias the Game's `defaultWorld`: it is the first
|
|
401
|
+
* threejs root (else the first
|
|
440
402
|
* world) by DECLARATION order, per `Game.defaultWorld`'s existing rule —
|
|
441
403
|
* independent of `zOrder`/canvas stacking, which is a rendering-only concern.
|
|
442
404
|
*/
|
|
@@ -451,7 +413,7 @@ export interface GameSession {
|
|
|
451
413
|
/** The mounted game (interface surface). Cast to `VgaiMountedGame` for first-party extras. */
|
|
452
414
|
readonly mounted: MountedGame;
|
|
453
415
|
/** The Game root (T6.1 slice 1) — the multi-world entry point
|
|
454
|
-
* (`game.
|
|
416
|
+
* (`game.roots`/`game.world(id)`/`game.queryByComponent`) for callers
|
|
455
417
|
* that need more than the default-world aliases above. */
|
|
456
418
|
readonly game: Game;
|
|
457
419
|
}
|
|
@@ -459,154 +421,12 @@ export interface GameSession {
|
|
|
459
421
|
/**
|
|
460
422
|
* Bootstrap the game host and mount a game adapter.
|
|
461
423
|
*
|
|
462
|
-
* `createGameRuntime` is the HOST: it owns the
|
|
463
|
-
*
|
|
464
|
-
*
|
|
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.
|
|
424
|
+
* `createGameRuntime` is the universal HOST: it owns the root surfaces,
|
|
425
|
+
* renderers, loop, and asset cache. Every game — including a one-root game —
|
|
426
|
+
* uses the same explicit adapter-root path.
|
|
471
427
|
*/
|
|
472
|
-
export async function createGameRuntime(config:
|
|
473
|
-
|
|
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
|
-
};
|
|
428
|
+
export async function createGameRuntime(config: WorldsRuntimeConfig): Promise<GameSession> {
|
|
429
|
+
return createWorldsGameRuntime(config);
|
|
610
430
|
}
|
|
611
431
|
|
|
612
432
|
// ---------------------------------------------------------------------------
|
|
@@ -614,7 +434,7 @@ export async function createGameRuntime(config: RuntimeConfig): Promise<GameSess
|
|
|
614
434
|
// ---------------------------------------------------------------------------
|
|
615
435
|
|
|
616
436
|
/** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) threejs
|
|
617
|
-
*
|
|
437
|
+
* roots — mirrors `VgaiSceneGameAdapter.mount`'s own `headlessComposer`
|
|
618
438
|
* pattern (`adapter/vgai-scene-game-adapter.ts`): a headless first-party
|
|
619
439
|
* mount never calls a render-phase method on `host.renderer` at all, so
|
|
620
440
|
* this only needs to satisfy the handful of calls THIS file itself makes
|
|
@@ -654,11 +474,26 @@ function derivePixiHitTest(
|
|
|
654
474
|
): ((x: number, y: number) => boolean) | undefined {
|
|
655
475
|
if ((mounted as { firstParty?: unknown }).firstParty !== true) return undefined;
|
|
656
476
|
const app = (mounted.ctx as unknown as { app?: unknown }).app as
|
|
657
|
-
| {
|
|
477
|
+
| {
|
|
478
|
+
renderer?: {
|
|
479
|
+
events?: {
|
|
480
|
+
rootBoundary?: {
|
|
481
|
+
rootTarget?: unknown;
|
|
482
|
+
hitTest?: (x: number, y: number) => unknown;
|
|
483
|
+
};
|
|
484
|
+
};
|
|
485
|
+
};
|
|
486
|
+
}
|
|
658
487
|
| undefined;
|
|
659
488
|
const rootBoundary = app?.renderer?.events?.rootBoundary;
|
|
660
489
|
if (!rootBoundary || typeof rootBoundary.hitTest !== 'function') return undefined;
|
|
661
|
-
|
|
490
|
+
// Pixi assigns `rootTarget` lazily on the first render. Pointer movement
|
|
491
|
+
// can reach the host router during that small window (or while a world is
|
|
492
|
+
// tearing down); EventBoundary.hitTest dereferences it unconditionally.
|
|
493
|
+
// An unready boundary cannot claim input, so degrade to no hit instead of
|
|
494
|
+
// surfacing Pixi's internal `undefined.eventMode` exception.
|
|
495
|
+
return (x: number, y: number) =>
|
|
496
|
+
rootBoundary.rootTarget != null && rootBoundary.hitTest!(x, y) != null;
|
|
662
497
|
}
|
|
663
498
|
|
|
664
499
|
/** One already-mounted world, tracked for disposal + the router. `element`
|
|
@@ -666,18 +501,18 @@ function derivePixiHitTest(
|
|
|
666
501
|
* pixijs, or the DOM-root layer `<div>` for react (T6.2 slice 1) — kept
|
|
667
502
|
* under one field name so `fullCleanup`'s disposal loop stays kind-generic
|
|
668
503
|
* (`container.removeChild(entry.element)` needs no branch). */
|
|
669
|
-
interface
|
|
504
|
+
interface MountedAdapterRoot {
|
|
670
505
|
readonly id: string;
|
|
671
506
|
readonly kind: 'threejs' | 'pixijs' | 'react';
|
|
672
507
|
readonly element: HTMLElement;
|
|
673
508
|
readonly mounted: MountedWorld;
|
|
674
|
-
/** Only threejs
|
|
509
|
+
/** Only threejs roots own a renderer this file constructed. */
|
|
675
510
|
readonly renderer: THREE.WebGLRenderer | undefined;
|
|
676
511
|
}
|
|
677
512
|
|
|
678
513
|
interface OneWorldResult {
|
|
679
|
-
readonly mountedEntry:
|
|
680
|
-
readonly routerEntry:
|
|
514
|
+
readonly mountedEntry: MountedAdapterRoot;
|
|
515
|
+
readonly routerEntry: RouterAdapterRoot;
|
|
681
516
|
}
|
|
682
517
|
|
|
683
518
|
/** Shared per-world mount inputs, computed once in `createWorldsGameRuntime`'s
|
|
@@ -692,7 +527,6 @@ interface OneWorldContext {
|
|
|
692
527
|
readonly dpr: number;
|
|
693
528
|
readonly isBottom: boolean;
|
|
694
529
|
readonly headless: boolean;
|
|
695
|
-
readonly uiContainer: HTMLDivElement;
|
|
696
530
|
readonly loopHandle: LoopHandle;
|
|
697
531
|
readonly assets: ReturnType<typeof createAssetCache>;
|
|
698
532
|
}
|
|
@@ -705,7 +539,7 @@ async function mountOneThreeWorld(
|
|
|
705
539
|
spec: ThreeWorldMountSpec,
|
|
706
540
|
ctx: OneWorldContext,
|
|
707
541
|
): Promise<OneWorldResult> {
|
|
708
|
-
const { game, canvas, w, h, dpr, isBottom, headless,
|
|
542
|
+
const { game, canvas, w, h, dpr, isBottom, headless, loopHandle, assets } = ctx;
|
|
709
543
|
const renderer = headless
|
|
710
544
|
? createHeadlessRendererStub()
|
|
711
545
|
: createHostRenderer(canvas, w, h, undefined, {
|
|
@@ -721,7 +555,7 @@ async function mountOneThreeWorld(
|
|
|
721
555
|
// loop above just set. The `renderer.setSize(w, h, false)` line right above
|
|
722
556
|
// this comment does NOT undo that stamp (updateStyle:false only skips
|
|
723
557
|
// TOUCHING style, it can't un-stamp a previous call) — so without this
|
|
724
|
-
// re-assertion, every
|
|
558
|
+
// re-assertion, every roots-path threejs canvas' on-screen size was
|
|
725
559
|
// permanently pinned to whatever `w`/`h` it happened to mount at (usually
|
|
726
560
|
// the manifest's `resolution`, since standalone builds mount before a real
|
|
727
561
|
// container size is known — see `mount-manifest.ts`). Re-asserting here
|
|
@@ -730,14 +564,14 @@ async function mountOneThreeWorld(
|
|
|
730
564
|
// fixes the template-standalone bug (broken at any viewport other than the
|
|
731
565
|
// manifest's `resolution`, which is also Playwright's DEFAULT viewport —
|
|
732
566
|
// why it went unnoticed) and, for free, tri-world's pre-existing
|
|
733
|
-
// resize-staleness (the
|
|
567
|
+
// resize-staleness (the roots-path `resize()` below always calls
|
|
734
568
|
// `setSize(rw, rh, false)`, so nothing else was ever going to update this
|
|
735
569
|
// canvas' CSS after mount). `createHostRenderer`'s own default
|
|
736
570
|
// (`updateStyle:true`) is intentionally left alone — the LEGACY
|
|
737
571
|
// single-canvas path (editor play-mode's non-multi-world branch) still
|
|
738
572
|
// relies on that construction-time stamp + its own later `updateStyle:true`
|
|
739
573
|
// resizes for byte-identical behavior; this fix touches only the
|
|
740
|
-
//
|
|
574
|
+
// roots-path canvas, after the fact.
|
|
741
575
|
canvas.style.width = '100%';
|
|
742
576
|
canvas.style.height = '100%';
|
|
743
577
|
renderer.setPixelRatio(dpr);
|
|
@@ -749,7 +583,6 @@ async function mountOneThreeWorld(
|
|
|
749
583
|
renderer,
|
|
750
584
|
loop: loopHandle,
|
|
751
585
|
assets,
|
|
752
|
-
ui: uiContainer,
|
|
753
586
|
headless,
|
|
754
587
|
game,
|
|
755
588
|
requestSystem: () => null,
|
|
@@ -773,7 +606,7 @@ async function mountOneThreeWorld(
|
|
|
773
606
|
* `canvas.style.width`/`.height` (real CSS px, matching the LOGICAL
|
|
774
607
|
* width/height passed to `resize()`) on every `app.renderer.resize()` call —
|
|
775
608
|
* including the one this world's `mounted.resize?.()` triggers from the
|
|
776
|
-
*
|
|
609
|
+
* roots-path `resize()` below. So while a pixi world's canvas can start
|
|
777
610
|
* pinned to the mount-time `w`/`h` (same as threejs, until PIXI's own
|
|
778
611
|
* construction-time resize runs), it self-heals the moment ANY real
|
|
779
612
|
* `session.resize(rw, rh)` fires — every shipped standalone entry
|
|
@@ -785,12 +618,12 @@ async function mountOnePixiWorld(
|
|
|
785
618
|
spec: PixiWorldMountSpec,
|
|
786
619
|
ctx: Omit<OneWorldContext, 'headless' | 'loopHandle' | 'assets'>,
|
|
787
620
|
): Promise<OneWorldResult> {
|
|
788
|
-
const { game, canvas, w, h, dpr, isBottom
|
|
621
|
+
const { game, canvas, w, h, dpr, isBottom } = ctx;
|
|
789
622
|
const pixiHost: World2DHost = {
|
|
790
623
|
canvas,
|
|
791
624
|
width: w,
|
|
792
625
|
height: h,
|
|
793
|
-
|
|
626
|
+
game,
|
|
794
627
|
dpr,
|
|
795
628
|
transparent: !isBottom,
|
|
796
629
|
preserveDrawingBuffer: true,
|
|
@@ -826,8 +659,7 @@ async function mountOnePixiWorld(
|
|
|
826
659
|
* `createWorldsGameRuntime`'s stack-building loop) but needs no entry in the
|
|
827
660
|
* delegating router's hit-test loop (§1.C — "DOM layers need no entry in the
|
|
828
661
|
* router's hit-test loop"): the layer's own `pointer-events` discipline
|
|
829
|
-
* (this file sets `none` on the layer by default
|
|
830
|
-
* `uiContainer`/HUD rule; the mounted react tree opts specific elements back
|
|
662
|
+
* (this file sets `none` on the layer by default; the mounted React tree opts specific elements back
|
|
831
663
|
* in with `pointer-events:auto`) is what lets its interactive elements claim
|
|
832
664
|
* events NATIVELY, via the real DOM, with zero router involvement — and lets
|
|
833
665
|
* a click over its non-interactive (transparent) area fall through to the
|
|
@@ -847,10 +679,10 @@ async function mountOneReactWorld(
|
|
|
847
679
|
* create its own) so `WorldInstance.reactRoot()` returns the SAME node
|
|
848
680
|
* that is actually positioned in the stack. */
|
|
849
681
|
layer: HTMLElement,
|
|
850
|
-
): Promise<{ mountedEntry:
|
|
682
|
+
): Promise<{ mountedEntry: MountedAdapterRoot }> {
|
|
851
683
|
layer.style.pointerEvents = 'none';
|
|
852
|
-
//
|
|
853
|
-
//
|
|
684
|
+
// An absolutely-positioned layer with no width/height collapses to zero
|
|
685
|
+
// content size, so a child's
|
|
854
686
|
// own position:absolute offsets resolve against a degenerate containing
|
|
855
687
|
// block (found by the T6.2 slice-3 e2e — clicks landed outside the game).
|
|
856
688
|
layer.style.width = '100%';
|
|
@@ -873,21 +705,21 @@ async function mountOneReactWorld(
|
|
|
873
705
|
}
|
|
874
706
|
|
|
875
707
|
/**
|
|
876
|
-
* The
|
|
877
|
-
* 1; react
|
|
708
|
+
* The roots-path implementer behind {@link createGameRuntime} (T6.1 slice
|
|
709
|
+
* 1; react roots added T6.2 slice 1). Builds ONE surface per world — a
|
|
878
710
|
* canvas for threejs/pixijs, a DOM-root `<div>` layer for react — stacked
|
|
879
711
|
* per COMPOSITION-DESIGN D5 §1, z-order/ties exactly matching
|
|
880
712
|
* `manifest/load.ts`'s sort, ONE `Game`, and registers every world onto it
|
|
881
713
|
* via `registerThreeWorld`/`registerPixiWorld`/`registerReactWorld` — the
|
|
882
714
|
* SAME wiring `test/game-three-plus-pixi.test.ts` proves by hand for the
|
|
883
|
-
* three/pixi pair. Worlds MOUNT in `
|
|
715
|
+
* three/pixi pair. Worlds MOUNT in `roots` ARRAY order (GAME-ROOT-DESIGN §4's
|
|
884
716
|
* "manifest declaration order" — the frame/registration axis), independent of
|
|
885
717
|
* `zOrder` (the canvas-stacking/rendering axis) — the two orders can differ
|
|
886
718
|
* and both are honored correctly.
|
|
887
719
|
*/
|
|
888
720
|
|
|
889
721
|
/**
|
|
890
|
-
* Dev/e2e-only `window.__vgaiScene`/`__vgaiCamera` exposure for the
|
|
722
|
+
* Dev/e2e-only `window.__vgaiScene`/`__vgaiCamera` exposure for the roots
|
|
891
723
|
* path's DEFAULT world (E4) — split out of `createWorldsGameRuntime` purely
|
|
892
724
|
* to keep that function's own cyclomatic complexity down. Mirrors the
|
|
893
725
|
* legacy single-world path's identical exposure (above, in this same file),
|
|
@@ -923,15 +755,15 @@ interface MountAllWorldsInputs extends Omit<OneWorldContext, 'canvas' | 'isBotto
|
|
|
923
755
|
}
|
|
924
756
|
|
|
925
757
|
interface MountAllWorldsResult {
|
|
926
|
-
readonly mountedEntries:
|
|
927
|
-
readonly routerEntries:
|
|
758
|
+
readonly mountedEntries: MountedAdapterRoot[];
|
|
759
|
+
readonly routerEntries: RouterAdapterRoot[];
|
|
928
760
|
}
|
|
929
761
|
|
|
930
762
|
/**
|
|
931
763
|
* Mount + register every world spec, in ARRAY (declaration) order — split
|
|
932
764
|
* out of `createWorldsGameRuntime` purely to keep that function's own
|
|
933
765
|
* cyclomatic complexity down (E4, same reason `mountOneThreeWorld`/
|
|
934
|
-
* `mountOnePixiWorld` are already split out). React
|
|
766
|
+
* `mountOnePixiWorld` are already split out). React roots contribute NO
|
|
935
767
|
* router entry (docs/REACT-WORLD-DESIGN.md §1.C — "DOM layers need no entry
|
|
936
768
|
* in the router's hit-test loop"): their layer's own `pointer-events`
|
|
937
769
|
* discipline handles claim/fall-through natively, with zero router
|
|
@@ -943,8 +775,8 @@ async function mountAllWorldSpecs(
|
|
|
943
775
|
inputs: MountAllWorldsInputs,
|
|
944
776
|
): Promise<MountAllWorldsResult> {
|
|
945
777
|
const { surfacesById, ...shared } = inputs;
|
|
946
|
-
const mountedEntries:
|
|
947
|
-
const routerEntries:
|
|
778
|
+
const mountedEntries: MountedAdapterRoot[] = [];
|
|
779
|
+
const routerEntries: RouterAdapterRoot[] = [];
|
|
948
780
|
|
|
949
781
|
for (const spec of mountSpecs) {
|
|
950
782
|
const isBottom = spec.id === bottomId;
|
|
@@ -956,15 +788,15 @@ async function mountAllWorldSpecs(
|
|
|
956
788
|
}
|
|
957
789
|
const canvas = surfacesById.get(spec.id)! as HTMLCanvasElement;
|
|
958
790
|
const oneCtx: OneWorldContext = { ...shared, canvas, isBottom };
|
|
959
|
-
let mountedEntry:
|
|
960
|
-
let routerEntry:
|
|
791
|
+
let mountedEntry: MountedAdapterRoot;
|
|
792
|
+
let routerEntry: RouterAdapterRoot;
|
|
961
793
|
if (spec.kind === 'threejs') {
|
|
962
794
|
({ mountedEntry, routerEntry } = await mountOneThreeWorld(spec, oneCtx));
|
|
963
795
|
} else if (spec.kind === 'pixijs') {
|
|
964
796
|
({ mountedEntry, routerEntry } = await mountOnePixiWorld(spec, oneCtx));
|
|
965
797
|
} else {
|
|
966
798
|
// Exhaustiveness guard (§7.4-2): 'react' was already handled by the
|
|
967
|
-
// early `continue` above, so only a hypothetical 4th `
|
|
799
|
+
// early `continue` above, so only a hypothetical 4th `AdapterSurface` can
|
|
968
800
|
// reach here — fail loudly rather than silently defaulting. `spec`
|
|
969
801
|
// itself (not `spec.kind`) is what TS has narrowed to `never`, since
|
|
970
802
|
// `WorldMountSpec` is a discriminated union at the object level.
|
|
@@ -977,14 +809,20 @@ async function mountAllWorldSpecs(
|
|
|
977
809
|
}
|
|
978
810
|
|
|
979
811
|
async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<GameSession> {
|
|
980
|
-
const { container,
|
|
812
|
+
const { container, roots: specs, width, height, headless = false, seed } = config;
|
|
981
813
|
if (specs.length === 0) {
|
|
982
|
-
throw new Error('createGameRuntime: `
|
|
814
|
+
throw new Error('createGameRuntime: `roots` must contain at least one root.');
|
|
983
815
|
}
|
|
984
816
|
const mountSpecs = specs as (ThreeWorldMountSpec | PixiWorldMountSpec | ReactWorldMountSpec)[];
|
|
985
817
|
|
|
986
|
-
const w =
|
|
987
|
-
|
|
818
|
+
const w = Math.max(
|
|
819
|
+
1,
|
|
820
|
+
width ?? (container as HTMLElement & { clientWidth?: number }).clientWidth ?? 0,
|
|
821
|
+
);
|
|
822
|
+
const h = Math.max(
|
|
823
|
+
1,
|
|
824
|
+
height ?? (container as HTMLElement & { clientHeight?: number }).clientHeight ?? 0,
|
|
825
|
+
);
|
|
988
826
|
const dpr = headless
|
|
989
827
|
? 1
|
|
990
828
|
: Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2);
|
|
@@ -1026,18 +864,19 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
1026
864
|
// too (not just in `mountOneThreeWorld`) means every surface, whatever
|
|
1027
865
|
// kind, starts container-relative from its very first paint, before any
|
|
1028
866
|
// per-kind mount work has even run.
|
|
1029
|
-
|
|
867
|
+
// `contain:layout paint` makes each surface the CONTAINING BLOCK for
|
|
868
|
+
// `position:fixed` descendants (and clips overflow to the world's
|
|
869
|
+
// rectangle): full-screen game UI written the natural way (`fixed;
|
|
870
|
+
// inset:0`) then fills the WORLD, not the page. Without it, `fixed` UI
|
|
871
|
+
// looks correct standalone (surface == window) but escapes over the
|
|
872
|
+
// editor chrome whenever the surface is a sub-rectangle of the page —
|
|
873
|
+
// and toggles behavior when any ancestor gains a transform (e.g. the
|
|
874
|
+
// editor's space-pan). Dogfooded 2026-07-12 via a react shell world.
|
|
875
|
+
surface.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;z-index:${i + 1};contain:layout paint;`;
|
|
1030
876
|
container.appendChild(surface);
|
|
1031
877
|
surfacesById.set(entry.id, surface);
|
|
1032
878
|
});
|
|
1033
879
|
|
|
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
880
|
const assets = createAssetCache();
|
|
1042
881
|
const extraUpdaters = new Set<(dt: number) => void>();
|
|
1043
882
|
const loopHandle: LoopHandle = {
|
|
@@ -1055,7 +894,7 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
1055
894
|
for (const fn of extraUpdaters) fn(dt);
|
|
1056
895
|
},
|
|
1057
896
|
});
|
|
1058
|
-
const game = createGame({ loop, assets });
|
|
897
|
+
const game = createGame({ loop, assets, seed });
|
|
1059
898
|
|
|
1060
899
|
// --- Mount + register every world, in ARRAY (declaration) order. ---
|
|
1061
900
|
// Split out into its own top-level function purely to keep
|
|
@@ -1069,7 +908,6 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
1069
908
|
h,
|
|
1070
909
|
dpr,
|
|
1071
910
|
headless,
|
|
1072
|
-
uiContainer,
|
|
1073
911
|
loopHandle,
|
|
1074
912
|
assets,
|
|
1075
913
|
});
|
|
@@ -1082,7 +920,7 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
1082
920
|
|
|
1083
921
|
// Expose scene & camera for dev tools / e2e tests — mirrors the legacy
|
|
1084
922
|
// single-world path's identical dev-only exposure above, generalized to
|
|
1085
|
-
// the
|
|
923
|
+
// the roots path's DEFAULT world (E4, docs/unified-world-editor/
|
|
1086
924
|
// 27-visual-react-editing.md §7 E4): `mountGameFromManifest`/
|
|
1087
925
|
// `mountManifestWorlds` route every caller (including a single-threejs-
|
|
1088
926
|
// world scaffold project) through THIS path, so a caller migrating off
|
|
@@ -1102,7 +940,6 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
1102
940
|
entry.renderer?.forceContextLoss();
|
|
1103
941
|
container.removeChild(entry.element);
|
|
1104
942
|
}
|
|
1105
|
-
container.removeChild(uiContainer);
|
|
1106
943
|
retractDevGlobals();
|
|
1107
944
|
}
|
|
1108
945
|
|
|
@@ -1124,9 +961,11 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
1124
961
|
game.play.step();
|
|
1125
962
|
},
|
|
1126
963
|
resize(rw: number, rh: number) {
|
|
964
|
+
const safeWidth = Math.max(1, rw);
|
|
965
|
+
const safeHeight = Math.max(1, rh);
|
|
1127
966
|
for (const entry of mountedEntries) {
|
|
1128
|
-
entry.renderer?.setSize(
|
|
1129
|
-
entry.mounted.resize?.(
|
|
967
|
+
entry.renderer?.setSize(safeWidth, safeHeight, false);
|
|
968
|
+
entry.mounted.resize?.(safeWidth, safeHeight);
|
|
1130
969
|
}
|
|
1131
970
|
},
|
|
1132
971
|
// `GameSession.scene`/`.camera`/`.mounted` alias `Game.defaultWorld` (T6.1
|
|
@@ -1134,7 +973,7 @@ async function createWorldsGameRuntime(config: WorldsRuntimeConfig): Promise<Gam
|
|
|
1134
973
|
// convenience shaped for the threejs-only past. T7.5 narrows the read via
|
|
1135
974
|
// the `kind` discriminant instead of a blind `.scene`/`.camera` cast
|
|
1136
975
|
// through a nonexistent property (identical behavior to before: still
|
|
1137
|
-
// `undefined` for a
|
|
976
|
+
// `undefined` for a roots-path session whose default world isn't
|
|
1138
977
|
// threejs — real per-world surface routing for that case is T7.6's).
|
|
1139
978
|
get scene() {
|
|
1140
979
|
const m = game.defaultWorld.mounted;
|