@vgai/engine 0.5.13 → 0.5.14

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 (192) hide show
  1. package/dist/adapter/adapter-module.d.ts +447 -0
  2. package/dist/adapter/adapter-module.d.ts.map +1 -0
  3. package/dist/adapter/adapter-module.js +308 -0
  4. package/dist/adapter/authoring.d.ts +357 -10
  5. package/dist/adapter/authoring.d.ts.map +1 -1
  6. package/dist/adapter/authoring.js +87 -1
  7. package/dist/adapter/constraint.d.ts +9 -3
  8. package/dist/adapter/constraint.d.ts.map +1 -1
  9. package/dist/adapter/finders/finder-result.d.ts +28 -0
  10. package/dist/adapter/finders/finder-result.d.ts.map +1 -0
  11. package/dist/adapter/finders/finder-result.js +10 -0
  12. package/dist/adapter/finders/index.d.ts +38 -0
  13. package/dist/adapter/finders/index.d.ts.map +1 -0
  14. package/dist/adapter/finders/index.js +41 -0
  15. package/dist/adapter/finders/prefabs-from-stories.d.ts +64 -0
  16. package/dist/adapter/finders/prefabs-from-stories.d.ts.map +1 -0
  17. package/dist/adapter/finders/prefabs-from-stories.js +92 -0
  18. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +67 -0
  19. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -0
  20. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +226 -0
  21. package/dist/adapter/first-party-systems.d.ts.map +1 -1
  22. package/dist/adapter/first-party-systems.js +29 -0
  23. package/dist/adapter/index.d.ts +7 -3
  24. package/dist/adapter/index.d.ts.map +1 -1
  25. package/dist/adapter/index.js +5 -2
  26. package/dist/adapter/ingest/contract-system-adapters.d.ts +24 -1
  27. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
  28. package/dist/adapter/ingest/contract-system-adapters.js +100 -34
  29. package/dist/adapter/ingest/game-contract.d.ts +188 -2
  30. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  31. package/dist/adapter/ingest/game-contract.js +107 -0
  32. package/dist/adapter/ingest/mount-readiness.d.ts +84 -0
  33. package/dist/adapter/ingest/mount-readiness.d.ts.map +1 -0
  34. package/dist/adapter/ingest/mount-readiness.js +81 -0
  35. package/dist/adapter/ingest/scene-capture.d.ts +91 -2
  36. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  37. package/dist/adapter/ingest/scene-capture.js +110 -18
  38. package/dist/adapter/ingest/structural-ids.d.ts +11 -5
  39. package/dist/adapter/ingest/structural-ids.d.ts.map +1 -1
  40. package/dist/adapter/ingest/structural-ids.js +18 -9
  41. package/dist/adapter/ingest/upstream-pin.d.ts +4 -2
  42. package/dist/adapter/ingest/upstream-pin.d.ts.map +1 -1
  43. package/dist/adapter/ingest/upstream-pin.js +4 -2
  44. package/dist/adapter/ingest/visible-capture-window.d.ts +116 -0
  45. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -0
  46. package/dist/adapter/ingest/visible-capture-window.js +141 -0
  47. package/dist/adapter/loop-gate-report.d.ts +3 -4
  48. package/dist/adapter/loop-gate-report.d.ts.map +1 -1
  49. package/dist/adapter/loop-gate-report.js +3 -4
  50. package/dist/adapter/root-adapter.d.ts +22 -2
  51. package/dist/adapter/root-adapter.d.ts.map +1 -1
  52. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  53. package/dist/adapter/setup-three-root-adapter.js +15 -0
  54. package/dist/adapter/system-adapter.d.ts +161 -2
  55. package/dist/adapter/system-adapter.d.ts.map +1 -1
  56. package/dist/adapter/system-adapter.js +18 -1
  57. package/dist/animation/camera-ownership.d.ts +4 -2
  58. package/dist/animation/camera-ownership.d.ts.map +1 -1
  59. package/dist/animation/camera-ownership.js +58 -1
  60. package/dist/animation/runtime-inspection.d.ts +70 -0
  61. package/dist/animation/runtime-inspection.d.ts.map +1 -0
  62. package/dist/animation/runtime-inspection.js +25 -0
  63. package/dist/animation/xstate-animation-binding.d.ts +9 -1
  64. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  65. package/dist/animation/xstate-animation-binding.js +48 -1
  66. package/dist/asset-formats/material.d.ts +1 -1
  67. package/dist/asset-formats/mesh.d.ts +1 -1
  68. package/dist/asset-formats/particles.d.ts +13 -0
  69. package/dist/asset-formats/particles.d.ts.map +1 -1
  70. package/dist/asset-formats/particles.js +28 -0
  71. package/dist/behavior/xstate-inspection.d.ts +24 -0
  72. package/dist/behavior/xstate-inspection.d.ts.map +1 -0
  73. package/dist/behavior/xstate-inspection.js +25 -0
  74. package/dist/core/game-loop.d.ts +7 -5
  75. package/dist/core/game-loop.d.ts.map +1 -1
  76. package/dist/core/game-loop.js +39 -18
  77. package/dist/core/types.d.ts +11 -12
  78. package/dist/core/types.d.ts.map +1 -1
  79. package/dist/data/data-asset.js +1 -1
  80. package/dist/dev/instruments.d.ts.map +1 -1
  81. package/dist/dev/instruments.js +5 -1
  82. package/dist/dev/render-debug-adapter.d.ts +19 -2
  83. package/dist/dev/render-debug-adapter.d.ts.map +1 -1
  84. package/dist/dev/render-debug-adapter.js +10 -0
  85. package/dist/ecs/user-data.d.ts +10 -2
  86. package/dist/ecs/user-data.d.ts.map +1 -1
  87. package/dist/ecs/user-data.js +6 -2
  88. package/dist/input/binding-identity.d.ts +10 -0
  89. package/dist/input/binding-identity.d.ts.map +1 -0
  90. package/dist/input/binding-identity.js +36 -0
  91. package/dist/input/input-manager.d.ts +18 -185
  92. package/dist/input/input-manager.d.ts.map +1 -1
  93. package/dist/input/input-manager.js +78 -68
  94. package/dist/manifest/load.d.ts +14 -0
  95. package/dist/manifest/load.d.ts.map +1 -1
  96. package/dist/manifest/load.js +6 -1
  97. package/dist/manifest/schema.d.ts +18 -0
  98. package/dist/manifest/schema.d.ts.map +1 -1
  99. package/dist/manifest/schema.js +64 -7
  100. package/dist/pixi/authoring.d.ts +27 -0
  101. package/dist/pixi/authoring.d.ts.map +1 -1
  102. package/dist/pixi/authoring.js +10 -3
  103. package/dist/pixi/index.d.ts +1 -1
  104. package/dist/pixi/index.d.ts.map +1 -1
  105. package/dist/pixi/index.js +1 -1
  106. package/dist/pixi/ingest.d.ts +8 -0
  107. package/dist/pixi/ingest.d.ts.map +1 -1
  108. package/dist/pixi/ingest.js +10 -1
  109. package/dist/pixi/render-pass-bracket.d.ts +56 -0
  110. package/dist/pixi/render-pass-bracket.d.ts.map +1 -0
  111. package/dist/pixi/render-pass-bracket.js +98 -0
  112. package/dist/pixi/scene-capture.d.ts +32 -1
  113. package/dist/pixi/scene-capture.d.ts.map +1 -1
  114. package/dist/pixi/scene-capture.js +21 -9
  115. package/dist/pixi/system-adapters.d.ts +29 -0
  116. package/dist/pixi/system-adapters.d.ts.map +1 -1
  117. package/dist/pixi/system-adapters.js +27 -0
  118. package/dist/render/particles-factory.d.ts +16 -2
  119. package/dist/render/particles-factory.d.ts.map +1 -1
  120. package/dist/render/particles-factory.js +88 -5
  121. package/dist/render/soft-particle-depth.d.ts +149 -0
  122. package/dist/render/soft-particle-depth.d.ts.map +1 -0
  123. package/dist/render/soft-particle-depth.js +305 -0
  124. package/dist/render/voxel-surface.d.ts +117 -0
  125. package/dist/render/voxel-surface.d.ts.map +1 -0
  126. package/dist/render/voxel-surface.js +263 -0
  127. package/dist/runtime/create-runtime.js +7 -0
  128. package/dist/runtime/debug-bridge.d.ts +15 -15
  129. package/dist/runtime/debug-bridge.d.ts.map +1 -1
  130. package/dist/runtime/debug-bridge.js +27 -26
  131. package/dist/runtime/game.d.ts.map +1 -1
  132. package/dist/runtime/game.js +1 -2
  133. package/dist/setup/setup-particles.d.ts.map +1 -1
  134. package/dist/setup/setup-particles.js +9 -0
  135. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  136. package/dist/world3d-react/r3f-adapter.js +42 -3
  137. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  138. package/dist/world3d-react/rapier-physics-bridge.js +201 -11
  139. package/dist-config/config.js.map +1 -1
  140. package/package.json +4 -2
  141. package/schemas/engine-capabilities.json +12 -12
  142. package/schemas/vgai-project.schema.json +41 -2
  143. package/src/adapter/adapter-module.ts +607 -0
  144. package/src/adapter/authoring.ts +440 -10
  145. package/src/adapter/constraint.ts +10 -3
  146. package/src/adapter/finders/finder-result.ts +29 -0
  147. package/src/adapter/finders/index.ts +62 -0
  148. package/src/adapter/finders/prefabs-from-stories.ts +142 -0
  149. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +304 -0
  150. package/src/adapter/first-party-systems.ts +30 -0
  151. package/src/adapter/index.ts +32 -1
  152. package/src/adapter/ingest/contract-system-adapters.ts +116 -32
  153. package/src/adapter/ingest/game-contract.ts +265 -2
  154. package/src/adapter/ingest/mount-readiness.ts +140 -0
  155. package/src/adapter/ingest/scene-capture.ts +199 -22
  156. package/src/adapter/ingest/structural-ids.ts +21 -9
  157. package/src/adapter/ingest/upstream-pin.ts +4 -2
  158. package/src/adapter/ingest/visible-capture-window.ts +213 -0
  159. package/src/adapter/loop-gate-report.ts +3 -4
  160. package/src/adapter/root-adapter.ts +22 -2
  161. package/src/adapter/setup-three-root-adapter.ts +16 -0
  162. package/src/adapter/system-adapter.ts +188 -2
  163. package/src/animation/camera-ownership.ts +68 -3
  164. package/src/animation/runtime-inspection.ts +104 -0
  165. package/src/animation/xstate-animation-binding.ts +53 -1
  166. package/src/asset-formats/particles.ts +31 -0
  167. package/src/behavior/xstate-inspection.ts +51 -0
  168. package/src/core/game-loop.ts +39 -17
  169. package/src/core/types.ts +11 -12
  170. package/src/data/data-asset.ts +1 -1
  171. package/src/dev/instruments.ts +5 -1
  172. package/src/dev/render-debug-adapter.ts +22 -2
  173. package/src/ecs/user-data.ts +10 -2
  174. package/src/input/binding-identity.ts +37 -0
  175. package/src/input/input-manager.ts +93 -68
  176. package/src/manifest/load.ts +23 -2
  177. package/src/manifest/schema.ts +76 -7
  178. package/src/pixi/authoring.ts +37 -5
  179. package/src/pixi/index.ts +2 -0
  180. package/src/pixi/ingest.ts +15 -2
  181. package/src/pixi/render-pass-bracket.ts +117 -0
  182. package/src/pixi/scene-capture.ts +63 -13
  183. package/src/pixi/system-adapters.ts +41 -0
  184. package/src/render/particles-factory.ts +89 -6
  185. package/src/render/soft-particle-depth.ts +336 -0
  186. package/src/render/voxel-surface.ts +358 -0
  187. package/src/runtime/create-runtime.ts +7 -0
  188. package/src/runtime/debug-bridge.ts +32 -31
  189. package/src/runtime/game.ts +1 -2
  190. package/src/setup/setup-particles.ts +9 -0
  191. package/src/world3d-react/r3f-adapter.tsx +48 -2
  192. package/src/world3d-react/rapier-physics-bridge.tsx +249 -11
