@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,1003 @@
1
+ /**
2
+ * Game root — T7.1 slice 1 ("introduce Game internally") + slice 2 ("world
3
+ * registry + ordered frame algorithm"), extended in T7.3 slice 1 with real
4
+ * `'pixijs'`-kind `WorldInstance` support (`stage`/`physics2d`/`collisions2d`,
5
+ * a real `pixiRoot()`) so `world2d/pixi-game-adapter.ts` can register its
6
+ * world onto a real `Game` instead of driving its own loop, and in T7.3
7
+ * slice 2 with a construction-time stage check (symmetric with the
8
+ * threejs/scene check) and the two-world (threejs + pixijs) proof
9
+ * (`test/game-three-plus-pixi.test.ts`). T6.2 slice 1 adds real `'react'`-kind
10
+ * `WorldInstance` support (`container`, a real `reactRoot()`) — the DOM-root
11
+ * world surface `runtime/create-runtime.ts`'s worlds path now mounts (see
12
+ * `docs/REACT-WORLD-DESIGN.md` §1.B); react worlds host no `ComponentManager`
13
+ * (D8 — components attaching to a react-kind manager already throw,
14
+ * `ecs/component-manager.ts`), so `physics`/`collisions`/`camera`/`frame`
15
+ * stay `undefined` for them exactly like an opaque/foreign mount.
16
+ *
17
+ * See `docs/GAME-ROOT-DESIGN.md` (D6), particularly §4 (the frame algorithm)
18
+ * and §8 stage 2, for the full target shape. This file holds the Game/
19
+ * WorldInstance shell, the NEW game-scoped `SystemRunner`, and the
20
+ * host-facing `runFrame` frame executor. The public surface is NOT frozen
21
+ * yet — do not add these exports to a barrel/index; only the in-scope
22
+ * runtime/adapter files import this module directly. The `gpu` per-world
23
+ * cache is the one remaining slice-1 omission.
24
+ */
25
+
26
+ // TYPE-ONLY pixi import (T7.3 slice 1): `game.ts` must never value-import
27
+ // `pixi.js` — a value import would pull the Pixi runtime into 3D-only
28
+ // bundles that never touch world2d. Every pixi-typed field below is erased
29
+ // at compile time; nothing here constructs or calls into Pixi.
30
+ import type * as PIXI from 'pixi.js';
31
+ import type * as THREE from 'three';
32
+ import type { MountedWorld } from '../adapter/game-adapter';
33
+ import { formatAudioGateMessage, formatLoopGateMessage } from '../adapter/loop-gate-report';
34
+ import type { SystemAdapters } from '../adapter/system-adapter';
35
+ import type { VgaiMountedGame } from '../adapter/vgai-scene-game-adapter';
36
+ import type { WorldKind as WorldKindLeaf } from '../adapter/world-kind';
37
+ import type { AssetCache } from '../assets';
38
+ import type { createGameLoop } from '../core/game-loop';
39
+ import { createSystemRunner, type SystemRunner } from '../core/system-runner';
40
+ import { PHASE_ORDER, SystemPhase, type SystemPhaseName } from '../core/types';
41
+ import type { ComponentManager } from '../ecs/component-manager';
42
+ import type { GameComponent } from '../ecs/game-component';
43
+ import type { InputManager } from '../input/input-manager';
44
+ import type { CollisionSystem } from '../physics/collision-system';
45
+ import type { PhysicsRegistry } from '../physics/physics-registry';
46
+ import type { AudioContext as GameAudio } from '../setup/setup-audio';
47
+ // TYPE-ONLY (same rule as the pixi import above): these live under
48
+ // `world2d/`, but `game.ts` only ever names their TYPES.
49
+ import type { Collision2DSystem } from '../world2d/collision-2d';
50
+ import type { Physics2DRegistry } from '../world2d/physics2d-registry';
51
+ import { createStateBridge, type GameStateBridge } from './state-bridge';
52
+
53
+ /** The one loop type — `createGameLoop`'s return shape (D1, fixed-step). */
54
+ export type GameLoop = ReturnType<typeof createGameLoop>;
55
+
56
+ /**
57
+ * Game-level play-state control surface (D10, T7.6 — `docs/DECISIONS-PENDING.md`
58
+ * §D10): pause/resume/step as ONE control surface on the running game, with
59
+ * PER-WORLD `pausable` semantics (`WorldInstance.pausable`) — a menu/HUD world
60
+ * declaring `pausable: false` keeps ticking (input, components, physics) while
61
+ * every `pausable: true` world freezes. Interacts with D1 (deliberate
62
+ * fixed-rate rendering): a paused-and-pausable world's `render` phase still
63
+ * runs every substep (with `dt` forced to `0`, so time-based render effects —
64
+ * e.g. a post-processing pass with its own internal clock — don't silently
65
+ * keep animating under a "frozen" scene) — simulation freezes, the screen does
66
+ * not go black. `GameLoop.timeScale` (unaffected by this) remains the
67
+ * orthogonal "speed up/slow down" axis — pausing never touches it, so the
68
+ * host's accumulator/rAF loop keeps ticking at its normal cadence and keeps
69
+ * calling `runFrame`, which is what makes "paused still renders" possible
70
+ * (the previous `GameSession.pause()` implementation set `loop.timeScale = 0`,
71
+ * which — per D1 — starves the accumulator and stops rendering too; that was
72
+ * the bug this control surface replaces, not a compatible behavior to keep).
73
+ */
74
+ export interface PlayState {
75
+ /** Whether the game is currently paused (game-level — see the per-world
76
+ * `pausable` caveat above: a `pausable: false` world ignores this). */
77
+ readonly paused: boolean;
78
+ /**
79
+ * Freeze every `pausable` world's simulation (idempotent — a second call
80
+ * while already paused is a no-op) and, for every `pausable`, self-driven
81
+ * (`drivesOwnLoop`) world, invoke its loop-gate capability
82
+ * (`mounted.setPaused(true)`) — reporting loudly, once per world, when that
83
+ * capability is absent (an honest "cannot gate" instead of a silent no-op,
84
+ * `docs/CAPABILITY-TIERS.md` §d). Also silences every `pausable` world's
85
+ * audio via its `SystemAdapters.audio` (absent ⇒ the same loud, once-only
86
+ * report).
87
+ */
88
+ pause(): void;
89
+ /** Resume every `pausable` world — the inverse of `pause()`, same
90
+ * idempotency and loop-gate/audio fan-out (no re-reporting; a world that
91
+ * couldn't be gated on pause simply never was, so resume has nothing to
92
+ * undo for it). */
93
+ resume(): void;
94
+ /**
95
+ * Advance exactly the currently-FROZEN worlds by one fixed substep: the
96
+ * `pausable`, host-driven (`!drivesOwnLoop`) worlds while `paused` is
97
+ * true. While NOT paused this is a whole-call no-op — under D10 the loop
98
+ * never stops, so every non-frozen world is already being ticked; an
99
+ * unconditional extra tick was the §7.1-2 double-tick bug (probe4). A
100
+ * `pausable`, self-driven world that was actually gated by `pause()`
101
+ * (adapter has `setPaused`) steps via its own loop-gate `step()`
102
+ * capability instead (absent ⇒ the same loud report as `pause()`); a
103
+ * self-driven world the gate couldn't reach is still running and is left
104
+ * alone. `dt` defaults to the engine's fixed timestep (1/60s — every real
105
+ * host constructs its loop with this value; see `create-runtime.ts`).
106
+ */
107
+ step(dt?: number): void;
108
+ }
109
+
110
+ /**
111
+ * The kinds of render surface a world can be. `'threejs'` and `'pixijs'`
112
+ * worlds are real as of T7.3 slice 1 (`create-runtime.ts`'s default threejs
113
+ * world; `world2d/pixi-game-adapter.ts`'s pixi world); `'react'` is named
114
+ * here so the type is already shaped for T7.4 and no caller has to widen a
115
+ * union later.
116
+ *
117
+ * Re-exported from `adapter/world-kind.ts` (T7.5) — moved there so
118
+ * `adapter/game-adapter.ts`'s kind-tagged `MountedWorld` types can name it
119
+ * without an import cycle back to this file. This re-export keeps every
120
+ * existing `import type { WorldKind } from '../runtime/game'` call site
121
+ * (`ecs/game-component.ts`, `ecs/component-manager.ts`) compiling unchanged.
122
+ */
123
+ export type WorldKind = WorldKindLeaf;
124
+
125
+ /**
126
+ * A world's per-phase frame hooks (GAME-ROOT-DESIGN §4, T7.1 slice 2).
127
+ * Populated on a `WorldInstance` only for first-party mounts — an opaque/
128
+ * foreign mount has no phase-partitioned entry point, so it stays
129
+ * `undefined` and the Game's frame executor (`GameInternal.runFrame`) falls
130
+ * back to calling its single `mounted.update(dt)` once per substep instead.
131
+ */
132
+ export interface WorldFrameHooks {
133
+ /** Run this world's engine systems + component ticks + world-bound game
134
+ * systems for one phase. For a first-party world this delegates to the
135
+ * SAME `SystemRunner.runPhase` its (legacy) `mounted.update` uses. */
136
+ runPhase(phase: SystemPhaseName, dt: number): void;
137
+ /** Run once per substep, after ALL phases have run for ALL worlds this
138
+ * substep (mirrors where `mounted.update`'s post-`systems.run` work sat
139
+ * today — e.g. `input.endFrame()`). Optional: a world may have nothing
140
+ * to do here. */
141
+ endFrame?(): void;
142
+ }
143
+
144
+ /**
145
+ * A single world instance: the unit of adaptation (GAME-ROOT-DESIGN §3).
146
+ * Slice-1 subset — `gpu` (per-world GPU resource cache, §6.2) is omitted
147
+ * until the slice that builds it.
148
+ */
149
+ export interface WorldInstance {
150
+ /** Manifest id (T3.1). Slice 1 always registers exactly one: `'main'`. */
151
+ readonly id: string;
152
+ readonly kind: WorldKind;
153
+ /** Per-world play/pause semantics (D10, T7.6). Slice 1 always `true`. */
154
+ readonly pausable: boolean;
155
+ /** The GameAdapter that produced `mounted` — first-party or external.
156
+ * Deliberately narrower than `GameAdapter<K>` (T7.5): this field is only
157
+ * ever read for `.id` (`Game.registerWorld`'s diagnostic message below) —
158
+ * never re-invoked — and `GameAdapter<K>`'s `mount` signature legitimately
159
+ * differs per kind's host type (a pixijs adapter's `World2DHost`, a react
160
+ * adapter's `ReactWorldHost`, vs a threejs adapter's `HostContext`), so
161
+ * requiring the FULL interface here would force a cast at every non-
162
+ * threejs registration site for no behavioral gain — see `AdapterHandle`
163
+ * below. */
164
+ readonly adapter: AdapterHandle;
165
+ /** The live mounted surface — one of `MountedWorld`'s kind-tagged shapes
166
+ * (T7.5; `MountedThreeWorld` for a threejs world, `MountedPixiWorld` for
167
+ * pixijs, `MountedReactWorld` for react). */
168
+ readonly mounted: MountedWorld;
169
+ /** Kind-narrowed accessor: throws a descriptive error when this world is
170
+ * not a threejs world. */
171
+ threeRoot(): THREE.Scene;
172
+ /** Kind-narrowed accessor for pixijs worlds (T7.3): returns the stage
173
+ * passed to `createWorldInstance` for a `'pixijs'`-kind world. Throws
174
+ * descriptively for a non-pixijs world, or a pixijs world built without
175
+ * a `stage` (see `WorldInstanceInit.stage`). */
176
+ pixiRoot(): PIXI.Container;
177
+ /** Kind-narrowed accessor for react worlds (T6.2 slice 1,
178
+ * `docs/REACT-WORLD-DESIGN.md` §1.B): returns the DOM-root layer
179
+ * `<div>` the host mounted this world's react tree into (the SAME
180
+ * element passed as `container` to `createWorldInstance` — identity
181
+ * matters, mirroring `threeRoot()`/`pixiRoot()`'s "same instance the
182
+ * adapter mounted" contract). Throws descriptively for a non-react
183
+ * world, or a react world built without a `container` (see
184
+ * `WorldInstanceInit.container`). */
185
+ reactRoot(): HTMLElement;
186
+ /** Present only when the world's mount is first-party (Rapier3D for
187
+ * threejs worlds). */
188
+ readonly physics?: PhysicsRegistry | undefined;
189
+ readonly collisions?: CollisionSystem | undefined;
190
+ /** Present only when the world's mount is a first-party pixijs world
191
+ * (Rapier2D, T7.3) — the 2D analog of `physics`/`collisions` above. Kept
192
+ * as separate fields (not folded into `physics`/`collisions` as a union)
193
+ * so 3D call sites keep their non-union `PhysicsRegistry`/`CollisionSystem`
194
+ * typing unchanged. */
195
+ readonly physics2d?: Physics2DRegistry | undefined;
196
+ readonly collisions2d?: Collision2DSystem | undefined;
197
+ /** Kind-typed via `mounted` in T7.5; `unknown` here deliberately. */
198
+ readonly camera?: unknown;
199
+ /** Phase-partitioned frame entry point (T7.1 slice 2) — present only for
200
+ * first-party mounts. `undefined` for an opaque/foreign mount, which
201
+ * `GameInternal.runFrame` drives via its single `mounted.update` call
202
+ * instead (unless it `drivesOwnLoop`, in which case it isn't ticked at
203
+ * all — see `runFrame`). */
204
+ readonly frame?: WorldFrameHooks | undefined;
205
+ }
206
+
207
+ /**
208
+ * Minimal identity surface `WorldInstance.adapter` needs (T7.5) — see that
209
+ * field's doc comment for why it's narrower than `GameAdapter<K>`. Any real
210
+ * adapter object (a NAMED type, not a fresh object literal) — `GameAdapter<K>`,
211
+ * `Pixi2DGameAdapter`, `ReactWorldAdapter`, or a project's own custom adapter —
212
+ * satisfies this trivially (extra members beyond `id` are always fine for a
213
+ * non-literal source); a bare `{ id, mount }` object literal built INLINE at
214
+ * a `createWorldInstance`/`registerXWorld` call site needs an intermediate
215
+ * `const` (excess-property checking only special-cases fresh literals).
216
+ */
217
+ export interface AdapterHandle {
218
+ readonly id: string;
219
+ }
220
+
221
+ /** Inputs to {@link createWorldInstance}. */
222
+ export interface WorldInstanceInit {
223
+ readonly id: string;
224
+ readonly kind: WorldKind;
225
+ /** Defaults to `true` (D10's per-world default). */
226
+ readonly pausable?: boolean;
227
+ readonly adapter: AdapterHandle;
228
+ readonly mounted: MountedWorld;
229
+ /** Required when `kind === 'threejs'` — backs `threeRoot()`. */
230
+ readonly scene?: THREE.Scene | undefined;
231
+ /** Required when `kind === 'pixijs'` — backs `pixiRoot()` (T7.3 slice 2,
232
+ * symmetric with `scene` for threejs above). */
233
+ readonly stage?: PIXI.Container | undefined;
234
+ /** Required when `kind === 'react'` — backs `reactRoot()` (T6.2 slice 1,
235
+ * symmetric with `scene`/`stage` above): the DOM-root layer `<div>` this
236
+ * world's react tree is mounted into. */
237
+ readonly container?: HTMLElement | undefined;
238
+ readonly physics?: PhysicsRegistry | undefined;
239
+ readonly collisions?: CollisionSystem | undefined;
240
+ readonly physics2d?: Physics2DRegistry | undefined;
241
+ readonly collisions2d?: Collision2DSystem | undefined;
242
+ readonly camera?: unknown;
243
+ readonly frame?: WorldFrameHooks | undefined;
244
+ }
245
+
246
+ /**
247
+ * Build a `WorldInstance` whose kind-narrowed accessors throw descriptively
248
+ * on kind mismatch (GAME-ROOT-DESIGN §3). This is the one place that builds
249
+ * `threeRoot`/`pixiRoot`, so every world (however it's constructed, in this
250
+ * slice or later ones) gets identical throw behavior.
251
+ */
252
+ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
253
+ const {
254
+ id,
255
+ kind,
256
+ pausable = true,
257
+ adapter,
258
+ mounted,
259
+ scene,
260
+ stage,
261
+ container,
262
+ physics,
263
+ collisions,
264
+ physics2d,
265
+ collisions2d,
266
+ camera,
267
+ frame,
268
+ } = init;
269
+
270
+ // A threejs world with no scene has nothing for `threeRoot()` to return —
271
+ // fail loudly HERE, at construction, rather than letting `threeRoot()`
272
+ // throw its generic "not a threejs world" message later for a world whose
273
+ // kind IS threejs (checklist item 5; a misleading error for this case).
274
+ if (kind === 'threejs' && !scene) {
275
+ throw new Error(
276
+ `WorldInstance "${id}" (kind: threejs): a threejs world requires a scene — ` +
277
+ 'pass `scene` in WorldInstanceInit.',
278
+ );
279
+ }
280
+
281
+ // Symmetric check for pixijs (T7.3 slice 2) — a pixijs world with no stage
282
+ // has nothing for `pixiRoot()` to return; fail loudly here, at
283
+ // construction, same as the threejs/scene check above.
284
+ if (kind === 'pixijs' && !stage) {
285
+ throw new Error(
286
+ `WorldInstance "${id}" (kind: pixijs): a pixijs world requires a stage — ` +
287
+ 'pass `stage` in WorldInstanceInit.',
288
+ );
289
+ }
290
+
291
+ // Symmetric check for react (T6.2 slice 1, docs/REACT-WORLD-DESIGN.md
292
+ // §1.B) — a react world with no container has nothing for `reactRoot()`
293
+ // to return; fail loudly here, at construction, same as the two checks
294
+ // above.
295
+ if (kind === 'react' && !container) {
296
+ throw new Error(
297
+ `WorldInstance "${id}" (kind: react): a react world requires a container — ` +
298
+ 'pass `container` in WorldInstanceInit.',
299
+ );
300
+ }
301
+
302
+ // Disposed-world guard (GAME-ROOT-DESIGN §8 stage 3 / T7.1 slice 3). There
303
+ // is no `unregisterWorld` (decision 4 — worlds are manifest-declared; a
304
+ // whole Game is disposed, not one world out of its registry), so a caller
305
+ // that disposes ONE world's `mounted` directly (e.g. ending a sub-session)
306
+ // leaves that `WorldInstance` sitting in `Game.worlds` — and `runFrame`
307
+ // would otherwise keep invoking its (now-torn-down) frame hooks every
308
+ // subsequent frame. `MountedGame` has no public "am I disposed" flag to
309
+ // read, so this wraps `mounted.dispose` in place (mutating the SAME mount
310
+ // object every holder of `mounted` shares — calling `mounted.dispose()`
311
+ // directly, exactly like calling `world.mounted.dispose()`, trips this)
312
+ // to flip a private flag, and wraps the frame hooks so they silently no-op
313
+ // once that flag is set. `runFrame` itself needs no knowledge of disposal
314
+ // — a disposed world is skipped by construction from the next call
315
+ // onward. (The opaque-world `mounted.update` fallback path is unaffected
316
+ // by this guard — no opaque-mount two-world scenario exists yet to need it.)
317
+ let disposed = false;
318
+ const originalDispose = mounted.dispose.bind(mounted);
319
+ mounted.dispose = () => {
320
+ disposed = true;
321
+ originalDispose();
322
+ };
323
+ const guardedFrame: WorldFrameHooks | undefined = frame
324
+ ? {
325
+ runPhase(phase, dt) {
326
+ if (disposed) return;
327
+ frame.runPhase(phase, dt);
328
+ },
329
+ endFrame() {
330
+ if (disposed) return;
331
+ frame.endFrame?.();
332
+ },
333
+ }
334
+ : undefined;
335
+
336
+ return {
337
+ id,
338
+ kind,
339
+ pausable,
340
+ adapter,
341
+ mounted,
342
+ physics,
343
+ collisions,
344
+ physics2d,
345
+ collisions2d,
346
+ camera,
347
+ frame: guardedFrame,
348
+ threeRoot(): THREE.Scene {
349
+ if (kind !== 'threejs' || !scene) {
350
+ throw new Error(
351
+ `WorldInstance "${id}" (kind: ${kind}): threeRoot() requested but this world is not ` +
352
+ 'a threejs world',
353
+ );
354
+ }
355
+ return scene;
356
+ },
357
+ pixiRoot(): PIXI.Container {
358
+ // The `!stage` branch a pixijs world could hit here is now unreachable
359
+ // (T7.3 slice 2): construction above throws for `kind === 'pixijs'`
360
+ // with no `stage`, symmetric with `threeRoot()`'s `scene` guard.
361
+ if (kind !== 'pixijs' || !stage) {
362
+ throw new Error(
363
+ `WorldInstance "${id}" (kind: ${kind}): pixiRoot() requested but this world is not ` +
364
+ 'a pixijs world',
365
+ );
366
+ }
367
+ return stage;
368
+ },
369
+ reactRoot(): HTMLElement {
370
+ // The `!container` branch a react world could hit here is now
371
+ // unreachable (T6.2 slice 1): construction above throws for
372
+ // `kind === 'react'` with no `container`, symmetric with
373
+ // `threeRoot()`/`pixiRoot()`'s guards.
374
+ if (kind !== 'react' || !container) {
375
+ throw new Error(
376
+ `WorldInstance "${id}" (kind: ${kind}): reactRoot() requested but this world is not ` +
377
+ 'a react world',
378
+ );
379
+ }
380
+ return container;
381
+ },
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Type guard for whether a `MountedGame` is a first-party
387
+ * `VgaiSceneGameAdapter` mount (has a live `GameContext` at `.ctx`). Used to
388
+ * decide whether a world's `physics`/`collisions`/`camera` (and, on `Game`,
389
+ * `components`/`input`/`audio`) can be populated from it — an external
390
+ * adapter's mount has none of these first-party handles.
391
+ *
392
+ * Checks the `firstParty: true` brand (checklist item 1), NOT `'ctx' in
393
+ * mounted` — world2d's `MountedGame2D` (`world2d/pixi-game-adapter.ts`) also
394
+ * has a `ctx` key (a `World2DContext`, unrelated to `GameContext`), so a
395
+ * structural `'ctx' in mounted` check would misfire once T7.3 registers 2D
396
+ * worlds onto the same `Game`. The probe is a plain property read, not an
397
+ * `instanceof`/value import of `vgai-scene-game-adapter.ts` — the
398
+ * `VgaiMountedGame` import above stays type-only.
399
+ */
400
+ export function isFirstPartyMounted(mounted: MountedWorld): mounted is VgaiMountedGame {
401
+ return (mounted as { firstParty?: unknown }).firstParty === true;
402
+ }
403
+
404
+ /**
405
+ * The Game root (GAME-ROOT-DESIGN §3). Owns the one loop, the raw-asset
406
+ * cache, the world registry, and (T7.1 slice 2) the game-scoped
407
+ * `SystemRunner`. `components`/`input`/`audio` remain slice-1 late
408
+ * additions: they delegate to the default world's first-party mount so
409
+ * existing single-world call sites keep working; hoisting them to true
410
+ * Game ownership (GAME-ROOT-DESIGN §3's target shape) is a later slice's
411
+ * work, not this one's.
412
+ */
413
+ export interface Game {
414
+ readonly loop: GameLoop;
415
+ readonly assets: AssetCache;
416
+ /**
417
+ * The game-scoped `SystemRunner` (GAME-ROOT-DESIGN §4, T7.1 slice 2) — a
418
+ * NEW bucket, separate from any world's own runner. Within each phase,
419
+ * `GameInternal.runFrame` runs THIS runner's `runPhase` first, before any
420
+ * world's engine systems/component ticks/world-bound game systems (e.g.
421
+ * `ctx.systems.add`, which stays world-bound to the default world — see
422
+ * `runtime/types.ts`). Empty for every existing game (nothing registers
423
+ * against it yet), so `runFrame`'s behavior for a single-world game is
424
+ * unchanged by its presence.
425
+ */
426
+ readonly systems: SystemRunner;
427
+ /** Declaration-ordered. Slice 1 registers exactly one (the default
428
+ * threejs world) — this is the SAME array reference `registerWorld`
429
+ * mutates, not a snapshot, so holders (e.g. `GameContext.worlds`) observe
430
+ * later registrations. */
431
+ readonly worlds: ReadonlyArray<WorldInstance>;
432
+ world(id: string): WorldInstance | null;
433
+ /** First threejs world, else first world. Throws descriptively when no
434
+ * world has been registered yet. */
435
+ readonly defaultWorld: WorldInstance;
436
+ /**
437
+ * Game-scoped aggregation of every world's `SystemAdapters` (§7.1-3,
438
+ * `docs/GAME-ROOT-DESIGN.md` §3: "`registerSystemAdapter` is game-scoped" —
439
+ * the recorded decision this getter finally implements; probe1). Each
440
+ * mounted world builds its OWN `mounted.systems` object (a game registers
441
+ * capabilities like `networking` from ITS OWN `setup()`, via
442
+ * `ctx.registerSystemAdapter`, per-world) — this merges every world's
443
+ * `mounted.systems` into ONE `SystemAdapters`, in `worlds` REGISTRATION
444
+ * order, first registration wins per key. A later world registering the
445
+ * SAME kind (e.g. two worlds both exposing `networking`) does not
446
+ * override the first — instead this warns ONCE per (game instance, key)
447
+ * naming both world ids, matching this file's `[game] world "<id>" …`
448
+ * console idiom (see `registerWorld` below). NOT to be confused with
449
+ * `Game.systems` (the game-scoped `SystemRunner` bucket, an entirely
450
+ * different concept — see that field's doc comment) — this name was
451
+ * deliberately chosen not to collide with it.
452
+ *
453
+ * A plain getter (recomputed on every read, not cached) so a LATER
454
+ * `registerWorld` call (or a world's setup registering a NEW adapter kind
455
+ * after this was first read) is always reflected — only the COLLISION
456
+ * warning is deduped (once per key, for the lifetime of this `Game`).
457
+ * Includes every world regardless of first-party-ness — `mounted.systems`
458
+ * is a capability any adapter (first-party or foreign) may expose.
459
+ */
460
+ readonly systemAdapters: SystemAdapters;
461
+ /** Delegates to the default world's first-party `ComponentManager`.
462
+ * Throws when the default world is not a first-party mount. */
463
+ readonly components: ComponentManager;
464
+ /** Delegates to the default world's first-party `InputManager`. Throws
465
+ * when the default world is not a first-party mount. */
466
+ readonly input: InputManager;
467
+ /** Delegates to the default world's first-party audio context. Throws
468
+ * when the default world is not a first-party mount. */
469
+ readonly audio: GameAudio;
470
+ /**
471
+ * Frame-versioned state bridge (T7.4 slice 1 — `docs/REACT-STATE-BRIDGE.md`
472
+ * §2). Bumped once per completed `runFrame`, after all phases of all
473
+ * worlds and all `endFrame` hooks (see `runFrame`'s tail below). This is
474
+ * the ONE subscription surface `useGameState`
475
+ * (`packages/editor/template/src/ui/game-state.tsx`) — or any
476
+ * other frame-versioned consumer — subscribes to; `state-bridge.ts` itself
477
+ * has no react import, matching the rest of `runtime/`.
478
+ */
479
+ readonly state: GameStateBridge;
480
+ /** Game-level play-state control surface (D10, T7.6) — see {@link PlayState}. */
481
+ readonly play: PlayState;
482
+ /**
483
+ * Cross-world component query (GAME-ROOT-DESIGN §5, T7.1 slice 3) — the
484
+ * game-level half; `ComponentManager.queryByComponent` (`ecs/
485
+ * component-manager.ts`) is the per-world half this aggregates.
486
+ *
487
+ * Iterates `worlds` in declaration order, skipping any world whose mount
488
+ * is not first-party (an opaque/foreign mount has no `ComponentManager`
489
+ * to query), applying `opts.worldId`/`opts.kind` as world-level filters,
490
+ * and concatenating each remaining world's `queryByComponent(cls)`
491
+ * result. No filter (`opts` omitted) spans **every** world — that is the
492
+ * whole point of this API existing (D7's "cross-world flow is ordinary
493
+ * component access", e.g. a pixi minimap component querying the 3D
494
+ * world's tank components).
495
+ *
496
+ * Returns component **instances**, not nodes — `inst.world`/typed node
497
+ * accessors (`inst.object3D`, the generic `inst.node`) are T7.2's; this
498
+ * API's contract freezes HERE. The physical ONE-`ComponentManager`
499
+ * unification (partitioning one manager by (phase, world) instead of one
500
+ * manager per world) lands with T7.2/T7.3 — this per-world-aggregation
501
+ * implementation is the compatible interim: callers written against this
502
+ * signature keep working unchanged once the manager is unified
503
+ * underneath.
504
+ *
505
+ * `T extends GameComponent<WorldKind>` (not the bare default-`'threejs'`
506
+ * `GameComponent`, T7.3 slice 2) — the whole point of a CROSS-WORLD query
507
+ * is spanning every kind in one call (the T7.3 AC,
508
+ * `test/game-three-plus-pixi.test.ts`), so a `GameComponent<'pixijs'>`
509
+ * subclass must type-check here exactly like a default-kind one does.
510
+ */
511
+ queryByComponent<T extends GameComponent<WorldKind>>(
512
+ cls: new () => T,
513
+ opts?: { worldId?: string; kind?: WorldKind },
514
+ ): T[];
515
+ }
516
+
517
+ /**
518
+ * Host-internal extension of {@link Game}: adds `registerWorld` (the host's
519
+ * wiring surface for populating the world registry) and `runFrame` (the
520
+ * frame executor). NEITHER is part of the game-facing `Game` surface —
521
+ * games/components never call either directly; the host loop
522
+ * (`createGameRuntime`) and `GameSession.step()` are the only callers of
523
+ * `runFrame`.
524
+ */
525
+ export interface GameInternal extends Game {
526
+ /** Append a world in declaration order. Throws on a duplicate id. */
527
+ registerWorld(world: WorldInstance): void;
528
+ /**
529
+ * Run ONE fixed substep across every phase and every world
530
+ * (GAME-ROOT-DESIGN §4):
531
+ *
532
+ * ```
533
+ * for phase in PHASE_ORDER:
534
+ * game.systems.runPhase(phase, dt) // game-scoped, first
535
+ * for world in worlds (declaration order):
536
+ * if world.mounted.drivesOwnLoop: continue
537
+ * world.frame?.runPhase(phase, dt)
538
+ * for world in worlds: // after ALL phases
539
+ * if world.mounted.drivesOwnLoop: continue
540
+ * if world.frame: world.frame.endFrame?.()
541
+ * else: world.mounted.update?.(dt) // opaque world fallback
542
+ * ```
543
+ *
544
+ * For today's single first-party world this is byte-identical to the
545
+ * legacy `mounted.update(dt)` (`game.systems` is empty; the one world's
546
+ * `frame.runPhase` delegates to the SAME `SystemRunner.runPhase` its
547
+ * `update` used; `endFrame` is the same `postFrame` call). A
548
+ * `drivesOwnLoop` world is never ticked here at all — matching its
549
+ * exclusion from the legacy `!mountedRef.drivesOwnLoop` guard. An opaque
550
+ * host-driven world (no `frame`) gets exactly one `update(dt)` call per
551
+ * substep, after the phase loop — unchanged cadence from today.
552
+ *
553
+ * D10/T7.6 play-state addendum: when `Game.play.paused` is true, every
554
+ * `pausable` (and non-`drivesOwnLoop`) world skips every phase EXCEPT
555
+ * `render` (still called, every substep, with `dt` forced to `0`) and skips
556
+ * its `endFrame`/opaque-`update` call entirely — a `pausable: false` world
557
+ * is completely unaffected. `opts.ignorePause` runs this call as if nothing
558
+ * were paused, regardless of the live `paused` flag; kept for external
559
+ * byte-compatibility (no first-party caller passes it today — grep finds
560
+ * none) but is no longer how `Game.play.step()` works (§7.1-2 fix,
561
+ * probe4: the old `ignorePause` full-frame re-run double-ticked every
562
+ * already-running `pausable: false` world, since D10's ordinary loop never
563
+ * stops ticking them). `Game.play.step()` now drives this function via the
564
+ * internal-only `onlyFrozen` mode instead (see `runFrameImpl` — not part of
565
+ * this public, host-facing signature): it ticks EXACTLY the currently
566
+ * frozen set (host-driven, `pausable`, and `paused`) through every phase +
567
+ * `endFrame` with the real `dt` (not the render-phase's forced `0`), and
568
+ * touches no other world at all — a natural no-op while not paused, since
569
+ * the frozen set is then empty.
570
+ */
571
+ runFrame(dt: number, opts?: { ignorePause?: boolean }): void;
572
+ }
573
+
574
+ function describeMismatch(handle: 'components' | 'input' | 'audio', world: WorldInstance): string {
575
+ return (
576
+ `Game.${handle}: default world "${world.id}" (kind: ${world.kind}) is not a first-party ` +
577
+ "mount — hoisting these to true Game ownership is a later slice's work; available only " +
578
+ 'via a first-party default world today'
579
+ );
580
+ }
581
+
582
+ /**
583
+ * Construct the (host-internal) Game shell. Callers: `createGameRuntime`
584
+ * builds this BEFORE mounting its one adapter, then registers the default
585
+ * threejs world once mount resolves (see `registerDefaultThreeWorld` in
586
+ * `create-runtime.ts`).
587
+ */
588
+ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameInternal {
589
+ const worlds: WorldInstance[] = [];
590
+ const systems = createSystemRunner();
591
+ const stateBridge = createStateBridge();
592
+
593
+ function requireDefaultWorld(): WorldInstance {
594
+ if (worlds.length === 0) {
595
+ throw new Error('Game.defaultWorld: no worlds registered yet');
596
+ }
597
+ return worlds.find((w) => w.kind === 'threejs') ?? worlds[0]!;
598
+ }
599
+
600
+ function requireFirstPartyCtx(handle: 'components' | 'input' | 'audio') {
601
+ const world = requireDefaultWorld();
602
+ if (!isFirstPartyMounted(world.mounted)) {
603
+ throw new Error(describeMismatch(handle, world));
604
+ }
605
+ return world.mounted.ctx;
606
+ }
607
+
608
+ // --- Game.systemAdapters aggregation (§7.1-3, probe1) --------------------
609
+ // Warn-once-per-colliding-key state, scoped to this Game instance (a fresh
610
+ // Game gets a fresh warn history) — deliberately NOT reset by anything
611
+ // short of a new `createGame` call, matching `reportedGateShortfalls`
612
+ // above's "once per game instance" idiom.
613
+ const warnedSystemAdapterKeys = new Set<string>();
614
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
615
+ function computeSystemAdapters(): SystemAdapters {
616
+ const result: SystemAdapters = {};
617
+ const ownerWorldId = new Map<string, string>();
618
+ for (const world of worlds) {
619
+ const adapters = world.mounted.systems;
620
+ if (!adapters) continue;
621
+ for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
622
+ if (adapters[key] === undefined) continue;
623
+ const existingOwner = ownerWorldId.get(key);
624
+ if (existingOwner !== undefined) {
625
+ if (!warnedSystemAdapterKeys.has(key)) {
626
+ warnedSystemAdapterKeys.add(key);
627
+ // biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors this file's own reportGateShortfallOnce's deliberate direct console.warn just above
628
+ console.warn(
629
+ `[game] systemAdapters: "${key}" is registered by both world "${existingOwner}" and ` +
630
+ `world "${world.id}" — the FIRST registration ("${existingOwner}") wins; the later ` +
631
+ 'one is shadowed (docs/GAME-ROOT-DESIGN.md §3: game-scoped system adapters).',
632
+ );
633
+ }
634
+ continue;
635
+ }
636
+ // biome-ignore lint/suspicious/noExplicitAny: SystemAdapters is a plain optional-field record; the per-key copy is correct by construction (same key on both sides), just not expressible without a cast
637
+ (result as any)[key] = adapters[key];
638
+ ownerWorldId.set(key, world.id);
639
+ }
640
+ }
641
+ return result;
642
+ }
643
+
644
+ // --- D10/T7.6 play-state (Game.play) -------------------------------------
645
+ let paused = false;
646
+ // Dedupe loop-gate/audio-gate shortfall warnings to ONCE per (world, kind)
647
+ // — `pause()` fans out over every world every call; without this a
648
+ // multi-second play session would re-log the same "can't gate" shortfall
649
+ // every time the user hits Pause.
650
+ const reportedGateShortfalls = new Set<string>();
651
+ function reportGateShortfallOnce(kind: 'loop' | 'audio', worldId: string, reason: string): void {
652
+ const key = `${kind}:${worldId}`;
653
+ if (reportedGateShortfalls.has(key)) return;
654
+ reportedGateShortfalls.add(key);
655
+ const message =
656
+ kind === 'loop'
657
+ ? formatLoopGateMessage({ worldId, reason })
658
+ : formatAudioGateMessage({ worldId, reason });
659
+ // Other native `console.warn`/`console.error` call sites in this file are
660
+ // unsuppressed and already counted in the lint baseline (see `runFrame`'s
661
+ // impl below); this one is a NEW site, so it's suppressed to keep this
662
+ // task's diff at zero NEW warnings (same reasoning as `overlay-report.ts`'s
663
+ // identical suppression).
664
+ // biome-ignore lint/suspicious/noConsole: see comment above
665
+ console.warn(message);
666
+ }
667
+
668
+ /** Fan out a loop-gate (self-driven worlds) + audio-gate call over every
669
+ * `pausable` world, reporting honestly (once) wherever the capability is
670
+ * absent — shared by `pause()`/`resume()` below (same fan-out, opposite
671
+ * boolean). */
672
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fans out TWO independent capability gates (loop, audio) with the same "call it, else report once" shape per world — splitting the two gates into separate loops would duplicate the fan-out, not reduce real complexity
673
+ function setWorldGates(next: boolean): void {
674
+ for (const world of worlds) {
675
+ if (!world.pausable) continue; // pausable:false worlds are untouched by design
676
+ if (world.mounted.drivesOwnLoop) {
677
+ if (world.mounted.setPaused) {
678
+ world.mounted.setPaused(next);
679
+ } else if (next) {
680
+ reportGateShortfallOnce(
681
+ 'loop',
682
+ world.id,
683
+ 'self-driven world, adapter declares no setPaused capability',
684
+ );
685
+ }
686
+ }
687
+ const audio = world.mounted.systems?.audio;
688
+ if (audio) {
689
+ audio.setMuted(next);
690
+ } else if (next) {
691
+ reportGateShortfallOnce('audio', world.id, 'no SystemAdapters.audio on this world');
692
+ }
693
+ }
694
+ }
695
+
696
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (GAME-ROOT-DESIGN §4, now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/worlds — splitting it would obscure the ordering contract documented on GameInternal.runFrame
697
+ function runFrameImpl(
698
+ dt: number,
699
+ frameOpts?: { ignorePause?: boolean; onlyFrozen?: boolean },
700
+ ): void {
701
+ const ignorePause = frameOpts?.ignorePause ?? false;
702
+ // `onlyFrozen` is internal-only (not part of the public `GameInternal.runFrame`
703
+ // signature — no external caller sets it) — `Game.play.step()` below is the
704
+ // one and only caller. It ticks EXACTLY the currently-frozen set (host-driven,
705
+ // `pausable`, and `paused`) through every phase + `endFrame`, with the REAL
706
+ // `dt` (not the render-phase's forced `0`), and touches no other world at all
707
+ // — §7.1-2 fix, probe4: the previous `ignorePause` full-frame re-run ticked
708
+ // EVERY host-driven world, double-ticking every already-running
709
+ // `pausable: false` world (D10's ordinary loop never stops ticking them).
710
+ const onlyFrozen = frameOpts?.onlyFrozen ?? false;
711
+ // Checklist item 7: snapshot the world count ONCE at entry and iterate
712
+ // by index in both loops below. A world registered mid-frame (e.g. from
713
+ // a game-scoped system's side effect) joins at the NEXT `runFrame` call,
714
+ // not this one — `for (const world of worlds)` would otherwise pick up
715
+ // a world pushed during this very frame. This also drops the two
716
+ // per-phase `for...of` iterator allocations.
717
+ const n = worlds.length;
718
+
719
+ // §7.1-11 fix (probe5): `stateBridge.bump()` must fire iff at least one
720
+ // world actually advanced this call — not unconditionally. `advanced`
721
+ // covers rule (a) below (host-driven worlds this call actually ticked);
722
+ // rule (b): a self-driven world that is RUNNING — which is every
723
+ // self-driven world while not paused, and, while paused, the ones the
724
+ // gate can't reach (`pausable: false`, or no `setPaused` capability) —
725
+ // checked once, up front, over the same `worlds` array (no extra
726
+ // allocation, matching every other loop here). Rule (b) is skipped in
727
+ // `onlyFrozen` mode: a `step()` call bumps iff it ticked a frozen world
728
+ // — self-driven notifications belong to the loop's own `runFrame`s.
729
+ let advanced = false;
730
+ if (!onlyFrozen) {
731
+ for (let i = 0; i < n; i++) {
732
+ const world = worlds[i]!;
733
+ if (!world.mounted.drivesOwnLoop) continue;
734
+ if (!paused || !world.pausable || !world.mounted.setPaused) {
735
+ advanced = true;
736
+ break;
737
+ }
738
+ }
739
+ }
740
+
741
+ if (onlyFrozen) {
742
+ // step(): the frozen set is host-driven + pausable + currently paused.
743
+ // While NOT paused this set is empty by construction, so this whole
744
+ // branch is a natural no-op — `Game.play.step()`'s "no-op while
745
+ // running" behavior falls straight out of this, no separate guard
746
+ // needed.
747
+ for (const phase of PHASE_ORDER) {
748
+ for (let i = 0; i < n; i++) {
749
+ const world = worlds[i]!;
750
+ if (world.mounted.drivesOwnLoop) continue;
751
+ if (!(paused && world.pausable)) continue; // only the frozen set
752
+ try {
753
+ world.frame?.runPhase(phase, dt);
754
+ } catch (err) {
755
+ console.error(
756
+ `[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw ` +
757
+ '(step()):',
758
+ err,
759
+ );
760
+ }
761
+ }
762
+ }
763
+ for (let i = 0; i < n; i++) {
764
+ const world = worlds[i]!;
765
+ if (world.mounted.drivesOwnLoop) continue;
766
+ if (!(paused && world.pausable)) continue;
767
+ advanced = true;
768
+ if (world.frame) {
769
+ try {
770
+ world.frame.endFrame?.();
771
+ } catch (err) {
772
+ console.error(
773
+ `[game] world "${world.id}" (kind: ${world.kind}) endFrame() threw (step()):`,
774
+ err,
775
+ );
776
+ }
777
+ } else {
778
+ try {
779
+ world.mounted.update?.(dt);
780
+ } catch (err) {
781
+ console.error(
782
+ `[game] world "${world.id}" (kind: ${world.kind}) update() threw (step()):`,
783
+ err,
784
+ );
785
+ }
786
+ }
787
+ }
788
+ } else {
789
+ for (const phase of PHASE_ORDER) {
790
+ systems.runPhase(phase, dt);
791
+ for (let i = 0; i < n; i++) {
792
+ const world = worlds[i]!;
793
+ if (world.mounted.drivesOwnLoop) continue;
794
+ // D10/T7.6: a `pausable` world under an active (non-ignored) pause
795
+ // skips every phase except `render` — its render still runs, every
796
+ // substep, but with `dt` forced to `0` (deterministic: no
797
+ // time-based render effect silently keeps animating a "frozen"
798
+ // scene). A `pausable: false` world (or any world while
799
+ // `ignorePause`) is unaffected.
800
+ const frozen = !ignorePause && paused && world.pausable;
801
+ if (frozen && phase !== SystemPhase.RENDER) continue;
802
+ const phaseDt = frozen ? 0 : dt;
803
+ // Checklist item 2: isolate each world's per-phase work — one
804
+ // world's `runPhase` throwing must not starve sibling worlds still
805
+ // due this phase, nor abort the frame. Mirrors `runOne`'s style in
806
+ // `core/system-runner.ts` (loud console.error, never swallowed).
807
+ try {
808
+ world.frame?.runPhase(phase, phaseDt);
809
+ } catch (err) {
810
+ console.error(
811
+ `[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw:`,
812
+ err,
813
+ );
814
+ }
815
+ }
816
+ }
817
+ for (let i = 0; i < n; i++) {
818
+ const world = worlds[i]!;
819
+ if (world.mounted.drivesOwnLoop) continue;
820
+ // A fully-frozen world gets no `endFrame`/opaque-`update` call either —
821
+ // there is nothing to "end the frame" of when nothing ran this substep.
822
+ const frozen = !ignorePause && paused && world.pausable;
823
+ if (frozen) continue;
824
+ advanced = true;
825
+ if (world.frame) {
826
+ try {
827
+ world.frame.endFrame?.();
828
+ } catch (err) {
829
+ console.error(
830
+ `[game] world "${world.id}" (kind: ${world.kind}) endFrame() threw:`,
831
+ err,
832
+ );
833
+ }
834
+ } else {
835
+ // Checklist item 2: same isolation for the opaque-world fallback —
836
+ // one foreign mount's `update` throwing must not starve its
837
+ // siblings' `endFrame`/`update` calls this same loop.
838
+ try {
839
+ world.mounted.update?.(dt);
840
+ } catch (err) {
841
+ console.error(`[game] world "${world.id}" (kind: ${world.kind}) update() threw:`, err);
842
+ }
843
+ }
844
+ }
845
+ }
846
+
847
+ // T7.4 slice 1 (REACT-STATE-BRIDGE.md §2): bump + notify LAST, after
848
+ // every phase of every world and every world's endFrame/update above —
849
+ // subscribers must only ever observe post-frame state. Bumped at most
850
+ // once per completed `runFrame`/`step()` call, never per phase/world —
851
+ // and, per §7.1-11's fix, only when `advanced` (see above) is true: a
852
+ // fully-gated paused game produces no notifications at all.
853
+ if (advanced) stateBridge.bump();
854
+ }
855
+
856
+ return {
857
+ loop: opts.loop,
858
+ assets: opts.assets,
859
+ systems,
860
+ get worlds() {
861
+ return worlds;
862
+ },
863
+ world(id: string): WorldInstance | null {
864
+ return worlds.find((w) => w.id === id) ?? null;
865
+ },
866
+ get defaultWorld() {
867
+ return requireDefaultWorld();
868
+ },
869
+ get systemAdapters() {
870
+ return computeSystemAdapters();
871
+ },
872
+ get components() {
873
+ return requireFirstPartyCtx('components').components;
874
+ },
875
+ get input() {
876
+ return requireFirstPartyCtx('input').input;
877
+ },
878
+ get audio() {
879
+ return requireFirstPartyCtx('audio').audio;
880
+ },
881
+ state: stateBridge,
882
+ play: {
883
+ get paused() {
884
+ return paused;
885
+ },
886
+ pause() {
887
+ if (paused) return;
888
+ paused = true;
889
+ setWorldGates(true);
890
+ },
891
+ resume() {
892
+ if (!paused) return;
893
+ paused = false;
894
+ setWorldGates(false);
895
+ },
896
+ step(dt = 1 / 60) {
897
+ // Self-driven pausable worlds advance via their adapter's `step()`
898
+ // capability — but only the ones that are actually FROZEN: the game
899
+ // must be paused, and the world must have been gate-able in the
900
+ // first place (`setPaused` present — a world the gate couldn't reach
901
+ // never stopped, so "stepping" it would double-tick a still-running
902
+ // loop, the same §7.1-2 class as the host-driven fix below). While
903
+ // not paused, nothing here runs — step() is a whole-call no-op.
904
+ if (paused) {
905
+ for (const world of worlds) {
906
+ if (!world.pausable || !world.mounted.drivesOwnLoop) continue;
907
+ if (!world.mounted.setPaused) continue; // never gated — still running
908
+ if (world.mounted.step) {
909
+ world.mounted.step();
910
+ } else {
911
+ reportGateShortfallOnce(
912
+ 'loop',
913
+ world.id,
914
+ 'self-driven world, adapter declares no step capability',
915
+ );
916
+ }
917
+ }
918
+ }
919
+ // §7.1-2 fix (probe4): tick EXACTLY the frozen (host-driven, pausable,
920
+ // paused) set — not `{ ignorePause: true }`, which re-ran a FULL
921
+ // extra frame for every host-driven world (including already-running
922
+ // `pausable: false` ones — a double-tick, since D10's ordinary loop
923
+ // never stops ticking them). See `runFrameImpl`'s `onlyFrozen` mode.
924
+ runFrameImpl(dt, { onlyFrozen: true });
925
+ },
926
+ },
927
+ queryByComponent<T extends GameComponent<WorldKind>>(
928
+ cls: new () => T,
929
+ opts?: { worldId?: string; kind?: WorldKind },
930
+ ): T[] {
931
+ // Checklist item 8a: a `worldId` naming a world that doesn't exist is a
932
+ // caller error (typo'd id, wrong manifest) — degrade loudly, matching
933
+ // repo habit, rather than silently returning `[]`. A worldId that DOES
934
+ // exist but isn't first-party legitimately answers "no components"
935
+ // below (the loop's `!isFirstPartyMounted` `continue`), which stays
936
+ // silent — that's a real, not a mistaken, empty result.
937
+ if (opts?.worldId !== undefined && !worlds.some((w) => w.id === opts.worldId)) {
938
+ throw new Error(`Game.queryByComponent: unknown worldId "${opts.worldId}"`);
939
+ }
940
+ const result: T[] = [];
941
+ for (const world of worlds) {
942
+ if (!isFirstPartyMounted(world.mounted)) continue;
943
+ if (opts?.worldId !== undefined && world.id !== opts.worldId) continue;
944
+ if (opts?.kind !== undefined && world.kind !== opts.kind) continue;
945
+ // Checklist item 8b: a plain for-loop push instead of
946
+ // `result.push(...arr)` — spread-as-arguments can hit engine/runtime
947
+ // argument-count limits once a world's instance count gets large.
948
+ const instances = world.mounted.ctx.components.queryByComponent(cls);
949
+ for (const inst of instances) result.push(inst);
950
+ }
951
+ return result;
952
+ },
953
+ registerWorld(world: WorldInstance): void {
954
+ if (worlds.some((w) => w.id === world.id)) {
955
+ throw new Error(`Game.registerWorld: duplicate world id "${world.id}"`);
956
+ }
957
+ // Checklist item 6: the SAME `mounted` object registered under two
958
+ // world ids would be double-ticked by `runFrame` (its `frame.runPhase`/
959
+ // `endFrame` called once per registration) and double-dispose-wrapped
960
+ // (`createWorldInstance` wraps `mounted.dispose` in place — a second
961
+ // wrap would flip `disposed` and call through on ITS OWN wrapped
962
+ // `originalDispose`, which is harmless today only by accident of
963
+ // `VgaiSceneGameAdapter.dispose` being idempotent; a foreign adapter
964
+ // has no such guarantee). Reject it outright instead.
965
+ if (worlds.some((w) => w.mounted === world.mounted)) {
966
+ throw new Error(
967
+ `Game.registerWorld: world "${world.id}" shares its \`mounted\` object with an ` +
968
+ `already-registered world ("${worlds.find((w) => w.mounted === world.mounted)!.id}") — ` +
969
+ 'the same mount cannot be registered twice.',
970
+ );
971
+ }
972
+ worlds.push(world);
973
+ // T7.4 slice 2 (REACT-STATE-BRIDGE.md §4): a NON-first-party mount
974
+ // (an ingested/foreign world — first-party mounts are exempt, they're
975
+ // observed via `Game.state`/`useGameState` instead) with no `observe`
976
+ // has no state bridge at all — react HUDs cannot subscribe to it, and
977
+ // silently returning `undefined` forever would hide that. Report ONCE
978
+ // per mount, at registration time, matching this file's existing
979
+ // `[game] world "<id>" (kind: <kind>) ...` console idiom (see
980
+ // `runFrame` below).
981
+ //
982
+ // §7.1-15: a `kind: 'react'` world is ALSO exempt — it has no `observe`
983
+ // BY DESIGN (D8/REACT-WORLD-DESIGN.md: a react world's own mounted tree
984
+ // reads state via `Game.state`/`useGameState`, the SAME first-party
985
+ // bridge a threejs/pixijs world's HUD uses, never `WorldStateObserver`
986
+ // — that hook is scoped to the ingested/foreign-world case). Without
987
+ // this exemption every production react world logged a false-positive
988
+ // "no state bridge" warning at registration (probe: every real react
989
+ // world mount), eroding the signal for a genuinely un-observable
990
+ // ingested world.
991
+ if (!isFirstPartyMounted(world.mounted) && world.kind !== 'react' && !world.mounted.observe) {
992
+ console.warn(
993
+ `[game] world "${world.id}" (kind: ${world.kind}, adapter: "${world.adapter.id}"): ` +
994
+ 'no state bridge — this mounted game has no `observe` (WorldStateObserver); ' +
995
+ 'react HUDs/useWorldObservation cannot subscribe to its state (docs/REACT-STATE-BRIDGE.md §4).',
996
+ );
997
+ }
998
+ },
999
+ runFrame(dt: number, frameOpts?: { ignorePause?: boolean }): void {
1000
+ runFrameImpl(dt, frameOpts);
1001
+ },
1002
+ };
1003
+ }