@vgai/engine 0.5.21 → 0.5.22

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 (87) hide show
  1. package/dist/adapter/manifest-interpreter.d.ts +59 -0
  2. package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
  3. package/dist/adapter/manifest-interpreter.js +50 -0
  4. package/dist/adapter/native-debug-module.d.ts +45 -3
  5. package/dist/adapter/native-debug-module.d.ts.map +1 -1
  6. package/dist/adapter/native-debug-module.js +128 -10
  7. package/dist/adapter/renderer-config.d.ts +1 -1
  8. package/dist/adapter/renderer-config.js +1 -1
  9. package/dist/canvas-react/index.d.ts +5 -12
  10. package/dist/canvas-react/index.d.ts.map +1 -1
  11. package/dist/canvas-react/index.js +5 -12
  12. package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
  13. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  14. package/dist/canvas-react/pixi-react-root-factory.js +274 -36
  15. package/dist/core/countdown-timer.d.ts +118 -0
  16. package/dist/core/countdown-timer.d.ts.map +1 -0
  17. package/dist/core/countdown-timer.js +137 -0
  18. package/dist/core/deferred-commands.d.ts +127 -0
  19. package/dist/core/deferred-commands.d.ts.map +1 -0
  20. package/dist/core/deferred-commands.js +132 -0
  21. package/dist/core/sim-clock.d.ts +14 -2
  22. package/dist/core/sim-clock.d.ts.map +1 -1
  23. package/dist/core/sim-clock.js +14 -2
  24. package/dist/dev/instruments.d.ts +38 -11
  25. package/dist/dev/instruments.d.ts.map +1 -1
  26. package/dist/dev/instruments.js +81 -11
  27. package/dist/input/input-manager.d.ts +15 -0
  28. package/dist/input/input-manager.d.ts.map +1 -1
  29. package/dist/input/input-manager.js +15 -0
  30. package/dist/render/directional-shadow-fit.d.ts +88 -0
  31. package/dist/render/directional-shadow-fit.d.ts.map +1 -0
  32. package/dist/render/directional-shadow-fit.js +133 -0
  33. package/dist/render/environment-capture.d.ts +68 -0
  34. package/dist/render/environment-capture.d.ts.map +1 -0
  35. package/dist/render/environment-capture.js +90 -0
  36. package/dist/render/ibl-override-material.d.ts +97 -0
  37. package/dist/render/ibl-override-material.d.ts.map +1 -0
  38. package/dist/render/ibl-override-material.js +114 -0
  39. package/dist/runtime/game-input-seams.d.ts +51 -0
  40. package/dist/runtime/game-input-seams.d.ts.map +1 -0
  41. package/dist/runtime/game-input-seams.js +86 -0
  42. package/dist/runtime/game.d.ts +15 -0
  43. package/dist/runtime/game.d.ts.map +1 -1
  44. package/dist/runtime/game.js +40 -8
  45. package/dist/runtime/mount-game.d.ts.map +1 -1
  46. package/dist/runtime/mount-game.js +18 -2
  47. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  48. package/dist/runtime/mount-manifest.js +4 -2
  49. package/dist/runtime/types.d.ts +1 -1
  50. package/dist/world3d-react/index.d.ts +15 -25
  51. package/dist/world3d-react/index.d.ts.map +1 -1
  52. package/dist/world3d-react/index.js +15 -25
  53. package/dist/world3d-react/r3f-root-factory.d.ts +19 -25
  54. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  55. package/dist/world3d-react/r3f-root-factory.js +396 -50
  56. package/dist/world3d-react/rapier-physics-bridge.d.ts +24 -12
  57. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  58. package/dist/world3d-react/rapier-physics-bridge.js +82 -49
  59. package/package.json +1 -1
  60. package/schemas/engine-capabilities.json +3 -3
  61. package/src/adapter/manifest-interpreter.ts +64 -0
  62. package/src/adapter/native-debug-module.ts +212 -15
  63. package/src/adapter/renderer-config.ts +1 -1
  64. package/src/canvas-react/index.ts +5 -21
  65. package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
  66. package/src/core/countdown-timer.ts +188 -0
  67. package/src/core/deferred-commands.ts +174 -0
  68. package/src/core/sim-clock.ts +14 -2
  69. package/src/dev/instruments.ts +108 -11
  70. package/src/input/input-manager.ts +15 -0
  71. package/src/render/directional-shadow-fit.ts +156 -0
  72. package/src/render/environment-capture.ts +102 -0
  73. package/src/render/ibl-override-material.ts +170 -0
  74. package/src/runtime/game-input-seams.ts +108 -0
  75. package/src/runtime/game.ts +65 -7
  76. package/src/runtime/mount-game.ts +23 -1
  77. package/src/runtime/mount-manifest.ts +4 -2
  78. package/src/runtime/types.ts +1 -1
  79. package/src/world3d-react/index.ts +15 -31
  80. package/src/world3d-react/r3f-root-factory.tsx +444 -53
  81. package/src/world3d-react/rapier-physics-bridge.tsx +88 -49
  82. package/src/canvas-react/engine-bridge.ts +0 -59
  83. package/src/canvas-react/pixi-react-adapter.tsx +0 -356
  84. package/src/canvas-react/world-context.ts +0 -253
  85. package/src/world3d-react/engine-bridge.ts +0 -86
  86. package/src/world3d-react/r3f-adapter.tsx +0 -717
  87. package/src/world3d-react/world-context.ts +0 -358
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * `pixiReactRootFactory` — mount a `canvas` root whose entry module
3
- * DEFAULT-EXPORTS a React component.
4
- *
5
- * A model asked for a 2D world writes:
3
+ * DEFAULT-EXPORTS a React component. This is the ONE first-party canvas mount:
6
4
  *