@@ -62,6 +62,13 @@ export type ResolvedAdapter =
62
62
  readonly assets: Record<string, string> | undefined;
63
63
  readonly domStubs: string[] | undefined;
64
64
  readonly captureTimeoutMs: number | undefined;
65
+ /**
66
+ * The component this root'''s WORLD is, in the game'''s own source (see the
67
+ * field'''s schema description). Present ⇒ Edit mounts THAT component
68
+ * alone as a still design-time document and Play mounts the game
69
+ * through `entry`; absent ⇒ `entry` is what both modes mount.
70
+ */
71
+ readonly world?: { readonly entry: string; readonly export?: string | undefined } | undefined;
65
72
  };
66
73
 
67
74
  export interface ResolvedAdapterRoot {
@@ -86,7 +93,16 @@ export interface ResolvedGameManifest {
86
93
  readonly engine: { readonly version: string };
87
94
  /** Sorted by zOrder; ties broken by original array order (§3). */
88
95
  readonly roots: readonly ResolvedAdapterRoot[];
89
- readonly server: { readonly room: string; readonly module: string } | undefined;
96
+ readonly server:
97
+ | {
98
+ readonly room: string;
99
+ readonly module: string;
100
+ /** The declared endpoint, or `undefined` = the scaffold default (see
101
+ * the schema's own description; the editor resolves it against the
102
+ * page host, which the loader has no business knowing). */
103
+ readonly endpoint: string | undefined;
104
+ }
105
+ | undefined;
90
106
  readonly resolution: { readonly width: number; readonly height: number } | undefined;
91
107
  /** Construction-time renderer properties no world can declare for itself — see the schema. */
92
108
  readonly rendering: { readonly antialias: boolean } | undefined;
@@ -218,6 +234,7 @@ function resolveAdapter(root: AdapterRoot): ResolvedAdapter {
218
234
  assets: ingest.assets,
219
235
  domStubs: ingest.domStubs,
220
236
  captureTimeoutMs: ingest.captureTimeoutMs,
237
+ world: ingest.world,
221
238
  };
222
239
  }
223
240
 
@@ -312,7 +329,11 @@ export function loadGameManifest(raw: unknown): ResolvedGameManifest {
312
329
  const roots = resolvedWithIndex.map(({ root }) => root);
313
330
 
314
331
  const server = manifest.server
315
- ? { room: manifest.server.room, module: manifest.server.module ?? DEFAULT_SERVER_MODULE }
332
+ ? {
333
+ room: manifest.server.room,
334
+ module: manifest.server.module ?? DEFAULT_SERVER_MODULE,
335
+ endpoint: manifest.server.endpoint,
336
+ }
316
337
  : undefined;
317
338
 
318
339
  return {
@@ -154,8 +154,9 @@ export const RootAdapterSchema = z
154
154
  'game`s entry has had ITS relative imports rewritten to whatever url the server ' +
155
155
  'considers canonical for those files. Module identity is per-url, so the shim ' +
156
156
  'silently binds a SECOND, freshly-evaluated copy of the whole game and every ' +
157
- 'provider reports a game that never started. Measured on three-descent: 285 ' +
158
- 'segments through the game`s graph, 0 through the shim`s, and no error anywhere.',
157
+ 'provider reports a game that never started. Measured on a vendored three ' +
158
+ 'game: 285 world segments through the game`s own graph, 0 through the shim`s, ' +
159
+ 'and no error anywhere.',
159
160
  ),
160
161
  dataWriter: z
161
162
  .string()
@@ -163,17 +164,18 @@ export const RootAdapterSchema = z
163
164
  .describe(
164
165
  "Project-relative path to a HOST-ADDED ES module that writes this game's own " +
165
166
  'DATA file back — the sibling of a source write for a game whose authorable ' +
166
- 'truth is not source. Descent places its robots, powerups and hostages in ' +
167
- 'binary records inside `descent.hog`, reaching no source literal, so an ' +
167
+ 'truth is not source. A level-based game places its enemies, pickups and ' +
168
+ 'objectives in binary records inside a level file, reaching no source ' +
169
+ 'literal, so an ' +
168
170
  'authored move of one is an edit to that file and nothing else can write it. ' +
169
171
  'The module declares two exports and the host knows nothing else about it: ' +
170
172
  '`dataFile` (a project-relative string — the ONE file this writer edits) and ' +
171
173
  '`planDataEdit(bytes, {record, property, baseline, next})`, which returns ' +
172
174
  '`{changed: true, bytes}` or `{changed: false, reason}` and may be async. ' +
173
175
  'It is loaded in the editor realm beside the game, so it may `import()` the ' +
174
- "game's own modules to resolve what only a running game knows (Descent's " +
175
- 'writer asks the game`s own `find_point_seg` which segment a moved object ' +
176
- 'landed in, and refuses "outside the mine" rather than writing data the game ' +
176
+ "game's own modules to resolve what only a running game knows (a level " +
177
+ 'writer asks the game`s own spatial lookup which cell a moved object ' +
178
+ 'landed in, and refuses "outside the level" rather than writing data the game ' +
177
179
  'would read back wrong). It NEVER fabricates: a record it cannot address, or ' +
178
180
  'a property it does not model, is a named refusal. ' +
179
181
  'An object is anchored to a record by carrying `userData.vgaiRecordIndex` — ' +
@@ -186,6 +188,23 @@ export const RootAdapterSchema = z
186
188
  .describe(
187
189
  'Path-substring -> served-URL rewrites for this ingested game (IngestGame.assets today)',
188
190
  ),
191
+ moduleAliases: z
192
+ .record(z.string(), z.string())
193
+ .optional()
194
+ .describe(
195
+ "The game's OWN bundler path aliases, restated as a mounting fact so its " +
196
+ 'source resolves unedited. A game built with its own Vite/webpack config ' +
197
+ 'routinely imports itself through an alias (`~/lib/config`, `@/store/x`); the ' +
198
+ "editor boots Vite from the ENGINE's config and never reads a game's own, so " +
199
+ 'without this every such import is an unresolvable specifier and the mount dies ' +
200
+ 'at transform time. Key = the specifier PREFIX exactly as the game writes it ' +
201
+ '("~/"); value = the repo-root-relative directory it means ' +
202
+ '("/vendor/games/<id>/src/"). SELF-SCOPING: the host honors an entry only for ' +
203
+ "importers inside the vendored game folder the value points into, so one game's " +
204
+ "alias can never capture another module's import. Rewriting the game's own " +
205
+ 'imports instead would be a large non-seam-shaped diff against unmodified ' +
206
+ 'source — this declares the fact rather than editing it away.',
207
+ ),
189
208
  domStubs: z
190
209
  .array(z.string())
191
210
  .optional()
@@ -200,6 +219,34 @@ export const RootAdapterSchema = z
200
219
  'FAILS by name. A game that needs a long boot (streamed world assets, a menu ' +
201
220
  'the player must clear) raises it; the default is 10s.',
202
221
  ),
222
+ world: z
223
+ .object({
224
+ entry: z
225
+ .string()
226
+ .describe(
227
+ "Project-relative path to the game's OWN module exporting that component",
228
+ ),
229
+ export: z
230
+ .string()
231
+ .optional()
232
+ .describe(
233
+ 'Named export to read from that module; omit when the module default-exports it',
234
+ ),
235
+ })
236
+ .strict()
237
+ .optional()
238
+ .describe(
239
+ "The component this root's WORLD is, in the game's own source — a three root " +
240
+ 'whose scene is a React/R3F component tree names it here. Two readers, and ' +
241
+ 'they mount different things: the editor mounts THIS component alone as the ' +
242
+ "root's design-time document (a still scene — its frameloop runs ONE bounded " +
243
+ 'settle at mount, so physics-owned bodies rest at their authored spawn, and ' +
244
+ 'is then frozen forever; nothing moves in Edit), while `entry` ' +
245
+ 'above stays what PLAY mounts (the whole game, its own composition, its own ' +
246
+ 'HUD and loop). Declaring it is what lets a scene be a scene in Edit rather ' +
247
+ "than the running game's mount surfaces. A game whose world is not a " +
248
+ 'component omits it and keeps mounting through `entry` in both modes.',
249
+ ),
203
250
  })
