@vgai/engine 0.5.15 → 0.5.17

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 (220) hide show
  1. package/dist/adapter/adapter-module.d.ts +229 -33
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +222 -50
  4. package/dist/adapter/authoring-seam-contract.d.ts +530 -0
  5. package/dist/adapter/authoring-seam-contract.d.ts.map +1 -0
  6. package/dist/adapter/authoring-seam-contract.js +162 -0
  7. package/dist/adapter/authoring.d.ts +237 -50
  8. package/dist/adapter/authoring.d.ts.map +1 -1
  9. package/dist/adapter/authoring.js +2 -1
  10. package/dist/adapter/entrypoint-selection-source.d.ts +35 -0
  11. package/dist/adapter/entrypoint-selection-source.d.ts.map +1 -0
  12. package/dist/adapter/entrypoint-selection-source.js +151 -0
  13. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +7 -3
  14. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -1
  15. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +145 -27
  16. package/dist/adapter/first-party-systems.d.ts +13 -3
  17. package/dist/adapter/first-party-systems.d.ts.map +1 -1
  18. package/dist/adapter/first-party-systems.js +10 -3
  19. package/dist/adapter/index.d.ts +7 -2
  20. package/dist/adapter/index.d.ts.map +1 -1
  21. package/dist/adapter/index.js +4 -0
  22. package/dist/adapter/ingest/contract-system-adapters.d.ts +1 -1
  23. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
  24. package/dist/adapter/ingest/contract-system-adapters.js +13 -25
  25. package/dist/adapter/ingest/game-contract-seams.d.ts +214 -0
  26. package/dist/adapter/ingest/game-contract-seams.d.ts.map +1 -0
  27. package/dist/adapter/ingest/game-contract-seams.js +62 -0
  28. package/dist/adapter/ingest/game-contract.d.ts +5 -3
  29. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  30. package/dist/adapter/ingest/game-contract.js +4 -1
  31. package/dist/adapter/ingest/merge-debug-adapters.d.ts +52 -0
  32. package/dist/adapter/ingest/merge-debug-adapters.d.ts.map +1 -0
  33. package/dist/adapter/ingest/merge-debug-adapters.js +141 -0
  34. package/dist/adapter/ingest/observation-debug-adapter.d.ts +56 -0
  35. package/dist/adapter/ingest/observation-debug-adapter.d.ts.map +1 -0
  36. package/dist/adapter/ingest/observation-debug-adapter.js +131 -0
  37. package/dist/adapter/ingest/scene-capture.d.ts +38 -13
  38. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  39. package/dist/adapter/ingest/scene-capture.js +101 -24
  40. package/dist/adapter/ingest/visible-capture-window.d.ts +22 -5
  41. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -1
  42. package/dist/adapter/ingest/visible-capture-window.js +46 -7
  43. package/dist/adapter/native-debug-module.d.ts +43 -0
  44. package/dist/adapter/native-debug-module.d.ts.map +1 -0
  45. package/dist/adapter/native-debug-module.js +230 -0
  46. package/dist/adapter/object3d-authoring-subject.d.ts +32 -0
  47. package/dist/adapter/object3d-authoring-subject.d.ts.map +1 -0
  48. package/dist/adapter/object3d-authoring-subject.js +25 -0
  49. package/dist/adapter/root-adapter.d.ts +25 -56
  50. package/dist/adapter/root-adapter.d.ts.map +1 -1
  51. package/dist/adapter/root-adapter.js +4 -20
  52. package/dist/adapter/root-seam-contract.d.ts +129 -0
  53. package/dist/adapter/root-seam-contract.d.ts.map +1 -0
  54. package/dist/adapter/root-seam-contract.js +37 -0
  55. package/dist/adapter/seam-evidence.d.ts +118 -0
  56. package/dist/adapter/seam-evidence.d.ts.map +1 -0
  57. package/dist/adapter/seam-evidence.js +231 -0
  58. package/dist/adapter/setup-three-root-adapter.d.ts +2 -3
  59. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  60. package/dist/adapter/setup-three-root-adapter.js +11 -12
  61. package/dist/adapter/system-adapter.d.ts +6 -1
  62. package/dist/adapter/system-adapter.d.ts.map +1 -1
  63. package/dist/adapter/system-seam-contract.d.ts +354 -0
  64. package/dist/adapter/system-seam-contract.d.ts.map +1 -0
  65. package/dist/adapter/system-seam-contract.js +95 -0
  66. package/dist/animation/xstate-animation-binding.d.ts +6 -0
  67. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  68. package/dist/animation/xstate-animation-binding.js +142 -4
  69. package/dist/asset-formats/collider.d.ts +1 -1
  70. package/dist/asset-formats/mesh.d.ts +2 -2
  71. package/dist/asset-formats/particles.d.ts +1 -1
  72. package/dist/canvas-react/engine-bridge.d.ts +6 -6
  73. package/dist/canvas-react/engine-bridge.d.ts.map +1 -1
  74. package/dist/canvas-react/engine-bridge.js +2 -2
  75. package/dist/canvas-react/index.d.ts +2 -2
  76. package/dist/canvas-react/index.d.ts.map +1 -1
  77. package/dist/canvas-react/index.js +1 -1
  78. package/dist/canvas-react/pixi-react-adapter.d.ts +15 -9
  79. package/dist/canvas-react/pixi-react-adapter.d.ts.map +1 -1
  80. package/dist/canvas-react/pixi-react-adapter.js +129 -64
  81. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  82. package/dist/canvas-react/pixi-react-root-factory.js +4 -0
  83. package/dist/canvas-react/world-context.d.ts +22 -12
  84. package/dist/canvas-react/world-context.d.ts.map +1 -1
  85. package/dist/canvas-react/world-context.js +12 -51
  86. package/dist/data/curve.d.ts +1 -1
  87. package/dist/data/curve.js +2 -2
  88. package/dist/dev/instruments.d.ts +4 -4
  89. package/dist/dev/instruments.js +4 -4
  90. package/dist/dev/register-render-vitals.d.ts +2 -2
  91. package/dist/dev/register-render-vitals.js +2 -2
  92. package/dist/dev/static-batch-advisor.d.ts +1 -1
  93. package/dist/dev/static-batch-advisor.js +2 -2
  94. package/dist/ecs/user-data.d.ts +5 -0
  95. package/dist/ecs/user-data.d.ts.map +1 -1
  96. package/dist/ecs/user-data.js +3 -0
  97. package/dist/input/input-manager.d.ts +64 -0
  98. package/dist/input/input-manager.d.ts.map +1 -1
  99. package/dist/input/input-manager.js +149 -0
  100. package/dist/manifest/load.d.ts +0 -3
  101. package/dist/manifest/load.d.ts.map +1 -1
  102. package/dist/manifest/load.js +0 -4
  103. package/dist/manifest/schema.d.ts +9 -11
  104. package/dist/manifest/schema.d.ts.map +1 -1
  105. package/dist/manifest/schema.js +2 -23
  106. package/dist/pixi/authoring.d.ts +33 -0
  107. package/dist/pixi/authoring.d.ts.map +1 -1
  108. package/dist/pixi/authoring.js +301 -2
  109. package/dist/pixi/ingest.d.ts +2 -1
  110. package/dist/pixi/ingest.d.ts.map +1 -1
  111. package/dist/pixi/ingest.js +4 -2
  112. package/dist/pixi/scene-capture.d.ts +9 -1
  113. package/dist/pixi/scene-capture.d.ts.map +1 -1
  114. package/dist/pixi/scene-capture.js +9 -6
  115. package/dist/react/world-state.d.ts +12 -0
  116. package/dist/react/world-state.d.ts.map +1 -1
  117. package/dist/react/world-state.js +14 -0
  118. package/dist/render/particles-factory.d.ts +13 -6
  119. package/dist/render/particles-factory.d.ts.map +1 -1
  120. package/dist/render/particles-factory.js +6 -3
  121. package/dist/render/soft-particle-depth.d.ts +3 -3
  122. package/dist/render/soft-particle-depth.d.ts.map +1 -1
  123. package/dist/render/soft-particle-depth.js +8 -13
  124. package/dist/runtime/create-runtime.d.ts +25 -62
  125. package/dist/runtime/create-runtime.d.ts.map +1 -1
  126. package/dist/runtime/create-runtime.js +58 -105
  127. package/dist/runtime/dev-build.d.ts +37 -0
  128. package/dist/runtime/dev-build.d.ts.map +1 -0
  129. package/dist/runtime/dev-build.js +49 -0
  130. package/dist/runtime/game.d.ts +38 -72
  131. package/dist/runtime/game.d.ts.map +1 -1
  132. package/dist/runtime/game.js +40 -55
  133. package/dist/runtime/mount-game.d.ts.map +1 -1
  134. package/dist/runtime/mount-game.js +12 -1
  135. package/dist/runtime/mount-manifest.d.ts +0 -13
  136. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  137. package/dist/runtime/mount-manifest.js +5 -25
  138. package/dist/runtime/types.d.ts +18 -0
  139. package/dist/runtime/types.d.ts.map +1 -1
  140. package/dist/setup/setup-renderer.d.ts +3 -4
  141. package/dist/setup/setup-renderer.d.ts.map +1 -1
  142. package/dist/setup/setup-renderer.js +3 -4
  143. package/dist/world3d-react/engine-bridge.d.ts +1 -1
  144. package/dist/world3d-react/engine-bridge.d.ts.map +1 -1
  145. package/dist/world3d-react/engine-bridge.js +29 -4
  146. package/dist/world3d-react/index.d.ts +1 -1
  147. package/dist/world3d-react/index.js +1 -1
  148. package/dist/world3d-react/r3f-adapter.d.ts +7 -0
  149. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  150. package/dist/world3d-react/r3f-adapter.js +132 -28
  151. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  152. package/dist/world3d-react/r3f-root-factory.js +14 -0
  153. package/dist/world3d-react/rapier-physics-bridge.d.ts +4 -4
  154. package/dist/world3d-react/rapier-physics-bridge.js +3 -3
  155. package/dist/world3d-react/world-context.d.ts +52 -3
  156. package/dist/world3d-react/world-context.d.ts.map +1 -1
  157. package/dist/world3d-react/world-context.js +94 -64
  158. package/dist-config/config.js.map +1 -1
  159. package/package.json +1 -1
  160. package/schemas/engine-capabilities.json +8 -8
  161. package/schemas/vgai-project.schema.json +1 -6
  162. package/src/adapter/adapter-module.ts +405 -72
  163. package/src/adapter/authoring-seam-contract.ts +211 -0
  164. package/src/adapter/authoring.ts +242 -50
  165. package/src/adapter/entrypoint-selection-source.ts +159 -0
  166. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +162 -36
  167. package/src/adapter/first-party-systems.ts +17 -3
  168. package/src/adapter/index.ts +51 -1
  169. package/src/adapter/ingest/contract-system-adapters.ts +26 -30
  170. package/src/adapter/ingest/game-contract-seams.ts +85 -0
  171. package/src/adapter/ingest/game-contract.ts +9 -4
  172. package/src/adapter/ingest/merge-debug-adapters.ts +193 -0
  173. package/src/adapter/ingest/observation-debug-adapter.ts +158 -0
  174. package/src/adapter/ingest/scene-capture.ts +126 -23
  175. package/src/adapter/ingest/visible-capture-window.ts +63 -9
  176. package/src/adapter/native-debug-module.ts +307 -0
  177. package/src/adapter/object3d-authoring-subject.ts +50 -0
  178. package/src/adapter/root-adapter.ts +25 -56
  179. package/src/adapter/root-seam-contract.ts +57 -0
  180. package/src/adapter/seam-evidence.ts +336 -0
  181. package/src/adapter/setup-three-root-adapter.ts +13 -15
  182. package/src/adapter/system-adapter.ts +6 -1
  183. package/src/adapter/system-seam-contract.ts +121 -0
  184. package/src/animation/xstate-animation-binding.ts +151 -4
  185. package/src/canvas-react/engine-bridge.ts +6 -6
  186. package/src/canvas-react/index.ts +2 -1
  187. package/src/canvas-react/pixi-react-adapter.tsx +143 -77
  188. package/src/canvas-react/pixi-react-root-factory.tsx +4 -0
  189. package/src/canvas-react/world-context.ts +26 -101
  190. package/src/data/curve.ts +2 -2
  191. package/src/dev/instruments.ts +4 -4
  192. package/src/dev/register-render-vitals.ts +2 -2
  193. package/src/dev/static-batch-advisor.ts +2 -2
  194. package/src/ecs/user-data.ts +5 -0
  195. package/src/input/input-manager.ts +145 -0
  196. package/src/manifest/load.ts +0 -7
  197. package/src/manifest/schema.ts +2 -25
  198. package/src/pixi/authoring.ts +329 -2
  199. package/src/pixi/ingest.ts +9 -2
  200. package/src/pixi/scene-capture.ts +20 -5
  201. package/src/react/world-state.tsx +15 -0
  202. package/src/render/particles-factory.ts +16 -6
  203. package/src/render/soft-particle-depth.ts +8 -13
  204. package/src/runtime/create-runtime.ts +79 -158
  205. package/src/runtime/dev-build.ts +49 -0
  206. package/src/runtime/game.ts +72 -113
  207. package/src/runtime/mount-game.ts +19 -1
  208. package/src/runtime/mount-manifest.ts +5 -37
  209. package/src/runtime/types.ts +22 -0
  210. package/src/setup/setup-renderer.ts +3 -4
  211. package/src/world3d-react/engine-bridge.ts +29 -4
  212. package/src/world3d-react/index.ts +1 -1
  213. package/src/world3d-react/r3f-adapter.tsx +140 -31
  214. package/src/world3d-react/r3f-root-factory.tsx +14 -0
  215. package/src/world3d-react/rapier-physics-bridge.tsx +4 -4
  216. package/src/world3d-react/world-context.ts +111 -87
  217. package/dist/runtime/dev-layers.d.ts +0 -34
  218. package/dist/runtime/dev-layers.d.ts.map +0 -1
  219. package/dist/runtime/dev-layers.js +0 -46
  220. package/src/runtime/dev-layers.ts +0 -46
