@vgai/engine 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -0
  3. package/package.json +55 -0
  4. package/src/adapter/authoring.ts +402 -0
  5. package/src/adapter/colyseus-networking-adapter.ts +72 -0
  6. package/src/adapter/first-party-systems.ts +103 -0
  7. package/src/adapter/game-adapter.ts +151 -0
  8. package/src/adapter/host-context.ts +77 -0
  9. package/src/adapter/index.ts +85 -0
  10. package/src/adapter/ingest/game-contract.ts +59 -0
  11. package/src/adapter/ingest/overlay-applier.ts +207 -0
  12. package/src/adapter/ingest/overlay-apply.ts +124 -0
  13. package/src/adapter/ingest/overlay-file.ts +126 -0
  14. package/src/adapter/ingest/overlay-report.ts +176 -0
  15. package/src/adapter/ingest/scene-capture.ts +307 -0
  16. package/src/adapter/ingest/upstream-pin.ts +52 -0
  17. package/src/adapter/loop-gate-report.ts +54 -0
  18. package/src/adapter/rapier-physics-adapter.ts +56 -0
  19. package/src/adapter/system-adapter.ts +154 -0
  20. package/src/adapter/transform.ts +18 -0
  21. package/src/adapter/vgai-scene-game-adapter.ts +886 -0
  22. package/src/adapter/world-kind.ts +34 -0
  23. package/src/ai/navigation.ts +164 -0
  24. package/src/animation/anim-graph-types.ts +56 -0
  25. package/src/animation/anim-graph.ts +406 -0
  26. package/src/animation/anim-system.ts +28 -0
  27. package/src/animation/blend-node.ts +119 -0
  28. package/src/animation/property-track.ts +178 -0
  29. package/src/animation/schema.ts +204 -0
  30. package/src/assets.ts +80 -0
  31. package/src/audio/ambient.ts +300 -0
  32. package/src/audio/impacts.ts +212 -0
  33. package/src/audio/index.ts +7 -0
  34. package/src/audio/movement.ts +140 -0
  35. package/src/audio/musical.ts +200 -0
  36. package/src/audio/ui-sounds.ts +171 -0
  37. package/src/audio/vehicle.ts +235 -0
  38. package/src/audio/weapons.ts +152 -0
  39. package/src/core/game-loop.ts +127 -0
  40. package/src/core/system-runner.ts +298 -0
  41. package/src/core/types.ts +58 -0
  42. package/src/dev/console-bridge.ts +83 -0
  43. package/src/dev/debug-draw.ts +80 -0
  44. package/src/dev/logger.ts +119 -0
  45. package/src/ecs/component-manager.ts +748 -0
  46. package/src/ecs/game-component.ts +147 -0
  47. package/src/ecs/hmr-swap-report.ts +65 -0
  48. package/src/input/input-manager.ts +439 -0
  49. package/src/input/input-types.ts +19 -0
  50. package/src/input/schema.ts +129 -0
  51. package/src/loader.ts +70 -0
  52. package/src/manifest/index.ts +24 -0
  53. package/src/manifest/load-file.ts +16 -0
  54. package/src/manifest/load.ts +378 -0
  55. package/src/manifest/schema.ts +375 -0
  56. package/src/physics/collision-system.ts +76 -0
  57. package/src/physics/physics-registry.ts +83 -0
  58. package/src/physics/transform-writer.ts +41 -0
  59. package/src/physics/trigger-dispatch.ts +97 -0
  60. package/src/react/game-state.tsx +172 -0
  61. package/src/render/auto-batcher.ts +169 -0
  62. package/src/render/render-batch-system.ts +268 -0
  63. package/src/render/render-features.ts +146 -0
  64. package/src/render/render-settings.ts +72 -0
  65. package/src/runtime/create-runtime.ts +1152 -0
  66. package/src/runtime/frame-selector-cache.ts +81 -0
  67. package/src/runtime/game.ts +1003 -0
  68. package/src/runtime/input-router.ts +213 -0
  69. package/src/runtime/mount-game.ts +269 -0
  70. package/src/runtime/mount-manifest.ts +361 -0
  71. package/src/runtime/scene-ui-bridge.ts +86 -0
  72. package/src/runtime/scene-ui-data.ts +119 -0
  73. package/src/runtime/state-bridge.ts +79 -0
  74. package/src/runtime/types.ts +196 -0
  75. package/src/scene/asset-loaders.ts +195 -0
  76. package/src/scene/asset-paths.ts +123 -0
  77. package/src/scene/asset-registry.ts +67 -0
  78. package/src/scene/collider-dimensions.ts +125 -0
  79. package/src/scene/component-registry.ts +40 -0
  80. package/src/scene/defaults.ts +164 -0
  81. package/src/scene/geometries/index.ts +7 -0
  82. package/src/scene/geometries/terrain.ts +42 -0
  83. package/src/scene/geometry-registry.ts +42 -0
  84. package/src/scene/instance-registry.ts +84 -0
  85. package/src/scene/instancers/grid.ts +38 -0
  86. package/src/scene/instancers/index.ts +7 -0
  87. package/src/scene/light-camera-factory.ts +97 -0
  88. package/src/scene/material-factory.ts +211 -0
  89. package/src/scene/material-registry.ts +73 -0
  90. package/src/scene/materials/index.ts +7 -0
  91. package/src/scene/materials/water.ts +56 -0
  92. package/src/scene/parse.ts +71 -0
  93. package/src/scene/particles-factory.ts +383 -0
  94. package/src/scene/scene-apply.ts +356 -0
  95. package/src/scene/scene-diff-schema.ts +115 -0
  96. package/src/scene/scene-diff-types.ts +29 -0
  97. package/src/scene/scene-loader.ts +1533 -0
  98. package/src/scene/scene-query.ts +63 -0
  99. package/src/scene/scene-types.ts +34 -0
  100. package/src/scene/scene-version.ts +40 -0
  101. package/src/scene/schema/animation.ts +95 -0
  102. package/src/scene/schema/audio.ts +25 -0
  103. package/src/scene/schema/camera.ts +21 -0
  104. package/src/scene/schema/collider.ts +69 -0
  105. package/src/scene/schema/entity-ref.ts +78 -0
  106. package/src/scene/schema/entity.ts +169 -0
  107. package/src/scene/schema/environment.ts +384 -0
  108. package/src/scene/schema/index.ts +95 -0
  109. package/src/scene/schema/instances.ts +35 -0
  110. package/src/scene/schema/joint.ts +26 -0
  111. package/src/scene/schema/light.ts +38 -0
  112. package/src/scene/schema/material.ts +113 -0
  113. package/src/scene/schema/mesh.ts +108 -0
  114. package/src/scene/schema/particles.ts +398 -0
  115. package/src/scene/schema/physics.ts +49 -0
  116. package/src/scene/schema/scene-file.ts +299 -0
  117. package/src/scene/schema/shadow.ts +24 -0
  118. package/src/scene/schema/spline.ts +21 -0
  119. package/src/scene/schema/tuples.ts +21 -0
  120. package/src/scene/schema/ui.ts +602 -0
  121. package/src/scene/user-data.ts +203 -0
  122. package/src/setup/setup-audio.ts +60 -0
  123. package/src/setup/setup-particles.ts +23 -0
  124. package/src/setup/setup-physics.ts +67 -0
  125. package/src/setup/setup-renderer.ts +529 -0
  126. package/src/types-n8ao.d.ts +37 -0
  127. package/src/types-realism-effects.d.ts +61 -0
  128. package/src/world2d/authoring-2d.ts +208 -0
  129. package/src/world2d/capture-to-scene2d.ts +52 -0
  130. package/src/world2d/collision-2d.ts +106 -0
  131. package/src/world2d/components-2d.ts +86 -0
  132. package/src/world2d/index.ts +66 -0
  133. package/src/world2d/ingest-iframe-2d.ts +255 -0
  134. package/src/world2d/ingest2d.ts +131 -0
  135. package/src/world2d/physics2d-registry.ts +49 -0
  136. package/src/world2d/pixi-game-adapter.ts +325 -0
  137. package/src/world2d/pixi-surface.ts +78 -0
  138. package/src/world2d/scene-capture-2d.ts +117 -0
  139. package/src/world2d/scene2d-loader.ts +308 -0
  140. package/src/world2d/schema/entity2d.ts +145 -0
  141. package/src/world2d/schema/physics2d.ts +53 -0
  142. package/src/world2d/schema/sprite.ts +71 -0
  143. package/src/world2d/schema/tilemap.ts +22 -0
  144. package/src/world2d/schema/tuples2d.ts +25 -0
  145. package/src/world2d/system-adapters-2d.ts +49 -0
  146. package/src/world2d/transform-writer-2d.ts +24 -0
  147. package/src/world2d/types.ts +55 -0