7
5
  * ```tsx
8
6
  * export default function World() {
@@ -10,11 +8,13 @@
10
8
  * }
11
9
  * ```
12
10
  *
13
- * It does NOT write `export const adapter = createPixiReactAdapter({ id, content })`.
14
- * That export is vgai ceremony wrapped around a component the author already
15
- * wrote. The `three` and `dom` roots both settled this convention read
16
- * `entryModule.default` and wrap it and this is the same contract for canvas
17
- * roots, so all three surfaces answer "what do I export?" the same way.
11
+ * The world is an ordinary `@pixi/react` app. Everything vgai-shaped stays at
12
+ * the entry module's STATIC surface (`export { debug, systems } from
13
+ * './commands'`) and on the host's side of the seam; no vgai runtime context
14
+ * ever enters the React tree. The host advances Pixi's real (never-started)
15
+ * ticker on GAME time, wires the game-scoped input seams from outside
16
+ * (`../runtime/game-input-seams.ts`), and installs `WorldProvider` off the
17
+ * Game handle so the lib-legal react doors resolve.
18
18
  *
19
19
  * ## Why this is NOT in `mount-game.ts`
20
20
  *
@@ -24,22 +24,298 @@
24
24
  * the opt-in module that owns those dependencies, so the factory lives here and
25
25
  * a project opts in with `registerAdapter('canvas', pixiReactRootFactory)`.
26
26
  *
27
- * ## Compatibility
27
+ * ## The loop contract
28
+ *
29
+ * `Application` is initialized with `autoStart: false` and `sharedTicker:
30
+ * false`, so it owns a native Pixi ticker that is NEVER started and therefore
31
+ * never requests an animation frame. `mounted.update(dt)` is the only thing
32
+ * that advances that ticker, and it feeds GAME time, not wall time — which is
33
+ * what makes the world freeze exactly when the host freezes it, and what makes
34
+ * `game.waitSimTime` work in a hidden tab (a hidden tab has no rAF; the engine
35
+ * loop's own catch-up still calls `update`, and Pixi has no independent clock
36
+ * to disagree with it).
28
37
  *
29
- * An `adapter` export still wins, so a game needing full control over its own
30
- * `RootAdapter<'canvas'>` keeps it.
38
+ * ## Component catalogue
39
+ *
40
+ * `@pixi/react` resolves `<pixiSprite>` etc. through a module-global catalogue
41
+ * that starts EMPTY — the first intrinsic in an un-extended tree throws
42
+ * "X is not part of the PIXI namespace! Did you forget to extend?". This
43
+ * module extends it with the whole `pixi.js` namespace once per mount
44
+ * (idempotent `Object.assign`). A world that adds its own classes
45
+ * (`@pixi/tilemap`, a custom `Container` subclass) calls `extend`/`useExtend`
46
+ * itself.
31
47
  */
32
48
 
33
- import { type ComponentType, createElement } from 'react';
34
- import type { RootAdapter } from '../adapter/root-adapter';
49
+ import { createRoot, extend } from '@pixi/react';
50
+ import type { Application, ApplicationOptions } from 'pixi.js';
51
+ import * as PIXI from 'pixi.js';
52
+ import { type ComponentType, createElement, Fragment, useEffect, useLayoutEffect } from 'react';
53
+ import type { CanvasHostContext, MountedPixiRoot, RootAdapter } from '../adapter';
54
+ import type { SystemAdapters } from '../adapter/system-adapter';
55
+ import { WorldProvider } from '../react/world-state';
56
+ import { getDebugRegistry } from '../runtime/debug-registry';
57
+ import { DEFAULT_INPUT_MAP_PATH, wireGameInputSeams } from '../runtime/game-input-seams';
35
58
  import type { AdapterSurfaceFactory } from '../runtime/mount-game';
36
- import { createPixiReactAdapter } from './pixi-react-adapter';
59
+
60
+ /** How long `mount()` waits for the tree's first commit before failing loudly
61
+ * rather than hanging (and wedging every root declared after this one, since
62
+ * roots mount sequentially). */
63
+ const FIRST_COMMIT_TIMEOUT_MS = 10_000;
64
+
65
+ /**
66
+ * `@pixi/react` keys its reconciler roots off the canvas element in a
67
+ * module-global map, and its own unmount path (which would clear that key) is
68
+ * not exported. So a canvas this lane has already mounted must never be handed
69
+ * to a second mount: `createRoot` would silently hand back the FIRST root,
70
+ * whose `Application` this lane already destroyed. Every host in this repo
71
+ * builds a fresh canvas per mount, so this only ever fires on a genuine bug —
72
+ * and it fires as a named error rather than as a dead world.
73
+ */
74
+ const MOUNTED_CANVASES = new WeakSet<HTMLCanvasElement>();
75
+
76
+ /** `@pixi/react`'s reconciler-root handle. Its `Root` type is internal (the
77
+ * package exports the FUNCTION, not the type), so name it off the function. */
78
+ type PixiReactRoot = ReturnType<typeof createRoot>;
37
79
 
38
80
  interface CanvasEntryModuleExports {
39
- /** The idiomatic shape: the world IS a component. */
81
+ /** The one shape: the world IS a component. */
40
82
  readonly default?: ComponentType;
41
- /** Full control, and still the winner when present. */
42
- readonly adapter?: RootAdapter<'canvas'>;
83
+ }
84
+
85
+ /**
86
+ * Build the `RootAdapter<'canvas'>` for one default-exported Pixi world.
87
+ * Mounts through `@pixi/react`, gated entirely by the host's own loop and
88
+ * rendering into the host's own canvas — never a second canvas, never a
89
+ * second `requestAnimationFrame` loop.
90
+ */
91
+ function canvasWorldAdapter(id: string, component: ComponentType): RootAdapter<'canvas'> {
92
+ const content = createElement(component);
93
+
94
+ return {
95
+ id,
96
+
97
+ async mount(host: CanvasHostContext): Promise<MountedPixiRoot> {
98
+ // THE HOST WIRES THIS GAME'S INPUT FROM OUTSIDE THE TREE — the project's
99
+ // declared map (conventional path, optional: a brand-new project
100
+ // declares no actions yet) and this root's `game.input.*` seams on the
101
+ // debug registry. Actions must exist before any component's first tick
102
+ // reads them — wait for the (never-rejecting) load before the first
103
+ // commit.
104
+ const registry = host.game ? getDebugRegistry(host.game) : null;
105
+ if (registry) {
106
+ await wireGameInputSeams(host, registry, {
107
+ id,
108
+ inputMapPath: DEFAULT_INPUT_MAP_PATH,
109
+ optionalInputMap: true,
110
+ });
111
+ }
112
+
113
+ const canvas = host.canvas;
114
+ if (MOUNTED_CANVASES.has(canvas)) {
115
+ throw new Error(
116
+ `canvas world "${id}": this lane has already mounted this canvas. @pixi/react keys ` +
117
+ 'its reconciler roots off the canvas element, so re-using one would resurrect a ' +
118
+ 'root whose Application is destroyed. Hand each mount a fresh canvas (every vgai ' +
119
+ 'host already does).',
120
+ );
121
+ }
122
+ MOUNTED_CANVASES.add(canvas);
123
+
124
+ // The catalogue is module-global and starts empty; this is idempotent.
125
+ extend(PIXI as unknown as Parameters<typeof extend>[0]);
126
+
127
+ let resolveCommitted!: () => void;
128
+ const committed = new Promise<void>((resolve) => {
129
+ resolveCommitted = resolve;
130
+ });
131
+ let resolveEffectsReady!: () => void;
132
+ const effectsReady = new Promise<void>((resolve) => {
133
+ resolveEffectsReady = resolve;
134
+ });
135
+ let resolveUnmounted!: () => void;
136
+ const unmounted = new Promise<void>((resolve) => {
137
+ resolveUnmounted = resolve;
138
+ });
139
+ /**
140
+ * Signals the tree's FIRST COMMIT and its LAST. A layout effect,
141
+ * deliberately, and rendered as the LAST child, deliberately:
142
+ *
143
+ * - mount: layout effects run synchronously inside the commit, before
144
+ * any passive effect and before any tick can be scheduled;
145
+ * - unmount: React commits deletion effects in tree order, each node
146
+ * before its own children, so the LAST sibling's cleanup is the last
147
+ * to run. That is what makes this a reliable "every component has
148
+ * finished tearing down" signal — and it is load-bearing, because
149
+ * `useTick`'s own cleanup calls `ticker.remove()`. Destroying the
150
+ * Application before that cleanup lands throws inside React's commit
151
+ * on every teardown of any world with a tick hook.
152
+ */
153
+ function Lifecycle(): null {
154
+ useLayoutEffect(() => {
155
+ resolveCommitted();
156
+ return resolveUnmounted;
157
+ }, []);
158
+ // This component is the last sibling. Reaching its passive effect
159
+ // means the initial tree's provider/command effects have registered,
160
+ // so mount() can truthfully back Play's ack.
161
+ useEffect(resolveEffectsReady, []);
162
+ return null;
163
+ }
164
+
165
+ // `WorldProvider` rides `host.game` — the React seam every dom root
166
+ // already has, and the only way a capability hook can reach the ONE
167
+ // game-scoped registry the editor/`vgai eval` read. `host.game` is
168
+ // genuinely absent in bare/foreign hosts, so the provider is
169
+ // conditional and those hooks stay the inert no-ops they already
170
+ // document themselves to be.
171
+ const game = host.game;
172
+ const world = createElement(Fragment, null, content, createElement(Lifecycle));
173
+ const element = game ? createElement(WorldProvider, { game }, world) : world;
174
+
175
+ const root: PixiReactRoot = createRoot(canvas);
176
+
177
+ // The host owns size, dpr, transparency and capture cost; the loop
178
+ // contract owns `autoStart`/`sharedTicker`.
179
+ const initOptions: Partial<ApplicationOptions> = {
180
+ antialias: true,
181
+ width: host.width,
182
+ height: host.height,
183
+ resolution: host.dpr ?? globalThis.devicePixelRatio ?? 1,
184
+ autoDensity: true,
185
+ // Stacked ABOVE a sibling root -> clear with alpha 0 so the layer below
186
+ // shows through (COMPOSITION-DESIGN D5 §1).
187
+ backgroundAlpha: host.transparent ? 0 : 1,
188
+ preserveDrawingBuffer: host.preserveDrawingBuffer ?? false,
189
+ autoStart: false,
190
+ sharedTicker: false,
191
+ };
192
+
193
+ // Install the reconcile-error listener BEFORE `root.render`: Pixi hands
194
+ // the Application back after `init()` but React can already have thrown
195
+ // during that same turn. Attaching after the await loses the real error
196
+ // and turns it into a misleading ten-second timeout.
197
+ let reportReconcileFailure!: (why: string) => void;
198
+ const reconcileFailure = new Promise<string>((resolve) => {
199
+ reportReconcileFailure = resolve;
200
+ });
201
+ const onError = (event: ErrorEvent): void => {
202
+ reportReconcileFailure(
203
+ `the Pixi tree crashed before its first commit — ${event.message} ` +
204
+ '(mount() fails loudly instead of hanging)',
205
+ );
206
+ };
207
+ globalThis.addEventListener?.('error', onError);
208
+
209
+ let app: Application | undefined;
210
+ let timer: ReturnType<typeof setTimeout> | undefined;
211
+ try {
212
+ // `root.render` is async: it awaits `Application.init()` (which is what
213
+ // creates the renderer) and only then hands the tree to the reconciler.
214
+ // A crash inside init rejects here, which is the loud failure we want.
215
+ const renderedApp = await root.render(element, initOptions);
216
+ app = renderedApp;
217
+
218
+ // A reconcile-time crash (e.g. an un-extended component) surfaces as
219
+ // an uncaught window error and the first commit never lands. Race that
220
+ // exact signal against the commit and the last-resort ceiling.
221
+ const timeout = new Promise<string>((resolve) => {
222
+ timer = setTimeout(
223
+ () =>
224
+ resolve(
225
+ `no first commit within ${FIRST_COMMIT_TIMEOUT_MS / 1000}s ` +
226
+ '(mount() fails loudly instead of hanging)',
227
+ ),
228
+ FIRST_COMMIT_TIMEOUT_MS,
229
+ );
230
+ });
231
+ const failure = await Promise.race([
232
+ Promise.all([committed, effectsReady]).then(() => null),
233
+ reconcileFailure,
234
+ timeout,
235
+ ]);
236
+ if (failure !== null) {
237
+ throw new Error(`canvas world "${id}": ${failure}`);
238
+ }
239
+ } catch (error) {
240
+ try {
241
+ app?.destroy(false, { children: true });
242
+ } catch {
243
+ /* the world never fully came up; teardown failure must not mask why */
244
+ }
245
+ throw error;
246
+ } finally {
247
+ if (timer !== undefined) clearTimeout(timer);
248
+ globalThis.removeEventListener?.('error', onError);
249
+ }
250
+
251
+ // `app` is assigned before the first-commit race. Reaching here proves
252
+ // both initialization and commit succeeded, so narrow once for the live
253
+ // mounted-root methods below.
254
+ if (app === undefined) {
255
+ throw new Error(`canvas world "${id}": initialized without an Application`);
256
+ }
257
+ const mountedApp = app;
258
+
259
+ // Pin the ticker's clock to GAME time. `Ticker.lastTime` starts at -1, so
260
+ // without this the first `update()` would report a 1ms phantom delta on
261
+ // top of the real one. From here every `deltaMS` a `useTick` callback
262
+ // sees is exactly the `dt` the host handed us — no wall clock anywhere,
263
+ // which is what makes a paused world's resume frame ordinary rather than
264
+ // one giant catch-up step.
265
+ mountedApp.ticker.lastTime = 0;
266
+ let elapsedMs = 0;
267
+ let disposed = false;
268
+ // Adapter surface: the host's shared debug registry. The game's own
269
+ // capabilities arrive as the entry module's declared `systems`.
270
+ const systemAdapters: SystemAdapters = registry ? { debug: registry.adapter } : {};
271
+ const disposeComplete = unmounted.then(() => {
272
+ mountedApp.destroy(false, { children: true });
273
+ });
274
+
275
+ return {
276
+ kind: 'canvas',
277
+ // The reconciler's real root container — the same `PIXI.Container`
278
+ // `RootInstance.pixiStage()` hands the editor.
279
+ stage: mountedApp.stage,
280
+ drivesOwnLoop: false,
281
+ systems: systemAdapters,
282
+ disposeComplete,
283
+ update(dt: number): void {
284
+ if (disposed) return;
285
+ elapsedMs += dt * 1000;
286
+ // The real Pixi ticker remains the one scheduler `useTick` observes;
287
+ // the host only gates/advances it.
288
+ mountedApp.ticker.update(elapsedMs);
289
+ },
290
+ resize(width: number, height: number): void {
291
+ if (disposed) return;
292
+ // `autoDensity: true` makes Pixi re-stamp `canvas.style.width`/
293
+ // `.height` from this call, so the on-screen box follows the logical
294
+ // size with no explicit CSS write from the host.
295
+ mountedApp.renderer.resize(width, height);
296
+ },
297
+ dispose(): void {
298
+ if (disposed) return;
299
+ disposed = true;
300
+ // Pixi's EventSystem owns document.pointermove + window.pointerup
301
+ // (and its event ticker's global listener). React's deletion commit
302
+ // is asynchronous, so waiting to destroy the Application leaves
303
+ // those listeners live when the host audits this game realm. Detach
304
+ // the input target synchronously; the later Application.destroy()
305
+ // repeats this idempotently after every useTick cleanup has landed.
306
+ mountedApp.renderer.events.setTargetElement(null as unknown as HTMLElement);
307
+ // Rendering `null` children is the only unmount path `@pixi/react`
308
+ // exposes publicly (its `unmountRoot` helper is internal), and React
309
+ // commits that deletion on its own schedule — NOT synchronously. So
310
+ // the Application is destroyed on the `Lifecycle` cleanup signal
311
+ // instead of inline: `useTick`'s cleanup calls `ticker.remove()`, and
312
+ // a ticker destroyed before it lands throws inside React's commit.
313
+ // `removeView` stays false — the canvas belongs to the HOST.
314
+ void root.render(null, initOptions);
315
+ },
316
+ };
317
+ },
318
+ };
43
319
  }
44
320
 
45
321
  /**
@@ -48,45 +324,31 @@ interface CanvasEntryModuleExports {
48
324
  * Two callers need this answer and must never disagree about it: the runtime
49
325
  * mount ({@link pixiReactRootFactory}, below) and the EDITOR, which resolves
50
326
  * the same entry for play mode and for the design-time layer. When only one
51
- * side knows about the default-export shape, a world plays fine and shows
52
- * "(no scene loaded)" in edit mode — the exact split the three lane already
53
- * paid for.
327
+ * side knows the shape, a world plays fine and shows "(no scene loaded)" in
328
+ * edit mode.
54
329
  *
55
- * Returns `null` when the module is neither shape — the callers differ on what
56
- * to do about that (the factory throws; the editor reports a mount failure on
57
- * that root's boundary node and leaves every sibling working).
330
+ * Returns `null` when the module has no default-exported component — the
331
+ * callers differ on what to do about that (the factory throws; the editor
332
+ * reports a mount failure on that root's boundary node and leaves every
333
+ * sibling working).
58
334
  */
59
335
  export function resolveCanvasEntryAdapter(
60
336
  entryModule: unknown,
61
337
  rootId: string,
62
338
  ): RootAdapter<'canvas'> | null {
63
339
  const mod = entryModule as CanvasEntryModuleExports | undefined;
64
- if (mod?.adapter) return mod.adapter;
65
- if (typeof mod?.default === 'function') {
66
- return createPixiReactAdapter({
67
- id: rootId,
68
- content: createElement(mod.default),
69
- // The supported Canvas portfolio still consumes the compatibility
70
- // context. Removing it belongs to the tracked component-root migration;
71
- // disabling it here first made every current default-exported game fail
72
- // to mount before that migration had happened.
73
- });
74
- }
340
+ if (typeof mod?.default === 'function') return canvasWorldAdapter(rootId, mod.default);
75
341
  return null;
76
342
  }
77
343
 
78
344
  /**
79
345
  * Register with `registerAdapter('canvas', pixiReactRootFactory)`.
80
- *
81
- * Resolution order mirrors the three lane's (explicit adapter wins) and then
82
- * accepts the default-exported component.
83
346
  */
84
347
  export const pixiReactRootFactory: AdapterSurfaceFactory = (root, ctx) => {
85
348
  const adapter = resolveCanvasEntryAdapter(ctx.entryModule, root.id);
86
349
  if (adapter) return { kind: 'canvas', adapter };
87
350
  throw new Error(
88
351
  `pixiReactRootFactory: entry module "${root.entry ?? '(none)'}" for root "${root.id}" must ` +
89
- 'default-export a React component (`export default function World() { … }`) — or export ' +
90
- 'an `adapter` for full control.',
352
+ 'default-export a React component (`export default function World() { … }`).',
91
353
  );
92
354
  };
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `Countdown` — a restartable interval the CALLER steps, with the two
3
+ * behaviours under a frame hitch that decide whether a game's event count is
4
+ * right.
5
+ *
6
+ * A respawn delay, a weapon cooldown, a wave spawner, a grace window: each is
7
+ * "count down from N seconds, tell me, maybe go again, and let me restart or
8
+ * stop you at any point". `core/sim-clock.ts` schedules a one-shot at an
9
+ * ABSOLUTE sim time — the right shape for "3 seconds from now, once" and the
10
+ * wrong one for a live interval that game code re-arms, reads
11
+ * ({@link Countdown.remaining} is what a HUD ring draws) and cancels.
12
+ *
13
+ * That gap used to be papered over in `sim-clock.ts`'s own header, which said
14
+ * an `after` re-arming itself was "three lines the game owns". Those three
15
+ * lines are wrong in a way nobody notices: re-arming schedules the next fire
16
+ * from the moment the callback RAN, so every long frame permanently lengthens
17
+ * the interval, and a game that hitches ten times has silently slowed its
18
+ * spawner. That is the drift {@link Countdown} exists to not have.
19
+ *
20
+ * ## It owns no clock, and that is the requirement rather than a shortcut
21
+ *
22
+ * There is no `setTimeout`, no `setInterval`, no `requestAnimationFrame` and no
23
+ * subscription anywhere in this module. A countdown advances only inside
24
+ * {@link Countdown.advance}, from seconds the caller already has — the fixed
25
+ * loop's own dt. This repo runs a fixed-step sim with display-rate
26
+ * presentation and can step a game deterministically; a countdown on wall-clock
27
+ * `setTimeout` would be a second, undeterministic timeline that no care
28
+ * downstream could re-sync. A countdown nobody advances does not fire —
29
+ * visibly, rather than drifting.
30
+ *
31
+ * ## The frame-hitch contract, which is the whole reason this is a module
32
+ *
33
+ * Both rules are about keeping the EVENT COUNT correct when `dt` is larger than
34
+ * the period, which is exactly when a game is already in trouble:
35
+ *
36
+ * - **Overshoot carries into the next period.** A 0.5 s repeating countdown
37
+ * advanced by 0.6 fires once and has 0.4 s left, not 0.5. Resetting to the
38
+ * full period instead would make one slow frame lengthen every interval
39
+ * after it — the drift above, one layer down.
40
+ * - **A single large step fires MORE THAN ONCE.** A 0.1 s countdown advanced
41
+ * by 0.35 fires three times and has 0.05 s left. Dropping the extras would
42
+ * silently slow a repeating countdown down whenever the frame was long, so a
43
+ * wave that should have spawned 3 enemies spawns 1 and the difficulty curve
44
+ * quietly depends on frame rate.
45
+ *
46
+ * Two consequences a caller must know about, both deliberate: `onElapsed` can
47
+ * be invoked several times within one `advance` call, and it runs INLINE
48
+ * (synchronously, inside `advance`) rather than on a microtask — so the caller
49
+ * always knows exactly which point of its frame the callback ran at.
50
+ *
51
+ * A one-shot countdown STOPS ITSELF BEFORE invoking `onElapsed`, so a callback
52
+ * that calls {@link Countdown.start} re-arms rather than fighting the stop.
53
+ * A callback that calls {@link Countdown.stop} ends the multi-fire loop
54
+ * immediately, which is what makes "fire once then disarm from inside the
55
+ * callback" work.
56
+ *
57
+ * ## Resource ownership
58
+ *
59
+ * **Owner:** whoever calls {@link createCountdown}. A countdown holds two
60
+ * numbers, a boolean and the `onElapsed` reference; it registers nothing,
61
+ * subscribes to nothing, and this module has no module-level state — so there
62
+ * is deliberately no `dispose()` and dropping the countdown drops everything it
63
+ * had. **Sharers:** none; a countdown is not shareable, because whoever
64
+ * advances it decides its timeline. **Teardown:** what DOES need unregistering
65
+ * is the countdown's slot in whatever set the owner advances each frame, and
66
+ * that is the owner's — the same way `SceneTree.addTimer` hands back a remover.
67
+ */
68
+
69
+ /** A restartable countdown. Build one with {@link createCountdown}. */
70
+ export interface Countdown {
71
+ /** Seconds it counts from. Reflects the last {@link Countdown.start} that
72
+ * passed a duration. */
73
+ readonly duration: number;
74
+ /** Does it re-arm itself after firing, or stop? */
75
+ readonly repeats: boolean;
76
+ /** Seconds left, or 0 when it is not running. */
77
+ readonly remaining: number;
78
+ /** Is it counting? */
79
+ readonly running: boolean;
80
+ /**
81
+ * Start, or RESTART a running countdown from the top. Passing `duration`
82
+ * overwrites {@link Countdown.duration} for this and every later start.
83
+ *
84
+ * @throws RangeError if `duration` is not a finite positive number of
85
+ * seconds. A non-positive period cannot fire and reads as a hung game
86
+ * rather than a misconfigured one, so it refuses at the call site.
87
+ */
88
+ start(duration?: number): void;
89
+ /** Stop counting. Invokes nothing. */
90
+ stop(): void;
91
+ /**
92
+ * Advance by `dt` SECONDS, invoking `onElapsed` once per completed period —
93
+ * possibly several times in one call. See the module header's frame-hitch
94
+ * contract.
95
+ */
96
+ advance(dt: number): void;
97
+ }
98
+
99
+ /** What {@link createCountdown} needs. */
100
+ export interface CountdownOptions {
101
+ /** Seconds to count from. Default 1. */
102
+ readonly duration?: number;
103
+ /** Re-arm after firing instead of stopping. Default `false` (one-shot). */
104
+ readonly repeats?: boolean;
105
+ /** Begin counting immediately instead of waiting for a `start()`. Default
106
+ * `false`. */
107
+ readonly autostart?: boolean;
108
+ /** Invoked inline, once per completed period. */
109
+ readonly onElapsed: () => void;
110
+ }
111
+
112
+ function assertDuration(value: number): void {
113
+ if (!Number.isFinite(value) || value <= 0) {
114
+ throw new RangeError(
115
+ `createCountdown: duration must be a finite, positive number of seconds, got ${String(value)}`,
116
+ );
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Build a countdown.
122
+ *
123
+ * ```ts
124
+ * const wave = createCountdown({ duration: 8, repeats: true, autostart: true, onElapsed: spawnWave });
125
+ * useFrame((_, dt) => wave.advance(dt)); // the caller owns the step
126
+ * ```
127
+ */
128
+ export function createCountdown(options: CountdownOptions): Countdown {
129
+ const onElapsed = options.onElapsed;
130
+ const repeats = options.repeats === true;
131
+ let duration = options.duration ?? 1;
132
+ let remaining = 0;
133
+ let running = false;
134
+
135
+ assertDuration(duration);
136
+
137
+ const countdown: Countdown = {
138
+ get duration(): number {
139
+ return duration;
140
+ },
141
+ get repeats(): boolean {
142
+ return repeats;
143
+ },
144
+ get remaining(): number {
145
+ return running ? remaining : 0;
146
+ },
147
+ get running(): boolean {
148
+ return running;
149
+ },
150
+
151
+ start(newDuration): void {
152
+ if (newDuration !== undefined) {
153
+ assertDuration(newDuration);
154
+ duration = newDuration;
155
+ }
156
+ remaining = duration;
157
+ running = true;
158
+ },
159
+
160
+ stop(): void {
161
+ running = false;
162
+ remaining = 0;
163
+ },
164
+
165
+ advance(dt): void {
166
+ if (!running) return;
167
+ remaining -= dt;
168
+ // The loop is the multi-fire rule; `running` in the condition is what
169
+ // lets a callback's own stop() end it. Module header, frame-hitch.
170
+ while (running && remaining <= 0) {
171
+ if (!repeats) {
172
+ // Disarm BEFORE invoking, so a callback that calls start() re-arms
173
+ // instead of being immediately stopped by this branch.
174
+ running = false;
175
+ remaining = 0;
176
+ onElapsed();
177
+ return;
178
+ }
179
+ // Carry the overshoot rather than resetting to the full period.
180
+ remaining += duration;
181
+ onElapsed();
182
+ }
183
+ },
184
+ };
185
+
186
+ if (options.autostart === true) countdown.start();
187
+ return countdown;
188
+ }