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