@volter/editor-game 0.5.66 → 0.5.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/contributions/audio-unlock.service.ts +2 -2
- package/contributions/autoplay.service.ts +1 -1
- package/contributions/bridge.command.ts +3 -3
- package/contributions/build-player.document.tsx +95 -0
- package/contributions/canvas/component-board.service.ts +16 -0
- package/contributions/canvas/design-time-mount.service.ts +45 -0
- package/contributions/canvas-story-capture.service.ts +12 -0
- package/contributions/edit-mode-audio.service.ts +2 -2
- package/contributions/edit-mode-networking.service.ts +1 -1
- package/contributions/gameplay.command.ts +4 -4
- package/contributions/generation.service.ts +1 -1
- package/contributions/godot.style.ts +26 -6
- package/contributions/godot.view.ts +6 -0
- package/contributions/ingest.service.ts +2 -2
- package/contributions/instances.command.ts +1 -1
- package/contributions/navmesh.menu.ts +1 -1
- package/contributions/network-observer.service.ts +14 -0
- package/contributions/play.command.ts +10 -4
- package/contributions/react/component-board.service.ts +1 -1
- package/contributions/react/design-time-mount.service.ts +1 -1
- package/contributions/scene-document.service.ts +3 -3
- package/contributions/state-watch.menu.ts +1 -1
- package/contributions/state-watch.utility.tsx +1 -1
- package/contributions/team-playtest.service.ts +4 -4
- package/contributions/three/component-board.service.ts +1 -1
- package/contributions/three/component-verbs.command.ts +6 -6
- package/contributions/three/three-authoring.service.ts +8 -5
- package/contributions/three-story-capture.service.ts +12 -0
- package/contributions/unity.style.ts +17 -7
- package/contributions/unity.view.ts +6 -0
- package/contributions/unreal.style.ts +10 -2
- package/contributions/unreal.view.ts +5 -0
- package/package.json +21 -10
- package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
- package/src/asset-budget/asset-budget-model.ts +1 -1
- package/src/audio/AudioDebuggerPanel.tsx +1 -1
- package/src/bridge/dispatch.ts +14 -14
- package/src/bridge/live-frames.ts +1 -1
- package/src/bridge/screenshot.ts +3 -3
- package/src/build/BuildProfilesPanel.tsx +14 -3
- package/src/build/build-session.ts +35 -0
- package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
- package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
- package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
- package/src/canvas/canvas-design-mount.ts +524 -0
- package/src/canvas/design-time-canvas-mount.ts +79 -0
- package/src/coverage/live-authoring-surface.ts +5 -5
- package/src/coverage/live-project-verbs.ts +1 -1
- package/src/coverage/native-system-coverage.ts +5 -5
- package/src/coverage/root-coverage.ts +1 -1
- package/src/coverage/session-coverage.ts +3 -3
- package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
- package/src/edit-mode/edit-mode-audio.ts +4 -4
- package/src/edit-mode/edit-mode-networking.ts +2 -2
- package/src/game-document/GameCaptureFrameButton.tsx +1 -1
- package/src/game-document/GameDocument.tsx +2 -2
- package/src/game-document/GamePanel.tsx +4 -4
- package/src/game-document/InstanceInspectorPicker.tsx +2 -2
- package/src/game-document/crowd-debug.ts +1 -1
- package/src/game-document/device-preview.ts +10 -11
- package/src/game-document/physics-debug.ts +1 -1
- package/src/generation/GenerationActivity.tsx +3 -3
- package/src/generation/generation-documents.tsx +5 -5
- package/src/generation/generation-jobs.ts +1 -1
- package/src/host/adapter-runtime-bindings.ts +96 -5
- package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
- package/src/host/authoring/gesture-persist.ts +1 -1
- package/src/host/authoring/ingest-data-writer.ts +1 -1
- package/src/host/authoring/ingest-source-persistence.ts +4 -4
- package/src/host/authoring/mounted-authoring.ts +4 -4
- package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
- package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
- package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
- package/src/host/authoring/pixi-live-write-target.ts +4 -4
- package/src/host/authoring/pixi-source-identity.ts +3 -3
- package/src/host/authoring/pixi-source-write-target.ts +1709 -0
- package/src/host/authoring/pixi-still-presentation.ts +1 -1
- package/src/host/authoring/pixi-structure-history.ts +2 -2
- package/src/host/authoring/pixi-transform-channels.ts +16 -14
- package/src/host/authoring/source-persistence-backend.ts +3 -3
- package/src/host/authoring/struct-write-pipe.ts +1 -1
- package/src/host/binding-resolver.ts +8 -9
- package/src/host/browser-transpile.ts +1 -1
- package/src/host/canvas-entry-runtime.ts +58 -47
- package/src/host/canvas-preview-frames.ts +482 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
- package/src/host/components/HeaderTelemetry.tsx +4 -4
- package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
- package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
- package/src/host/components/frame-debugger-model.ts +2 -2
- package/src/host/components/header-telemetry-model.ts +2 -2
- package/src/host/components/scene-document.tsx +14 -14
- package/src/host/components/utility-view-state.ts +1 -1
- package/src/host/components/world-root-stage-binding.tsx +12 -12
- package/src/host/components/world-root-stage.ts +70 -49
- package/src/host/coverage/system-adapter-coverage.ts +3 -4
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
- package/src/host/document-preview-three.ts +1 -1
- package/src/host/entry-adjudication.ts +6 -6
- package/src/host/game-css-scope-transform.ts +4 -0
- package/src/host/game-realm-page.ts +1 -1
- package/src/host/gameplay-export.ts +25 -14
- package/src/host/gameplay-recording.ts +5 -5
- package/src/host/gated-globals.ts +2 -2
- package/src/host/history/json-history-resource.ts +1 -1
- package/src/host/projection/pixi.ts +24 -2
- package/src/host/r3f-entry-runtime.ts +65 -34
- package/src/host/react-mount-runtime.ts +7 -48
- package/src/host/realm-services.ts +1 -1
- package/src/host/roots/canvas-root.tsx +373 -0
- package/src/host/roots/r3f-root.tsx +473 -0
- package/src/host/roots/react-root.ts +9 -43
- package/src/host/served-bundle-runtime-modules.ts +3 -19
- package/src/host/server-log-bridge.ts +2 -2
- package/src/host/stories/mounted-story-viewport-source.ts +1 -1
- package/src/host/stories/pixi-story-model.ts +30 -0
- package/src/host/stories/story-media-captures.ts +46 -0
- package/src/host/stories/story-media-presence.ts +3 -3
- package/src/host/stories/story-pixi-preview.ts +408 -0
- package/src/host/stories/story-three-preview.ts +806 -0
- package/src/host/stories/three-story-captures.ts +35 -0
- package/src/host/story-three-preview-runtime.ts +56 -0
- package/src/host/use-active-performance-source.ts +2 -2
- package/src/host/viewport-pose-memory.ts +1 -1
- package/src/host/viewport-root-presentation.ts +6 -5
- package/src/ingest/active-ingest.ts +1 -1
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
- package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
- package/src/ingest/deferred-ingest-play.ts +6 -6
- package/src/ingest/discovery-public-ingest.ts +2 -2
- package/src/ingest/ingest-boot-viewport.ts +2 -2
- package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
- package/src/ingest/ingest-canvas-scene.ts +3 -3
- package/src/ingest/ingest-evidence-hook.ts +1 -1
- package/src/ingest/ingest-frame-snapshot.ts +1 -1
- package/src/ingest/ingest-play-control.ts +1 -1
- package/src/ingest/ingest-render-debug.ts +10 -10
- package/src/ingest/ingest-siblings.ts +13 -25
- package/src/ingest/module-mode.ts +14 -14
- package/src/ingest/mount-canvas-ingest-root.ts +21 -21
- package/src/ingest/mount-coverage.ts +2 -2
- package/src/ingest/mount-dom-ingest-root.ts +11 -11
- package/src/ingest/mount-ingest-root.ts +8 -8
- package/src/ingest/mount-three-ingest-root.ts +8 -8
- package/src/ingest/resolve-canvas.ts +1 -1
- package/src/ingest/served-html-boot.ts +1 -1
- package/src/ingest/surface-canvas.ts +1 -1
- package/src/ingest/unmount-ingest-root.ts +5 -5
- package/src/navmesh/navmesh-handler.ts +24 -16
- package/src/network/NetworkInspectorPanel.tsx +939 -37
- package/src/network/network-inspector-model.ts +20 -2
- package/src/play/play-log-events.ts +1 -1
- package/src/play/play-mode.ts +76 -133
- package/src/play/play-recording.ts +1 -1
- package/src/play/react-play-live-authoring.ts +3 -3
- package/src/play/run-selection.ts +93 -0
- package/src/play-bar/PlayBar.tsx +20 -39
- package/src/profiler/FrameDebuggerPanel.tsx +1 -1
- package/src/profiler/PerformancePanel.tsx +3 -3
- package/src/react/design-time-react-mount.ts +23 -65
- package/src/react/dom-authoring-adapter.ts +9 -9
- package/src/react/react-inspector-section.tsx +5 -5
- package/src/react/react-world-authoring-adapter.ts +11 -11
- package/src/react/story-documents/story-documents.tsx +9 -9
- package/src/react/ui-board-document.tsx +7 -7
- package/src/react/ui-component-board.ts +2 -2
- package/src/runtime/adapter/audio-meter.ts +21 -0
- package/src/runtime/adapter/first-party-audio-system.ts +230 -0
- package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
- package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
- package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
- package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
- package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
- package/src/runtime/adapter/native-debug-module.ts +498 -0
- package/src/runtime/audio/bus-mixer.ts +161 -0
- package/src/runtime/audio/pose-guard.ts +80 -0
- package/src/runtime/core/frame-pacing.ts +126 -0
- package/src/runtime/core/game-loop.ts +225 -0
- package/src/runtime/core/game-scoped-slot.ts +28 -0
- package/src/runtime/core/seeded-random.ts +162 -0
- package/src/runtime/core/sim-clock.ts +391 -0
- package/src/runtime/core/system-runner.ts +269 -0
- package/src/runtime/core/types.ts +104 -0
- package/src/runtime/create-runtime.ts +1128 -0
- package/src/runtime/debug-bridge.ts +570 -0
- package/src/runtime/debug-registry.ts +899 -0
- package/src/runtime/dev/chrome-trace.ts +153 -0
- package/src/runtime/dev/instruments.ts +403 -0
- package/src/runtime/dev/logger.ts +119 -0
- package/src/runtime/dev/performance-profiler.ts +367 -0
- package/src/runtime/dev/register-render-vitals.ts +276 -0
- package/src/runtime/dev/render-census.ts +354 -0
- package/src/runtime/dev/render-debug-adapter.ts +218 -0
- package/src/runtime/dev/render-memory.ts +226 -0
- package/src/runtime/dev/render-vitals.ts +338 -0
- package/src/runtime/dev/static-batch-advisor.ts +188 -0
- package/src/runtime/dev/webgl-frame-capture.ts +366 -0
- package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
- package/src/runtime/dev-build.ts +47 -0
- package/src/runtime/game.ts +1636 -0
- package/src/runtime/gameplay-rng-trap.ts +135 -0
- package/src/runtime/host-context.ts +64 -0
- package/src/runtime/input-router.ts +182 -0
- package/src/runtime/mount-manifest.ts +480 -0
- package/src/runtime/pixi/authoring.ts +706 -0
- package/src/runtime/pixi/ingest.ts +116 -0
- package/src/runtime/pixi/physics-registry.ts +49 -0
- package/src/runtime/pixi/render-pass-bracket.ts +117 -0
- package/src/runtime/pixi/scene-capture.ts +179 -0
- package/src/runtime/pixi/system-adapters.ts +69 -0
- package/src/runtime/playtest.ts +22 -0
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-control.ts +642 -0
- package/src/runtime/render-seed.ts +77 -0
- package/src/runtime/run-ticks-settled.ts +73 -0
- package/src/runtime/setup/setup-audio.ts +72 -0
- package/src/services/audio-pose-guard.ts +2 -2
- package/src/services/game-audio.ts +152 -0
- package/src/services/game-network.ts +767 -0
- package/src/services/game-physics.ts +334 -0
- package/src/state-watch/StateWatchPanel.tsx +1 -1
- package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
- package/src/three/authoring/constraint-inspector-section.tsx +6 -5
- package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
- package/src/three/authoring/oid-source-persistence.ts +7 -7
- package/src/three/authoring/r3f-design-session.ts +58 -54
- package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
- package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
- package/src/three/authoring/three-authoring-adapter.ts +29 -29
- package/src/three/component-verbs/extract-menu.ts +7 -6
- package/src/three/component-verbs/fork-menu.ts +7 -6
- package/src/three/component-verbs/internals-menu.ts +2 -2
- package/src/three/story-documents/three-story-documents.tsx +13 -13
- package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
- package/src/three/three-board/board-scene.ts +6 -6
- package/src/three/three-board/three-component-board.ts +2 -2
- package/src/services/game-audio-unlock.ts +0 -48
|
@@ -0,0 +1,1636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The surface-neutral Game root and its declaration-ordered root registry.
|
|
3
|
+
* Three, Canvas, and DOM roots share one game-scoped loop, input manager,
|
|
4
|
+
* system runner, state bridge, debug registry, clock, and seeded RNG. Each
|
|
5
|
+
* RootInstance retains its own mounted surface and optional capabilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AdapterSurface as AdapterSurfaceLeaf } from '@volter/editor-project/adapter/adapter-surface';
|
|
9
|
+
import type { RootBinding } from '@volter/editor-project/adapter/binding';
|
|
10
|
+
import {
|
|
11
|
+
formatAudioGateMessage,
|
|
12
|
+
formatLoopGateMessage,
|
|
13
|
+
} from '@volter/editor-project/adapter/loop-gate-report';
|
|
14
|
+
import type { MountedRoot } from '@volter/editor-project/adapter/root-adapter';
|
|
15
|
+
import type { AudioAdapter, SystemAdapters } from '@volter/editor-project/adapter/system-adapter';
|
|
16
|
+
import { bindScopedSystem } from '@volter/editor-project/adapter/system-slot';
|
|
17
|
+
import type { AssetCache } from '@volter/threejs-runtime/assets';
|
|
18
|
+
import { hasUserData } from '@volter/threejs-runtime/ecs/user-data';
|
|
19
|
+
import type { CollisionSystem } from '@volter/threejs-runtime/physics/collision-system';
|
|
20
|
+
import type { PhysicsRegistry } from '@volter/threejs-runtime/physics/physics-registry';
|
|
21
|
+
import type * as THREE from 'three';
|
|
22
|
+
import type { createGameLoop } from './core/game-loop';
|
|
23
|
+
import {
|
|
24
|
+
createSeededRandom,
|
|
25
|
+
DEFAULT_SEEDED_RANDOM_SEED,
|
|
26
|
+
registerSeededRandom,
|
|
27
|
+
} from './core/seeded-random';
|
|
28
|
+
import { createSimClock, registerSimClock, type SimClockInternal } from './core/sim-clock';
|
|
29
|
+
import { createSystemRunner, type SystemRunner } from './core/system-runner';
|
|
30
|
+
import { PHASE_ORDER, SystemPhase, type SystemPhaseName } from './core/types';
|
|
31
|
+
import { createPerformanceProfiler, type PerformanceProfiler } from './dev/performance-profiler';
|
|
32
|
+
// TYPE-ONLY (same rule as the pixi import above): this lives under `pixi/`,
|
|
33
|
+
// but `game.ts` only ever names its TYPE.
|
|
34
|
+
import type { Physics2DRegistry } from './pixi/physics-registry';
|
|
35
|
+
import {
|
|
36
|
+
createDebugRegistry,
|
|
37
|
+
DebugError,
|
|
38
|
+
type RunTicksOptions,
|
|
39
|
+
registerDebugRegistry,
|
|
40
|
+
} from './debug-registry';
|
|
41
|
+
import { createGameplayRngTrap, registerGameplayRngTrapControl } from './gameplay-rng-trap';
|
|
42
|
+
import type { PlaytestContext } from './playtest';
|
|
43
|
+
|
|
44
|
+
/** The one loop type — `createGameLoop`'s return shape (fixed-step sim,
|
|
45
|
+
* display-rate presentation). */
|
|
46
|
+
export type GameLoop = ReturnType<typeof createGameLoop>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The tail of `PHASE_ORDER` that presents rather than simulates, and
|
|
50
|
+
* therefore runs once per DISPLAY frame (`runRenderFrameImpl`) rather than
|
|
51
|
+
* once per fixed substep. The same two phases `runTicks`' `skipRenderPhases`
|
|
52
|
+
* fast-forward skips and `render-control.ts`'s `renderOnce()` drives — one
|
|
53
|
+
* definition of "the render phases" across all three, kept in `PHASE_ORDER`'s
|
|
54
|
+
* own order.
|
|
55
|
+
*/
|
|
56
|
+
const DISPLAY_RATE_PHASES: readonly SystemPhaseName[] = PHASE_ORDER.filter(
|
|
57
|
+
(phase) => phase === SystemPhase.PRE_RENDER || phase === SystemPhase.RENDER,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Game-level play-state control surface (D10, T7.6): pause/resume/step as ONE
|
|
62
|
+
* control surface on the running game, with PER-WORLD `pausable` semantics
|
|
63
|
+
* (`RootInstance.pausable`) — a menu/HUD world declaring `pausable: false` keeps
|
|
64
|
+
* ticking (input, physics) while every `pausable: true` world
|
|
65
|
+
* freezes. A paused-and-pausable world's `render` phase still runs (with `dt`
|
|
66
|
+
* forced to `0`, so time-based render effects — e.g. a post-processing pass with
|
|
67
|
+
* its own internal clock — don't silently keep animating under a "frozen" scene)
|
|
68
|
+
* — simulation freezes, the screen does not go black. That render runs once per
|
|
69
|
+
* display frame under a host that drives `runRenderFrame`, and once per substep
|
|
70
|
+
* under one that still renders inside
|
|
71
|
+
* `runFrame`. `GameLoop.timeScale` remains the orthogonal
|
|
72
|
+
* "speed up/slow down" axis — pausing never touches it, so the host's
|
|
73
|
+
* accumulator/rAF loop keeps ticking at its normal cadence, which is what makes
|
|
74
|
+
* "paused still renders" possible.
|
|
75
|
+
*/
|
|
76
|
+
export interface PlayState {
|
|
77
|
+
/** Whether the game is currently paused (game-level — see the per-world
|
|
78
|
+
* `pausable` caveat above: a `pausable: false` world ignores this). */
|
|
79
|
+
readonly paused: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Freeze every `pausable` world's simulation (idempotent — a second call
|
|
82
|
+
* while already paused is a no-op) and, for every `pausable`, self-driven
|
|
83
|
+
* (`drivesOwnLoop`) world, invoke its loop-gate capability
|
|
84
|
+
* (`mounted.setPaused(true)`) — reporting loudly, once per world, when that
|
|
85
|
+
* capability is absent (an honest "cannot gate" instead of a silent no-op).
|
|
86
|
+
* Also silences every `pausable` world's audio via its
|
|
87
|
+
* `SystemAdapters.audio` (absent ⇒ the same loud, once-only report).
|
|
88
|
+
*/
|
|
89
|
+
pause(): void;
|
|
90
|
+
/** Resume every `pausable` world — the inverse of `pause()`, same
|
|
91
|
+
* idempotency and loop-gate/audio fan-out (no re-reporting; a world that
|
|
92
|
+
* couldn't be gated on pause simply never was, so resume has nothing to
|
|
93
|
+
* undo for it). */
|
|
94
|
+
resume(): void;
|
|
95
|
+
/**
|
|
96
|
+
* Advance exactly the currently-FROZEN roots by one fixed substep: the
|
|
97
|
+
* `pausable`, host-driven (`!drivesOwnLoop`) roots while `paused` is
|
|
98
|
+
* true. While NOT paused this is a whole-call no-op — under D10 the loop
|
|
99
|
+
* never stops, so every non-frozen world is already being ticked; an
|
|
100
|
+
* unconditional extra tick was the §7.1-2 double-tick bug (probe4). A
|
|
101
|
+
* `pausable`, self-driven world that was actually gated by `pause()`
|
|
102
|
+
* (adapter has `setPaused`) steps via its own loop-gate `step()`
|
|
103
|
+
* capability instead (absent ⇒ the same loud report as `pause()`); a
|
|
104
|
+
* self-driven world the gate couldn't reach is still running and is left
|
|
105
|
+
* alone. `dt` defaults to the engine's fixed timestep (1/60s — every real
|
|
106
|
+
* host constructs its loop with this value; see `create-runtime.ts`).
|
|
107
|
+
*/
|
|
108
|
+
step(dt?: number): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The kinds of render surface a world can be. `'three'` and `'canvas'`
|
|
113
|
+
* roots are canvas-backed; `'dom'` roots own a DOM layer.
|
|
114
|
+
*
|
|
115
|
+
* Re-exported from `adapter/adapter-surface.ts` so
|
|
116
|
+
* `adapter/root-adapter.ts`'s kind-tagged `MountedRoot` types can name it
|
|
117
|
+
* without an import cycle back to this file. This re-export keeps every
|
|
118
|
+
* existing `import type { AdapterSurface } from './game'` call site
|
|
119
|
+
* compiling unchanged.
|
|
120
|
+
*/
|
|
121
|
+
export type AdapterSurface = AdapterSurfaceLeaf;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A world's per-phase frame hooks.
|
|
125
|
+
* Populated on a `RootInstance` only for first-party mounts — an opaque/
|
|
126
|
+
* foreign mount has no phase-partitioned entry point, so it stays
|
|
127
|
+
* `undefined` and the Game's frame executor falls back to its single
|
|
128
|
+
* `mounted.update(dt)`. The mount's `updateCadence` chooses fixed-substep
|
|
129
|
+
* (the default) or display-rate dispatch.
|
|
130
|
+
*/
|
|
131
|
+
export interface RootFrameHooks {
|
|
132
|
+
/** Run this world's engine systems + component ticks + world-bound game
|
|
133
|
+
* systems for one phase. For a first-party world this delegates to the
|
|
134
|
+
* same `SystemRunner.runPhase` as its direct `mounted.update` entry. */
|
|
135
|
+
runPhase(phase: SystemPhaseName, dt: number): void;
|
|
136
|
+
/** Run once per substep, after ALL phases have run for ALL roots this
|
|
137
|
+
* substep (mirrors where `mounted.update`'s post-`systems.run` work sat
|
|
138
|
+
* today — e.g. `input.endFrame()`). Optional: a world may have nothing
|
|
139
|
+
* to do here. */
|
|
140
|
+
endFrame?(): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A single world instance: the unit of adaptation.
|
|
145
|
+
*/
|
|
146
|
+
/**
|
|
147
|
+
* One `SystemAdapters` slot a mounted root's `systems` table answered with
|
|
148
|
+
* `absent(reason)` (`adapter/system-slot.ts`) — a POSITIVE absence.
|
|
149
|
+
*
|
|
150
|
+
* Nothing is installed for it. It exists so a reader can tell "this game has no
|
|
151
|
+
* physics" from "nobody looked", which the adapter bag alone cannot do: an
|
|
152
|
+
* unfilled slot has the same shape for both, and only one of them is a work
|
|
153
|
+
* order.
|
|
154
|
+
*/
|
|
155
|
+
export interface DeclaredSystemAbsence {
|
|
156
|
+
/** The root whose declaration answered — absences are declared per root, and
|
|
157
|
+
* the slot is only truly absent when NO root fills it. */
|
|
158
|
+
readonly rootId: string;
|
|
159
|
+
readonly slot: keyof SystemAdapters;
|
|
160
|
+
/** The game's own words: what was searched, and what was found. */
|
|
161
|
+
readonly reason: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface RootInstance {
|
|
165
|
+
/** Manifest root id. */
|
|
166
|
+
readonly id: string;
|
|
167
|
+
readonly kind: AdapterSurface;
|
|
168
|
+
/** Per-world play/pause semantics. */
|
|
169
|
+
readonly pausable: boolean;
|
|
170
|
+
/** The RootAdapter that produced `mounted` — first-party or external.
|
|
171
|
+
* Deliberately narrower than `RootAdapter<K>` (T7.5): this field is only
|
|
172
|
+
* ever read for `.id` (`Game.registerRoot`'s diagnostic message below) —
|
|
173
|
+
* never re-invoked — and `RootAdapter<K>`'s `mount` signature legitimately
|
|
174
|
+
* differs per kind's host CONTEXT (`HostContextFor<K>`, P-8: a canvas
|
|
175
|
+
* adapter's `CanvasHostContext`, a react adapter's `DomHostContext`, vs a
|
|
176
|
+
* three adapter's `ThreeHostContext`), so requiring the FULL interface here
|
|
177
|
+
* would force each registration site to name its own `K` for no behavioral
|
|
178
|
+
* gain — see `AdapterHandle` below. */
|
|
179
|
+
readonly adapter: AdapterHandle;
|
|
180
|
+
/** The live mounted surface — one of `MountedRoot`'s kind-tagged shapes
|
|
181
|
+
* (T7.5; `MountedThreeRoot` for a three world, `MountedCanvasRoot` for
|
|
182
|
+
* canvas, `MountedReactRoot` for react). */
|
|
183
|
+
readonly mounted: MountedRoot;
|
|
184
|
+
/** Kind-narrowed accessor: throws a descriptive error when this world is
|
|
185
|
+
* not a three world. */
|
|
186
|
+
threeScene(): THREE.Scene;
|
|
187
|
+
/** Kind-narrowed accessor for canvas roots: returns the native substrate
|
|
188
|
+
* root the adapter mounted. The host keeps it opaque; a substrate adapter
|
|
189
|
+
* that declared the matching name narrows `T`. */
|
|
190
|
+
canvasRoot<T = unknown>(): T;
|
|
191
|
+
/** Kind-narrowed accessor for DOM roots: returns the
|
|
192
|
+
* DOM-root layer `<div>` the host mounted this world's react tree into
|
|
193
|
+
* (the SAME element passed as `container` to `createRootInstance` —
|
|
194
|
+
* identity matters, mirroring `threeScene()`/`canvasRoot()`'s "same
|
|
195
|
+
* instance the adapter mounted" contract). Throws descriptively for a
|
|
196
|
+
* non-react world, or a react world built without a `container` (see
|
|
197
|
+
* `RootInstanceInit.container`). */
|
|
198
|
+
reactRoot(): HTMLElement;
|
|
199
|
+
/** Present only when the world's mount is first-party (Rapier3D for
|
|
200
|
+
* three roots). */
|
|
201
|
+
readonly physics?: PhysicsRegistry | undefined;
|
|
202
|
+
readonly collisions?: CollisionSystem | undefined;
|
|
203
|
+
/** Present only when the world's mount owns a Rapier-2D world — the 2D
|
|
204
|
+
* analog of `physics` above. Kept as a separate field (not folded into
|
|
205
|
+
* `physics` as a union) so 3D call sites keep their non-union
|
|
206
|
+
* `PhysicsRegistry` typing unchanged. */
|
|
207
|
+
readonly physics2d?: Physics2DRegistry | undefined;
|
|
208
|
+
/** Kind-typed via `mounted` in T7.5; `unknown` here deliberately. */
|
|
209
|
+
readonly camera?: unknown;
|
|
210
|
+
/** Phase-partitioned frame entry point — present only for
|
|
211
|
+
* first-party mounts. `undefined` for an opaque/foreign mount, which
|
|
212
|
+
* `GameInternal.runFrame` drives via its single `mounted.update` call
|
|
213
|
+
* instead (unless it `drivesOwnLoop`, in which case it isn't ticked at
|
|
214
|
+
* all — see `runFrame`). */
|
|
215
|
+
readonly frame?: RootFrameHooks | undefined;
|
|
216
|
+
/** True from the moment this world's `mounted.dispose()` runs. There is no
|
|
217
|
+
* `unregisterRoot`, so a disposed world stays in `Game.roots`; `runFrame`
|
|
218
|
+
* reads this to skip it entirely — frame hooks AND the opaque
|
|
219
|
+
* `mounted.update` fallback (see the disposed-world guard in
|
|
220
|
+
* `createRootInstance`). */
|
|
221
|
+
readonly disposed: boolean;
|
|
222
|
+
/**
|
|
223
|
+
* THIS ROOT'S WHOLE DECLARATION, BOUND — `adapter/binding.ts`'s
|
|
224
|
+
* {@link RootBinding}, assembled at registration from this instance's own
|
|
225
|
+
* `adapter`/`mounted`/`pausable` plus the {@link RootDeclaration} the
|
|
226
|
+
* resolver produced. Every member of it is a reference to something already
|
|
227
|
+
* reachable through the fields above; it is the ONE place the five protocol
|
|
228
|
+
* families are addressed by name rather than re-assembled per consumer.
|
|
229
|
+
*
|
|
230
|
+
* `null` in exactly ONE honest case, and never as a degrade: the
|
|
231
|
+
* registration carried NO declaration — a bare
|
|
232
|
+
* `registerThreeRoot(game, adapter, mounted)` from a test harness, or a
|
|
233
|
+
* `mountManifestRoots` caller that supplied an already-constructed adapter
|
|
234
|
+
* with no entry namespace behind it. There is no manifest root, no parsed
|
|
235
|
+
* definition and no entry surface to bind, and fabricating them would be
|
|
236
|
+
* the anti-shim rule broken at the seam that exists to hold it. Every root
|
|
237
|
+
* the editor resolves through `resolveAllRoots`/`resolveAllRootEntries`
|
|
238
|
+
* carries a declaration, so its binding is non-null.
|
|
239
|
+
*
|
|
240
|
+
* A mount that exposes no `authoring` does NOT null this out; it costs that
|
|
241
|
+
* root its `projection` and `truth` members only (see
|
|
242
|
+
* {@link RootBinding.projection}). That distinction is load-bearing: the
|
|
243
|
+
* editor supplies native TSX roots' authoring itself, so treating an
|
|
244
|
+
* absent `mounted.authoring` as an absent BINDING made `binding` null for
|
|
245
|
+
* every first-party root and left four perfectly constructible families
|
|
246
|
+
* unreadable.
|
|
247
|
+
*/
|
|
248
|
+
readonly binding: RootBinding | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Minimal identity surface `RootInstance.adapter` needs (T7.5) — see that
|
|
253
|
+
* field's doc comment for why it's narrower than `RootAdapter<K>`. Any real
|
|
254
|
+
* adapter object (a NAMED type, not a fresh object literal) — `RootAdapter<K>`,
|
|
255
|
+
* `Pixi2DRootAdapter`, `ReactRootAdapter`, or a project's own custom adapter —
|
|
256
|
+
* satisfies this trivially (extra members beyond `id` are always fine for a
|
|
257
|
+
* non-literal source); a bare `{ id, mount }` object literal built INLINE at
|
|
258
|
+
* a `createRootInstance`/`registerXRoot` call site needs an intermediate
|
|
259
|
+
* `const` (excess-property checking only special-cases fresh literals).
|
|
260
|
+
*/
|
|
261
|
+
export interface AdapterHandle {
|
|
262
|
+
readonly id: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Inputs to {@link createRootInstance}. */
|
|
266
|
+
export interface RootInstanceInit {
|
|
267
|
+
readonly id: string;
|
|
268
|
+
readonly kind: AdapterSurface;
|
|
269
|
+
/** Defaults to `true` (D10's per-world default). */
|
|
270
|
+
readonly pausable?: boolean;
|
|
271
|
+
readonly adapter: AdapterHandle;
|
|
272
|
+
readonly mounted: MountedRoot;
|
|
273
|
+
/** Required when `kind === 'three'` — backs `threeScene()`. */
|
|
274
|
+
readonly scene?: THREE.Scene | undefined;
|
|
275
|
+
/** Required when `kind === 'canvas'` — backs `canvasRoot()`, symmetric with
|
|
276
|
+
* `scene` for three above. */
|
|
277
|
+
readonly canvasRoot?: unknown;
|
|
278
|
+
/** Required when `kind === 'dom'` — backs `reactRoot()` (T6.2 slice 1,
|
|
279
|
+
* symmetric with `scene`/`stage` above): the DOM-root layer `<div>` this
|
|
280
|
+
* world's react tree is mounted into. */
|
|
281
|
+
readonly container?: HTMLElement | undefined;
|
|
282
|
+
readonly physics?: PhysicsRegistry | undefined;
|
|
283
|
+
readonly collisions?: CollisionSystem | undefined;
|
|
284
|
+
readonly physics2d?: Physics2DRegistry | undefined;
|
|
285
|
+
readonly camera?: unknown;
|
|
286
|
+
readonly frame?: RootFrameHooks | undefined;
|
|
287
|
+
/** This root's bound declaration — see {@link RootInstance.binding} for
|
|
288
|
+
* when it is legitimately absent. */
|
|
289
|
+
readonly binding?: RootBinding | null | undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build a `RootInstance` whose kind-narrowed accessors throw descriptively
|
|
294
|
+
* on kind mismatch. This is the one place that builds
|
|
295
|
+
* `threeScene`/`canvasRoot`, so every world (however it's constructed, in this
|
|
296
|
+
* slice or later ones) gets identical throw behavior.
|
|
297
|
+
*/
|
|
298
|
+
export function createRootInstance(init: RootInstanceInit): RootInstance {
|
|
299
|
+
const {
|
|
300
|
+
id,
|
|
301
|
+
kind,
|
|
302
|
+
pausable = true,
|
|
303
|
+
adapter,
|
|
304
|
+
mounted,
|
|
305
|
+
scene,
|
|
306
|
+
canvasRoot,
|
|
307
|
+
container,
|
|
308
|
+
physics,
|
|
309
|
+
collisions,
|
|
310
|
+
physics2d,
|
|
311
|
+
camera,
|
|
312
|
+
frame,
|
|
313
|
+
binding = null,
|
|
314
|
+
} = init;
|
|
315
|
+
|
|
316
|
+
// A three world with no scene has nothing for `threeScene()` to return —
|
|
317
|
+
// fail loudly HERE, at construction, rather than letting `threeScene()`
|
|
318
|
+
// throw its generic "not a three world" message later for a world whose
|
|
319
|
+
// kind IS three (checklist item 5; a misleading error for this case).
|
|
320
|
+
if (kind === 'three' && !scene) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`RootInstance "${id}" (kind: three): a three world requires a scene — ` +
|
|
323
|
+
'pass `scene` in RootInstanceInit.',
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Symmetric check for canvas — a canvas world with no native substrate root
|
|
328
|
+
// has nothing for `canvasRoot()` to return; fail loudly here, at
|
|
329
|
+
// construction, same as the three/scene check above.
|
|
330
|
+
if (kind === 'canvas' && (canvasRoot === undefined || canvasRoot === null)) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`RootInstance "${id}" (kind: canvas): a canvas world requires a native substrate root — ` +
|
|
333
|
+
'pass `canvasRoot` in RootInstanceInit.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Symmetric check for react (T6.2 slice 1) — a react world with no
|
|
338
|
+
// container has nothing for `reactRoot()` to return; fail loudly here,
|
|
339
|
+
// at construction, same as the two checks above.
|
|
340
|
+
if (kind === 'dom' && !container) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`RootInstance "${id}" (kind: react): a react world requires a container — ` +
|
|
343
|
+
'pass `container` in RootInstanceInit.',
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Disposed-world guard (T7.1 slice 3). There
|
|
348
|
+
// is no `unregisterRoot` (decision 4 — roots are manifest-declared; a
|
|
349
|
+
// whole Game is disposed, not one world out of its registry), so a caller
|
|
350
|
+
// that disposes ONE world's `mounted` directly (e.g. ending a sub-session)
|
|
351
|
+
// leaves that `RootInstance` sitting in `Game.roots` — and `runFrame`
|
|
352
|
+
// would otherwise keep invoking its (now-torn-down) frame hooks every
|
|
353
|
+
// subsequent frame. `MountedThreeRoot` has no public "am I disposed" flag to
|
|
354
|
+
// read, so this wraps `mounted.dispose` in place (mutating the SAME mount
|
|
355
|
+
// object every holder of `mounted` shares — calling `mounted.dispose()`
|
|
356
|
+
// directly, exactly like calling `world.mounted.dispose()`, trips this)
|
|
357
|
+
// to flip a flag this instance exposes as `disposed`, and wraps the frame
|
|
358
|
+
// hooks so they silently no-op once it is set. `runFrame` reads `disposed`
|
|
359
|
+
// and skips the world outright, which is what also covers the OPAQUE
|
|
360
|
+
// `mounted.update` fallback — the path every three root takes now that a
|
|
361
|
+
// mount is a plain `RootAdapter` with no phase-partitioned entry point.
|
|
362
|
+
let disposed = false;
|
|
363
|
+
const originalDispose = mounted.dispose.bind(mounted);
|
|
364
|
+
mounted.dispose = () => {
|
|
365
|
+
disposed = true;
|
|
366
|
+
originalDispose();
|
|
367
|
+
};
|
|
368
|
+
const guardedFrame: RootFrameHooks | undefined = frame
|
|
369
|
+
? {
|
|
370
|
+
runPhase(phase, dt) {
|
|
371
|
+
if (disposed) return;
|
|
372
|
+
frame.runPhase(phase, dt);
|
|
373
|
+
},
|
|
374
|
+
endFrame() {
|
|
375
|
+
if (disposed) return;
|
|
376
|
+
frame.endFrame?.();
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
: undefined;
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
id,
|
|
383
|
+
kind,
|
|
384
|
+
pausable,
|
|
385
|
+
adapter,
|
|
386
|
+
mounted,
|
|
387
|
+
physics,
|
|
388
|
+
collisions,
|
|
389
|
+
physics2d,
|
|
390
|
+
camera,
|
|
391
|
+
frame: guardedFrame,
|
|
392
|
+
binding,
|
|
393
|
+
get disposed(): boolean {
|
|
394
|
+
return disposed;
|
|
395
|
+
},
|
|
396
|
+
threeScene(): THREE.Scene {
|
|
397
|
+
if (kind !== 'three' || !scene) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`RootInstance "${id}" (kind: ${kind}): threeScene() requested but this world is not ` +
|
|
400
|
+
'a three world',
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return scene;
|
|
404
|
+
},
|
|
405
|
+
canvasRoot<T = unknown>(): T {
|
|
406
|
+
if (kind !== 'canvas' || canvasRoot === undefined || canvasRoot === null) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`RootInstance "${id}" (kind: ${kind}): canvasRoot() requested but this world is not ` +
|
|
409
|
+
'a canvas world',
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return canvasRoot as T;
|
|
413
|
+
},
|
|
414
|
+
reactRoot(): HTMLElement {
|
|
415
|
+
// The `!container` branch a react world could hit here is now
|
|
416
|
+
// unreachable (T6.2 slice 1): construction above throws for
|
|
417
|
+
// `kind === 'dom'` with no `container`, symmetric with
|
|
418
|
+
// `threeScene()`/`canvasRoot()`'s guards.
|
|
419
|
+
if (kind !== 'dom' || !container) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`RootInstance "${id}" (kind: ${kind}): reactRoot() requested but this world is not ` +
|
|
422
|
+
'a react world',
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return container;
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* One world's half of a debris disposal, as {@link disposeDebrisSubtree} needs
|
|
432
|
+
* it.
|
|
433
|
+
*
|
|
434
|
+
* `physics`/`rapierWorld` are OPTIONAL: an owner that builds no first-party
|
|
435
|
+
* Rapier runtime (an R3F world) carries neither, and {@link
|
|
436
|
+
* disposeDebrisSubtree} reads them behind `readRapierHandles` rather than
|
|
437
|
+
* unconditionally.
|
|
438
|
+
*/
|
|
439
|
+
export interface DebrisOwner {
|
|
440
|
+
readonly physics?: PhysicsRegistry;
|
|
441
|
+
readonly rapierWorld?: import('@dimforge/rapier3d-compat').World;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Read an owner's Rapier handles, treating an absent pair as "this owner holds
|
|
446
|
+
* no rigid bodies" — the honest reading for a world (R3F, or a bare
|
|
447
|
+
* `ComponentManager`-only owner) that builds no first-party physics runtime.
|
|
448
|
+
*/
|
|
449
|
+
function readRapierHandles(owner: DebrisOwner): {
|
|
450
|
+
physics: NonNullable<DebrisOwner['physics']>;
|
|
451
|
+
rapierWorld: NonNullable<DebrisOwner['rapierWorld']>;
|
|
452
|
+
} | null {
|
|
453
|
+
const { physics, rapierWorld } = owner;
|
|
454
|
+
if (!physics || !rapierWorld) return null;
|
|
455
|
+
return { physics, rapierWorld };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* P3 — what `SimClock.disposeAfter` actually does. `core/sim-clock.ts` knows
|
|
460
|
+
* only *when*; this is the *what*, and it lives here because it is the runtime
|
|
461
|
+
* that knows about Rapier and shared geometry.
|
|
462
|
+
*
|
|
463
|
+
* The order mirrors `editor-game/src/host/roots/r3f-root.tsx`'s world teardown for
|
|
464
|
+
* ONE subtree. Within the physics step,
|
|
465
|
+
* `rapierWorld.removeRigidBody(body)` comes BEFORE `physics.remove(node)`: the
|
|
466
|
+
* registry's `remove()` only drops index entries, so reversing the two leaks
|
|
467
|
+
* the Rapier body.
|
|
468
|
+
*
|
|
469
|
+
* `owners` is every world that could own part of the subtree. `physics.get`
|
|
470
|
+
* is a no-op for a node the
|
|
471
|
+
* owner does not own, so offering the subtree to each is safe.
|
|
472
|
+
*
|
|
473
|
+
* Safe on an object already removed or already disposed: `removeFromParent` on
|
|
474
|
+
* a parentless `Object3D` is a no-op and three's `dispose()` calls are
|
|
475
|
+
* idempotent.
|
|
476
|
+
*/
|
|
477
|
+
export function disposeDebrisSubtree(
|
|
478
|
+
obj: THREE.Object3D,
|
|
479
|
+
owners: ReadonlyArray<DebrisOwner>,
|
|
480
|
+
): void {
|
|
481
|
+
const nodes: THREE.Object3D[] = [];
|
|
482
|
+
obj.traverse((node) => nodes.push(node));
|
|
483
|
+
for (const owner of owners) {
|
|
484
|
+
const rapier = readRapierHandles(owner);
|
|
485
|
+
if (!rapier) continue;
|
|
486
|
+
for (const node of nodes) {
|
|
487
|
+
const refs = rapier.physics.get(node);
|
|
488
|
+
if (!refs) continue;
|
|
489
|
+
rapier.rapierWorld.removeRigidBody(refs.body);
|
|
490
|
+
rapier.physics.remove(node);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
obj.removeFromParent();
|
|
494
|
+
for (const node of nodes) {
|
|
495
|
+
// Duck-typed rather than `instanceof THREE.Mesh` so this module keeps its
|
|
496
|
+
// TYPE-ONLY three import (it is surface-neutral — it also hosts pixi
|
|
497
|
+
// roots). Covers Points/Line/Sprite debris too, which a Mesh check would
|
|
498
|
+
// silently leak.
|
|
499
|
+
const drawable = node as Partial<THREE.Mesh>;
|
|
500
|
+
if (!hasUserData(node, '__sharedGeometry')) drawable.geometry?.dispose();
|
|
501
|
+
const material = drawable.material;
|
|
502
|
+
if (Array.isArray(material)) {
|
|
503
|
+
for (const m of material) m.dispose();
|
|
504
|
+
} else {
|
|
505
|
+
material?.dispose();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The {@link DebrisOwner}s one registered world contributes. Written as a
|
|
512
|
+
* standalone function
|
|
513
|
+
* so `createGame`'s clock disposer is one line and the "which mounts count"
|
|
514
|
+
* rule has exactly one home.
|
|
515
|
+
*/
|
|
516
|
+
function debrisOwnersOf(_world: RootInstance): DebrisOwner[] {
|
|
517
|
+
// No mount carries first-party Rapier handles: physics is a declared
|
|
518
|
+
// system built inside the world's own tree, and the library owns its
|
|
519
|
+
// bodies' lifecycles there. The owner list stays as the disposal seam's
|
|
520
|
+
// shape; today it is always empty.
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The Game root. Owns the loop, raw-asset cache, world registry, input,
|
|
526
|
+
* and game-scoped `SystemRunner`. Surface-specific capabilities remain on
|
|
527
|
+
* their mounted roots or in the aggregated `SystemAdapters` contract.
|
|
528
|
+
*/
|
|
529
|
+
export interface Game {
|
|
530
|
+
readonly loop: GameLoop;
|
|
531
|
+
readonly assets: AssetCache;
|
|
532
|
+
/** Host identity for this private play run or coordinated Team Test. */
|
|
533
|
+
readonly playtest?: PlaytestContext | null;
|
|
534
|
+
/** Per-game diagnostic store. Disabled by default; the editor enables it on demand. */
|
|
535
|
+
readonly profiler: PerformanceProfiler;
|
|
536
|
+
/**
|
|
537
|
+
* The game-scoped `SystemRunner`, separate from any world's own runner.
|
|
538
|
+
* Within each phase,
|
|
539
|
+
* `GameInternal.runFrame` runs THIS runner's `runPhase` first, before any
|
|
540
|
+
* world's engine systems/component ticks/world-bound game systems (e.g.
|
|
541
|
+
* `ctx.systems.add`, which stays world-bound to the default world).
|
|
542
|
+
* Empty for every existing game (nothing registers
|
|
543
|
+
* against it), so an empty runner has no frame cost beyond dispatch.
|
|
544
|
+
*/
|
|
545
|
+
readonly systems: SystemRunner;
|
|
546
|
+
/** Declaration-ordered. This is the same array reference `registerRoot`
|
|
547
|
+
* mutates, not a snapshot, so holders observe
|
|
548
|
+
* later registrations. */
|
|
549
|
+
readonly roots: ReadonlyArray<RootInstance>;
|
|
550
|
+
world(id: string): RootInstance | null;
|
|
551
|
+
/** First three world, else first world. Throws descriptively when no
|
|
552
|
+
* world has been registered yet. */
|
|
553
|
+
readonly defaultRoot: RootInstance;
|
|
554
|
+
/**
|
|
555
|
+
* Game-scoped aggregation of every world's `SystemAdapters` (§7.1-3:
|
|
556
|
+
* "`registerSystemAdapter` is game-scoped" — the recorded decision this
|
|
557
|
+
* getter finally implements; probe1). Each mounted world builds its OWN
|
|
558
|
+
* `mounted.systems` object (a game registers capabilities like `networking`
|
|
559
|
+
* from ITS OWN `setup()`, via `ctx.registerSystemAdapter`, per-world) —
|
|
560
|
+
* this merges every world's `mounted.systems` into ONE `SystemAdapters`, in
|
|
561
|
+
* `roots` REGISTRATION order, first registration wins per key. A later
|
|
562
|
+
* world registering the SAME kind (e.g. two roots both exposing
|
|
563
|
+
* `networking`) does not override the first — instead this warns ONCE per
|
|
564
|
+
* (game instance, key) naming both world ids, matching this file's `[game]
|
|
565
|
+
* world "<id>" …` console idiom (see `registerRoot` below). NOT to be
|
|
566
|
+
* confused with `Game.systems` (the game-scoped `SystemRunner` bucket, an
|
|
567
|
+
* entirely different concept — see that field's doc comment) — this name
|
|
568
|
+
* was deliberately chosen not to collide with it.
|
|
569
|
+
*
|
|
570
|
+
* A plain getter (recomputed on every read, not cached) so a LATER
|
|
571
|
+
* `registerRoot` call (or a world's setup registering a NEW adapter kind
|
|
572
|
+
* after this was first read) is always reflected — only the COLLISION
|
|
573
|
+
* warning is deduped (once per key, for the lifetime of this `Game`).
|
|
574
|
+
* Includes every world regardless of first-party-ness — `mounted.systems`
|
|
575
|
+
* is a capability any adapter (first-party or foreign) may expose.
|
|
576
|
+
*/
|
|
577
|
+
readonly systemAdapters: SystemAdapters;
|
|
578
|
+
/** Subscribe when a mounted root registers or replaces a system adapter. */
|
|
579
|
+
subscribeSystemAdapters?(listener: () => void): () => void;
|
|
580
|
+
/**
|
|
581
|
+
* Install a root's STATICALLY DECLARED system-adapter slots — the native
|
|
582
|
+
* module surface's `export const systems` (adapter/native-debug-module.ts),
|
|
583
|
+
* already validated by `contract-system-adapters.ts`. The host calls this
|
|
584
|
+
* once per declaring root at mount; project components never do
|
|
585
|
+
* (ARCHITECTURE-CORE §System adapters: "Components never call
|
|
586
|
+
* `registerSystemAdapter`" — this door is the replacement, and the slots it
|
|
587
|
+
* installs take precedence over a component registration for the same root
|
|
588
|
+
* in {@link systemAdapters}'s merge, with the standard collision warning).
|
|
589
|
+
* Throws on an unmounted root id or a second declaration for the same root.
|
|
590
|
+
* Optional for the same reason {@link subscribeSystemAdapters} is: partial
|
|
591
|
+
* `Game` doubles in tests; `installNativeSystemsBindings` refuses loudly
|
|
592
|
+
* when the hosting Game lacks it.
|
|
593
|
+
*/
|
|
594
|
+
installDeclaredSystemAdapters?(
|
|
595
|
+
rootId: string,
|
|
596
|
+
slots: Readonly<Partial<SystemAdapters>>,
|
|
597
|
+
absent?: readonly DeclaredSystemAbsence[],
|
|
598
|
+
): void;
|
|
599
|
+
/**
|
|
600
|
+
* Every slot a mounted root's `systems` table answered with `absent(reason)`,
|
|
601
|
+
* across all roots — the game-scoped read of the positive absences.
|
|
602
|
+
*
|
|
603
|
+
* This is what separates "this game has no networking" from "nobody looked",
|
|
604
|
+
* and it is the ONLY source for that distinction on a native mount: the
|
|
605
|
+
* adapter bag ({@link systemAdapters}) can only say a slot is unfilled, which
|
|
606
|
+
* is the same shape for both. Empty means no root declared any absence — NOT
|
|
607
|
+
* that every slot is answered.
|
|
608
|
+
*/
|
|
609
|
+
readonly declaredSystemAbsences?: readonly DeclaredSystemAbsence[];
|
|
610
|
+
/** Game-level play-state control surface (D10, T7.6) — see {@link PlayState}. */
|
|
611
|
+
readonly play: PlayState;
|
|
612
|
+
/**
|
|
613
|
+
* The interpolation alpha the most recent display frame presented
|
|
614
|
+
* at: `accumulator / fixedDt`, in `[0, 1]`. `0` means "exactly on the last
|
|
615
|
+
* completed fixed state", `0.5` means "halfway to the next one".
|
|
616
|
+
*
|
|
617
|
+
* Read it to interpolate your own transforms between the last two fixed
|
|
618
|
+
* states; it is the same number `onRenderStep` hands its callbacks, exposed
|
|
619
|
+
* here for code that renders from somewhere other than a callback. Stays
|
|
620
|
+
* `0` for a Game whose host never wired a display-rate render pass (see
|
|
621
|
+
* {@link onRenderStep}).
|
|
622
|
+
*/
|
|
623
|
+
readonly renderAlpha: number;
|
|
624
|
+
/**
|
|
625
|
+
* The `RenderStepped`-shaped host: register a callback that runs
|
|
626
|
+
* ONCE PER DISPLAY FRAME, before that frame's `preRender`/`render` phases,
|
|
627
|
+
* with `(alpha, displayDt)`.
|
|
628
|
+
*
|
|
629
|
+
* This is the seam for presentation-only work whose natural rate is the
|
|
630
|
+
* monitor's, not the simulation's — camera polish/smoothing, procedural
|
|
631
|
+
* sway, a cosmetic bob. It is NOT a gameplay hook: it can fire twice
|
|
632
|
+
* between two fixed substeps (120 Hz display, 60 Hz sim) and zero times
|
|
633
|
+
* across a `runTicks` fast-forward, so anything that must be deterministic
|
|
634
|
+
* belongs in a fixed engine phase (`ctx.systems.add`) instead. Ecosystem
|
|
635
|
+
* presentation hooks such as R3F's `useFrame` share this display clock.
|
|
636
|
+
*
|
|
637
|
+
* Reached from game code as `ctx.game?.onRenderStep(...)`. Returns an
|
|
638
|
+
* unsubscribe function; pass `opts.signal` to unsubscribe with the same
|
|
639
|
+
* `AbortSignal` a `setup()` already uses for its listeners and sim timers
|
|
640
|
+
* (`core/sim-clock.ts`'s ownership section — cancelling what you registered
|
|
641
|
+
* is the game's job, and a stale render-step closure is the same hazard
|
|
642
|
+
* class as a stale event listener).
|
|
643
|
+
*
|
|
644
|
+
* Silent-no-op honesty: a callback registered on a Game whose host wired no
|
|
645
|
+
* display-rate render pass (a bare test harness, a `drivesOwnLoop`-only
|
|
646
|
+
* mount, an `externalDrive` capture page) never fires, because nothing
|
|
647
|
+
* drives it. That is the same shape as `ctx.clock` on a Game-less mount.
|
|
648
|
+
*/
|
|
649
|
+
onRenderStep(
|
|
650
|
+
fn: (alpha: number, displayDt: number) => void,
|
|
651
|
+
opts?: { signal?: AbortSignal | undefined },
|
|
652
|
+
): () => void;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Host-internal extension of {@link Game}: adds `registerRoot` (the host's
|
|
657
|
+
* wiring surface for populating the world registry) and `runFrame` (the
|
|
658
|
+
* frame executor). NEITHER is part of the game-facing `Game` surface —
|
|
659
|
+
* games never call either directly; the host loop
|
|
660
|
+
* (`createGameRuntime`) and `GameSession.step()` are the only callers of
|
|
661
|
+
* `runFrame`.
|
|
662
|
+
*/
|
|
663
|
+
export interface GameInternal extends Game {
|
|
664
|
+
/** Host-side signal used by root contexts after `registerSystemAdapter`. */
|
|
665
|
+
notifySystemAdaptersChanged(): void;
|
|
666
|
+
/** Append a world in declaration order. Throws on a duplicate id. */
|
|
667
|
+
registerRoot(world: RootInstance): void;
|
|
668
|
+
/**
|
|
669
|
+
* Run ONE fixed substep across every phase and every world:
|
|
670
|
+
*
|
|
671
|
+
* ```
|
|
672
|
+
* for phase in PHASE_ORDER:
|
|
673
|
+
* game.systems.runPhase(phase, dt) // game-scoped, first
|
|
674
|
+
* for world in roots (declaration order):
|
|
675
|
+
* if world.mounted.drivesOwnLoop: continue
|
|
676
|
+
* world.frame?.runPhase(phase, dt)
|
|
677
|
+
* for world in roots: // after ALL phases
|
|
678
|
+
* if world.mounted.drivesOwnLoop: continue
|
|
679
|
+
* if world.frame: world.frame.endFrame?.()
|
|
680
|
+
* else if updateCadence != 'display' OR render phases are included:
|
|
681
|
+
* world.mounted.update?.(dt) // opaque world fallback
|
|
682
|
+
* ```
|
|
683
|
+
*
|
|
684
|
+
* A single first-party world and its direct `mounted.update(dt)` entry run
|
|
685
|
+
* the same `SystemRunner` and `postFrame` work. A `drivesOwnLoop` world is
|
|
686
|
+
* never ticked here. An opaque host-driven world (no `frame`) gets exactly
|
|
687
|
+
* one `update(dt)` call per substep after the phase loop by default. A
|
|
688
|
+
* display-cadence opaque world is withheld when `skipRenderPhases` is true;
|
|
689
|
+
* the matching host calls it once from {@link runRenderFrame}. A direct
|
|
690
|
+
* `runFrame` with render phases included still calls it once.
|
|
691
|
+
*
|
|
692
|
+
* D10/T7.6 play-state addendum: when `Game.play.paused` is true, every
|
|
693
|
+
* `pausable` (and non-`drivesOwnLoop`) world skips every phase EXCEPT
|
|
694
|
+
* `render` (still called, every substep, with `dt` forced to `0`) and skips
|
|
695
|
+
* its `endFrame`/opaque-`update` call entirely — a `pausable: false` world
|
|
696
|
+
* is completely unaffected. `Game.play.step()` drives this function via the
|
|
697
|
+
* internal-only `onlyFrozen` mode (see `runFrameImpl` — not part of this
|
|
698
|
+
* public, host-facing signature): it ticks exactly the currently
|
|
699
|
+
* frozen set (host-driven, `pausable`, and `paused`) through every phase +
|
|
700
|
+
* `endFrame` with the real `dt` (not the render-phase's forced `0`), and
|
|
701
|
+
* touches no other world at all — a natural no-op while not paused, since
|
|
702
|
+
* the frozen set is then empty.
|
|
703
|
+
*
|
|
704
|
+
* `opts.skipRenderPhases` omits `preRender`+`render` from
|
|
705
|
+
* the pass — every other phase, and `endFrame`, run exactly as always. A
|
|
706
|
+
* host that drives presentation at DISPLAY rate sets it on every substep
|
|
707
|
+
* and calls {@link runRenderFrame} once per real frame instead
|
|
708
|
+
* (`create-runtime.ts`). A host that does
|
|
709
|
+
* not set it renders inside the substep — which is why
|
|
710
|
+
* `externalDrive` capture (`render-control.ts`'s `simulateSubsteps`, which
|
|
711
|
+
* calls `runFrame(fixedDt)` with no opts) is frame-exact.
|
|
712
|
+
* `runTicks` sets it per tick for its `render: 'none' | 'last'` modes.
|
|
713
|
+
*/
|
|
714
|
+
runFrame(dt: number, opts?: { skipRenderPhases?: boolean }): void;
|
|
715
|
+
/**
|
|
716
|
+
* Run ONE display frame's presentation pass: the registered
|
|
717
|
+
* `onRenderStep` callbacks, then the `preRender` and `render` phases across
|
|
718
|
+
* game-scoped systems and every host-driven world, in the same
|
|
719
|
+
* game-systems-then-worlds order {@link runFrame} uses.
|
|
720
|
+
*
|
|
721
|
+
* ```
|
|
722
|
+
* for cb in onRenderStep callbacks: cb(alpha, displayDt)
|
|
723
|
+
* for phase in [preRender, render]:
|
|
724
|
+
* game.systems.runPhase(phase, displayDt)
|
|
725
|
+
* for world in roots (declaration order):
|
|
726
|
+
* if world.mounted.drivesOwnLoop: continue
|
|
727
|
+
* world.frame?.runPhase(phase, frozen ? 0 : displayDt)
|
|
728
|
+
* for opaque world with updateCadence == 'display':
|
|
729
|
+
* world.mounted.update?.(frozen ? 0 : displayDt)
|
|
730
|
+
* ```
|
|
731
|
+
*
|
|
732
|
+
* Advances NOTHING: no `tick`, no `simT`, no sim-clock flush, no `endFrame`. It is presentation only, which is what makes calling
|
|
733
|
+
* it at a rate the simulation does not share safe in the first place.
|
|
734
|
+
*
|
|
735
|
+
* D10's pause rule carries over unchanged in substance: a frozen world
|
|
736
|
+
* (paused + `pausable`) still RENDERS — that is what keeps a paused editor
|
|
737
|
+
* viewport painted — but with `dt` forced to `0`, so no time-based render
|
|
738
|
+
* effect animates a frozen scene. What changes is only the cadence: once per
|
|
739
|
+
* display frame instead of once per fixed substep.
|
|
740
|
+
*/
|
|
741
|
+
runRenderFrame(alpha: number, displayDt: number): void;
|
|
742
|
+
/**
|
|
743
|
+
* D15/T-D15.3-.4 — a deterministic fast-forward primitive: synchronously
|
|
744
|
+
* call the SAME per-tick pipeline `runFrame` uses, `n` times in a tight
|
|
745
|
+
* loop, with `dt` fixed to the host loop's own fixed timestep
|
|
746
|
+
* (`this.loop.fixedDt` — `core/game-loop.ts`). This generalizes
|
|
747
|
+
* `render-control.ts`'s proven `VgaiRenderHarness. simulateSubsteps` from
|
|
748
|
+
* the capture-only door (`?vgai-render=1`) to a Game-level primitive every
|
|
749
|
+
* door can reach (the bridge's `window.__vgai.runTicks`, the editor relay's
|
|
750
|
+
* `run-ticks` case → `play.runTicks`) — `simulateSubsteps` itself is
|
|
751
|
+
* UNTOUCHED by this addition (it may later delegate to this method; not
|
|
752
|
+
* this unit's job).
|
|
753
|
+
*
|
|
754
|
+
* Semantics:
|
|
755
|
+
* - **Decoupled from wall clock and the accumulator.** `runTicks` drives
|
|
756
|
+
* `runFrame` DIRECTLY — it never goes through `GameLoop`'s own
|
|
757
|
+
* accumulator/spiral-of-death drop path, so `n` ticks always advance
|
|
758
|
+
* `tick`/`simT` by exactly `n`/`n * fixedDt`, on any hardware, regardless
|
|
759
|
+
* of how slow or fast the call actually took wall-clock-wise. Single-
|
|
760
|
+
* threaded-burst note: because JS has one thread, this synchronous burst
|
|
761
|
+
* can never interleave with a real rAF frame — but if the host's own
|
|
762
|
+
* loop is still running (`loop.start()`), wall-clock time keeps accruing
|
|
763
|
+
* in ITS accumulator while this call executes; the NEXT rAF frame after
|
|
764
|
+
* the burst sees that gap and the loop's existing `maxAccumulator` clamp
|
|
765
|
+
* absorbs it exactly as it would absorb any other slow-frame gap (up to
|
|
766
|
+
* 8 substeps, additional time dropped) — `runTicks` does not need to
|
|
767
|
+
* (and does not) touch the accumulator itself to make this safe.
|
|
768
|
+
* One consequence worth its own sentence: because the burst is one
|
|
769
|
+
* synchronous JS turn, no React commit can interleave it — a scene
|
|
770
|
+
* RELOAD triggered inside the burst (a translated
|
|
771
|
+
* `reload_current_scene`, a `SceneManager.LoadScene`) leaves the
|
|
772
|
+
* incoming world unmounted for the burst's remaining ticks, which the
|
|
773
|
+
* outgoing world's replacement therefore never simulates. Measured on
|
|
774
|
+
* the starter-kit port: a 20-tick burst spanning a reload left the
|
|
775
|
+
* fresh scene with zero ticks (its census had no player), while the
|
|
776
|
+
* same 20 ticks driven as 1-tick calls interleaved the commit and
|
|
777
|
+
* matched the source engine, whose reloads happen between frames. A
|
|
778
|
+
* caller that can trigger remounts mid-window drives 1-tick bursts
|
|
779
|
+
* (the fidelity driver's `advanceTicks` is the worked example).
|
|
780
|
+
* - **`opts.render`** (default `'last'`): `'last'` skips the `preRender`/
|
|
781
|
+
* `render` phases for ticks `0..n-2` and runs the full phase list
|
|
782
|
+
* (including `preRender`/`render`) on the final tick only — the GGPO
|
|
783
|
+
* tick-without-render pattern. `'all'` renders every tick. `'none'`
|
|
784
|
+
* never renders, not even the last tick. `tick`/`simT`/the debug event
|
|
785
|
+
* ring advance identically on EVERY tick regardless of `render` — only the
|
|
786
|
+
* paint-affecting phases are skipped, so debug state providers stay correct
|
|
787
|
+
* even when fast-forwarding with no visible output.
|
|
788
|
+
* - **Refuses while paused.** Throws a structured `DebugError`
|
|
789
|
+
* (`code: 'RUN_TICKS_PAUSED'`) if `Game.play.paused` is true —
|
|
790
|
+
* `Game.play.step()` owns stepping the frozen set; `runTicks` is a
|
|
791
|
+
* running-game primitive, not a paused-world stepper, and silently
|
|
792
|
+
* no-op-ing or silently ignoring pause would violate the "byte-identical
|
|
793
|
+
* across doors" contract this primitive exists to provide.
|
|
794
|
+
* - **Does not bypass the input focus gate.** Every tick runs the same
|
|
795
|
+
* frame every other call to `runFrame` does; `runTicks` has no
|
|
796
|
+
* special-cased "force focus" behavior.
|
|
797
|
+
*/
|
|
798
|
+
runTicks(n: number, opts?: RunTicksOptions): void;
|
|
799
|
+
/** Release game-owned resources after every mounted root has disposed. */
|
|
800
|
+
dispose(): void;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Construct the (host-internal) Game shell. Callers: `createGameRuntime`
|
|
805
|
+
* builds this BEFORE mounting its one adapter, then registers the default
|
|
806
|
+
* three world once mount resolves (see `registerThreeRoot` in
|
|
807
|
+
* `create-runtime.ts`).
|
|
808
|
+
*/
|
|
809
|
+
export function createGame(opts: {
|
|
810
|
+
loop: GameLoop;
|
|
811
|
+
assets: AssetCache;
|
|
812
|
+
playtest?: PlaytestContext | null | undefined;
|
|
813
|
+
/** D15 (T-D15.1) — the root seed `ctx.random` boots from, on every world
|
|
814
|
+
* mounted onto this Game. Defaults to `DEFAULT_SEEDED_RANDOM_SEED` (a
|
|
815
|
+
* fixed, non-wall-clock constant — `ctx.random` is always reproducible
|
|
816
|
+
* on its own terms, whether or not the project's manifest DECLARES that
|
|
817
|
+
* reproducibility as a contract). The manifest-aware boot path
|
|
818
|
+
* (`mount-manifest.ts`'s `mountManifestRoots`) is what actually resolves
|
|
819
|
+
* `manifest.determinism.defaultSeed`/`?vgai-seed=`/explicit config and
|
|
820
|
+
* passes the result here, BEFORE any world's `mount()`/`setup()` runs —
|
|
821
|
+
* `createGameRuntime` always constructs the Game (this call) first (see
|
|
822
|
+
* this function's own doc comment below). */
|
|
823
|
+
seed?: number | undefined;
|
|
824
|
+
}): GameInternal {
|
|
825
|
+
const roots: RootInstance[] = [];
|
|
826
|
+
const profiler = createPerformanceProfiler();
|
|
827
|
+
const systems = createSystemRunner(profiler.systemObserver, 'game');
|
|
828
|
+
// D15 (T-D15.1) — the game-scoped seeded-random surface every world's
|
|
829
|
+
// `ctx.random` aliases (see `editor-game/src/host/roots/r3f-root.tsx`'s `ctx.random =
|
|
830
|
+
// ...`, wired the same way `ctx.debug` is just below). Constructed
|
|
831
|
+
// unconditionally (cheap — a handful of closures) regardless of whether
|
|
832
|
+
// this project ever declares `determinism.seededRandom`; only the BOOT
|
|
833
|
+
// SEED and the enforcement (the burn-down scan, the trap right below) are
|
|
834
|
+
// conditional on that declaration.
|
|
835
|
+
const seededRandom = createSeededRandom(opts.seed ?? DEFAULT_SEEDED_RANDOM_SEED);
|
|
836
|
+
// D15 (T-D15.3) — the dev-mode `Math.random` phase trap. Constructed
|
|
837
|
+
// unconditionally too (disabled by default: `rngTrapEnabled` starts
|
|
838
|
+
// `false`, so `runFrameImpl`'s enable/disable calls below are no-ops) —
|
|
839
|
+
// the manifest-aware boot path flips `rngTrapControl.setEnabled(true)`
|
|
840
|
+
// once it resolves `manifest.determinism?.seededRandom` (mirrors
|
|
841
|
+
// `debugRegistry.setRoomDeclared` being flipped post-hoc from the same
|
|
842
|
+
// boot path for the very same "only the caller who read the manifest
|
|
843
|
+
// knows" reason).
|
|
844
|
+
const rngTrap = createGameplayRngTrap();
|
|
845
|
+
let rngTrapEnabled = false;
|
|
846
|
+
const rngTrapControl = {
|
|
847
|
+
setEnabled(enabled: boolean): void {
|
|
848
|
+
rngTrapEnabled = enabled;
|
|
849
|
+
},
|
|
850
|
+
get enabled(): boolean {
|
|
851
|
+
return rngTrapEnabled;
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
// `tick` counts completed fixed substeps, `simT` accumulates their `dt` —
|
|
855
|
+
// both game-scoped, advanced ONLY under `advanced` (`runFrame`'s tail below), so a paused/frozen frame never
|
|
856
|
+
// advances either. The debug registry reads them via suppliers (not by
|
|
857
|
+
// capturing the numbers now) so its built-in `time` provider always sees
|
|
858
|
+
// the CURRENT values.
|
|
859
|
+
let tick = 0;
|
|
860
|
+
let simT = 0;
|
|
861
|
+
// The display-rate half. `renderAlpha` is the last alpha
|
|
862
|
+
// `runRenderFrameImpl` presented at (0 until a host drives one); the set is
|
|
863
|
+
// the `RenderStepped`-shaped registry `Game.onRenderStep` feeds. Deliberately
|
|
864
|
+
// NOT beside `tick`/`simT` in meaning: neither of these ever advances sim
|
|
865
|
+
// state, which is exactly what makes running them at the monitor's rate safe.
|
|
866
|
+
let renderAlpha = 0;
|
|
867
|
+
const renderStepCallbacks = new Set<(alpha: number, displayDt: number) => void>();
|
|
868
|
+
// P3 — the ONE sim clock this Game owns, declared beside the accumulator it
|
|
869
|
+
// is bound to (`flush(simT)` runs at the tail of `runFrameImpl`, inside the
|
|
870
|
+
// same `advanced` guard as the two bumps above, so a paused/frozen frame
|
|
871
|
+
// fires no timers). Every world's `ctx.clock` is THIS instance, reached the
|
|
872
|
+
// same way `ctx.random`/`ctx.debug` reach their game-scoped singletons —
|
|
873
|
+
// `getSimClock(host.game)` over the game-scoped slot filed below.
|
|
874
|
+
//
|
|
875
|
+
// The disposer is supplied HERE rather than inside the clock because
|
|
876
|
+
// `core/sim-clock.ts` deliberately knows nothing about Rapier or shared
|
|
877
|
+
// geometry. It mirrors `editor-game/src/host/roots/r3f-root.tsx`'s world teardown
|
|
878
|
+
// ordering for ONE subtree.
|
|
879
|
+
const simClock: SimClockInternal = createSimClock({
|
|
880
|
+
// Every mount that could own part of the subtree is offered it:
|
|
881
|
+
// `physics.get` is a no-op for a node the mount does not own, so this is
|
|
882
|
+
// correct with several three roots on one Game and needs no ownership
|
|
883
|
+
// bookkeeping.
|
|
884
|
+
dispose: (obj) => disposeDebrisSubtree(obj, roots.flatMap(debrisOwnersOf)),
|
|
885
|
+
});
|
|
886
|
+
const debugRegistry = createDebugRegistry({
|
|
887
|
+
getTick: () => tick,
|
|
888
|
+
getSimT: () => simT,
|
|
889
|
+
getFixedDt: () => opts.loop.fixedDt,
|
|
890
|
+
// D15/T-D15.5 — "the manifest's first/default world" for the debug
|
|
891
|
+
// registry's world-addressed input-target surface: the root an unaddressed
|
|
892
|
+
// `game.input.*` call reaches when several roots export an input door.
|
|
893
|
+
// Use the SAME "first three world, else first world" rule
|
|
894
|
+
// `requireDefaultRoot` (declared just below — safe: this closure is
|
|
895
|
+
// only ever CALLED later, once at least one world has mounted) already
|
|
896
|
+
// defines for `Game.defaultRoot`.
|
|
897
|
+
getDefaultRootId: () => (roots.length > 0 ? requireDefaultRoot().id : null),
|
|
898
|
+
// Issue #175 — the built-in `time` provider's `loopLiveness` field reads
|
|
899
|
+
// the REAL loop, not any UI-level play-state store: `opts.loop` is the
|
|
900
|
+
// SAME `GameLoop` this `Game`'s own `.loop` field exposes, so this
|
|
901
|
+
// registry can never disagree with `game.loop.liveness` about whether
|
|
902
|
+
// the loop is actually ticking.
|
|
903
|
+
getLoopLiveness: () => opts.loop.liveness,
|
|
904
|
+
});
|
|
905
|
+
/** Whether any world advances this frame — the game-owned systems run only then. */
|
|
906
|
+
let worldsAdvancing = false;
|
|
907
|
+
|
|
908
|
+
function requireDefaultRoot(): RootInstance {
|
|
909
|
+
if (roots.length === 0) {
|
|
910
|
+
throw new Error('Game.defaultRoot: no roots registered yet');
|
|
911
|
+
}
|
|
912
|
+
return roots.find((w) => w.kind === 'three') ?? roots[0]!;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// --- Game.systemAdapters aggregation (§7.1-3, probe1) --------------------
|
|
916
|
+
// Warn-once-per-colliding-key state, scoped to this Game instance (a fresh
|
|
917
|
+
// Game gets a fresh warn history) — deliberately NOT reset by anything
|
|
918
|
+
// short of a new `createGame` call, matching `reportedGateShortfalls`
|
|
919
|
+
// above's "once per game instance" idiom.
|
|
920
|
+
const warnedSystemAdapterKeys = new Set<string>();
|
|
921
|
+
const systemAdapterListeners = new Set<() => void>();
|
|
922
|
+
/** Per-root slots installed by `installDeclaredSystemAdapters` — the native
|
|
923
|
+
* module surface's static `systems` declaration. Merged BEFORE the same
|
|
924
|
+
* root's live `mounted.systems` below, so during migration a lingering
|
|
925
|
+
* component registration for a declared slot is shadowed (with the
|
|
926
|
+
* standard warning) rather than silently winning by running later. */
|
|
927
|
+
const declaredSystemAdapters = new Map<string, Readonly<Partial<SystemAdapters>>>();
|
|
928
|
+
/** Per-root POSITIVE ABSENCES from the same declaration — `absent(reason)`
|
|
929
|
+
* slots. Nothing binds them (there is nothing to bind); they are kept so a
|
|
930
|
+
* reader can tell an answered absence from an unasked question. */
|
|
931
|
+
const declaredAbsences = new Map<string, readonly DeclaredSystemAbsence[]>();
|
|
932
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-source × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
|
|
933
|
+
function computeSystemAdapters(): SystemAdapters {
|
|
934
|
+
// Debug is game-scoped: React hooks, probes, and the built-in time
|
|
935
|
+
// provider all register with the one registry created above, regardless
|
|
936
|
+
// of whether any mounted root happens to expose a `systems` object. A
|
|
937
|
+
// root may still publish this SAME adapter (the first-party Three/Pixi
|
|
938
|
+
// mounts do); the reference-equality branch below treats that as the
|
|
939
|
+
// intentional shared registration it is.
|
|
940
|
+
const result: SystemAdapters = { debug: debugRegistry.adapter };
|
|
941
|
+
const ownerRootId = new Map<string, string>([['debug', '(game)']]);
|
|
942
|
+
const merge = (
|
|
943
|
+
owner: string,
|
|
944
|
+
adapters: Readonly<Partial<SystemAdapters>>,
|
|
945
|
+
quietKeys?: ReadonlySet<string>,
|
|
946
|
+
): void => {
|
|
947
|
+
for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
|
|
948
|
+
if (adapters[key] === undefined) continue;
|
|
949
|
+
const existingOwner = ownerRootId.get(key);
|
|
950
|
+
if (existingOwner !== undefined) {
|
|
951
|
+
// Reference-equality short-circuit: two roots sharing the ONE
|
|
952
|
+
// game-scoped debug registry's adapter (T1.1) both expose the SAME
|
|
953
|
+
// object under `systems.debug` — that is by design, not a
|
|
954
|
+
// collision, so it must never warn.
|
|
955
|
+
if (result[key] === adapters[key]) continue;
|
|
956
|
+
// A world's own DECLARED slot overriding that same world's
|
|
957
|
+
// substrate-seeded default (the three lane pre-seeds physics/audio/
|
|
958
|
+
// renderDebug into `mounted.systems`) is the door working as
|
|
959
|
+
// intended, not a collision — same silence `ctx.
|
|
960
|
+
// registerSystemAdapter` gives the same override. Cross-root
|
|
961
|
+
// collisions still warn.
|
|
962
|
+
if (quietKeys?.has(key)) continue;
|
|
963
|
+
if (!warnedSystemAdapterKeys.has(key)) {
|
|
964
|
+
warnedSystemAdapterKeys.add(key);
|
|
965
|
+
// biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors this file's own reportGateShortfallOnce's deliberate direct console.warn just above
|
|
966
|
+
console.warn(
|
|
967
|
+
`[game] systemAdapters: "${key}" is registered by both ${existingOwner} and ` +
|
|
968
|
+
`${owner} — the FIRST registration (${existingOwner}) wins; the later ` +
|
|
969
|
+
'one is shadowed (game-scoped system adapters).',
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
// 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
|
|
975
|
+
(result as any)[key] = adapters[key];
|
|
976
|
+
ownerRootId.set(key, owner);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
for (const world of roots) {
|
|
980
|
+
const declared = declaredSystemAdapters.get(world.id);
|
|
981
|
+
if (declared) merge(`world "${world.id}" (declared systems export)`, declared);
|
|
982
|
+
const adapters = world.mounted.systems;
|
|
983
|
+
if (adapters) {
|
|
984
|
+
merge(
|
|
985
|
+
`world "${world.id}"`,
|
|
986
|
+
adapters,
|
|
987
|
+
declared ? new Set(Object.keys(declared)) : undefined,
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// --- D10/T7.6 play-state (Game.play) -------------------------------------
|
|
995
|
+
let paused = false;
|
|
996
|
+
// Dedupe loop-gate/audio-gate shortfall warnings to ONCE per (world, kind)
|
|
997
|
+
// — `pause()` fans out over every world every call; without this a
|
|
998
|
+
// multi-second play session would re-log the same "can't gate" shortfall
|
|
999
|
+
// every time the user hits Pause.
|
|
1000
|
+
const reportedGateShortfalls = new Set<string>();
|
|
1001
|
+
function reportGateShortfallOnce(kind: 'loop' | 'audio', worldId: string, reason: string): void {
|
|
1002
|
+
const key = `${kind}:${worldId}`;
|
|
1003
|
+
if (reportedGateShortfalls.has(key)) return;
|
|
1004
|
+
reportedGateShortfalls.add(key);
|
|
1005
|
+
const message =
|
|
1006
|
+
kind === 'loop'
|
|
1007
|
+
? formatLoopGateMessage({ worldId, reason })
|
|
1008
|
+
: formatAudioGateMessage({ worldId, reason });
|
|
1009
|
+
// Other native `console.warn`/`console.error` call sites in this file are
|
|
1010
|
+
// unsuppressed and already counted in the lint baseline (see `runFrame`'s
|
|
1011
|
+
// impl below); this one is a NEW site, so it's suppressed to keep this
|
|
1012
|
+
// task's diff at zero NEW warnings.
|
|
1013
|
+
// biome-ignore lint/suspicious/noConsole: see comment above
|
|
1014
|
+
console.warn(message);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const audioMuteBeforePause = new WeakMap<AudioAdapter, boolean>();
|
|
1018
|
+
|
|
1019
|
+
/** Fan out a loop-gate (self-driven roots) + audio-gate call over every
|
|
1020
|
+
* `pausable` world, reporting honestly (once) wherever the capability is
|
|
1021
|
+
* absent — shared by `pause()`/`resume()` below (same fan-out, opposite
|
|
1022
|
+
* boolean). */
|
|
1023
|
+
// 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
|
|
1024
|
+
function setRootGates(next: boolean): void {
|
|
1025
|
+
for (const world of roots) {
|
|
1026
|
+
// A disposed world stays in `roots` (there is no `unregisterRoot`), and
|
|
1027
|
+
// its adapter's `setPaused`/`systems.audio` reach a renderer, ticker or
|
|
1028
|
+
// audio graph that `dispose()` already released — the same reason
|
|
1029
|
+
// `runFrame`/`runRenderFrame`/`runTicks` skip it. Without this, a
|
|
1030
|
+
// pause/resume after a partial teardown calls into freed resources, and
|
|
1031
|
+
// the audio leg re-mutes a context that no longer exists.
|
|
1032
|
+
if (world.disposed) continue;
|
|
1033
|
+
if (!world.pausable) continue; // pausable:false roots are untouched by design
|
|
1034
|
+
if (world.mounted.drivesOwnLoop) {
|
|
1035
|
+
if (world.mounted.setPaused) {
|
|
1036
|
+
world.mounted.setPaused(next);
|
|
1037
|
+
} else if (next) {
|
|
1038
|
+
reportGateShortfallOnce(
|
|
1039
|
+
'loop',
|
|
1040
|
+
world.id,
|
|
1041
|
+
'self-driven world, adapter declares no setPaused capability',
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// A native entry's static `systems` export is this root's primary
|
|
1046
|
+
// declaration surface. `computeSystemAdapters()` already gives it
|
|
1047
|
+
// precedence over a substrate-seeded mounted adapter; pause must read
|
|
1048
|
+
// the same source or a valid entry-declared audio graph is visible to
|
|
1049
|
+
// the editor yet paradoxically reported as ungateable here.
|
|
1050
|
+
const audio = declaredSystemAdapters.get(world.id)?.audio ?? world.mounted.systems?.audio;
|
|
1051
|
+
if (audio) {
|
|
1052
|
+
if (next) {
|
|
1053
|
+
if (!audioMuteBeforePause.has(audio)) audioMuteBeforePause.set(audio, audio.isMuted());
|
|
1054
|
+
audio.setMuted(true);
|
|
1055
|
+
} else if (audioMuteBeforePause.has(audio)) {
|
|
1056
|
+
audio.setMuted(audioMuteBeforePause.get(audio)!);
|
|
1057
|
+
audioMuteBeforePause.delete(audio);
|
|
1058
|
+
}
|
|
1059
|
+
} else if (
|
|
1060
|
+
next &&
|
|
1061
|
+
!declaredAbsences.get(world.id)?.some((absence) => absence.slot === 'audio')
|
|
1062
|
+
) {
|
|
1063
|
+
reportGateShortfallOnce('audio', world.id, 'no SystemAdapters.audio on this world');
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// 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
|
|
1069
|
+
function runFrameImpl(
|
|
1070
|
+
dt: number,
|
|
1071
|
+
frameOpts?: { onlyFrozen?: boolean; skipRenderPhases?: boolean },
|
|
1072
|
+
): void {
|
|
1073
|
+
// D15 (T-D15.3) — brackets the ENTIRE frame body (every phase, every
|
|
1074
|
+
// world, both the `onlyFrozen` and normal branches below converge on the
|
|
1075
|
+
// single `profiler.endFrame()` at the tail) with zero reordering of the
|
|
1076
|
+
// phase algorithm itself — a no-op pair of calls while
|
|
1077
|
+
// `rngTrapEnabled` is false (the common case: most projects never
|
|
1078
|
+
// declare `determinism.seededRandom`).
|
|
1079
|
+
if (rngTrapEnabled) rngTrap.enable();
|
|
1080
|
+
profiler.beginFrame();
|
|
1081
|
+
// The render-phase skip. Two callers set it, for the
|
|
1082
|
+
// same reason — this substep is not the thing that paints. `runTicks`'s
|
|
1083
|
+
// `render: 'none'|'last'` fast-forward sets it on every tick it doesn't
|
|
1084
|
+
// want to paint (see `runTicks`'s doc comment on `GameInternal`), and a
|
|
1085
|
+
// DISPLAY-RATE host sets it on every substep because `runRenderFrame`
|
|
1086
|
+
// paints once per real frame instead. Unlike `onlyFrozen` below it is not
|
|
1087
|
+
// internal-only: it is part of the public `GameInternal.runFrame`
|
|
1088
|
+
// signature, since an external host is one of those two callers.
|
|
1089
|
+
const skipRenderPhases = frameOpts?.skipRenderPhases ?? false;
|
|
1090
|
+
// `onlyFrozen` is internal-only (not part of the public `GameInternal.runFrame`
|
|
1091
|
+
// signature — no external caller sets it) — `Game.play.step()` below is the
|
|
1092
|
+
// one and only caller. It ticks EXACTLY the currently-frozen set (host-driven,
|
|
1093
|
+
// `pausable`, and `paused`) through every phase + `endFrame`, with the REAL
|
|
1094
|
+
// `dt` (not the render-phase's forced `0`), and touches no other world at all
|
|
1095
|
+
const onlyFrozen = frameOpts?.onlyFrozen ?? false;
|
|
1096
|
+
// Checklist item 7: snapshot the world count ONCE at entry and iterate
|
|
1097
|
+
// by index in both loops below. A world registered mid-frame (e.g. from
|
|
1098
|
+
// a game-scoped system's side effect) joins at the NEXT `runFrame` call,
|
|
1099
|
+
// not this one — `for (const world of roots)` would otherwise pick up
|
|
1100
|
+
// a world pushed during this very frame. This also drops the two
|
|
1101
|
+
// per-phase `for...of` iterator allocations.
|
|
1102
|
+
const n = roots.length;
|
|
1103
|
+
worldsAdvancing = false;
|
|
1104
|
+
for (let i = 0; i < n; i++) {
|
|
1105
|
+
const world = roots[i]!;
|
|
1106
|
+
if (world.disposed) continue;
|
|
1107
|
+
if (onlyFrozen) {
|
|
1108
|
+
if (paused && world.pausable && !world.mounted.drivesOwnLoop) {
|
|
1109
|
+
worldsAdvancing = true;
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
} else if (
|
|
1113
|
+
world.mounted.drivesOwnLoop
|
|
1114
|
+
? !paused || !world.pausable || !world.mounted.setPaused
|
|
1115
|
+
: !paused || !world.pausable
|
|
1116
|
+
) {
|
|
1117
|
+
worldsAdvancing = true;
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// §7.1-11 fix (probe5): the frame's tail (tick, sim time, timers) runs iff
|
|
1123
|
+
// at least one world actually advanced this call — not unconditionally. `advanced`
|
|
1124
|
+
// covers rule (a) below (host-driven roots this call actually ticked);
|
|
1125
|
+
// rule (b): a self-driven world that is RUNNING — which is every
|
|
1126
|
+
// self-driven world while not paused, and, while paused, the ones the
|
|
1127
|
+
// gate can't reach (`pausable: false`, or no `setPaused` capability) —
|
|
1128
|
+
// checked once, up front, over the same `roots` array (no extra
|
|
1129
|
+
// allocation, matching every other loop here). Rule (b) is skipped in
|
|
1130
|
+
// `onlyFrozen` mode: a `step()` call advances iff it ticked a frozen world
|
|
1131
|
+
// — self-driven notifications belong to the loop's own `runFrame`s.
|
|
1132
|
+
let advanced = false;
|
|
1133
|
+
if (!onlyFrozen) {
|
|
1134
|
+
for (let i = 0; i < n; i++) {
|
|
1135
|
+
const world = roots[i]!;
|
|
1136
|
+
if (world.disposed) continue;
|
|
1137
|
+
if (!world.mounted.drivesOwnLoop) continue;
|
|
1138
|
+
if (!paused || !world.pausable || !world.mounted.setPaused) {
|
|
1139
|
+
advanced = true;
|
|
1140
|
+
break;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (onlyFrozen) {
|
|
1146
|
+
// step(): the frozen set is host-driven + pausable + currently paused.
|
|
1147
|
+
// While NOT paused this set is empty by construction, so this whole
|
|
1148
|
+
// branch is a natural no-op — `Game.play.step()`'s "no-op while
|
|
1149
|
+
// running" behavior falls straight out of this, no separate guard
|
|
1150
|
+
// needed.
|
|
1151
|
+
for (const phase of PHASE_ORDER) {
|
|
1152
|
+
profiler.beginPhase();
|
|
1153
|
+
// Game-owned systems follow the same one-run-per-phase contract as a
|
|
1154
|
+
// normal frame whenever Step advances at least one frozen world.
|
|
1155
|
+
if (worldsAdvancing) systems.runPhase(phase, dt);
|
|
1156
|
+
for (let i = 0; i < n; i++) {
|
|
1157
|
+
const world = roots[i]!;
|
|
1158
|
+
if (world.disposed) continue;
|
|
1159
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
1160
|
+
if (!(paused && world.pausable)) continue; // only the frozen set
|
|
1161
|
+
try {
|
|
1162
|
+
world.frame?.runPhase(phase, dt);
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
console.error(
|
|
1165
|
+
`[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw ` +
|
|
1166
|
+
'(step()):',
|
|
1167
|
+
err,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
profiler.endPhase(phase);
|
|
1172
|
+
}
|
|
1173
|
+
for (let i = 0; i < n; i++) {
|
|
1174
|
+
const world = roots[i]!;
|
|
1175
|
+
if (world.disposed) continue;
|
|
1176
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
1177
|
+
if (!(paused && world.pausable)) continue;
|
|
1178
|
+
advanced = true;
|
|
1179
|
+
if (world.frame) {
|
|
1180
|
+
try {
|
|
1181
|
+
world.frame.endFrame?.();
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
console.error(
|
|
1184
|
+
`[game] world "${world.id}" (kind: ${world.kind}) endFrame() threw (step()):`,
|
|
1185
|
+
err,
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
} else {
|
|
1189
|
+
try {
|
|
1190
|
+
world.mounted.update?.(dt);
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
console.error(
|
|
1193
|
+
`[game] world "${world.id}" (kind: ${world.kind}) update() threw (step()):`,
|
|
1194
|
+
err,
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
} else {
|
|
1200
|
+
for (const phase of PHASE_ORDER) {
|
|
1201
|
+
profiler.beginPhase();
|
|
1202
|
+
// D15/T-D15.4: `runTicks`'s fast-forward skip — `preRender`/`render`
|
|
1203
|
+
// are the ONLY phases ever skipped this way (every earlier gameplay
|
|
1204
|
+
// phase, and `endFrame` below, always run) — see `skipRenderPhases`'s
|
|
1205
|
+
// declaration above and `runTicks`'s doc comment on `GameInternal`.
|
|
1206
|
+
const skipThisPhase =
|
|
1207
|
+
skipRenderPhases && (phase === SystemPhase.PRE_RENDER || phase === SystemPhase.RENDER);
|
|
1208
|
+
if (!skipThisPhase) {
|
|
1209
|
+
systems.runPhase(phase, dt);
|
|
1210
|
+
for (let i = 0; i < n; i++) {
|
|
1211
|
+
const world = roots[i]!;
|
|
1212
|
+
if (world.disposed) continue;
|
|
1213
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
1214
|
+
// D10/T7.6: a `pausable` world under an active pause
|
|
1215
|
+
// skips every phase except `render` — its render still runs, every
|
|
1216
|
+
// substep, but with `dt` forced to `0` (deterministic: no
|
|
1217
|
+
// time-based render effect silently keeps animating a "frozen"
|
|
1218
|
+
// scene). A `pausable: false` world is unaffected.
|
|
1219
|
+
const frozen = paused && world.pausable;
|
|
1220
|
+
if (frozen && phase !== SystemPhase.RENDER) continue;
|
|
1221
|
+
const phaseDt = frozen ? 0 : dt;
|
|
1222
|
+
// Checklist item 2: isolate each world's per-phase work — one
|
|
1223
|
+
// world's `runPhase` throwing must not starve sibling roots still
|
|
1224
|
+
// due this phase, nor abort the frame. Mirrors `runOne`'s style in
|
|
1225
|
+
// `core/system-runner.ts` (loud console.error, never swallowed).
|
|
1226
|
+
try {
|
|
1227
|
+
world.frame?.runPhase(phase, phaseDt);
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
console.error(
|
|
1230
|
+
`[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw:`,
|
|
1231
|
+
err,
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
profiler.endPhase(phase);
|
|
1237
|
+
}
|
|
1238
|
+
for (let i = 0; i < n; i++) {
|
|
1239
|
+
const world = roots[i]!;
|
|
1240
|
+
if (world.disposed) continue;
|
|
1241
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
1242
|
+
// A fully-frozen world gets no `endFrame`/opaque-`update` call either —
|
|
1243
|
+
// there is nothing to "end the frame" of when nothing ran this substep.
|
|
1244
|
+
const frozen = paused && world.pausable;
|
|
1245
|
+
if (frozen) continue;
|
|
1246
|
+
advanced = true;
|
|
1247
|
+
if (world.frame) {
|
|
1248
|
+
try {
|
|
1249
|
+
world.frame.endFrame?.();
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
console.error(
|
|
1252
|
+
`[game] world "${world.id}" (kind: ${world.kind}) endFrame() threw:`,
|
|
1253
|
+
err,
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
} else {
|
|
1257
|
+
// Checklist item 2: same isolation for the opaque-world fallback —
|
|
1258
|
+
// one foreign mount's `update` throwing must not starve its
|
|
1259
|
+
// siblings' `endFrame`/`update` calls this same loop.
|
|
1260
|
+
// A display-cadence opaque update is the root's indivisible native
|
|
1261
|
+
// presentation (R3F `advance()` is callbacks + draw). The real
|
|
1262
|
+
// rAF host withholds render phases from EVERY fixed catch-up step;
|
|
1263
|
+
// withhold this update beside them, then run it once in
|
|
1264
|
+
// `runRenderFrameImpl`. A direct/offline `runFrame` includes render
|
|
1265
|
+
// phases and therefore still advances the root exactly once.
|
|
1266
|
+
if (!(skipRenderPhases && world.mounted.updateCadence === 'display')) {
|
|
1267
|
+
try {
|
|
1268
|
+
world.mounted.update?.(dt);
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
console.error(
|
|
1271
|
+
`[game] world "${world.id}" (kind: ${world.kind}) update() threw:`,
|
|
1272
|
+
err,
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// The frame's tail runs LAST, after every phase of every world and every
|
|
1281
|
+
// world's endFrame/update above, at most once per completed
|
|
1282
|
+
// `runFrame`/`step()` call and, per §7.1-11's fix, only when `advanced`
|
|
1283
|
+
// (see above) is true: a fully-gated paused game advances nothing.
|
|
1284
|
+
if (advanced) {
|
|
1285
|
+
tick++;
|
|
1286
|
+
simT += dt;
|
|
1287
|
+
// P3 — sim timers fire IMMEDIATELY after the bump, inside this same
|
|
1288
|
+
// `advanced` guard: a paused/frozen world advances no sim time, so it
|
|
1289
|
+
// must fire no timers either, and `Game.play.step()` (which reaches this
|
|
1290
|
+
// block with `advanced` set by the frozen-set loop above) fires exactly
|
|
1291
|
+
// the timers that ONE substep makes due. One flush === one completed
|
|
1292
|
+
// substep, which is what lets `SimClock.tickNow()` simply count flushes
|
|
1293
|
+
// and always agree with `tick`.
|
|
1294
|
+
simClock.flush(simT);
|
|
1295
|
+
}
|
|
1296
|
+
profiler.endFrame();
|
|
1297
|
+
if (rngTrapEnabled) rngTrap.disable();
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* One display frame's PRESENTATION pass. See
|
|
1302
|
+
* `GameInternal.runRenderFrame`'s doc comment for the contract; this is the
|
|
1303
|
+
* `preRender`+`render` slice of `runFrameImpl`'s phase loop, lifted out and
|
|
1304
|
+
* driven by the loop's own per-real-frame callback instead of by the substep
|
|
1305
|
+
* loop.
|
|
1306
|
+
*
|
|
1307
|
+
* Three deliberate parallels with `runFrameImpl`, so the two passes cannot
|
|
1308
|
+
* drift into different rules for the same situation:
|
|
1309
|
+
* - game-scoped systems run before per-world hooks, per phase;
|
|
1310
|
+
* - one world's throw is logged and isolated, never allowed to starve a
|
|
1311
|
+
* sibling still due this phase;
|
|
1312
|
+
* - the RNG trap brackets the whole pass, so `Math.random` called from a
|
|
1313
|
+
* render-phase system is still caught by D15's dev-mode trap — it was,
|
|
1314
|
+
* back when this pass lived inside `runFrame`, and moving code must not
|
|
1315
|
+
* quietly move it out from under a guard.
|
|
1316
|
+
*
|
|
1317
|
+
* Profiler note (a real, accepted consequence): the profiler's "frame" has
|
|
1318
|
+
* always meant "one `runFrame` call", so a display frame now produces the
|
|
1319
|
+
* substep records it always did PLUS one record for this pass, carrying the
|
|
1320
|
+
* `preRender`/`render` spans and the renderer counters. Re-modelling the
|
|
1321
|
+
* profiler's frame boundary around the display frame is a separate change to
|
|
1322
|
+
* a debugging surface, not part of flipping the loop.
|
|
1323
|
+
*/
|
|
1324
|
+
function runRenderFrameImpl(alpha: number, displayDt: number): void {
|
|
1325
|
+
renderAlpha = alpha;
|
|
1326
|
+
if (rngTrapEnabled) rngTrap.enable();
|
|
1327
|
+
profiler.beginFrame();
|
|
1328
|
+
|
|
1329
|
+
// `RenderStepped` first: a camera adjusted here is drawn by THIS frame's
|
|
1330
|
+
// render phase, not next frame's. Iterated over a snapshot so a callback
|
|
1331
|
+
// that unsubscribes itself (or registers another) cannot mutate the set
|
|
1332
|
+
// mid-iteration.
|
|
1333
|
+
if (renderStepCallbacks.size > 0) {
|
|
1334
|
+
for (const fn of [...renderStepCallbacks]) {
|
|
1335
|
+
try {
|
|
1336
|
+
fn(alpha, displayDt);
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
console.error('[game] an onRenderStep callback threw:', err);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const n = roots.length;
|
|
1344
|
+
for (const phase of DISPLAY_RATE_PHASES) {
|
|
1345
|
+
profiler.beginPhase();
|
|
1346
|
+
systems.runPhase(phase, displayDt);
|
|
1347
|
+
for (let i = 0; i < n; i++) {
|
|
1348
|
+
const world = roots[i]!;
|
|
1349
|
+
if (world.disposed) continue;
|
|
1350
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
1351
|
+
// D10/T7.6, carried over verbatim in substance: a frozen world still
|
|
1352
|
+
// renders (a paused viewport stays painted) but with `dt` forced to
|
|
1353
|
+
// `0`, and skips every other phase.
|
|
1354
|
+
const frozen = paused && world.pausable;
|
|
1355
|
+
if (frozen && phase !== SystemPhase.RENDER) continue;
|
|
1356
|
+
try {
|
|
1357
|
+
world.frame?.runPhase(phase, frozen ? 0 : displayDt);
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
console.error(
|
|
1360
|
+
`[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw ` +
|
|
1361
|
+
'(display frame):',
|
|
1362
|
+
err,
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
profiler.endPhase(phase);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// An opaque display-cadence root cannot expose phase hooks because its
|
|
1370
|
+
// native advance is inseparable. It still belongs to THIS presentation
|
|
1371
|
+
// frame, once — never to every fixed catch-up substep that preceded it.
|
|
1372
|
+
// A frozen world redraws at dt=0, preserving the established pause rule.
|
|
1373
|
+
for (let i = 0; i < n; i++) {
|
|
1374
|
+
const world = roots[i]!;
|
|
1375
|
+
if (world.disposed) continue;
|
|
1376
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
1377
|
+
if (world.frame) continue;
|
|
1378
|
+
if (world.mounted.updateCadence !== 'display') continue;
|
|
1379
|
+
const frozen = paused && world.pausable;
|
|
1380
|
+
try {
|
|
1381
|
+
world.mounted.update?.(frozen ? 0 : displayDt);
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
console.error(
|
|
1384
|
+
`[game] world "${world.id}" (kind: ${world.kind}) update() threw (display frame):`,
|
|
1385
|
+
err,
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
profiler.endFrame();
|
|
1391
|
+
if (rngTrapEnabled) rngTrap.disable();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const gameInternal: GameInternal = {
|
|
1395
|
+
loop: opts.loop,
|
|
1396
|
+
assets: opts.assets,
|
|
1397
|
+
playtest: opts.playtest ?? null,
|
|
1398
|
+
profiler,
|
|
1399
|
+
systems,
|
|
1400
|
+
get roots() {
|
|
1401
|
+
return roots;
|
|
1402
|
+
},
|
|
1403
|
+
world(id: string): RootInstance | null {
|
|
1404
|
+
return roots.find((w) => w.id === id) ?? null;
|
|
1405
|
+
},
|
|
1406
|
+
get defaultRoot() {
|
|
1407
|
+
return requireDefaultRoot();
|
|
1408
|
+
},
|
|
1409
|
+
get systemAdapters() {
|
|
1410
|
+
return computeSystemAdapters();
|
|
1411
|
+
},
|
|
1412
|
+
subscribeSystemAdapters(listener: () => void) {
|
|
1413
|
+
systemAdapterListeners.add(listener);
|
|
1414
|
+
return () => systemAdapterListeners.delete(listener);
|
|
1415
|
+
},
|
|
1416
|
+
notifySystemAdaptersChanged() {
|
|
1417
|
+
for (const listener of systemAdapterListeners) listener();
|
|
1418
|
+
},
|
|
1419
|
+
get declaredSystemAbsences() {
|
|
1420
|
+
return [...declaredAbsences.values()].flat();
|
|
1421
|
+
},
|
|
1422
|
+
installDeclaredSystemAdapters(
|
|
1423
|
+
rootId: string,
|
|
1424
|
+
slots: Readonly<Partial<SystemAdapters>>,
|
|
1425
|
+
absent: readonly DeclaredSystemAbsence[] = [],
|
|
1426
|
+
) {
|
|
1427
|
+
const root = roots.find((w) => w.id === rootId);
|
|
1428
|
+
if (!root) {
|
|
1429
|
+
throw new Error(
|
|
1430
|
+
`installDeclaredSystemAdapters: no mounted root is named "${rootId}" ` +
|
|
1431
|
+
`(mounted: ${roots.map((w) => `"${w.id}"`).join(', ') || 'none'}).`,
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
if (declaredSystemAdapters.has(rootId)) {
|
|
1435
|
+
throw new Error(
|
|
1436
|
+
`installDeclaredSystemAdapters: root "${rootId}" already installed its declared ` +
|
|
1437
|
+
'systems — a declaration is static and installs exactly once per mount.',
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
const scope = root.mounted.systemScope ?? root.mounted;
|
|
1441
|
+
const bound = Object.fromEntries(
|
|
1442
|
+
Object.entries(slots).map(([key, adapter]) => [
|
|
1443
|
+
key,
|
|
1444
|
+
adapter === undefined ? undefined : bindScopedSystem(adapter, scope),
|
|
1445
|
+
]),
|
|
1446
|
+
) as Partial<SystemAdapters>;
|
|
1447
|
+
declaredSystemAdapters.set(rootId, bound);
|
|
1448
|
+
// ALWAYS set, never merge: a re-install (an HMR of the entry's `systems`
|
|
1449
|
+
// table) that dropped an `absent()` marker must also drop the recorded
|
|
1450
|
+
// absence, or the coverage report keeps printing a game statement the
|
|
1451
|
+
// game no longer makes.
|
|
1452
|
+
declaredAbsences.set(rootId, absent);
|
|
1453
|
+
gameInternal.notifySystemAdaptersChanged();
|
|
1454
|
+
},
|
|
1455
|
+
play: {
|
|
1456
|
+
get paused() {
|
|
1457
|
+
return paused;
|
|
1458
|
+
},
|
|
1459
|
+
pause() {
|
|
1460
|
+
if (paused) return;
|
|
1461
|
+
paused = true;
|
|
1462
|
+
setRootGates(true);
|
|
1463
|
+
},
|
|
1464
|
+
resume() {
|
|
1465
|
+
if (!paused) return;
|
|
1466
|
+
paused = false;
|
|
1467
|
+
setRootGates(false);
|
|
1468
|
+
},
|
|
1469
|
+
step(dt = 1 / 60) {
|
|
1470
|
+
// Self-driven pausable roots advance via their adapter's `step()`
|
|
1471
|
+
// capability — but only the ones that are actually FROZEN: the game
|
|
1472
|
+
// must be paused, and the world must have been gate-able in the
|
|
1473
|
+
// first place (`setPaused` present — a world the gate couldn't reach
|
|
1474
|
+
// never stopped, so "stepping" it would double-tick a still-running
|
|
1475
|
+
// loop, the same §7.1-2 class as the host-driven fix below). While
|
|
1476
|
+
// not paused, nothing here runs — step() is a whole-call no-op.
|
|
1477
|
+
if (paused) {
|
|
1478
|
+
for (const world of roots) {
|
|
1479
|
+
// Same disposed-world guard `runFrameImpl` (and `setRootGates`)
|
|
1480
|
+
// make: a disposed world's `mounted.step` drives a loop whose
|
|
1481
|
+
// resources are already freed.
|
|
1482
|
+
if (world.disposed) continue;
|
|
1483
|
+
if (!world.pausable || !world.mounted.drivesOwnLoop) continue;
|
|
1484
|
+
if (!world.mounted.setPaused) continue; // never gated — still running
|
|
1485
|
+
if (world.mounted.step) {
|
|
1486
|
+
world.mounted.step();
|
|
1487
|
+
} else {
|
|
1488
|
+
reportGateShortfallOnce(
|
|
1489
|
+
'loop',
|
|
1490
|
+
world.id,
|
|
1491
|
+
'self-driven world, adapter declares no step capability',
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
// Tick exactly the frozen (host-driven, pausable, paused) set.
|
|
1497
|
+
runFrameImpl(dt, { onlyFrozen: true });
|
|
1498
|
+
},
|
|
1499
|
+
},
|
|
1500
|
+
registerRoot(world: RootInstance): void {
|
|
1501
|
+
if (roots.some((w) => w.id === world.id)) {
|
|
1502
|
+
throw new Error(`Game.registerRoot: duplicate world id "${world.id}"`);
|
|
1503
|
+
}
|
|
1504
|
+
// Checklist item 6: the SAME `mounted` object registered under two
|
|
1505
|
+
// world ids would be double-ticked by `runFrame` (its `frame.runPhase`/
|
|
1506
|
+
// `endFrame` called once per registration) and double-dispose-wrapped
|
|
1507
|
+
// (`createRootInstance` wraps `mounted.dispose` in place — a second
|
|
1508
|
+
// wrap would flip `disposed` and call through on ITS OWN wrapped
|
|
1509
|
+
// `originalDispose`, which would be harmless only if the adapter's
|
|
1510
|
+
// dispose happened to be idempotent; a foreign adapter
|
|
1511
|
+
// has no such guarantee). Reject it outright instead.
|
|
1512
|
+
if (roots.some((w) => w.mounted === world.mounted)) {
|
|
1513
|
+
throw new Error(
|
|
1514
|
+
`Game.registerRoot: world "${world.id}" shares its \`mounted\` object with an ` +
|
|
1515
|
+
`already-registered world ("${roots.find((w) => w.mounted === world.mounted)!.id}") — ` +
|
|
1516
|
+
'the same mount cannot be registered twice.',
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
roots.push(world);
|
|
1520
|
+
gameInternal.notifySystemAdaptersChanged();
|
|
1521
|
+
// T7.4 slice 2: a self-driven mount with no `observe` has no state
|
|
1522
|
+
// bridge at all — the editor's observations cannot read it, and
|
|
1523
|
+
// silently returning `undefined` forever would hide that. Report ONCE
|
|
1524
|
+
// per mount, at registration time, matching this file's existing
|
|
1525
|
+
// `[game] world "<id>" (kind: <kind>) ...` console idiom (see
|
|
1526
|
+
// `runFrame` below).
|
|
1527
|
+
//
|
|
1528
|
+
// §7.1-15: a `kind: 'dom'` world is exempt — it has no `observe`
|
|
1529
|
+
// BY DESIGN (its state is its own React tree's). Without
|
|
1530
|
+
// this exemption every production react world logged a false-positive
|
|
1531
|
+
// "no state bridge" warning at registration (probe: every real react
|
|
1532
|
+
// world mount), eroding the signal for a genuinely un-observable
|
|
1533
|
+
// ingested world.
|
|
1534
|
+
// A HOST-DRIVEN mount (`drivesOwnLoop: false`, notably the R3F adapter)
|
|
1535
|
+
// ticks inside `runFrame`, where the editor reads it directly; requiring
|
|
1536
|
+
// an `observe` bridge there produces a false warning.
|
|
1537
|
+
if (world.mounted.drivesOwnLoop && world.kind !== 'dom' && !world.mounted.observe) {
|
|
1538
|
+
console.warn(
|
|
1539
|
+
`[game] world "${world.id}" (kind: ${world.kind}, adapter: "${world.adapter.id}"): ` +
|
|
1540
|
+
'no state bridge — this mounted game has no `observe` (RootStateObserver); ' +
|
|
1541
|
+
"the editor's observations cannot subscribe to its state.",
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
},
|
|
1545
|
+
runFrame(dt: number, frameOpts?: { skipRenderPhases?: boolean }): void {
|
|
1546
|
+
runFrameImpl(dt, frameOpts);
|
|
1547
|
+
},
|
|
1548
|
+
runRenderFrame(alpha: number, displayDt: number): void {
|
|
1549
|
+
runRenderFrameImpl(alpha, displayDt);
|
|
1550
|
+
},
|
|
1551
|
+
get renderAlpha() {
|
|
1552
|
+
return renderAlpha;
|
|
1553
|
+
},
|
|
1554
|
+
onRenderStep(
|
|
1555
|
+
fn: (alpha: number, displayDt: number) => void,
|
|
1556
|
+
stepOpts?: { signal?: AbortSignal | undefined },
|
|
1557
|
+
): () => void {
|
|
1558
|
+
const signal = stepOpts?.signal;
|
|
1559
|
+
// Already-aborted is a no-op registration, never a throw — the same
|
|
1560
|
+
// `AbortSignal` contract `SimClock.after` honors.
|
|
1561
|
+
if (signal?.aborted) return () => {};
|
|
1562
|
+
renderStepCallbacks.add(fn);
|
|
1563
|
+
let detachAbort: (() => void) | null = null;
|
|
1564
|
+
const unsubscribe = (): void => {
|
|
1565
|
+
renderStepCallbacks.delete(fn);
|
|
1566
|
+
detachAbort?.();
|
|
1567
|
+
detachAbort = null;
|
|
1568
|
+
};
|
|
1569
|
+
if (signal) {
|
|
1570
|
+
signal.addEventListener('abort', unsubscribe, { once: true });
|
|
1571
|
+
detachAbort = (): void => signal.removeEventListener('abort', unsubscribe);
|
|
1572
|
+
}
|
|
1573
|
+
return unsubscribe;
|
|
1574
|
+
},
|
|
1575
|
+
runTicks(n: number, ticksOpts?: RunTicksOptions): void {
|
|
1576
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1577
|
+
throw new RangeError(`Game.runTicks: n must be a non-negative integer, got ${n}`);
|
|
1578
|
+
}
|
|
1579
|
+
// Refuse while paused (D15 §2.b): stepping the frozen set is
|
|
1580
|
+
// `Game.play.step()`'s contract, not this one's — see `runTicks`'s doc
|
|
1581
|
+
// comment on `GameInternal` above.
|
|
1582
|
+
if (paused) {
|
|
1583
|
+
throw new DebugError(
|
|
1584
|
+
'RUN_TICKS_PAUSED',
|
|
1585
|
+
'Game.runTicks: refused — Game.play.paused is true; stepping the frozen set is ' +
|
|
1586
|
+
"Game.play.step()'s contract, not runTicks'",
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
const render = ticksOpts?.render ?? 'last';
|
|
1590
|
+
const fixedDt = opts.loop.fixedDt;
|
|
1591
|
+
for (let i = 0; i < n; i++) {
|
|
1592
|
+
const isFinalTick = i === n - 1;
|
|
1593
|
+
const skipRenderPhases = render === 'all' ? false : render === 'none' ? true : !isFinalTick;
|
|
1594
|
+
runFrameImpl(fixedDt, { skipRenderPhases });
|
|
1595
|
+
}
|
|
1596
|
+
},
|
|
1597
|
+
dispose(): void {
|
|
1598
|
+
// P3 — the sim clock is GAME-scoped, so this is the only correct place to
|
|
1599
|
+
// dispose it: `create-runtime.ts`'s `fullCleanup` calls us after EVERY
|
|
1600
|
+
// root's `mounted.dispose()`, whereas a per-root teardown may be ending
|
|
1601
|
+
// just one sub-session while sibling roots keep running (see the
|
|
1602
|
+
// disposed-world guard in `runFrame`). It used to be disposed from
|
|
1603
|
+
// `editor-game/src/host/roots/r3f-root.tsx`'s teardown, which meant disposing one of
|
|
1604
|
+
// two three roots froze `now()` for the whole Game, rejected the other
|
|
1605
|
+
// world's pending `delay`s and turned its `after()` calls into silent
|
|
1606
|
+
// no-ops. Note the asymmetry that gives the bug away: `seededRandom` and
|
|
1607
|
+
// `debugRegistry` are game-scoped too, and per-root teardown has never
|
|
1608
|
+
// destroyed either — only ever `strip(this.id)`, its own slice.
|
|
1609
|
+
simClock.dispose();
|
|
1610
|
+
debugRegistry.strip();
|
|
1611
|
+
},
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
// Filed AFTER the shell exists (the slot lives on the Game object
|
|
1615
|
+
// itself) so `getDebugRegistry(game)` — the react hooks' and any later
|
|
1616
|
+
// consumer's reach-in — works from the moment `createGame` returns.
|
|
1617
|
+
registerDebugRegistry(gameInternal, debugRegistry);
|
|
1618
|
+
// D15/T-D15.4: wire the run-ticks target the instant the Game shell exists
|
|
1619
|
+
// (unlike `setVirtualInputTarget`, which waits for a per-world mount, a
|
|
1620
|
+
// Game's own `runTicks` needs nothing else) — this is what makes
|
|
1621
|
+
// `window.__vgai.runTicks` (`debug-bridge.ts`) and the editor relay's
|
|
1622
|
+
// `run-ticks` case reach the SAME implementation `game.runTicks` above is.
|
|
1623
|
+
debugRegistry.setRunTicksTarget({ runTicks: gameInternal.runTicks });
|
|
1624
|
+
// D15 (T-D15.1/.3) — same "file after the shell exists" ordering as the
|
|
1625
|
+
// debug registry above: `getSeededRandom(game)`/`getGameplayRngTrapControl
|
|
1626
|
+
// (game)` (per-world `ctx.random` wiring, and the manifest-aware boot
|
|
1627
|
+
// path's post-hoc `setEnabled` call) both work from the moment
|
|
1628
|
+
// `createGame` returns.
|
|
1629
|
+
registerSeededRandom(gameInternal, seededRandom);
|
|
1630
|
+
registerGameplayRngTrapControl(gameInternal, rngTrapControl);
|
|
1631
|
+
// P3 — same "file after the shell exists" ordering: `getSimClock(game)` is
|
|
1632
|
+
// how every world's mount resolves `ctx.clock` to THIS game's one clock.
|
|
1633
|
+
registerSimClock(gameInternal, simClock);
|
|
1634
|
+
|
|
1635
|
+
return gameInternal;
|
|
1636
|
+
}
|