@vgai/engine 0.5.2 → 0.5.3
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/README.md +18 -11
- package/package.json +10 -6
- package/schemas/engine-api.json +1 -68
- package/schemas/engine-api.md +1 -32
- package/schemas/engine-capabilities.json +30 -42
- package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
- package/src/adapter/adapter-surface.ts +5 -5
- package/src/adapter/authoring.ts +168 -226
- package/src/adapter/colyseus-networking-adapter.ts +44 -5
- package/src/adapter/first-party-systems.ts +156 -42
- package/src/adapter/host-context.ts +177 -46
- package/src/adapter/index.ts +47 -51
- package/src/adapter/ingest/game-contract.ts +2 -2
- package/src/adapter/ingest/scene-capture.ts +18 -19
- package/src/adapter/ingest/structural-ids.ts +127 -0
- package/src/adapter/ingest/upstream-pin.ts +9 -12
- package/src/adapter/loop-gate-report.ts +11 -11
- package/src/adapter/rapier-physics-adapter.ts +27 -9
- package/src/adapter/root-adapter.ts +217 -0
- package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
- package/src/adapter/system-adapter.ts +80 -63
- package/src/ai/navigation.ts +1 -1
- package/src/animation/animation-clock.ts +1 -1
- package/src/animation/camera-ownership.ts +1 -2
- package/src/animation/cubic-spline-interpolant.ts +132 -0
- package/src/animation/theatre-clock-binding.ts +2 -2
- package/src/animation/theatre-object-binding.ts +4 -4
- package/src/animation/xstate-animation-binding.ts +75 -5
- package/src/{scene/schema → asset-formats}/camera.ts +2 -5
- package/src/{scene/schema → asset-formats}/collider.ts +2 -5
- package/src/asset-formats/index.ts +54 -0
- package/src/{scene/schema → asset-formats}/instances.ts +10 -6
- package/src/{scene/schema → asset-formats}/light.ts +3 -6
- package/src/{scene/schema → asset-formats}/material.ts +4 -7
- package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
- package/src/asset-formats/parse.ts +39 -0
- package/src/{scene/schema → asset-formats}/particles.ts +3 -6
- package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
- package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
- package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
- package/src/asset-parse-error.ts +33 -0
- package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
- package/src/assets.ts +1 -1
- package/src/audio/wav-encode.ts +9 -9
- package/src/canvas-react/engine-bridge.ts +59 -0
- package/src/canvas-react/index.ts +50 -0
- package/src/canvas-react/pixi-primitive.tsx +202 -0
- package/src/canvas-react/pixi-react-adapter.tsx +290 -0
- package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
- package/src/canvas-react/world-context.ts +328 -0
- package/src/core/frame-pacing.ts +100 -0
- package/src/core/game-loop.ts +50 -28
- package/src/core/seeded-random.ts +7 -7
- package/src/core/sim-clock.ts +388 -0
- package/src/core/system-runner.ts +17 -63
- package/src/core/types.ts +34 -15
- package/src/data/data-asset.ts +3 -3
- package/src/data/data-check-core.ts +6 -7
- package/src/data/data-ref.ts +11 -11
- package/src/data/vite-plugin-data.ts +10 -10
- package/src/{scene/defaults.ts → defaults.ts} +18 -40
- package/src/dev/render-debug-adapter.ts +1 -1
- package/src/dev/webgl-frame-capture.ts +1 -1
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/{scene → ecs}/user-data.ts +17 -36
- package/src/index.ts +7 -9
- package/src/input/input-manager.ts +29 -32
- package/src/input/input-types.ts +2 -2
- package/src/input/schema.ts +5 -5
- package/src/loader.ts +57 -0
- package/src/manifest/editor-port.ts +69 -0
- package/src/manifest/filename.ts +49 -0
- package/src/manifest/index.ts +8 -2
- package/src/manifest/load-file.ts +11 -0
- package/src/manifest/load.ts +65 -77
- package/src/manifest/locate.ts +55 -0
- package/src/manifest/schema.ts +400 -233
- package/src/{scene → physics}/collider-dimensions.ts +3 -3
- package/src/physics/physics-registry.ts +1 -1
- package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
- package/src/pixi/index.ts +43 -0
- package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
- package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
- package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
- package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
- package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
- package/src/react/unmanaged-root-detector.ts +26 -1
- package/src/react/use-data.ts +5 -5
- package/src/react/use-selection.tsx +15 -42
- package/src/react/{game-state.tsx → world-state.tsx} +44 -46
- package/src/render/auto-batcher.ts +1 -2
- package/src/{scene → render}/instance-mesh.ts +1 -1
- package/src/{scene → render}/light-camera-factory.ts +14 -13
- package/src/render/lod.ts +17 -0
- package/src/{scene → render}/material-factory.ts +8 -7
- package/src/{scene → render}/particles-factory.ts +62 -12
- package/src/render/render-batch-system.ts +14 -41
- package/src/render/render-features.ts +1 -1
- package/src/render/render-settings.ts +1 -2
- package/src/render/spark-renderer-lifecycle.ts +1 -1
- package/src/runtime/create-runtime.ts +352 -463
- package/src/runtime/debug-bridge.ts +148 -93
- package/src/runtime/debug-registry.ts +79 -54
- package/src/runtime/dev-layers.ts +40 -0
- package/src/runtime/frame-selector-cache.ts +4 -4
- package/src/runtime/game.ts +618 -369
- package/src/runtime/gameplay-rng-trap.ts +6 -7
- package/src/runtime/input-router.ts +11 -11
- package/src/runtime/mount-game.ts +54 -55
- package/src/runtime/mount-manifest.ts +154 -150
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-audio-control.ts +64 -53
- package/src/runtime/render-control.ts +45 -51
- package/src/runtime/render-seed.ts +3 -4
- package/src/runtime/state-bridge.ts +17 -18
- package/src/runtime/types.ts +94 -61
- package/src/setup/setup-renderer.ts +11 -6
- package/src/world3d-react/engine-bridge.ts +46 -33
- package/src/world3d-react/index.ts +31 -26
- package/src/world3d-react/r3f-adapter.tsx +211 -96
- package/src/world3d-react/r3f-root-factory.tsx +91 -0
- package/src/world3d-react/renderer-config.ts +137 -0
- package/src/world3d-react/world-context.ts +141 -111
- package/schemas/entity2d.schema.json +0 -468
- package/schemas/prefab.schema.json +0 -9992
- package/schemas/scn2d.schema.json +0 -494
- package/schemas/vscn.schema.json +0 -10851
- package/src/adapter/game-adapter.ts +0 -164
- package/src/adapter/ingest/overlay-applier.ts +0 -207
- package/src/adapter/ingest/overlay-apply.ts +0 -168
- package/src/adapter/ingest/overlay-file.ts +0 -126
- package/src/adapter/ingest/overlay-report.ts +0 -176
- package/src/animation/gsap-registration.ts +0 -184
- package/src/audio/audio-introspection.ts +0 -290
- package/src/audio/index.ts +0 -39
- package/src/audio/tone-clock-binding.ts +0 -98
- package/src/audio/tone-context.ts +0 -175
- package/src/audio/tone-offline-render.ts +0 -167
- package/src/ecs/component-manager.ts +0 -814
- package/src/ecs/game-component.ts +0 -260
- package/src/ecs/hmr-swap-report.ts +0 -65
- package/src/physics/trigger-dispatch.ts +0 -97
- package/src/react/root-adapter.tsx +0 -49
- package/src/scene/asset-paths.ts +0 -121
- package/src/scene/asset-ref-check.ts +0 -248
- package/src/scene/component-registry.ts +0 -51
- package/src/scene/parse.ts +0 -204
- package/src/scene/scene-apply.ts +0 -407
- package/src/scene/scene-diff-schema.ts +0 -115
- package/src/scene/scene-diff-types.ts +0 -29
- package/src/scene/scene-loader.ts +0 -1526
- package/src/scene/scene-query.ts +0 -63
- package/src/scene/scene-types.ts +0 -33
- package/src/scene/scene-version.ts +0 -40
- package/src/scene/schema/animation.ts +0 -46
- package/src/scene/schema/audio.ts +0 -25
- package/src/scene/schema/entity-ref.ts +0 -78
- package/src/scene/schema/entity.ts +0 -189
- package/src/scene/schema/index.ts +0 -51
- package/src/scene/schema/joint.ts +0 -26
- package/src/scene/schema/physics.ts +0 -49
- package/src/scene/schema/scene-file.ts +0 -292
- package/src/scene/schema/shadow.ts +0 -24
- package/src/scene/schema/spline.ts +0 -21
- package/src/world2d/asset-paths2d.ts +0 -44
- package/src/world2d/capture-to-scene2d.ts +0 -52
- package/src/world2d/collision-2d.ts +0 -99
- package/src/world2d/entity2d-asset.ts +0 -22
- package/src/world2d/index.ts +0 -91
- package/src/world2d/physics2d-transform.ts +0 -173
- package/src/world2d/physics2d-units.ts +0 -10
- package/src/world2d/pixi-game-adapter.ts +0 -439
- package/src/world2d/pixi-surface.ts +0 -78
- package/src/world2d/scene2d-identity.ts +0 -49
- package/src/world2d/scene2d-loader.ts +0 -433
- package/src/world2d/schema/entity2d.ts +0 -163
- package/src/world2d/schema/physics2d.ts +0 -64
- package/src/world2d/schema/sprite.ts +0 -99
- package/src/world2d/schema/tilemap.ts +0 -39
- package/src/world2d/schema/tuples2d.ts +0 -25
- package/src/world2d/transform-writer-2d.ts +0 -42
- package/src/world2d/types.ts +0 -74
- package/src/world3d-react/behavior.tsx +0 -146
- /package/src/{scene → render}/mesh-shadow.ts +0 -0
package/src/runtime/game.ts
CHANGED
|
@@ -1,40 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Game root — T7.1 slice 1 ("introduce Game internally") + slice 2 ("world
|
|
3
3
|
* registry + ordered frame algorithm"), extended in T7.3 slice 1 with real
|
|
4
|
-
* `'
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
4
|
+
* `'canvas'`-kind `RootInstance` support (`stage`/`physics2d`, a real
|
|
5
|
+
* `pixiStage()`) so a canvas adapter can register its world onto a real `Game`
|
|
6
|
+
* instead of driving its own loop, and in T7.3 slice 2 with a
|
|
7
|
+
* construction-time stage check (symmetric with the three/scene check).
|
|
8
|
+
* T6.2 slice 1 adds real `'react'`-kind
|
|
9
|
+
* `RootInstance` support (`container`, a real `reactRoot()`) — the DOM-root
|
|
10
|
+
* world surface `runtime/create-runtime.ts`'s roots path now mounts, with
|
|
11
|
+
* `physics`/`collisions`/`camera`/`frame` `undefined` for them exactly
|
|
12
|
+
* like an opaque/foreign mount.
|
|
16
13
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* cache is the one remaining slice-1 omission.
|
|
14
|
+
* (D6), particularly §4 (the frame algorithm) and §8 stage 2, for the full
|
|
15
|
+
* target shape. This file holds the Game/ RootInstance shell, the NEW
|
|
16
|
+
* game-scoped `SystemRunner`, and the host-facing `runFrame` frame executor.
|
|
17
|
+
* The public surface is NOT frozen yet — do not add these exports to a
|
|
18
|
+
* barrel/index; only the in-scope runtime/adapter files import this module
|
|
19
|
+
* directly. The `gpu` per-world cache is the one remaining slice-1 omission.
|
|
24
20
|
*/
|
|
25
21
|
|
|
26
22
|
// TYPE-ONLY pixi import (T7.3 slice 1): `game.ts` must never value-import
|
|
27
23
|
// `pixi.js` — a value import would pull the Pixi runtime into 3D-only
|
|
28
|
-
// bundles that never touch
|
|
24
|
+
// bundles that never touch a canvas root. Every pixi-typed field below is erased
|
|
29
25
|
// at compile time; nothing here constructs or calls into Pixi.
|
|
30
26
|
import type * as PIXI from 'pixi.js';
|
|
31
27
|
import type * as THREE from 'three';
|
|
32
28
|
import type { AdapterSurface as AdapterSurfaceLeaf } from '../adapter/adapter-surface';
|
|
33
|
-
import { createInputManagerAdapter } from '../adapter/first-party-systems';
|
|
34
|
-
import type { MountedWorld } from '../adapter/game-adapter';
|
|
35
29
|
import { formatAudioGateMessage, formatLoopGateMessage } from '../adapter/loop-gate-report';
|
|
30
|
+
import type { MountedRoot } from '../adapter/root-adapter';
|
|
31
|
+
import type { MountedSetupThreeRoot } from '../adapter/setup-three-root-adapter';
|
|
36
32
|
import type { SystemAdapters } from '../adapter/system-adapter';
|
|
37
|
-
import type { VgaiMountedGame } from '../adapter/vgai-scene-game-adapter';
|
|
38
33
|
import type { AssetCache } from '../assets';
|
|
39
34
|
import type { createGameLoop } from '../core/game-loop';
|
|
40
35
|
import {
|
|
@@ -42,19 +37,18 @@ import {
|
|
|
42
37
|
DEFAULT_SEEDED_RANDOM_SEED,
|
|
43
38
|
registerSeededRandom,
|
|
44
39
|
} from '../core/seeded-random';
|
|
40
|
+
import { createSimClock, registerSimClock, type SimClockInternal } from '../core/sim-clock';
|
|
45
41
|
import { createSystemRunner, type SystemRunner } from '../core/system-runner';
|
|
46
42
|
import { PHASE_ORDER, SystemPhase, type SystemPhaseName } from '../core/types';
|
|
47
43
|
import { createPerformanceProfiler, type PerformanceProfiler } from '../dev/performance-profiler';
|
|
48
|
-
import
|
|
49
|
-
import type { GameComponent } from '../ecs/game-component';
|
|
44
|
+
import { hasUserData } from '../ecs/user-data';
|
|
50
45
|
import { InputManager } from '../input/input-manager';
|
|
51
46
|
import type { CollisionSystem } from '../physics/collision-system';
|
|
52
47
|
import type { PhysicsRegistry } from '../physics/physics-registry';
|
|
48
|
+
// TYPE-ONLY (same rule as the pixi import above): this lives under `pixi/`,
|
|
49
|
+
// but `game.ts` only ever names its TYPE.
|
|
50
|
+
import type { Physics2DRegistry } from '../pixi/physics-registry';
|
|
53
51
|
import type { AudioContext as GameAudio } from '../setup/setup-audio';
|
|
54
|
-
// TYPE-ONLY (same rule as the pixi import above): these live under
|
|
55
|
-
// `world2d/`, but `game.ts` only ever names their TYPES.
|
|
56
|
-
import type { Collision2DSystem } from '../world2d/collision-2d';
|
|
57
|
-
import type { Physics2DRegistry } from '../world2d/physics2d-registry';
|
|
58
52
|
import {
|
|
59
53
|
createDebugRegistry,
|
|
60
54
|
DebugError,
|
|
@@ -63,27 +57,42 @@ import {
|
|
|
63
57
|
} from './debug-registry';
|
|
64
58
|
import { createGameplayRngTrap, registerGameplayRngTrapControl } from './gameplay-rng-trap';
|
|
65
59
|
import { createStateBridge, type GameStateBridge } from './state-bridge';
|
|
60
|
+
import type { GameContext, PlaytestContext } from './types';
|
|
66
61
|
|
|
67
|
-
/** The one loop type — `createGameLoop`'s return shape (
|
|
62
|
+
/** The one loop type — `createGameLoop`'s return shape (fixed-step sim,
|
|
63
|
+
* display-rate presentation since WO-P13). */
|
|
68
64
|
export type GameLoop = ReturnType<typeof createGameLoop>;
|
|
69
65
|
|
|
70
66
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
67
|
+
* WO-P13 — the tail of `PHASE_ORDER` that presents rather than simulates, and
|
|
68
|
+
* therefore runs once per DISPLAY frame (`runRenderFrameImpl`) rather than
|
|
69
|
+
* once per fixed substep. The same two phases `runTicks`' `skipRenderPhases`
|
|
70
|
+
* fast-forward skips and `render-control.ts`'s `renderOnce()` drives — one
|
|
71
|
+
* definition of "the render phases" across all three, kept in `PHASE_ORDER`'s
|
|
72
|
+
* own order.
|
|
73
|
+
*/
|
|
74
|
+
const DISPLAY_RATE_PHASES: readonly SystemPhaseName[] = PHASE_ORDER.filter(
|
|
75
|
+
(phase) => phase === SystemPhase.PRE_RENDER || phase === SystemPhase.RENDER,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Game-level play-state control surface (D10, T7.6): pause/resume/step as ONE
|
|
80
|
+
* control surface on the running game, with PER-WORLD `pausable` semantics
|
|
81
|
+
* (`RootInstance.pausable`) — a menu/HUD world declaring `pausable: false` keeps
|
|
82
|
+
* ticking (input, physics) while every `pausable: true` world
|
|
83
|
+
* freezes. A paused-and-pausable world's `render` phase still runs (with `dt`
|
|
84
|
+
* forced to `0`, so time-based render effects — e.g. a post-processing pass with
|
|
85
|
+
* its own internal clock — don't silently keep animating under a "frozen" scene)
|
|
86
|
+
* — simulation freezes, the screen does not go black. WO-P13 changed only the
|
|
87
|
+
* CADENCE of that: once per display frame under a host that drives
|
|
88
|
+
* `runRenderFrame`, once per substep under one that still renders inside
|
|
89
|
+
* `runFrame`. `GameLoop.timeScale` (unaffected by this) remains the orthogonal
|
|
90
|
+
* "speed up/slow down" axis — pausing never touches it, so the host's
|
|
91
|
+
* accumulator/rAF loop keeps ticking at its normal cadence, which is what makes
|
|
92
|
+
* "paused still renders" possible (the previous `GameSession.pause()`
|
|
93
|
+
* implementation set `loop.timeScale = 0`, which starves the accumulator — and,
|
|
94
|
+
* before WO-P13, stopped rendering with it; that was the bug this control
|
|
95
|
+
* surface replaces, not a compatible behavior to keep).
|
|
87
96
|
*/
|
|
88
97
|
export interface PlayState {
|
|
89
98
|
/** Whether the game is currently paused (game-level — see the per-world
|
|
@@ -94,10 +103,9 @@ export interface PlayState {
|
|
|
94
103
|
* while already paused is a no-op) and, for every `pausable`, self-driven
|
|
95
104
|
* (`drivesOwnLoop`) world, invoke its loop-gate capability
|
|
96
105
|
* (`mounted.setPaused(true)`) — reporting loudly, once per world, when that
|
|
97
|
-
* capability is absent (an honest "cannot gate" instead of a silent no-op
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* report).
|
|
106
|
+
* capability is absent (an honest "cannot gate" instead of a silent no-op).
|
|
107
|
+
* Also silences every `pausable` world's audio via its
|
|
108
|
+
* `SystemAdapters.audio` (absent ⇒ the same loud, once-only report).
|
|
101
109
|
*/
|
|
102
110
|
pause(): void;
|
|
103
111
|
/** Resume every `pausable` world — the inverse of `pause()`, same
|
|
@@ -122,28 +130,27 @@ export interface PlayState {
|
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
/**
|
|
125
|
-
* The kinds of render surface a world can be. `'
|
|
126
|
-
* roots are real
|
|
127
|
-
* world; `world2d/pixi-game-adapter.ts`'s pixi world); `'react'` is named
|
|
133
|
+
* The kinds of render surface a world can be. `'three'` and `'canvas'`
|
|
134
|
+
* roots are both real; `'react'` is named
|
|
128
135
|
* here so the type is already shaped for T7.4 and no caller has to widen a
|
|
129
136
|
* union later.
|
|
130
137
|
*
|
|
131
138
|
* Re-exported from `adapter/adapter-surface.ts` (T7.5) — moved there so
|
|
132
|
-
* `adapter/
|
|
139
|
+
* `adapter/root-adapter.ts`'s kind-tagged `MountedRoot` types can name it
|
|
133
140
|
* without an import cycle back to this file. This re-export keeps every
|
|
134
141
|
* existing `import type { AdapterSurface } from '../runtime/game'` call site
|
|
135
|
-
* (`ecs/
|
|
142
|
+
* (`ecs/behavior.ts`, `ecs/component-manager.ts`) compiling unchanged.
|
|
136
143
|
*/
|
|
137
144
|
export type AdapterSurface = AdapterSurfaceLeaf;
|
|
138
145
|
|
|
139
146
|
/**
|
|
140
|
-
* A world's per-phase frame hooks (
|
|
141
|
-
* Populated on a `
|
|
147
|
+
* A world's per-phase frame hooks (T7.1 slice 2).
|
|
148
|
+
* Populated on a `RootInstance` only for first-party mounts — an opaque/
|
|
142
149
|
* foreign mount has no phase-partitioned entry point, so it stays
|
|
143
150
|
* `undefined` and the Game's frame executor (`GameInternal.runFrame`) falls
|
|
144
151
|
* back to calling its single `mounted.update(dt)` once per substep instead.
|
|
145
152
|
*/
|
|
146
|
-
export interface
|
|
153
|
+
export interface RootFrameHooks {
|
|
147
154
|
/** Run this world's engine systems + component ticks + world-bound game
|
|
148
155
|
* systems for one phase. For a first-party world this delegates to the
|
|
149
156
|
* SAME `SystemRunner.runPhase` its (legacy) `mounted.update` uses. */
|
|
@@ -156,58 +163,55 @@ export interface WorldFrameHooks {
|
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
/**
|
|
159
|
-
* A single world instance: the unit of adaptation
|
|
166
|
+
* A single world instance: the unit of adaptation.
|
|
160
167
|
* Slice-1 subset — `gpu` (per-world GPU resource cache, §6.2) is omitted
|
|
161
168
|
* until the slice that builds it.
|
|
162
169
|
*/
|
|
163
|
-
export interface
|
|
170
|
+
export interface RootInstance {
|
|
164
171
|
/** Manifest id (T3.1). Slice 1 always registers exactly one: `'main'`. */
|
|
165
172
|
readonly id: string;
|
|
166
173
|
readonly kind: AdapterSurface;
|
|
167
174
|
/** Per-world play/pause semantics (D10, T7.6). Slice 1 always `true`. */
|
|
168
175
|
readonly pausable: boolean;
|
|
169
|
-
/** The
|
|
170
|
-
* Deliberately narrower than `
|
|
171
|
-
* ever read for `.id` (`Game.
|
|
172
|
-
* never re-invoked — and `
|
|
173
|
-
* differs per kind's host
|
|
174
|
-
* adapter's `
|
|
175
|
-
* requiring the FULL interface here
|
|
176
|
-
*
|
|
177
|
-
* below. */
|
|
176
|
+
/** The RootAdapter that produced `mounted` — first-party or external.
|
|
177
|
+
* Deliberately narrower than `RootAdapter<K>` (T7.5): this field is only
|
|
178
|
+
* ever read for `.id` (`Game.registerRoot`'s diagnostic message below) —
|
|
179
|
+
* never re-invoked — and `RootAdapter<K>`'s `mount` signature legitimately
|
|
180
|
+
* differs per kind's host CONTEXT (`HostContextFor<K>`, P-8: a canvas
|
|
181
|
+
* adapter's `CanvasHostContext`, a react adapter's `DomHostContext`, vs a
|
|
182
|
+
* three adapter's `ThreeHostContext`), so requiring the FULL interface here
|
|
183
|
+
* would force each registration site to name its own `K` for no behavioral
|
|
184
|
+
* gain — see `AdapterHandle` below. */
|
|
178
185
|
readonly adapter: AdapterHandle;
|
|
179
|
-
/** The live mounted surface — one of `
|
|
180
|
-
* (T7.5; `
|
|
181
|
-
*
|
|
182
|
-
readonly mounted:
|
|
186
|
+
/** The live mounted surface — one of `MountedRoot`'s kind-tagged shapes
|
|
187
|
+
* (T7.5; `MountedThreeRoot` for a three world, `MountedPixiRoot` for
|
|
188
|
+
* canvas, `MountedReactRoot` for react). */
|
|
189
|
+
readonly mounted: MountedRoot;
|
|
183
190
|
/** Kind-narrowed accessor: throws a descriptive error when this world is
|
|
184
|
-
* not a
|
|
185
|
-
|
|
186
|
-
/** Kind-narrowed accessor for
|
|
187
|
-
* passed to `
|
|
188
|
-
* descriptively for a non-pixijs world, or a
|
|
189
|
-
* a `stage` (see `
|
|
190
|
-
|
|
191
|
-
/** Kind-narrowed accessor for react roots (T6.2 slice 1
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* `WorldInstanceInit.container`). */
|
|
191
|
+
* not a three world. */
|
|
192
|
+
threeScene(): THREE.Scene;
|
|
193
|
+
/** Kind-narrowed accessor for canvas roots (T7.3): returns the stage
|
|
194
|
+
* passed to `createRootInstance` for a `'canvas'`-kind world. Throws
|
|
195
|
+
* descriptively for a non-pixijs world, or a canvas world built without
|
|
196
|
+
* a `stage` (see `RootInstanceInit.stage`). */
|
|
197
|
+
pixiStage(): PIXI.Container;
|
|
198
|
+
/** Kind-narrowed accessor for react roots (T6.2 slice 1): returns the
|
|
199
|
+
* DOM-root layer `<div>` the host mounted this world's react tree into
|
|
200
|
+
* (the SAME element passed as `container` to `createRootInstance` —
|
|
201
|
+
* identity matters, mirroring `threeScene()`/`pixiStage()`'s "same
|
|
202
|
+
* instance the adapter mounted" contract). Throws descriptively for a
|
|
203
|
+
* non-react world, or a react world built without a `container` (see
|
|
204
|
+
* `RootInstanceInit.container`). */
|
|
199
205
|
reactRoot(): HTMLElement;
|
|
200
206
|
/** Present only when the world's mount is first-party (Rapier3D for
|
|
201
|
-
*
|
|
207
|
+
* three roots). */
|
|
202
208
|
readonly physics?: PhysicsRegistry | undefined;
|
|
203
209
|
readonly collisions?: CollisionSystem | undefined;
|
|
204
|
-
/** Present only when the world's mount
|
|
205
|
-
*
|
|
206
|
-
* as
|
|
207
|
-
*
|
|
208
|
-
* typing unchanged. */
|
|
210
|
+
/** Present only when the world's mount owns a Rapier-2D world — the 2D
|
|
211
|
+
* analog of `physics` above. Kept as a separate field (not folded into
|
|
212
|
+
* `physics` as a union) so 3D call sites keep their non-union
|
|
213
|
+
* `PhysicsRegistry` typing unchanged. */
|
|
209
214
|
readonly physics2d?: Physics2DRegistry | undefined;
|
|
210
|
-
readonly collisions2d?: Collision2DSystem | undefined;
|
|
211
215
|
/** Kind-typed via `mounted` in T7.5; `unknown` here deliberately. */
|
|
212
216
|
readonly camera?: unknown;
|
|
213
217
|
/** Phase-partitioned frame entry point (T7.1 slice 2) — present only for
|
|
@@ -215,55 +219,54 @@ export interface WorldInstance {
|
|
|
215
219
|
* `GameInternal.runFrame` drives via its single `mounted.update` call
|
|
216
220
|
* instead (unless it `drivesOwnLoop`, in which case it isn't ticked at
|
|
217
221
|
* all — see `runFrame`). */
|
|
218
|
-
readonly frame?:
|
|
222
|
+
readonly frame?: RootFrameHooks | undefined;
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
/**
|
|
222
|
-
* Minimal identity surface `
|
|
223
|
-
* field's doc comment for why it's narrower than `
|
|
224
|
-
* adapter object (a NAMED type, not a fresh object literal) — `
|
|
225
|
-
* `
|
|
226
|
+
* Minimal identity surface `RootInstance.adapter` needs (T7.5) — see that
|
|
227
|
+
* field's doc comment for why it's narrower than `RootAdapter<K>`. Any real
|
|
228
|
+
* adapter object (a NAMED type, not a fresh object literal) — `RootAdapter<K>`,
|
|
229
|
+
* `Pixi2DRootAdapter`, `ReactRootAdapter`, or a project's own custom adapter —
|
|
226
230
|
* satisfies this trivially (extra members beyond `id` are always fine for a
|
|
227
231
|
* non-literal source); a bare `{ id, mount }` object literal built INLINE at
|
|
228
|
-
* a `
|
|
232
|
+
* a `createRootInstance`/`registerXRoot` call site needs an intermediate
|
|
229
233
|
* `const` (excess-property checking only special-cases fresh literals).
|
|
230
234
|
*/
|
|
231
235
|
export interface AdapterHandle {
|
|
232
236
|
readonly id: string;
|
|
233
237
|
}
|
|
234
238
|
|
|
235
|
-
/** Inputs to {@link
|
|
236
|
-
export interface
|
|
239
|
+
/** Inputs to {@link createRootInstance}. */
|
|
240
|
+
export interface RootInstanceInit {
|
|
237
241
|
readonly id: string;
|
|
238
242
|
readonly kind: AdapterSurface;
|
|
239
243
|
/** Defaults to `true` (D10's per-world default). */
|
|
240
244
|
readonly pausable?: boolean;
|
|
241
245
|
readonly adapter: AdapterHandle;
|
|
242
|
-
readonly mounted:
|
|
243
|
-
/** Required when `kind === '
|
|
246
|
+
readonly mounted: MountedRoot;
|
|
247
|
+
/** Required when `kind === 'three'` — backs `threeScene()`. */
|
|
244
248
|
readonly scene?: THREE.Scene | undefined;
|
|
245
|
-
/** Required when `kind === '
|
|
246
|
-
* symmetric with `scene` for
|
|
249
|
+
/** Required when `kind === 'canvas'` — backs `pixiStage()` (T7.3 slice 2,
|
|
250
|
+
* symmetric with `scene` for three above). */
|
|
247
251
|
readonly stage?: PIXI.Container | undefined;
|
|
248
|
-
/** Required when `kind === '
|
|
252
|
+
/** Required when `kind === 'dom'` — backs `reactRoot()` (T6.2 slice 1,
|
|
249
253
|
* symmetric with `scene`/`stage` above): the DOM-root layer `<div>` this
|
|
250
254
|
* world's react tree is mounted into. */
|
|
251
255
|
readonly container?: HTMLElement | undefined;
|
|
252
256
|
readonly physics?: PhysicsRegistry | undefined;
|
|
253
257
|
readonly collisions?: CollisionSystem | undefined;
|
|
254
258
|
readonly physics2d?: Physics2DRegistry | undefined;
|
|
255
|
-
readonly collisions2d?: Collision2DSystem | undefined;
|
|
256
259
|
readonly camera?: unknown;
|
|
257
|
-
readonly frame?:
|
|
260
|
+
readonly frame?: RootFrameHooks | undefined;
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
/**
|
|
261
|
-
* Build a `
|
|
262
|
-
* on kind mismatch
|
|
263
|
-
* `
|
|
264
|
+
* Build a `RootInstance` whose kind-narrowed accessors throw descriptively
|
|
265
|
+
* on kind mismatch. This is the one place that builds
|
|
266
|
+
* `threeScene`/`pixiStage`, so every world (however it's constructed, in this
|
|
264
267
|
* slice or later ones) gets identical throw behavior.
|
|
265
268
|
*/
|
|
266
|
-
export function
|
|
269
|
+
export function createRootInstance(init: RootInstanceInit): RootInstance {
|
|
267
270
|
const {
|
|
268
271
|
id,
|
|
269
272
|
kind,
|
|
@@ -276,50 +279,48 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
|
|
|
276
279
|
physics,
|
|
277
280
|
collisions,
|
|
278
281
|
physics2d,
|
|
279
|
-
collisions2d,
|
|
280
282
|
camera,
|
|
281
283
|
frame,
|
|
282
284
|
} = init;
|
|
283
285
|
|
|
284
|
-
// A
|
|
285
|
-
// fail loudly HERE, at construction, rather than letting `
|
|
286
|
-
// throw its generic "not a
|
|
287
|
-
// kind IS
|
|
288
|
-
if (kind === '
|
|
286
|
+
// A three world with no scene has nothing for `threeScene()` to return —
|
|
287
|
+
// fail loudly HERE, at construction, rather than letting `threeScene()`
|
|
288
|
+
// throw its generic "not a three world" message later for a world whose
|
|
289
|
+
// kind IS three (checklist item 5; a misleading error for this case).
|
|
290
|
+
if (kind === 'three' && !scene) {
|
|
289
291
|
throw new Error(
|
|
290
|
-
`
|
|
291
|
-
'pass `scene` in
|
|
292
|
+
`RootInstance "${id}" (kind: three): a three world requires a scene — ` +
|
|
293
|
+
'pass `scene` in RootInstanceInit.',
|
|
292
294
|
);
|
|
293
295
|
}
|
|
294
296
|
|
|
295
|
-
// Symmetric check for
|
|
296
|
-
// has nothing for `
|
|
297
|
-
// construction, same as the
|
|
298
|
-
if (kind === '
|
|
297
|
+
// Symmetric check for canvas (T7.3 slice 2) — a canvas world with no stage
|
|
298
|
+
// has nothing for `pixiStage()` to return; fail loudly here, at
|
|
299
|
+
// construction, same as the three/scene check above.
|
|
300
|
+
if (kind === 'canvas' && !stage) {
|
|
299
301
|
throw new Error(
|
|
300
|
-
`
|
|
301
|
-
'pass `stage` in
|
|
302
|
+
`RootInstance "${id}" (kind: canvas): a canvas world requires a stage — ` +
|
|
303
|
+
'pass `stage` in RootInstanceInit.',
|
|
302
304
|
);
|
|
303
305
|
}
|
|
304
306
|
|
|
305
|
-
// Symmetric check for react (T6.2 slice 1
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
if (kind === 'react' && !container) {
|
|
307
|
+
// Symmetric check for react (T6.2 slice 1) — a react world with no
|
|
308
|
+
// container has nothing for `reactRoot()` to return; fail loudly here,
|
|
309
|
+
// at construction, same as the two checks above.
|
|
310
|
+
if (kind === 'dom' && !container) {
|
|
310
311
|
throw new Error(
|
|
311
|
-
`
|
|
312
|
-
'pass `container` in
|
|
312
|
+
`RootInstance "${id}" (kind: react): a react world requires a container — ` +
|
|
313
|
+
'pass `container` in RootInstanceInit.',
|
|
313
314
|
);
|
|
314
315
|
}
|
|
315
316
|
|
|
316
|
-
// Disposed-world guard (
|
|
317
|
-
// is no `
|
|
317
|
+
// Disposed-world guard (T7.1 slice 3). There
|
|
318
|
+
// is no `unregisterRoot` (decision 4 — roots are manifest-declared; a
|
|
318
319
|
// whole Game is disposed, not one world out of its registry), so a caller
|
|
319
320
|
// that disposes ONE world's `mounted` directly (e.g. ending a sub-session)
|
|
320
|
-
// leaves that `
|
|
321
|
+
// leaves that `RootInstance` sitting in `Game.roots` — and `runFrame`
|
|
321
322
|
// would otherwise keep invoking its (now-torn-down) frame hooks every
|
|
322
|
-
// subsequent frame. `
|
|
323
|
+
// subsequent frame. `MountedThreeRoot` has no public "am I disposed" flag to
|
|
323
324
|
// read, so this wraps `mounted.dispose` in place (mutating the SAME mount
|
|
324
325
|
// object every holder of `mounted` shares — calling `mounted.dispose()`
|
|
325
326
|
// directly, exactly like calling `world.mounted.dispose()`, trips this)
|
|
@@ -334,7 +335,7 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
|
|
|
334
335
|
disposed = true;
|
|
335
336
|
originalDispose();
|
|
336
337
|
};
|
|
337
|
-
const guardedFrame:
|
|
338
|
+
const guardedFrame: RootFrameHooks | undefined = frame
|
|
338
339
|
? {
|
|
339
340
|
runPhase(phase, dt) {
|
|
340
341
|
if (disposed) return;
|
|
@@ -356,26 +357,25 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
|
|
|
356
357
|
physics,
|
|
357
358
|
collisions,
|
|
358
359
|
physics2d,
|
|
359
|
-
collisions2d,
|
|
360
360
|
camera,
|
|
361
361
|
frame: guardedFrame,
|
|
362
|
-
|
|
363
|
-
if (kind !== '
|
|
362
|
+
threeScene(): THREE.Scene {
|
|
363
|
+
if (kind !== 'three' || !scene) {
|
|
364
364
|
throw new Error(
|
|
365
|
-
`
|
|
366
|
-
'a
|
|
365
|
+
`RootInstance "${id}" (kind: ${kind}): threeScene() requested but this world is not ` +
|
|
366
|
+
'a three world',
|
|
367
367
|
);
|
|
368
368
|
}
|
|
369
369
|
return scene;
|
|
370
370
|
},
|
|
371
|
-
|
|
372
|
-
// The `!stage` branch a
|
|
373
|
-
// (T7.3 slice 2): construction above throws for `kind === '
|
|
374
|
-
// with no `stage`, symmetric with `
|
|
375
|
-
if (kind !== '
|
|
371
|
+
pixiStage(): PIXI.Container {
|
|
372
|
+
// The `!stage` branch a canvas world could hit here is now unreachable
|
|
373
|
+
// (T7.3 slice 2): construction above throws for `kind === 'canvas'`
|
|
374
|
+
// with no `stage`, symmetric with `threeScene()`'s `scene` guard.
|
|
375
|
+
if (kind !== 'canvas' || !stage) {
|
|
376
376
|
throw new Error(
|
|
377
|
-
`
|
|
378
|
-
'a
|
|
377
|
+
`RootInstance "${id}" (kind: ${kind}): pixiStage() requested but this world is not ` +
|
|
378
|
+
'a canvas world',
|
|
379
379
|
);
|
|
380
380
|
}
|
|
381
381
|
return stage;
|
|
@@ -383,11 +383,11 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
|
|
|
383
383
|
reactRoot(): HTMLElement {
|
|
384
384
|
// The `!container` branch a react world could hit here is now
|
|
385
385
|
// unreachable (T6.2 slice 1): construction above throws for
|
|
386
|
-
// `kind === '
|
|
387
|
-
// `
|
|
388
|
-
if (kind !== '
|
|
386
|
+
// `kind === 'dom'` with no `container`, symmetric with
|
|
387
|
+
// `threeScene()`/`pixiStage()`'s guards.
|
|
388
|
+
if (kind !== 'dom' || !container) {
|
|
389
389
|
throw new Error(
|
|
390
|
-
`
|
|
390
|
+
`RootInstance "${id}" (kind: ${kind}): reactRoot() requested but this world is not ` +
|
|
391
391
|
'a react world',
|
|
392
392
|
);
|
|
393
393
|
}
|
|
@@ -397,40 +397,131 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
|
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
/**
|
|
400
|
-
* Type guard for whether a `
|
|
401
|
-
* `
|
|
400
|
+
* Type guard for whether a `MountedThreeRoot` is a first-party
|
|
401
|
+
* `SetupThreeRootAdapter` mount (has a live `GameContext` at `.ctx`). Used to
|
|
402
402
|
* decide whether a world's `physics`/`collisions`/`camera` (and, on `Game`,
|
|
403
|
-
* `
|
|
403
|
+
* `input`/`audio`) can be populated from it — an external
|
|
404
404
|
* adapter's mount has none of these first-party handles.
|
|
405
405
|
*
|
|
406
406
|
* Checks the `firstParty: true` brand (checklist item 1), NOT `'ctx' in
|
|
407
|
-
* mounted` —
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
* `instanceof`/value import of `vgai-scene-game-adapter.ts` — the
|
|
412
|
-
* `VgaiMountedGame` import above stays type-only.
|
|
407
|
+
* mounted` — a foreign mount may carry an unrelated `ctx` key, so a
|
|
408
|
+
* structural `'ctx' in mounted` check would misfire. The probe is a plain property read, not an
|
|
409
|
+
* `instanceof`/value import of `setup-three-root-adapter.ts` — the
|
|
410
|
+
* `MountedSetupThreeRoot` import above stays type-only.
|
|
413
411
|
*/
|
|
414
|
-
export function isFirstPartyMounted(mounted:
|
|
412
|
+
export function isFirstPartyMounted(mounted: MountedRoot): mounted is MountedSetupThreeRoot {
|
|
415
413
|
return (mounted as { firstParty?: unknown }).firstParty === true;
|
|
416
414
|
}
|
|
417
415
|
|
|
418
416
|
/**
|
|
419
|
-
*
|
|
417
|
+
* One world's half of a debris disposal, as {@link disposeDebrisSubtree} needs
|
|
418
|
+
* it. A first-party `GameContext` satisfies this structurally.
|
|
419
|
+
*
|
|
420
|
+
* `physics`/`rapierWorld` are OPTIONAL: an owner that builds no first-party
|
|
421
|
+
* Rapier runtime (an R3F world) carries neither, and {@link
|
|
422
|
+
* disposeDebrisSubtree} reads them behind `readRapierHandles` rather than
|
|
423
|
+
* unconditionally.
|
|
424
|
+
*/
|
|
425
|
+
export interface DebrisOwner {
|
|
426
|
+
readonly physics?: GameContext['physics'];
|
|
427
|
+
readonly rapierWorld?: GameContext['rapierWorld'];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Read an owner's Rapier handles, treating an absent pair as "this owner holds
|
|
432
|
+
* no rigid bodies" — the honest reading for a world (R3F, or a bare
|
|
433
|
+
* `ComponentManager`-only owner) that builds no first-party physics runtime.
|
|
434
|
+
*/
|
|
435
|
+
function readRapierHandles(owner: DebrisOwner): {
|
|
436
|
+
physics: NonNullable<DebrisOwner['physics']>;
|
|
437
|
+
rapierWorld: GameContext['rapierWorld'];
|
|
438
|
+
} | null {
|
|
439
|
+
const { physics, rapierWorld } = owner;
|
|
440
|
+
if (!physics || !rapierWorld) return null;
|
|
441
|
+
return { physics, rapierWorld };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* P3 — what `SimClock.disposeAfter` actually does. `core/sim-clock.ts` knows
|
|
446
|
+
* only *when*; this is the *what*, and it lives here because it is the runtime
|
|
447
|
+
* that knows about Rapier and shared geometry.
|
|
448
|
+
*
|
|
449
|
+
* The order mirrors `adapter/setup-three-root-adapter.ts`'s world teardown for
|
|
450
|
+
* ONE subtree. Within the physics step,
|
|
451
|
+
* `rapierWorld.removeRigidBody(body)` comes BEFORE `physics.remove(node)`: the
|
|
452
|
+
* registry's `remove()` only drops index entries, so reversing the two leaks
|
|
453
|
+
* the Rapier body.
|
|
454
|
+
*
|
|
455
|
+
* `owners` is every world that could own part of the subtree — `createGame`
|
|
456
|
+
* passes all of its first-party three roots as their `GameContext`, a bare
|
|
457
|
+
* adapter mount passes its own ctx. `physics.get` is a no-op for a node the
|
|
458
|
+
* owner does not own, so offering the subtree to each is safe.
|
|
459
|
+
*
|
|
460
|
+
* Safe on an object already removed or already disposed: `removeFromParent` on
|
|
461
|
+
* a parentless `Object3D` is a no-op and three's `dispose()` calls are
|
|
462
|
+
* idempotent.
|
|
463
|
+
*/
|
|
464
|
+
export function disposeDebrisSubtree(
|
|
465
|
+
obj: THREE.Object3D,
|
|
466
|
+
owners: ReadonlyArray<DebrisOwner>,
|
|
467
|
+
): void {
|
|
468
|
+
const nodes: THREE.Object3D[] = [];
|
|
469
|
+
obj.traverse((node) => nodes.push(node));
|
|
470
|
+
for (const owner of owners) {
|
|
471
|
+
const rapier = readRapierHandles(owner);
|
|
472
|
+
if (!rapier) continue;
|
|
473
|
+
for (const node of nodes) {
|
|
474
|
+
const refs = rapier.physics.get(node);
|
|
475
|
+
if (!refs) continue;
|
|
476
|
+
rapier.rapierWorld.removeRigidBody(refs.body);
|
|
477
|
+
rapier.physics.remove(node);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
obj.removeFromParent();
|
|
481
|
+
for (const node of nodes) {
|
|
482
|
+
// Duck-typed rather than `instanceof THREE.Mesh` so this module keeps its
|
|
483
|
+
// TYPE-ONLY three import (it is surface-neutral — it also hosts pixi
|
|
484
|
+
// roots). Covers Points/Line/Sprite debris too, which a Mesh check would
|
|
485
|
+
// silently leak.
|
|
486
|
+
const drawable = node as Partial<THREE.Mesh>;
|
|
487
|
+
if (!hasUserData(node, '__sharedGeometry')) drawable.geometry?.dispose();
|
|
488
|
+
const material = drawable.material;
|
|
489
|
+
if (Array.isArray(material)) {
|
|
490
|
+
for (const m of material) m.dispose();
|
|
491
|
+
} else {
|
|
492
|
+
material?.dispose();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The {@link DebrisOwner}s one registered world contributes: its `GameContext`
|
|
499
|
+
* when the mount is first-party, else nothing. Written as a standalone function
|
|
500
|
+
* so `createGame`'s clock disposer is one line and the "which mounts count"
|
|
501
|
+
* rule has exactly one home.
|
|
502
|
+
*/
|
|
503
|
+
function debrisOwnersOf(world: RootInstance): DebrisOwner[] {
|
|
504
|
+
return isFirstPartyMounted(world.mounted) ? [world.mounted.ctx] : [];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The Game root. Owns the one loop, the raw-asset
|
|
420
509
|
* cache, the world registry, and (T7.1 slice 2) the game-scoped
|
|
421
|
-
* `SystemRunner`. `
|
|
510
|
+
* `SystemRunner`. `input`/`audio` remain slice-1 late
|
|
422
511
|
* additions: they delegate to the default world's first-party mount so
|
|
423
512
|
* existing single-world call sites keep working; hoisting them to true
|
|
424
|
-
* Game ownership (
|
|
513
|
+
* Game ownership (the target shape) is a later slice's
|
|
425
514
|
* work, not this one's.
|
|
426
515
|
*/
|
|
427
516
|
export interface Game {
|
|
428
517
|
readonly loop: GameLoop;
|
|
429
518
|
readonly assets: AssetCache;
|
|
519
|
+
/** Host identity for this private play run or coordinated Team Test. */
|
|
520
|
+
readonly playtest?: PlaytestContext | null;
|
|
430
521
|
/** Per-game diagnostic store. Disabled by default; the editor enables it on demand. */
|
|
431
522
|
readonly profiler: PerformanceProfiler;
|
|
432
523
|
/**
|
|
433
|
-
* The game-scoped `SystemRunner` (
|
|
524
|
+
* The game-scoped `SystemRunner` (T7.1 slice 2) — a
|
|
434
525
|
* NEW bucket, separate from any world's own runner. Within each phase,
|
|
435
526
|
* `GameInternal.runFrame` runs THIS runner's `runPhase` first, before any
|
|
436
527
|
* world's engine systems/component ticks/world-bound game systems (e.g.
|
|
@@ -441,111 +532,114 @@ export interface Game {
|
|
|
441
532
|
*/
|
|
442
533
|
readonly systems: SystemRunner;
|
|
443
534
|
/** Declaration-ordered. Slice 1 registers exactly one (the default
|
|
444
|
-
*
|
|
535
|
+
* three world) — this is the SAME array reference `registerRoot`
|
|
445
536
|
* mutates, not a snapshot, so holders (e.g. `GameContext.roots`) observe
|
|
446
537
|
* later registrations. */
|
|
447
|
-
readonly roots: ReadonlyArray<
|
|
448
|
-
world(id: string):
|
|
449
|
-
/** First
|
|
538
|
+
readonly roots: ReadonlyArray<RootInstance>;
|
|
539
|
+
world(id: string): RootInstance | null;
|
|
540
|
+
/** First three world, else first world. Throws descriptively when no
|
|
450
541
|
* world has been registered yet. */
|
|
451
|
-
readonly
|
|
542
|
+
readonly defaultRoot: RootInstance;
|
|
452
543
|
/**
|
|
453
|
-
* Game-scoped aggregation of every world's `SystemAdapters` (§7.1-3
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* `
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* deliberately chosen not to collide with it.
|
|
544
|
+
* Game-scoped aggregation of every world's `SystemAdapters` (§7.1-3:
|
|
545
|
+
* "`registerSystemAdapter` is game-scoped" — the recorded decision this
|
|
546
|
+
* getter finally implements; probe1). Each mounted world builds its OWN
|
|
547
|
+
* `mounted.systems` object (a game registers capabilities like `networking`
|
|
548
|
+
* from ITS OWN `setup()`, via `ctx.registerSystemAdapter`, per-world) —
|
|
549
|
+
* this merges every world's `mounted.systems` into ONE `SystemAdapters`, in
|
|
550
|
+
* `roots` REGISTRATION order, first registration wins per key. A later
|
|
551
|
+
* world registering the SAME kind (e.g. two roots both exposing
|
|
552
|
+
* `networking`) does not override the first — instead this warns ONCE per
|
|
553
|
+
* (game instance, key) naming both world ids, matching this file's `[game]
|
|
554
|
+
* world "<id>" …` console idiom (see `registerRoot` below). NOT to be
|
|
555
|
+
* confused with `Game.systems` (the game-scoped `SystemRunner` bucket, an
|
|
556
|
+
* entirely different concept — see that field's doc comment) — this name
|
|
557
|
+
* was deliberately chosen not to collide with it.
|
|
468
558
|
*
|
|
469
559
|
* A plain getter (recomputed on every read, not cached) so a LATER
|
|
470
|
-
* `
|
|
560
|
+
* `registerRoot` call (or a world's setup registering a NEW adapter kind
|
|
471
561
|
* after this was first read) is always reflected — only the COLLISION
|
|
472
562
|
* warning is deduped (once per key, for the lifetime of this `Game`).
|
|
473
563
|
* Includes every world regardless of first-party-ness — `mounted.systems`
|
|
474
564
|
* is a capability any adapter (first-party or foreign) may expose.
|
|
475
565
|
*/
|
|
476
566
|
readonly systemAdapters: SystemAdapters;
|
|
477
|
-
/**
|
|
478
|
-
|
|
479
|
-
readonly components: ComponentManager;
|
|
567
|
+
/** Subscribe when a mounted root registers or replaces a system adapter. */
|
|
568
|
+
subscribeSystemAdapters?(listener: () => void): () => void;
|
|
480
569
|
/** The one game-owned `InputManager`, shared by every first-party root. */
|
|
481
570
|
readonly input: InputManager;
|
|
482
571
|
/** Delegates to the default world's first-party audio context. Throws
|
|
483
572
|
* when the default world is not a first-party mount. */
|
|
484
573
|
readonly audio: GameAudio;
|
|
485
574
|
/**
|
|
486
|
-
* Frame-versioned state bridge (T7.4 slice 1
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
* has no react import, matching the rest of `runtime/`.
|
|
575
|
+
* Frame-versioned state bridge (T7.4 slice 1). Bumped once per completed
|
|
576
|
+
* `runFrame`, after all phases of all roots and all `endFrame` hooks (see
|
|
577
|
+
* `runFrame`'s tail below). This is the ONE subscription surface
|
|
578
|
+
* `useWorldState` (`@vgai/engine/react/world-state`) — or any other
|
|
579
|
+
* frame-versioned consumer — subscribes to; `state-bridge.ts` itself has no
|
|
580
|
+
* react import, matching the rest of `runtime/`.
|
|
493
581
|
*/
|
|
494
582
|
readonly state: GameStateBridge;
|
|
495
583
|
/** Game-level play-state control surface (D10, T7.6) — see {@link PlayState}. */
|
|
496
584
|
readonly play: PlayState;
|
|
497
585
|
/**
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
586
|
+
* WO-P13 — the interpolation alpha the most recent display frame presented
|
|
587
|
+
* at: `accumulator / fixedDt`, in `[0, 1]`. `0` means "exactly on the last
|
|
588
|
+
* completed fixed state", `0.5` means "halfway to the next one".
|
|
501
589
|
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
590
|
+
* Read it to interpolate your own transforms between the last two fixed
|
|
591
|
+
* states; it is the same number `onRenderStep` hands its callbacks, exposed
|
|
592
|
+
* here for code that renders from somewhere other than a callback. Stays
|
|
593
|
+
* `0` for a Game whose host never wired a display-rate render pass (see
|
|
594
|
+
* {@link onRenderStep}).
|
|
595
|
+
*/
|
|
596
|
+
readonly renderAlpha: number;
|
|
597
|
+
/**
|
|
598
|
+
* WO-P13 — the `RenderStepped`-shaped host: register a callback that runs
|
|
599
|
+
* ONCE PER DISPLAY FRAME, before that frame's `preRender`/`render` phases,
|
|
600
|
+
* with `(alpha, displayDt)`.
|
|
601
|
+
*
|
|
602
|
+
* This is the seam for presentation-only work whose natural rate is the
|
|
603
|
+
* monitor's, not the simulation's — camera polish/smoothing, procedural
|
|
604
|
+
* sway, a cosmetic bob. It is NOT a gameplay hook: it can fire twice
|
|
605
|
+
* between two fixed substeps (120 Hz display, 60 Hz sim) and zero times
|
|
606
|
+
* across a `runTicks` fast-forward, so anything that must be deterministic
|
|
607
|
+
* belongs in a fixed phase (`ctx.systems.add`, a `useFrame` hook, a
|
|
608
|
+
* `useFrame`) instead.
|
|
513
609
|
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* signature keep working unchanged once the manager is unified
|
|
521
|
-
* underneath.
|
|
610
|
+
* Reached from game code as `ctx.game?.onRenderStep(...)`. Returns an
|
|
611
|
+
* unsubscribe function; pass `opts.signal` to unsubscribe with the same
|
|
612
|
+
* `AbortSignal` a `setup()` already uses for its listeners and sim timers
|
|
613
|
+
* (`core/sim-clock.ts`'s ownership section — cancelling what you registered
|
|
614
|
+
* is the game's job, and a stale render-step closure is the same hazard
|
|
615
|
+
* class as a stale event listener).
|
|
522
616
|
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* `
|
|
527
|
-
* subclass must type-check here exactly like a default-kind one does.
|
|
617
|
+
* Silent-no-op honesty: a callback registered on a Game whose host wired no
|
|
618
|
+
* display-rate render pass (a bare test harness, a `drivesOwnLoop`-only
|
|
619
|
+
* mount, an `externalDrive` capture page) never fires, because nothing
|
|
620
|
+
* drives it. That is the same shape as `ctx.clock` on a Game-less mount.
|
|
528
621
|
*/
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
opts?: {
|
|
532
|
-
):
|
|
622
|
+
onRenderStep(
|
|
623
|
+
fn: (alpha: number, displayDt: number) => void,
|
|
624
|
+
opts?: { signal?: AbortSignal | undefined },
|
|
625
|
+
): () => void;
|
|
533
626
|
}
|
|
534
627
|
|
|
535
628
|
/**
|
|
536
|
-
* Host-internal extension of {@link Game}: adds `
|
|
629
|
+
* Host-internal extension of {@link Game}: adds `registerRoot` (the host's
|
|
537
630
|
* wiring surface for populating the world registry) and `runFrame` (the
|
|
538
631
|
* frame executor). NEITHER is part of the game-facing `Game` surface —
|
|
539
|
-
* games
|
|
632
|
+
* games never call either directly; the host loop
|
|
540
633
|
* (`createGameRuntime`) and `GameSession.step()` are the only callers of
|
|
541
634
|
* `runFrame`.
|
|
542
635
|
*/
|
|
543
636
|
export interface GameInternal extends Game {
|
|
637
|
+
/** Host-side signal used by root contexts after `registerSystemAdapter`. */
|
|
638
|
+
notifySystemAdaptersChanged(): void;
|
|
544
639
|
/** Append a world in declaration order. Throws on a duplicate id. */
|
|
545
|
-
|
|
640
|
+
registerRoot(world: RootInstance): void;
|
|
546
641
|
/**
|
|
547
|
-
* Run ONE fixed substep across every phase and every world
|
|
548
|
-
* (GAME-ROOT-DESIGN §4):
|
|
642
|
+
* Run ONE fixed substep across every phase and every world:
|
|
549
643
|
*
|
|
550
644
|
* ```
|
|
551
645
|
* for phase in PHASE_ORDER:
|
|
@@ -585,19 +679,55 @@ export interface GameInternal extends Game {
|
|
|
585
679
|
* `endFrame` with the real `dt` (not the render-phase's forced `0`), and
|
|
586
680
|
* touches no other world at all — a natural no-op while not paused, since
|
|
587
681
|
* the frozen set is then empty.
|
|
682
|
+
*
|
|
683
|
+
* WO-P13 addendum: `opts.skipRenderPhases` omits `preRender`+`render` from
|
|
684
|
+
* the pass — every other phase, and `endFrame`, run exactly as always. A
|
|
685
|
+
* host that drives presentation at DISPLAY rate sets it on every substep
|
|
686
|
+
* and calls {@link runRenderFrame} once per real frame instead
|
|
687
|
+
* (`create-runtime.ts`). A host that does
|
|
688
|
+
* not set it keeps the pre-WO-P13 shape verbatim — which is why
|
|
689
|
+
* `externalDrive` capture (`render-control.ts`'s `simulateSubsteps`, which
|
|
690
|
+
* calls `runFrame(fixedDt)` with no opts) is frame-exact and unchanged.
|
|
691
|
+
* `runTicks` sets it per tick for its `render: 'none' | 'last'` modes.
|
|
692
|
+
*/
|
|
693
|
+
runFrame(dt: number, opts?: { ignorePause?: boolean; skipRenderPhases?: boolean }): void;
|
|
694
|
+
/**
|
|
695
|
+
* WO-P13 — run ONE display frame's presentation pass: the registered
|
|
696
|
+
* `onRenderStep` callbacks, then the `preRender` and `render` phases across
|
|
697
|
+
* game-scoped systems and every host-driven world, in the same
|
|
698
|
+
* game-systems-then-worlds order {@link runFrame} uses.
|
|
699
|
+
*
|
|
700
|
+
* ```
|
|
701
|
+
* for cb in onRenderStep callbacks: cb(alpha, displayDt)
|
|
702
|
+
* for phase in [preRender, render]:
|
|
703
|
+
* game.systems.runPhase(phase, displayDt)
|
|
704
|
+
* for world in roots (declaration order):
|
|
705
|
+
* if world.mounted.drivesOwnLoop: continue
|
|
706
|
+
* world.frame?.runPhase(phase, frozen ? 0 : displayDt)
|
|
707
|
+
* ```
|
|
708
|
+
*
|
|
709
|
+
* Advances NOTHING: no `tick`, no `simT`, no sim-clock flush, no state-bridge
|
|
710
|
+
* bump, no `endFrame`. It is presentation only, which is what makes calling
|
|
711
|
+
* it at a rate the simulation does not share safe in the first place.
|
|
712
|
+
*
|
|
713
|
+
* D10's pause rule carries over unchanged in substance: a frozen world
|
|
714
|
+
* (paused + `pausable`) still RENDERS — that is what keeps a paused editor
|
|
715
|
+
* viewport painted — but with `dt` forced to `0`, so no time-based render
|
|
716
|
+
* effect animates a frozen scene. What changes is only the cadence: once per
|
|
717
|
+
* display frame instead of once per fixed substep.
|
|
588
718
|
*/
|
|
589
|
-
|
|
719
|
+
runRenderFrame(alpha: number, displayDt: number): void;
|
|
590
720
|
/**
|
|
591
|
-
* D15/T-D15.3-.4
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
* `
|
|
599
|
-
*
|
|
600
|
-
*
|
|
721
|
+
* D15/T-D15.3-.4 — a deterministic fast-forward primitive: synchronously
|
|
722
|
+
* call the SAME per-tick pipeline `runFrame` uses, `n` times in a tight
|
|
723
|
+
* loop, with `dt` fixed to the host loop's own fixed timestep
|
|
724
|
+
* (`this.loop.fixedDt` — `core/game-loop.ts`). This generalizes
|
|
725
|
+
* `render-control.ts`'s proven `VgaiRenderHarness. simulateSubsteps` from
|
|
726
|
+
* the capture-only door (`?vgai-render=1`) to a Game-level primitive every
|
|
727
|
+
* door can reach (the bridge's `window.__vgai.runTicks`, the editor relay's
|
|
728
|
+
* `run-ticks` case → `play.runTicks`) — `simulateSubsteps` itself is
|
|
729
|
+
* UNTOUCHED by this addition (it may later delegate to this method; not
|
|
730
|
+
* this unit's job).
|
|
601
731
|
*
|
|
602
732
|
* Semantics:
|
|
603
733
|
* - **Decoupled from wall clock and the accumulator.** `runTicks` drives
|
|
@@ -620,7 +750,7 @@ export interface GameInternal extends Game {
|
|
|
620
750
|
* never renders, not even the last tick. `tick`/`simT`/
|
|
621
751
|
* `stateBridge.bump()`/the debug event ring advance identically on
|
|
622
752
|
* EVERY tick regardless of `render` — only the paint-affecting phases
|
|
623
|
-
* are skipped, so state watchers (`
|
|
753
|
+
* are skipped, so state watchers (`useWorldState`, debug state providers)
|
|
624
754
|
* stay correct even when fast-forwarding with no visible output.
|
|
625
755
|
* - **Refuses while paused.** Throws a structured `DebugError`
|
|
626
756
|
* (`code: 'RUN_TICKS_PAUSED'`) if `Game.play.paused` is true —
|
|
@@ -637,8 +767,8 @@ export interface GameInternal extends Game {
|
|
|
637
767
|
runTicks(n: number, opts?: RunTicksOptions): void;
|
|
638
768
|
/**
|
|
639
769
|
* ADAPTER-MOUNT surface, not game-facing: an adapter's `mount()` calls
|
|
640
|
-
* this (via `host.game` — the classic `
|
|
641
|
-
* `
|
|
770
|
+
* this (via `host.game` — the classic `SetupThreeRootAdapter` and the R3F
|
|
771
|
+
* `createR3FRootContext` both do) to load the game-owned input map ONCE,
|
|
642
772
|
* even when several roots ask for the same path; competing paths throw
|
|
643
773
|
* (input is game-owned, so roots cannot load competing maps). GAME code
|
|
644
774
|
* never calls this — a project's map loads automatically at mount from
|
|
@@ -651,7 +781,7 @@ export interface GameInternal extends Game {
|
|
|
651
781
|
dispose(): void;
|
|
652
782
|
}
|
|
653
783
|
|
|
654
|
-
function describeMismatch(handle: '
|
|
784
|
+
function describeMismatch(handle: 'input' | 'audio', world: RootInstance): string {
|
|
655
785
|
return (
|
|
656
786
|
`Game.${handle}: default world "${world.id}" (kind: ${world.kind}) is not a first-party ` +
|
|
657
787
|
"mount — hoisting these to true Game ownership is a later slice's work; available only " +
|
|
@@ -662,31 +792,32 @@ function describeMismatch(handle: 'components' | 'input' | 'audio', world: World
|
|
|
662
792
|
/**
|
|
663
793
|
* Construct the (host-internal) Game shell. Callers: `createGameRuntime`
|
|
664
794
|
* builds this BEFORE mounting its one adapter, then registers the default
|
|
665
|
-
*
|
|
795
|
+
* three world once mount resolves (see `registerThreeRoot` in
|
|
666
796
|
* `create-runtime.ts`).
|
|
667
797
|
*/
|
|
668
798
|
export function createGame(opts: {
|
|
669
799
|
loop: GameLoop;
|
|
670
800
|
assets: AssetCache;
|
|
801
|
+
playtest?: PlaytestContext | null | undefined;
|
|
671
802
|
/** D15 (T-D15.1) — the root seed `ctx.random` boots from, on every world
|
|
672
803
|
* mounted onto this Game. Defaults to `DEFAULT_SEEDED_RANDOM_SEED` (a
|
|
673
804
|
* fixed, non-wall-clock constant — `ctx.random` is always reproducible
|
|
674
805
|
* on its own terms, whether or not the project's manifest DECLARES that
|
|
675
806
|
* reproducibility as a contract). The manifest-aware boot path
|
|
676
|
-
* (`mount-manifest.ts`'s `
|
|
807
|
+
* (`mount-manifest.ts`'s `mountManifestRoots`) is what actually resolves
|
|
677
808
|
* `manifest.determinism.defaultSeed`/`?vgai-seed=`/explicit config and
|
|
678
809
|
* passes the result here, BEFORE any world's `mount()`/`setup()` runs —
|
|
679
810
|
* `createGameRuntime` always constructs the Game (this call) first (see
|
|
680
811
|
* this function's own doc comment below). */
|
|
681
812
|
seed?: number | undefined;
|
|
682
813
|
}): GameInternal {
|
|
683
|
-
const roots:
|
|
814
|
+
const roots: RootInstance[] = [];
|
|
684
815
|
const profiler = createPerformanceProfiler();
|
|
685
816
|
const systems = createSystemRunner(profiler.systemObserver, 'game');
|
|
686
817
|
const input = new InputManager();
|
|
687
818
|
const stateBridge = createStateBridge();
|
|
688
819
|
// D15 (T-D15.1) — the game-scoped seeded-random surface every world's
|
|
689
|
-
// `ctx.random` aliases (see `
|
|
820
|
+
// `ctx.random` aliases (see `setup-three-root-adapter.ts`'s `ctx.random =
|
|
690
821
|
// ...`, wired the same way `ctx.debug` is just below). Constructed
|
|
691
822
|
// unconditionally (cheap — a handful of closures) regardless of whether
|
|
692
823
|
// this project ever declares `determinism.seededRandom`; only the BOOT
|
|
@@ -711,14 +842,39 @@ export function createGame(opts: {
|
|
|
711
842
|
return rngTrapEnabled;
|
|
712
843
|
},
|
|
713
844
|
};
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
845
|
+
// `tick` counts completed fixed substeps, `simT` accumulates their `dt` —
|
|
846
|
+
// both game-scoped, bumped ONLY where `stateBridge.bump()` is (guarded by
|
|
847
|
+
// `advanced`, `runFrame`'s tail below), so a paused/frozen frame never
|
|
848
|
+
// advances either. The debug registry reads them via suppliers (not by
|
|
849
|
+
// capturing the numbers now) so its built-in `time` provider always sees
|
|
850
|
+
// the CURRENT values.
|
|
720
851
|
let tick = 0;
|
|
721
852
|
let simT = 0;
|
|
853
|
+
// WO-P13 — the display-rate half. `renderAlpha` is the last alpha
|
|
854
|
+
// `runRenderFrameImpl` presented at (0 until a host drives one); the set is
|
|
855
|
+
// the `RenderStepped`-shaped registry `Game.onRenderStep` feeds. Deliberately
|
|
856
|
+
// NOT beside `tick`/`simT` in meaning: neither of these ever advances sim
|
|
857
|
+
// state, which is exactly what makes running them at the monitor's rate safe.
|
|
858
|
+
let renderAlpha = 0;
|
|
859
|
+
const renderStepCallbacks = new Set<(alpha: number, displayDt: number) => void>();
|
|
860
|
+
// P3 — the ONE sim clock this Game owns, declared beside the accumulator it
|
|
861
|
+
// is bound to (`flush(simT)` runs at the tail of `runFrameImpl`, inside the
|
|
862
|
+
// same `advanced` guard as the two bumps above, so a paused/frozen frame
|
|
863
|
+
// fires no timers). Every world's `ctx.clock` is THIS instance, reached the
|
|
864
|
+
// same way `ctx.random`/`ctx.debug` reach their game-scoped singletons —
|
|
865
|
+
// `getSimClock(host.game)` over the WeakMap filed below.
|
|
866
|
+
//
|
|
867
|
+
// The disposer is supplied HERE rather than inside the clock because
|
|
868
|
+
// `core/sim-clock.ts` deliberately knows nothing about Rapier or shared
|
|
869
|
+
// geometry. It mirrors `setup-three-root-adapter.ts`'s world teardown
|
|
870
|
+
// ordering for ONE subtree.
|
|
871
|
+
const simClock: SimClockInternal = createSimClock({
|
|
872
|
+
// Every mount that could own part of the subtree is offered it:
|
|
873
|
+
// `physics.get` is a no-op for a node the mount does not own, so this is
|
|
874
|
+
// correct with several three roots on one Game and needs no ownership
|
|
875
|
+
// bookkeeping.
|
|
876
|
+
dispose: (obj) => disposeDebrisSubtree(obj, roots.flatMap(debrisOwnersOf)),
|
|
877
|
+
});
|
|
722
878
|
const debugRegistry = createDebugRegistry({
|
|
723
879
|
getTick: () => tick,
|
|
724
880
|
getSimT: () => simT,
|
|
@@ -726,11 +882,11 @@ export function createGame(opts: {
|
|
|
726
882
|
// registry's world-addressed input-target compatibility surface. Every
|
|
727
883
|
// first-party root now registers the SAME game-owned InputManager, but the
|
|
728
884
|
// stable default id still keeps explicit/implicit debug routing coherent.
|
|
729
|
-
// Use the SAME "first
|
|
730
|
-
// `
|
|
885
|
+
// Use the SAME "first three world, else first world" rule
|
|
886
|
+
// `requireDefaultRoot` (declared just below — safe: this closure is
|
|
731
887
|
// only ever CALLED later, once at least one world has mounted) already
|
|
732
|
-
// defines for `Game.
|
|
733
|
-
|
|
888
|
+
// defines for `Game.defaultRoot`.
|
|
889
|
+
getDefaultRootId: () => (roots.length > 0 ? requireDefaultRoot().id : null),
|
|
734
890
|
// Issue #175 — the built-in `time` provider's `loopLiveness` field reads
|
|
735
891
|
// the REAL loop, not any UI-level play-state store: `opts.loop` is the
|
|
736
892
|
// SAME `GameLoop` this `Game`'s own `.loop` field exposes, so this
|
|
@@ -738,7 +894,6 @@ export function createGame(opts: {
|
|
|
738
894
|
// the loop is actually ticking.
|
|
739
895
|
getLoopLiveness: () => opts.loop.liveness,
|
|
740
896
|
});
|
|
741
|
-
const inputAdapter = createInputManagerAdapter(input);
|
|
742
897
|
let inputFrameActive = false;
|
|
743
898
|
systems.add(
|
|
744
899
|
'input',
|
|
@@ -747,19 +902,19 @@ export function createGame(opts: {
|
|
|
747
902
|
},
|
|
748
903
|
{ name: 'input.poll' },
|
|
749
904
|
);
|
|
750
|
-
input.setDebugEmit((event, detail) => debugRegistry.
|
|
905
|
+
input.setDebugEmit((event, detail) => debugRegistry.forRoot('(game)').emit(event, detail));
|
|
751
906
|
let inputMapPath: string | null = null;
|
|
752
907
|
let inputMapLoad: Promise<void> | null = null;
|
|
753
908
|
|
|
754
|
-
function
|
|
909
|
+
function requireDefaultRoot(): RootInstance {
|
|
755
910
|
if (roots.length === 0) {
|
|
756
|
-
throw new Error('Game.
|
|
911
|
+
throw new Error('Game.defaultRoot: no roots registered yet');
|
|
757
912
|
}
|
|
758
|
-
return roots.find((w) => w.kind === '
|
|
913
|
+
return roots.find((w) => w.kind === 'three') ?? roots[0]!;
|
|
759
914
|
}
|
|
760
915
|
|
|
761
|
-
function requireFirstPartyCtx(handle: '
|
|
762
|
-
const world =
|
|
916
|
+
function requireFirstPartyCtx(handle: 'input' | 'audio') {
|
|
917
|
+
const world = requireDefaultRoot();
|
|
763
918
|
if (!isFirstPartyMounted(world.mounted)) {
|
|
764
919
|
throw new Error(describeMismatch(handle, world));
|
|
765
920
|
}
|
|
@@ -772,6 +927,7 @@ export function createGame(opts: {
|
|
|
772
927
|
// short of a new `createGame` call, matching `reportedGateShortfalls`
|
|
773
928
|
// above's "once per game instance" idiom.
|
|
774
929
|
const warnedSystemAdapterKeys = new Set<string>();
|
|
930
|
+
const systemAdapterListeners = new Set<() => void>();
|
|
775
931
|
// 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
|
|
776
932
|
function computeSystemAdapters(): SystemAdapters {
|
|
777
933
|
// Debug is game-scoped: React hooks, probes, and the built-in time
|
|
@@ -780,17 +936,14 @@ export function createGame(opts: {
|
|
|
780
936
|
// root may still publish this SAME adapter (the first-party Three/Pixi
|
|
781
937
|
// mounts do); the reference-equality branch below treats that as the
|
|
782
938
|
// intentional shared registration it is.
|
|
783
|
-
const result: SystemAdapters = { debug: debugRegistry.adapter
|
|
784
|
-
const
|
|
785
|
-
['debug', '(game)'],
|
|
786
|
-
['input', '(game)'],
|
|
787
|
-
]);
|
|
939
|
+
const result: SystemAdapters = { debug: debugRegistry.adapter };
|
|
940
|
+
const ownerRootId = new Map<string, string>([['debug', '(game)']]);
|
|
788
941
|
for (const world of roots) {
|
|
789
942
|
const adapters = world.mounted.systems;
|
|
790
943
|
if (!adapters) continue;
|
|
791
944
|
for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
|
|
792
945
|
if (adapters[key] === undefined) continue;
|
|
793
|
-
const existingOwner =
|
|
946
|
+
const existingOwner = ownerRootId.get(key);
|
|
794
947
|
if (existingOwner !== undefined) {
|
|
795
948
|
// Reference-equality short-circuit: two roots sharing the ONE
|
|
796
949
|
// game-scoped debug registry's adapter (T1.1) both expose the SAME
|
|
@@ -803,14 +956,14 @@ export function createGame(opts: {
|
|
|
803
956
|
console.warn(
|
|
804
957
|
`[game] systemAdapters: "${key}" is registered by both world "${existingOwner}" and ` +
|
|
805
958
|
`world "${world.id}" — the FIRST registration ("${existingOwner}") wins; the later ` +
|
|
806
|
-
'one is shadowed (
|
|
959
|
+
'one is shadowed (game-scoped system adapters).',
|
|
807
960
|
);
|
|
808
961
|
}
|
|
809
962
|
continue;
|
|
810
963
|
}
|
|
811
964
|
// 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
|
|
812
965
|
(result as any)[key] = adapters[key];
|
|
813
|
-
|
|
966
|
+
ownerRootId.set(key, world.id);
|
|
814
967
|
}
|
|
815
968
|
}
|
|
816
969
|
return result;
|
|
@@ -834,7 +987,7 @@ export function createGame(opts: {
|
|
|
834
987
|
// Other native `console.warn`/`console.error` call sites in this file are
|
|
835
988
|
// unsuppressed and already counted in the lint baseline (see `runFrame`'s
|
|
836
989
|
// impl below); this one is a NEW site, so it's suppressed to keep this
|
|
837
|
-
// task's diff at zero NEW warnings (same reasoning as `
|
|
990
|
+
// task's diff at zero NEW warnings (same reasoning as `achieved-tier.ts`'s
|
|
838
991
|
// identical suppression).
|
|
839
992
|
// biome-ignore lint/suspicious/noConsole: see comment above
|
|
840
993
|
console.warn(message);
|
|
@@ -845,7 +998,7 @@ export function createGame(opts: {
|
|
|
845
998
|
* absent — shared by `pause()`/`resume()` below (same fan-out, opposite
|
|
846
999
|
* boolean). */
|
|
847
1000
|
// 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
|
|
848
|
-
function
|
|
1001
|
+
function setRootGates(next: boolean): void {
|
|
849
1002
|
for (const world of roots) {
|
|
850
1003
|
if (!world.pausable) continue; // pausable:false roots are untouched by design
|
|
851
1004
|
if (world.mounted.drivesOwnLoop) {
|
|
@@ -868,7 +1021,7 @@ export function createGame(opts: {
|
|
|
868
1021
|
}
|
|
869
1022
|
}
|
|
870
1023
|
|
|
871
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (
|
|
1024
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/roots — splitting it would obscure the ordering contract documented on GameInternal.runFrame
|
|
872
1025
|
function runFrameImpl(
|
|
873
1026
|
dt: number,
|
|
874
1027
|
frameOpts?: { ignorePause?: boolean; onlyFrozen?: boolean; skipRenderPhases?: boolean },
|
|
@@ -882,11 +1035,14 @@ export function createGame(opts: {
|
|
|
882
1035
|
if (rngTrapEnabled) rngTrap.enable();
|
|
883
1036
|
profiler.beginFrame();
|
|
884
1037
|
const ignorePause = frameOpts?.ignorePause ?? false;
|
|
885
|
-
// D15/T-D15.4
|
|
886
|
-
//
|
|
887
|
-
// `
|
|
888
|
-
//
|
|
889
|
-
//
|
|
1038
|
+
// D15/T-D15.4 + WO-P13: the render-phase skip. Two callers set it, for the
|
|
1039
|
+
// same reason — this substep is not the thing that paints. `runTicks`'s
|
|
1040
|
+
// `render: 'none'|'last'` fast-forward sets it on every tick it doesn't
|
|
1041
|
+
// want to paint (see `runTicks`'s doc comment on `GameInternal`), and a
|
|
1042
|
+
// DISPLAY-RATE host sets it on every substep because `runRenderFrame`
|
|
1043
|
+
// paints once per real frame instead. Unlike `onlyFrozen` below it is no
|
|
1044
|
+
// longer internal-only: it is part of the public `GameInternal.runFrame`
|
|
1045
|
+
// signature, since an external host is now one of those two callers.
|
|
890
1046
|
const skipRenderPhases = frameOpts?.skipRenderPhases ?? false;
|
|
891
1047
|
// `onlyFrozen` is internal-only (not part of the public `GameInternal.runFrame`
|
|
892
1048
|
// signature — no external caller sets it) — `Game.play.step()` below is the
|
|
@@ -1069,7 +1225,7 @@ export function createGame(opts: {
|
|
|
1069
1225
|
}
|
|
1070
1226
|
}
|
|
1071
1227
|
|
|
1072
|
-
// T7.4 slice 1
|
|
1228
|
+
// T7.4 slice 1: bump + notify LAST, after
|
|
1073
1229
|
// every phase of every world and every world's endFrame/update above —
|
|
1074
1230
|
// subscribers must only ever observe post-frame state. Bumped at most
|
|
1075
1231
|
// once per completed `runFrame`/`step()` call, never per phase/world —
|
|
@@ -1080,30 +1236,115 @@ export function createGame(opts: {
|
|
|
1080
1236
|
stateBridge.bump();
|
|
1081
1237
|
tick++;
|
|
1082
1238
|
simT += dt;
|
|
1239
|
+
// P3 — sim timers fire IMMEDIATELY after the bump, inside this same
|
|
1240
|
+
// `advanced` guard: a paused/frozen world advances no sim time, so it
|
|
1241
|
+
// must fire no timers either, and `Game.play.step()` (which reaches this
|
|
1242
|
+
// block with `advanced` set by the frozen-set loop above) fires exactly
|
|
1243
|
+
// the timers that ONE substep makes due. One flush === one completed
|
|
1244
|
+
// substep, which is what lets `SimClock.tickNow()` simply count flushes
|
|
1245
|
+
// and always agree with `tick`.
|
|
1246
|
+
simClock.flush(simT);
|
|
1083
1247
|
}
|
|
1084
1248
|
profiler.endFrame();
|
|
1085
1249
|
if (rngTrapEnabled) rngTrap.disable();
|
|
1086
1250
|
}
|
|
1087
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* WO-P13 — one display frame's PRESENTATION pass. See
|
|
1254
|
+
* `GameInternal.runRenderFrame`'s doc comment for the contract; this is the
|
|
1255
|
+
* `preRender`+`render` slice of `runFrameImpl`'s phase loop, lifted out and
|
|
1256
|
+
* driven by the loop's own per-real-frame callback instead of by the substep
|
|
1257
|
+
* loop.
|
|
1258
|
+
*
|
|
1259
|
+
* Three deliberate parallels with `runFrameImpl`, so the two passes cannot
|
|
1260
|
+
* drift into different rules for the same situation:
|
|
1261
|
+
* - game-scoped systems run before per-world hooks, per phase;
|
|
1262
|
+
* - one world's throw is logged and isolated, never allowed to starve a
|
|
1263
|
+
* sibling still due this phase;
|
|
1264
|
+
* - the RNG trap brackets the whole pass, so `Math.random` called from a
|
|
1265
|
+
* render-phase system is still caught by D15's dev-mode trap — it was,
|
|
1266
|
+
* back when this pass lived inside `runFrame`, and moving code must not
|
|
1267
|
+
* quietly move it out from under a guard.
|
|
1268
|
+
*
|
|
1269
|
+
* Profiler note (a real, accepted consequence): the profiler's "frame" has
|
|
1270
|
+
* always meant "one `runFrame` call", so a display frame now produces the
|
|
1271
|
+
* substep records it always did PLUS one record for this pass, carrying the
|
|
1272
|
+
* `preRender`/`render` spans and the renderer counters. Re-modelling the
|
|
1273
|
+
* profiler's frame boundary around the display frame is a separate change to
|
|
1274
|
+
* a debugging surface, not part of flipping the loop.
|
|
1275
|
+
*/
|
|
1276
|
+
function runRenderFrameImpl(alpha: number, displayDt: number): void {
|
|
1277
|
+
renderAlpha = alpha;
|
|
1278
|
+
if (rngTrapEnabled) rngTrap.enable();
|
|
1279
|
+
profiler.beginFrame();
|
|
1280
|
+
|
|
1281
|
+
// `RenderStepped` first: a camera adjusted here is drawn by THIS frame's
|
|
1282
|
+
// render phase, not next frame's. Iterated over a snapshot so a callback
|
|
1283
|
+
// that unsubscribes itself (or registers another) cannot mutate the set
|
|
1284
|
+
// mid-iteration.
|
|
1285
|
+
if (renderStepCallbacks.size > 0) {
|
|
1286
|
+
for (const fn of [...renderStepCallbacks]) {
|
|
1287
|
+
try {
|
|
1288
|
+
fn(alpha, displayDt);
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
console.error('[game] an onRenderStep callback threw:', err);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const n = roots.length;
|
|
1296
|
+
for (const phase of DISPLAY_RATE_PHASES) {
|
|
1297
|
+
profiler.beginPhase();
|
|
1298
|
+
systems.runPhase(phase, displayDt);
|
|
1299
|
+
for (let i = 0; i < n; i++) {
|
|
1300
|
+
const world = roots[i]!;
|
|
1301
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
1302
|
+
// D10/T7.6, carried over verbatim in substance: a frozen world still
|
|
1303
|
+
// renders (a paused viewport stays painted) but with `dt` forced to
|
|
1304
|
+
// `0`, and skips every other phase.
|
|
1305
|
+
const frozen = paused && world.pausable;
|
|
1306
|
+
if (frozen && phase !== SystemPhase.RENDER) continue;
|
|
1307
|
+
try {
|
|
1308
|
+
world.frame?.runPhase(phase, frozen ? 0 : displayDt);
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
console.error(
|
|
1311
|
+
`[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw ` +
|
|
1312
|
+
'(display frame):',
|
|
1313
|
+
err,
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
profiler.endPhase(phase);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
profiler.endFrame();
|
|
1321
|
+
if (rngTrapEnabled) rngTrap.disable();
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1088
1324
|
const gameInternal: GameInternal = {
|
|
1089
1325
|
loop: opts.loop,
|
|
1090
1326
|
assets: opts.assets,
|
|
1327
|
+
playtest: opts.playtest ?? null,
|
|
1091
1328
|
profiler,
|
|
1092
1329
|
systems,
|
|
1093
1330
|
get roots() {
|
|
1094
1331
|
return roots;
|
|
1095
1332
|
},
|
|
1096
|
-
world(id: string):
|
|
1333
|
+
world(id: string): RootInstance | null {
|
|
1097
1334
|
return roots.find((w) => w.id === id) ?? null;
|
|
1098
1335
|
},
|
|
1099
|
-
get
|
|
1100
|
-
return
|
|
1336
|
+
get defaultRoot() {
|
|
1337
|
+
return requireDefaultRoot();
|
|
1101
1338
|
},
|
|
1102
1339
|
get systemAdapters() {
|
|
1103
1340
|
return computeSystemAdapters();
|
|
1104
1341
|
},
|
|
1105
|
-
|
|
1106
|
-
|
|
1342
|
+
subscribeSystemAdapters(listener: () => void) {
|
|
1343
|
+
systemAdapterListeners.add(listener);
|
|
1344
|
+
return () => systemAdapterListeners.delete(listener);
|
|
1345
|
+
},
|
|
1346
|
+
notifySystemAdaptersChanged() {
|
|
1347
|
+
for (const listener of systemAdapterListeners) listener();
|
|
1107
1348
|
},
|
|
1108
1349
|
input,
|
|
1109
1350
|
get audio() {
|
|
@@ -1117,12 +1358,12 @@ export function createGame(opts: {
|
|
|
1117
1358
|
pause() {
|
|
1118
1359
|
if (paused) return;
|
|
1119
1360
|
paused = true;
|
|
1120
|
-
|
|
1361
|
+
setRootGates(true);
|
|
1121
1362
|
},
|
|
1122
1363
|
resume() {
|
|
1123
1364
|
if (!paused) return;
|
|
1124
1365
|
paused = false;
|
|
1125
|
-
|
|
1366
|
+
setRootGates(false);
|
|
1126
1367
|
},
|
|
1127
1368
|
step(dt = 1 / 60) {
|
|
1128
1369
|
// Self-driven pausable roots advance via their adapter's `step()`
|
|
@@ -1155,72 +1396,37 @@ export function createGame(opts: {
|
|
|
1155
1396
|
runFrameImpl(dt, { onlyFrozen: true });
|
|
1156
1397
|
},
|
|
1157
1398
|
},
|
|
1158
|
-
|
|
1159
|
-
cls: new () => T,
|
|
1160
|
-
opts?: { worldId?: string; kind?: AdapterSurface },
|
|
1161
|
-
): T[] {
|
|
1162
|
-
// Checklist item 8a: a `worldId` naming a world that doesn't exist is a
|
|
1163
|
-
// caller error (typo'd id, wrong manifest) — degrade loudly, matching
|
|
1164
|
-
// repo habit, rather than silently returning `[]`. A worldId that DOES
|
|
1165
|
-
// exist but isn't first-party legitimately answers "no components"
|
|
1166
|
-
// below (the loop's `!isFirstPartyMounted` `continue`), which stays
|
|
1167
|
-
// silent — that's a real, not a mistaken, empty result.
|
|
1168
|
-
if (opts?.worldId !== undefined && !roots.some((w) => w.id === opts.worldId)) {
|
|
1169
|
-
throw new Error(`Game.queryByComponent: unknown worldId "${opts.worldId}"`);
|
|
1170
|
-
}
|
|
1171
|
-
const result: T[] = [];
|
|
1172
|
-
for (const world of roots) {
|
|
1173
|
-
// A world's manager comes from its first-party ctx OR from the
|
|
1174
|
-
// optional `MountedWorldBase.components` capability (a non-first-
|
|
1175
|
-
// party adapter running the engine's REAL ComponentManager — e.g.
|
|
1176
|
-
// `@engine/world3d-react`'s R3F mount, whose `<Behavior>` components
|
|
1177
|
-
// used to be invisible here: the query compiled, ran, and returned
|
|
1178
|
-
// `[]` forever). A mount with NEITHER hosts no GameComponents, so
|
|
1179
|
-
// its silent empty contribution is a real, not a mistaken, result.
|
|
1180
|
-
const manager = isFirstPartyMounted(world.mounted)
|
|
1181
|
-
? world.mounted.ctx.components
|
|
1182
|
-
: world.mounted.components;
|
|
1183
|
-
if (!manager) continue;
|
|
1184
|
-
if (opts?.worldId !== undefined && world.id !== opts.worldId) continue;
|
|
1185
|
-
if (opts?.kind !== undefined && world.kind !== opts.kind) continue;
|
|
1186
|
-
// Checklist item 8b: a plain for-loop push instead of
|
|
1187
|
-
// `result.push(...arr)` — spread-as-arguments can hit engine/runtime
|
|
1188
|
-
// argument-count limits once a world's instance count gets large.
|
|
1189
|
-
const instances = manager.queryByComponent(cls);
|
|
1190
|
-
for (const inst of instances) result.push(inst);
|
|
1191
|
-
}
|
|
1192
|
-
return result;
|
|
1193
|
-
},
|
|
1194
|
-
registerWorld(world: WorldInstance): void {
|
|
1399
|
+
registerRoot(world: RootInstance): void {
|
|
1195
1400
|
if (roots.some((w) => w.id === world.id)) {
|
|
1196
|
-
throw new Error(`Game.
|
|
1401
|
+
throw new Error(`Game.registerRoot: duplicate world id "${world.id}"`);
|
|
1197
1402
|
}
|
|
1198
1403
|
// Checklist item 6: the SAME `mounted` object registered under two
|
|
1199
1404
|
// world ids would be double-ticked by `runFrame` (its `frame.runPhase`/
|
|
1200
1405
|
// `endFrame` called once per registration) and double-dispose-wrapped
|
|
1201
|
-
// (`
|
|
1406
|
+
// (`createRootInstance` wraps `mounted.dispose` in place — a second
|
|
1202
1407
|
// wrap would flip `disposed` and call through on ITS OWN wrapped
|
|
1203
1408
|
// `originalDispose`, which is harmless today only by accident of
|
|
1204
|
-
// `
|
|
1409
|
+
// `SetupThreeRootAdapter.dispose` being idempotent; a foreign adapter
|
|
1205
1410
|
// has no such guarantee). Reject it outright instead.
|
|
1206
1411
|
if (roots.some((w) => w.mounted === world.mounted)) {
|
|
1207
1412
|
throw new Error(
|
|
1208
|
-
`Game.
|
|
1413
|
+
`Game.registerRoot: world "${world.id}" shares its \`mounted\` object with an ` +
|
|
1209
1414
|
`already-registered world ("${roots.find((w) => w.mounted === world.mounted)!.id}") — ` +
|
|
1210
1415
|
'the same mount cannot be registered twice.',
|
|
1211
1416
|
);
|
|
1212
1417
|
}
|
|
1213
1418
|
roots.push(world);
|
|
1419
|
+
gameInternal.notifySystemAdaptersChanged();
|
|
1214
1420
|
// Notify state-bridge subscribers THE INSTANT the world list changes —
|
|
1215
1421
|
// not just at the next completed `runFrame`. Root-cause fix for the
|
|
1216
|
-
// "roots: " (empty) hang in `36-r3f-first-party.spec.ts` on CI
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
// caller calls `
|
|
1422
|
+
// "roots: " (empty) hang in `36-r3f-first-party.spec.ts` on CI (gate
|
|
1423
|
+
// 4): `mountAllRootSpecs` (`create-runtime.ts`) mounts roots
|
|
1424
|
+
// SEQUENTIALLY, and a react world's `adapter.mount()`
|
|
1425
|
+
// (`resolveDefaultReactAdapter`/`mountOneReactRoot`) renders its tree —
|
|
1426
|
+
// synchronously in some React builds, but React 19's concurrent
|
|
1427
|
+
// renderer does NOT guarantee a synchronous first commit (see
|
|
1428
|
+
// `r3f-adapter.tsx`'s own doc comment on `onCreated`) — BEFORE the
|
|
1429
|
+
// caller calls `registerRoot` for that very world. A `useWorldState`
|
|
1224
1430
|
// selector reading `g.roots` can therefore render for the first time
|
|
1225
1431
|
// while `roots` is still missing entries that register moments later.
|
|
1226
1432
|
// Previously the ONLY way such a subscriber ever saw the corrected
|
|
@@ -1232,57 +1438,85 @@ export function createGame(opts: {
|
|
|
1232
1438
|
// that first tick can be delayed well past a test's assertion window,
|
|
1233
1439
|
// or — if the tab is ever backgrounded — not fire at all for a long
|
|
1234
1440
|
// stretch; the subscriber's cached snapshot then sits on its stale
|
|
1235
|
-
// (possibly fully empty) first render for that whole time, matching
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1238
|
-
// `packages/engine/test/state-bridge.test.ts`'s "
|
|
1441
|
+
// (possibly fully empty) first render for that whole time, matching the
|
|
1442
|
+
// observed CI symptom exactly ("the HUD element IS mounted; the roots
|
|
1443
|
+
// list is EMPTY"). Proven red-then-green by
|
|
1444
|
+
// `packages/engine/test/state-bridge.test.ts`'s "registerRoot notifies
|
|
1239
1445
|
// subscribers immediately" case: a subscriber registered before a
|
|
1240
1446
|
// world, with `runFrame` NEVER called, only saw the update after this
|
|
1241
|
-
// fix. `bump()` also advances `frameVersion` (the only invalidation
|
|
1242
|
-
//
|
|
1447
|
+
// fix. `bump()` also advances `frameVersion` (the only invalidation key
|
|
1448
|
+
// `createFrameSelectorCache`/`useWorldState` understand — see
|
|
1243
1449
|
// `frame-selector-cache.ts`), so this doubles as "frameVersion is
|
|
1244
1450
|
// bumped once per completed runFrame OR once per world registered",
|
|
1245
1451
|
// documented on `GameStateBridge.frameVersion` below.
|
|
1246
1452
|
stateBridge.bump();
|
|
1247
|
-
// T7.4 slice 2
|
|
1453
|
+
// T7.4 slice 2: a NON-first-party mount
|
|
1248
1454
|
// (an ingested/foreign world — first-party mounts are exempt, they're
|
|
1249
|
-
// observed via `Game.state`/`
|
|
1455
|
+
// observed via `Game.state`/`useWorldState` instead) with no `observe`
|
|
1250
1456
|
// has no state bridge at all — react HUDs cannot subscribe to it, and
|
|
1251
1457
|
// silently returning `undefined` forever would hide that. Report ONCE
|
|
1252
1458
|
// per mount, at registration time, matching this file's existing
|
|
1253
1459
|
// `[game] world "<id>" (kind: <kind>) ...` console idiom (see
|
|
1254
1460
|
// `runFrame` below).
|
|
1255
1461
|
//
|
|
1256
|
-
// §7.1-15: a `kind: '
|
|
1257
|
-
// BY DESIGN (D8
|
|
1258
|
-
// reads state via `Game.state`/`
|
|
1259
|
-
// bridge a
|
|
1462
|
+
// §7.1-15: a `kind: 'dom'` world is ALSO exempt — it has no `observe`
|
|
1463
|
+
// BY DESIGN (D8: a react world's own mounted tree
|
|
1464
|
+
// reads state via `Game.state`/`useWorldState`, the SAME first-party
|
|
1465
|
+
// bridge a three/canvas world's HUD uses, never `RootStateObserver`
|
|
1260
1466
|
// — that hook is scoped to the ingested/foreign-world case). Without
|
|
1261
1467
|
// this exemption every production react world logged a false-positive
|
|
1262
1468
|
// "no state bridge" warning at registration (probe: every real react
|
|
1263
1469
|
// world mount), eroding the signal for a genuinely un-observable
|
|
1264
1470
|
// ingested world.
|
|
1265
|
-
// A mount
|
|
1266
|
-
//
|
|
1267
|
-
//
|
|
1268
|
-
// `
|
|
1269
|
-
// produces a false warning while
|
|
1471
|
+
// A HOST-DRIVEN mount (`drivesOwnLoop: false`, notably the R3F adapter)
|
|
1472
|
+
// is observable through `Game.state` even though it does not carry the
|
|
1473
|
+
// classic scene adapter's `firstParty` brand: it ticks inside
|
|
1474
|
+
// `runFrame`, so the frame-versioned bridge covers it. Requiring a
|
|
1475
|
+
// foreign-world `observe` bridge there produces a false warning while
|
|
1476
|
+
// the canonical bridge is already live.
|
|
1270
1477
|
if (
|
|
1271
1478
|
!isFirstPartyMounted(world.mounted) &&
|
|
1272
|
-
|
|
1273
|
-
world.kind !== '
|
|
1479
|
+
world.mounted.drivesOwnLoop &&
|
|
1480
|
+
world.kind !== 'dom' &&
|
|
1274
1481
|
!world.mounted.observe
|
|
1275
1482
|
) {
|
|
1276
1483
|
console.warn(
|
|
1277
1484
|
`[game] world "${world.id}" (kind: ${world.kind}, adapter: "${world.adapter.id}"): ` +
|
|
1278
|
-
'no state bridge — this mounted game has no `observe` (
|
|
1279
|
-
'react HUDs/
|
|
1485
|
+
'no state bridge — this mounted game has no `observe` (RootStateObserver); ' +
|
|
1486
|
+
'react HUDs/useRootObservation cannot subscribe to its state.',
|
|
1280
1487
|
);
|
|
1281
1488
|
}
|
|
1282
1489
|
},
|
|
1283
|
-
runFrame(dt: number, frameOpts?: { ignorePause?: boolean }): void {
|
|
1490
|
+
runFrame(dt: number, frameOpts?: { ignorePause?: boolean; skipRenderPhases?: boolean }): void {
|
|
1284
1491
|
runFrameImpl(dt, frameOpts);
|
|
1285
1492
|
},
|
|
1493
|
+
runRenderFrame(alpha: number, displayDt: number): void {
|
|
1494
|
+
runRenderFrameImpl(alpha, displayDt);
|
|
1495
|
+
},
|
|
1496
|
+
get renderAlpha() {
|
|
1497
|
+
return renderAlpha;
|
|
1498
|
+
},
|
|
1499
|
+
onRenderStep(
|
|
1500
|
+
fn: (alpha: number, displayDt: number) => void,
|
|
1501
|
+
stepOpts?: { signal?: AbortSignal | undefined },
|
|
1502
|
+
): () => void {
|
|
1503
|
+
const signal = stepOpts?.signal;
|
|
1504
|
+
// Already-aborted is a no-op registration, never a throw — the same
|
|
1505
|
+
// `AbortSignal` contract `SimClock.after` honors.
|
|
1506
|
+
if (signal?.aborted) return () => {};
|
|
1507
|
+
renderStepCallbacks.add(fn);
|
|
1508
|
+
let detachAbort: (() => void) | null = null;
|
|
1509
|
+
const unsubscribe = (): void => {
|
|
1510
|
+
renderStepCallbacks.delete(fn);
|
|
1511
|
+
detachAbort?.();
|
|
1512
|
+
detachAbort = null;
|
|
1513
|
+
};
|
|
1514
|
+
if (signal) {
|
|
1515
|
+
signal.addEventListener('abort', unsubscribe, { once: true });
|
|
1516
|
+
detachAbort = (): void => signal.removeEventListener('abort', unsubscribe);
|
|
1517
|
+
}
|
|
1518
|
+
return unsubscribe;
|
|
1519
|
+
},
|
|
1286
1520
|
runTicks(n: number, ticksOpts?: RunTicksOptions): void {
|
|
1287
1521
|
if (!Number.isInteger(n) || n < 0) {
|
|
1288
1522
|
throw new RangeError(`Game.runTicks: n must be a non-negative integer, got ${n}`);
|
|
@@ -1294,7 +1528,7 @@ export function createGame(opts: {
|
|
|
1294
1528
|
throw new DebugError(
|
|
1295
1529
|
'RUN_TICKS_PAUSED',
|
|
1296
1530
|
'Game.runTicks: refused — Game.play.paused is true; stepping the frozen set is ' +
|
|
1297
|
-
"Game.play.step()'s contract, not runTicks'
|
|
1531
|
+
"Game.play.step()'s contract, not runTicks'",
|
|
1298
1532
|
);
|
|
1299
1533
|
}
|
|
1300
1534
|
const render = ticksOpts?.render ?? 'last';
|
|
@@ -1317,6 +1551,18 @@ export function createGame(opts: {
|
|
|
1317
1551
|
return inputMapLoad;
|
|
1318
1552
|
},
|
|
1319
1553
|
dispose(): void {
|
|
1554
|
+
// P3 — the sim clock is GAME-scoped, so this is the only correct place to
|
|
1555
|
+
// dispose it: `create-runtime.ts`'s `fullCleanup` calls us after EVERY
|
|
1556
|
+
// root's `mounted.dispose()`, whereas a per-root teardown may be ending
|
|
1557
|
+
// just one sub-session while sibling roots keep running (see the
|
|
1558
|
+
// disposed-world guard in `runFrame`). It used to be disposed from
|
|
1559
|
+
// `setup-three-root-adapter.ts`'s teardown, which meant disposing one of
|
|
1560
|
+
// two three roots froze `now()` for the whole Game, rejected the other
|
|
1561
|
+
// world's pending `delay`s and turned its `after()` calls into silent
|
|
1562
|
+
// no-ops. Note the asymmetry that gives the bug away: `seededRandom` and
|
|
1563
|
+
// `debugRegistry` are game-scoped too, and per-root teardown has never
|
|
1564
|
+
// destroyed either — only ever `strip(this.id)`, its own slice.
|
|
1565
|
+
simClock.dispose();
|
|
1320
1566
|
input.dispose();
|
|
1321
1567
|
debugRegistry.strip();
|
|
1322
1568
|
},
|
|
@@ -1339,6 +1585,9 @@ export function createGame(opts: {
|
|
|
1339
1585
|
// `createGame` returns.
|
|
1340
1586
|
registerSeededRandom(gameInternal, seededRandom);
|
|
1341
1587
|
registerGameplayRngTrapControl(gameInternal, rngTrapControl);
|
|
1588
|
+
// P3 — same "file after the shell exists" ordering: `getSimClock(game)` is
|
|
1589
|
+
// how every world's mount resolves `ctx.clock` to THIS game's one clock.
|
|
1590
|
+
registerSimClock(gameInternal, simClock);
|
|
1342
1591
|
|
|
1343
1592
|
return gameInternal;
|
|
1344
1593
|
}
|