@volter/editor-game 0.5.66 → 0.5.67
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/contributions/audio-unlock.service.ts +2 -2
- package/contributions/autoplay.service.ts +1 -1
- package/contributions/bridge.command.ts +3 -3
- package/contributions/canvas/component-board.service.ts +16 -0
- package/contributions/canvas/design-time-mount.service.ts +45 -0
- package/contributions/canvas-story-capture.service.ts +12 -0
- package/contributions/edit-mode-audio.service.ts +2 -2
- package/contributions/edit-mode-networking.service.ts +1 -1
- package/contributions/gameplay.command.ts +4 -4
- package/contributions/generation.service.ts +1 -1
- package/contributions/godot.style.ts +26 -6
- package/contributions/godot.view.ts +6 -0
- package/contributions/ingest.service.ts +2 -2
- package/contributions/instances.command.ts +1 -1
- package/contributions/navmesh.menu.ts +1 -1
- package/contributions/network-observer.service.ts +14 -0
- package/contributions/play.command.ts +10 -4
- package/contributions/react/component-board.service.ts +1 -1
- package/contributions/react/design-time-mount.service.ts +1 -1
- package/contributions/scene-document.service.ts +3 -3
- package/contributions/state-watch.menu.ts +1 -1
- package/contributions/state-watch.utility.tsx +1 -1
- package/contributions/team-playtest.service.ts +4 -4
- package/contributions/three/component-board.service.ts +1 -1
- package/contributions/three/component-verbs.command.ts +6 -6
- package/contributions/three/three-authoring.service.ts +8 -5
- package/contributions/three-story-capture.service.ts +12 -0
- package/contributions/unity.style.ts +17 -7
- package/contributions/unity.view.ts +6 -0
- package/contributions/unreal.style.ts +10 -2
- package/contributions/unreal.view.ts +5 -0
- package/package.json +20 -10
- package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
- package/src/asset-budget/asset-budget-model.ts +1 -1
- package/src/audio/AudioDebuggerPanel.tsx +1 -1
- package/src/bridge/dispatch.ts +14 -14
- package/src/bridge/live-frames.ts +1 -1
- package/src/bridge/screenshot.ts +3 -3
- package/src/build/BuildProfilesPanel.tsx +3 -3
- package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
- package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
- package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
- package/src/canvas/canvas-design-mount.ts +524 -0
- package/src/canvas/design-time-canvas-mount.ts +79 -0
- package/src/coverage/live-authoring-surface.ts +5 -5
- package/src/coverage/live-project-verbs.ts +1 -1
- package/src/coverage/native-system-coverage.ts +5 -5
- package/src/coverage/root-coverage.ts +1 -1
- package/src/coverage/session-coverage.ts +3 -3
- package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
- package/src/edit-mode/edit-mode-audio.ts +4 -4
- package/src/edit-mode/edit-mode-networking.ts +2 -2
- package/src/game-document/GameCaptureFrameButton.tsx +1 -1
- package/src/game-document/GameDocument.tsx +2 -2
- package/src/game-document/GamePanel.tsx +4 -4
- package/src/game-document/InstanceInspectorPicker.tsx +2 -2
- package/src/game-document/crowd-debug.ts +1 -1
- package/src/game-document/device-preview.ts +10 -11
- package/src/game-document/physics-debug.ts +1 -1
- package/src/generation/GenerationActivity.tsx +3 -3
- package/src/generation/generation-documents.tsx +5 -5
- package/src/generation/generation-jobs.ts +1 -1
- package/src/host/adapter-runtime-bindings.ts +96 -5
- package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
- package/src/host/authoring/gesture-persist.ts +1 -1
- package/src/host/authoring/ingest-data-writer.ts +1 -1
- package/src/host/authoring/ingest-source-persistence.ts +4 -4
- package/src/host/authoring/mounted-authoring.ts +4 -4
- package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
- package/src/host/authoring/pixi-authoring-adapter.ts +40 -15
- package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
- package/src/host/authoring/pixi-live-write-target.ts +4 -4
- package/src/host/authoring/pixi-source-identity.ts +3 -3
- package/src/host/authoring/pixi-source-write-target.ts +1614 -0
- package/src/host/authoring/pixi-still-presentation.ts +1 -1
- package/src/host/authoring/pixi-structure-history.ts +2 -2
- package/src/host/authoring/pixi-transform-channels.ts +16 -14
- package/src/host/authoring/source-persistence-backend.ts +3 -3
- package/src/host/authoring/struct-write-pipe.ts +1 -1
- package/src/host/binding-resolver.ts +8 -9
- package/src/host/browser-transpile.ts +1 -1
- package/src/host/canvas-entry-runtime.ts +58 -47
- package/src/host/canvas-preview-frames.ts +482 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
- package/src/host/components/HeaderTelemetry.tsx +4 -4
- package/src/host/components/PixiIsolationSceneContent.tsx +10 -10
- package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
- package/src/host/components/frame-debugger-model.ts +2 -2
- package/src/host/components/header-telemetry-model.ts +2 -2
- package/src/host/components/scene-document.tsx +14 -14
- package/src/host/components/utility-view-state.ts +1 -1
- package/src/host/components/world-root-stage-binding.tsx +12 -12
- package/src/host/components/world-root-stage.ts +70 -49
- package/src/host/coverage/system-adapter-coverage.ts +3 -4
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
- package/src/host/document-preview-three.ts +1 -1
- package/src/host/entry-adjudication.ts +6 -6
- package/src/host/game-css-scope-transform.ts +4 -0
- package/src/host/game-realm-page.ts +1 -1
- package/src/host/gameplay-export.ts +25 -14
- package/src/host/gameplay-recording.ts +5 -5
- package/src/host/gated-globals.ts +2 -2
- package/src/host/history/json-history-resource.ts +1 -1
- package/src/host/projection/pixi.ts +24 -2
- package/src/host/r3f-entry-runtime.ts +65 -34
- package/src/host/react-mount-runtime.ts +7 -48
- package/src/host/realm-services.ts +1 -1
- package/src/host/roots/canvas-root.tsx +361 -0
- package/src/host/roots/r3f-root.tsx +473 -0
- package/src/host/roots/react-root.ts +9 -43
- package/src/host/served-bundle-runtime-modules.ts +1 -19
- package/src/host/server-log-bridge.ts +2 -2
- package/src/host/stories/mounted-story-viewport-source.ts +1 -1
- package/src/host/stories/pixi-story-model.ts +30 -0
- package/src/host/stories/story-media-captures.ts +46 -0
- package/src/host/stories/story-media-presence.ts +3 -3
- package/src/host/stories/story-pixi-preview.ts +408 -0
- package/src/host/stories/story-three-preview.ts +806 -0
- package/src/host/stories/three-story-captures.ts +35 -0
- package/src/host/story-three-preview-runtime.ts +56 -0
- package/src/host/use-active-performance-source.ts +2 -2
- package/src/host/viewport-pose-memory.ts +1 -1
- package/src/host/viewport-root-presentation.ts +6 -5
- package/src/ingest/active-ingest.ts +1 -1
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
- package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
- package/src/ingest/deferred-ingest-play.ts +6 -6
- package/src/ingest/discovery-public-ingest.ts +2 -2
- package/src/ingest/ingest-boot-viewport.ts +2 -2
- package/src/ingest/ingest-canvas-scene-document.tsx +8 -8
- package/src/ingest/ingest-canvas-scene.ts +3 -3
- package/src/ingest/ingest-evidence-hook.ts +1 -1
- package/src/ingest/ingest-frame-snapshot.ts +1 -1
- package/src/ingest/ingest-play-control.ts +1 -1
- package/src/ingest/ingest-render-debug.ts +10 -10
- package/src/ingest/ingest-siblings.ts +13 -25
- package/src/ingest/module-mode.ts +14 -14
- package/src/ingest/mount-canvas-ingest-root.ts +21 -21
- package/src/ingest/mount-coverage.ts +2 -2
- package/src/ingest/mount-dom-ingest-root.ts +11 -11
- package/src/ingest/mount-ingest-root.ts +8 -8
- package/src/ingest/mount-three-ingest-root.ts +8 -8
- package/src/ingest/resolve-canvas.ts +1 -1
- package/src/ingest/served-html-boot.ts +1 -1
- package/src/ingest/surface-canvas.ts +1 -1
- package/src/ingest/unmount-ingest-root.ts +5 -5
- package/src/navmesh/navmesh-handler.ts +24 -16
- package/src/network/NetworkInspectorPanel.tsx +449 -14
- package/src/network/network-inspector-model.ts +14 -2
- package/src/play/play-log-events.ts +1 -1
- package/src/play/play-mode.ts +76 -133
- package/src/play/play-recording.ts +1 -1
- package/src/play/react-play-live-authoring.ts +3 -3
- package/src/play/run-selection.ts +93 -0
- package/src/play-bar/PlayBar.tsx +20 -39
- package/src/profiler/FrameDebuggerPanel.tsx +1 -1
- package/src/profiler/PerformancePanel.tsx +3 -3
- package/src/react/design-time-react-mount.ts +23 -65
- package/src/react/dom-authoring-adapter.ts +9 -9
- package/src/react/react-inspector-section.tsx +5 -5
- package/src/react/react-world-authoring-adapter.ts +11 -11
- package/src/react/story-documents/story-documents.tsx +9 -9
- package/src/react/ui-board-document.tsx +7 -7
- package/src/react/ui-component-board.ts +2 -2
- package/src/runtime/adapter/audio-meter.ts +21 -0
- package/src/runtime/adapter/first-party-audio-system.ts +230 -0
- package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
- package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
- package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
- package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
- package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
- package/src/runtime/adapter/native-debug-module.ts +498 -0
- package/src/runtime/audio/bus-mixer.ts +161 -0
- package/src/runtime/audio/pose-guard.ts +80 -0
- package/src/runtime/core/frame-pacing.ts +126 -0
- package/src/runtime/core/game-loop.ts +225 -0
- package/src/runtime/core/game-scoped-slot.ts +28 -0
- package/src/runtime/core/seeded-random.ts +162 -0
- package/src/runtime/core/sim-clock.ts +391 -0
- package/src/runtime/core/system-runner.ts +269 -0
- package/src/runtime/core/types.ts +104 -0
- package/src/runtime/create-runtime.ts +1128 -0
- package/src/runtime/debug-bridge.ts +570 -0
- package/src/runtime/debug-registry.ts +899 -0
- package/src/runtime/dev/chrome-trace.ts +153 -0
- package/src/runtime/dev/instruments.ts +403 -0
- package/src/runtime/dev/logger.ts +119 -0
- package/src/runtime/dev/performance-profiler.ts +367 -0
- package/src/runtime/dev/register-render-vitals.ts +276 -0
- package/src/runtime/dev/render-census.ts +354 -0
- package/src/runtime/dev/render-debug-adapter.ts +218 -0
- package/src/runtime/dev/render-memory.ts +226 -0
- package/src/runtime/dev/render-vitals.ts +338 -0
- package/src/runtime/dev/static-batch-advisor.ts +188 -0
- package/src/runtime/dev/webgl-frame-capture.ts +366 -0
- package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
- package/src/runtime/dev-build.ts +47 -0
- package/src/runtime/game.ts +1636 -0
- package/src/runtime/gameplay-rng-trap.ts +135 -0
- package/src/runtime/host-context.ts +64 -0
- package/src/runtime/input-router.ts +169 -0
- package/src/runtime/mount-manifest.ts +480 -0
- package/src/runtime/pixi/authoring.ts +706 -0
- package/src/runtime/pixi/ingest.ts +116 -0
- package/src/runtime/pixi/physics-registry.ts +49 -0
- package/src/runtime/pixi/render-pass-bracket.ts +117 -0
- package/src/runtime/pixi/scene-capture.ts +179 -0
- package/src/runtime/pixi/system-adapters.ts +69 -0
- package/src/runtime/playtest.ts +22 -0
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-control.ts +642 -0
- package/src/runtime/render-seed.ts +77 -0
- package/src/runtime/run-ticks-settled.ts +73 -0
- package/src/runtime/setup/setup-audio.ts +72 -0
- package/src/services/audio-pose-guard.ts +2 -2
- package/src/services/game-audio.ts +152 -0
- package/src/services/game-network.ts +657 -0
- package/src/services/game-physics.ts +334 -0
- package/src/state-watch/StateWatchPanel.tsx +1 -1
- package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
- package/src/three/authoring/constraint-inspector-section.tsx +6 -5
- package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
- package/src/three/authoring/oid-source-persistence.ts +7 -7
- package/src/three/authoring/r3f-design-session.ts +58 -54
- package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
- package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
- package/src/three/authoring/three-authoring-adapter.ts +29 -29
- package/src/three/component-verbs/extract-menu.ts +7 -6
- package/src/three/component-verbs/fork-menu.ts +7 -6
- package/src/three/component-verbs/internals-menu.ts +2 -2
- package/src/three/story-documents/three-story-documents.tsx +13 -13
- package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
- package/src/three/three-board/board-scene.ts +6 -6
- package/src/three/three-board/three-component-board.ts +2 -2
- package/src/services/game-audio-unlock.ts +0 -48
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
import { assertNever } from '@volter/editor-project/adapter/adapter-surface';
|
|
2
|
+
import {
|
|
3
|
+
createRootBinding,
|
|
4
|
+
type RootBinding,
|
|
5
|
+
type RootDeclaration,
|
|
6
|
+
} from '@volter/editor-project/adapter/binding';
|
|
7
|
+
import type { DomHostContext } from '@volter/editor-project/adapter/host-context';
|
|
8
|
+
import type {
|
|
9
|
+
GameCanvasHostContext,
|
|
10
|
+
GameDomHostContext,
|
|
11
|
+
GameThreeHostContext,
|
|
12
|
+
} from './host-context';
|
|
13
|
+
import type {
|
|
14
|
+
MountedCanvasRoot,
|
|
15
|
+
MountedReactRoot,
|
|
16
|
+
MountedRoot,
|
|
17
|
+
MountedThreeRoot,
|
|
18
|
+
RootAdapter,
|
|
19
|
+
SurfaceAdapter,
|
|
20
|
+
} from '@volter/editor-project/adapter/root-adapter';
|
|
21
|
+
import { stackOrder } from '@volter/editor-project/adapter/root-stacking';
|
|
22
|
+
import { createAssetCache } from '@volter/threejs-runtime/assets';
|
|
23
|
+
import { createHostRenderer } from '@volter/threejs-runtime/setup/setup-renderer';
|
|
24
|
+
import * as THREE from 'three';
|
|
25
|
+
import { createGameLoop } from './core/game-loop';
|
|
26
|
+
import { getDebugRegistry } from './debug-registry';
|
|
27
|
+
// TYPE-ONLY: `create-runtime.ts` must never value-import `pixi.js` — a canvas
|
|
28
|
+
// `RootMountSpec`'s `adapter` is supplied ALREADY-CONSTRUCTED by the caller, so
|
|
29
|
+
// the host only ever needs the seam's types.
|
|
30
|
+
import {
|
|
31
|
+
createGame,
|
|
32
|
+
createRootInstance,
|
|
33
|
+
type Game,
|
|
34
|
+
type GameInternal,
|
|
35
|
+
type RootInstance,
|
|
36
|
+
} from './game';
|
|
37
|
+
import { createInputRouter, type RouterAdapterRoot } from './input-router';
|
|
38
|
+
import type { PlaytestContext } from './playtest';
|
|
39
|
+
import { installRenderControlHarness, isRenderModeRequested } from './render-control';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register a three world onto the Game shell. `opts.id` defaults to
|
|
43
|
+
* `'main'`; multi-root callers pass an explicit id. This is the one
|
|
44
|
+
* registration path for a three world, whatever its id.
|
|
45
|
+
*/
|
|
46
|
+
export function registerThreeRoot(
|
|
47
|
+
game: GameInternal,
|
|
48
|
+
adapter: RootAdapter,
|
|
49
|
+
mounted: MountedThreeRoot,
|
|
50
|
+
opts?: RegisterRootOptions,
|
|
51
|
+
): RootInstance {
|
|
52
|
+
const pausable = opts?.pausable ?? true;
|
|
53
|
+
const world = createRootInstance({
|
|
54
|
+
id: opts?.id ?? 'main',
|
|
55
|
+
kind: 'three',
|
|
56
|
+
pausable,
|
|
57
|
+
adapter,
|
|
58
|
+
mounted,
|
|
59
|
+
scene: mounted.scene as THREE.Scene,
|
|
60
|
+
binding: bindRoot(
|
|
61
|
+
game,
|
|
62
|
+
opts?.declaration,
|
|
63
|
+
{
|
|
64
|
+
surface: 'three',
|
|
65
|
+
adapter: adapter as RootAdapter<'three'>,
|
|
66
|
+
},
|
|
67
|
+
mounted,
|
|
68
|
+
pausable,
|
|
69
|
+
),
|
|
70
|
+
});
|
|
71
|
+
game.registerRoot(world);
|
|
72
|
+
return world;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What every `register<Surface>Root` takes beyond the mount itself. */
|
|
76
|
+
export interface RegisterRootOptions {
|
|
77
|
+
readonly id?: string | undefined;
|
|
78
|
+
readonly pausable?: boolean | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* The DECLARATION half of this root's binding, from the resolver that
|
|
81
|
+
* produced `adapter` (`adapter/binding.ts`). Absent for a bare harness
|
|
82
|
+
* registration or a `mountManifestRoots` caller who supplied an
|
|
83
|
+
* already-constructed adapter — see `RootInstance.binding` for why that
|
|
84
|
+
* legitimately yields `null` rather than a fabricated binding.
|
|
85
|
+
*/
|
|
86
|
+
readonly declaration?: RootDeclaration | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Complete the root's binding at the first moment it CAN be completed: the
|
|
91
|
+
* declaration is what the resolver knew, `mounted`/`adapter`/`pausable` are
|
|
92
|
+
* what mounting produced, and the debug registry is the game's own. Nothing
|
|
93
|
+
* here loads or fabricates — see `createRootBinding`.
|
|
94
|
+
*/
|
|
95
|
+
function bindRoot(
|
|
96
|
+
game: GameInternal,
|
|
97
|
+
declaration: RootDeclaration | undefined,
|
|
98
|
+
adapter: SurfaceAdapter,
|
|
99
|
+
mounted: MountedRoot,
|
|
100
|
+
pausable: boolean,
|
|
101
|
+
): RootBinding | null {
|
|
102
|
+
if (!declaration) return null;
|
|
103
|
+
return createRootBinding({
|
|
104
|
+
...declaration,
|
|
105
|
+
adapter,
|
|
106
|
+
mounted,
|
|
107
|
+
pausable,
|
|
108
|
+
debugRegistry: getDebugRegistry(game),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Register a canvas world onto the Game shell — the canvas analog of
|
|
114
|
+
* {@link registerThreeRoot}. `adapter` is the seam's own
|
|
115
|
+
* `RootAdapter<'canvas'>`; `RootInstance.adapter` itself only needs `.id`
|
|
116
|
+
* (`AdapterHandle`, `runtime/game.ts`), so this passes through with zero cast.
|
|
117
|
+
* The substrate root remains opaque here; the editor dispatches from the
|
|
118
|
+
* mount's explicit `substrate.name` declaration.
|
|
119
|
+
*/
|
|
120
|
+
export function registerCanvasRoot(
|
|
121
|
+
game: GameInternal,
|
|
122
|
+
adapter: RootAdapter<'canvas'>,
|
|
123
|
+
mounted: MountedCanvasRoot,
|
|
124
|
+
opts?: RegisterRootOptions,
|
|
125
|
+
): RootInstance {
|
|
126
|
+
const id = opts?.id ?? 'main';
|
|
127
|
+
const pausable = opts?.pausable ?? true;
|
|
128
|
+
const world = createRootInstance({
|
|
129
|
+
id,
|
|
130
|
+
kind: 'canvas',
|
|
131
|
+
pausable,
|
|
132
|
+
adapter,
|
|
133
|
+
mounted,
|
|
134
|
+
canvasRoot: mounted.substrate.root,
|
|
135
|
+
binding: bindRoot(game, opts?.declaration, { surface: 'canvas', adapter }, mounted, pausable),
|
|
136
|
+
});
|
|
137
|
+
game.registerRoot(world);
|
|
138
|
+
return world;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Register a React world onto the Game shell — the DOM
|
|
143
|
+
* analog of {@link registerThreeRoot}/{@link registerCanvasRoot}. A react
|
|
144
|
+
* world has no `frame` hooks (react's own
|
|
145
|
+
* `createRoot` schedules its commits; `GameInternal.runFrame` correctly
|
|
146
|
+
* leaves a world with no `frame` untouched by its opaque-`update` fallback
|
|
147
|
+
* too, since `MountedReactGame` declares no `update`). `adapter` is typed
|
|
148
|
+
* as the real {@link ReactRootAdapter} shape (same reasoning as
|
|
149
|
+
* `registerCanvasRoot`'s doc comment above) — `RootInstance.adapter` only
|
|
150
|
+
* needs `.id`, so this passes through with zero cast.
|
|
151
|
+
*/
|
|
152
|
+
export function registerReactRoot(
|
|
153
|
+
game: GameInternal,
|
|
154
|
+
adapter: RootAdapter<'dom'>,
|
|
155
|
+
mounted: MountedReactRoot,
|
|
156
|
+
container: HTMLElement,
|
|
157
|
+
opts?: RegisterRootOptions,
|
|
158
|
+
): RootInstance {
|
|
159
|
+
const pausable = opts?.pausable ?? true;
|
|
160
|
+
const world = createRootInstance({
|
|
161
|
+
id: opts?.id ?? 'main',
|
|
162
|
+
kind: 'dom',
|
|
163
|
+
pausable,
|
|
164
|
+
adapter,
|
|
165
|
+
mounted,
|
|
166
|
+
container,
|
|
167
|
+
binding: bindRoot(game, opts?.declaration, { surface: 'dom', adapter }, mounted, pausable),
|
|
168
|
+
});
|
|
169
|
+
game.registerRoot(world);
|
|
170
|
+
return world;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* One world to mount in the universal host — the host-facing mirror of
|
|
175
|
+
* `manifest/load.ts`'s `ResolvedAdapterRoot` (same identity and presentation
|
|
176
|
+
* fields; the
|
|
177
|
+
* manifest-to-host translation itself is the editor's job, not this file's).
|
|
178
|
+
*
|
|
179
|
+
* A `dom` root mounts as a DOM layer `<div>` in the same
|
|
180
|
+
* stack instead of a canvas — see {@link ReactRootMountSpec}/{@link
|
|
181
|
+
* ReactRootAdapter} below.
|
|
182
|
+
*/
|
|
183
|
+
export interface RootMountSpecBase {
|
|
184
|
+
/** Manifest id — must be unique within one `roots` array. */
|
|
185
|
+
readonly id: string;
|
|
186
|
+
/** Canvas stacking order (D5 §1); ties broken by array
|
|
187
|
+
* order, mirroring `manifest/load.ts`'s `loadGameManifest` sort. Defaults
|
|
188
|
+
* to `0`. */
|
|
189
|
+
readonly zOrder?: number | undefined;
|
|
190
|
+
/** Whether play-mode pause/step applies to this world (D10). Defaults to `true`. */
|
|
191
|
+
readonly pausable?: boolean | undefined;
|
|
192
|
+
/**
|
|
193
|
+
* Optional claim predicate for the delegating input router (D5 §2a), over
|
|
194
|
+
* a point RELATIVE TO THE CONTAINER. Absent means: this world claims only
|
|
195
|
+
* if it ends up the bottom (lowest zOrder) world — see
|
|
196
|
+
* `input-router.ts`'s `resolveClaimingRoot`.
|
|
197
|
+
*/
|
|
198
|
+
readonly hitTest?: ((x: number, y: number) => boolean) | undefined;
|
|
199
|
+
/**
|
|
200
|
+
* The DECLARATION half of this root's binding, from whoever resolved
|
|
201
|
+
* `adapter` — see `adapter/binding.ts`'s {@link RootDeclaration}. Carried on
|
|
202
|
+
* the spec because the resolver knows it and the host is the only one who
|
|
203
|
+
* can complete the binding (it needs `mounted`, which does not exist until
|
|
204
|
+
* this spec is mounted).
|
|
205
|
+
*/
|
|
206
|
+
readonly declaration?: RootDeclaration | undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface ThreeRootMountSpec extends RootMountSpecBase {
|
|
210
|
+
readonly kind: 'three';
|
|
211
|
+
readonly adapter: RootAdapter;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `adapter` is the SEAM's `RootAdapter<'canvas'>` — a canvas root's mount
|
|
216
|
+
* honestly returns a `MountedCanvasRoot`, and naming the real contract
|
|
217
|
+
* here is what keeps the resolver cast-free.
|
|
218
|
+
*/
|
|
219
|
+
export interface CanvasRootMountSpec extends RootMountSpecBase {
|
|
220
|
+
readonly kind: 'canvas';
|
|
221
|
+
readonly adapter: RootAdapter<'canvas'>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A live, mounted React world — the DOM analog of a mounted canvas world.
|
|
226
|
+
* React roots render from game state
|
|
227
|
+
* instead of a per-frame `update`, so this shape carries no `update`/`fixedUpdate`/
|
|
228
|
+
* `ctx`/`frame` — `drivesOwnLoop` is always `false` (react's `createRoot`
|
|
229
|
+
* schedules its OWN commits; the host's fixed-step loop never drives it,
|
|
230
|
+
* and it is correctly skipped by `GameInternal.runFrame`'s per-world
|
|
231
|
+
* `frame`-hooks/opaque-`update` dispatch — see `registerReactRoot` below,
|
|
232
|
+
* which registers this `RootInstance` with no `frame`, same as any other
|
|
233
|
+
* opaque mount with nothing to tick).
|
|
234
|
+
*
|
|
235
|
+
* Deliberately carries NO `firstParty: true` brand: that brand specifically
|
|
236
|
+
* a react world has no physics or component runtime at all, so branding it
|
|
237
|
+
* first-party would be a type lie.
|
|
238
|
+
* `Game.registerRoot`'s "no state bridge" console warning explicitly exempts
|
|
239
|
+
* `kind: 'dom'` (§7.1-15): a react world has no `observe` BY DESIGN — its
|
|
240
|
+
* state is its own React tree's.
|
|
241
|
+
*
|
|
242
|
+
* `kind`/`container` satisfy `MountedReactRoot` (`adapter/
|
|
243
|
+
* root-adapter.ts`) — `container` is the SAME `DomHostContext.container` the
|
|
244
|
+
* adapter's `mount` was handed (identity matters, mirroring `threeScene()`/
|
|
245
|
+
* `canvasRoot()`'s "same instance the adapter mounted" contract); every
|
|
246
|
+
* `ReactRootAdapter` implementer echoes it back here so `mounted` alone
|
|
247
|
+
* (with no separately-threaded `container`) satisfies the union
|
|
248
|
+
* `RootInstanceInit.mounted`/`RootInstance.mounted` with zero cast.
|
|
249
|
+
*/
|
|
250
|
+
export interface MountedReactGame extends MountedReactRoot {
|
|
251
|
+
readonly drivesOwnLoop: false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A react-shaped adapter — the structural contract `RootMountSpec`'s
|
|
256
|
+
* `react` variant requires. Deliberately
|
|
257
|
+
* NOT tied to a concrete implementer here so an editor-resolved adapter (the
|
|
258
|
+
* `default-react` resolver branch) can satisfy
|
|
259
|
+
* this shape without this file importing react-dom or any editor code.
|
|
260
|
+
*
|
|
261
|
+
* A genuine `RootAdapter<'dom'>` refinement (`HostContextFor<'dom'>` =
|
|
262
|
+
* `DomHostContext` = {@link DomHostContext}), narrowing only the return type to
|
|
263
|
+
* {@link MountedReactGame}.
|
|
264
|
+
*/
|
|
265
|
+
export interface ReactRootAdapter extends RootAdapter<'dom'> {
|
|
266
|
+
readonly id: string;
|
|
267
|
+
mount(host: DomHostContext): Promise<MountedReactGame>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Same reasoning as {@link CanvasRootMountSpec} above: the seam's
|
|
271
|
+
* `RootAdapter<'dom'>` is what a DOM root actually guarantees. */
|
|
272
|
+
export interface ReactRootMountSpec extends RootMountSpecBase {
|
|
273
|
+
readonly kind: 'dom';
|
|
274
|
+
readonly adapter: RootAdapter<'dom'>;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type RootMountSpec = ThreeRootMountSpec | CanvasRootMountSpec | ReactRootMountSpec;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Mount N adapter roots (three + canvas + react)
|
|
281
|
+
* on ONE `Game`, stacked in `container` per D5 §1.
|
|
282
|
+
* `roots[0]` participates in `Game.defaultRoot`'s existing "first three
|
|
283
|
+
* root, else first root" rule.
|
|
284
|
+
*/
|
|
285
|
+
export interface RootsRuntimeConfig {
|
|
286
|
+
/** The host creates one absolutely-positioned surface per world inside
|
|
287
|
+
* this element (D5 §1) — a canvas for three/canvas, a DOM-root `<div>`
|
|
288
|
+
* layer for react — plus one shared UI overlay above all of them. */
|
|
289
|
+
container: HTMLElement;
|
|
290
|
+
roots: RootMountSpec[];
|
|
291
|
+
width?: number | undefined;
|
|
292
|
+
height?: number | undefined;
|
|
293
|
+
/**
|
|
294
|
+
* Mirrors `ThreeHostContext.headless` (Node conformance tests — no GPU): skips
|
|
295
|
+
* real `WebGLRenderer` construction for every three world in this
|
|
296
|
+
* session (a stand-in renderer is used instead; adapters special-case
|
|
297
|
+
* `host.headless` themselves). Pixijs roots are unaffected — Pixi already falls back to
|
|
298
|
+
* a 2D canvas renderer with no GPU. Never set `true` in a real host.
|
|
299
|
+
*/
|
|
300
|
+
headless?: boolean | undefined;
|
|
301
|
+
/**
|
|
302
|
+
* `vgai.project.json`'s `rendering.antialias` — whether every three root's
|
|
303
|
+
* WebGL context is built with a multisampled drawing buffer.
|
|
304
|
+
*
|
|
305
|
+
* It is a RUNTIME-CONFIG option rather than a world's own
|
|
306
|
+
* `WorldRendererConfig` field for the reason that file's header gives: a
|
|
307
|
+
* context's sample count is fixed at CREATION, before any world exists, so
|
|
308
|
+
* there is no honest moment at which a world could ask for it. The
|
|
309
|
+
* manifest-aware boot path (`mount-manifest.ts`) is the real reader;
|
|
310
|
+
* omitting it leaves `createHostRenderer`'s own registry default alone.
|
|
311
|
+
*/
|
|
312
|
+
antialias?: boolean | undefined;
|
|
313
|
+
/** D15 (T-D15.1) — the root seed `ctx.random` boots from on every world
|
|
314
|
+
* mounted onto this session's Game, forwarded to `createGame` BEFORE any
|
|
315
|
+
* world's `mount()`/`setup()` runs (this function constructs the Game
|
|
316
|
+
* first — see `createRootsGameRuntime`). The manifest-aware boot path
|
|
317
|
+
* (`mount-manifest.ts`'s `mountManifestRoots`) is the real caller that
|
|
318
|
+
* resolves this from `manifest.determinism`/`?vgai-seed=`/its own
|
|
319
|
+
* explicit-config leg; a caller building `RootsRuntimeConfig` by hand
|
|
320
|
+
* (a test, a bespoke host) may also set it directly. Omitting it falls
|
|
321
|
+
* back to `createGame`'s own fixed default. */
|
|
322
|
+
seed?: number | undefined;
|
|
323
|
+
/** Private-play or Team Test identity supplied by the host. Networking
|
|
324
|
+
* remains game-owned; games may use `roomKey` in their direct room join. */
|
|
325
|
+
playtest?: PlaytestContext | null | undefined;
|
|
326
|
+
/**
|
|
327
|
+
* Test-only overrides for the render-control seam this host now wires up
|
|
328
|
+
* for EVERY session (see `createRootsGameRuntime`): `location` is where
|
|
329
|
+
* `?vgai-render=1` is read from (defaults to the real `window.location`;
|
|
330
|
+
* a Node test has no `window` and must inject one to exercise the seam),
|
|
331
|
+
* `target` is where the harness publishes (defaults to the real `window`
|
|
332
|
+
* — override in a unit test to avoid touching the global object; mirrors
|
|
333
|
+
* `RenderControlHarnessOptions.target`/`.location` and the
|
|
334
|
+
* `debugBridge.url` override precedent in `mount-manifest.ts`).
|
|
335
|
+
*/
|
|
336
|
+
renderControl?:
|
|
337
|
+
| {
|
|
338
|
+
readonly location?: { readonly search: string } | undefined;
|
|
339
|
+
readonly target?: Record<string, unknown> | undefined;
|
|
340
|
+
}
|
|
341
|
+
| undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Handle returned by createGameRuntime() for controlling the running game.
|
|
346
|
+
*
|
|
347
|
+
* This is the GENERIC host handle — it has no first-party concepts. A
|
|
348
|
+
* root's own surface is reached through `game.defaultRoot.mounted`, typed
|
|
349
|
+
* as the mounted-root contract (`MountedRoot`).
|
|
350
|
+
*
|
|
351
|
+
* Root access is explicit through `game.roots`, `game.world(id)`, and
|
|
352
|
+
* `game.defaultRoot`; the session does not project a surface-specific alias.
|
|
353
|
+
*/
|
|
354
|
+
export interface GameSession {
|
|
355
|
+
stop(): void;
|
|
356
|
+
/** Resolves after every root has finished the cleanup initiated by stop(). */
|
|
357
|
+
readonly stopComplete: Promise<void>;
|
|
358
|
+
pause(): void;
|
|
359
|
+
resume(): void;
|
|
360
|
+
step(): void;
|
|
361
|
+
/** Resize every world's render buffer. `pixelRatio` optionally re-pins the
|
|
362
|
+
* three renderers' DPR in the same pass. */
|
|
363
|
+
resize(width: number, height: number, pixelRatio?: number): void;
|
|
364
|
+
/** The multi-root entry point. Root access stays explicit through
|
|
365
|
+
* `game.roots`, `game.world(id)`, and `game.defaultRoot`. */
|
|
366
|
+
readonly game: Game;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Bootstrap the game host and mount a game adapter.
|
|
371
|
+
*
|
|
372
|
+
* `createGameRuntime` is the universal HOST: it owns the root surfaces,
|
|
373
|
+
* renderers, loop, and asset cache. Every game — including a one-root game —
|
|
374
|
+
* uses the same explicit adapter-root path.
|
|
375
|
+
*/
|
|
376
|
+
export async function createGameRuntime(config: RootsRuntimeConfig): Promise<GameSession> {
|
|
377
|
+
return createRootsGameRuntime(config);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Universal root host
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/** A stand-in `THREE.WebGLRenderer` for headless (`headless:true`) three
|
|
385
|
+
* roots: draw submission is inert while native scene/component evaluation
|
|
386
|
+
* still runs. This satisfies Fiber's custom-renderer gate and the calls
|
|
387
|
+
* (`setPixelRatio`/`setClearColor`/`setSize` at mount, `dispose`/
|
|
388
|
+
* `forceContextLoss` at teardown) — never a real GL call. Node conformance
|
|
389
|
+
* tests only; never used when `headless` is left `false`/absent. */
|
|
390
|
+
function createHeadlessRendererStub(canvas: HTMLCanvasElement): THREE.WebGLRenderer {
|
|
391
|
+
return {
|
|
392
|
+
domElement: canvas,
|
|
393
|
+
render() {},
|
|
394
|
+
setPixelRatio() {},
|
|
395
|
+
setClearColor() {},
|
|
396
|
+
setSize() {},
|
|
397
|
+
dispose() {},
|
|
398
|
+
forceContextLoss() {},
|
|
399
|
+
} as unknown as THREE.WebGLRenderer;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** One already-mounted world, tracked for disposal + the router. `element`
|
|
403
|
+
* is the world's stacked surface — an `HTMLCanvasElement` for three/
|
|
404
|
+
* canvas, or the DOM-root layer `<div>` for React — kept
|
|
405
|
+
* under one field name so `fullCleanup`'s disposal loop stays kind-generic
|
|
406
|
+
* (`container.removeChild(entry.element)` needs no branch). */
|
|
407
|
+
interface MountedAdapterRoot {
|
|
408
|
+
readonly id: string;
|
|
409
|
+
readonly kind: 'three' | 'canvas' | 'dom';
|
|
410
|
+
readonly element: HTMLElement;
|
|
411
|
+
readonly mounted: MountedRoot;
|
|
412
|
+
/** Only three roots own a renderer this file constructed. */
|
|
413
|
+
readonly renderer: THREE.WebGLRenderer | undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Reclaim ONE mounted root: its adapter, the renderer this file constructed for
|
|
418
|
+
* it (including the WebGL context — a browser keeps only a handful alive, so
|
|
419
|
+
* `dispose()` alone is not enough), and its stacked surface element.
|
|
420
|
+
*
|
|
421
|
+
* ONE body, TWO callers, deliberately: the session's `fullCleanup` (a normal
|
|
422
|
+
* stop) and `mountAllRootSpecs`'s partial-mount ROLLBACK (a later root's mount
|
|
423
|
+
* rejected, so the roots that already mounted must not survive the rejection).
|
|
424
|
+
* Those two must never drift — a rollback that reclaims less than a stop is how
|
|
425
|
+
* a failed play leaves a live renderer and a lost GL context behind with no
|
|
426
|
+
* handle anywhere able to reach it.
|
|
427
|
+
*
|
|
428
|
+
* Throws only what the adapter's own `dispose()` throws; every caller wraps it
|
|
429
|
+
* per entry so one bad root cannot abort the rest of the teardown.
|
|
430
|
+
*/
|
|
431
|
+
function disposeMountedRoot(entry: MountedAdapterRoot, container: HTMLElement): void {
|
|
432
|
+
try {
|
|
433
|
+
entry.mounted.dispose();
|
|
434
|
+
} finally {
|
|
435
|
+
// Renderer + surface release run even when the adapter's dispose threw:
|
|
436
|
+
// the GL context is the scarce resource, and the caller still sees the
|
|
437
|
+
// error (this rethrows through the `finally`).
|
|
438
|
+
entry.renderer?.dispose();
|
|
439
|
+
entry.renderer?.forceContextLoss();
|
|
440
|
+
// Remove from the element's CURRENT parent, not the mount-time
|
|
441
|
+
// `container`: the editor's Game panel re-parents the live surfaces
|
|
442
|
+
// when its mount element swaps (fill <-> device preset, W2c), and
|
|
443
|
+
// `container.removeChild` would throw NotFoundError after such a move.
|
|
444
|
+
// `container` remains the fallback for hosts whose element stand-ins
|
|
445
|
+
// never wire `parentNode` (headless unit fixtures); an element already
|
|
446
|
+
// detached by such a host is a no-op via the catch.
|
|
447
|
+
try {
|
|
448
|
+
(entry.element.parentNode ?? container).removeChild(entry.element);
|
|
449
|
+
} catch {
|
|
450
|
+
/* already detached — nothing to remove */
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
interface OneRootResult {
|
|
456
|
+
readonly mountedEntry: MountedAdapterRoot;
|
|
457
|
+
readonly routerEntry: RouterAdapterRoot;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Shared per-world mount inputs, computed once in `createRootsGameRuntime`'s
|
|
461
|
+
* loop (canvas stacking + one dpr) and threaded into whichever of
|
|
462
|
+
* `mountOneThreeRoot`/`mountOneCanvasRoot` this world's `kind` needs — split
|
|
463
|
+
* out so the orchestrating loop itself stays a simple dispatch. */
|
|
464
|
+
interface OneRootContext {
|
|
465
|
+
readonly game: GameInternal;
|
|
466
|
+
readonly canvas: HTMLCanvasElement;
|
|
467
|
+
readonly w: number;
|
|
468
|
+
readonly h: number;
|
|
469
|
+
readonly dpr: number;
|
|
470
|
+
readonly isBottom: boolean;
|
|
471
|
+
readonly headless: boolean;
|
|
472
|
+
/** `RootsRuntimeConfig.antialias` — see that field. Undefined leaves
|
|
473
|
+
* `createHostRenderer`'s own registry default alone. */
|
|
474
|
+
readonly antialias: boolean | undefined;
|
|
475
|
+
readonly assets: ReturnType<typeof createAssetCache>;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Mount one three `RootMountSpec` (canvas + renderer construction, per
|
|
479
|
+
* D5 §1/§4, then `registerThreeRoot`). Split out of
|
|
480
|
+
* `createRootsGameRuntime` purely to keep that function's own branching
|
|
481
|
+
* simple — see `mountOneCanvasRoot` for the canvas sibling. */
|
|
482
|
+
async function mountOneThreeRoot(
|
|
483
|
+
spec: ThreeRootMountSpec,
|
|
484
|
+
ctx: OneRootContext,
|
|
485
|
+
): Promise<OneRootResult> {
|
|
486
|
+
const { game, canvas, w, h, dpr, isBottom, headless, assets, antialias } = ctx;
|
|
487
|
+
const renderer = headless
|
|
488
|
+
? createHeadlessRendererStub(canvas)
|
|
489
|
+
: createHostRenderer(
|
|
490
|
+
canvas,
|
|
491
|
+
w,
|
|
492
|
+
h,
|
|
493
|
+
// The manifest's `rendering.antialias` reaches the WebGL context here and NOWHERE else:
|
|
494
|
+
// `createHostRenderer` passes it straight to the constructor, and the sample count is
|
|
495
|
+
// fixed from that moment. Omitted → the render-settings registry's own default.
|
|
496
|
+
antialias === undefined ? undefined : { antialias },
|
|
497
|
+
{
|
|
498
|
+
alpha: !isBottom,
|
|
499
|
+
preserveDrawingBuffer: true,
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
renderer.setSize(w, h, false); // backing resolution only — CSS stacking owns layout size
|
|
503
|
+
// Defect E4.R1 (Fable review): `createHostRenderer`'s OWN construction-time
|
|
504
|
+
// `setSize` call (`setup-renderer.ts`, `updateStyle` defaulting `true` —
|
|
505
|
+
// unchanged there on purpose, see below) stamps `canvas.style.width`/
|
|
506
|
+
// `.height` to literal `${w}px`/`${h}px` the moment the renderer is built,
|
|
507
|
+
// OVERWRITING the container-relative `100%`/`100%` the surface-stacking
|
|
508
|
+
// loop above just set. The `renderer.setSize(w, h, false)` line right above
|
|
509
|
+
// this comment does NOT undo that stamp (updateStyle:false only skips
|
|
510
|
+
// TOUCHING style, it can't un-stamp a previous call) — so without this
|
|
511
|
+
// re-assertion, every roots-path three canvas' on-screen size was
|
|
512
|
+
// permanently pinned to whatever `w`/`h` it happened to mount at (usually
|
|
513
|
+
// the manifest's `resolution`, since standalone builds mount before a real
|
|
514
|
+
// container size is known — see `mount-manifest.ts`). Re-asserting here
|
|
515
|
+
// makes the CSS layout size and the render-buffer resolution fully
|
|
516
|
+
// independent, exactly like the react DOM world layer already is: this
|
|
517
|
+
// fixes the template-standalone bug (broken at any viewport other than the
|
|
518
|
+
// manifest's `resolution`, which is also Playwright's DEFAULT viewport —
|
|
519
|
+
// why it went unnoticed) and, for free, tri-world's pre-existing
|
|
520
|
+
// resize-staleness (the roots-path `resize()` below always calls
|
|
521
|
+
// `setSize(rw, rh, false)`, so nothing else was ever going to update this
|
|
522
|
+
// canvas' CSS after mount). `createHostRenderer`'s own default
|
|
523
|
+
// (`updateStyle:true`) is intentionally left alone; the host owns CSS layout
|
|
524
|
+
// and reasserts the container-relative size after renderer construction.
|
|
525
|
+
canvas.style.width = '100%';
|
|
526
|
+
canvas.style.height = '100%';
|
|
527
|
+
renderer.setPixelRatio(dpr);
|
|
528
|
+
if (!isBottom) renderer.setClearColor(0x000000, 0); // D5 §1: alpha-clear above the bottom layer
|
|
529
|
+
|
|
530
|
+
const host: GameThreeHostContext = {
|
|
531
|
+
three: THREE,
|
|
532
|
+
surface: { canvas, width: w, height: h },
|
|
533
|
+
renderer,
|
|
534
|
+
assets,
|
|
535
|
+
headless,
|
|
536
|
+
game,
|
|
537
|
+
};
|
|
538
|
+
const mounted = await spec.adapter.mount(host);
|
|
539
|
+
registerThreeRoot(game, spec.adapter, mounted, {
|
|
540
|
+
id: spec.id,
|
|
541
|
+
pausable: spec.pausable,
|
|
542
|
+
declaration: spec.declaration,
|
|
543
|
+
});
|
|
544
|
+
return {
|
|
545
|
+
mountedEntry: { id: spec.id, kind: 'three', element: canvas, mounted, renderer },
|
|
546
|
+
routerEntry: { id: spec.id, zOrder: spec.zOrder ?? 0, canvas, hitTest: spec.hitTest },
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Mount one canvas `RootMountSpec` (via its own `CanvasHostContext`, per D5
|
|
551
|
+
* §1/§3/§4, then `registerCanvasRoot`) — the canvas sibling of
|
|
552
|
+
* `mountOneThreeRoot` above.
|
|
553
|
+
*
|
|
554
|
+
* Unlike `mountOneThreeRoot`, this needs no explicit `canvas.style.width`/
|
|
555
|
+
* `.height` re-assertion. The canvas surface constructs its
|
|
556
|
+
* `Application` with `autoDensity: true`, which makes PIXI ITSELF re-stamp
|
|
557
|
+
* `canvas.style.width`/`.height` (real CSS px, matching the LOGICAL
|
|
558
|
+
* width/height passed to `resize()`) on every `app.renderer.resize()` call —
|
|
559
|
+
* including the one this world's `mounted.resize?.()` triggers from the
|
|
560
|
+
* roots-path `resize()` below. So while a pixi world's canvas can start
|
|
561
|
+
* pinned to the mount-time `w`/`h` (same as three, until PIXI's own
|
|
562
|
+
* construction-time resize runs), it self-heals the moment ANY real
|
|
563
|
+
* `session.resize(rw, rh)` fires — every shipped standalone entry
|
|
564
|
+
* (`packages/editor/template/src/main.ts`, `examples/tri-world/src/main.ts`)
|
|
565
|
+
* already calls `session.resize()` unconditionally right after mount, so
|
|
566
|
+
* this never surfaces as a lasting bug the way three' buffer-only resize
|
|
567
|
+
* did (never self-healing, by design — see above). */
|
|
568
|
+
async function mountOneCanvasRoot(
|
|
569
|
+
spec: CanvasRootMountSpec,
|
|
570
|
+
ctx: Omit<OneRootContext, 'assets'>,
|
|
571
|
+
): Promise<OneRootResult> {
|
|
572
|
+
const { game, canvas, w, h, dpr, headless, isBottom } = ctx;
|
|
573
|
+
const pixiHost: GameCanvasHostContext = {
|
|
574
|
+
canvas,
|
|
575
|
+
width: w,
|
|
576
|
+
height: h,
|
|
577
|
+
game,
|
|
578
|
+
headless,
|
|
579
|
+
dpr,
|
|
580
|
+
transparent: !isBottom,
|
|
581
|
+
preserveDrawingBuffer: true,
|
|
582
|
+
};
|
|
583
|
+
const mounted = await spec.adapter.mount(pixiHost);
|
|
584
|
+
registerCanvasRoot(game, spec.adapter, mounted, {
|
|
585
|
+
id: spec.id,
|
|
586
|
+
pausable: spec.pausable,
|
|
587
|
+
declaration: spec.declaration,
|
|
588
|
+
});
|
|
589
|
+
return {
|
|
590
|
+
mountedEntry: {
|
|
591
|
+
id: spec.id,
|
|
592
|
+
kind: 'canvas',
|
|
593
|
+
element: canvas,
|
|
594
|
+
mounted,
|
|
595
|
+
renderer: undefined,
|
|
596
|
+
},
|
|
597
|
+
routerEntry: {
|
|
598
|
+
id: spec.id,
|
|
599
|
+
zOrder: spec.zOrder ?? 0,
|
|
600
|
+
canvas,
|
|
601
|
+
hitTest: spec.hitTest,
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Mount one React `RootMountSpec` — the DOM sibling of
|
|
608
|
+
* `mountOneThreeRoot`/`mountOneCanvasRoot`. Unlike its canvas-backed siblings
|
|
609
|
+
* this returns NO `routerEntry`: a react world's DOM-root layer participates in
|
|
610
|
+
* D5's z-order/box stacking (the caller still creates and positions its `<div>`
|
|
611
|
+
* exactly like a canvas — see `createRootsGameRuntime`'s stack-building loop)
|
|
612
|
+
* but needs no entry in the delegating router's hit-test loop (§1.C — "DOM
|
|
613
|
+
* layers need no entry in the router's hit-test loop"): the layer's own
|
|
614
|
+
* `pointer-events` discipline (this file sets `none` on the layer by default;
|
|
615
|
+
* the mounted React tree opts specific elements back in with
|
|
616
|
+
* `pointer-events:auto`) is what lets its interactive elements claim events
|
|
617
|
+
* NATIVELY, via the real DOM, with zero router involvement — and lets a click
|
|
618
|
+
* over its non-interactive (transparent) area fall through to the canvas below
|
|
619
|
+
* it via the SAME native DOM hit-testing (a `pointer-events:none` element is
|
|
620
|
+
* invisible to hit-testing entirely, so the click lands on whatever real DOM
|
|
621
|
+
* element is beneath it — the router's normal canvas-vs-canvas forwarding,
|
|
622
|
+
* unaffected by this layer's presence).
|
|
623
|
+
*/
|
|
624
|
+
async function mountOneReactRoot(
|
|
625
|
+
spec: ReactRootMountSpec,
|
|
626
|
+
game: GameInternal,
|
|
627
|
+
/** The ALREADY-created, already-stacked (position/z-index set, appended to
|
|
628
|
+
* `container`) DOM-root layer for this world — see
|
|
629
|
+
* `createRootsGameRuntime`'s surface-stack loop, which builds a `<div>`
|
|
630
|
+
* for every react-kind spec up front, in the SAME pass that builds every
|
|
631
|
+
* other world's canvas. This function must reuse that exact element (never
|
|
632
|
+
* create its own) so `RootInstance.reactRoot()` returns the SAME node
|
|
633
|
+
* that is actually positioned in the stack. */
|
|
634
|
+
layer: HTMLElement,
|
|
635
|
+
): Promise<{ mountedEntry: MountedAdapterRoot }> {
|
|
636
|
+
layer.style.pointerEvents = 'none';
|
|
637
|
+
// An absolutely-positioned layer with no width/height collapses to zero
|
|
638
|
+
// content size, so a child's
|
|
639
|
+
// own position:absolute offsets resolve against a degenerate containing
|
|
640
|
+
// block; otherwise clicks land outside the game.
|
|
641
|
+
layer.style.width = '100%';
|
|
642
|
+
layer.style.height = '100%';
|
|
643
|
+
const reactHost: GameDomHostContext = { container: layer, game };
|
|
644
|
+
const mounted = await spec.adapter.mount(reactHost);
|
|
645
|
+
registerReactRoot(game, spec.adapter, mounted, layer, {
|
|
646
|
+
id: spec.id,
|
|
647
|
+
pausable: spec.pausable,
|
|
648
|
+
declaration: spec.declaration,
|
|
649
|
+
});
|
|
650
|
+
return {
|
|
651
|
+
mountedEntry: {
|
|
652
|
+
id: spec.id,
|
|
653
|
+
kind: 'dom',
|
|
654
|
+
element: layer,
|
|
655
|
+
mounted,
|
|
656
|
+
renderer: undefined,
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* The universal host implementer behind {@link createGameRuntime}. It builds
|
|
663
|
+
* one surface per world — a
|
|
664
|
+
* canvas for three/canvas, a DOM-root `<div>` layer for react — stacked
|
|
665
|
+
* per D5 §1, z-order/ties exactly matching
|
|
666
|
+
* `manifest/load.ts`'s sort, ONE `Game`, and registers every world onto it
|
|
667
|
+
* via `registerThreeRoot`/`registerCanvasRoot`/`registerReactRoot` — the
|
|
668
|
+
* SAME wiring `test/create-runtime-worlds.test.ts` proves for the
|
|
669
|
+
* three/canvas pair. Worlds MOUNT in `roots` ARRAY order ("manifest
|
|
670
|
+
* declaration order" — the frame/registration axis), independent of
|
|
671
|
+
* `zOrder` (the canvas-stacking/rendering axis) — the two orders can differ
|
|
672
|
+
* and both are honored correctly.
|
|
673
|
+
*/
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Dev/e2e-only `window.__vgaiScene`/`__vgaiCamera` exposure for the roots
|
|
677
|
+
* path's default world — split out of `createRootsGameRuntime` purely
|
|
678
|
+
* to keep that function's own cyclomatic complexity down. It uses the
|
|
679
|
+
* "first three world, else none" rule: a non-Three default world publishes
|
|
680
|
+
* neither global. Returns an identity-guarded retraction callback so an
|
|
681
|
+
* older session's stop cannot clobber a newer session's globals. It is a no-op when nothing was
|
|
682
|
+
* published (non-DEV build, or non-threejs default world).
|
|
683
|
+
*/
|
|
684
|
+
function installDefaultRootDevGlobals(game: GameInternal): () => void {
|
|
685
|
+
if (!import.meta.env?.DEV) return () => {};
|
|
686
|
+
const defaultMounted = game.defaultRoot.mounted;
|
|
687
|
+
if (defaultMounted.kind !== 'three') return () => {};
|
|
688
|
+
const scene = defaultMounted.scene;
|
|
689
|
+
const w = window as unknown as Record<string, unknown>;
|
|
690
|
+
w['__vgaiScene'] = scene;
|
|
691
|
+
// An ACCESSOR, not a value read once: a world may replace its camera after
|
|
692
|
+
// mount (fiber's `set({ camera })` — drei's `makeDefault`, or a translated
|
|
693
|
+
// Godot world installing the camera its `.tscn` authors), and a snapshot
|
|
694
|
+
// here reads as authoritative to the probe that reaches for it while naming
|
|
695
|
+
// a camera the frame no longer uses. The retraction guard compares the
|
|
696
|
+
// GETTER's identity, which is the same "don't clobber a newer session"
|
|
697
|
+
// rule the value comparison was.
|
|
698
|
+
const readCamera = (): THREE.PerspectiveCamera =>
|
|
699
|
+
defaultMounted.camera as THREE.PerspectiveCamera;
|
|
700
|
+
Object.defineProperty(w, '__vgaiCamera', {
|
|
701
|
+
get: readCamera,
|
|
702
|
+
configurable: true,
|
|
703
|
+
enumerable: true,
|
|
704
|
+
});
|
|
705
|
+
return () => {
|
|
706
|
+
if (w['__vgaiScene'] === scene) delete w['__vgaiScene'];
|
|
707
|
+
if (Object.getOwnPropertyDescriptor(w, '__vgaiCamera')?.get === readCamera)
|
|
708
|
+
delete w['__vgaiCamera'];
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Shared inputs `mountAllRootSpecs` needs beyond each individual spec —
|
|
713
|
+
* everything `OneRootContext` needs except the per-world `canvas`/
|
|
714
|
+
* `isBottom`, plus the surface lookup and bottom-id needed to derive them. */
|
|
715
|
+
interface MountAllRootsInputs extends Omit<OneRootContext, 'canvas' | 'isBottom'> {
|
|
716
|
+
readonly surfacesById: Map<string, HTMLElement>;
|
|
717
|
+
/** The host element the surfaces were stacked into — needed only by the
|
|
718
|
+
* partial-mount rollback, which reclaims them through the same
|
|
719
|
+
* {@link disposeMountedRoot} body a normal stop uses. */
|
|
720
|
+
readonly container: HTMLElement;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
interface MountAllRootsResult {
|
|
724
|
+
readonly mountedEntries: MountedAdapterRoot[];
|
|
725
|
+
readonly routerEntries: RouterAdapterRoot[];
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Mount + register every world spec, in ARRAY (declaration) order — split
|
|
730
|
+
* out of `createRootsGameRuntime` purely to keep that function's own
|
|
731
|
+
* cyclomatic complexity down (E4, same reason `mountOneThreeRoot`/
|
|
732
|
+
* `mountOneCanvasRoot` are already split out). React roots contribute NO
|
|
733
|
+
* router entry ("DOM layers need no entry in the router's hit-test loop"):
|
|
734
|
+
* their layer's own `pointer-events` discipline handles claim/fall-through
|
|
735
|
+
* natively, with zero router involvement (see `mountOneReactRoot`'s doc
|
|
736
|
+
* comment).
|
|
737
|
+
*/
|
|
738
|
+
async function mountAllRootSpecs(
|
|
739
|
+
mountSpecs: (ThreeRootMountSpec | CanvasRootMountSpec | ReactRootMountSpec)[],
|
|
740
|
+
bottomId: string | undefined,
|
|
741
|
+
inputs: MountAllRootsInputs,
|
|
742
|
+
): Promise<MountAllRootsResult> {
|
|
743
|
+
const { surfacesById, container, ...shared } = inputs;
|
|
744
|
+
const mountedEntries: MountedAdapterRoot[] = [];
|
|
745
|
+
const routerEntries: RouterAdapterRoot[] = [];
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
for (const spec of mountSpecs) {
|
|
749
|
+
const isBottom = spec.id === bottomId;
|
|
750
|
+
if (spec.kind === 'dom') {
|
|
751
|
+
const layer = surfacesById.get(spec.id)!;
|
|
752
|
+
const { mountedEntry } = await mountOneReactRoot(spec, shared.game, layer);
|
|
753
|
+
mountedEntries.push(mountedEntry);
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const canvas = surfacesById.get(spec.id)! as HTMLCanvasElement;
|
|
757
|
+
const oneCtx: OneRootContext = { ...shared, canvas, isBottom };
|
|
758
|
+
let mountedEntry: MountedAdapterRoot;
|
|
759
|
+
let routerEntry: RouterAdapterRoot;
|
|
760
|
+
if (spec.kind === 'three') {
|
|
761
|
+
({ mountedEntry, routerEntry } = await mountOneThreeRoot(spec, oneCtx));
|
|
762
|
+
} else if (spec.kind === 'canvas') {
|
|
763
|
+
({ mountedEntry, routerEntry } = await mountOneCanvasRoot(spec, oneCtx));
|
|
764
|
+
} else {
|
|
765
|
+
// Exhaustiveness guard (§7.4-2): 'react' was already handled by the
|
|
766
|
+
// early `continue` above, so only a hypothetical 4th `AdapterSurface` can
|
|
767
|
+
// reach here — fail loudly rather than silently defaulting. `spec`
|
|
768
|
+
// itself (not `spec.kind`) is what TS has narrowed to `never`, since
|
|
769
|
+
// `RootMountSpec` is a discriminated union at the object level.
|
|
770
|
+
assertNever(spec, 'create-runtime mount loop');
|
|
771
|
+
}
|
|
772
|
+
mountedEntries.push(mountedEntry);
|
|
773
|
+
routerEntries.push(routerEntry);
|
|
774
|
+
}
|
|
775
|
+
} catch (err) {
|
|
776
|
+
// PARTIAL MOUNT ROLLBACK. Roots mount sequentially, so a rejection from
|
|
777
|
+
// root N leaves roots 1..N-1 fully live — renderer, WebGL context, R3F
|
|
778
|
+
// tree, adapter registrations — and NOTHING ever gets a handle to them:
|
|
779
|
+
// this function throws instead of returning, so no `GameSession` (and
|
|
780
|
+
// therefore no `stop()`) is ever constructed. Reclaim them here, in
|
|
781
|
+
// REVERSE mount order (the inverse of the order they were built in), then
|
|
782
|
+
// rethrow the ORIGINAL error — the mount failure is what the caller must
|
|
783
|
+
// see, never a teardown error raised while cleaning up after it.
|
|
784
|
+
for (let i = mountedEntries.length - 1; i >= 0; i--) {
|
|
785
|
+
const entry = mountedEntries[i]!;
|
|
786
|
+
try {
|
|
787
|
+
disposeMountedRoot(entry, container);
|
|
788
|
+
} catch (disposeErr) {
|
|
789
|
+
// biome-ignore lint/suspicious/noConsole: a rollback failure must be visible; the original mount error is still what we rethrow
|
|
790
|
+
console.error(
|
|
791
|
+
`createGameRuntime: rolling back root "${entry.id}" after a mount failure threw:`,
|
|
792
|
+
disposeErr,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
throw err;
|
|
797
|
+
}
|
|
798
|
+
return { mountedEntries, routerEntries };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* G3/FT-11 — publish the render-control harness (`render-control.ts`) for a
|
|
803
|
+
* live session, returning its retraction (wired into the session's own
|
|
804
|
+
* `stop()`, same leave-nothing-live rule as the debug bridge's `uninstall()`
|
|
805
|
+
* in mount-manifest.ts — render-control has no uninstall surface of its own,
|
|
806
|
+
* a render page being a single-load host by design, so the retraction is
|
|
807
|
+
* the publication's inverse). Split out of `createRootsGameRuntime` purely
|
|
808
|
+
* to keep that function's cyclomatic complexity down (the same reason
|
|
809
|
+
* `mountAllRootSpecs`/`installDefaultRootDevGlobals` are split out).
|
|
810
|
+
*
|
|
811
|
+
* The clock is a documented no-op: a manifest-mounted game has no global
|
|
812
|
+
* cinematic AnimationClock to seek (a cinematic fixture that HAS one
|
|
813
|
+
* installs its own harness with the real clock, e.g.
|
|
814
|
+
* packages/engine/e2e/render-cinematic/main.ts) — the meaningful advance
|
|
815
|
+
* path for a plain game is `simulateSubsteps` (I5 `--simulate`), which
|
|
816
|
+
* drives `game.runFrame`.
|
|
817
|
+
*/
|
|
818
|
+
function installSessionRenderHarness(
|
|
819
|
+
game: GameInternal,
|
|
820
|
+
location: { readonly search: string },
|
|
821
|
+
targetOverride: Record<string, unknown> | undefined,
|
|
822
|
+
): (() => void) | undefined {
|
|
823
|
+
const target =
|
|
824
|
+
targetOverride ??
|
|
825
|
+
(typeof window !== 'undefined' ? (window as unknown as Record<string, unknown>) : undefined);
|
|
826
|
+
if (target === undefined) return undefined;
|
|
827
|
+
installRenderControlHarness({
|
|
828
|
+
game,
|
|
829
|
+
clock: {
|
|
830
|
+
seek() {
|
|
831
|
+
/* no global cinematic clock on a manifest-mounted game — see above */
|
|
832
|
+
},
|
|
833
|
+
seekFrame() {
|
|
834
|
+
/* intentional no-op, as seek() */
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
readiness: {
|
|
838
|
+
// Mount is complete by the time this harness exists (the install site
|
|
839
|
+
// in `createRootsGameRuntime` is after `mountAllRootSpecs`
|
|
840
|
+
// resolved) — the hook resolving immediately IS the readiness
|
|
841
|
+
// statement, not an assumption.
|
|
842
|
+
scene() {},
|
|
843
|
+
},
|
|
844
|
+
location,
|
|
845
|
+
target,
|
|
846
|
+
});
|
|
847
|
+
return () => {
|
|
848
|
+
delete target['__vgaiRender'];
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Reclaim what `createRootsGameRuntime` built for ITSELF when the mount
|
|
854
|
+
* rejects. `mountAllRootSpecs` has already rolled back every root that DID
|
|
855
|
+
* mount, so what is left is the Game shell (holding whatever registrations
|
|
856
|
+
* those roots made into it) and the stacked surface elements of the roots that
|
|
857
|
+
* never got to mount. Neither is reachable afterwards: the mount throws instead
|
|
858
|
+
* of returning, so no `GameSession` — and therefore no `stop()` — ever exists.
|
|
859
|
+
*
|
|
860
|
+
* Never throws: the MOUNT error is what the caller must see, so a failure while
|
|
861
|
+
* cleaning up after it is reported and swallowed.
|
|
862
|
+
*/
|
|
863
|
+
function reclaimAfterMountFailure(
|
|
864
|
+
game: GameInternal,
|
|
865
|
+
surfaces: Iterable<HTMLElement>,
|
|
866
|
+
container: HTMLElement,
|
|
867
|
+
): void {
|
|
868
|
+
try {
|
|
869
|
+
game.dispose();
|
|
870
|
+
} catch (disposeErr) {
|
|
871
|
+
// biome-ignore lint/suspicious/noConsole: a failed rollback must be visible; the original mount error is what the caller gets
|
|
872
|
+
console.error('createGameRuntime: disposing the game after a mount failure threw:', disposeErr);
|
|
873
|
+
}
|
|
874
|
+
for (const surface of surfaces) {
|
|
875
|
+
try {
|
|
876
|
+
(surface.parentNode ?? container).removeChild(surface);
|
|
877
|
+
} catch {
|
|
878
|
+
/* already detached by the per-root rollback */
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameSession> {
|
|
884
|
+
const { container, roots: specs, width, height, headless = false, seed, playtest } = config;
|
|
885
|
+
if (specs.length === 0) {
|
|
886
|
+
throw new Error('createGameRuntime: `roots` must contain at least one root.');
|
|
887
|
+
}
|
|
888
|
+
const mountSpecs = specs as (ThreeRootMountSpec | CanvasRootMountSpec | ReactRootMountSpec)[];
|
|
889
|
+
|
|
890
|
+
const w = Math.max(
|
|
891
|
+
1,
|
|
892
|
+
width ?? (container as HTMLElement & { clientWidth?: number }).clientWidth ?? 0,
|
|
893
|
+
);
|
|
894
|
+
const h = Math.max(
|
|
895
|
+
1,
|
|
896
|
+
height ?? (container as HTMLElement & { clientHeight?: number }).clientHeight ?? 0,
|
|
897
|
+
);
|
|
898
|
+
const dpr = headless
|
|
899
|
+
? 1
|
|
900
|
+
: Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2);
|
|
901
|
+
|
|
902
|
+
if (!container.style.position) container.style.position = 'relative';
|
|
903
|
+
|
|
904
|
+
// --- Surface stack (D5 §1): DOM/z-order follows zOrder, ties -> array order
|
|
905
|
+
// — computed FIRST (bottom -> top) so both the z-index assignment below and
|
|
906
|
+
// the router's default-claim rule share one definition. A react world's
|
|
907
|
+
// DOM-root layer shares this SAME stacking pass even though it is a `<div>`, not a
|
|
908
|
+
// canvas, and carries no `hitTest` (the router never sees react entries at
|
|
909
|
+
// all — see the dispatch loop below). ---
|
|
910
|
+
const claimEntries = mountSpecs.map((spec) => ({
|
|
911
|
+
id: spec.id,
|
|
912
|
+
zOrder: spec.zOrder ?? 0,
|
|
913
|
+
hitTest: spec.hitTest,
|
|
914
|
+
}));
|
|
915
|
+
const stacked = stackOrder(claimEntries);
|
|
916
|
+
const bottomId = stacked[0]?.id;
|
|
917
|
+
const kindById = new Map(mountSpecs.map((spec) => [spec.id, spec.kind] as const));
|
|
918
|
+
|
|
919
|
+
const surfacesById = new Map<string, HTMLElement>();
|
|
920
|
+
stacked.forEach((entry, i) => {
|
|
921
|
+
const isReact = kindById.get(entry.id) === 'dom';
|
|
922
|
+
const surface = document.createElement(isReact ? 'div' : 'canvas') as HTMLElement;
|
|
923
|
+
surface.dataset['vgaiRootSurface'] = 'true';
|
|
924
|
+
// WHICH ROOT THIS SURFACE PRESENTS — the host mounted it, so the host
|
|
925
|
+
// knows, and stamping it here is the difference between a declaration and
|
|
926
|
+
// the guess every late reader had to make instead ("the first <canvas> in
|
|
927
|
+
// DOM order is the game's"). Read by `packages/editor/src/presentation-
|
|
928
|
+
// surface.ts`, which is the one door the capture/staleness/screenshot
|
|
929
|
+
// sites now ask (ARCHITECTURE-CORE §The editor protocol, zero inference).
|
|
930
|
+
surface.dataset['vgaiRootId'] = entry.id;
|
|
931
|
+
if (!isReact) {
|
|
932
|
+
const canvas = surface as unknown as HTMLCanvasElement;
|
|
933
|
+
canvas.width = w;
|
|
934
|
+
canvas.height = h;
|
|
935
|
+
}
|
|
936
|
+
// Defect E4.R1 (Fable review): layout size is ALWAYS container-relative
|
|
937
|
+
// (`width:100%;height:100%`), fully decoupled from the surface's
|
|
938
|
+
// intrinsic buffer resolution (`w`/`h`, set on the canvas element's
|
|
939
|
+
// `width`/`height` ATTRIBUTES above — a device-pixel/render-resolution
|
|
940
|
+
// concern only). Without this, a canvas with no CSS size falls back to
|
|
941
|
+
// its `width`/`height` attribute as its CSS layout size too, so whatever
|
|
942
|
+
// stamped those attributes (`createHostRenderer`'s construction-time
|
|
943
|
+
// `setSize`, `mountOneThreeRoot` below) pins the ON-SCREEN size — see
|
|
944
|
+
// that function's doc comment for the concrete bug this caused (template
|
|
945
|
+
// standalone at any viewport ≠ the manifest resolution). Setting it HERE
|
|
946
|
+
// too (not just in `mountOneThreeRoot`) means every surface, whatever
|
|
947
|
+
// kind, starts container-relative from its very first paint, before any
|
|
948
|
+
// per-kind mount work has even run.
|
|
949
|
+
// `contain:layout paint` makes each surface the containing block for
|
|
950
|
+
// `position:fixed` descendants (and clips overflow to the world's
|
|
951
|
+
// rectangle): full-screen game UI written the natural way (`fixed;
|
|
952
|
+
// inset:0`) then fills the WORLD, not the page. Without it, `fixed` UI
|
|
953
|
+
// looks correct standalone (surface == window) but escapes over the
|
|
954
|
+
// editor chrome whenever the surface is a sub-rectangle of the page —
|
|
955
|
+
// and toggles behavior when any ancestor gains a transform (e.g. the
|
|
956
|
+
// editor's space-pan). Dogfooded 2026-07-12 via a react shell world.
|
|
957
|
+
surface.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;z-index:${i + 1};contain:layout paint;`;
|
|
958
|
+
container.appendChild(surface);
|
|
959
|
+
surfacesById.set(entry.id, surface);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
const assets = createAssetCache();
|
|
963
|
+
// Every manifest/host-mounted game is a deterministic-capture candidate —
|
|
964
|
+
// the render-control seam (`?vgai-render=1`, render-control.ts) is wired
|
|
965
|
+
// HERE, at the one host every boot path shares (mountManifestRoots and
|
|
966
|
+
// direct createGameRuntime callers both reach this function), instead of asking every project's entry page to install it
|
|
967
|
+
// the way the e2e fixtures do. Production-protected twice over:
|
|
968
|
+
// `isRenderModeRequested` gates on the query param, and
|
|
969
|
+
// `installRenderControlHarness` re-checks it internally (its AC 4), so a
|
|
970
|
+
// normal gameplay page sees zero change.
|
|
971
|
+
const renderModeLocation =
|
|
972
|
+
config.renderControl?.location ?? (typeof window !== 'undefined' ? window.location : undefined);
|
|
973
|
+
const renderMode = renderModeLocation !== undefined && isRenderModeRequested(renderModeLocation);
|
|
974
|
+
let started = false;
|
|
975
|
+
const loop = createGameLoop({
|
|
976
|
+
fixedTimestep: 1 / 60,
|
|
977
|
+
maxSubSteps: 8,
|
|
978
|
+
// Render mode runs the loop in EXTERNAL-DRIVE mode (game-loop.ts, I2):
|
|
979
|
+
// no RAF is ever armed, so nothing advances gameplay between the capture
|
|
980
|
+
// host's explicit `simulateSubsteps`/`renderOnce` calls — wall-clock time
|
|
981
|
+
// passing while a screenshot is taken must not move the world.
|
|
982
|
+
externalDrive: renderMode,
|
|
983
|
+
// Sim and presentation are wired to different callbacks here:
|
|
984
|
+
// `update` runs the gameplay phases once per consumed fixed substep with
|
|
985
|
+
// `preRender`/`render` withheld, and `render` runs those two once per real
|
|
986
|
+
// display frame with the interpolation alpha. Both closures are invoked
|
|
987
|
+
// only from the loop's own rAF arm, which `externalDrive` never arms — so
|
|
988
|
+
// a capture/offline-export page (`renderMode`) reaches neither, and its
|
|
989
|
+
// `simulateSubsteps` → `game.runFrame(fixedDt)` path stays frame-exact,
|
|
990
|
+
// rendering inside the substep.
|
|
991
|
+
update: (dt) => {
|
|
992
|
+
if (started) game.runFrame(dt, { skipRenderPhases: true });
|
|
993
|
+
},
|
|
994
|
+
render: (alpha, displayDt) => {
|
|
995
|
+
if (started) game.runRenderFrame(alpha, displayDt);
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
const game = createGame({ loop, assets, seed, playtest });
|
|
999
|
+
|
|
1000
|
+
// --- Mount + register every world, in ARRAY (declaration) order. ---
|
|
1001
|
+
// Split out into its own top-level function purely to keep
|
|
1002
|
+
// `createRootsGameRuntime`'s own cyclomatic complexity down (E4) — same
|
|
1003
|
+
// reason `mountOneThreeRoot`/`mountOneCanvasRoot` are already split out
|
|
1004
|
+
// below.
|
|
1005
|
+
let mounted: MountAllRootsResult;
|
|
1006
|
+
try {
|
|
1007
|
+
mounted = await mountAllRootSpecs(mountSpecs, bottomId, {
|
|
1008
|
+
game,
|
|
1009
|
+
surfacesById,
|
|
1010
|
+
container,
|
|
1011
|
+
w,
|
|
1012
|
+
h,
|
|
1013
|
+
dpr,
|
|
1014
|
+
headless,
|
|
1015
|
+
assets,
|
|
1016
|
+
antialias: config.antialias,
|
|
1017
|
+
});
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
reclaimAfterMountFailure(game, surfacesById.values(), container);
|
|
1020
|
+
throw err;
|
|
1021
|
+
}
|
|
1022
|
+
const { mountedEntries, routerEntries } = mounted;
|
|
1023
|
+
|
|
1024
|
+
// --- Delegating input router (D5 §2a) ---
|
|
1025
|
+
const router = createInputRouter(container, routerEntries);
|
|
1026
|
+
|
|
1027
|
+
started = true;
|
|
1028
|
+
loop.start();
|
|
1029
|
+
|
|
1030
|
+
// Expose the default Three root's scene and camera for dev/e2e tooling.
|
|
1031
|
+
const retractDevGlobals = installDefaultRootDevGlobals(game);
|
|
1032
|
+
|
|
1033
|
+
// G3/FT-11 — publish `window.__vgaiRender` for the deterministic capture
|
|
1034
|
+
// host. Installed at the TAIL of the mount (same position as
|
|
1035
|
+
// mount-manifest.ts's debug bridge): every world's `mount()`/`setup()` has
|
|
1036
|
+
// resolved by now, so the harness's `scene` readiness hook reporting ready
|
|
1037
|
+
// is truthful. See `installSessionRenderHarness` below.
|
|
1038
|
+
const retractRenderHarness =
|
|
1039
|
+
renderMode && renderModeLocation !== undefined
|
|
1040
|
+
? installSessionRenderHarness(game, renderModeLocation, config.renderControl?.target)
|
|
1041
|
+
: undefined;
|
|
1042
|
+
|
|
1043
|
+
let resolveStopComplete!: () => void;
|
|
1044
|
+
const stopComplete = new Promise<void>((resolve) => {
|
|
1045
|
+
resolveStopComplete = resolve;
|
|
1046
|
+
});
|
|
1047
|
+
let stopping = false;
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Reclaim EVERYTHING this session owns, and let no single failure stop that.
|
|
1051
|
+
*
|
|
1052
|
+
* Teardown used to be one `try` over the whole body, so a throwing
|
|
1053
|
+
* `entry.mounted.dispose()` on root 1 skipped root 2's disposal, both
|
|
1054
|
+
* renderers' `forceContextLoss`, `game.dispose()` and the two global
|
|
1055
|
+
* retractions — while `stopComplete` still resolved and the editor still
|
|
1056
|
+
* reported the instance reclaimed. Every step is now isolated: a failure is
|
|
1057
|
+
* COLLECTED and reported, never allowed to abort the steps after it.
|
|
1058
|
+
*/
|
|
1059
|
+
function fullCleanup(): void {
|
|
1060
|
+
if (stopping) return;
|
|
1061
|
+
stopping = true;
|
|
1062
|
+
const failures: { readonly step: string; readonly error: unknown }[] = [];
|
|
1063
|
+
/** Run one teardown step; record its failure and keep going. */
|
|
1064
|
+
const step = (name: string, run: () => void): void => {
|
|
1065
|
+
try {
|
|
1066
|
+
run();
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
failures.push({ step: name, error });
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
try {
|
|
1072
|
+
step('loop.stop', () => loop.stop());
|
|
1073
|
+
step('router.dispose', () => router.dispose());
|
|
1074
|
+
for (const entry of mountedEntries) {
|
|
1075
|
+
step(`root "${entry.id}"`, () => disposeMountedRoot(entry, container));
|
|
1076
|
+
}
|
|
1077
|
+
step('game.dispose', () => game.dispose());
|
|
1078
|
+
step('dev globals', () => retractDevGlobals());
|
|
1079
|
+
step('render harness', () => retractRenderHarness?.());
|
|
1080
|
+
} finally {
|
|
1081
|
+
void Promise.allSettled(
|
|
1082
|
+
mountedEntries.map((entry) => entry.mounted.disposeComplete ?? Promise.resolve()),
|
|
1083
|
+
).then(() => resolveStopComplete());
|
|
1084
|
+
}
|
|
1085
|
+
if (failures.length > 0) {
|
|
1086
|
+
// LOUD, and after everything else has been reclaimed. `stop()` is called
|
|
1087
|
+
// from hosts that must not be left half-torn-down by an early throw, so
|
|
1088
|
+
// the report comes last — but it IS a report: a silent partial teardown
|
|
1089
|
+
// is the failure this whole restructure exists to end. The editor's
|
|
1090
|
+
// `exitPlayMode` catches it and prints it to the editor console.
|
|
1091
|
+
throw new AggregateError(
|
|
1092
|
+
failures.map((f) => f.error),
|
|
1093
|
+
`Game session teardown failed in ${failures.length} step(s): ${failures
|
|
1094
|
+
.map((f) => f.step)
|
|
1095
|
+
.join(', ')}. Every other step still ran.`,
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
stop: fullCleanup,
|
|
1102
|
+
stopComplete,
|
|
1103
|
+
// `Game.play` owns per-world pause, loop-gate, and audio-gate semantics.
|
|
1104
|
+
pause() {
|
|
1105
|
+
game.play.pause();
|
|
1106
|
+
},
|
|
1107
|
+
resume() {
|
|
1108
|
+
game.play.resume();
|
|
1109
|
+
},
|
|
1110
|
+
step() {
|
|
1111
|
+
game.play.step();
|
|
1112
|
+
},
|
|
1113
|
+
resize(rw: number, rh: number, pixelRatio?: number) {
|
|
1114
|
+
const safeWidth = Math.max(1, rw);
|
|
1115
|
+
const safeHeight = Math.max(1, rh);
|
|
1116
|
+
for (const entry of mountedEntries) {
|
|
1117
|
+
// W2c device preview: re-pin DPR before setSize so the drawing
|
|
1118
|
+
// buffer (and the first-party composer, whose `mounted.resize` reads
|
|
1119
|
+
// `getDrawingBufferSize`) picks it up in this same pass. Pixi/react
|
|
1120
|
+
// worlds have no `entry.renderer` and keep their own resolution.
|
|
1121
|
+
if (pixelRatio !== undefined) entry.renderer?.setPixelRatio(pixelRatio);
|
|
1122
|
+
entry.renderer?.setSize(safeWidth, safeHeight, false);
|
|
1123
|
+
entry.mounted.resize?.(safeWidth, safeHeight);
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
game,
|
|
1127
|
+
};
|
|
1128
|
+
}
|