@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
@@ -1,176 +0,0 @@
1
- /**
2
- * Overlay apply report — D9 (docs/DECISIONS-PENDING.md §D9): decide + surface
3
- * what happens when a saved ingest overlay is reapplied onto a scene that may
4
- * have moved on since the overlay was authored (a vendored game bump, or a
5
- * dynamic subtree that never respawned). Two independent signals, either of
6
- * which is worth a loud (never silent) structured warning:
7
- * - a GAME-VERSION mismatch: the overlay's `authoredAgainst.gameVersion`
8
- * (`./overlay-file.ts`'s envelope, stamped from the manifest's `version`
9
- * at save time) disagrees with the currently-mounted game's version;
10
- * - ORPHANED ids: structural-path ids in the overlay that resolved to
11
- * nothing on the current scene walk (`./overlay-apply.ts`'s
12
- * `applyOverlayToObjects`).
13
- *
14
- * Neither condition blocks mounting — the overlay still applies everything it
15
- * CAN; this module only decides whether/how to report what it couldn't.
16
- *
17
- * This is the SEAM T3.8 (the runtime overlay applier, i.e. reapplying an
18
- * ingest overlay against a shipped/played build rather than the editor)
19
- * reuses: `./overlay-applier.ts`'s `applyVgaiOverlay` builds + logs this EXACT
20
- * report shape, same as the editor does. Moved here from the editor-only
21
- * module that used to own it (`packages/editor/src/authoring/overlay-report.ts`,
22
- * which now re-exports everything below unchanged plus the editor-only
23
- * "active report" banner slot) — kept dependency-free (no `editor-console`/
24
- * `window` import) so it stays importable from a headless runtime context —
25
- * same discipline as `../achieved-tier.ts`.
26
- *
27
- * R5 (docs/MASTER-ARCHITECTURE-REVIEW.md §4(c)/§7.1 item 9) adds a THIRD
28
- * independent signal alongside version-mismatch/orphans: an UPSTREAM-PIN
29
- * mismatch. `gameVersion` alone cannot catch a same-slot substitution after a
30
- * re-vendor (structural ids are position+type+name; a re-vendor that keeps a
31
- * slot's shape reapplies the overlay onto a semantically different object
32
- * with `applied:1, orphanedIds:[]`, and every vendored game's manifest
33
- * `version` is hand-set and need not change on a re-vendor). The upstream pin
34
- * (`./upstream-pin.ts`, read from the vendored game's `UPSTREAM.md`) is bound
35
- * to the thing that actually changes. Like the other two signals, a mismatch
36
- * never blocks application — it's a report, not a block (matching orphan
37
- * semantics) — so a re-vendor surfaces as an explicit "authored against pin
38
- * X, applying onto pin Y" instead of silent success.
39
- */
40
-
41
- /** One mount's version + orphan findings — the D9 acceptance-criteria shape. */
42
- export interface OverlayApplyReport {
43
- gameId: string;
44
- /** `authoredAgainst.gameVersion` from the loaded overlay envelope, or `null` if never stamped. */
45
- authoredAgainst: string | null;
46
- /** The currently-mounted game's manifest `version`, or `null` if unknown at this call site. */
47
- currentVersion: string | null;
48
- /** Both sides known AND different — never true if either side is `null` (anti-shim: no guessing). */
49
- versionMismatch: boolean;
50
- applied: number;
51
- /** Structural-path ids present in the overlay that resolved to nothing on this scene walk. */
52
- orphanedIds: string[];
53
- /**
54
- * R5 — upstream-pin identity check (see this module's header). Distinct
55
- * from `versionMismatch`/`orphanedIds`: it catches a same-slot
56
- * SUBSTITUTION (id persists, meaning changed) that those two cannot.
57
- */
58
- upstreamPin: {
59
- /** The pin the overlay was authored against, or `null` if never recorded/discoverable. */
60
- authored: string | null;
61
- /** The currently-mounted game's own discoverable pin, or `null` if undiscoverable here
62
- * (e.g. the runtime ship-path applier, which has no `UPSTREAM.md` access at all — a
63
- * documented no-op there — or an externally-authored, non-vendored project). */
64
- current: string | null;
65
- /**
66
- * Whether the SAVED overlay envelope recorded the upstream-pin concept at
67
- * all (i.e. `authoredAgainst.upstreamPin` was a present key, even if its
68
- * value was `null`). `false` means a legacy overlay — saved by pre-R5
69
- * code (or the pre-D9 flat-file shape) — for which the pin check is
70
- * skipped entirely, not silently treated as a match. This is the
71
- * "explicit legacy indication" the report surfaces so a legacy overlay's
72
- * clean apply doesn't read as "verified" when it was never checked.
73
- */
74
- recorded: boolean;
75
- /** `recorded` AND both sides known AND different — never true otherwise (anti-shim). */
76
- mismatch: boolean;
77
- };
78
- }
79
-
80
- /** Greppable prefix shared by every overlay-related log line (mirrors `achieved-tier.ts`'s `TIER_SHORTFALL_PREFIX`). */
81
- export const OVERLAY_REPORT_PREFIX = 'overlay-report';
82
-
83
- /** Build the report from a mount's raw findings — pure, no logging. */
84
- export function buildOverlayApplyReport(args: {
85
- gameId: string;
86
- authoredAgainst: string | null;
87
- currentVersion: string | null;
88
- applied: number;
89
- orphanedIds: string[];
90
- /**
91
- * R5: the loaded overlay's `authoredAgainst.upstreamPin`. Pass `undefined`
92
- * (or omit) when the saved envelope never had the key at all (legacy
93
- * overlay — `upstreamPin.recorded` becomes `false`); pass `null` when the
94
- * envelope DID have the key but its value was `null` (R5-aware code
95
- * looked, found nothing — `recorded` is `true`, `authored` is `null`); pass
96
- * the pin string otherwise.
97
- */
98
- authoredUpstreamPin?: string | null | undefined;
99
- /**
100
- * R5: the currently-mounted game's own discoverable pin, or `null`/omitted
101
- * when undiscoverable at this call site (anti-shim: never fabricated).
102
- */
103
- currentUpstreamPin?: string | null | undefined;
104
- }): OverlayApplyReport {
105
- const versionMismatch =
106
- args.authoredAgainst !== null &&
107
- args.currentVersion !== null &&
108
- args.authoredAgainst !== args.currentVersion;
109
- const recorded = args.authoredUpstreamPin !== undefined;
110
- const authoredPin = args.authoredUpstreamPin ?? null;
111
- const currentPin = args.currentUpstreamPin ?? null;
112
- const upstreamPinMismatch =
113
- recorded && authoredPin !== null && currentPin !== null && authoredPin !== currentPin;
114
- return {
115
- gameId: args.gameId,
116
- authoredAgainst: args.authoredAgainst,
117
- currentVersion: args.currentVersion,
118
- applied: args.applied,
119
- orphanedIds: args.orphanedIds,
120
- versionMismatch,
121
- upstreamPin: {
122
- authored: authoredPin,
123
- current: currentPin,
124
- recorded,
125
- mismatch: upstreamPinMismatch,
126
- },
127
- };
128
- }
129
-
130
- /** Whether this report is worth surfacing (mismatch OR any orphan) — pure, no logging. */
131
- export function overlayReportWarrants(report: OverlayApplyReport): boolean {
132
- return report.versionMismatch || report.orphanedIds.length > 0 || report.upstreamPin.mismatch;
133
- }
134
-
135
- /** The one shared message shape — greppable on the prefix, JSON-parseable on the trailing payload. */
136
- export function formatOverlayApplyReportMessage(report: OverlayApplyReport): string {
137
- const parts: string[] = [];
138
- if (report.versionMismatch) {
139
- parts.push(
140
- `overlay authored against v${report.authoredAgainst}, game is v${report.currentVersion}`,
141
- );
142
- }
143
- if (report.upstreamPin.mismatch) {
144
- parts.push(
145
- `overlay authored against upstream pin ${report.upstreamPin.authored}, ` +
146
- `game is vendored at pin ${report.upstreamPin.current}`,
147
- );
148
- }
149
- if (report.orphanedIds.length > 0) {
150
- parts.push(
151
- `${report.orphanedIds.length} orphaned overlay entr${report.orphanedIds.length === 1 ? 'y' : 'ies'}`,
152
- );
153
- }
154
- return (
155
- `${OVERLAY_REPORT_PREFIX} game "${report.gameId}": ${parts.join('; ')} ` +
156
- `(D9, docs/DECISIONS-PENDING.md §D9; upstream-pin check R5, ` +
157
- `docs/MASTER-ARCHITECTURE-REVIEW.md §4(c)). ${OVERLAY_REPORT_PREFIX} ${JSON.stringify(report)}`
158
- );
159
- }
160
-
161
- /**
162
- * Emit the ONE structured `console.warn` this report warrants (mirroring the
163
- * tier-shortfall pattern), or do nothing when neither signal fires. Returns
164
- * whether it warned (callers that also want the panel-visible `editorConsole`
165
- * echo, or the inline banner, use the return value / the report itself).
166
- */
167
- export function logOverlayApplyReport(report: OverlayApplyReport): boolean {
168
- if (!overlayReportWarrants(report)) return false;
169
- // The D9 AC requires this EXACT native console.warn (spied on directly in
170
- // overlay-versioning.test.ts) — other deliberate native console.warn call sites in
171
- // this codebase (e.g. editor-store.ts) are unsuppressed and already counted in the
172
- // lint baseline; this one is suppressed to keep this task's diff at zero NEW warnings.
173
- // biome-ignore lint/suspicious/noConsole: see comment above
174
- console.warn(formatOverlayApplyReportMessage(report));
175
- return true;
176
- }
@@ -1,184 +0,0 @@
1
- import gsap from 'gsap';
2
- import type { SystemPhaseName } from '../core/types';
3
- import type { AnimationClock, ClockEvaluatorHandle } from './animation-clock';
4
-
5
- /**
6
- * D7 — register a native, paused GSAP timeline on the canonical
7
- * `AnimationClock` (spec §10 D7, §5.5 GSAP Contract, §3.2).
8
- *
9
- * Mirrors `theatre-clock-binding.ts` (D2)'s shape: VGAI's entire GSAP
10
- * integration is "who drives time". Games build GSAP timelines with GSAP's
11
- * own native, un-renamed API (`gsap.timeline()`, `.to()`, `.from()`, …,
12
- * spec §5.5) — this module never wraps or renames the tween-building API. It
13
- * only stores the native `gsap.core.Timeline` and seeks it every time the
14
- * clock moves, exactly like the Theatre sequence binding seeks
15
- * `sequence.position`.
16
- *
17
- * ## Unregistered GSAP is unaffected (§5.5 contract)
18
- *
19
- * `gsap.to(enemy.position, { … })` called anywhere in game code — without
20
- * ever touching this module — keeps running on GSAP's own real-time,
21
- * `requestAnimationFrame`-driven global ticker exactly as GSAP ships it. This
22
- * module does not touch `gsap.ticker`, `gsap.globalTimeline`, or any other
23
- * global GSAP state, precisely so it cannot perturb that ordinary real-time
24
- * code path. Only a timeline explicitly passed to `registerGsap` — and
25
- * created paused — participates in deterministic scrub/export.
26
- *
27
- * ## Why a PAUSED timeline seeked with `.time()` is deterministic
28
- *
29
- * GSAP's global ticker only auto-advances animations that are actually
30
- * playing; a timeline created with `{ paused: true }` (and never resumed) is
31
- * never touched by the ticker's rAF-driven `dt` accumulation, so wall-clock
32
- * elapsed time, dropped frames, and GSAP's lag-smoothing heuristics (which
33
- * exist purely to compensate a *playing* animation for an irregular ticker
34
- * `dt`) cannot affect it. The only thing that ever moves a registered
35
- * timeline's playhead is this module's own `timeline.time(clock.time)` call,
36
- * driven synchronously off the clock — same story as `theatre-clock-binding`
37
- * driving `sequence.position` off the clock instead of Theatre's own raf
38
- * loop. This is proven directly against the real `gsap` runtime in
39
- * `packages/engine/test/gsap-registration.test.ts` (including a real
40
- * wall-clock `setTimeout` wait between two `clock.seek()` calls to the same
41
- * time, asserting the sampled value is bit-for-bit identical — nothing
42
- * "polished" the value while real time passed). `timeline.time(value)` (not
43
- * `.seek(value)`) is used because it takes only a numeric offset in the
44
- * clock's own second-denominated domain — `.seek()`'s label-string overload
45
- * is irrelevant here and would only widen the accepted input past what a
46
- * clock time actually is. Neither call resumes playback; both simply
47
- * position an already-paused timeline, which is why this module never calls
48
- * `.play()`/`.resume()`.
49
- *
50
- * ## Phase choice
51
- *
52
- * Registered by default at the `'animation'` phase — the same phase
53
- * `bindClockToTheatreSequence` (D2) uses, and the same phase
54
- * XState-bound `AnimationMixer` playback ticks in (see `ARCHITECTURE.md`'s
55
- * phase table). A registered GSAP timeline commonly targets the same
56
- * Object3D/material properties a Theatre-driven object binding also writes,
57
- * so keeping both in the `'animation'` phase (ahead of `'preRender'`, where
58
- * camera/material reads happen) avoids ordering surprises.
59
- */
60
-
61
- export interface GsapRegistrationOptions {
62
- /** Which `AnimationClock` phase to seek the timeline in. Defaults to `'animation'`. */
63
- phase?: SystemPhaseName;
64
- }
65
-
66
- export interface GsapRegistration {
67
- /**
68
- * Removes the clock evaluator and stops driving the timeline (no further
69
- * `timeline.time(...)` calls occur after this). Also frees this
70
- * clock+timeline pair for re-registration (see `GsapRegistrationError`'s
71
- * `'duplicate'` case below).
72
- */
73
- dispose(): void;
74
- }
75
-
76
- /**
77
- * Thrown by `registerGsap` when the input does not satisfy the D7 contract.
78
- * `code` distinguishes the three rejection reasons so a caller can branch on
79
- * it programmatically instead of string-matching `message`:
80
- *
81
- * - `'not-a-timeline'` — the second argument is not a real
82
- * `gsap.core.Timeline` instance (e.g. a plain object, a single GSAP
83
- * `Tween`, or `undefined`). Checked with `instanceof gsap.core.Timeline`
84
- * against the actual installed GSAP runtime, not duck-typing, so a
85
- * same-shaped fake cannot slip through.
86
- * - `'not-paused'` — the timeline exists but was not created with
87
- * `{ paused: true }` (or was later resumed) at registration time. This
88
- * module deliberately REJECTS rather than silently force-pausing: a
89
- * caller who already relies on the timeline advancing in real time (e.g.
90
- * a UI polish tween that happens to also get registered by mistake) would
91
- * otherwise have its behavior silently changed. Rejecting makes the fix
92
- * (`gsap.timeline({ paused: true })`) obvious at the call site instead of
93
- * producing a hard-to-notice runtime pause.
94
- * - `'duplicate'` — this exact timeline is already registered against this
95
- * exact clock. Detected per `(clock, timeline)` pair (not globally), so
96
- * the same timeline may legitimately be registered against two
97
- * independent clocks/cinematic contexts — mirroring D2's "multiple
98
- * projects/sheets coexist" allowance — while registering it twice on the
99
- * SAME clock (almost always a bug: a duplicate `useEffect` run, a missed
100
- * disposer, …) is caught immediately instead of silently doubling the
101
- * number of `.time()` writes per crossing.
102
- */
103
- export class GsapRegistrationError extends Error {
104
- constructor(
105
- message: string,
106
- readonly code: 'not-a-timeline' | 'not-paused' | 'duplicate',
107
- ) {
108
- super(message);
109
- this.name = 'GsapRegistrationError';
110
- }
111
- }
112
-
113
- // Per-clock membership of already-registered timelines. A WeakMap keyed by
114
- // clock (not a field on AnimationClock itself — D1 owns no knowledge of
115
- // GSAP) holding a WeakSet keyed by timeline, so neither the clock nor a
116
- // disposed/dropped timeline is kept alive by this module.
117
- const registeredByClock = new WeakMap<AnimationClock, WeakSet<gsap.core.Timeline>>();
118
-
119
- /**
120
- * Register a native, paused `gsap.core.Timeline` on `clock`: from now on,
121
- * every clock crossing (`seek`, `advance`, idempotent re-seeks, `setRange`
122
- * clamps — anything that fires a `ClockEvaluator`, per `animation-clock.ts`)
123
- * positions the timeline at `clock.time` via `timeline.time(clock.time)`.
124
- * The timeline is never played/resumed — it stays paused and is driven
125
- * exclusively by the clock, both in live preview and in offline
126
- * frame-by-frame export (whatever calls `clock.seek`/`clock.seekFrame`).
127
- *
128
- * Throws `GsapRegistrationError` if `timeline` is not a real
129
- * `gsap.core.Timeline`, is not paused, or is already registered against this
130
- * `clock` — see that class's doc comment for the exact policy on each case.
131
- *
132
- * Returns a `GsapRegistration` whose `dispose()` removes the clock evaluator
133
- * (mirrors `bindClockToTheatreSequence`'s `TheatreClockBinding`).
134
- */
135
- export function registerGsap(
136
- clock: AnimationClock,
137
- timeline: gsap.core.Timeline,
138
- options: GsapRegistrationOptions = {},
139
- ): GsapRegistration {
140
- if (!(timeline instanceof gsap.core.Timeline)) {
141
- throw new GsapRegistrationError(
142
- '[registerGsap] expected a native gsap.core.Timeline (e.g. gsap.timeline({ paused: true })), ' +
143
- `got ${timeline === null ? 'null' : typeof timeline}`,
144
- 'not-a-timeline',
145
- );
146
- }
147
- if (!timeline.paused()) {
148
- throw new GsapRegistrationError(
149
- '[registerGsap] timeline must be created paused — gsap.timeline({ paused: true }) — before ' +
150
- 'registration; a registered timeline is driven exclusively by the clock and must never also ' +
151
- 'be advancing under its own real-time playback',
152
- 'not-paused',
153
- );
154
- }
155
-
156
- let seenForClock = registeredByClock.get(clock);
157
- if (!seenForClock) {
158
- seenForClock = new WeakSet();
159
- registeredByClock.set(clock, seenForClock);
160
- }
161
- if (seenForClock.has(timeline)) {
162
- throw new GsapRegistrationError(
163
- '[registerGsap] this timeline is already registered against this clock — dispose() the prior ' +
164
- 'registration before registering it again',
165
- 'duplicate',
166
- );
167
- }
168
- seenForClock.add(timeline);
169
-
170
- const phase = options.phase ?? 'animation';
171
- const handle: ClockEvaluatorHandle = clock.subscribe(phase, () => {
172
- timeline.time(clock.time);
173
- });
174
-
175
- let disposed = false;
176
- return {
177
- dispose(): void {
178
- if (disposed) return;
179
- disposed = true;
180
- handle.dispose();
181
- seenForClock!.delete(timeline);
182
- },
183
- };
184
- }
@@ -1,290 +0,0 @@
1
- /**
2
- * W3c — first-party `AudioAdapter` INTROSPECTION over the engine's own audio
3
- * stack (the engine is its own first adapter). `attachAudioIntrospection`
4
- * augments the mute-only adapter `createAudioSystemAdapter` built with the
5
- * optional read-only debugger capabilities the editor's Audio tab consumes:
6
- *
7
- * - `graphSnapshot()` — the engine bus hierarchy (`setup-audio.ts`'s
8
- * music/sfx/voice → master → destination, plus the THREE listener tap)
9
- * and every Tone node a game routed through `connectToneBusToMasterGain`
10
- * (recorded at that seam — see `tone-context.ts`). Tone-INTERNAL wiring
11
- * (a game's private synth chains) is not visible from here and is not
12
- * fabricated; a game wanting richer graphs registers its own adapter.
13
- * - `transportState()` — Tone's real transport (`Tone.getTransport()`),
14
- * reported ONLY when the game bridged Tone onto this world's context
15
- * (`hasToneBridge`); otherwise an honest `null` ("no musical transport"),
16
- * and Tone's global context is never instantiated just by asking.
17
- * - `acquireMeters()` — per-bus linear-RMS levels via native `AnalyserNode`
18
- * taps (the "Tone.Meter or equivalent": analysers work identically
19
- * whether or not Tone is bridged, and disposal is a plain disconnect).
20
- * Returns `null` on a headless world (no real `createAnalyser`).
21
- * - `audioEvents()` — bounded seq-fenced ring fed by REAL observation
22
- * points only: Tone transport start/stop/pause emitter events (attached
23
- * lazily once a bridge exists), native context `statechange`, and the
24
- * adapter's own mute/unmute calls.
25
- *
26
- * SQ-4 boundary: this module is the READ-ONLY debugger seam. The full mixer
27
- * (sends/effects/per-bus gain EDITING, FMOD-class adaptive-audio authoring)
28
- * waits on SQ-4 and is deliberately absent here.
29
- *
30
- * Uses Tone directly (no wrapper). The EDITOR never imports this module or
31
- * Tone — it consumes only `SystemAdapters.audio` (mirror of the networking
32
- * rule). Wired by `vgai-scene-game-adapter.ts` via dynamic import so `tone`
33
- * stays out of the mount-critical chunk, with the returned disposer called
34
- * from `disposeGame` (no leaked transport listeners across mounts — the
35
- * Tone transport is a singleton that outlives any one world).
36
- */
37
-
38
- import * as Tone from 'tone';
39
- import type {
40
- AudioAdapter,
41
- AudioDebugEvent,
42
- AudioGraphNode,
43
- AudioMeterFrame,
44
- AudioMeterHandle,
45
- AudioTransportState,
46
- } from '../adapter/system-adapter';
47
- import type { AudioContext as GameAudio } from '../setup/setup-audio';
48
- import { getToneMasterGainRoutes, hasToneBridge } from './tone-context';
49
-
50
- /** Ring capacity — mirrors the debug-event ring's bounded-drop-oldest idiom. */
51
- const EVENT_RING_CAP = 200;
52
-
53
- function now(): number {
54
- return typeof performance !== 'undefined' ? performance.now() : Date.now();
55
- }
56
-
57
- function ctorName(value: object): string {
58
- const name = (value as { constructor?: { name?: string } }).constructor?.name;
59
- return name && name !== 'Object' ? name : 'AudioNode';
60
- }
61
-
62
- /**
63
- * Attach the W3c introspection capabilities to `adapter` (mutating it — the
64
- * same object stays registered on `SystemAdapters.audio`) and return a
65
- * disposer that detaches every listener and releases any meter handle still
66
- * alive. Idempotent per adapter object is NOT required: call once per mount.
67
- */
68
- export function attachAudioIntrospection(adapter: AudioAdapter, audio: GameAudio): () => void {
69
- // --- event ring -----------------------------------------------------------
70
- const ring: AudioDebugEvent[] = [];
71
- let nextSeq = 1;
72
- const record = (kind: AudioDebugEvent['kind'], detail?: string): void => {
73
- const event: AudioDebugEvent = { seq: nextSeq++, time: now(), kind };
74
- if (detail !== undefined) event.detail = detail;
75
- ring.push(event);
76
- if (ring.length > EVENT_RING_CAP) ring.splice(0, ring.length - EVENT_RING_CAP);
77
- };
78
-
79
- // The native context, when this world has a real one (headless worlds use
80
- // a plain stand-in whose `listener.context` may be absent).
81
- const nativeContext = (): (BaseAudioContext & Partial<AudioContext>) | null => {
82
- const context = (audio.listener as { context?: BaseAudioContext } | undefined)?.context;
83
- return context && typeof context.state === 'string'
84
- ? (context as BaseAudioContext & Partial<AudioContext>)
85
- : null;
86
- };
87
-
88
- /** Tone is CURRENTLY bridged onto this world's context. `hasToneBridge`
89
- * gates first so `Tone.getContext()` is only consulted when a Tone global
90
- * context already exists (never fabricating one — see tone-context.ts). */
91
- const isBridged = (): boolean => {
92
- const context = nativeContext();
93
- if (!context || !hasToneBridge(context as AudioContext)) return false;
94
- try {
95
- return Tone.getContext().rawContext === context;
96
- } catch {
97
- return false; // no constructible Tone context in this environment (Node)
98
- }
99
- };
100
-
101
- // --- mute/unmute events: wrap the adapter's OWN entry point ---------------
102
- const baseSetMuted = adapter.setMuted.bind(adapter);
103
- adapter.setMuted = (muted: boolean): void => {
104
- baseSetMuted(muted);
105
- record(muted ? 'mute' : 'unmute');
106
- };
107
-
108
- // --- context statechange --------------------------------------------------
109
- const context = nativeContext();
110
- const onStateChange = (): void => {
111
- record('context-statechange', nativeContext()?.state ?? 'unknown');
112
- };
113
- if (context && typeof context.addEventListener === 'function') {
114
- context.addEventListener('statechange', onStateChange);
115
- }
116
-
117
- // --- transport events (lazy: the game bridges Tone DURING its setup, after
118
- // this adapter is seeded — so hooks attach on the first capability read
119
- // that finds a live bridge) ------------------------------------------
120
- let transportHooked = false;
121
- const onTransportStart = (): void => record('transport-start');
122
- const onTransportStop = (): void => record('transport-stop');
123
- const onTransportPause = (): void => record('transport-pause');
124
- const ensureTransportHooks = (): void => {
125
- if (transportHooked || !isBridged()) return;
126
- transportHooked = true;
127
- const transport = Tone.getTransport();
128
- transport.on('start', onTransportStart);
129
- transport.on('stop', onTransportStop);
130
- transport.on('pause', onTransportPause);
131
- // A game typically bridges Tone and starts its transport DURING setup(),
132
- // before anything polls this adapter — that start predates the listeners
133
- // above. If the transport is already running at hook time, record that
134
- // truthful observation (marked as such) so the log's first entry isn't a
135
- // hole where the session's defining event happened.
136
- if (transport.state === 'started') {
137
- record('transport-start', 'already running when observed');
138
- }
139
- };
140
-
141
- // --- capabilities ---------------------------------------------------------
142
-
143
- adapter.graphSnapshot = (): AudioGraphNode[] => {
144
- const nodes: AudioGraphNode[] = [];
145
- const contextState = nativeContext()?.state;
146
- const destination: AudioGraphNode = {
147
- id: 'destination',
148
- type: 'AudioDestinationNode',
149
- label: 'Output',
150
- outputs: [],
151
- };
152
- if (contextState !== undefined) destination.state = contextState;
153
- nodes.push(destination);
154
- nodes.push({
155
- id: 'master',
156
- type: ctorName(audio.masterGain),
157
- label: 'Master',
158
- outputs: ['destination'],
159
- });
160
- nodes.push({
161
- id: 'bus-music',
162
- type: ctorName(audio.buses.music),
163
- label: 'Music bus',
164
- outputs: ['master'],
165
- });
166
- nodes.push({
167
- id: 'bus-sfx',
168
- type: ctorName(audio.buses.sfx),
169
- label: 'SFX bus',
170
- outputs: ['master'],
171
- });
172
- nodes.push({
173
- id: 'bus-voice',
174
- type: ctorName(audio.buses.voice),
175
- label: 'Voice bus',
176
- outputs: ['master'],
177
- });
178
- if (audio.listener) {
179
- nodes.push({
180
- id: 'listener',
181
- type: ctorName(audio.listener),
182
- label: '3D listener (THREE)',
183
- outputs: ['master'],
184
- });
185
- }
186
- // Tone nodes routed into THIS world's master bus (seam-recorded; disposed
187
- // nodes are pruned by the registry itself).
188
- getToneMasterGainRoutes(audio.masterGain).forEach((toneNode, index) => {
189
- nodes.push({
190
- id: `tone-${index}`,
191
- // Tone nodes carry their own class name (`'Gain'`, `'Volume'`…).
192
- type: (toneNode as { name?: string }).name ?? ctorName(toneNode),
193
- label: `Tone route ${index + 1}`,
194
- outputs: ['master'],
195
- });
196
- });
197
- return nodes;
198
- };
199
-
200
- adapter.transportState = (): AudioTransportState | null => {
201
- ensureTransportHooks();
202
- if (!isBridged()) return null; // honest: no musical transport in this world
203
- const transport = Tone.getTransport();
204
- return {
205
- state: transport.state,
206
- seconds: transport.seconds,
207
- bpm: transport.bpm.value,
208
- position: String(transport.position),
209
- };
210
- };
211
-
212
- const liveMeterHandles = new Set<AudioMeterHandle>();
213
- adapter.acquireMeters = (): AudioMeterHandle | null => {
214
- const meterContext = nativeContext();
215
- if (!meterContext || typeof meterContext.createAnalyser !== 'function') {
216
- return null; // headless world — metering is impossible, and says so
217
- }
218
- const taps = (
219
- [
220
- ['master', 'Master', audio.masterGain],
221
- ['bus-music', 'Music', audio.buses.music],
222
- ['bus-sfx', 'SFX', audio.buses.sfx],
223
- ['bus-voice', 'Voice', audio.buses.voice],
224
- ] as const
225
- ).map(([id, label, gain]) => {
226
- const analyser = meterContext.createAnalyser();
227
- analyser.fftSize = 1024;
228
- gain.connect(analyser); // a TAP (analyser is a sink), not an insert
229
- return { id, label, gain, analyser, buffer: new Float32Array(analyser.fftSize) };
230
- });
231
- let disposed = false;
232
- const handle: AudioMeterHandle = {
233
- read: (): AudioMeterFrame[] =>
234
- taps.map(({ id, label, analyser, buffer }) => {
235
- analyser.getFloatTimeDomainData(buffer);
236
- let sum = 0;
237
- for (let i = 0; i < buffer.length; i++) {
238
- const sample = buffer[i] as number;
239
- sum += sample * sample;
240
- }
241
- return { id, label, level: Math.sqrt(sum / buffer.length) };
242
- }),
243
- dispose: (): void => {
244
- if (disposed) return;
245
- disposed = true;
246
- for (const tap of taps) {
247
- try {
248
- tap.gain.disconnect(tap.analyser);
249
- } catch {
250
- // already torn down with the context — nothing to release
251
- }
252
- }
253
- liveMeterHandles.delete(handle);
254
- },
255
- };
256
- liveMeterHandles.add(handle);
257
- return handle;
258
- };
259
-
260
- adapter.audioEvents = (sinceSeq?: number): AudioDebugEvent[] => {
261
- ensureTransportHooks();
262
- return sinceSeq !== undefined ? ring.filter((e) => e.seq > sinceSeq) : [...ring];
263
- };
264
-
265
- // --- teardown -------------------------------------------------------------
266
- return (): void => {
267
- if (transportHooked) {
268
- try {
269
- const transport = Tone.getTransport();
270
- transport.off('start', onTransportStart);
271
- transport.off('stop', onTransportStop);
272
- transport.off('pause', onTransportPause);
273
- } catch {
274
- // Tone context already closed — the listeners died with it
275
- }
276
- transportHooked = false;
277
- }
278
- if (context && typeof context.removeEventListener === 'function') {
279
- context.removeEventListener('statechange', onStateChange);
280
- }
281
- // Safety net: a consumer should dispose its own handle, but Stop must
282
- // never leak analyser taps regardless.
283
- for (const handle of [...liveMeterHandles]) handle.dispose();
284
- adapter.setMuted = baseSetMuted;
285
- delete adapter.graphSnapshot;
286
- delete adapter.transportState;
287
- delete adapter.acquireMeters;
288
- delete adapter.audioEvents;
289
- };
290
- }