@vgai/engine 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +18 -11
  2. package/package.json +10 -6
  3. package/schemas/engine-api.json +1 -68
  4. package/schemas/engine-api.md +1 -32
  5. package/schemas/engine-capabilities.json +30 -42
  6. package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
  7. package/src/adapter/adapter-surface.ts +5 -5
  8. package/src/adapter/authoring.ts +168 -226
  9. package/src/adapter/colyseus-networking-adapter.ts +44 -5
  10. package/src/adapter/first-party-systems.ts +156 -42
  11. package/src/adapter/host-context.ts +177 -46
  12. package/src/adapter/index.ts +47 -51
  13. package/src/adapter/ingest/game-contract.ts +2 -2
  14. package/src/adapter/ingest/scene-capture.ts +18 -19
  15. package/src/adapter/ingest/structural-ids.ts +127 -0
  16. package/src/adapter/ingest/upstream-pin.ts +9 -12
  17. package/src/adapter/loop-gate-report.ts +11 -11
  18. package/src/adapter/rapier-physics-adapter.ts +27 -9
  19. package/src/adapter/root-adapter.ts +217 -0
  20. package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
  21. package/src/adapter/system-adapter.ts +80 -63
  22. package/src/ai/navigation.ts +1 -1
  23. package/src/animation/animation-clock.ts +1 -1
  24. package/src/animation/camera-ownership.ts +1 -2
  25. package/src/animation/cubic-spline-interpolant.ts +132 -0
  26. package/src/animation/theatre-clock-binding.ts +2 -2
  27. package/src/animation/theatre-object-binding.ts +4 -4
  28. package/src/animation/xstate-animation-binding.ts +75 -5
  29. package/src/{scene/schema → asset-formats}/camera.ts +2 -5
  30. package/src/{scene/schema → asset-formats}/collider.ts +2 -5
  31. package/src/asset-formats/index.ts +54 -0
  32. package/src/{scene/schema → asset-formats}/instances.ts +10 -6
  33. package/src/{scene/schema → asset-formats}/light.ts +3 -6
  34. package/src/{scene/schema → asset-formats}/material.ts +4 -7
  35. package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
  36. package/src/asset-formats/parse.ts +39 -0
  37. package/src/{scene/schema → asset-formats}/particles.ts +3 -6
  38. package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
  39. package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
  40. package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
  41. package/src/asset-parse-error.ts +33 -0
  42. package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
  43. package/src/assets.ts +1 -1
  44. package/src/audio/wav-encode.ts +9 -9
  45. package/src/canvas-react/engine-bridge.ts +59 -0
  46. package/src/canvas-react/index.ts +50 -0
  47. package/src/canvas-react/pixi-primitive.tsx +202 -0
  48. package/src/canvas-react/pixi-react-adapter.tsx +290 -0
  49. package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
  50. package/src/canvas-react/world-context.ts +328 -0
  51. package/src/core/frame-pacing.ts +100 -0
  52. package/src/core/game-loop.ts +50 -28
  53. package/src/core/seeded-random.ts +7 -7
  54. package/src/core/sim-clock.ts +388 -0
  55. package/src/core/system-runner.ts +17 -63
  56. package/src/core/types.ts +34 -15
  57. package/src/data/data-asset.ts +3 -3
  58. package/src/data/data-check-core.ts +6 -7
  59. package/src/data/data-ref.ts +11 -11
  60. package/src/data/vite-plugin-data.ts +10 -10
  61. package/src/{scene/defaults.ts → defaults.ts} +18 -40
  62. package/src/dev/render-debug-adapter.ts +1 -1
  63. package/src/dev/webgl-frame-capture.ts +1 -1
  64. package/src/ecs/scene-index.ts +439 -0
  65. package/src/ecs/scene-query.ts +43 -0
  66. package/src/{scene → ecs}/user-data.ts +17 -36
  67. package/src/index.ts +7 -9
  68. package/src/input/input-manager.ts +29 -32
  69. package/src/input/input-types.ts +2 -2
  70. package/src/input/schema.ts +5 -5
  71. package/src/loader.ts +57 -0
  72. package/src/manifest/editor-port.ts +69 -0
  73. package/src/manifest/filename.ts +49 -0
  74. package/src/manifest/index.ts +8 -2
  75. package/src/manifest/load-file.ts +11 -0
  76. package/src/manifest/load.ts +65 -77
  77. package/src/manifest/locate.ts +55 -0
  78. package/src/manifest/schema.ts +400 -233
  79. package/src/{scene → physics}/collider-dimensions.ts +3 -3
  80. package/src/physics/physics-registry.ts +1 -1
  81. package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
  82. package/src/pixi/index.ts +43 -0
  83. package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
  84. package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
  85. package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
  86. package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
  87. package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
  88. package/src/react/unmanaged-root-detector.ts +26 -1
  89. package/src/react/use-data.ts +5 -5
  90. package/src/react/use-selection.tsx +15 -42
  91. package/src/react/{game-state.tsx → world-state.tsx} +44 -46
  92. package/src/render/auto-batcher.ts +1 -2
  93. package/src/{scene → render}/instance-mesh.ts +1 -1
  94. package/src/{scene → render}/light-camera-factory.ts +14 -13
  95. package/src/render/lod.ts +17 -0
  96. package/src/{scene → render}/material-factory.ts +8 -7
  97. package/src/{scene → render}/particles-factory.ts +62 -12
  98. package/src/render/render-batch-system.ts +14 -41
  99. package/src/render/render-features.ts +1 -1
  100. package/src/render/render-settings.ts +1 -2
  101. package/src/render/spark-renderer-lifecycle.ts +1 -1
  102. package/src/runtime/create-runtime.ts +352 -463
  103. package/src/runtime/debug-bridge.ts +148 -93
  104. package/src/runtime/debug-registry.ts +79 -54
  105. package/src/runtime/dev-layers.ts +40 -0
  106. package/src/runtime/frame-selector-cache.ts +4 -4
  107. package/src/runtime/game.ts +618 -369
  108. package/src/runtime/gameplay-rng-trap.ts +6 -7
  109. package/src/runtime/input-router.ts +11 -11
  110. package/src/runtime/mount-game.ts +54 -55
  111. package/src/runtime/mount-manifest.ts +154 -150
  112. package/src/runtime/presentation.ts +141 -0
  113. package/src/runtime/render-audio-control.ts +64 -53
  114. package/src/runtime/render-control.ts +45 -51
  115. package/src/runtime/render-seed.ts +3 -4
  116. package/src/runtime/state-bridge.ts +17 -18
  117. package/src/runtime/types.ts +94 -61
  118. package/src/setup/setup-renderer.ts +11 -6
  119. package/src/world3d-react/engine-bridge.ts +46 -33
  120. package/src/world3d-react/index.ts +31 -26
  121. package/src/world3d-react/r3f-adapter.tsx +211 -96
  122. package/src/world3d-react/r3f-root-factory.tsx +91 -0
  123. package/src/world3d-react/renderer-config.ts +137 -0
  124. package/src/world3d-react/world-context.ts +141 -111
  125. package/schemas/entity2d.schema.json +0 -468
  126. package/schemas/prefab.schema.json +0 -9992
  127. package/schemas/scn2d.schema.json +0 -494
  128. package/schemas/vscn.schema.json +0 -10851
  129. package/src/adapter/game-adapter.ts +0 -164
  130. package/src/adapter/ingest/overlay-applier.ts +0 -207
  131. package/src/adapter/ingest/overlay-apply.ts +0 -168
  132. package/src/adapter/ingest/overlay-file.ts +0 -126
  133. package/src/adapter/ingest/overlay-report.ts +0 -176
  134. package/src/animation/gsap-registration.ts +0 -184
  135. package/src/audio/audio-introspection.ts +0 -290
  136. package/src/audio/index.ts +0 -39
  137. package/src/audio/tone-clock-binding.ts +0 -98
  138. package/src/audio/tone-context.ts +0 -175
  139. package/src/audio/tone-offline-render.ts +0 -167
  140. package/src/ecs/component-manager.ts +0 -814
  141. package/src/ecs/game-component.ts +0 -260
  142. package/src/ecs/hmr-swap-report.ts +0 -65
  143. package/src/physics/trigger-dispatch.ts +0 -97
  144. package/src/react/root-adapter.tsx +0 -49
  145. package/src/scene/asset-paths.ts +0 -121
  146. package/src/scene/asset-ref-check.ts +0 -248
  147. package/src/scene/component-registry.ts +0 -51
  148. package/src/scene/parse.ts +0 -204
  149. package/src/scene/scene-apply.ts +0 -407
  150. package/src/scene/scene-diff-schema.ts +0 -115
  151. package/src/scene/scene-diff-types.ts +0 -29
  152. package/src/scene/scene-loader.ts +0 -1526
  153. package/src/scene/scene-query.ts +0 -63
  154. package/src/scene/scene-types.ts +0 -33
  155. package/src/scene/scene-version.ts +0 -40
  156. package/src/scene/schema/animation.ts +0 -46
  157. package/src/scene/schema/audio.ts +0 -25
  158. package/src/scene/schema/entity-ref.ts +0 -78
  159. package/src/scene/schema/entity.ts +0 -189
  160. package/src/scene/schema/index.ts +0 -51
  161. package/src/scene/schema/joint.ts +0 -26
  162. package/src/scene/schema/physics.ts +0 -49
  163. package/src/scene/schema/scene-file.ts +0 -292
  164. package/src/scene/schema/shadow.ts +0 -24
  165. package/src/scene/schema/spline.ts +0 -21
  166. package/src/world2d/asset-paths2d.ts +0 -44
  167. package/src/world2d/capture-to-scene2d.ts +0 -52
  168. package/src/world2d/collision-2d.ts +0 -99
  169. package/src/world2d/entity2d-asset.ts +0 -22
  170. package/src/world2d/index.ts +0 -91
  171. package/src/world2d/physics2d-transform.ts +0 -173
  172. package/src/world2d/physics2d-units.ts +0 -10
  173. package/src/world2d/pixi-game-adapter.ts +0 -439
  174. package/src/world2d/pixi-surface.ts +0 -78
  175. package/src/world2d/scene2d-identity.ts +0 -49
  176. package/src/world2d/scene2d-loader.ts +0 -433
  177. package/src/world2d/schema/entity2d.ts +0 -163
  178. package/src/world2d/schema/physics2d.ts +0 -64
  179. package/src/world2d/schema/sprite.ts +0 -99
  180. package/src/world2d/schema/tilemap.ts +0 -39
  181. package/src/world2d/schema/tuples2d.ts +0 -25
  182. package/src/world2d/transform-writer-2d.ts +0 -42
  183. package/src/world2d/types.ts +0 -74
  184. package/src/world3d-react/behavior.tsx +0 -146
  185. /package/src/{scene → render}/mesh-shadow.ts +0 -0