@@ -0,0 +1,886 @@
1
+ /**
2
+ * VgaiSceneGameAdapter — the FIRST-PARTY implementer of {@link GameAdapter}.
3
+ *
4
+ * This is where `.vscn` + `GameComponent` + Rapier live now: the host no longer
5
+ * knows about any of them. `mount(host)` builds the full first-party runtime
6
+ * (the body that used to live inline in `create-runtime.ts`) from a neutral
7
+ * {@link HostContext}, runs the game's `setup`/scene load, and returns a
8
+ * {@link MountedGame}. First-party content is now just one implementer of the
9
+ * same interface an unmodified external game implements (Phase B).
10
+ *
11
+ * `GameSetupFn` / `GameContext` are imported ONLY here (and in the example
12
+ * `fromSetup` wrappers) — never by the host. That confinement is the inversion.
13
+ */
14
+
15
+ import RAPIER from '@dimforge/rapier3d-compat';
16
+ import type { EffectComposer } from 'postprocessing';
17
+ import * as THREE from 'three';
18
+ import type { AnimGraph } from '../animation/anim-graph';
19
+ import { animationSystem } from '../animation/anim-system';
20
+ import { createSystemRunner } from '../core/system-runner';
21
+ import { createDebugDraw } from '../dev/debug-draw';
22
+ import { createComponentManager } from '../ecs/component-manager';
23
+ import { InputManager } from '../input/input-manager';
24
+ import { setAssetPrefix } from '../loader';
25
+ import { createCollisionSystem } from '../physics/collision-system';
26
+ import { createPhysicsRegistry } from '../physics/physics-registry';
27
+ import { createTransformWriter } from '../physics/transform-writer';
28
+ import { createTriggerDispatch } from '../physics/trigger-dispatch';
29
+ import { RenderBatchSystem } from '../render/render-batch-system';
30
+ import { type RenderScope, resolveRenderSettings } from '../render/render-settings';
31
+ import type { WorldFrameHooks } from '../runtime/game';
32
+ import {
33
+ renderSceneUI,
34
+ type SceneUIDataSourceLike,
35
+ type SceneUIGameServices,
36
+ type SceneUIHandle,
37
+ } from '../runtime/scene-ui-bridge';
38
+ import { gameStateDataSource } from '../runtime/scene-ui-data';
39
+ import type { EditorPreview, GameCleanup, GameContext, GameSetupFn } from '../runtime/types';
40
+ import type { ComponentRegistry } from '../scene/component-registry';
41
+ import { tickCustomMaterials } from '../scene/material-registry';
42
+ import {
43
+ clearAssetCaches,
44
+ loadScene,
45
+ loadSceneFromData,
46
+ type SceneInstance,
47
+ updateSceneLODs,
48
+ } from '../scene/scene-loader';
49
+ import type { SceneFile } from '../scene/scene-types';
50
+ import { hasUserData, setUserData } from '../scene/user-data';
51
+ import { type AudioContext as GameAudio, setupAudio } from '../setup/setup-audio';
52
+ import { setupParticles } from '../setup/setup-particles';
53
+ import { setupPhysics, updatePhysicsDebug } from '../setup/setup-physics';
54
+ import {
55
+ applyRendererSettings,
56
+ applyScenePostProcessing,
57
+ createSceneView,
58
+ } from '../setup/setup-renderer';
59
+ import {
60
+ createAnimationAdapter,
61
+ createAudioSystemAdapter,
62
+ createInputManagerAdapter,
63
+ createNavigationAdapter,
64
+ createVgaiAssetAdapter,
65
+ } from './first-party-systems';
66
+ import type { GameAdapter, MountedGame } from './game-adapter';
67
+ import type { HostContext } from './host-context';
68
+ import { createRapierPhysicsAdapter } from './rapier-physics-adapter';
69
+ import type { SystemAdapters } from './system-adapter';
70
+
71
+ /** How a {@link VgaiSceneGameAdapter} builds its game (the former RuntimeConfig). */
72
+ export interface VgaiSceneConfig {
73
+ /** Game setup function — registers systems and loads scenes. */
74
+ setup?: GameSetupFn | undefined;
75
+ /** Editor context — passed to setup() when launched from the editor. */
76
+ editorPreview?: EditorPreview | undefined;
77
+ /** Path to a .vscn.json scene file. Loaded if no setup is provided. */
78
+ scenePath?: string | undefined;
79
+ /** In-memory scene data. Loaded if no setup and no scenePath is provided. */
80
+ sceneData?: SceneFile | undefined;
81
+ /**
82
+ * Component name -> GameComponent class map, threaded into the scene
83
+ * loader's `SceneLoadContext.componentRegistry` for both the `scenePath`
84
+ * and `sceneData` branches (never for `setup` — a custom setup already
85
+ * owns its own registry, e.g. `template/src/scripts/main.ts`'s generic
86
+ * fallback). Without this, a scene's `components:` entries throw
87
+ * ("Component ... not found in registry") or, pre-T7.2-closure, were
88
+ * simply unreachable through these two config options at all (the T7.2
89
+ * finding `docs/CLI-ON-FOLDER-DESIGN.md` §1D calls out — see
90
+ * `packages/editor/src/adapter-resolver.ts`, the only production caller
91
+ * that sets this today).
92
+ */
93
+ componentRegistry?: ComponentRegistry | undefined;
94
+ /**
95
+ * The project's scene-UI registry (design/24-scene-ui.md D9/D11) —
96
+ * `components`/`transforms`/`strings` only, opaque records engine-side (the
97
+ * engine never imports `@vgai/scene-ui`'s real `ProjectUIRegistry` type;
98
+ * they're React things it never inspects). Threaded per-mount into the
99
+ * services `renderSceneUI` receives, mirroring `componentRegistry` above:
100
+ * the editor's default-three adapter resolver passes its cached project
101
+ * registry here for the editor-play path (D11); a standalone host that
102
+ * already registered a static registry via `registerSceneUIRenderer`
103
+ * typically omits this (registration-time registry is enough there).
104
+ */
105
+ uiRegistry?:
106
+ | {
107
+ components?: Record<string, unknown>;
108
+ transforms?: Record<string, unknown>;
109
+ strings?: Record<string, Record<string, string>>;
110
+ }
111
+ | undefined;
112
+ /** Input map JSON path. Defaults to 'inputmaps/default.inputmap.json'. */
113
+ inputMapPath?: string | undefined;
114
+ /** URL prefix for relative asset paths. Defaults to '/'. */
115
+ assetPrefix?: string | undefined;
116
+ /** Stable adapter id (for registry/conformance). Defaults to 'vgai-scene'. */
117
+ id?: string | undefined;
118
+ }
119
+
120
+ /**
121
+ * The first-party `MountedGame`, with concrete extras the editor host uses for
122
+ * first-party features (HMR, physics sync) that are NOT part of the neutral
123
+ * interface. The generic host only touches the `MountedGame` surface.
124
+ */
125
+ export interface VgaiMountedGame extends MountedGame {
126
+ /**
127
+ * First-party brand (checklist item 1 / T7.3 lookahead): a plain `'ctx' in
128
+ * mounted` structural check misfires once world2d's `MountedGame2D`
129
+ * (`world2d/pixi-game-adapter.ts`) also has a `ctx` key — its `ctx` is a
130
+ * `World2DContext`, not a `GameContext`, and has none of
131
+ * `queryByComponent`/`physics`/`collisions`/`camera`. This literal-`true`
132
+ * property is what `isFirstPartyMounted` (`runtime/game.ts`) actually
133
+ * checks; `game.ts` reads it via a structural `{ firstParty?: unknown }`
134
+ * probe so it never needs a value import of this module (the existing
135
+ * import is type-only and must stay that way).
136
+ */
137
+ readonly firstParty: true;
138
+ readonly ctx: GameContext;
139
+ readonly composer: EffectComposer | null;
140
+ /** Warm-restart: dispose the current game, re-run a new setup on the same ctx. */
141
+ hotReload(newSetup: GameSetupFn, editorPreview?: EditorPreview): Promise<void>;
142
+ /**
143
+ * Phase-partitioned frame entry point (T7.1 slice 2,
144
+ * `docs/GAME-ROOT-DESIGN.md` §4) — wired onto this world's `WorldInstance`
145
+ * by `registerDefaultThreeWorld` (`create-runtime.ts`). `Game.runFrame` is
146
+ * the CANONICAL driver going forward: it calls `frame.runPhase` once per
147
+ * (phase, substep) for every world, then `frame.endFrame` once per
148
+ * substep after all worlds finish all phases. `update` (below) remains
149
+ * the legacy/direct entry point to the exact same `SystemRunner` — direct
150
+ * callers (editor HMR, adapter conformance tests) keep using it unchanged.
151
+ */
152
+ readonly frame: WorldFrameHooks;
153
+ }
154
+
155
+ /** Minimal stand-in for the GPU composer when mounting headlessly. */
156
+ function headlessComposer(renderer: THREE.WebGLRenderer): EffectComposer {
157
+ return {
158
+ render: () => {},
159
+ setSize: () => {},
160
+ dispose: () => {},
161
+ addPass: () => {},
162
+ removeAllPasses: () => {},
163
+ passes: [],
164
+ getRenderer: () => renderer,
165
+ } as unknown as EffectComposer;
166
+ }
167
+
168
+ /**
169
+ * Wrap a first-party `setup` function as a {@link GameAdapter}. This is how every
170
+ * first-party example becomes an implementer of the same interface an external
171
+ * game implements — with no change to the setup's body.
172
+ */
173
+ export function fromSetup(id: string, setup: GameSetupFn): GameAdapter {
174
+ return new VgaiSceneGameAdapter({ id, setup });
175
+ }
176
+
177
+ export class VgaiSceneGameAdapter implements GameAdapter {
178
+ readonly id: string;
179
+ constructor(private readonly config: VgaiSceneConfig = {}) {
180
+ this.id = config.id ?? 'vgai-scene';
181
+ }
182
+
183
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive first-party bootstrap (moved verbatim from create-runtime); splitting it would obscure the ordering contract
184
+ async mount(host: HostContext): Promise<VgaiMountedGame> {
185
+ const {
186
+ setup,
187
+ editorPreview,
188
+ scenePath,
189
+ sceneData,
190
+ componentRegistry,
191
+ uiRegistry,
192
+ inputMapPath = '/inputmaps/default.inputmap.json',
193
+ assetPrefix = '/',
194
+ } = this.config;
195
+ const headless = host.headless === true;
196
+ const { width, height } = host.surface;
197
+ const renderer = host.renderer;
198
+
199
+ setAssetPrefix(assetPrefix);
200
+
201
+ // --- Scene view (scene/camera/composer). Browser: the canonical builder, so
202
+ // behavior is byte-identical to the old create-runtime. Headless: plain
203
+ // scene/camera + a no-op composer (no GPU). ---
204
+ let scene: THREE.Scene;
205
+ let camera: THREE.PerspectiveCamera;
206
+ let composer: EffectComposer;
207
+ if (headless) {
208
+ scene = new THREE.Scene();
209
+ scene.background = new THREE.Color(0x1a1a2e);
210
+ camera = new THREE.PerspectiveCamera(60, width / height || 1, 0.1, 1000);
211
+ camera.position.set(0, 5, 10);
212
+ camera.lookAt(0, 0, 0);
213
+ composer = headlessComposer(renderer);
214
+ } else {
215
+ const view = createSceneView(renderer, width, height);
216
+ scene = view.scene;
217
+ camera = view.camera;
218
+ composer = view.composer;
219
+ }
220
+
221
+ // --- Rapier physics ---
222
+ await RAPIER.init();
223
+ const physics = setupPhysics(RAPIER, scene);
224
+ const physicsRegistry = createPhysicsRegistry();
225
+ const writeTransforms = createTransformWriter(physicsRegistry, scene);
226
+
227
+ // --- Input (InputManager attaches window listeners; stub it headlessly) ---
228
+ const input = headless ? headlessInput() : new InputManager();
229
+ if (!headless) await input.loadMap(inputMapPath);
230
+
231
+ // --- Collision system ---
232
+ const collisions = createCollisionSystem(physics.rapierWorld, physics.eventQueue);
233
+
234
+ // --- Audio (Web Audio — browser only) ---
235
+ const audio = headless ? headlessAudio() : setupAudio(camera);
236
+
237
+ // --- Particles (three.quarks BatchedRenderer) ---
238
+ const particles = setupParticles(scene);
239
+
240
+ // --- UI container (host-provided) ---
241
+ const uiContainer = host.ui as HTMLDivElement;
242
+
243
+ // --- Debug draw / assets / anim graphs ---
244
+ const debugDraw = createDebugDraw(scene);
245
+ const assets = host.assets;
246
+ const animGraphs = new Map<THREE.Object3D, AnimGraph>();
247
+
248
+ // --- System runner (engine-level systems) ---
249
+ const systems = createSystemRunner();
250
+ systems.add('input', () => input.poll());
251
+ systems.add('physics', () => physics.rapierWorld.step(physics.eventQueue));
252
+ systems.add('postPhysics', () => {
253
+ collisions.drain();
254
+ writeTransforms();
255
+ });
256
+ systems.add('animation', (dt) => animationSystem(animGraphs, dt));
257
+ systems.add('preRender', (dt) => {
258
+ particles.batchedRenderer.update(dt);
259
+ tickCustomMaterials(scene, dt);
260
+ updateSceneLODs(scene, camera);
261
+ });
262
+ if (!headless) {
263
+ systems.add('render', (dt) => {
264
+ composer.render(dt);
265
+ if (physics.debugEnabled) updatePhysicsDebug(physics.rapierWorld, physics.debugMesh);
266
+ });
267
+ }
268
+
269
+ // --- Debug toggle (browser only) ---
270
+ const onDebugToggle = (e: KeyboardEvent) => {
271
+ if (e.code === 'KeyP') {
272
+ physics.debugEnabled = !physics.debugEnabled;
273
+ physics.debugMesh.visible = physics.debugEnabled;
274
+ }
275
+ };
276
+ if (!headless) window.addEventListener('keydown', onDebugToggle);
277
+
278
+ const postFrame = () => input.endFrame();
279
+
280
+ // The mounted game's adapter surface (`mounted.systems`). Declared before
281
+ // ctx so `ctx.registerSystemAdapter` can close over it: the engine fills
282
+ // the first-party entries below; GAME-owned capabilities (networking,
283
+ // navigation) are registered by the game's setup through the ctx hook.
284
+ // The editor holds this same object reference (setActiveSystems), so
285
+ // late registrations are visible to its panels at read time.
286
+ const systemAdapters: SystemAdapters = {};
287
+
288
+ // Game-registered scene-UI services (design/24-scene-ui.md D2/D7) — a `let`
289
+ // bag mutated by `ctx.setSceneUIServices`, declared here (BEFORE `ctx` is
290
+ // built) so the closure below has something to capture: a GameComponent
291
+ // calling `ctx.setSceneUIServices` from its `init()` runs DURING
292
+ // `componentManager.initAll()` (scene-loader.ts), which is awaited before
293
+ // `loadScene`/`loadFromData` return below — well before `sceneUIHandle`
294
+ // exists — so early calls just accumulate here and are folded into the
295
+ // FIRST render (`buildSceneUIServices`, below) rather than routed through
296
+ // the late-update path.
297
+ let sceneUIServices: SceneUIGameServices = {};
298
+
299
+ // --- GameContext (the first-party runtime surface) ---
300
+ const ctx: GameContext = {
301
+ scene,
302
+ camera,
303
+ rapierWorld: physics.rapierWorld,
304
+ rapier: RAPIER,
305
+ physics: physicsRegistry,
306
+ input,
307
+ collisions,
308
+ composer,
309
+ audio,
310
+ particles,
311
+ debugDraw,
312
+ animGraphs,
313
+ assets,
314
+ systems,
315
+ components: null!,
316
+ uiContainer,
317
+ registerSystemAdapter: (kind, adapter) => {
318
+ systemAdapters[kind] = adapter;
319
+ },
320
+ setSceneUIServices: (incoming) => {
321
+ sceneUIServices = { ...sceneUIServices, ...incoming };
322
+ // Late call (after the initial mount already resolved a sceneInstance
323
+ // and rendered it): route to the live handle instead of just banking
324
+ // the services for a mount that already happened (D7).
325
+ if (sceneUIHandle && sceneInstance) {
326
+ const merged = buildSceneUIServices(sceneInstance);
327
+ if (sceneUIHandle.update) {
328
+ sceneUIHandle.update(merged);
329
+ } else {
330
+ sceneUIHandle.dispose();
331
+ sceneUIHandle = sceneInstance.ui
332
+ ? renderSceneUI(uiContainer, sceneInstance.ui, merged)
333
+ : null;
334
+ }
335
+ }
336
+ },
337
+ };
338
+ // Game root (T7.1 slice 1): only present once the host has constructed a
339
+ // Game shell (createGameRuntime does this before mount; headless/foreign
340
+ // hosts may omit it — ctx.game/ctx.worlds simply stay undefined, which is
341
+ // the documented zero-break behavior). `ctx.worlds` is assigned the SAME
342
+ // live array `host.game` mutates via `registerWorld` (not a copy) — the
343
+ // default world is registered AFTER mount returns, and this reference
344
+ // must observe that later push.
345
+ if (host.game) {
346
+ ctx.game = host.game;
347
+ ctx.worlds = host.game.worlds;
348
+ }
349
+ ctx.components = createComponentManager(ctx, physicsRegistry);
350
+ collisions.onCollision(
351
+ createTriggerDispatch(physicsRegistry, ctx.components, physics.rapierWorld, ctx),
352
+ );
353
+
354
+ // Everything registered above (engine systems + the ComponentManager's
355
+ // per-phase tick, just wired in) is "engine" and must survive a warm
356
+ // restart. Everything a game (setup()/scenePath/sceneData, below) adds
357
+ // from here on is "game" content that `hotReload` bulk-removes via
358
+ // `systems.removeAllNonEngine()` on every restart (T1.7).
359
+ systems.markEngineBoundary();
360
+
361
+ // First-party System adapters over the real subsystems — seeded HERE,
362
+ // BEFORE setup()/scene load runs below, so that a game which registers any
363
+ // of these kinds during its own setup (via `ctx.registerSystemAdapter`)
364
+ // always wins over the engine's first-party entry (see
365
+ // registerSystemAdapter's docstring in runtime/types.ts). `navigation` is
366
+ // ALSO first-party now (R8 item 7): `createNavigationAdapter` wraps the
367
+ // real `NavMeshManager` a scenePath/sceneData scene load may produce (see
368
+ // `applySceneNavigation` below) — it is wired once that scene instance
369
+ // resolves, not here (no NavMeshManager exists yet at this point in the
370
+ // mount). `networking` remains the one game-owned capability with no
371
+ // first-party implementer; it simply starts absent here.
372
+ systemAdapters.physics = createRapierPhysicsAdapter(physicsRegistry, physics.debugMesh);
373
+ systemAdapters.input = createInputManagerAdapter(input);
374
+ systemAdapters.assets = createVgaiAssetAdapter();
375
+ systemAdapters.animation = createAnimationAdapter(animGraphs);
376
+ // D10/T7.6: the audio seam `Game.play.pause()` silences on pause. Works
377
+ // against the headless stand-in too (`headlessAudio()`'s plain
378
+ // `masterGain.gain` object) — this world's `pause()` mutes it harmlessly.
379
+ systemAdapters.audio = createAudioSystemAdapter(audio);
380
+
381
+ // Snapshot of the engine-owned adapter kinds, taken right after seeding
382
+ // and before any setup() has had a chance to run. `hotReload`/`disposeGame`
383
+ // use this to strip every GAME-registered kind (including an override of a
384
+ // first-party kind) left over from the outgoing game, so a warm restart or
385
+ // a full stop never exposes a disposed game's networking/etc. adapter to
386
+ // the next setup.
387
+ const engineAdapterKinds = new Set(Object.keys(systemAdapters));
388
+ // 'navigation' is engine-owned too (R8 item 7), but the scene load that
389
+ // populates it (below, `applySceneNavigation`) happens AFTER this
390
+ // snapshot — add it explicitly so the generic "strip every non-engine
391
+ // kind" step (hotReload/disposeGame) never mistakes a first-party
392
+ // navigation adapter for game-registered content and deletes it. (The
393
+ // adapter is still kept in sync with the CURRENT scene's NavMeshManager
394
+ // via the dedicated `clear navigation adapter` teardown step below, which
395
+ // runs independently of this snapshot.)
396
+ engineAdapterKinds.add('navigation');
397
+
398
+ // --- Scene / game setup ---
399
+ const engineSceneChildren = new Set(scene.children.slice());
400
+ // Tag engine-owned infra (particle BatchedRenderer, debug-draw + subtrees) so
401
+ // the editor's play-mode hierarchy skips them — they're not game entities.
402
+ for (const c of engineSceneChildren) {
403
+ c.traverse((n) => setUserData(n, 'engineInternal', true));
404
+ }
405
+ let currentCleanup: GameCleanup | null = null;
406
+ let sceneUIHandle: SceneUIHandle | null = null;
407
+ let batchSystem: RenderBatchSystem | null = null;
408
+ // The scenePath/sceneData branch's resolved SceneInstance, if either ran
409
+ // (stays undefined for a `setup`-based mount — see the `sceneUI` deriva-
410
+ // tion below). Single source of truth for both the scene-graph `ui` tree
411
+ // and (potentially, in future) other instance-derived wiring.
412
+ let sceneInstance: SceneInstance | undefined;
413
+
414
+ // Apply scene-level render settings + transparent auto-batching after a scene
415
+ // loads (GPU host only). Gated on the resolved `environment.rendering` cascade
416
+ // (defaults preserve prior behavior when a scene omits `rendering`).
417
+ const applySceneRendering = (inst: SceneInstance): void => {
418
+ if (headless) return;
419
+ const scope = inst.environment?.rendering as RenderScope | undefined;
420
+ const settings = resolveRenderSettings(scope);
421
+ // live renderer settings (shadows, resolution scale)
422
+ applyRendererSettings(renderer, scope);
423
+ // post-processing master switch: (re)build when the scene defines effects OR
424
+ // the master is off (to strip the default bloom pass down to a plain scene).
425
+ if (inst.environment?.postProcessing || !settings['postProcessing']) {
426
+ applyScenePostProcessing(composer, renderer, scene, camera, inst.environment);
427
+ }
428
+ // transparent batching: collapse static/mover meshes sharing a signature
429
+ if (settings['autoBatch']) {
430
+ batchSystem = new RenderBatchSystem(scene, settings);
431
+ batchSystem.build();
432
+ ctx.systems.add('preRender', () => batchSystem?.update());
433
+ if (import.meta.env?.DEV)
434
+ (window as unknown as { __vgaiBatch?: unknown }).__vgaiBatch = batchSystem;
435
+ }
436
+ };
437
+
438
+ // Wire the (first-party) navigation system adapter to whichever
439
+ // NavMeshManager the CURRENT scene instance carries — reassigned on every
440
+ // scene load, and explicitly cleared (not just left stale) when the new
441
+ // scene has none, so `mounted.systems.navigation` never lingers pointing
442
+ // at a manager a later teardown step disposes (R8 item 7).
443
+ const applySceneNavigation = (inst: SceneInstance): void => {
444
+ if (inst.navMesh) {
445
+ systemAdapters.navigation = createNavigationAdapter(inst.navMesh);
446
+ } else {
447
+ delete systemAdapters.navigation;
448
+ }
449
+ };
450
+
451
+ // Scratch vector for the D5 default worldProjector — reused across calls
452
+ // (one per bound world-tracked canvas per render), never allocated per-frame.
453
+ const _sceneUIProjectorTmp = new THREE.Vector3();
454
+
455
+ // The D4 default data source subscribes to game.state for its whole
456
+ // lifetime with no unsubscribe path, so it must be built at most ONCE per
457
+ // adapter mount — buildSceneUIServices runs again on every late
458
+ // setSceneUIServices call, and rebuilding it there would both leak a
459
+ // subscription and force every bound node to resubscribe to a fresh store.
460
+ let defaultSceneUIData: SceneUIDataSourceLike | undefined;
461
+
462
+ /**
463
+ * Assemble the services `renderSceneUI` receives (design/24-scene-ui.md
464
+ * D2): engine defaults (D4 data source, D5 worldProjector, D6 onEvent) <-
465
+ * the project's per-mount UI registry (D11, editor-play threading) <-
466
+ * game-registered services (`ctx.setSceneUIServices`) — per key, each
467
+ * later layer winning over the one before it.
468
+ */
469
+ const buildSceneUIServices = (inst: SceneInstance): SceneUIGameServices => {
470
+ const defaults: SceneUIGameServices = {
471
+ onEvent: (handlerId) => {
472
+ console.warn(
473
+ `scene-ui: unhandled UI event "${handlerId}" — register onEvent via ctx.setSceneUIServices`,
474
+ );
475
+ },
476
+ worldProjector: (id) => {
477
+ const obj = inst.entities.get(id);
478
+ if (!obj) return null;
479
+ const projected = obj.getWorldPosition(_sceneUIProjectorTmp).project(camera);
480
+ if (projected.z > 1) return null; // behind the camera -> hidden
481
+ const w = uiContainer.clientWidth;
482
+ const h = uiContainer.clientHeight;
483
+ // Rounded (not sub-pixel) — matches CrtScreen's integer-rounding
484
+ // (crt-screen.ts), which exists specifically to stop an asymptotic
485
+ // camera lerp from shimmering a tracked canvas every frame.
486
+ return {
487
+ x: Math.round(((projected.x + 1) / 2) * w),
488
+ y: Math.round(((1 - projected.y) / 2) * h),
489
+ };
490
+ },
491
+ };
492
+ // Lazily and at-most-once; skipped entirely while the game supplies its
493
+ // own `data` service (no point subscribing a default nobody reads).
494
+ if (!sceneUIServices.data && ctx.game && componentRegistry) {
495
+ defaultSceneUIData ??= gameStateDataSource(ctx.game, componentRegistry);
496
+ defaults.data = defaultSceneUIData;
497
+ }
498
+ const registryPortion: SceneUIGameServices = {
499
+ ...(uiRegistry?.components ? { components: uiRegistry.components } : {}),
500
+ ...(uiRegistry?.transforms ? { transforms: uiRegistry.transforms } : {}),
501
+ ...(uiRegistry?.strings ? { strings: uiRegistry.strings } : {}),
502
+ };
503
+ return { ...defaults, ...registryPortion, ...sceneUIServices };
504
+ };
505
+
506
+ const loadFromData = async (data: SceneFile): Promise<SceneInstance> => {
507
+ const inst = await loadSceneFromData(data, {
508
+ scene,
509
+ rapierWorld: ctx.rapierWorld,
510
+ rapier: ctx.rapier,
511
+ physics: ctx.physics,
512
+ audioListener: ctx.audio.listener,
513
+ animGraphs: ctx.animGraphs,
514
+ ...(componentRegistry ? { componentRegistry } : {}),
515
+ componentManager: ctx.components,
516
+ particleRenderer: ctx.particles.batchedRenderer,
517
+ ...(headless ? {} : { renderer }),
518
+ });
519
+ applySceneRendering(inst);
520
+ applySceneNavigation(inst);
521
+ ctx.systems.add('postPhysics', (dt) => inst.update(dt));
522
+ return inst;
523
+ };
524
+
525
+ if (setup) {
526
+ if (headless) throw new Error('VgaiSceneGameAdapter: setup-based games require a GPU host');
527
+ currentCleanup = await setup(ctx, editorPreview);
528
+ } else if (scenePath) {
529
+ const inst = await loadScene(scenePath, {
530
+ scene,
531
+ rapierWorld: ctx.rapierWorld,
532
+ rapier: ctx.rapier,
533
+ physics: ctx.physics,
534
+ audioListener: ctx.audio.listener,
535
+ animGraphs: ctx.animGraphs,
536
+ ...(componentRegistry ? { componentRegistry } : {}),
537
+ componentManager: ctx.components,
538
+ particleRenderer: ctx.particles.batchedRenderer,
539
+ ...(headless ? {} : { renderer }),
540
+ });
541
+ applyEditorCamera(editorPreview, camera) || adoptSceneCamera(inst, camera);
542
+ applySceneRendering(inst);
543
+ applySceneNavigation(inst);
544
+ ctx.systems.add('postPhysics', (dt) => inst.update(dt));
545
+ sceneInstance = inst;
546
+ // A pre-baked navmesh (sibling .navmesh file — see scene-loader.ts) holds
547
+ // live recast-navigation WASM handles that leak unless destroyed
548
+ // explicitly; dispose it alongside the scene instance on teardown.
549
+ currentCleanup = {
550
+ dispose: () => {
551
+ inst.navMesh?.dispose(scene);
552
+ inst.dispose();
553
+ },
554
+ };
555
+ } else if (sceneData) {
556
+ const inst = await loadFromData(sceneData);
557
+ applyEditorCamera(editorPreview, camera) || adoptSceneCamera(inst, camera);
558
+ sceneInstance = inst;
559
+ currentCleanup = {
560
+ dispose: () => {
561
+ inst.navMesh?.dispose(scene);
562
+ inst.dispose();
563
+ },
564
+ };
565
+ }
566
+
567
+ // Render scene-graph UI (the `ui` tree) into the overlay via the injected React
568
+ // renderer (B1). Sourced from the loaded `SceneInstance` — the single source of
569
+ // truth for BOTH the scenePath and sceneData branches (R8 item 7 / B: previously
570
+ // only sceneData's raw `SceneFile.ui` was read via a `resolvedSceneData` special-
571
+ // case, so a scenePath mount's `ui` tree was silently dropped even though
572
+ // `loadScene` fully parses it). A `setup`-based mount has no `sceneInstance` at
573
+ // all — its game owns any UI mounting itself. No-op if no UI or no renderer
574
+ // registered. Engine stays React-free.
575
+ const sceneUI = sceneInstance?.ui;
576
+ if (sceneUI && sceneInstance && !headless) {
577
+ sceneUIHandle = renderSceneUI(uiContainer, sceneUI, buildSceneUIServices(sceneInstance));
578
+ }
579
+
580
+ // Idempotency guard for disposeGame — see below. Declared here (not inside
581
+ // disposeGame) so hotReload can also refuse to run on an already-disposed
582
+ // adapter.
583
+ let disposed = false;
584
+
585
+ // Run one teardown step in isolation: a throwing step (e.g. a
586
+ // GameComponent's dispose() misbehaving, or a WASM free() panicking) must
587
+ // not abort the remaining steps — otherwise a single bad component leaks
588
+ // the Rapier world and skips input.dispose() with no way to retry (a
589
+ // second call is a no-op once `disposed` is set). Logs and continues.
590
+ const safeStep = (label: string, fn: () => void): void => {
591
+ try {
592
+ fn();
593
+ } catch (err) {
594
+ console.error(`VgaiSceneGameAdapter: teardown step "${label}" threw (continuing)`, err);
595
+ }
596
+ };
597
+
598
+ const disposeGame = (): void => {
599
+ // A second stop() must be a safe no-op — do NOT double-free the Rapier
600
+ // world/EventQueue (WASM handles panic/throw on a second free) or
601
+ // double-run teardown side effects.
602
+ if (disposed) return;
603
+ disposed = true;
604
+
605
+ safeStep('batchSystem.teardown', () => {
606
+ batchSystem?.teardown(); // re-attach detached sources before scene teardown
607
+ batchSystem = null;
608
+ });
609
+ safeStep('currentCleanup.dispose', () => {
610
+ currentCleanup?.dispose();
611
+ currentCleanup = null;
612
+ });
613
+ // The scene instance's NavMeshManager (if any) was just disposed above
614
+ // (currentCleanup.dispose calls `inst.navMesh?.dispose(scene)`) — clear
615
+ // the wrapper too, or `mounted.systems.navigation` would keep pointing
616
+ // at a freed manager (R8 item 7). Unconditional: harmless if navigation
617
+ // was never set.
618
+ safeStep('clear navigation adapter', () => {
619
+ delete systemAdapters.navigation;
620
+ });
621
+ if (!headless) {
622
+ safeStep('removeEventListener(keydown)', () =>
623
+ window.removeEventListener('keydown', onDebugToggle),
624
+ );
625
+ }
626
+
627
+ // Components BEFORE the physics world is freed/bodies are removed: a
628
+ // GameComponent's dispose() may read `this.rigidBody`/`this.collider`
629
+ // (see game-component.ts) — freeing the world first would make those
630
+ // dangling WASM references. Isolated: a throwing component dispose must
631
+ // not prevent the world/eventQueue frees or input.dispose() below.
632
+ safeStep('components.clear', () => ctx.components.clear());
633
+
634
+ safeStep('remove rigid bodies', () => {
635
+ const handles: number[] = [];
636
+ physics.rapierWorld.forEachRigidBody((b) => handles.push(b.handle));
637
+ for (const h of handles) {
638
+ const b = physics.rapierWorld.getRigidBody(h);
639
+ if (b) physics.rapierWorld.removeRigidBody(b);
640
+ }
641
+ });
642
+ safeStep('rapierWorld.free', () => physics.rapierWorld.free());
643
+ safeStep('eventQueue.free', () => physics.eventQueue.free()); // WASM handle — must be freed manually
644
+ safeStep('physicsRegistry.clear', () => physicsRegistry.clear());
645
+
646
+ safeStep('scene children dispose', () => {
647
+ for (const obj of [...scene.children]) {
648
+ scene.remove(obj);
649
+ obj.traverse((node) => {
650
+ if (node instanceof THREE.Mesh) {
651
+ if (!hasUserData(node, '__sharedGeometry')) node.geometry?.dispose();
652
+ if (Array.isArray(node.material))
653
+ node.material.forEach((m) => {
654
+ m.dispose();
655
+ });
656
+ else node.material?.dispose();
657
+ }
658
+ });
659
+ }
660
+ scene.fog = null;
661
+ scene.background = null;
662
+ scene.environment = null;
663
+ });
664
+ safeStep('clearAssetCaches', () => clearAssetCaches());
665
+ safeStep('debugDraw.clear', () => debugDraw.clear());
666
+ safeStep('animGraphs.clear', () => animGraphs.clear());
667
+ safeStep('sceneUIHandle.dispose', () => {
668
+ sceneUIHandle?.dispose();
669
+ sceneUIHandle = null;
670
+ });
671
+ safeStep('uiContainer reset', () => {
672
+ uiContainer.innerHTML = '';
673
+ });
674
+
675
+ // R8 item 7 (C) — disposeGame (Stop) used to skip both of these, unlike
676
+ // hotReload (warm restart), which already ran them: a game lifecycle
677
+ // system registered via `ctx.systems.register(...)` never had its
678
+ // `dispose()` hook called on a full Stop, and a game-registered
679
+ // systemAdapters kind (e.g. a `setup`-registered 'networking') survived
680
+ // a Stop and would be visible on a STALE `mounted.systems` even though
681
+ // the game that owned it is gone. Same two steps, same relative
682
+ // ordering as hotReload (right after `uiContainer reset`, before the
683
+ // engine-owned audio/input teardown below, which hotReload doesn't
684
+ // touch at all since it warm-restarts rather than tearing engine
685
+ // subsystems down).
686
+ safeStep('systems.removeAllNonEngine', () => systems.removeAllNonEngine());
687
+ safeStep('strip game-registered systemAdapters', () => {
688
+ for (const kind of Object.keys(systemAdapters)) {
689
+ if (!engineAdapterKinds.has(kind)) delete systemAdapters[kind as keyof SystemAdapters];
690
+ }
691
+ });
692
+
693
+ if (!headless) {
694
+ safeStep('audio teardown', () => {
695
+ audio.masterGain.gain.value = 0;
696
+ audio.listener.context.suspend();
697
+ camera.remove(audio.listener);
698
+ composer.dispose();
699
+ });
700
+ }
701
+ safeStep('input.dispose', () => input.dispose());
702
+ };
703
+
704
+ const hotReload = async (
705
+ newSetup: GameSetupFn,
706
+ newEditorPreview?: EditorPreview,
707
+ ): Promise<void> => {
708
+ if (disposed) {
709
+ throw new Error('VgaiSceneGameAdapter: cannot hotReload after dispose()/stop()');
710
+ }
711
+
712
+ // Full parity with disposeGame for the parts a warm restart rebuilds:
713
+ // batch-render teardown, game cleanup, component batch teardown (in that
714
+ // ordering, BEFORE bodies are pulled from the world — see disposeGame),
715
+ // scene-UI dispose (React root unmount), and bulk system removal. Each
716
+ // step isolated the same way as disposeGame — one throwing step (e.g. a
717
+ // component dispose) must not abort the rest of the warm restart.
718
+ safeStep('batchSystem.teardown', () => {
719
+ batchSystem?.teardown(); // re-attach detached sources before scene teardown
720
+ batchSystem = null;
721
+ });
722
+ safeStep('currentCleanup.dispose', () => {
723
+ currentCleanup?.dispose();
724
+ currentCleanup = null;
725
+ });
726
+ // Same reasoning as disposeGame: the outgoing scene instance's
727
+ // NavMeshManager was just disposed above, and `hotReload`'s `newSetup`
728
+ // (below) never auto-wires a replacement (that only happens for THIS
729
+ // adapter's own scenePath/sceneData mount, once, at initial mount) — so
730
+ // clear the stale wrapper here. A `newSetup` that wants navigation may
731
+ // register its own via `ctx.registerSystemAdapter('navigation', ...)`.
732
+ safeStep('clear navigation adapter', () => {
733
+ delete systemAdapters.navigation;
734
+ });
735
+
736
+ safeStep('components.clear', () => ctx.components.clear());
737
+
738
+ safeStep('remove rigid bodies', () => {
739
+ const handles: number[] = [];
740
+ physics.rapierWorld.forEachRigidBody((b) => handles.push(b.handle));
741
+ for (const h of handles) {
742
+ const b = physics.rapierWorld.getRigidBody(h);
743
+ if (b) physics.rapierWorld.removeRigidBody(b);
744
+ }
745
+ physicsRegistry.clear();
746
+ });
747
+ safeStep('scene children dispose', () => {
748
+ for (const obj of [...scene.children]) {
749
+ if (engineSceneChildren.has(obj)) continue;
750
+ scene.remove(obj);
751
+ obj.traverse((node) => {
752
+ if (node instanceof THREE.Mesh) {
753
+ if (!hasUserData(node, '__sharedGeometry')) node.geometry?.dispose();
754
+ if (Array.isArray(node.material))
755
+ node.material.forEach((m) => {
756
+ m.dispose();
757
+ });
758
+ else node.material?.dispose();
759
+ }
760
+ });
761
+ }
762
+ scene.fog = null;
763
+ scene.background = null;
764
+ scene.environment = null;
765
+ });
766
+ safeStep('debugDraw.clear', () => debugDraw.clear());
767
+ safeStep('animGraphs.clear', () => animGraphs.clear());
768
+ safeStep('sceneUIHandle.dispose', () => {
769
+ sceneUIHandle?.dispose();
770
+ sceneUIHandle = null;
771
+ });
772
+ // Strip game-registered scene-UI services back to none before the
773
+ // incoming game's setup/init runs (D7) — same contract as the
774
+ // systemAdapters strip below: a disposed game's onEvent/data/
775
+ // worldProjector must never leak onto the next mount.
776
+ safeStep('reset sceneUIServices', () => {
777
+ sceneUIServices = {};
778
+ });
779
+ safeStep('uiContainer reset', () => {
780
+ uiContainer.innerHTML = '';
781
+ });
782
+
783
+ // Bulk-remove every system the outgoing game registered (this restart's
784
+ // predecessor) so warm restarts never accumulate duplicate systems —
785
+ // engine systems registered before mount()'s boundary mark are untouched.
786
+ safeStep('systems.removeAllNonEngine', () => systems.removeAllNonEngine());
787
+
788
+ // Strip every GAME-registered systemAdapters kind left by the outgoing
789
+ // game (including any override of a first-party kind) so the disposed
790
+ // game's networking/etc. adapter is never exposed to the next setup —
791
+ // see registerSystemAdapter's docstring in runtime/types.ts. ('navigation'
792
+ // is engine-kind and is NOT touched here — it's a first-party adapter now,
793
+ // kept in sync with the current scene's NavMeshManager by the dedicated
794
+ // `clear navigation adapter` step above instead.) The new setup below
795
+ // either re-registers a kind or it stays absent.
796
+ safeStep('strip game-registered systemAdapters', () => {
797
+ for (const kind of Object.keys(systemAdapters)) {
798
+ if (!engineAdapterKinds.has(kind)) delete systemAdapters[kind as keyof SystemAdapters];
799
+ }
800
+ });
801
+
802
+ currentCleanup = await newSetup(ctx, newEditorPreview);
803
+ };
804
+
805
+ return {
806
+ kind: 'threejs',
807
+ scene,
808
+ camera,
809
+ firstParty: true,
810
+ drivesOwnLoop: false,
811
+ update: (dt: number) => {
812
+ systems.run(dt);
813
+ postFrame();
814
+ },
815
+ resize: (w: number, h: number) => {
816
+ composer.setSize(w, h);
817
+ camera.aspect = w / h;
818
+ camera.updateProjectionMatrix();
819
+ },
820
+ dispose: disposeGame,
821
+ systems: systemAdapters,
822
+ ctx,
823
+ composer: headless ? null : composer,
824
+ hotReload,
825
+ frame: {
826
+ runPhase: (phase, dt) => systems.runPhase(phase, dt),
827
+ endFrame: postFrame,
828
+ },
829
+ };
830
+ }
831
+ }
832
+
833
+ // --- Camera precedence helpers (moved from create-runtime, unchanged) ---
834
+
835
+ function applyEditorCamera(
836
+ editor: EditorPreview | undefined,
837
+ runtimeCamera: THREE.PerspectiveCamera,
838
+ ): boolean {
839
+ if (!editor?.viewportCamera) return false;
840
+ runtimeCamera.position.fromArray(editor.viewportCamera.position);
841
+ runtimeCamera.quaternion.fromArray(editor.viewportCamera.quaternion);
842
+ return true;
843
+ }
844
+
845
+ export function adoptSceneCamera(
846
+ sceneInstance: SceneInstance,
847
+ runtimeCamera: THREE.PerspectiveCamera,
848
+ ): boolean {
849
+ const sceneCam = sceneInstance.cameras[0];
850
+ if (!sceneCam) return false;
851
+ const parent = sceneCam.parent;
852
+ if (parent) {
853
+ parent.updateMatrixWorld(true);
854
+ runtimeCamera.position.copy(parent.getWorldPosition(new THREE.Vector3()));
855
+ runtimeCamera.quaternion.copy(parent.getWorldQuaternion(new THREE.Quaternion()));
856
+ }
857
+ if (sceneCam instanceof THREE.PerspectiveCamera) {
858
+ runtimeCamera.fov = sceneCam.fov;
859
+ runtimeCamera.near = sceneCam.near;
860
+ runtimeCamera.far = sceneCam.far;
861
+ runtimeCamera.updateProjectionMatrix();
862
+ }
863
+ return true;
864
+ }
865
+
866
+ /** Headless input stand-in — `InputManager` attaches `window` listeners in its
867
+ * constructor, which Node lacks. The scene-data path never reads input. */
868
+ function headlessInput(): InputManager {
869
+ return {
870
+ poll: () => {},
871
+ endFrame: () => {},
872
+ dispose: () => {},
873
+ loadMap: async () => {},
874
+ isPressed: () => false,
875
+ getAxis: () => 0,
876
+ } as unknown as InputManager;
877
+ }
878
+
879
+ /** Headless audio stand-in (no Web Audio). Only the fields the loader/cleanup
880
+ * touch are present; setup-based games never mount headlessly. */
881
+ function headlessAudio(): GameAudio {
882
+ return {
883
+ listener: undefined as unknown as THREE.AudioListener,
884
+ masterGain: { gain: { value: 1 } },
885
+ } as unknown as GameAudio;
886
+ }