@vgai/engine 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +35 -0
- package/package.json +55 -0
- package/src/adapter/authoring.ts +402 -0
- package/src/adapter/colyseus-networking-adapter.ts +72 -0
- package/src/adapter/first-party-systems.ts +103 -0
- package/src/adapter/game-adapter.ts +151 -0
- package/src/adapter/host-context.ts +77 -0
- package/src/adapter/index.ts +85 -0
- package/src/adapter/ingest/game-contract.ts +59 -0
- package/src/adapter/ingest/overlay-applier.ts +207 -0
- package/src/adapter/ingest/overlay-apply.ts +124 -0
- package/src/adapter/ingest/overlay-file.ts +126 -0
- package/src/adapter/ingest/overlay-report.ts +176 -0
- package/src/adapter/ingest/scene-capture.ts +307 -0
- package/src/adapter/ingest/upstream-pin.ts +52 -0
- package/src/adapter/loop-gate-report.ts +54 -0
- package/src/adapter/rapier-physics-adapter.ts +56 -0
- package/src/adapter/system-adapter.ts +154 -0
- package/src/adapter/transform.ts +18 -0
- package/src/adapter/vgai-scene-game-adapter.ts +886 -0
- package/src/adapter/world-kind.ts +34 -0
- package/src/ai/navigation.ts +164 -0
- package/src/animation/anim-graph-types.ts +56 -0
- package/src/animation/anim-graph.ts +406 -0
- package/src/animation/anim-system.ts +28 -0
- package/src/animation/blend-node.ts +119 -0
- package/src/animation/property-track.ts +178 -0
- package/src/animation/schema.ts +204 -0
- package/src/assets.ts +80 -0
- package/src/audio/ambient.ts +300 -0
- package/src/audio/impacts.ts +212 -0
- package/src/audio/index.ts +7 -0
- package/src/audio/movement.ts +140 -0
- package/src/audio/musical.ts +200 -0
- package/src/audio/ui-sounds.ts +171 -0
- package/src/audio/vehicle.ts +235 -0
- package/src/audio/weapons.ts +152 -0
- package/src/core/game-loop.ts +127 -0
- package/src/core/system-runner.ts +298 -0
- package/src/core/types.ts +58 -0
- package/src/dev/console-bridge.ts +83 -0
- package/src/dev/debug-draw.ts +80 -0
- package/src/dev/logger.ts +119 -0
- package/src/ecs/component-manager.ts +748 -0
- package/src/ecs/game-component.ts +147 -0
- package/src/ecs/hmr-swap-report.ts +65 -0
- package/src/input/input-manager.ts +439 -0
- package/src/input/input-types.ts +19 -0
- package/src/input/schema.ts +129 -0
- package/src/loader.ts +70 -0
- package/src/manifest/index.ts +24 -0
- package/src/manifest/load-file.ts +16 -0
- package/src/manifest/load.ts +378 -0
- package/src/manifest/schema.ts +375 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/physics/trigger-dispatch.ts +97 -0
- package/src/react/game-state.tsx +172 -0
- package/src/render/auto-batcher.ts +169 -0
- package/src/render/render-batch-system.ts +268 -0
- package/src/render/render-features.ts +146 -0
- package/src/render/render-settings.ts +72 -0
- package/src/runtime/create-runtime.ts +1152 -0
- package/src/runtime/frame-selector-cache.ts +81 -0
- package/src/runtime/game.ts +1003 -0
- package/src/runtime/input-router.ts +213 -0
- package/src/runtime/mount-game.ts +269 -0
- package/src/runtime/mount-manifest.ts +361 -0
- package/src/runtime/scene-ui-bridge.ts +86 -0
- package/src/runtime/scene-ui-data.ts +119 -0
- package/src/runtime/state-bridge.ts +79 -0
- package/src/runtime/types.ts +196 -0
- package/src/scene/asset-loaders.ts +195 -0
- package/src/scene/asset-paths.ts +123 -0
- package/src/scene/asset-registry.ts +67 -0
- package/src/scene/collider-dimensions.ts +125 -0
- package/src/scene/component-registry.ts +40 -0
- package/src/scene/defaults.ts +164 -0
- package/src/scene/geometries/index.ts +7 -0
- package/src/scene/geometries/terrain.ts +42 -0
- package/src/scene/geometry-registry.ts +42 -0
- package/src/scene/instance-registry.ts +84 -0
- package/src/scene/instancers/grid.ts +38 -0
- package/src/scene/instancers/index.ts +7 -0
- package/src/scene/light-camera-factory.ts +97 -0
- package/src/scene/material-factory.ts +211 -0
- package/src/scene/material-registry.ts +73 -0
- package/src/scene/materials/index.ts +7 -0
- package/src/scene/materials/water.ts +56 -0
- package/src/scene/parse.ts +71 -0
- package/src/scene/particles-factory.ts +383 -0
- package/src/scene/scene-apply.ts +356 -0
- package/src/scene/scene-diff-schema.ts +115 -0
- package/src/scene/scene-diff-types.ts +29 -0
- package/src/scene/scene-loader.ts +1533 -0
- package/src/scene/scene-query.ts +63 -0
- package/src/scene/scene-types.ts +34 -0
- package/src/scene/scene-version.ts +40 -0
- package/src/scene/schema/animation.ts +95 -0
- package/src/scene/schema/audio.ts +25 -0
- package/src/scene/schema/camera.ts +21 -0
- package/src/scene/schema/collider.ts +69 -0
- package/src/scene/schema/entity-ref.ts +78 -0
- package/src/scene/schema/entity.ts +169 -0
- package/src/scene/schema/environment.ts +384 -0
- package/src/scene/schema/index.ts +95 -0
- package/src/scene/schema/instances.ts +35 -0
- package/src/scene/schema/joint.ts +26 -0
- package/src/scene/schema/light.ts +38 -0
- package/src/scene/schema/material.ts +113 -0
- package/src/scene/schema/mesh.ts +108 -0
- package/src/scene/schema/particles.ts +398 -0
- package/src/scene/schema/physics.ts +49 -0
- package/src/scene/schema/scene-file.ts +299 -0
- package/src/scene/schema/shadow.ts +24 -0
- package/src/scene/schema/spline.ts +21 -0
- package/src/scene/schema/tuples.ts +21 -0
- package/src/scene/schema/ui.ts +602 -0
- package/src/scene/user-data.ts +203 -0
- package/src/setup/setup-audio.ts +60 -0
- package/src/setup/setup-particles.ts +23 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +529 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/src/world2d/authoring-2d.ts +208 -0
- package/src/world2d/capture-to-scene2d.ts +52 -0
- package/src/world2d/collision-2d.ts +106 -0
- package/src/world2d/components-2d.ts +86 -0
- package/src/world2d/index.ts +66 -0
- package/src/world2d/ingest-iframe-2d.ts +255 -0
- package/src/world2d/ingest2d.ts +131 -0
- package/src/world2d/physics2d-registry.ts +49 -0
- package/src/world2d/pixi-game-adapter.ts +325 -0
- package/src/world2d/pixi-surface.ts +78 -0
- package/src/world2d/scene-capture-2d.ts +117 -0
- package/src/world2d/scene2d-loader.ts +308 -0
- package/src/world2d/schema/entity2d.ts +145 -0
- package/src/world2d/schema/physics2d.ts +53 -0
- package/src/world2d/schema/sprite.ts +71 -0
- package/src/world2d/schema/tilemap.ts +22 -0
- package/src/world2d/schema/tuples2d.ts +25 -0
- package/src/world2d/system-adapters-2d.ts +49 -0
- package/src/world2d/transform-writer-2d.ts +24 -0
- package/src/world2d/types.ts +55 -0
|
@@ -0,0 +1,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
|
+
}
|