@@ -0,0 +1,328 @@
1
+ /**
2
+ * `createCanvasRootContext` — the ENGINE runtime a canvas world owns, split out
3
+ * of `./pixi-react-adapter.tsx` so it stays react-free and headlessly
4
+ * unit-testable (`test/canvas-react-context-parity.test.tsx`).
5
+ *
6
+ * This is the canvas surface's answer to the same question
7
+ * `world3d-react/world-context.ts` answers for three: what does a component
8
+ * inside the world tree reach when it asks the engine for something?
9
+ *
10
+ * 1. **`ctx.debug` / `ctx.random` / `ctx.game` / `ctx.roots` are wired** from
11
+ * the ONE game-scoped registry (`getDebugRegistry(host.game)`), so a
12
+ * component's effect can `ctx.debug.registerStateProvider(...)` and be
13
+ * visible to `vgai eval`/the editor's debug panels.
14
+ * 2. **`ctx.input` is the game-owned `InputManager`** (`host.game.input` — the
15
+ * same instance every sibling root shares), and this module registers the
16
+ * per-world debug-registry seams (`setVirtualInputTarget`/
17
+ * `setInputActionsSource`/`setInputTraceSource`) that make `game.input.*`
18
+ * (the bot-input doctrine) resolve instead of throwing
19
+ * `DEBUG_INPUT_UNAVAILABLE`. The project's input map loads through
20
+ * `Game.loadInputMap` (the game-owned, load-once path every root shares).
21
+ * A missing/unparseable map degrades LOUDLY without failing the mount — a
22
+ * canvas world with no declared actions is legal.
23
+ * 3. **Absent subsystems fail loudly at the ctx surface.** A canvas world
24
+ * builds no THREE scene/camera, no Rapier world, no composer, no first-party
25
+ * audio/particles/debug-draw, and is handed no `AssetCache` (the canvas host
26
+ * context carries none — see `adapter/host-context.ts`). Reading those
27
+ * `ctx` fields yields a throwing getter naming the limitation and the
28
+ * sanctioned alternative, never a silent `undefined`.
29
+ *
30
+ * The Pixi handles themselves are deliberately NOT re-exposed here: a component
31
+ * reaches the live `Application` (and therefore `app.stage`, `app.renderer`,
32
+ * `app.ticker`) through `@pixi/react`'s own `useApplication()`, and its assets
33
+ * through Pixi's own `Assets`. Re-publishing them on `ctx` would be a wrapper
34
+ * standing between the author and the library.
35
+ */
36
+
37
+ import type { Application } from 'pixi.js';
38
+ import type { CanvasHostContext } from '../adapter';
39
+ import type { SystemAdapters } from '../adapter/system-adapter';
40
+ import {
41
+ createSeededRandom,
42
+ DEFAULT_SEEDED_RANDOM_SEED,
43
+ getSeededRandom,
44
+ } from '../core/seeded-random';
45
+ import { createSimClock, getSimClock, type SimClockInternal } from '../core/sim-clock';
46
+ import { createSystemRunner } from '../core/system-runner';
47
+ import {
48
+ createDebugRegistry,
49
+ type DebugRegistry,
50
+ getDebugRegistry,
51
+ } from '../runtime/debug-registry';
52
+ import type { GameContext } from '../runtime/types';
53
+
54
+ /** The conventional input-map location every scaffolded project ships. Shared
55
+ * verbatim with the three lane — a project has ONE input map, whatever surface
56
+ * reads it. */
57
+ export const DEFAULT_INPUT_MAP_PATH = '/inputmaps/default.inputmap.json';
58
+
59
+ export interface CanvasRootContextOptions {
60
+ /** World id — provenance for every debug-registry registration (matches the
61
+ * manifest root id). */
62
+ readonly id: string;
63
+ /**
64
+ * Input map to load through `Game.loadInputMap` (game-owned, load-once —
65
+ * several roots asking for the SAME path share one load; competing paths
66
+ * throw there). Defaults to {@link DEFAULT_INPUT_MAP_PATH}. Pass `null` to
67
+ * skip loading (a world with no actions). Ignored when no `host.game` is
68
+ * present.
69
+ */
70
+ readonly inputMapPath?: string | null | undefined;
71
+ }
72
+
73
+ /** What {@link createCanvasRootContext} returns — the engine runtime one canvas
74
+ * world owns, plus the wiring hooks the adapter's `mount()` drives. */
75
+ export interface CanvasRootRuntime {
76
+ /** The (partial, loud-on-absence) `GameContext` this world's components read
77
+ * through `useGameContext`. See this module's header for what is present. */
78
+ readonly ctx: GameContext;
79
+ /** The world-local phase runner `mount().update(dt)` ticks. */
80
+ readonly systems: ReturnType<typeof createSystemRunner>;
81
+ /** The mounted world's `SystemAdapters` bag (`mounted.systems`) —
82
+ * `ctx.registerSystemAdapter` writes into it; `debug` is pre-seeded. */
83
+ readonly systemAdapters: SystemAdapters;
84
+ /** Resolves once the input map load settles (immediately when skipped).
85
+ * Never rejects — a failed load reports loudly and resolves. */
86
+ readonly inputMapReady: Promise<void>;
87
+ /** Backfill the live `Application` once `@pixi/react` has initialized it —
88
+ * before any tick can run. Read back via {@link CanvasRootRuntime.app}. */
89
+ setApplication(app: Application): void;
90
+ /** The live `Application`, or `null` before the first commit resolves it. */
91
+ readonly app: Application | null;
92
+ /** Strip this world's debug registrations (scoped, so sibling roots and
93
+ * react-door registrations are untouched when a Game hosts this world) and
94
+ * dispose a clock this mount created. */
95
+ dispose(): void;
96
+ }
97
+
98
+ /** Install a throwing getter for a `GameContext` field this world genuinely
99
+ * does not build — a component reaching for it fails LOUDLY at its own call
100
+ * site, naming the limitation, instead of reading `undefined` and breaking
101
+ * somewhere downstream. Non-enumerable so spreads/serialization of the ctx
102
+ * never trip it. */
103
+ function defineAbsentCtxField(
104
+ target: Record<string, unknown>,
105
+ worldId: string,
106
+ key: string,
107
+ why: string,
108
+ ): void {
109
+ Object.defineProperty(target, key, {
110
+ configurable: true,
111
+ enumerable: false,
112
+ get(): never {
113
+ throw new Error(
114
+ `canvas world "${worldId}": ctx.${key} is not available — ${why} ` +
115
+ '(A canvas world ctx provides: input, systems, clock, debug, random, game, roots, ' +
116
+ "registerSystemAdapter. The Pixi runtime itself comes from @pixi/react's " +
117
+ 'useApplication() — app.stage, app.renderer, app.ticker — and assets from Pixi`s own ' +
118
+ 'Assets.)',
119
+ );
120
+ },
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Build the engine runtime for one canvas world from a `CanvasHostContext`.
126
+ * React-free on purpose: everything here is plain engine wiring the adapter
127
+ * threads into the `@pixi/react` tree through `EngineBridge`.
128
+ */
129
+ export function createCanvasRootContext(
130
+ host: CanvasHostContext,
131
+ options: CanvasRootContextOptions,
132
+ ): CanvasRootRuntime {
133
+ const { id, inputMapPath = DEFAULT_INPUT_MAP_PATH } = options;
134
+
135
+ // Same observer threading as the three lane — this world's phases show up
136
+ // under the shared game profiler when hosted.
137
+ const systems = createSystemRunner(host.game?.profiler.systemObserver, 'canvas');
138
+
139
+ // The mounted world's adapter surface (`mounted.systems`) — declared before
140
+ // ctx so `ctx.registerSystemAdapter` can close over it (a game-owned
141
+ // capability like networking registers here from component code).
142
+ const systemAdapters: SystemAdapters = {};
143
+
144
+ let app: Application | null = null;
145
+
146
+ const ctxRaw: Record<string, unknown> = {
147
+ systems,
148
+ registerSystemAdapter: (kind: keyof SystemAdapters, adapter: SystemAdapters[typeof kind]) => {
149
+ // biome-ignore lint/suspicious/noExplicitAny: same per-key record write the three lane performs; correct by construction
150
+ (systemAdapters as any)[kind] = adapter;
151
+ host.game?.notifySystemAdaptersChanged();
152
+ },
153
+ };
154
+ const ctx = ctxRaw as unknown as GameContext;
155
+ if (host.game) {
156
+ ctx.game = host.game;
157
+ ctx.roots = host.game.roots;
158
+ ctx.playtest = host.game.playtest ?? null;
159
+ }
160
+
161
+ // --- ctx.debug — the ONE registry per Game root, shared across every world
162
+ // mounted onto it; a bare mount with no Game shell gets a private,
163
+ // mount-local registry instead. ---
164
+ const debugRegistry: DebugRegistry =
165
+ (host.game ? getDebugRegistry(host.game) : null) ??
166
+ createDebugRegistry({ getTick: () => 0, getSimT: () => 0 });
167
+ systemAdapters.debug = debugRegistry.adapter;
168
+ ctx.debug = debugRegistry.forRoot(id);
169
+ ctx.random =
170
+ (host.game ? getSeededRandom(host.game) : null) ??
171
+ createSeededRandom(DEFAULT_SEEDED_RANDOM_SEED);
172
+
173
+ // --- ctx.clock — a REAL value, never a throwing getter: every world has a
174
+ // sim clock. A bare mount with no Game shell gets a private, mount-local one.
175
+ // `SimClock.disposeAfter` takes a THREE.Object3D, so the mount-local
176
+ // fallback's disposer is a loud refusal rather than a fabricated no-op: a
177
+ // canvas world's debris is a `PIXI.Container`, destroyed with its own
178
+ // `container.destroy()`.
179
+ const gameClock: SimClockInternal | null = host.game ? getSimClock(host.game) : null;
180
+ const clock: SimClockInternal =
181
+ gameClock ??
182
+ createSimClock({
183
+ dispose: () => {
184
+ throw new Error(
185
+ `canvas world "${id}": clock.disposeAfter() disposes a THREE.Object3D and has no ` +
186
+ 'meaning in a canvas world — destroy a display object with its own ' +
187
+ '`container.destroy({ children: true })`, scheduled with `clock.after(seconds, ...)`.',
188
+ );
189
+ },
190
+ });
191
+ ctx.clock = clock;
192
+
193
+ // --- ctx.input + the debug-registry input seams — the game-owned
194
+ // InputManager, polled by the game-scoped runner each frame; this world
195
+ // registers the per-world seams so `game.input.*` (virtual input) and the
196
+ // built-in `input.actions`/`input.trace` providers resolve to it. ---
197
+ let inputMapReady: Promise<void> = Promise.resolve();
198
+ if (host.game) {
199
+ const game = host.game;
200
+ const input = game.input;
201
+ ctxRaw['input'] = input;
202
+ debugRegistry.setInputActionsSource(id, () =>
203
+ input.actionNames().map((name) => ({ name, valueType: input.getActionValueType(name) })),
204
+ );
205
+ debugRegistry.setInputTraceSource(id, () => {
206
+ const raw = input.getInputTrace();
207
+ return {
208
+ version: raw.version,
209
+ seed: getSeededRandom(game)?.seed ?? null,
210
+ fixedDt: game.loop.fixedDt,
211
+ ticks: raw.ticks,
212
+ };
213
+ });
214
+ debugRegistry.setVirtualInputTarget(id, {
215
+ setVirtualAction: (action, value) => input.setVirtualAction(action, value),
216
+ tapVirtualAction: (action) => input.tapVirtualAction(action),
217
+ clearVirtualActions: () => input.clearVirtualActions(),
218
+ scheduleActionAtTick: (tick, action, value) =>
219
+ input.scheduleActionAtTick(tick, action, value),
220
+ startInputRecording: () => input.startInputRecording(),
221
+ stopInputRecording: () => input.stopInputRecording(),
222
+ isInputRecording: () => input.isInputRecording(),
223
+ injectAxis: (sourceId, value) => input.injectAxis(sourceId, value),
224
+ injectVector2: (sourceId, value) => input.injectVector2(sourceId, value),
225
+ injectPointerDelta: (sourceId, delta) => input.injectPointerDelta(sourceId, delta),
226
+ injectPointerPosition: (sourceId, value) => input.injectPointerPosition(sourceId, value),
227
+ });
228
+ if (!host.headless && inputMapPath !== null) {
229
+ // Load-once through the game-owned path (competing paths across roots
230
+ // throw THERE, loudly). A FAILED load must not fail this mount: a canvas
231
+ // world with no declared actions is legal. It degrades loudly instead.
232
+ inputMapReady = game.loadInputMap(inputMapPath).catch((err: unknown) => {
233
+ // biome-ignore lint/suspicious/noConsole: deliberate loud degrade — the documented alternative to failing the mount
234
+ console.error(
235
+ `canvas world "${id}": failed to load input map "${inputMapPath}" — declared input ` +
236
+ 'actions and `game.input.*` (bot/virtual input) will not work until a valid map ' +
237
+ 'loads. Ship one at the conventional path or pass `inputMapPath` to ' +
238
+ `createPixiReactAdapter (null to opt out). Cause: ${err instanceof Error ? err.message : String(err)}`,
239
+ );
240
+ });
241
+ }
242
+ } else {
243
+ defineAbsentCtxField(
244
+ ctxRaw,
245
+ id,
246
+ 'input',
247
+ 'input is game-owned and this mount has no Game host (host.game is absent — bare ' +
248
+ 'harness/foreign host); mount through the vgai host to get the shared InputManager.',
249
+ );
250
+ }
251
+
252
+ // --- Loud absence for everything a canvas world genuinely does not build. ---
253
+ const threeWhy =
254
+ 'a canvas world renders through PixiJS and owns no THREE scene graph; the world root is ' +
255
+ "the Pixi stage, reached with @pixi/react's useApplication().app.stage.";
256
+ defineAbsentCtxField(ctxRaw, id, 'scene', threeWhy);
257
+ defineAbsentCtxField(ctxRaw, id, 'camera', threeWhy);
258
+ defineAbsentCtxField(
259
+ ctxRaw,
260
+ id,
261
+ 'sceneIndex',
262
+ 'the live scene index walks a THREE.Scene; a canvas world has none (walk the Pixi display ' +
263
+ 'tree from useApplication().app.stage instead).',
264
+ );
265
+ const rapierWhy =
266
+ 'canvas worlds do not build the first-party Rapier runtime; drive physics from inside the ' +
267
+ 'tree with the 2D physics library of your choice.';
268
+ defineAbsentCtxField(ctxRaw, id, 'rapierWorld', rapierWhy);
269
+ defineAbsentCtxField(ctxRaw, id, 'rapier', rapierWhy);
270
+ defineAbsentCtxField(ctxRaw, id, 'physics', rapierWhy);
271
+ defineAbsentCtxField(ctxRaw, id, 'collisions', rapierWhy);
272
+ defineAbsentCtxField(
273
+ ctxRaw,
274
+ id,
275
+ 'assets',
276
+ 'the canvas host context carries no AssetCache (that cache is a GLTF/texture loader ' +
277
+ "resolving THREE objects); load textures/spritesheets with Pixi's own `Assets`.",
278
+ );
279
+ defineAbsentCtxField(
280
+ ctxRaw,
281
+ id,
282
+ 'composer',
283
+ 'a canvas world has no postprocessing EffectComposer; use Pixi filters on a Container.',
284
+ );
285
+ defineAbsentCtxField(
286
+ ctxRaw,
287
+ id,
288
+ 'audio',
289
+ 'canvas worlds build no first-party audio context; use Web Audio directly (or @pixi/sound).',
290
+ );
291
+ defineAbsentCtxField(
292
+ ctxRaw,
293
+ id,
294
+ 'particles',
295
+ 'canvas worlds build no three.quarks particle runtime; use a Pixi particle container.',
296
+ );
297
+ defineAbsentCtxField(
298
+ ctxRaw,
299
+ id,
300
+ 'debugDraw',
301
+ 'canvas worlds build no debug-draw helper; draw into a Pixi `Graphics` in the tree.',
302
+ );
303
+
304
+ return {
305
+ ctx,
306
+ systems,
307
+ systemAdapters,
308
+ inputMapReady,
309
+ get app(): Application | null {
310
+ return app;
311
+ },
312
+ setApplication(next): void {
313
+ app = next;
314
+ },
315
+ dispose(): void {
316
+ // Same ownership rule the three lane follows: dispose ONLY a clock this
317
+ // mount created. The game-scoped one belongs to `GameInternal.dispose()`
318
+ // — killing it here would freeze sim time and cancel every SIBLING
319
+ // world's timers.
320
+ if (!gameClock) clock.dispose();
321
+ app = null;
322
+ // Scoped strip when a Game hosts this world (sibling roots stay live);
323
+ // a bare standalone mount owns its private registry and clears it all.
324
+ if (host.game) debugRegistry.strip(id);
325
+ else debugRegistry.strip();
326
+ },
327
+ };
328
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Frame pacing — the accumulator/alpha arithmetic of the fixed-timestep loop,
3
+ * extracted PURE (WO-P13, `docs/WORK.md` §2 P13).
4
+ *
5
+ * `core/game-loop.ts` owns the browser side (rAF, `visibilitychange`,
6
+ * `performance.now`); this module owns the arithmetic, so the interesting
7
+ * cases — a display frame that consumes zero substeps, a frame that hits the
8
+ * substep ceiling, the spiral-of-death clamp, alpha's monotonic march across a
9
+ * substep gap — are testable without stubbing a single global.
10
+ *
11
+ * The substep half is byte-for-byte the pacing the loop has always used
12
+ * (clamp the raw frame gap, scale by `timeScale`, clamp the accumulator, then
13
+ * consume up to `maxSubSteps` whole steps). The addition is `alpha`: the
14
+ * fraction of a fixed step the accumulator is holding AFTER those substeps
15
+ * were consumed, which is what lets a renderer present between two fixed
16
+ * states instead of only at them.
17
+ */
18
+
19
+ /** The two constants that define a loop's fixed cadence. */
20
+ export interface FramePacingLimits {
21
+ /** Seconds per fixed substep (the loop's `fixedTimestep`). */
22
+ readonly fixedDt: number;
23
+ /** Substep ceiling per display frame — also the accumulator's hard ceiling. */
24
+ readonly maxSubSteps: number;
25
+ }
26
+
27
+ /** What one display frame's worth of wall time resolves to. */
28
+ export interface PacedFrame {
29
+ /** Whole fixed substeps this display frame should consume (0..maxSubSteps). */
30
+ readonly steps: number;
31
+ /** The accumulator AFTER those substeps were consumed. */
32
+ readonly accumulator: number;
33
+ /**
34
+ * Interpolation alpha in `[0, 1]` — `accumulator / fixedDt`, i.e. how far
35
+ * the presentation clock sits past the last completed fixed state. `0` means
36
+ * "exactly on the last fixed state"; `0.5` means "halfway to the next one".
37
+ */
38
+ readonly alpha: number;
39
+ /**
40
+ * The display frame's own delta in seconds, clamped and `timeScale`d exactly
41
+ * like the time fed to the accumulator. This is the delta a per-display-frame
42
+ * consumer (post-processing, particles, a `RenderStepped`-shaped callback)
43
+ * should integrate against — NOT `fixedDt`, which is a sim quantity.
44
+ */
45
+ readonly displayDt: number;
46
+ }
47
+
48
+ /**
49
+ * `accumulator / fixedDt`, clamped to `[0, 1]`.
50
+ *
51
+ * Clamped rather than asserted because the accumulator can legitimately still
52
+ * hold a whole step when a frame hits the `maxSubSteps` ceiling; presenting
53
+ * "all the way at the next fixed state" is the only honest answer there, and a
54
+ * NaN/negative/zero-`fixedDt` degenerate resolves to `0` (present the last
55
+ * fixed state) rather than poisoning every transform downstream.
56
+ */
57
+ export function interpolationAlpha(accumulator: number, fixedDt: number): number {
58
+ if (!Number.isFinite(accumulator) || !Number.isFinite(fixedDt) || fixedDt <= 0) return 0;
59
+ if (accumulator <= 0) return 0;
60
+ const alpha = accumulator / fixedDt;
61
+ return alpha >= 1 ? 1 : alpha;
62
+ }
63
+
64
+ /**
65
+ * Resolve one display frame: how many fixed substeps it consumes, what the
66
+ * accumulator holds afterwards, and the alpha/displayDt the render pass should
67
+ * present with.
68
+ *
69
+ * @param accumulator unconsumed sim time carried in from the previous frame
70
+ * @param rawDt wall-clock seconds since the previous frame
71
+ * @param timeScale the loop's live time scale (already clamped to [0, 8] by
72
+ * its setter — this function does not re-clamp it)
73
+ */
74
+ export function paceFrame(
75
+ accumulator: number,
76
+ rawDt: number,
77
+ timeScale: number,
78
+ limits: FramePacingLimits,
79
+ ): PacedFrame {
80
+ const { fixedDt, maxSubSteps } = limits;
81
+ // Also the accumulator's hard ceiling (see the spiral-of-death clamp below).
82
+ const maxAccumulator = fixedDt * maxSubSteps;
83
+
84
+ // Clamp large frame gaps (a slow frame, a debugger pause) BEFORE scaling by
85
+ // timeScale — unchanged from the pre-WO-P13 loop.
86
+ const displayDt = Math.min(rawDt, maxAccumulator) * timeScale;
87
+ let next = accumulator + displayDt;
88
+ // Spiral-of-death guard: bound the accumulator regardless of timeScale or
89
+ // frame-gap size. Time that can't possibly be caught up on is dropped, never
90
+ // carried forward.
91
+ if (next > maxAccumulator) next = maxAccumulator;
92
+
93
+ let steps = 0;
94
+ while (next >= fixedDt && steps < maxSubSteps) {
95
+ next -= fixedDt;
96
+ steps++;
97
+ }
98
+
99
+ return { steps, accumulator: next, alpha: interpolationAlpha(next, fixedDt), displayDt };
100
+ }
@@ -1,23 +1,44 @@
1
+ import { paceFrame } from './frame-pacing';
1
2
  import type { GameLoopConfig, GameLoopLiveness } from './types';
2
3
 
3
4
  /**
4
- * Fixed-timestep game loop with accumulator pattern.
5
+ * Fixed-timestep game loop with accumulator pattern — SIM at a fixed rate,
6
+ * PRESENTATION at the display's rate (WO-P13, `docs/WORK.md` §2 P13, which
7
+ * flipped the former D1 "render is fixed-rate too" decision this comment used
8
+ * to state).
5
9
  *
6
- * Physics/logic run at a fixed rate (default 60 Hz) regardless of display
7
- * frame rate. Rendering is deliberately fixed-rate too (decided — D1, see
8
- * `docs/DECISIONS-PENDING.md`): `config.update(fixedDt)` runs once per
9
- * consumed substep, and per the documented phase order (`PHASE_ORDER` in
10
- * `core/types.ts`) the `render` phase executes as the last phase of that
11
- * same call there is no separate per-real-frame render step and no
12
- * display-refresh interpolation. A frame whose accumulator produces zero
13
- * substeps therefore calls `update` zero times and renders zero times; do
14
- * not reintroduce a render call outside the substep loop below.
10
+ * Per real (rAF) frame:
11
+ *
12
+ * 1. the accumulator absorbs the frame gap (clamped, `timeScale`d) and
13
+ * `config.update(fixedDt)` runs once per WHOLE fixed substep it can
14
+ * consume, up to `maxSubSteps` unchanged, and still the only thing that
15
+ * advances gameplay, physics, `tick`/`simT` and the sim clock;
16
+ * 2. `config.render(alpha, displayDt)` runs EXACTLY ONCE, whatever step 1
17
+ * did including zero times. `alpha` is `accumulator / fixedDt` after
18
+ * step 1 (see `core/frame-pacing.ts`): how far presentation sits past the
19
+ * last completed fixed state, in `[0, 1]`.
20
+ *
21
+ * So a 120 Hz display draws 120 frames a second off a 60 Hz simulation, and a
22
+ * frame that consumes zero substeps still presents — which is the whole point:
23
+ * before this, motion presented at SIM rate, so half of every 120 Hz display's
24
+ * frames were duplicates and there was nowhere at all to host
25
+ * `RenderStepped`-shaped per-display-frame code (camera polish, procedural
26
+ * sway). `Game.onRenderStep` (`runtime/game.ts`) is that host, driven from
27
+ * this callback.
28
+ *
29
+ * `config.render` is OPTIONAL, and the hosts that don't pass it keep the old
30
+ * shape exactly: the `render` phase then still runs inside `config.update` as
31
+ * the last phase of `PHASE_ORDER` (`runtime/game.ts` only skips the render
32
+ * phases when its caller asks it to). That is deliberate, because two paths
33
+ * must stay frame-exact and neither goes through the rAF arm at all:
34
+ * `externalDrive` capture/offline export (see `GameLoopConfig.externalDrive`)
35
+ * and `Game.runTicks`, which drives `runFrame` directly.
15
36
  */
16
37
  export function createGameLoop(config: GameLoopConfig) {
17
38
  const fixedDt = config.fixedTimestep ?? 1 / 60;
39
+ // Also the accumulator's hard ceiling — `paceFrame` derives
40
+ // `fixedDt * maxSubSteps` from these two and clamps against it.
18
41
  const maxSubSteps = config.maxSubSteps ?? 8;
19
- // Also the accumulator's hard ceiling (see the spiral-of-death guard below).
20
- const maxAccumulator = fixedDt * maxSubSteps;
21
42
  // I2 (render-control runtime mode, see `GameLoopConfig.externalDrive`'s
22
43
  // doc comment): in this mode `start()` below deliberately skips both the
23
44
  // `requestAnimationFrame` arm AND the `visibilitychange` listener install —
@@ -63,22 +84,23 @@ export function createGameLoop(config: GameLoopConfig) {
63
84
  const rawDt = (currentTime - lastTime) / 1000;
64
85
  lastTime = currentTime;
65
86
 
66
- // Clamp large frame gaps (e.g. a slow frame) before scaling by timeScale.
67
- const dt = Math.min(rawDt, maxAccumulator) * timeScale;
68
- accumulator += dt;
69
- // Spiral-of-death guard: bound the accumulator regardless of timeScale
70
- // (up to the clamped max of 8) or frame-gap size. Time that can't
71
- // possibly be caught up on is dropped, never carried forward.
72
- if (accumulator > maxAccumulator) accumulator = maxAccumulator;
73
-
74
- // Run fixed timestep updates (this is also where rendering happens —
75
- // see the module doc comment above).
76
- let steps = 0;
77
- while (accumulator >= fixedDt && steps < maxSubSteps) {
78
- config.update(fixedDt);
79
- accumulator -= fixedDt;
80
- steps++;
81
- }
87
+ // All of the pacing arithmetic frame-gap clamp, timeScale, the
88
+ // spiral-of-death accumulator clamp, the substep count and the
89
+ // interpolation alpha — lives in `core/frame-pacing.ts`, pure and unit
90
+ // tested. The accumulator is written back BEFORE the substeps run so a
91
+ // throwing `update` cannot make the loop re-consume time it already
92
+ // charged for.
93
+ const paced = paceFrame(accumulator, rawDt, timeScale, { fixedDt, maxSubSteps });
94
+ accumulator = paced.accumulator;
95
+
96
+ // Fixed-rate: gameplay/physics, once per consumed substep. Zero times on
97
+ // a frame that could not fill one.
98
+ for (let i = 0; i < paced.steps; i++) config.update(fixedDt);
99
+
100
+ // Display-rate: exactly once per real frame, whatever the substep count
101
+ // was — see the module doc comment. Optional; a host that never passes
102
+ // `render` keeps rendering inside `update`'s phase list.
103
+ config.render?.(paced.alpha, paced.displayDt);
82
104
  }
83
105
 
84
106
  return {
@@ -1,11 +1,11 @@
1
1
  /**
2
- * D15 — the seeded-random core (`docs/D15-DETERMINISM-DESIGN.md` §2.a,
3
- * T-D15.1). `ctx.random` is a game-scoped PRNG with NAMED STREAMS: calling
4
- * the object itself (`ctx.random()`) draws from the `'gameplay'` stream;
5
- * `ctx.random.stream('vfx')` (or any other name) derives an INDEPENDENT
6
- * generator, so a cosmetic/VFX draw can never perturb the gameplay draw
7
- * order the classic replay-drift trap option B ("one game-scoped PRNG
8
- * only") would fall into (see the design doc's §2.a option table).
2
+ * The seeded-random core (T-D15.1). `ctx.random` is a game-scoped PRNG with
3
+ * NAMED STREAMS: calling the object itself (`ctx.random()`) draws from the
4
+ * `'gameplay'` stream; `ctx.random.stream('vfx')` (or any other name) derives
5
+ * an INDEPENDENT generator, so a cosmetic/VFX draw can never perturb the
6
+ * gameplay draw order the classic replay-drift trap option B ("one
7
+ * game-scoped PRNG only") would fall into (see the design doc's §2.a option
8
+ * table).
9
9
  *
10
10
  * Each stream is seeded from `fnv1a(name) ^ rootSeed` — deterministic given
11
11
  * the root seed, independent of draw order across streams (drawing from