204
251
  .strict()
205
252
  .describe(
@@ -246,6 +293,19 @@ export const AdapterRootSchema = z
246
293
  'Module exporting a world root — a TSX/R3F component or a setup()/adapter (three), or a ' +
247
294
  'React component (dom). Required for every built-in adapter root.',
248
295
  ),
296
+ styles: z
297
+ .array(z.string())
298
+ .optional()
299
+ .describe(
300
+ "Project-root-relative CSS files this root's mount needs — the PAGE-level stylesheet a " +
301
+ 'game written to own a browser tab keeps its layout in (an ingested game is the ' +
302
+ 'standing case: its own entry imported the sheet, the host shim that replaces that ' +
303
+ 'entry cannot, because `html`/`body`/`*` rules would restyle the editor). Declared ' +
304
+ 'here, the editor serves each file rewritten to apply ONLY inside the container it ' +
305
+ "mounts the root's DOM in (`@scope`; packages/editor/server/scoped-game-css.ts), so " +
306
+ "the HUD is styled on the UI board's story cards and on the game surface without the " +
307
+ 'editor seeing a single one of those rules. The files themselves are never modified.',
308
+ ),
249
309
  zOrder: z
250
310
  .number()
251
311
  .int()
@@ -481,6 +541,15 @@ export const GameManifestSchema = z
481
541
  server: z
