@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +24 -4
- package/schemas/engine-api.json +124 -0
- package/schemas/engine-api.md +53 -0
- package/schemas/engine-capabilities.json +124 -0
- package/schemas/inputmap.schema.json +314 -0
- package/schemas/mat.schema.json +286 -0
- package/schemas/prefab.schema.json +10148 -0
- package/schemas/scn2d.schema.json +475 -0
- package/schemas/vgai-game.schema.json +383 -0
- package/schemas/vscn.schema.json +11007 -0
- package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
- package/src/adapter/authoring.ts +77 -0
- package/src/adapter/first-party-systems.ts +23 -34
- package/src/adapter/game-adapter.ts +8 -8
- package/src/adapter/host-context.ts +2 -4
- package/src/adapter/index.ts +4 -4
- package/src/adapter/system-adapter.ts +88 -22
- package/src/adapter/vgai-scene-game-adapter.ts +244 -194
- package/src/animation/anim-graph-types.ts +12 -43
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/camera-ownership.ts +467 -0
- package/src/animation/cinematic-cues.ts +451 -0
- package/src/animation/clip-map.ts +41 -0
- package/src/animation/gsap-registration.ts +184 -0
- package/src/animation/theatre-clock-binding.ts +111 -0
- package/src/animation/theatre-director.ts +347 -0
- package/src/animation/theatre-object-binding.ts +661 -0
- package/src/animation/xstate-animation-binding.ts +436 -0
- package/src/animation/xstate-animation-meta.ts +319 -0
- package/src/audio/index.ts +39 -7
- package/src/audio/tone-clock-binding.ts +98 -0
- package/src/audio/tone-context.ts +129 -0
- package/src/audio/tone-offline-render.ts +167 -0
- package/src/audio/wav-encode.ts +119 -0
- package/src/character/cloth-sim.ts +533 -0
- package/src/character/spring-chain.ts +307 -0
- package/src/core/game-loop.ts +57 -2
- package/src/core/seeded-random.ts +161 -0
- package/src/core/system-runner.ts +20 -3
- package/src/core/types.ts +50 -0
- package/src/data/data-asset.ts +167 -0
- package/src/data/data-check-core.ts +242 -0
- package/src/data/data-ref.ts +145 -0
- package/src/data/vite-plugin-data.ts +290 -0
- package/src/dev/performance-profiler.ts +213 -0
- package/src/dev/webgl-gpu-timer.ts +53 -0
- package/src/ecs/component-manager.ts +45 -12
- package/src/ecs/game-component.ts +95 -11
- package/src/humanoid/bake.operation.ts +326 -0
- package/src/humanoid/body.ts +663 -0
- package/src/humanoid/clips.ts +149 -0
- package/src/humanoid/compose.ts +209 -0
- package/src/humanoid/generate.ts +189 -0
- package/src/humanoid/index.ts +36 -0
- package/src/humanoid/schema.ts +108 -0
- package/src/humanoid/skeleton.ts +345 -0
- package/src/index.ts +48 -0
- package/src/input/input-manager.ts +1886 -33
- package/src/input/input-types.ts +158 -3
- package/src/input/prompt-labels.ts +122 -0
- package/src/input/rebind-controller.ts +105 -0
- package/src/input/schema.ts +206 -52
- package/src/manifest/index.ts +5 -5
- package/src/manifest/load.ts +125 -72
- package/src/manifest/schema.ts +362 -255
- package/src/react/game-state.tsx +135 -32
- package/src/react/root-adapter.tsx +49 -0
- package/src/react/unmanaged-root-detector.ts +66 -0
- package/src/react/use-data.ts +124 -0
- package/src/react/use-selection.tsx +135 -0
- package/src/runtime/create-runtime.ts +112 -273
- package/src/runtime/debug-bridge.ts +483 -0
- package/src/runtime/debug-registry.ts +856 -0
- package/src/runtime/game.ts +342 -93
- package/src/runtime/gameplay-rng-trap.ts +134 -0
- package/src/runtime/input-router.ts +7 -7
- package/src/runtime/mount-game.ts +40 -38
- package/src/runtime/mount-manifest.ts +169 -37
- package/src/runtime/render-audio-control.ts +168 -0
- package/src/runtime/render-control.ts +522 -0
- package/src/runtime/render-seed.ts +79 -0
- package/src/runtime/state-bridge.ts +24 -10
- package/src/runtime/types.ts +110 -33
- package/src/scene/asset-loaders.ts +10 -36
- package/src/scene/asset-paths.ts +0 -2
- package/src/scene/asset-ref-check.ts +248 -0
- package/src/scene/asset-registry.ts +22 -0
- package/src/scene/component-registry.ts +14 -3
- package/src/scene/defaults.ts +1 -0
- package/src/scene/light-camera-factory.ts +11 -3
- package/src/scene/parse.ts +133 -0
- package/src/scene/scene-apply.ts +55 -4
- package/src/scene/scene-loader.ts +91 -123
- package/src/scene/scene-types.ts +0 -1
- package/src/scene/schema/animation.ts +30 -79
- package/src/scene/schema/entity.ts +20 -0
- package/src/scene/schema/index.ts +2 -46
- package/src/scene/schema/light.ts +16 -1
- package/src/scene/schema/material.ts +96 -91
- package/src/scene/schema/scene-file.ts +1 -7
- package/src/scene/user-data.ts +22 -10
- package/src/setup/setup-renderer.ts +10 -3
- package/src/tools/define-tool.ts +191 -0
- package/src/world2d/authoring-2d.ts +17 -1
- package/src/world2d/collision-2d.ts +1 -1
- package/src/world2d/pixi-game-adapter.ts +19 -17
- package/src/world2d/scene2d-loader.ts +1 -0
- package/src/world2d/types.ts +8 -2
- package/src/animation/anim-graph.ts +0 -406
- package/src/animation/anim-system.ts +0 -28
- package/src/animation/property-track.ts +0 -178
- package/src/animation/schema.ts +0 -204
- package/src/audio/ambient.ts +0 -300
- package/src/audio/impacts.ts +0 -212
- package/src/audio/movement.ts +0 -140
- package/src/audio/musical.ts +0 -200
- package/src/audio/ui-sounds.ts +0 -171
- package/src/audio/vehicle.ts +0 -235
- package/src/audio/weapons.ts +0 -152
- package/src/runtime/scene-ui-bridge.ts +0 -86
- package/src/runtime/scene-ui-data.ts +0 -119
- package/src/scene/schema/ui.ts +0 -602
package/src/runtime/game.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* threejs/scene check) and the two-world (threejs + pixijs) proof
|
|
9
9
|
* (`test/game-three-plus-pixi.test.ts`). T6.2 slice 1 adds real `'react'`-kind
|
|
10
10
|
* `WorldInstance` support (`container`, a real `reactRoot()`) — the DOM-root
|
|
11
|
-
* world surface `runtime/create-runtime.ts`'s
|
|
12
|
-
* `docs/REACT-WORLD-DESIGN.md` §1.B); react
|
|
11
|
+
* world surface `runtime/create-runtime.ts`'s roots path now mounts (see
|
|
12
|
+
* `docs/REACT-WORLD-DESIGN.md` §1.B); react roots host no `ComponentManager`
|
|
13
13
|
* (D8 — components attaching to a react-kind manager already throw,
|
|
14
14
|
* `ecs/component-manager.ts`), so `physics`/`collisions`/`camera`/`frame`
|
|
15
15
|
* stay `undefined` for them exactly like an opaque/foreign mount.
|
|
@@ -29,15 +29,21 @@
|
|
|
29
29
|
// at compile time; nothing here constructs or calls into Pixi.
|
|
30
30
|
import type * as PIXI from 'pixi.js';
|
|
31
31
|
import type * as THREE from 'three';
|
|
32
|
+
import type { AdapterSurface as AdapterSurfaceLeaf } from '../adapter/adapter-surface';
|
|
32
33
|
import type { MountedWorld } from '../adapter/game-adapter';
|
|
33
34
|
import { formatAudioGateMessage, formatLoopGateMessage } from '../adapter/loop-gate-report';
|
|
34
35
|
import type { SystemAdapters } from '../adapter/system-adapter';
|
|
35
36
|
import type { VgaiMountedGame } from '../adapter/vgai-scene-game-adapter';
|
|
36
|
-
import type { WorldKind as WorldKindLeaf } from '../adapter/world-kind';
|
|
37
37
|
import type { AssetCache } from '../assets';
|
|
38
38
|
import type { createGameLoop } from '../core/game-loop';
|
|
39
|
+
import {
|
|
40
|
+
createSeededRandom,
|
|
41
|
+
DEFAULT_SEEDED_RANDOM_SEED,
|
|
42
|
+
registerSeededRandom,
|
|
43
|
+
} from '../core/seeded-random';
|
|
39
44
|
import { createSystemRunner, type SystemRunner } from '../core/system-runner';
|
|
40
45
|
import { PHASE_ORDER, SystemPhase, type SystemPhaseName } from '../core/types';
|
|
46
|
+
import { createPerformanceProfiler, type PerformanceProfiler } from '../dev/performance-profiler';
|
|
41
47
|
import type { ComponentManager } from '../ecs/component-manager';
|
|
42
48
|
import type { GameComponent } from '../ecs/game-component';
|
|
43
49
|
import type { InputManager } from '../input/input-manager';
|
|
@@ -48,6 +54,13 @@ import type { AudioContext as GameAudio } from '../setup/setup-audio';
|
|
|
48
54
|
// `world2d/`, but `game.ts` only ever names their TYPES.
|
|
49
55
|
import type { Collision2DSystem } from '../world2d/collision-2d';
|
|
50
56
|
import type { Physics2DRegistry } from '../world2d/physics2d-registry';
|
|
57
|
+
import {
|
|
58
|
+
createDebugRegistry,
|
|
59
|
+
DebugError,
|
|
60
|
+
type RunTicksOptions,
|
|
61
|
+
registerDebugRegistry,
|
|
62
|
+
} from './debug-registry';
|
|
63
|
+
import { createGameplayRngTrap, registerGameplayRngTrapControl } from './gameplay-rng-trap';
|
|
51
64
|
import { createStateBridge, type GameStateBridge } from './state-bridge';
|
|
52
65
|
|
|
53
66
|
/** The one loop type — `createGameLoop`'s return shape (D1, fixed-step). */
|
|
@@ -92,8 +105,8 @@ export interface PlayState {
|
|
|
92
105
|
* undo for it). */
|
|
93
106
|
resume(): void;
|
|
94
107
|
/**
|
|
95
|
-
* Advance exactly the currently-FROZEN
|
|
96
|
-
* `pausable`, host-driven (`!drivesOwnLoop`)
|
|
108
|
+
* Advance exactly the currently-FROZEN roots by one fixed substep: the
|
|
109
|
+
* `pausable`, host-driven (`!drivesOwnLoop`) roots while `paused` is
|
|
97
110
|
* true. While NOT paused this is a whole-call no-op — under D10 the loop
|
|
98
111
|
* never stops, so every non-frozen world is already being ticked; an
|
|
99
112
|
* unconditional extra tick was the §7.1-2 double-tick bug (probe4). A
|
|
@@ -109,18 +122,18 @@ export interface PlayState {
|
|
|
109
122
|
|
|
110
123
|
/**
|
|
111
124
|
* The kinds of render surface a world can be. `'threejs'` and `'pixijs'`
|
|
112
|
-
*
|
|
125
|
+
* roots are real as of T7.3 slice 1 (`create-runtime.ts`'s default threejs
|
|
113
126
|
* world; `world2d/pixi-game-adapter.ts`'s pixi world); `'react'` is named
|
|
114
127
|
* here so the type is already shaped for T7.4 and no caller has to widen a
|
|
115
128
|
* union later.
|
|
116
129
|
*
|
|
117
|
-
* Re-exported from `adapter/
|
|
130
|
+
* Re-exported from `adapter/adapter-surface.ts` (T7.5) — moved there so
|
|
118
131
|
* `adapter/game-adapter.ts`'s kind-tagged `MountedWorld` types can name it
|
|
119
132
|
* without an import cycle back to this file. This re-export keeps every
|
|
120
|
-
* existing `import type {
|
|
133
|
+
* existing `import type { AdapterSurface } from '../runtime/game'` call site
|
|
121
134
|
* (`ecs/game-component.ts`, `ecs/component-manager.ts`) compiling unchanged.
|
|
122
135
|
*/
|
|
123
|
-
export type
|
|
136
|
+
export type AdapterSurface = AdapterSurfaceLeaf;
|
|
124
137
|
|
|
125
138
|
/**
|
|
126
139
|
* A world's per-phase frame hooks (GAME-ROOT-DESIGN §4, T7.1 slice 2).
|
|
@@ -134,7 +147,7 @@ export interface WorldFrameHooks {
|
|
|
134
147
|
* systems for one phase. For a first-party world this delegates to the
|
|
135
148
|
* SAME `SystemRunner.runPhase` its (legacy) `mounted.update` uses. */
|
|
136
149
|
runPhase(phase: SystemPhaseName, dt: number): void;
|
|
137
|
-
/** Run once per substep, after ALL phases have run for ALL
|
|
150
|
+
/** Run once per substep, after ALL phases have run for ALL roots this
|
|
138
151
|
* substep (mirrors where `mounted.update`'s post-`systems.run` work sat
|
|
139
152
|
* today — e.g. `input.endFrame()`). Optional: a world may have nothing
|
|
140
153
|
* to do here. */
|
|
@@ -149,7 +162,7 @@ export interface WorldFrameHooks {
|
|
|
149
162
|
export interface WorldInstance {
|
|
150
163
|
/** Manifest id (T3.1). Slice 1 always registers exactly one: `'main'`. */
|
|
151
164
|
readonly id: string;
|
|
152
|
-
readonly kind:
|
|
165
|
+
readonly kind: AdapterSurface;
|
|
153
166
|
/** Per-world play/pause semantics (D10, T7.6). Slice 1 always `true`. */
|
|
154
167
|
readonly pausable: boolean;
|
|
155
168
|
/** The GameAdapter that produced `mounted` — first-party or external.
|
|
@@ -169,12 +182,12 @@ export interface WorldInstance {
|
|
|
169
182
|
/** Kind-narrowed accessor: throws a descriptive error when this world is
|
|
170
183
|
* not a threejs world. */
|
|
171
184
|
threeRoot(): THREE.Scene;
|
|
172
|
-
/** Kind-narrowed accessor for pixijs
|
|
185
|
+
/** Kind-narrowed accessor for pixijs roots (T7.3): returns the stage
|
|
173
186
|
* passed to `createWorldInstance` for a `'pixijs'`-kind world. Throws
|
|
174
187
|
* descriptively for a non-pixijs world, or a pixijs world built without
|
|
175
188
|
* a `stage` (see `WorldInstanceInit.stage`). */
|
|
176
189
|
pixiRoot(): PIXI.Container;
|
|
177
|
-
/** Kind-narrowed accessor for react
|
|
190
|
+
/** Kind-narrowed accessor for react roots (T6.2 slice 1,
|
|
178
191
|
* `docs/REACT-WORLD-DESIGN.md` §1.B): returns the DOM-root layer
|
|
179
192
|
* `<div>` the host mounted this world's react tree into (the SAME
|
|
180
193
|
* element passed as `container` to `createWorldInstance` — identity
|
|
@@ -184,7 +197,7 @@ export interface WorldInstance {
|
|
|
184
197
|
* `WorldInstanceInit.container`). */
|
|
185
198
|
reactRoot(): HTMLElement;
|
|
186
199
|
/** Present only when the world's mount is first-party (Rapier3D for
|
|
187
|
-
* threejs
|
|
200
|
+
* threejs roots). */
|
|
188
201
|
readonly physics?: PhysicsRegistry | undefined;
|
|
189
202
|
readonly collisions?: CollisionSystem | undefined;
|
|
190
203
|
/** Present only when the world's mount is a first-party pixijs world
|
|
@@ -208,7 +221,7 @@ export interface WorldInstance {
|
|
|
208
221
|
* Minimal identity surface `WorldInstance.adapter` needs (T7.5) — see that
|
|
209
222
|
* field's doc comment for why it's narrower than `GameAdapter<K>`. Any real
|
|
210
223
|
* adapter object (a NAMED type, not a fresh object literal) — `GameAdapter<K>`,
|
|
211
|
-
* `Pixi2DGameAdapter`, `
|
|
224
|
+
* `Pixi2DGameAdapter`, `ReactRootAdapter`, or a project's own custom adapter —
|
|
212
225
|
* satisfies this trivially (extra members beyond `id` are always fine for a
|
|
213
226
|
* non-literal source); a bare `{ id, mount }` object literal built INLINE at
|
|
214
227
|
* a `createWorldInstance`/`registerXWorld` call site needs an intermediate
|
|
@@ -221,7 +234,7 @@ export interface AdapterHandle {
|
|
|
221
234
|
/** Inputs to {@link createWorldInstance}. */
|
|
222
235
|
export interface WorldInstanceInit {
|
|
223
236
|
readonly id: string;
|
|
224
|
-
readonly kind:
|
|
237
|
+
readonly kind: AdapterSurface;
|
|
225
238
|
/** Defaults to `true` (D10's per-world default). */
|
|
226
239
|
readonly pausable?: boolean;
|
|
227
240
|
readonly adapter: AdapterHandle;
|
|
@@ -300,10 +313,10 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
|
|
|
300
313
|
}
|
|
301
314
|
|
|
302
315
|
// Disposed-world guard (GAME-ROOT-DESIGN §8 stage 3 / T7.1 slice 3). There
|
|
303
|
-
// is no `unregisterWorld` (decision 4 —
|
|
316
|
+
// is no `unregisterWorld` (decision 4 — roots are manifest-declared; a
|
|
304
317
|
// whole Game is disposed, not one world out of its registry), so a caller
|
|
305
318
|
// that disposes ONE world's `mounted` directly (e.g. ending a sub-session)
|
|
306
|
-
// leaves that `WorldInstance` sitting in `Game.
|
|
319
|
+
// leaves that `WorldInstance` sitting in `Game.roots` — and `runFrame`
|
|
307
320
|
// would otherwise keep invoking its (now-torn-down) frame hooks every
|
|
308
321
|
// subsequent frame. `MountedGame` has no public "am I disposed" flag to
|
|
309
322
|
// read, so this wraps `mounted.dispose` in place (mutating the SAME mount
|
|
@@ -393,7 +406,7 @@ export function createWorldInstance(init: WorldInstanceInit): WorldInstance {
|
|
|
393
406
|
* mounted` — world2d's `MountedGame2D` (`world2d/pixi-game-adapter.ts`) also
|
|
394
407
|
* has a `ctx` key (a `World2DContext`, unrelated to `GameContext`), so a
|
|
395
408
|
* structural `'ctx' in mounted` check would misfire once T7.3 registers 2D
|
|
396
|
-
*
|
|
409
|
+
* roots onto the same `Game`. The probe is a plain property read, not an
|
|
397
410
|
* `instanceof`/value import of `vgai-scene-game-adapter.ts` — the
|
|
398
411
|
* `VgaiMountedGame` import above stays type-only.
|
|
399
412
|
*/
|
|
@@ -413,6 +426,8 @@ export function isFirstPartyMounted(mounted: MountedWorld): mounted is VgaiMount
|
|
|
413
426
|
export interface Game {
|
|
414
427
|
readonly loop: GameLoop;
|
|
415
428
|
readonly assets: AssetCache;
|
|
429
|
+
/** Per-game diagnostic store. Disabled by default; the editor enables it on demand. */
|
|
430
|
+
readonly profiler: PerformanceProfiler;
|
|
416
431
|
/**
|
|
417
432
|
* The game-scoped `SystemRunner` (GAME-ROOT-DESIGN §4, T7.1 slice 2) — a
|
|
418
433
|
* NEW bucket, separate from any world's own runner. Within each phase,
|
|
@@ -426,9 +441,9 @@ export interface Game {
|
|
|
426
441
|
readonly systems: SystemRunner;
|
|
427
442
|
/** Declaration-ordered. Slice 1 registers exactly one (the default
|
|
428
443
|
* threejs world) — this is the SAME array reference `registerWorld`
|
|
429
|
-
* mutates, not a snapshot, so holders (e.g. `GameContext.
|
|
444
|
+
* mutates, not a snapshot, so holders (e.g. `GameContext.roots`) observe
|
|
430
445
|
* later registrations. */
|
|
431
|
-
readonly
|
|
446
|
+
readonly roots: ReadonlyArray<WorldInstance>;
|
|
432
447
|
world(id: string): WorldInstance | null;
|
|
433
448
|
/** First threejs world, else first world. Throws descriptively when no
|
|
434
449
|
* world has been registered yet. */
|
|
@@ -440,9 +455,9 @@ export interface Game {
|
|
|
440
455
|
* mounted world builds its OWN `mounted.systems` object (a game registers
|
|
441
456
|
* capabilities like `networking` from ITS OWN `setup()`, via
|
|
442
457
|
* `ctx.registerSystemAdapter`, per-world) — this merges every world's
|
|
443
|
-
* `mounted.systems` into ONE `SystemAdapters`, in `
|
|
458
|
+
* `mounted.systems` into ONE `SystemAdapters`, in `roots` REGISTRATION
|
|
444
459
|
* order, first registration wins per key. A later world registering the
|
|
445
|
-
* SAME kind (e.g. two
|
|
460
|
+
* SAME kind (e.g. two roots both exposing `networking`) does not
|
|
446
461
|
* override the first — instead this warns ONCE per (game instance, key)
|
|
447
462
|
* naming both world ids, matching this file's `[game] world "<id>" …`
|
|
448
463
|
* console idiom (see `registerWorld` below). NOT to be confused with
|
|
@@ -470,9 +485,9 @@ export interface Game {
|
|
|
470
485
|
/**
|
|
471
486
|
* Frame-versioned state bridge (T7.4 slice 1 — `docs/REACT-STATE-BRIDGE.md`
|
|
472
487
|
* §2). Bumped once per completed `runFrame`, after all phases of all
|
|
473
|
-
*
|
|
488
|
+
* roots and all `endFrame` hooks (see `runFrame`'s tail below). This is
|
|
474
489
|
* the ONE subscription surface `useGameState`
|
|
475
|
-
* (
|
|
490
|
+
* (`@vgai/engine/react/game-state`) — or any
|
|
476
491
|
* other frame-versioned consumer — subscribes to; `state-bridge.ts` itself
|
|
477
492
|
* has no react import, matching the rest of `runtime/`.
|
|
478
493
|
*/
|
|
@@ -484,7 +499,7 @@ export interface Game {
|
|
|
484
499
|
* game-level half; `ComponentManager.queryByComponent` (`ecs/
|
|
485
500
|
* component-manager.ts`) is the per-world half this aggregates.
|
|
486
501
|
*
|
|
487
|
-
* Iterates `
|
|
502
|
+
* Iterates `roots` in declaration order, skipping any world whose mount
|
|
488
503
|
* is not first-party (an opaque/foreign mount has no `ComponentManager`
|
|
489
504
|
* to query), applying `opts.worldId`/`opts.kind` as world-level filters,
|
|
490
505
|
* and concatenating each remaining world's `queryByComponent(cls)`
|
|
@@ -502,15 +517,15 @@ export interface Game {
|
|
|
502
517
|
* signature keep working unchanged once the manager is unified
|
|
503
518
|
* underneath.
|
|
504
519
|
*
|
|
505
|
-
* `T extends GameComponent<
|
|
520
|
+
* `T extends GameComponent<AdapterSurface>` (not the bare default-`'threejs'`
|
|
506
521
|
* `GameComponent`, T7.3 slice 2) — the whole point of a CROSS-WORLD query
|
|
507
522
|
* is spanning every kind in one call (the T7.3 AC,
|
|
508
523
|
* `test/game-three-plus-pixi.test.ts`), so a `GameComponent<'pixijs'>`
|
|
509
524
|
* subclass must type-check here exactly like a default-kind one does.
|
|
510
525
|
*/
|
|
511
|
-
queryByComponent<T extends GameComponent<
|
|
526
|
+
queryByComponent<T extends GameComponent<AdapterSurface>>(
|
|
512
527
|
cls: new () => T,
|
|
513
|
-
opts?: { worldId?: string; kind?:
|
|
528
|
+
opts?: { worldId?: string; kind?: AdapterSurface },
|
|
514
529
|
): T[];
|
|
515
530
|
}
|
|
516
531
|
|
|
@@ -532,10 +547,10 @@ export interface GameInternal extends Game {
|
|
|
532
547
|
* ```
|
|
533
548
|
* for phase in PHASE_ORDER:
|
|
534
549
|
* game.systems.runPhase(phase, dt) // game-scoped, first
|
|
535
|
-
* for world in
|
|
550
|
+
* for world in roots (declaration order):
|
|
536
551
|
* if world.mounted.drivesOwnLoop: continue
|
|
537
552
|
* world.frame?.runPhase(phase, dt)
|
|
538
|
-
* for world in
|
|
553
|
+
* for world in roots: // after ALL phases
|
|
539
554
|
* if world.mounted.drivesOwnLoop: continue
|
|
540
555
|
* if world.frame: world.frame.endFrame?.()
|
|
541
556
|
* else: world.mounted.update?.(dt) // opaque world fallback
|
|
@@ -569,6 +584,54 @@ export interface GameInternal extends Game {
|
|
|
569
584
|
* the frozen set is then empty.
|
|
570
585
|
*/
|
|
571
586
|
runFrame(dt: number, opts?: { ignorePause?: boolean }): void;
|
|
587
|
+
/**
|
|
588
|
+
* D15/T-D15.3-.4 (`docs/D15-DETERMINISM-DESIGN.md` §2.b) — a deterministic
|
|
589
|
+
* fast-forward primitive: synchronously call the SAME per-tick pipeline
|
|
590
|
+
* `runFrame` uses, `n` times in a tight loop, with `dt` fixed to the host
|
|
591
|
+
* loop's own fixed timestep (`this.loop.fixedDt` — `core/game-loop.ts`).
|
|
592
|
+
* This generalizes `render-control.ts`'s proven `VgaiRenderHarness.
|
|
593
|
+
* simulateSubsteps` from the capture-only door (`?vgai-render=1`) to a
|
|
594
|
+
* Game-level primitive every door can reach (the bridge's
|
|
595
|
+
* `window.__vgai.runTicks`, the editor relay's `run-ticks` case →
|
|
596
|
+
* `play.runTicks`) — `simulateSubsteps` itself is UNTOUCHED by this
|
|
597
|
+
* addition (it may later delegate to this method; not this unit's job).
|
|
598
|
+
*
|
|
599
|
+
* Semantics:
|
|
600
|
+
* - **Decoupled from wall clock and the accumulator.** `runTicks` drives
|
|
601
|
+
* `runFrame` DIRECTLY — it never goes through `GameLoop`'s own
|
|
602
|
+
* accumulator/spiral-of-death drop path, so `n` ticks always advance
|
|
603
|
+
* `tick`/`simT` by exactly `n`/`n * fixedDt`, on any hardware, regardless
|
|
604
|
+
* of how slow or fast the call actually took wall-clock-wise. Single-
|
|
605
|
+
* threaded-burst note: because JS has one thread, this synchronous burst
|
|
606
|
+
* can never interleave with a real rAF frame — but if the host's own
|
|
607
|
+
* loop is still running (`loop.start()`), wall-clock time keeps accruing
|
|
608
|
+
* in ITS accumulator while this call executes; the NEXT rAF frame after
|
|
609
|
+
* the burst sees that gap and the loop's existing `maxAccumulator` clamp
|
|
610
|
+
* absorbs it exactly as it would absorb any other slow-frame gap (up to
|
|
611
|
+
* 8 substeps, additional time dropped) — `runTicks` does not need to
|
|
612
|
+
* (and does not) touch the accumulator itself to make this safe.
|
|
613
|
+
* - **`opts.render`** (default `'last'`): `'last'` skips the `preRender`/
|
|
614
|
+
* `render` phases for ticks `0..n-2` and runs the full phase list
|
|
615
|
+
* (including `preRender`/`render`) on the final tick only — the GGPO
|
|
616
|
+
* tick-without-render pattern. `'all'` renders every tick. `'none'`
|
|
617
|
+
* never renders, not even the last tick. `tick`/`simT`/
|
|
618
|
+
* `stateBridge.bump()`/the debug event ring advance identically on
|
|
619
|
+
* EVERY tick regardless of `render` — only the paint-affecting phases
|
|
620
|
+
* are skipped, so state watchers (`useGameState`, debug state providers)
|
|
621
|
+
* stay correct even when fast-forwarding with no visible output.
|
|
622
|
+
* - **Refuses while paused.** Throws a structured `DebugError`
|
|
623
|
+
* (`code: 'RUN_TICKS_PAUSED'`) if `Game.play.paused` is true —
|
|
624
|
+
* `Game.play.step()` owns stepping the frozen set; `runTicks` is a
|
|
625
|
+
* running-game primitive, not a paused-world stepper, and silently
|
|
626
|
+
* no-op-ing or silently ignoring pause would violate the "byte-identical
|
|
627
|
+
* across doors" contract this primitive exists to provide.
|
|
628
|
+
* - **Does not bypass the input focus gate.** Every tick's `input` phase
|
|
629
|
+
* runs through the SAME `InputManager` every other call to `runFrame`
|
|
630
|
+
* does — a gated virtual actuation (page unfocused, Game tab inactive)
|
|
631
|
+
* still reports `{delivered: false, reason}` exactly as it would under
|
|
632
|
+
* normal play; `runTicks` has no special-cased "force focus" behavior.
|
|
633
|
+
*/
|
|
634
|
+
runTicks(n: number, opts?: RunTicksOptions): void;
|
|
572
635
|
}
|
|
573
636
|
|
|
574
637
|
function describeMismatch(handle: 'components' | 'input' | 'audio', world: WorldInstance): string {
|
|
@@ -582,19 +645,86 @@ function describeMismatch(handle: 'components' | 'input' | 'audio', world: World
|
|
|
582
645
|
/**
|
|
583
646
|
* Construct the (host-internal) Game shell. Callers: `createGameRuntime`
|
|
584
647
|
* builds this BEFORE mounting its one adapter, then registers the default
|
|
585
|
-
* threejs world once mount resolves (see `
|
|
648
|
+
* threejs world once mount resolves (see `registerThreeWorld` in
|
|
586
649
|
* `create-runtime.ts`).
|
|
587
650
|
*/
|
|
588
|
-
export function createGame(opts: {
|
|
589
|
-
|
|
590
|
-
|
|
651
|
+
export function createGame(opts: {
|
|
652
|
+
loop: GameLoop;
|
|
653
|
+
assets: AssetCache;
|
|
654
|
+
/** D15 (T-D15.1) — the root seed `ctx.random` boots from, on every world
|
|
655
|
+
* mounted onto this Game. Defaults to `DEFAULT_SEEDED_RANDOM_SEED` (a
|
|
656
|
+
* fixed, non-wall-clock constant — `ctx.random` is always reproducible
|
|
657
|
+
* on its own terms, whether or not the project's manifest DECLARES that
|
|
658
|
+
* reproducibility as a contract). The manifest-aware boot path
|
|
659
|
+
* (`mount-manifest.ts`'s `mountManifestWorlds`) is what actually resolves
|
|
660
|
+
* `manifest.determinism.defaultSeed`/`?vgai-seed=`/explicit config and
|
|
661
|
+
* passes the result here, BEFORE any world's `mount()`/`setup()` runs —
|
|
662
|
+
* `createGameRuntime` always constructs the Game (this call) first (see
|
|
663
|
+
* this function's own doc comment below). */
|
|
664
|
+
seed?: number | undefined;
|
|
665
|
+
}): GameInternal {
|
|
666
|
+
const roots: WorldInstance[] = [];
|
|
667
|
+
const profiler = createPerformanceProfiler();
|
|
668
|
+
const systems = createSystemRunner(profiler.systemObserver, 'game');
|
|
591
669
|
const stateBridge = createStateBridge();
|
|
670
|
+
// D15 (T-D15.1) — the game-scoped seeded-random surface every world's
|
|
671
|
+
// `ctx.random` aliases (see `vgai-scene-game-adapter.ts`'s `ctx.random =
|
|
672
|
+
// ...`, wired the same way `ctx.debug` is just below). Constructed
|
|
673
|
+
// unconditionally (cheap — a handful of closures) regardless of whether
|
|
674
|
+
// this project ever declares `determinism.seededRandom`; only the BOOT
|
|
675
|
+
// SEED and the enforcement (the burn-down scan, the trap right below) are
|
|
676
|
+
// conditional on that declaration.
|
|
677
|
+
const seededRandom = createSeededRandom(opts.seed ?? DEFAULT_SEEDED_RANDOM_SEED);
|
|
678
|
+
// D15 (T-D15.3) — the dev-mode `Math.random` phase trap. Constructed
|
|
679
|
+
// unconditionally too (disabled by default: `rngTrapEnabled` starts
|
|
680
|
+
// `false`, so `runFrameImpl`'s enable/disable calls below are no-ops) —
|
|
681
|
+
// the manifest-aware boot path flips `rngTrapControl.setEnabled(true)`
|
|
682
|
+
// once it resolves `manifest.determinism?.seededRandom` (mirrors
|
|
683
|
+
// `debugRegistry.setRoomDeclared` being flipped post-hoc from the same
|
|
684
|
+
// boot path for the very same "only the caller who read the manifest
|
|
685
|
+
// knows" reason).
|
|
686
|
+
const rngTrap = createGameplayRngTrap();
|
|
687
|
+
let rngTrapEnabled = false;
|
|
688
|
+
const rngTrapControl = {
|
|
689
|
+
setEnabled(enabled: boolean): void {
|
|
690
|
+
rngTrapEnabled = enabled;
|
|
691
|
+
},
|
|
692
|
+
get enabled(): boolean {
|
|
693
|
+
return rngTrapEnabled;
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
// T1.2 (docs/SYNTHETIC-PLAYER-SPEC.md §3.1/§3.3): `tick` counts completed
|
|
697
|
+
// fixed substeps, `simT` accumulates their `dt` — both game-scoped, bumped
|
|
698
|
+
// ONLY where `stateBridge.bump()` is (guarded by `advanced`, `runFrame`'s
|
|
699
|
+
// tail below), so a paused/frozen frame never advances either. The debug
|
|
700
|
+
// registry reads them via suppliers (not by capturing the numbers now)
|
|
701
|
+
// so its built-in `time` provider always sees the CURRENT values.
|
|
702
|
+
let tick = 0;
|
|
703
|
+
let simT = 0;
|
|
704
|
+
const debugRegistry = createDebugRegistry({
|
|
705
|
+
getTick: () => tick,
|
|
706
|
+
getSimT: () => simT,
|
|
707
|
+
// D15/T-D15.5 — "the manifest's first/default world" for the per-world
|
|
708
|
+
// input-target resolution (`resolveInputWorldId`, `debug-registry.ts`):
|
|
709
|
+
// the SAME "first threejs world, else first world" rule
|
|
710
|
+
// `requireDefaultWorld` (declared just below — safe: this closure is
|
|
711
|
+
// only ever CALLED later, once at least one world has mounted) already
|
|
712
|
+
// defines for `Game.defaultWorld`/`Game.input` — so the debug bridge and
|
|
713
|
+
// the editor relay resolve the exact same world `Game.input` would.
|
|
714
|
+
getDefaultWorldId: () => (roots.length > 0 ? requireDefaultWorld().id : null),
|
|
715
|
+
// Issue #175 — the built-in `time` provider's `loopLiveness` field reads
|
|
716
|
+
// the REAL loop, not any UI-level play-state store: `opts.loop` is the
|
|
717
|
+
// SAME `GameLoop` this `Game`'s own `.loop` field exposes, so this
|
|
718
|
+
// registry can never disagree with `game.loop.liveness` about whether
|
|
719
|
+
// the loop is actually ticking.
|
|
720
|
+
getLoopLiveness: () => opts.loop.liveness,
|
|
721
|
+
});
|
|
592
722
|
|
|
593
723
|
function requireDefaultWorld(): WorldInstance {
|
|
594
|
-
if (
|
|
595
|
-
throw new Error('Game.defaultWorld: no
|
|
724
|
+
if (roots.length === 0) {
|
|
725
|
+
throw new Error('Game.defaultWorld: no roots registered yet');
|
|
596
726
|
}
|
|
597
|
-
return
|
|
727
|
+
return roots.find((w) => w.kind === 'threejs') ?? roots[0]!;
|
|
598
728
|
}
|
|
599
729
|
|
|
600
730
|
function requireFirstPartyCtx(handle: 'components' | 'input' | 'audio') {
|
|
@@ -613,15 +743,26 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
613
743
|
const warnedSystemAdapterKeys = new Set<string>();
|
|
614
744
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
|
|
615
745
|
function computeSystemAdapters(): SystemAdapters {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
746
|
+
// Debug is game-scoped: React hooks, probes, and the built-in time
|
|
747
|
+
// provider all register with the one registry created above, regardless
|
|
748
|
+
// of whether any mounted root happens to expose a `systems` object. A
|
|
749
|
+
// root may still publish this SAME adapter (the first-party Three/Pixi
|
|
750
|
+
// mounts do); the reference-equality branch below treats that as the
|
|
751
|
+
// intentional shared registration it is.
|
|
752
|
+
const result: SystemAdapters = { debug: debugRegistry.adapter };
|
|
753
|
+
const ownerWorldId = new Map<string, string>([['debug', '(game)']]);
|
|
754
|
+
for (const world of roots) {
|
|
619
755
|
const adapters = world.mounted.systems;
|
|
620
756
|
if (!adapters) continue;
|
|
621
757
|
for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
|
|
622
758
|
if (adapters[key] === undefined) continue;
|
|
623
759
|
const existingOwner = ownerWorldId.get(key);
|
|
624
760
|
if (existingOwner !== undefined) {
|
|
761
|
+
// Reference-equality short-circuit: two roots sharing the ONE
|
|
762
|
+
// game-scoped debug registry's adapter (T1.1) both expose the SAME
|
|
763
|
+
// object under `systems.debug` — that is by design, not a
|
|
764
|
+
// collision, so it must never warn.
|
|
765
|
+
if (result[key] === adapters[key]) continue;
|
|
625
766
|
if (!warnedSystemAdapterKeys.has(key)) {
|
|
626
767
|
warnedSystemAdapterKeys.add(key);
|
|
627
768
|
// biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors this file's own reportGateShortfallOnce's deliberate direct console.warn just above
|
|
@@ -665,14 +806,14 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
665
806
|
console.warn(message);
|
|
666
807
|
}
|
|
667
808
|
|
|
668
|
-
/** Fan out a loop-gate (self-driven
|
|
809
|
+
/** Fan out a loop-gate (self-driven roots) + audio-gate call over every
|
|
669
810
|
* `pausable` world, reporting honestly (once) wherever the capability is
|
|
670
811
|
* absent — shared by `pause()`/`resume()` below (same fan-out, opposite
|
|
671
812
|
* boolean). */
|
|
672
813
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fans out TWO independent capability gates (loop, audio) with the same "call it, else report once" shape per world — splitting the two gates into separate loops would duplicate the fan-out, not reduce real complexity
|
|
673
814
|
function setWorldGates(next: boolean): void {
|
|
674
|
-
for (const world of
|
|
675
|
-
if (!world.pausable) continue; // pausable:false
|
|
815
|
+
for (const world of roots) {
|
|
816
|
+
if (!world.pausable) continue; // pausable:false roots are untouched by design
|
|
676
817
|
if (world.mounted.drivesOwnLoop) {
|
|
677
818
|
if (world.mounted.setPaused) {
|
|
678
819
|
world.mounted.setPaused(next);
|
|
@@ -693,12 +834,26 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
693
834
|
}
|
|
694
835
|
}
|
|
695
836
|
|
|
696
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (GAME-ROOT-DESIGN §4, now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/
|
|
837
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the frame algorithm (GAME-ROOT-DESIGN §4, now with D10's per-world pause gate + the onlyFrozen step()-only mode) is one cohesive nested loop over phases/roots — splitting it would obscure the ordering contract documented on GameInternal.runFrame
|
|
697
838
|
function runFrameImpl(
|
|
698
839
|
dt: number,
|
|
699
|
-
frameOpts?: { ignorePause?: boolean; onlyFrozen?: boolean },
|
|
840
|
+
frameOpts?: { ignorePause?: boolean; onlyFrozen?: boolean; skipRenderPhases?: boolean },
|
|
700
841
|
): void {
|
|
842
|
+
// D15 (T-D15.3) — brackets the ENTIRE frame body (every phase, every
|
|
843
|
+
// world, both the `onlyFrozen` and normal branches below converge on the
|
|
844
|
+
// single `profiler.endFrame()` at the tail) with zero reordering of the
|
|
845
|
+
// phase algorithm itself — a no-op pair of calls while
|
|
846
|
+
// `rngTrapEnabled` is false (the common case: most projects never
|
|
847
|
+
// declare `determinism.seededRandom`).
|
|
848
|
+
if (rngTrapEnabled) rngTrap.enable();
|
|
849
|
+
profiler.beginFrame();
|
|
701
850
|
const ignorePause = frameOpts?.ignorePause ?? false;
|
|
851
|
+
// D15/T-D15.4: internal-only render-phase skip (not part of the public
|
|
852
|
+
// `GameInternal.runFrame` signature — no external caller sets it, mirroring
|
|
853
|
+
// `onlyFrozen` below) — `runTicks`'s `render: 'none'|'last'` fast-forward
|
|
854
|
+
// mode sets this on every tick it doesn't want to paint. See `runTicks`'s
|
|
855
|
+
// doc comment on `GameInternal` for the full contract.
|
|
856
|
+
const skipRenderPhases = frameOpts?.skipRenderPhases ?? false;
|
|
702
857
|
// `onlyFrozen` is internal-only (not part of the public `GameInternal.runFrame`
|
|
703
858
|
// signature — no external caller sets it) — `Game.play.step()` below is the
|
|
704
859
|
// one and only caller. It ticks EXACTLY the currently-frozen set (host-driven,
|
|
@@ -711,25 +866,25 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
711
866
|
// Checklist item 7: snapshot the world count ONCE at entry and iterate
|
|
712
867
|
// by index in both loops below. A world registered mid-frame (e.g. from
|
|
713
868
|
// a game-scoped system's side effect) joins at the NEXT `runFrame` call,
|
|
714
|
-
// not this one — `for (const world of
|
|
869
|
+
// not this one — `for (const world of roots)` would otherwise pick up
|
|
715
870
|
// a world pushed during this very frame. This also drops the two
|
|
716
871
|
// per-phase `for...of` iterator allocations.
|
|
717
|
-
const n =
|
|
872
|
+
const n = roots.length;
|
|
718
873
|
|
|
719
874
|
// §7.1-11 fix (probe5): `stateBridge.bump()` must fire iff at least one
|
|
720
875
|
// world actually advanced this call — not unconditionally. `advanced`
|
|
721
|
-
// covers rule (a) below (host-driven
|
|
876
|
+
// covers rule (a) below (host-driven roots this call actually ticked);
|
|
722
877
|
// rule (b): a self-driven world that is RUNNING — which is every
|
|
723
878
|
// self-driven world while not paused, and, while paused, the ones the
|
|
724
879
|
// gate can't reach (`pausable: false`, or no `setPaused` capability) —
|
|
725
|
-
// checked once, up front, over the same `
|
|
880
|
+
// checked once, up front, over the same `roots` array (no extra
|
|
726
881
|
// allocation, matching every other loop here). Rule (b) is skipped in
|
|
727
882
|
// `onlyFrozen` mode: a `step()` call bumps iff it ticked a frozen world
|
|
728
883
|
// — self-driven notifications belong to the loop's own `runFrame`s.
|
|
729
884
|
let advanced = false;
|
|
730
885
|
if (!onlyFrozen) {
|
|
731
886
|
for (let i = 0; i < n; i++) {
|
|
732
|
-
const world =
|
|
887
|
+
const world = roots[i]!;
|
|
733
888
|
if (!world.mounted.drivesOwnLoop) continue;
|
|
734
889
|
if (!paused || !world.pausable || !world.mounted.setPaused) {
|
|
735
890
|
advanced = true;
|
|
@@ -745,8 +900,9 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
745
900
|
// running" behavior falls straight out of this, no separate guard
|
|
746
901
|
// needed.
|
|
747
902
|
for (const phase of PHASE_ORDER) {
|
|
903
|
+
profiler.beginPhase();
|
|
748
904
|
for (let i = 0; i < n; i++) {
|
|
749
|
-
const world =
|
|
905
|
+
const world = roots[i]!;
|
|
750
906
|
if (world.mounted.drivesOwnLoop) continue;
|
|
751
907
|
if (!(paused && world.pausable)) continue; // only the frozen set
|
|
752
908
|
try {
|
|
@@ -759,9 +915,10 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
759
915
|
);
|
|
760
916
|
}
|
|
761
917
|
}
|
|
918
|
+
profiler.endPhase(phase);
|
|
762
919
|
}
|
|
763
920
|
for (let i = 0; i < n; i++) {
|
|
764
|
-
const world =
|
|
921
|
+
const world = roots[i]!;
|
|
765
922
|
if (world.mounted.drivesOwnLoop) continue;
|
|
766
923
|
if (!(paused && world.pausable)) continue;
|
|
767
924
|
advanced = true;
|
|
@@ -787,35 +944,45 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
787
944
|
}
|
|
788
945
|
} else {
|
|
789
946
|
for (const phase of PHASE_ORDER) {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
world.
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
947
|
+
profiler.beginPhase();
|
|
948
|
+
// D15/T-D15.4: `runTicks`'s fast-forward skip — `preRender`/`render`
|
|
949
|
+
// are the ONLY phases ever skipped this way (every earlier gameplay
|
|
950
|
+
// phase, and `endFrame` below, always run) — see `skipRenderPhases`'s
|
|
951
|
+
// declaration above and `runTicks`'s doc comment on `GameInternal`.
|
|
952
|
+
const skipThisPhase =
|
|
953
|
+
skipRenderPhases && (phase === SystemPhase.PRE_RENDER || phase === SystemPhase.RENDER);
|
|
954
|
+
if (!skipThisPhase) {
|
|
955
|
+
systems.runPhase(phase, dt);
|
|
956
|
+
for (let i = 0; i < n; i++) {
|
|
957
|
+
const world = roots[i]!;
|
|
958
|
+
if (world.mounted.drivesOwnLoop) continue;
|
|
959
|
+
// D10/T7.6: a `pausable` world under an active (non-ignored) pause
|
|
960
|
+
// skips every phase except `render` — its render still runs, every
|
|
961
|
+
// substep, but with `dt` forced to `0` (deterministic: no
|
|
962
|
+
// time-based render effect silently keeps animating a "frozen"
|
|
963
|
+
// scene). A `pausable: false` world (or any world while
|
|
964
|
+
// `ignorePause`) is unaffected.
|
|
965
|
+
const frozen = !ignorePause && paused && world.pausable;
|
|
966
|
+
if (frozen && phase !== SystemPhase.RENDER) continue;
|
|
967
|
+
const phaseDt = frozen ? 0 : dt;
|
|
968
|
+
// Checklist item 2: isolate each world's per-phase work — one
|
|
969
|
+
// world's `runPhase` throwing must not starve sibling roots still
|
|
970
|
+
// due this phase, nor abort the frame. Mirrors `runOne`'s style in
|
|
971
|
+
// `core/system-runner.ts` (loud console.error, never swallowed).
|
|
972
|
+
try {
|
|
973
|
+
world.frame?.runPhase(phase, phaseDt);
|
|
974
|
+
} catch (err) {
|
|
975
|
+
console.error(
|
|
976
|
+
`[game] world "${world.id}" (kind: ${world.kind}) runPhase("${phase}") threw:`,
|
|
977
|
+
err,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
814
980
|
}
|
|
815
981
|
}
|
|
982
|
+
profiler.endPhase(phase);
|
|
816
983
|
}
|
|
817
984
|
for (let i = 0; i < n; i++) {
|
|
818
|
-
const world =
|
|
985
|
+
const world = roots[i]!;
|
|
819
986
|
if (world.mounted.drivesOwnLoop) continue;
|
|
820
987
|
// A fully-frozen world gets no `endFrame`/opaque-`update` call either —
|
|
821
988
|
// there is nothing to "end the frame" of when nothing ran this substep.
|
|
@@ -850,18 +1017,25 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
850
1017
|
// once per completed `runFrame`/`step()` call, never per phase/world —
|
|
851
1018
|
// and, per §7.1-11's fix, only when `advanced` (see above) is true: a
|
|
852
1019
|
// fully-gated paused game produces no notifications at all.
|
|
853
|
-
if (advanced)
|
|
1020
|
+
if (advanced) {
|
|
1021
|
+
stateBridge.bump();
|
|
1022
|
+
tick++;
|
|
1023
|
+
simT += dt;
|
|
1024
|
+
}
|
|
1025
|
+
profiler.endFrame();
|
|
1026
|
+
if (rngTrapEnabled) rngTrap.disable();
|
|
854
1027
|
}
|
|
855
1028
|
|
|
856
|
-
|
|
1029
|
+
const gameInternal: GameInternal = {
|
|
857
1030
|
loop: opts.loop,
|
|
858
1031
|
assets: opts.assets,
|
|
1032
|
+
profiler,
|
|
859
1033
|
systems,
|
|
860
|
-
get
|
|
861
|
-
return
|
|
1034
|
+
get roots() {
|
|
1035
|
+
return roots;
|
|
862
1036
|
},
|
|
863
1037
|
world(id: string): WorldInstance | null {
|
|
864
|
-
return
|
|
1038
|
+
return roots.find((w) => w.id === id) ?? null;
|
|
865
1039
|
},
|
|
866
1040
|
get defaultWorld() {
|
|
867
1041
|
return requireDefaultWorld();
|
|
@@ -894,7 +1068,7 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
894
1068
|
setWorldGates(false);
|
|
895
1069
|
},
|
|
896
1070
|
step(dt = 1 / 60) {
|
|
897
|
-
// Self-driven pausable
|
|
1071
|
+
// Self-driven pausable roots advance via their adapter's `step()`
|
|
898
1072
|
// capability — but only the ones that are actually FROZEN: the game
|
|
899
1073
|
// must be paused, and the world must have been gate-able in the
|
|
900
1074
|
// first place (`setPaused` present — a world the gate couldn't reach
|
|
@@ -902,7 +1076,7 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
902
1076
|
// loop, the same §7.1-2 class as the host-driven fix below). While
|
|
903
1077
|
// not paused, nothing here runs — step() is a whole-call no-op.
|
|
904
1078
|
if (paused) {
|
|
905
|
-
for (const world of
|
|
1079
|
+
for (const world of roots) {
|
|
906
1080
|
if (!world.pausable || !world.mounted.drivesOwnLoop) continue;
|
|
907
1081
|
if (!world.mounted.setPaused) continue; // never gated — still running
|
|
908
1082
|
if (world.mounted.step) {
|
|
@@ -924,9 +1098,9 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
924
1098
|
runFrameImpl(dt, { onlyFrozen: true });
|
|
925
1099
|
},
|
|
926
1100
|
},
|
|
927
|
-
queryByComponent<T extends GameComponent<
|
|
1101
|
+
queryByComponent<T extends GameComponent<AdapterSurface>>(
|
|
928
1102
|
cls: new () => T,
|
|
929
|
-
opts?: { worldId?: string; kind?:
|
|
1103
|
+
opts?: { worldId?: string; kind?: AdapterSurface },
|
|
930
1104
|
): T[] {
|
|
931
1105
|
// Checklist item 8a: a `worldId` naming a world that doesn't exist is a
|
|
932
1106
|
// caller error (typo'd id, wrong manifest) — degrade loudly, matching
|
|
@@ -934,11 +1108,11 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
934
1108
|
// exist but isn't first-party legitimately answers "no components"
|
|
935
1109
|
// below (the loop's `!isFirstPartyMounted` `continue`), which stays
|
|
936
1110
|
// silent — that's a real, not a mistaken, empty result.
|
|
937
|
-
if (opts?.worldId !== undefined && !
|
|
1111
|
+
if (opts?.worldId !== undefined && !roots.some((w) => w.id === opts.worldId)) {
|
|
938
1112
|
throw new Error(`Game.queryByComponent: unknown worldId "${opts.worldId}"`);
|
|
939
1113
|
}
|
|
940
1114
|
const result: T[] = [];
|
|
941
|
-
for (const world of
|
|
1115
|
+
for (const world of roots) {
|
|
942
1116
|
if (!isFirstPartyMounted(world.mounted)) continue;
|
|
943
1117
|
if (opts?.worldId !== undefined && world.id !== opts.worldId) continue;
|
|
944
1118
|
if (opts?.kind !== undefined && world.kind !== opts.kind) continue;
|
|
@@ -951,7 +1125,7 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
951
1125
|
return result;
|
|
952
1126
|
},
|
|
953
1127
|
registerWorld(world: WorldInstance): void {
|
|
954
|
-
if (
|
|
1128
|
+
if (roots.some((w) => w.id === world.id)) {
|
|
955
1129
|
throw new Error(`Game.registerWorld: duplicate world id "${world.id}"`);
|
|
956
1130
|
}
|
|
957
1131
|
// Checklist item 6: the SAME `mounted` object registered under two
|
|
@@ -962,14 +1136,47 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
962
1136
|
// `originalDispose`, which is harmless today only by accident of
|
|
963
1137
|
// `VgaiSceneGameAdapter.dispose` being idempotent; a foreign adapter
|
|
964
1138
|
// has no such guarantee). Reject it outright instead.
|
|
965
|
-
if (
|
|
1139
|
+
if (roots.some((w) => w.mounted === world.mounted)) {
|
|
966
1140
|
throw new Error(
|
|
967
1141
|
`Game.registerWorld: world "${world.id}" shares its \`mounted\` object with an ` +
|
|
968
|
-
`already-registered world ("${
|
|
1142
|
+
`already-registered world ("${roots.find((w) => w.mounted === world.mounted)!.id}") — ` +
|
|
969
1143
|
'the same mount cannot be registered twice.',
|
|
970
1144
|
);
|
|
971
1145
|
}
|
|
972
|
-
|
|
1146
|
+
roots.push(world);
|
|
1147
|
+
// Notify state-bridge subscribers THE INSTANT the world list changes —
|
|
1148
|
+
// not just at the next completed `runFrame`. Root-cause fix for the
|
|
1149
|
+
// "roots: " (empty) hang in `36-r3f-first-party.spec.ts` on CI
|
|
1150
|
+
// (docs/R3F-FIRST-PARTY-DESIGN.md W3, gate 4): `mountAllWorldSpecs`
|
|
1151
|
+
// (`create-runtime.ts`) mounts roots SEQUENTIALLY, and a react world's
|
|
1152
|
+
// `adapter.mount()` (`resolveDefaultReactAdapter`/`mountOneReactWorld`)
|
|
1153
|
+
// renders its tree — synchronously in some React builds, but React 19's
|
|
1154
|
+
// concurrent renderer does NOT guarantee a synchronous first commit
|
|
1155
|
+
// (see `r3f-adapter.tsx`'s own doc comment on `onCreated`) — BEFORE the
|
|
1156
|
+
// caller calls `registerWorld` for that very world. A `useGameState`
|
|
1157
|
+
// selector reading `g.roots` can therefore render for the first time
|
|
1158
|
+
// while `roots` is still missing entries that register moments later.
|
|
1159
|
+
// Previously the ONLY way such a subscriber ever saw the corrected
|
|
1160
|
+
// value was the state bridge's next `bump()`, which fires exclusively
|
|
1161
|
+
// from `GameInternal.runFrame`'s tail — i.e. only once the host's loop
|
|
1162
|
+
// has actually ticked (`started = true; loop.start()` in
|
|
1163
|
+
// `create-runtime.ts`, itself gated on EVERY world finishing its
|
|
1164
|
+
// mount). On a slow/contended host (CI's 4-core SwiftShader runners)
|
|
1165
|
+
// that first tick can be delayed well past a test's assertion window,
|
|
1166
|
+
// or — if the tab is ever backgrounded — not fire at all for a long
|
|
1167
|
+
// stretch; the subscriber's cached snapshot then sits on its stale
|
|
1168
|
+
// (possibly fully empty) first render for that whole time, matching
|
|
1169
|
+
// the observed CI symptom exactly ("the HUD element IS mounted; the
|
|
1170
|
+
// roots list is EMPTY"). Proven red-then-green by
|
|
1171
|
+
// `packages/engine/test/state-bridge.test.ts`'s "registerWorld notifies
|
|
1172
|
+
// subscribers immediately" case: a subscriber registered before a
|
|
1173
|
+
// world, with `runFrame` NEVER called, only saw the update after this
|
|
1174
|
+
// fix. `bump()` also advances `frameVersion` (the only invalidation
|
|
1175
|
+
// key `createFrameSelectorCache`/`useGameState` understand — see
|
|
1176
|
+
// `frame-selector-cache.ts`), so this doubles as "frameVersion is
|
|
1177
|
+
// bumped once per completed runFrame OR once per world registered",
|
|
1178
|
+
// documented on `GameStateBridge.frameVersion` below.
|
|
1179
|
+
stateBridge.bump();
|
|
973
1180
|
// T7.4 slice 2 (REACT-STATE-BRIDGE.md §4): a NON-first-party mount
|
|
974
1181
|
// (an ingested/foreign world — first-party mounts are exempt, they're
|
|
975
1182
|
// observed via `Game.state`/`useGameState` instead) with no `observe`
|
|
@@ -999,5 +1206,47 @@ export function createGame(opts: { loop: GameLoop; assets: AssetCache }): GameIn
|
|
|
999
1206
|
runFrame(dt: number, frameOpts?: { ignorePause?: boolean }): void {
|
|
1000
1207
|
runFrameImpl(dt, frameOpts);
|
|
1001
1208
|
},
|
|
1209
|
+
runTicks(n: number, ticksOpts?: RunTicksOptions): void {
|
|
1210
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1211
|
+
throw new RangeError(`Game.runTicks: n must be a non-negative integer, got ${n}`);
|
|
1212
|
+
}
|
|
1213
|
+
// Refuse while paused (D15 §2.b): stepping the frozen set is
|
|
1214
|
+
// `Game.play.step()`'s contract, not this one's — see `runTicks`'s doc
|
|
1215
|
+
// comment on `GameInternal` above.
|
|
1216
|
+
if (paused) {
|
|
1217
|
+
throw new DebugError(
|
|
1218
|
+
'RUN_TICKS_PAUSED',
|
|
1219
|
+
'Game.runTicks: refused — Game.play.paused is true; stepping the frozen set is ' +
|
|
1220
|
+
"Game.play.step()'s contract, not runTicks' (docs/D15-DETERMINISM-DESIGN.md §2.b)",
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
const render = ticksOpts?.render ?? 'last';
|
|
1224
|
+
const fixedDt = opts.loop.fixedDt;
|
|
1225
|
+
for (let i = 0; i < n; i++) {
|
|
1226
|
+
const isFinalTick = i === n - 1;
|
|
1227
|
+
const skipRenderPhases = render === 'all' ? false : render === 'none' ? true : !isFinalTick;
|
|
1228
|
+
runFrameImpl(fixedDt, { skipRenderPhases });
|
|
1229
|
+
}
|
|
1230
|
+
},
|
|
1002
1231
|
};
|
|
1232
|
+
|
|
1233
|
+
// Filed AFTER the shell exists (the WeakMap keys on the Game object
|
|
1234
|
+
// itself) so `getDebugRegistry(game)` — the react hooks' and any later
|
|
1235
|
+
// consumer's reach-in — works from the moment `createGame` returns.
|
|
1236
|
+
registerDebugRegistry(gameInternal, debugRegistry);
|
|
1237
|
+
// D15/T-D15.4: wire the run-ticks target the instant the Game shell exists
|
|
1238
|
+
// (unlike `setVirtualInputTarget`, which waits for a per-world mount, a
|
|
1239
|
+
// Game's own `runTicks` needs nothing else) — this is what makes
|
|
1240
|
+
// `window.__vgai.runTicks` (`debug-bridge.ts`) and the editor relay's
|
|
1241
|
+
// `run-ticks` case reach the SAME implementation `game.runTicks` above is.
|
|
1242
|
+
debugRegistry.setRunTicksTarget({ runTicks: gameInternal.runTicks });
|
|
1243
|
+
// D15 (T-D15.1/.3) — same "file after the shell exists" ordering as the
|
|
1244
|
+
// debug registry above: `getSeededRandom(game)`/`getGameplayRngTrapControl
|
|
1245
|
+
// (game)` (per-world `ctx.random` wiring, and the manifest-aware boot
|
|
1246
|
+
// path's post-hoc `setEnabled` call) both work from the moment
|
|
1247
|
+
// `createGame` returns.
|
|
1248
|
+
registerSeededRandom(gameInternal, seededRandom);
|
|
1249
|
+
registerGameplayRngTrapControl(gameInternal, rngTrapControl);
|
|
1250
|
+
|
|
1251
|
+
return gameInternal;
|
|
1003
1252
|
}
|