@@ -44,11 +44,264 @@ export type Overlay2D = Record<string, Override2D>;
44
44
  function kindOf(o: Container): string {
45
45
  const ctor = o.constructor?.name?.toLowerCase() ?? 'container';
46
46
  if (ctor.includes('animatedsprite')) return 'animatedsprite';
47
+ if (ctor.includes('nineslice')) return 'nineslicesprite';
48
+ if (ctor.includes('tiling')) return 'tilingsprite';
47
49
  if (ctor.includes('sprite')) return 'sprite';
48
50
  if (ctor.includes('text')) return 'text';
51
+ if (ctor.includes('graphics')) return 'graphics';
49
52
  return 'container';
50
53
  }
51
54
 
55
+ /**
56
+ * Hierarchy row text — the Pixi sibling of three's `object.name || object.type`.
57
+ *
58
+ * Authored `.label` first. Then a GAME name: a field, a bag key, or a class
59
+ * that owns this `.view`. Stock Pixi types (`Sprite`, `Graphics`, `_Container`)
60
+ * are not names — they used to win and hide every owner. Then the texture
61
+ * alias (`Sprite.from('satellite')`). Then a remaining game constructor
62
+ * (`TitleScreen`, `PrimaryButton`). Bare `new Container()` still reads as
63
+ * `container` — plus its child count when it has children, because ten
64
+ * unnamed siblings that all read `container` name nothing at all; the
65
+ * editor loads games that never labelled anything and the panel still has
66
+ * to be legible. The count is DISPLAY only (row ids keep using raw
67
+ * `.label`) and rides only this last fallthrough: a row that earned a real
68
+ * name is never decorated.
69
+ */
70
+ function displayLabel(o: Container): string {
71
+ const authored = authoredLabel(o);
72
+ if (authored) return authored;
73
+ const owner = viewOwnerLabel(o);
74
+ if (owner) return owner;
75
+ const asset = textureLabel(o);
76
+ if (asset) return asset;
77
+ const ctor = usefulCtorName(o);
78
+ if (ctor) return ctor;
79
+ const kind = kindOf(o);
80
+ const childCount = o.children?.length ?? 0;
81
+ return childCount > 0 ? `${kind} (${childCount})` : kind;
82
+ }
83
+
84
+ /**
85
+ * Pixi v8 stamps Sprite/Graphics `.label` with the class name. That is not an
86
+ * authored name.
87
+ *
88
+ * The minified-constructor guard is about the SAME leak: a shipped build's
89
+ * `_Container` becomes `e`, and that `e` is a class name wearing a label's
90
+ * clothes. It is only that leak when the label IS this object's own class
91
+ * name — `container.label = 'fx'` (or 'ui', 'hp') is a name the game
92
+ * actually authored, and a game gets to be terse.
93
+ */
94
+ function authoredLabel(o: Container): string | undefined {
95
+ const authored = o.label?.trim();
96
+ if (!authored) return undefined;
97
+ if (isMinifiedCtor(authored) && authored !== o.constructor?.name) return authored;
98
+ if (isGenericPixiCtor(authored)) return undefined;
99
+ return authored;
100
+ }
101
+
102
+ function isMinifiedCtor(name: string): boolean {
103
+ return name.length <= 2 && name === name.toLowerCase();
104
+ }
105
+
106
+ /** Pixi's own display types. The shipped build prefixes them `_`. */
107
+ const STOCK_PIXI_CTOR = new Set([
108
+ 'container',
109
+ 'displayobject',
110
+ 'sprite',
111
+ 'animatedsprite',
112
+ 'nineslicesprite',
113
+ 'tilingsprite',
114
+ 'text',
115
+ 'bitmaptext',
116
+ 'htmltext',
117
+ 'graphics',
118
+ 'mesh',
119
+ ]);
120
+
121
+ function isGenericPixiCtor(name: string): boolean {
122
+ if (isMinifiedCtor(name) || name === 'Object' || name === 'Function') return true;
123
+ return STOCK_PIXI_CTOR.has(name.replace(/^_+/, '').toLowerCase());
124
+ }
125
+
126
+ function textureLabel(object: Container): string | undefined {
127
+ const texture = (object as Container & { texture?: { label?: string } }).texture;
128
+ const raw = texture?.label?.trim();
129
+ if (!raw || /^(_?texture)?(empty|white)$/i.test(raw)) return undefined;
130
+ const leaf = raw.split('/').pop() ?? raw;
131
+ return leaf.replace(/\.[a-z0-9]+$/i, '') || undefined;
132
+ }
133
+
134
+ function usefulCtorName(value: object): string | undefined {
135
+ const name = value.constructor?.name;
136
+ if (!name || isGenericPixiCtor(name)) return undefined;
137
+ return name.replace(/^_+/, '') || undefined;
138
+ }
139
+
140
+ function fieldLabel(key: string): string | undefined {
141
+ const trimmed = key.replace(/^_+/, '');
142
+ return trimmed.length > 0 ? trimmed : undefined;
143
+ }
144
+
145
+ function isPixiDisplay(value: object): boolean {
146
+ return (
147
+ 'addChild' in value &&
148
+ typeof (value as { addChild?: unknown }).addChild === 'function' &&
149
+ 'children' in value
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Bubbo-style owner, identity only. TitleScreen keeps `this._cannon` and
155
+ * parents `this._cannon.view`. A grouping node is the field itself
156
+ * (`this._bottomAnimContainer`). A pool or atlas lives in a bag — Array,
157
+ * Map, Set, or an object literal (`_decor[]`, `allSystems`, `_parts[id]`).
158
+ * Scan each ancestor's own fields first — a flood walk of Pixi internals
159
+ * never reached `_cannon` inside the budget.
160
+ */
161
+ function viewOwnerLabel(view: Container): string | undefined {
162
+ const seen = new WeakSet<object>();
163
+ let host: unknown = view.parent;
164
+ for (let hops = 0; hops < 8 && host && typeof host === 'object'; hops++) {
165
+ const found = labelFromHolder(host, view, seen) ?? findHeldOwner(host, view, 2, seen);
166
+ if (found) return found;
167
+ host = (host as { parent?: unknown }).parent;
168
+ }
169
+ return undefined;
170
+ }
171
+
172
+ function isGameFieldKey(key: string): boolean {
173
+ // Skip display-list indices (`0`, `1`) and Pixi internals (`children`).
174
+ return /^_?[A-Za-z][A-Za-zA-Z0-9]*$/.test(key) && !PIXI_INTERNAL_KEY.has(key);
175
+ }
176
+
177
+ const PIXI_INTERNAL_KEY = new Set([
178
+ 'parent',
179
+ 'children',
180
+ 'transform',
181
+ 'position',
182
+ 'scale',
183
+ 'pivot',
184
+ 'skew',
185
+ 'worldTransform',
186
+ 'localTransform',
187
+ ]);
188
+
189
+ function labelFromHolder(host: object, view: Container, seen: WeakSet<object>): string | undefined {
190
+ if (seen.has(host)) return undefined;
191
+ seen.add(host);
192
+ const asBag = nameInBag(host, view, undefined, seen);
193
+ if (asBag) return asBag;
194
+ for (const [key, value] of Object.entries(host as Record<string, unknown>)) {
195
+ if (!isGameFieldKey(key)) continue;
196
+ const named = nameIfHoldsView(host, key, value, view);
197
+ if (named) return named;
198
+ const nested = nameInBag(value, view, key, seen);
199
+ if (nested) return nested;
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ function nameInBag(
205
+ value: unknown,
206
+ view: Container,
207
+ fieldKey: string | undefined,
208
+ seen: WeakSet<object>,
209
+ ): string | undefined {
210
+ const entries = bagEntries(value);
211
+ if (!entries) return undefined;
212
+ for (const [itemKey, item] of entries) {
213
+ const named = nameBagItem(value, item, usableBagKey(itemKey) ?? fieldKey, view, seen);
214
+ if (named) return named;
215
+ }
216
+ return undefined;
217
+ }
218
+
219
+ function nameBagItem(
220
+ bag: unknown,
221
+ item: unknown,
222
+ key: string | undefined,
223
+ view: Container,
224
+ seen: WeakSet<object>,
225
+ ): string | undefined {
226
+ if (key) {
227
+ const named = nameIfHoldsView(
228
+ item && typeof item === 'object' ? item : (bag as object),
229
+ key,
230
+ item,
231
+ view,
232
+ );
233
+ if (named) return named;
234
+ } else if (item && typeof item === 'object' && (item as { view?: unknown }).view === view) {
235
+ const named = usefulCtorName(item);
236
+ if (named) return named;
237
+ }
238
+ if (item && typeof item === 'object' && !isPixiDisplay(item)) {
239
+ return labelFromHolder(item, view, seen);
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ function usableBagKey(key: string): string | undefined {
245
+ if (!key || /^\d+$/.test(key)) return undefined;
246
+ return isGameFieldKey(key) || key.includes('-') ? (fieldLabel(key) ?? key) : undefined;
247
+ }
248
+
249
+ function bagEntries(value: unknown): readonly [string, unknown][] | undefined {
250
+ if (!value || typeof value !== 'object') return undefined;
251
+ // Game arrays are allowed to be exotic subclasses. Do not dispatch through
252
+ // their overridable `map`: one shipped Pixi game returns its elements rather
253
+ // than `[key, value]` pairs there, which made hierarchy labeling throw while
254
+ // destructuring the result. The authoring seam treats game objects as
255
+ // untrusted observations, so normalize with indexed reads into our own array.
256
+ if (Array.isArray(value)) {
257
+ const entries: [string, unknown][] = [];
258
+ for (let index = 0; index < value.length; index++) {
259
+ entries.push([String(index), value[index]]);
260
+ }
261
+ return entries;
262
+ }
263
+ if (value instanceof Map) return [...value.entries()].map(([key, item]) => [String(key), item]);
264
+ if (value instanceof Set) return [...value].map((item, index) => [String(index), item]);
265
+ if (Object.getPrototypeOf(value) !== Object.prototype) return undefined;
266
+ return Object.entries(value as Record<string, unknown>);
267
+ }
268
+
269
+ function nameIfHoldsView(
270
+ host: object,
271
+ key: string,
272
+ value: unknown,
273
+ view: Container,
274
+ ): string | undefined {
275
+ if (value === view) {
276
+ const field = fieldLabel(key);
277
+ if (field && field.toLowerCase() !== 'view') {
278
+ return usefulCtorName(value as object) ?? field;
279
+ }
280
+ return usefulCtorName(value as object) ?? usefulCtorName(host) ?? field;
281
+ }
282
+ if (value && typeof value === 'object' && (value as { view?: unknown }).view === view) {
283
+ return usefulCtorName(value) ?? fieldLabel(key);
284
+ }
285
+ return undefined;
286
+ }
287
+
288
+ function findHeldOwner(
289
+ held: object,
290
+ view: Container,
291
+ depth: number,
292
+ seen: WeakSet<object>,
293
+ ): string | undefined {
294
+ const direct = labelFromHolder(held, view, seen);
295
+ if (direct) return direct;
296
+ if (depth <= 0) return undefined;
297
+ for (const value of Object.values(held as Record<string, unknown>)) {
298
+ if (!value || typeof value !== 'object' || isPixiDisplay(value)) continue;
299
+ const found = findHeldOwner(value, view, depth - 1, seen);
300
+ if (found) return found;
301
+ }
302
+ return undefined;
303
+ }
304
+
52
305
  /**
53
306
  * WHERE A ROW'S ID COMES FROM — the identity axis of the canvas authoring
54
307
  * surface.
@@ -78,11 +331,52 @@ export const STRUCTURAL_CANVAS_IDENTITY: CanvasIdentity = {
78
331
  idFor: (object, path) => `w2d:${path.join('/')}:${kindOf(object)}:${object.label ?? ''}`,
79
332
  };
80
333
 
334
+ /** One remembered row name, with every input it was derived from. */
335
+ interface NameMemo {
336
+ readonly label: string;
337
+ readonly kind: string;
338
+ readonly parent: Container | null;
339
+ readonly type: unknown;
340
+ readonly own: string | null;
341
+ readonly texture: unknown;
342
+ readonly childCount: number;
343
+ }
344
+
81
345
  export class AuthoringAdapter2D {
82
346
  private readonly byId = new Map<string, Container>();
83
347
  private overlay: Overlay2D = {};
84
348
  private dirty = false;
85
349
  private readonly identity: CanvasIdentity;
350
+ /**
351
+ * THE ROW-NAME MEMO — what makes a hierarchy read linear instead of a
352
+ * reflective flood walk per node per read.
353
+ *
354
+ * {@link displayLabel} is not a field read. For anything the game did not
355
+ * label itself it calls `viewOwnerLabel`, which climbs eight ancestors and
356
+ * reflects over each one's own fields, its bags, and two levels of whatever
357
+ * those hold — a deliberate search for the game object that owns this
358
+ * `.view`. Measured 2026-08-20 on a 1822-node `@pixi/react` canvas world,
359
+ * with the tab VISIBLE and the world rendering, one full projection walk
360
+ * cost ~800ms, essentially all of it here: once a world has rendered, the
361
+ * ancestors' own fields include Pixi's live render-group graph, so the
362
+ * search walks that too and finds nothing, every time, for every node.
363
+ *
364
+ * And it was asked four times per node per derivation — the projection
365
+ * walk, the conformance probe and the hierarchy facet each re-ask — which
366
+ * is how a status snapshot reached 115,433 `toNode` calls on one tree.
367
+ *
368
+ * ## What invalidates a remembered name
369
+ *
370
+ * Every input the derivation reads OFF THE OBJECT: its authored `.label`,
371
+ * its class, its texture, its parent, and how many children it has. Any of
372
+ * those changing re-derives — so a rename, a reparent, a retexture, and a
373
+ * sibling added or removed are all seen. What a remembered name does NOT
374
+ * re-notice is a game reassigning an ANCESTOR's field to point at this view
375
+ * after the fact, with the tree otherwise untouched; `viewOwnerLabel` is a
376
+ * naming heuristic, not a verdict, and the alternative is the 800ms walk
377
+ * above on every read of the tree.
378
+ */
379
+ private readonly names = new WeakMap<Container, NameMemo>();
86
380
 
87
381
  constructor(
88
382
  private readonly root: Container,
@@ -143,12 +437,45 @@ export class AuthoringAdapter2D {
143
437
  return this.byId.get(id) ?? null;
144
438
  }
145
439
 
440
+ /** This object's row name, derived once and remembered until one of the
441
+ * inputs it is derived from changes. See {@link names}. */
442
+ private nameOf(o: Container): NameMemo {
443
+ const cached = this.names.get(o);
444
+ const parent = o.parent ?? null;
445
+ const type = o.constructor;
446
+ const own = o.label ?? null;
447
+ const texture = (o as Container & { texture?: unknown }).texture ?? null;
448
+ const childCount = o.children?.length ?? 0;
449
+ if (
450
+ cached &&
451
+ cached.parent === parent &&
452
+ cached.type === type &&
453
+ cached.own === own &&
454
+ cached.texture === texture &&
455
+ cached.childCount === childCount
456
+ ) {
457
+ return cached;
458
+ }
459
+ const derived: NameMemo = {
460
+ label: displayLabel(o),
461
+ kind: kindOf(o),
462
+ parent,
463
+ type,
464
+ own,
465
+ texture,
466
+ childCount,
467
+ };
468
+ this.names.set(o, derived);
469
+ return derived;
470
+ }
471
+
146
472
  private toNode(o: Container): EditorNode2D {
147
473
  const self = o as Container & { __authId?: string };
474
+ const { label, kind } = this.nameOf(o);
148
475
  return {
149
476
  id: self.__authId ?? '',
150
- label: o.label ?? kindOf(o),
151
- kind: kindOf(o),
477
+ label,
478
+ kind,
152
479
  parentId: (o.parent as (Container & { __authId?: string }) | null)?.__authId ?? null,
153
480
  childIds: o.children.map((c) => (c as Container & { __authId?: string }).__authId ?? ''),
154
481
  };
@@ -1,5 +1,9 @@
1
1
  import type { VisibleCaptureWindow } from '../adapter/ingest/visible-capture-window';
2
- import { installSceneCapture2D, type SceneCapture2DHandle } from './scene-capture';
2
+ import {
3
+ type CapturedRuntime2D,
4
+ installSceneCapture2D,
5
+ type SceneCapture2DHandle,
6
+ } from './scene-capture';
3
7
 
4
8
  /** A registered unmodified PixiJS game (the Pixi analog of `IngestGame`). */
5
9
  export interface IngestGame2D {
@@ -39,9 +43,12 @@ export async function mountIngestGame2D(
39
43
  opts: {
40
44
  captureTimeoutMs?: number;
41
45
  onWait?: (wait: VisibleCaptureWindow | null) => void;
46
+ acceptCapture?: (runtime: CapturedRuntime2D) => boolean;
42
47
  } = {},
43
48
  ): Promise<IngestMount2D> {
44
- const capture = installSceneCapture2D(pixiNamespace);
49
+ const capture = installSceneCapture2D(pixiNamespace, {
50
+ ...(opts.acceptCapture ? { accept: opts.acceptCapture } : {}),
51
+ });
45
52
  try {
46
53
  await game.load();
47
54
  } catch (err) {
@@ -79,12 +79,24 @@ interface PixiLike {
79
79
  Application: { prototype: Record<string, unknown> };
80
80
  }
81
81
 
82
+ export interface SceneCapture2DOptions {
83
+ /**
84
+ * Select the runtime owned by this mount when other Pixi Applications share
85
+ * the page (for example editor prefab previews). Rejected draws still count,
86
+ * but cannot satisfy {@link SceneCapture2DHandle.waitForCapture}.
87
+ */
88
+ accept?: ((runtime: CapturedRuntime2D) => boolean) | undefined;
89
+ }
90
+
82
91
  /**
83
92
  * Install the render trap on `pixiNamespace.Application.prototype.render`. Pass the
84
93
  * host's `pixi.js` namespace so a shared-instance game is trapped. Install once per
85
94
  * ingest session; `uninstall()` on teardown.
86
95
  */
87
- export function installSceneCapture2D(pixiNamespace: unknown): SceneCapture2DHandle {
96
+ export function installSceneCapture2D(
97
+ pixiNamespace: unknown,
98
+ options: SceneCapture2DOptions = {},
99
+ ): SceneCapture2DHandle {
88
100
  const PIXI = pixiNamespace as PixiLike;
89
101
  const proto = PIXI.Application.prototype;
90
102
 
@@ -102,8 +114,11 @@ export function installSceneCapture2D(pixiNamespace: unknown): SceneCapture2DHan
102
114
  drawCount++;
103
115
  const stage = this['stage'];
104
116
  if (!captured && stage) {
105
- captured = { stage, renderer: this['renderer'], app: this };
106
- for (const resolve of waiters.splice(0)) resolve(captured);
117
+ const candidate = { stage, renderer: this['renderer'], app: this };
118
+ if (options.accept?.(candidate) ?? true) {
119
+ captured = candidate;
120
+ for (const resolve of waiters.splice(0)) resolve(captured);
121
+ }
107
122
  }
108
123
  return realRender?.apply(this, args);
109
124
  },
@@ -138,8 +153,8 @@ export function installSceneCapture2D(pixiNamespace: unknown): SceneCapture2DHan
138
153
  reject(
139
154
  new Error(
140
155
  `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 ' +
156
+ `(${Math.round(captureWindow.elapsedHiddenMs())}ms browser-suspended, which is ` +
157
+ 'not counted because no frame can be presented) — the game never rendered, or it bundles its own ' +
143
158
  '(un-shared) copy of pixi.js.',
144
159
  ),
145
160
  );
@@ -70,6 +70,21 @@ export function useGame(): Game {
70
70
  return game;
71
71
  }
72
72
 
73
+ /**
74
+ * {@link useGame}'s non-throwing sibling: the `Game` when one hosts this tree,
75
+ * `null` otherwise.
76
+ *
77
+ * For components that legitimately mount BOTH under a running game and under a
78
+ * host that has none — the editor design-mounts a world to author it, and a
79
+ * story renders a component with no game at all. Those components must degrade
80
+ * (a dev/QA surface that simply has nothing to drive) rather than throw during
81
+ * render, which would take the whole world down with it. Everything else uses
82
+ * `useGame`, whose throw is the honest report of a wiring bug.
83
+ */
84
+ export function useOptionalGame(): Game | null {
85
+ return useContext(GameContext);
86
+ }
87
+
73
88
  /**
74
89
  * Subscribe to a selected slice of game state. Re-renders only when the
75
90
  * selected value changes: the selector re-runs at most once per completed
@@ -13,6 +13,7 @@ import {
13
13
  ApplyForce,
14
14
  ChangeEmitDirection,
15
15
  CircleEmitter,
16
+ type ColorGenerator,
16
17
  ColorGeneratorFromJSON,
17
18
  ColorOverLife,
18
19
  ConeEmitter,
@@ -20,6 +21,7 @@ import {
20
21
  type EmitterShape,
21
22
  ForceOverLife,
22
23
  FrameOverLife,
24
+ type FunctionColorGenerator,
23
25
  GravityForce,
24
26
  GridEmitter,
25
27
  HemisphereEmitter,
@@ -345,9 +347,9 @@ export interface ParticleSystemResult {
345
347
  * This is how a caller with fidelity a plain descriptor cannot express reaches
346
348
  * the SAME factory instead of hand-building its own `new ParticleSystem(...)`:
347
349
  * a foreign-engine carry (e.g. Godot CPUParticles) supplies its own
348
- * `EmitterShape` and an instancing geometry here, and expresses everything
349
- * else — lifecycle, start values, emission, behaviors, material — as descriptor
350
- * JSON.
350
+ * `EmitterShape`, start-colour generator, and instancing geometry here, and
351
+ * expresses everything else — lifecycle, scalar start values, emission,
352
+ * behaviors, material — as descriptor JSON.
351
353
  */
352
354
  export interface ParticleSystemObjects {
353
355
  /**
@@ -356,6 +358,12 @@ export interface ParticleSystemObjects {
356
358
  * present it REPLACES `data.shape`.
357
359
  */
358
360
  shape?: EmitterShape;
361
+ /**
362
+ * A pre-built start-colour generator for a per-particle draw the descriptor's
363
+ * JSON generator vocabulary cannot express. When present it REPLACES
364
+ * `data.startColor`.
365
+ */
366
+ startColor?: ColorGenerator | FunctionColorGenerator;
359
367
  /**
360
368
  * The geometry instanced per particle in `RenderMode.Mesh`. A
361
369
  * `BufferGeometry` is not JSON-serializable, so `renderMode: 'mesh'` requires
@@ -377,8 +385,9 @@ export interface ParticleSystemObjects {
377
385
  * Create a three.quarks ParticleSystem from ParticlesDescriptor JSON data.
378
386
  *
379
387
  * `objects` carries the pieces a JSON descriptor cannot: a plugin emitter
380
- * shape, a mesh-mode instancing geometry, and the default-sprite toggle. It is
381
- * optional and additive — a call with only `data` behaves exactly as before.
388
+ * shape, a start-colour generator, a mesh-mode instancing geometry, and the
389
+ * default-sprite toggle. It is optional and additive — a call with only `data`
390
+ * behaves exactly as before.
382
391
  */
383
392
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: straightforward field-by-field mapping
384
393
  export function createParticleSystemFromData(
@@ -410,7 +419,8 @@ export function createParticleSystemFromData(
410
419
  if (data.startSpeed) params['startSpeed'] = valueGen(data.startSpeed);
411
420
  if (data.startSize) params['startSize'] = valueGen(data.startSize);
412
421
  if (data.startRotation) params['startRotation'] = valueGen(data.startRotation);
413
- if (data.startColor) params['startColor'] = colorGen(data.startColor);
422
+ if (objects?.startColor) params['startColor'] = objects.startColor;
423
+ else if (data.startColor) params['startColor'] = colorGen(data.startColor);
414
424
  if (data.startTileIndex) params['startTileIndex'] = valueGen(data.startTileIndex);
415
425
 
416
426
  // Trail mode requires startLength inside rendererEmitterSettings, not as a top-level param
@@ -91,9 +91,9 @@
91
91
  * callers are `registerParticleSystem` / `unregisterParticleSystem`.
92
92
  * - The render target and override material belong to the
93
93
  * {@link SoftParticleDepthPass} instance and die with its `dispose()`. The
94
- * pass itself is owned by whichever adapter mount created it (the R3F adapter
95
- * and `setup-three-root-adapter`), and that mount's teardown is the one path
96
- * allowed to end it.
94
+ * pass is owned by the renderer loop that calls it: an adapter mount for a
95
+ * runtime renderer, or the editor Scene viewport for its renderer. That
96
+ * owner's teardown is the one path allowed to end it.
97
97
  */
98
98
 
99
99
  import * as THREE from 'three';
@@ -203,16 +203,11 @@ function drawsIntoDepth(object: THREE.Object3D): boolean {
203
203
  * The three renderer members this prepass CANNOT do without — the render-target
204
204
  * swap it draws through, and the buffer size it sizes that target from.
205
205
  *
206
- * A real `WebGLRenderer` has all of them. A DESIGN-TIME settle does not: a
207
- * stand-in that only has to answer `render` mounts a world so the editor can
208
- * read it, and reaching for `getDrawingBufferSize` on one threw
209
- * `renderer.getDrawingBufferSize is not a function` out of the first driven
210
- * frame so a world whose particles merely *declare* a proximity fade could
211
- * not be settled at all. A prepass is an EFFECT: with no renderer to run it on,
212
- * the right answer is the inert far-depth default every batch is already bound
213
- * to (see {@link farDepthTexture}), which is pixel-identical to a batch that
214
- * never asked for soft particles. So this degrades, loudly and once, instead of
215
- * taking the frame down with it.
206
+ * A real `WebGLRenderer` has all of them. A design-time stand-in that only has
207
+ * to answer `render` may mount a world so another host can read it, but cannot
208
+ * run this effect. The editor does not pass that stand-in here: its real
209
+ * viewport renderer owns its own pass. Other incomplete hosts still degrade
210
+ * loudly and once instead of taking the frame down.
216
211
  */
217
212
  const REQUIRED_RENDERER_MEMBERS = [
218
213
  'getDrawingBufferSize',