482
542
  .object({
483
543
  room: z.string().describe('Colyseus room name'),
544
+ endpoint: z
545
+ .string()
546
+ .optional()
547
+ .describe(
548
+ 'WebSocket endpoint the editor reports for this room, e.g. "wss://rooms.example.com". ' +
549
+ 'Omit to declare the scaffold default, `ws://<the page host>:2567` — the port ' +
550
+ "`server/colyseus-setup.ts` listens on. Stated here because the endpoint is the game's " +
551
+ 'own deployment fact, not something the editor may guess',
552
+ ),
484
553
  module: z
485
554
  .string()
486
555
  .optional()
@@ -49,16 +49,47 @@ function kindOf(o: Container): string {
49
49
  return 'container';
50
50
  }
51
51
 
52
+ /**
53
+ * WHERE A ROW'S ID COMES FROM — the identity axis of the canvas authoring
54
+ * surface.
55
+ *
56
+ * The tree walk is the same whatever authored the tree; what differs is
57
+ * whether the display objects carry an authored address. A first-party
58
+ * `@pixi/react` world's containers do (the `data-oid` the editor's transform
59
+ * stamps, which the reconciler lands on the instance), and keying on it is
60
+ * what makes selection survive a remount. A tree the editor did NOT author has
61
+ * no such address, so its ids are structural paths — stable for as long as the
62
+ * tree's shape is, which is the best that graph can honestly offer.
63
+ *
64
+ * Both are the SAME adapter over the SAME surface; only this collaborator
65
+ * changes. {@link STRUCTURAL_CANVAS_IDENTITY} is the default.
66
+ */
67
+ export interface CanvasIdentity {
68
+ /** Called once at the start of every walk — where an implementation that
69
+ * disambiguates repeats (one JSX element rendered ninety times) resets its
70
+ * per-walk counters. */
71
+ beginWalk?(): void;
72
+ /** The id for one display object at one position in the tree. */
73
+ idFor(object: Container, path: readonly number[]): string;
74
+ }
75
+
76
+ /** Deterministic structural-path ids: `w2d:<path>:<kind>:<label>`. */
77
+ export const STRUCTURAL_CANVAS_IDENTITY: CanvasIdentity = {
78
+ idFor: (object, path) => `w2d:${path.join('/')}:${kindOf(object)}:${object.label ?? ''}`,
79
+ };
80
+
52
81
  export class AuthoringAdapter2D {
53
82
  private readonly byId = new Map<string, Container>();
54
83
  private overlay: Overlay2D = {};
55
84
  private dirty = false;
85
+ private readonly identity: CanvasIdentity;
56
86
 
57
87
  constructor(
58
88
  private readonly root: Container,
59
- opts: { overlay?: Overlay2D } = {},
89
+ opts: { overlay?: Overlay2D; identity?: CanvasIdentity } = {},
60
90
  ) {
61
91
  if (opts.overlay) this.overlay = opts.overlay;
92
+ this.identity = opts.identity ?? STRUCTURAL_CANVAS_IDENTITY;
62
93
  this.refresh();
63
94
  }
64
95
 
@@ -73,20 +104,21 @@ export class AuthoringAdapter2D {
73
104
  */
74
105
  reindex(): { count: number; sprites: number } {
75
106
  this.byId.clear();
107
+ this.identity.beginWalk?.();
76
108
  let count = 0;
77
109
  let sprites = 0;
78
- const visit = (o: Container, path: string): void => {
79
- const id = `w2d:${path}:${kindOf(o)}:${o.label ?? ''}`;
110
+ const visit = (o: Container, path: readonly number[]): void => {
111
+ const id = this.identity.idFor(o, path);
80
112
  (o as Container & { __authId?: string }).__authId = id;
81
113
  this.byId.set(id, o);
82
114
  count++;
83
115
  if (kindOf(o).includes('sprite')) sprites++;
84
116
  o.children.forEach((c, i) => {
85
- visit(c as Container, `${path}/${i}`);
117
+ visit(c as Container, [...path, i]);
86
118
  });
87
119
  };
88
120
  this.root.children.forEach((c, i) => {
89
- visit(c as Container, `${i}`);
121
+ visit(c as Container, [i]);
90
122
  });
91
123
  return { count, sprites };
92
124
  }
package/src/pixi/index.ts CHANGED
@@ -10,10 +10,12 @@
10
10
 
11
11
  export {
12
12
  AuthoringAdapter2D,
13
+ type CanvasIdentity,
13
14
  type EditorNode2D,
14
15
  type Overlay2D,
15
16
  type Override2D,
16
17
  type Property2D,
18
+ STRUCTURAL_CANVAS_IDENTITY,
17
19
  type Transform2DValue,
18
20
  } from './authoring';
19
21
  export { type IngestGame2D, type IngestMount2D, mountIngestGame2D } from './ingest';
@@ -1,3 +1,4 @@
1
+ import type { VisibleCaptureWindow } from '../adapter/ingest/visible-capture-window';
1
2
  import { installSceneCapture2D, type SceneCapture2DHandle } from './scene-capture';
2
3
 
3
4
  /** A registered unmodified PixiJS game (the Pixi analog of `IngestGame`). */
@@ -25,11 +26,20 @@ export interface IngestMount2D {
25
26
  * instance, runs the game's `load()`, waits for the game to render its first
26
27
  * frame (capturing its live stage), and exposes loop gating + cleanup. A game
27
28
  * whose pixi the trap cannot reach never captures, and the mount FAILS by name.
29
+ *
30
+ * `onWait` is the three lane's own seam (`ingest-root-adapter.ts` passes
31
+ * `setCaptureWait`): the capture window PARKS while the tab is hidden, and a
32
+ * parked wait is otherwise indistinguishable from a hung mount at every door.
33
+ * Threading it here is what lets `vgai status` say "waiting for the first
34
+ * visible frame — the tab is hidden" over a canvas ingest too.
28
35
  */
29
36
  export async function mountIngestGame2D(
30
37
  pixiNamespace: unknown,
31
38
  game: IngestGame2D,
32
- opts: { captureTimeoutMs?: number } = {},
39
+ opts: {
40
+ captureTimeoutMs?: number;
41
+ onWait?: (wait: VisibleCaptureWindow | null) => void;
42
+ } = {},
33
43
  ): Promise<IngestMount2D> {
34
44
  const capture = installSceneCapture2D(pixiNamespace);
35
45
  try {
@@ -41,7 +51,10 @@ export async function mountIngestGame2D(
41
51
 
42
52
  const timeout = opts.captureTimeoutMs ?? game.captureTimeoutMs ?? 8000;
43
53
  try {
44
- const rt = await capture.waitForCapture(timeout);
54
+ const rt = await capture.waitForCapture({
55
+ timeoutMs: timeout,
56
+ ...(opts.onWait ? { onWait: opts.onWait } : {}),
57
+ });
45
58
  return {
46
59
  stage: rt.stage,
47
60
  capture,
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The render-pass BRACKET on the Pixi surface — the canvas-substrate answer to
3
+ * the question the three lane answers with the render accessor-trap's
4
+ * `setRenderPassHooks` (`adapter/ingest/scene-capture.ts`): *when does this
5
+ * renderer's pass begin and end?*
6
+ *
7
+ * ## Why the renderer's own runners, and not another wrap
8
+ *
9
+ * Pixi's `AbstractRenderer.render()` emits five runners in a fixed order —
10
+ * `prerender → renderStart → render → renderEnd → postrender` (verified in the
11
+ * installed pixi.js 8.19,
12
+ * `rendering/renderers/shared/system/AbstractRenderer.mjs`). EVERY GL draw the
13
+ * pass issues happens between the first and the last: the batcher draws under
14
+ * `render`, and even the back-buffer blit, the one draw that is easy to miss,
15
+ * runs under `renderStart`/`renderEnd` (`gl/GlBackBufferSystem.mjs`). So a
16
+ * listener added to `prerender` and `postrender` brackets the whole pass using
17
+ * the renderer's OWN published seam — no instance shadowing, no prototype
18
+ * patch, no ordering assumption beyond the one Pixi itself guarantees.
19
+ *
20
+ * `SystemRunner.add(item)` binds by METHOD NAME (`item[runnerName]` must
21
+ * exist) and `remove(item)` takes it back off, so installing and uninstalling
22
+ * is symmetric and touches nothing else on the renderer.
23
+ *
24
+ * ## Honest absence
25
+ *
26
+ * A renderer that publishes no such runners is not bracketed by guesswork:
27
+ * this returns `null`, and the caller treats that exactly like a missing
28
+ * WebGL2 context — the capability is absent, never faked.
29
+ */
30
+
31
+ import type { RenderPassHooks } from '../adapter/ingest/scene-capture';
32
+
33
+ /** The slice of a Pixi renderer this reads — structural, so a headless test
34
+ * hands in a plain object with two runners and nothing else. */
35
+ interface RunnerLike {
36
+ add(item: unknown): unknown;
37
+ remove(item: unknown): unknown;
38
+ }
39
+
40
+ interface RendererWithRunners {
41
+ runners?: {
42
+ prerender?: RunnerLike;
43
+ postrender?: RunnerLike;
44
+ };
45
+ }
46
+
47
+ function runnerPair(renderer: unknown): { pre: RunnerLike; post: RunnerLike } | null {
48
+ if (!renderer || typeof renderer !== 'object') return null;
49
+ const runners = (renderer as RendererWithRunners).runners;
50
+ const pre = runners?.prerender;
51
+ const post = runners?.postrender;
52
+ if (!pre || typeof pre.add !== 'function' || typeof pre.remove !== 'function') return null;
53
+ if (!post || typeof post.add !== 'function' || typeof post.remove !== 'function') return null;
54
+ return { pre, post };
55
+ }
56
+
57
+ /**
58
+ * Bracket a Pixi renderer's render pass with `hooks.before`/`hooks.after`.
59
+ *
60
+ * Returns the uninstall function, or `null` when this renderer publishes no
61
+ * `prerender`/`postrender` runners — the honest "cannot bracket this", which
62
+ * the caller reads as "no render-debug capability here".
63
+ *
64
+ * Neither hook may throw — the same contract the three lane's `RenderPassHooks`
65
+ * carries, and for a sharper reason here: these run as listeners on the
66
+ * renderer's own runners, so a throwing `before` aborts the game's frame AND
67
+ * skips the `postrender` emit that would have closed the pass. The assembly
68
+ * point (`ingest/ingest-render-debug.ts`) is where that contract is kept.
69
+ */
70
+ export function installPixiRenderPassBracket(
71
+ renderer: unknown,
72
+ hooks: RenderPassHooks,
73
+ ): (() => void) | null {
74
+ const pair = runnerPair(renderer);
75
+ if (!pair) return null;
76
+
77
+ // One object per runner: `SystemRunner.add` binds by method name, so the
78
+ // `prerender` listener and the `postrender` listener cannot be the same
79
+ // object unless it answers to both — keeping them separate makes each
80
+ // `remove()` exact.
81
+ const preListener = {
82
+ prerender() {
83
+ hooks.before();
84
+ },
85
+ };
86
+ const postListener = {
87
+ postrender() {
88
+ hooks.after();
89
+ },
90
+ };
91
+
92
+ pair.pre.add(preListener);
93
+ pair.post.add(postListener);
94
+
95
+ let uninstalled = false;
96
+ return () => {
97
+ if (uninstalled) return;
98
+ uninstalled = true;
99
+ pair.pre.remove(preListener);
100
+ pair.post.remove(postListener);
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The captured Pixi renderer's real WebGL2 context, or `undefined`.
106
+ *
107
+ * `WebGLRenderer.gl` is the renderer's own published field — `GlContextSystem`
108
+ * assigns it (`this._renderer.gl = gl`,
109
+ * `rendering/renderers/gl/context/GlContextSystem.mjs`) as part of
110
+ * initialisation, and `WebGLRenderer.d.ts` declares it. A WebGPU renderer, or
111
+ * a renderer that has not initialised a context, simply has none — which is the
112
+ * answer, not an error.
113
+ */
114
+ export function pixiRenderingContext(renderer: unknown): unknown {
115
+ if (!renderer || typeof renderer !== 'object') return undefined;
116
+ return (renderer as { gl?: unknown }).gl;
117
+ }
@@ -18,6 +18,27 @@
18
18
  * all, and its mount fails by name.
19
19
  */
20
20
 
21
+ import {
22
+ documentVisibilityClock,
23
+ startVisibleCaptureWindow,
24
+ type VisibilityClock,
25
+ type VisibleCaptureWindow,
26
+ } from '../adapter/ingest/visible-capture-window';
27
+
28
+ /**
29
+ * Options for {@link SceneCapture2DHandle.waitForCapture} — the same shape the
30
+ * three lane's `CaptureWaitOptions` has, because it is the same wait. Passing a
31
+ * bare number is `{ timeoutMs }`, which is what every existing caller does.
32
+ */
33
+ export interface CaptureWait2DOptions {
34
+ /** The capture window, in VISIBLE milliseconds (default 10s). */
35
+ timeoutMs?: number | undefined;
36
+ /** Injected in tests; defaults to the document's own visibility. */
37
+ visibility?: VisibilityClock | undefined;
38
+ /** Live view of the wait while it runs, `null` the moment it ends. */
39
+ onWait?: ((wait: VisibleCaptureWindow | null) => void) | undefined;
40
+ }
41
+
21
42
  /** A live Pixi runtime captured from an external PixiJS game. */
22
43
  export interface CapturedRuntime2D {
23
44
  /** The game's root stage Container. */
@@ -30,7 +51,24 @@ export interface CapturedRuntime2D {
30
51
 
31
52
  export interface SceneCapture2DHandle {
32
53
  readonly captured: CapturedRuntime2D | null;
33
- waitForCapture(timeoutMs?: number): Promise<CapturedRuntime2D>;
54
+ /**
55
+ * Resolve once the game's stage is captured.
56
+ *
57
+ * The timeout is a budget of **VISIBLE** time, not wall-clock time — the same
58
+ * rule the three lane has had since `visible-capture-window.ts`, and for the
59
+ * same measured reason: a hidden document cannot render (the browser parks
60
+ * rAF), so counting hidden time against the game counts time it was not
61
+ * allowed to use. MEASURED on the bubbo-bubbo ingest with a plain
62
+ * `setTimeout` here: the mount succeeded when the auto-opened tab happened to
63
+ * be in front and died terminally 8s later when it was behind — "canvas
64
+ * ingest: game … rendered no capturable frame", with foregrounding the tab
65
+ * afterwards changing nothing. The normal HUMAN path is the backgrounded one.
66
+ *
67
+ * So the wait PARKS while hidden, the trap stays installed, and the first
68
+ * frame the tab draws after it comes forward is trapped exactly as it would
69
+ * have been at boot. Rejects only when the window is spent while VISIBLE.
70
+ */
71
+ waitForCapture(options?: number | CaptureWait2DOptions): Promise<CapturedRuntime2D>;
34
72
  getDrawCount(): number;
35
73
  /** Pause/resume the captured game's own ticker (loop-host gating). */
36
74
  setPaused(paused: boolean): void;
@@ -84,24 +122,36 @@ export function installSceneCapture2D(pixiNamespace: unknown): SceneCapture2DHan
84
122
  if (paused) app.ticker.stop();
85
123
  else app.ticker.start();
86
124
  },
87
- waitForCapture(timeoutMs = 10_000) {
125
+ waitForCapture(options) {
126
+ const opts: CaptureWait2DOptions =
127
+ typeof options === 'number' ? { timeoutMs: options } : (options ?? {});
128
+ const timeoutMs = opts.timeoutMs ?? 10_000;
88
129
  if (captured) return Promise.resolve(captured);
89
130
  return new Promise<CapturedRuntime2D>((resolve, reject) => {
90
- const timer = setTimeout(() => {
91
- const i = waiters.indexOf(wrapped);
92
- if (i >= 0) waiters.splice(i, 1);
93
- reject(
94
- new Error(
95
- `pixi scene capture timed out after ${timeoutMs}ms — the game never ` +
96
- `rendered, or it bundles its own (un-shared) copy of pixi.js.`,
97
- ),
98
- );
99
- }, timeoutMs);
131
+ const captureWindow = startVisibleCaptureWindow({
132
+ budgetMs: timeoutMs,
133
+ clock: opts.visibility ?? documentVisibilityClock(),
134
+ onExpire: () => {
135
+ const i = waiters.indexOf(wrapped);
136
+ if (i >= 0) waiters.splice(i, 1);
137
+ opts.onWait?.(null);
138
+ reject(
139
+ new Error(
140
+ `pixi scene capture timed out after ${timeoutMs}ms of VISIBLE time ` +
141
+ `(${Math.round(captureWindow.elapsedHiddenMs())}ms hidden, which is not counted — ` +
142
+ 'a hidden tab cannot render) — the game never rendered, or it bundles its own ' +
143
+ '(un-shared) copy of pixi.js.',
144
+ ),
145
+ );
146
+ },
147
+ });
100
148
  const wrapped = (rt: CapturedRuntime2D) => {
101
- clearTimeout(timer);
149
+ captureWindow.cancel();
150
+ opts.onWait?.(null);
102
151
  resolve(rt);
103
152
  };
104
153
  waiters.push(wrapped);
154
+ opts.onWait?.(captureWindow);
105
155
  });
106
156
  },
107
157
  uninstall() {
@@ -6,8 +6,20 @@ import type { Physics2DRegistry } from './physics-registry';
6
6
  * Lets the editor coordinate editing of a Rapier-2D-driven entity WITHOUT owning the
7
7
  * simulation: report transform ownership, then freeze → commit → unfreeze so a drag
8
8
  * sticks instead of being stomped by the next physics step.
9
+ *
10
+ * KEYED BY THE DISPLAY OBJECT, and that is this surface's own vocabulary rather
11
+ * than a shortcut: the canvas gizmo's real caller
12
+ * (`editor/src/authoring/pixi-live-write-target.ts`) resolves node id → display
13
+ * itself before it asks, so there is no node-id hop on this lane at all.
14
+ *
15
+ * `keyedBy` is REQUIRED here and optional on the 3D {@link PhysicsAdapter} —
16
+ * see that interface's doc comment for why the union has to be tagged, and why
17
+ * the newcomer is the shape obliged to say so.
9
18
  */
10
19
  export interface PhysicsAdapter2D {
20
+ /** Discriminates this shape from the node-id-keyed {@link PhysicsAdapter}
21
+ * inside `SystemAdapters['physics']`. */
22
+ readonly keyedBy: 'display';
11
23
  ownerOf(display: Container): 'physics' | 'none';
12
24
  freeze(display: Container): void;
13
25
  commit(display: Container, position: [number, number], rotation: number): void;
@@ -18,6 +30,7 @@ export function createPhysicsAdapter2D(physics: Physics2DRegistry): PhysicsAdapt
18
30
  // Remember each frozen body's prior type so unfreeze restores it.
19
31
  const frozen = new WeakMap<Container, number>();
20
32
  return {
33
+ keyedBy: 'display',
21
34
  ownerOf(display) {
22
35
  const refs = physics.get(display);
23
36
  if (!refs) return 'none';
@@ -47,3 +60,31 @@ export function createPhysicsAdapter2D(physics: Physics2DRegistry): PhysicsAdapt
47
60
  },
48
61
  };
49
62
  }
63
+
64
+ /**
65
+ * ONE carrier over several, each owning a DIFFERENT set of display objects.
66
+ *
67
+ * The canvas ingest mount is the case: the host always builds a registry-backed
68
+ * carrier for objects the editor itself created, and an ingested game may ALSO
69
+ * declare one over its own simulation's objects. Neither is a superset of the
70
+ * other and neither can answer for the other's objects, so the composition is
71
+ * ownership-routed rather than layered — `ownerOf` asks each in order and the
72
+ * FIRST to claim the object handles every other call for it. A display object
73
+ * nobody claims answers `'none'` and the freeze/commit/unfreeze trio no-ops,
74
+ * which is exactly what a single unclaiming carrier already does.
75
+ *
76
+ * Order is precedence: earlier carriers win a (never-expected) double claim,
77
+ * so the caller states its own preference instead of this function inventing
78
+ * one.
79
+ */
80
+ export function composePhysicsAdapters2D(carriers: readonly PhysicsAdapter2D[]): PhysicsAdapter2D {
81
+ const ownerFor = (display: Container): PhysicsAdapter2D | null =>
82
+ carriers.find((carrier) => carrier.ownerOf(display) === 'physics') ?? null;
83
+ return {
84
+ keyedBy: 'display',
85
+ ownerOf: (display) => (ownerFor(display) ? 'physics' : 'none'),
86
+ freeze: (display) => ownerFor(display)?.freeze(display),
87
+ commit: (display, position, rotation) => ownerFor(display)?.commit(display, position, rotation),
88
+ unfreeze: (display) => ownerFor(display)?.unfreeze(display),
89
+ };
90
+ }