@vgai/engine 0.5.16 → 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.
- package/dist/adapter/adapter-module.d.ts +1 -16
- package/dist/adapter/adapter-module.d.ts.map +1 -1
- package/dist/adapter/adapter-module.js +1 -11
- package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +0 -2
- package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -1
- package/dist/adapter/finders/scenes-from-entrypoint-selection.js +3 -6
- package/dist/adapter/root-adapter.d.ts +4 -0
- package/dist/adapter/root-adapter.d.ts.map +1 -1
- package/dist/adapter/root-seam-contract.d.ts +5 -0
- package/dist/adapter/root-seam-contract.d.ts.map +1 -1
- package/dist/adapter/root-seam-contract.js +1 -0
- package/dist/adapter/setup-three-root-adapter.js +9 -9
- package/dist/canvas-react/pixi-react-adapter.d.ts.map +1 -1
- package/dist/canvas-react/pixi-react-adapter.js +92 -41
- package/dist/data/curve.d.ts +1 -1
- package/dist/data/curve.js +2 -2
- package/dist/dev/instruments.d.ts +4 -4
- package/dist/dev/instruments.js +4 -4
- package/dist/dev/register-render-vitals.d.ts +2 -2
- package/dist/dev/register-render-vitals.js +2 -2
- package/dist/dev/static-batch-advisor.d.ts +1 -1
- package/dist/dev/static-batch-advisor.js +2 -2
- package/dist/input/input-manager.d.ts +15 -0
- package/dist/input/input-manager.d.ts.map +1 -1
- package/dist/input/input-manager.js +22 -0
- package/dist/manifest/load.d.ts +0 -3
- package/dist/manifest/load.d.ts.map +1 -1
- package/dist/manifest/load.js +0 -4
- package/dist/manifest/schema.d.ts +0 -2
- package/dist/manifest/schema.d.ts.map +1 -1
- package/dist/manifest/schema.js +2 -23
- package/dist/pixi/authoring.d.ts +33 -0
- package/dist/pixi/authoring.d.ts.map +1 -1
- package/dist/pixi/authoring.js +86 -6
- package/dist/react/world-state.d.ts +12 -0
- package/dist/react/world-state.d.ts.map +1 -1
- package/dist/react/world-state.js +14 -0
- package/dist/render/soft-particle-depth.d.ts +3 -3
- package/dist/render/soft-particle-depth.d.ts.map +1 -1
- package/dist/render/soft-particle-depth.js +8 -13
- package/dist/runtime/create-runtime.d.ts +2 -16
- package/dist/runtime/create-runtime.d.ts.map +1 -1
- package/dist/runtime/create-runtime.js +36 -38
- package/dist/runtime/dev-build.d.ts +37 -0
- package/dist/runtime/dev-build.d.ts.map +1 -0
- package/dist/runtime/dev-build.js +49 -0
- package/dist/runtime/game.d.ts +9 -1
- package/dist/runtime/game.d.ts.map +1 -1
- package/dist/runtime/game.js +25 -1
- package/dist/runtime/mount-manifest.d.ts +0 -13
- package/dist/runtime/mount-manifest.d.ts.map +1 -1
- package/dist/runtime/mount-manifest.js +1 -20
- package/dist/world3d-react/engine-bridge.d.ts.map +1 -1
- package/dist/world3d-react/engine-bridge.js +28 -3
- package/dist/world3d-react/index.d.ts +1 -1
- package/dist/world3d-react/index.js +1 -1
- package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
- package/dist/world3d-react/r3f-adapter.js +89 -14
- package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
- package/dist/world3d-react/r3f-root-factory.js +12 -2
- package/dist/world3d-react/rapier-physics-bridge.d.ts +4 -4
- package/dist/world3d-react/rapier-physics-bridge.js +3 -3
- package/dist/world3d-react/world-context.d.ts +43 -0
- package/dist/world3d-react/world-context.d.ts.map +1 -1
- package/dist/world3d-react/world-context.js +89 -46
- package/dist-config/config.js.map +1 -1
- package/package.json +1 -1
- package/schemas/engine-capabilities.json +3 -3
- package/schemas/vgai-project.schema.json +1 -6
- package/src/adapter/adapter-module.ts +1 -25
- package/src/adapter/finders/scenes-from-entrypoint-selection.ts +3 -8
- package/src/adapter/root-adapter.ts +4 -0
- package/src/adapter/root-seam-contract.ts +1 -0
- package/src/adapter/setup-three-root-adapter.ts +9 -9
- package/src/canvas-react/pixi-react-adapter.tsx +98 -53
- package/src/data/curve.ts +2 -2
- package/src/dev/instruments.ts +4 -4
- package/src/dev/register-render-vitals.ts +2 -2
- package/src/dev/static-batch-advisor.ts +2 -2
- package/src/input/input-manager.ts +23 -0
- package/src/manifest/load.ts +0 -7
- package/src/manifest/schema.ts +2 -25
- package/src/pixi/authoring.ts +98 -6
- package/src/react/world-state.tsx +15 -0
- package/src/render/soft-particle-depth.ts +8 -13
- package/src/runtime/create-runtime.ts +38 -52
- package/src/runtime/dev-build.ts +49 -0
- package/src/runtime/game.ts +31 -2
- package/src/runtime/mount-manifest.ts +1 -32
- package/src/world3d-react/engine-bridge.ts +28 -3
- package/src/world3d-react/index.ts +1 -1
- package/src/world3d-react/r3f-adapter.tsx +87 -18
- package/src/world3d-react/r3f-root-factory.tsx +12 -2
- package/src/world3d-react/rapier-physics-bridge.tsx +4 -4
- package/src/world3d-react/world-context.ts +100 -51
- package/dist/runtime/dev-layers.d.ts +0 -34
- package/dist/runtime/dev-layers.d.ts.map +0 -1
- package/dist/runtime/dev-layers.js +0 -46
- package/src/runtime/dev-layers.ts +0 -46
|
@@ -171,21 +171,32 @@ export function createPixiReactAdapter(
|
|
|
171
171
|
return null;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
const game = runtime?.ctx.game;
|
|
175
174
|
// The React seam every dom root already has — `useWorldState`,
|
|
176
175
|
// `useDebugProvider`/`useDebugCommand`/`useDebugEmit`
|
|
177
176
|
// (`../react/world-state`) — works identically inside a canvas root
|
|
178
177
|
// because a canvas root IS React. `host.game` is genuinely absent in
|
|
179
178
|
// bare/foreign hosts, so the provider is conditional and those hooks
|
|
180
179
|
// stay the inert no-ops they already document themselves to be.
|
|
180
|
+
//
|
|
181
|
+
// READ FROM `host.game`, NEVER FROM `runtime` — the same rule, and the
|
|
182
|
+
// same reasoning, as the three lane's (`../world3d-react/r3f-adapter.tsx`,
|
|
183
|
+
// beside its own `const game = host.game`). The two providers answer
|
|
184
|
+
// different questions: `EngineBridge` rides `bridge` (the vgai runtime
|
|
185
|
+
// CONTEXT, which must not enter an ecosystem-native tree), `WorldProvider`
|
|
186
|
+
// rides the Game handle the host already holds. Keyed off `runtime`, a
|
|
187
|
+
// world mounted with `engineRuntime: false` gets no `WorldProvider` at
|
|
188
|
+
// all and every `useDebugProvider` in it no-ops IN SILENCE — no error, and
|
|
189
|
+
// `vgai eval 'await game.providers()'` lists only the built-ins. That is
|
|
190
|
+
// exactly what happened on the three lane. It has never fired here only
|
|
191
|
+
// because `pixi-react-root-factory.tsx` still leaves the runtime on;
|
|
192
|
+
// that flag is the tracked component-root migration's to flip, and this
|
|
193
|
+
// seam must not be waiting for it.
|
|
194
|
+
const game = host.game;
|
|
181
195
|
const world = createElement(Fragment, null, content, createElement(Lifecycle));
|
|
196
|
+
const provided = game ? createElement(WorldProvider, { game }, world) : world;
|
|
182
197
|
const element = bridge
|
|
183
|
-
? createElement(
|
|
184
|
-
|
|
185
|
-
{ value: bridge },
|
|
186
|
-
game ? createElement(WorldProvider, { game }, world) : world,
|
|
187
|
-
)
|
|
188
|
-
: world;
|
|
198
|
+
? createElement(EngineBridge.Provider, { value: bridge }, provided)
|
|
199
|
+
: provided;
|
|
189
200
|
|
|
190
201
|
const root: PixiReactRoot = createRoot(canvas);
|
|
191
202
|
|
|
@@ -207,50 +218,74 @@ export function createPixiReactAdapter(
|
|
|
207
218
|
sharedTicker: false,
|
|
208
219
|
};
|
|
209
220
|
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
221
|
+
// Install the reconcile-error listener BEFORE `root.render`: Pixi hands
|
|
222
|
+
// the Application back after `init()` but React can already have thrown
|
|
223
|
+
// during that same turn. Attaching after the await loses the real error
|
|
224
|
+
// and turns it into a misleading ten-second timeout.
|
|
225
|
+
let reportReconcileFailure!: (why: string) => void;
|
|
226
|
+
const reconcileFailure = new Promise<string>((resolve) => {
|
|
227
|
+
reportReconcileFailure = resolve;
|
|
228
|
+
});
|
|
229
|
+
const onError = (event: ErrorEvent): void => {
|
|
230
|
+
reportReconcileFailure(
|
|
231
|
+
`the Pixi tree crashed before its first commit — ${event.message} ` +
|
|
232
|
+
'(mount() fails loudly instead of hanging)',
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
globalThis.addEventListener?.('error', onError);
|
|
215
236
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
237
|
+
let app: Application | undefined;
|
|
238
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
239
|
+
try {
|
|
240
|
+
// `root.render` is async: it awaits `Application.init()` (which is what
|
|
241
|
+
// creates the renderer) and only then hands the tree to the reconciler.
|
|
242
|
+
// A crash inside init rejects here, which is the loud failure we want.
|
|
243
|
+
const renderedApp = await root.render(element, initOptions);
|
|
244
|
+
app = renderedApp;
|
|
245
|
+
runtime?.setApplication(renderedApp);
|
|
246
|
+
|
|
247
|
+
// A reconcile-time crash (e.g. an un-extended component) surfaces as
|
|
248
|
+
// an uncaught window error and the first commit never lands. Race that
|
|
249
|
+
// exact signal against the commit and the last-resort ceiling.
|
|
250
|
+
const timeout = new Promise<string>((resolve) => {
|
|
251
|
+
timer = setTimeout(
|
|
252
|
+
() =>
|
|
253
|
+
resolve(
|
|
254
|
+
`no first commit within ${FIRST_COMMIT_TIMEOUT_MS / 1000}s ` +
|
|
255
|
+
'(mount() fails loudly instead of hanging)',
|
|
256
|
+
),
|
|
257
|
+
FIRST_COMMIT_TIMEOUT_MS,
|
|
235
258
|
);
|
|
236
|
-
const timer = setTimeout(
|
|
237
|
-
() =>
|
|
238
|
-
fail(
|
|
239
|
-
`no first commit within ${FIRST_COMMIT_TIMEOUT_MS / 1000}s ` +
|
|
240
|
-
'(mount() fails loudly instead of hanging)',
|
|
241
|
-
),
|
|
242
|
-
FIRST_COMMIT_TIMEOUT_MS,
|
|
243
|
-
);
|
|
244
|
-
const cleanup = () => {
|
|
245
|
-
clearTimeout(timer);
|
|
246
|
-
globalThis.removeEventListener?.('error', onError);
|
|
247
|
-
};
|
|
248
|
-
globalThis.addEventListener?.('error', onError);
|
|
249
|
-
void Promise.all([committed, effectsReady]).then(() => {
|
|
250
|
-
cleanup();
|
|
251
|
-
resolve();
|
|
252
259
|
});
|
|
253
|
-
|
|
260
|
+
const failure = await Promise.race([
|
|
261
|
+
Promise.all([committed, effectsReady]).then(() => null),
|
|
262
|
+
reconcileFailure,
|
|
263
|
+
timeout,
|
|
264
|
+
]);
|
|
265
|
+
if (failure !== null) {
|
|
266
|
+
throw new Error(`createPixiReactAdapter: root "${id}" — ${failure}`);
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
try {
|
|
270
|
+
app?.destroy(false, { children: true });
|
|
271
|
+
} catch {
|
|
272
|
+
/* the world never fully came up; teardown failure must not mask why */
|
|
273
|
+
}
|
|
274
|
+
runtime?.dispose();
|
|
275
|
+
throw error;
|
|
276
|
+
} finally {
|
|
277
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
278
|
+
globalThis.removeEventListener?.('error', onError);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// `app` is assigned before the first-commit race. Reaching here proves
|
|
282
|
+
// both initialization and commit succeeded, so narrow once for the live
|
|
283
|
+
// mounted-root methods below.
|
|
284
|
+
if (app === undefined) {
|
|
285
|
+
runtime?.dispose();
|
|
286
|
+
throw new Error(`createPixiReactAdapter: root "${id}" initialized without an Application`);
|
|
287
|
+
}
|
|
288
|
+
const mountedApp = app;
|
|
254
289
|
|
|
255
290
|
// Pin the ticker's clock to GAME time. `Ticker.lastTime` starts at -1, so
|
|
256
291
|
// without this the first `update()` would report a 1ms phantom delta on
|
|
@@ -258,23 +293,27 @@ export function createPixiReactAdapter(
|
|
|
258
293
|
// sees is exactly the `dt` the host handed us — no wall clock anywhere,
|
|
259
294
|
// which is what makes a paused world's resume frame ordinary rather than
|
|
260
295
|
// one giant catch-up step.
|
|
261
|
-
|
|
296
|
+
mountedApp.ticker.lastTime = 0;
|
|
262
297
|
let elapsedMs = 0;
|
|
263
298
|
let disposed = false;
|
|
264
299
|
const registry = host.game ? getDebugRegistry(host.game) : null;
|
|
265
300
|
const systemAdapters: SystemAdapters =
|
|
266
301
|
runtime?.systemAdapters ?? (registry ? { debug: registry.adapter } : {});
|
|
302
|
+
const disposeComplete = unmounted.then(() => {
|
|
303
|
+
mountedApp.destroy(false, { children: true });
|
|
304
|
+
});
|
|
267
305
|
|
|
268
306
|
return {
|
|
269
307
|
kind: 'canvas',
|
|
270
308
|
// The reconciler's real root container — the same `PIXI.Container`
|
|
271
309
|
// `RootInstance.pixiStage()` hands the editor.
|
|
272
|
-
stage:
|
|
310
|
+
stage: mountedApp.stage,
|
|
273
311
|
drivesOwnLoop: false,
|
|
274
312
|
// Adapter surface: the host's shared debug registry. A native component
|
|
275
313
|
// tree registers nothing; its entry-module `debug` export is projected
|
|
276
314
|
// after mount by the shared native adapter.
|
|
277
315
|
systems: systemAdapters,
|
|
316
|
+
disposeComplete,
|
|
278
317
|
update(dt: number): void {
|
|
279
318
|
if (disposed) return;
|
|
280
319
|
elapsedMs += dt * 1000;
|
|
@@ -282,18 +321,25 @@ export function createPixiReactAdapter(
|
|
|
282
321
|
// the native path has none. The real Pixi ticker remains the one
|
|
283
322
|
// scheduler `useTick` observes, and the host only gates/advances it.
|
|
284
323
|
runtime?.systems.run(dt);
|
|
285
|
-
|
|
324
|
+
mountedApp.ticker.update(elapsedMs);
|
|
286
325
|
},
|
|
287
326
|
resize(width: number, height: number): void {
|
|
288
327
|
if (disposed) return;
|
|
289
328
|
// `autoDensity: true` makes Pixi re-stamp `canvas.style.width`/
|
|
290
329
|
// `.height` from this call, so the on-screen box follows the logical
|
|
291
330
|
// size with no explicit CSS write from the host.
|
|
292
|
-
|
|
331
|
+
mountedApp.renderer.resize(width, height);
|
|
293
332
|
},
|
|
294
333
|
dispose(): void {
|
|
295
334
|
if (disposed) return;
|
|
296
335
|
disposed = true;
|
|
336
|
+
// Pixi's EventSystem owns document.pointermove + window.pointerup
|
|
337
|
+
// (and its event ticker's global listener). React's deletion commit
|
|
338
|
+
// is asynchronous, so waiting to destroy the Application leaves
|
|
339
|
+
// those listeners live when the host audits this game realm. Detach
|
|
340
|
+
// the input target synchronously; the later Application.destroy()
|
|
341
|
+
// repeats this idempotently after every useTick cleanup has landed.
|
|
342
|
+
mountedApp.renderer.events.setTargetElement(null as unknown as HTMLElement);
|
|
297
343
|
// Rendering `null` children is the only unmount path `@pixi/react`
|
|
298
344
|
// exposes publicly (its `unmountRoot` helper is internal), and React
|
|
299
345
|
// commits that deletion on its own schedule — NOT synchronously. So
|
|
@@ -302,7 +348,6 @@ export function createPixiReactAdapter(
|
|
|
302
348
|
// a ticker destroyed before it lands throws inside React's commit.
|
|
303
349
|
// `removeView` stays false — the canvas belongs to the HOST.
|
|
304
350
|
void root.render(null, initOptions);
|
|
305
|
-
void unmounted.then(() => app.destroy(false, { children: true }));
|
|
306
351
|
runtime?.dispose();
|
|
307
352
|
},
|
|
308
353
|
};
|
package/src/data/curve.ts
CHANGED
|
@@ -59,7 +59,7 @@ export interface CurveAxes {
|
|
|
59
59
|
y: { min: number; max: number; label?: string };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** How the marker rides out to the emitted JSON Schema — the editor's
|
|
62
|
+
/** How the marker rides out to the emitted JSON Schema — the editor's detection key, the exact shape `dataRef` uses with `x-vgai-ref`. */
|
|
63
63
|
const CURVE_META_KEY = 'x-vgai-curve';
|
|
64
64
|
|
|
65
65
|
function axisLabel(axes: CurveAxes, axis: 'x' | 'y'): string {
|
|
@@ -155,7 +155,7 @@ export function curve(axes: CurveAxes): z.ZodType<CurveValue> {
|
|
|
155
155
|
* Read a curve field's declared axes back off a LIVE Zod schema (as opposed to
|
|
156
156
|
* the emitted JSON Schema, which the editor's Data panel reads instead).
|
|
157
157
|
*
|
|
158
|
-
* This is the dev-
|
|
158
|
+
* This is the dev-tools capability's detection door: it derives its rows by
|
|
159
159
|
* walking the game's own `TuningSchema` object, so it needs the axes from the
|
|
160
160
|
* schema in hand. Wrappers (`.optional()`, `.default(…)`, `.describe(…)` chains
|
|
161
161
|
* that re-wrap) hide the metadata behind an inner type, so a bounded unwrap
|
package/src/dev/instruments.ts
CHANGED
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
* game registry.
|
|
26
26
|
*
|
|
27
27
|
* ── THE DOOR ────────────────────────────────────────────────────────────────
|
|
28
|
-
* This module is IMPLEMENTATION ONLY. It publishes nothing itself: the dev-
|
|
29
|
-
* capability's own registry adopts this list (`
|
|
28
|
+
* This module is IMPLEMENTATION ONLY. It publishes nothing itself: the dev-tools
|
|
29
|
+
* capability's own registry adopts this list (`installDevToolsInstruments`) and
|
|
30
30
|
* publishes each instrument through the SAME door every declaration in that
|
|
31
31
|
* capability uses — one provider per reading (`instrument.timescale`), one
|
|
32
|
-
* command per action (`instrument.timescale.set`), plus the `dev-
|
|
32
|
+
* command per action (`instrument.timescale.set`), plus the `dev-tools` index
|
|
33
33
|
* provider. That is deliberate and load-bearing: the registry IS the product,
|
|
34
34
|
* so every reader of an instrument reaches the SAME registration.
|
|
35
35
|
*
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* ── RESOURCE OWNERSHIP, STATED ONCE ─────────────────────────────────────────
|
|
44
44
|
* {@link createDevInstruments} owns everything it allocates: the frame-time
|
|
45
45
|
* ring buffer and its ONE `game.onRenderStep` subscription. SHARER: the
|
|
46
|
-
* dev-
|
|
46
|
+
* dev-tools capability's `DevToolsBridge`, which creates one set per mounted
|
|
47
47
|
* game. TEARDOWN: the returned {@link DevInstrumentSet.dispose} — the ONE path
|
|
48
48
|
* that ends it. Nothing else here holds a resource; the time-scale, pause and
|
|
49
49
|
* collider instruments only drive state that `Game` already owns.
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
* the drawing buffer halves was never fill-bound.
|
|
37
37
|
*
|
|
38
38
|
* ── GATING ──────────────────────────────────────────────────────────────────
|
|
39
|
-
* The caller gates. Both seeders call this only under `
|
|
40
|
-
* (`runtime/dev-
|
|
39
|
+
* The caller gates. Both seeders call this only under `devBuildEnabled()`
|
|
40
|
+
* (`runtime/dev-build.ts` — the ONE owner of "is this a dev context"), so a
|
|
41
41
|
* ship build registers nothing and pays nothing. Each also requires a `Game`
|
|
42
42
|
* shell (there is no profiler to fold without one) and a non-headless mount
|
|
43
43
|
* (nothing brackets a submission, so the readings could only ever be empty).
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* require someone to ASK, and both require that someone to already know that
|
|
7
7
|
* static batching exists, is possible here, and is spelled `<Frozen>`. An
|
|
8
8
|
* agent building a game does not know any of that, so the measurement has to
|
|
9
|
-
* do the routing itself — the same idiom as
|
|
9
|
+
* do the routing itself — the same idiom as dev-tools' unconfigured-section
|
|
10
10
|
* warning: the reading names the exact edit.
|
|
11
11
|
*
|
|
12
12
|
* ── THE DECISION IS PURE, THE SCHEDULE IS NOT ───────────────────────────────
|
|
@@ -146,7 +146,7 @@ export function decideStaticBatchAdvisory(
|
|
|
146
146
|
* Once per PAGE, not once per module evaluation — a hot reload re-runs this
|
|
147
147
|
* module, and an advisory that reappears on every save is one that gets muted
|
|
148
148
|
* along with everything else on the console. Same mechanism, and the same
|
|
149
|
-
* reason, as
|
|
149
|
+
* reason, as dev-tools' unconfigured-section warning.
|
|
150
150
|
*/
|
|
151
151
|
const WARNED_KEY = '__vgaiStaticBatchAdvised';
|
|
152
152
|
|
|
@@ -679,10 +679,32 @@ export class InputManager {
|
|
|
679
679
|
* data reaches `isPressed`/`isJustPressed`/`isJustReleased`.
|
|
680
680
|
*/
|
|
681
681
|
async loadMap(url: string) {
|
|
682
|
+
await this.fetchMap(url, false);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* `loadMap`'s OPTIONAL sibling: apply the map at `url` when the project
|
|
687
|
+
* ships one and report `false` — SILENTLY — when it does not exist.
|
|
688
|
+
*
|
|
689
|
+
* The host loads the conventional map path at mount for every three/canvas
|
|
690
|
+
* root, including the scaffolded shape that has declared no actions yet, so
|
|
691
|
+
* "no map on disk" is the ordinary state of a brand-new project and must not
|
|
692
|
+
* print an error on every boot. Everything else stays exactly as loud as
|
|
693
|
+
* `loadMap`: a map that EXISTS and is malformed (or any non-404 failure)
|
|
694
|
+
* still logs and throws.
|
|
695
|
+
*/
|
|
696
|
+
async loadMapIfPresent(url: string): Promise<boolean> {
|
|
697
|
+
return this.fetchMap(url, true);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Shared fetch/validate/apply for {@link loadMap} and
|
|
701
|
+
* {@link loadMapIfPresent}; `optional` decides only what a 404 means. */
|
|
702
|
+
private async fetchMap(url: string, optional: boolean): Promise<boolean> {
|
|
682
703
|
let data: InputMapFile;
|
|
683
704
|
try {
|
|
684
705
|
const res = await fetch(resolveUrl(url));
|
|
685
706
|
if (!res.ok) {
|
|
707
|
+
if (optional && res.status === 404) return false;
|
|
686
708
|
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
687
709
|
}
|
|
688
710
|
const json = await res.json();
|
|
@@ -697,6 +719,7 @@ export class InputManager {
|
|
|
697
719
|
throw err instanceof AssetParseError ? err : new Error(message);
|
|
698
720
|
}
|
|
699
721
|
this.applyParsedMap(data);
|
|
722
|
+
return true;
|
|
700
723
|
}
|
|
701
724
|
|
|
702
725
|
/**
|
package/src/manifest/load.ts
CHANGED
|
@@ -79,9 +79,6 @@ export interface ResolvedAdapterRoot {
|
|
|
79
79
|
readonly entry: string | undefined;
|
|
80
80
|
readonly zOrder: number;
|
|
81
81
|
readonly pausable: boolean;
|
|
82
|
-
/** `dev: true` — this root is a DEV LAYER, not shipped game content. See
|
|
83
|
-
* `manifest/schema.ts`'s `dev` field for the four readers. */
|
|
84
|
-
readonly dev: boolean;
|
|
85
82
|
readonly loop: 'gated' | 'self-driven';
|
|
86
83
|
}
|
|
87
84
|
|
|
@@ -256,9 +253,6 @@ function checkEngineVersionPin(version: string): void {
|
|
|
256
253
|
* resolution; `entry` drives the entry cross-field rule and is
|
|
257
254
|
* carried through; `zOrder` drives the final sort; `pausable`/`loop` are
|
|
258
255
|
* carried through as-is (their consumers are the runtime/CLI, T3.2/T3.3);
|
|
259
|
-
* `dev` is carried through for the four dev-layer readers (`mountManifestRoots`'s
|
|
260
|
-
* ship gate, the host's topmost stacking pass, the play compositor's capture
|
|
261
|
-
* exclusion, and the editor's chrome-not-content seam);
|
|
262
256
|
* `description` (T3.3
|
|
263
257
|
* slice 3) is carried through as-is — its consumer today is
|
|
264
258
|
* `resolveIngestDescriptor` (packages/editor/src/adapter-resolver.ts), which
|
|
@@ -276,7 +270,6 @@ function resolveRoot(root: AdapterRoot): ResolvedAdapterRoot {
|
|
|
276
270
|
entry: root.entry,
|
|
277
271
|
zOrder: root.zOrder,
|
|
278
272
|
pausable: root.pausable,
|
|
279
|
-
dev: root.dev,
|
|
280
273
|
loop: root.loop,
|
|
281
274
|
};
|
|
282
275
|
}
|
package/src/manifest/schema.ts
CHANGED
|
@@ -315,19 +315,6 @@ export const AdapterRootSchema = z
|
|
|
315
315
|
.boolean()
|
|
316
316
|
.default(true)
|
|
317
317
|
.describe('Whether play-mode pause/step applies to this root'),
|
|
318
|
-
dev: z
|
|
319
|
-
.boolean()
|
|
320
|
-
.default(false)
|
|
321
|
-
.describe(
|
|
322
|
-
"Marks this root as a DEV LAYER: the game's own dev GUI, not shipped game content. " +
|
|
323
|
-
'Four readers act on it. `mountManifestRoots` mounts it only when dev layers are ' +
|
|
324
|
-
'enabled (`devLayersEnabled`, runtime/dev-layers.ts), so a production build carries ' +
|
|
325
|
-
'no dev GUI; the host stacks every dev root ABOVE every non-dev root (a dev layer ' +
|
|
326
|
-
'sits topmost, and as a DOM layer it steals no input from the game while closed); ' +
|
|
327
|
-
'the play compositor EXCLUDES dev layers from a capture unless the capture asks for ' +
|
|
328
|
-
'them; and the editor mounts a dev root as edit-time CHROME rather than authorable ' +
|
|
329
|
-
'content (no hierarchy entry, no OID stamping, no JSX write-back, not selectable).',
|
|
330
|
-
),
|
|
331
318
|
loop: z
|
|
332
319
|
.enum(['gated', 'self-driven'])
|
|
333
320
|
.default('gated')
|
|
@@ -475,17 +462,9 @@ export const GameManifestSchema = z
|
|
|
475
462
|
// root): a second root of the same medium parsed cleanly and was then
|
|
476
463
|
// silently never mounted, which is the failure mode this rejects.
|
|
477
464
|
//
|
|
478
|
-
// DEV LAYERS ARE COUNTED SEPARATELY. A `dev: true` root is not content
|
|
479
|
-
// competing for the medium — it is the game's dev GUI stacked above
|
|
480
|
-
// everything (`create-runtime.ts`'s stacking pass), and the editor
|
|
481
|
-
// mounts it as chrome, not as the one dom document. Counting it with
|
|
482
|
-
// the content roots would make the ratified shape — a game with a HUD
|
|
483
|
-
// and a dev panel, both `dom` — unrepresentable. The one-per-medium
|
|
484
|
-
// rule still applies WITHIN the dev band, for the same
|
|
485
|
-
// silently-never-mounted reason.
|
|
486
465
|
const idsByMedium = new Map<string, string[]>();
|
|
487
466
|
for (const root of roots) {
|
|
488
|
-
const medium =
|
|
467
|
+
const medium = rootMedium(root);
|
|
489
468
|
const ids = idsByMedium.get(medium);
|
|
490
469
|
if (ids) ids.push(root.id);
|
|
491
470
|
else idsByMedium.set(medium, [root.id]);
|
|
@@ -507,9 +486,7 @@ export const GameManifestSchema = z
|
|
|
507
486
|
"The Game's explicit, non-empty adapter-root composition. There is no implicit root. " +
|
|
508
487
|
'Root ids are unique, and a project declares at most ONE world root per medium — ' +
|
|
509
488
|
'one `three`, one `dom`, one canvas-surface root (first-party canvas, canvas ingest ' +
|
|
510
|
-
'and canvas module are the same medium).
|
|
511
|
-
'band: a dev layer is chrome stacked above the game, not content competing for the ' +
|
|
512
|
-
'medium, so a game may declare a `dom` HUD and a `dom` dev layer — but not two of either.',
|
|
489
|
+
'and canvas module are the same medium).',
|
|
513
490
|
),
|
|
514
491
|
authoring: z
|
|
515
492
|
.object({
|
package/src/pixi/authoring.ts
CHANGED
|
@@ -60,7 +60,12 @@ function kindOf(o: Container): string {
|
|
|
60
60
|
* are not names — they used to win and hide every owner. Then the texture
|
|
61
61
|
* alias (`Sprite.from('satellite')`). Then a remaining game constructor
|
|
62
62
|
* (`TitleScreen`, `PrimaryButton`). Bare `new Container()` still reads as
|
|
63
|
-
* `container
|
|
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.
|
|
64
69
|
*/
|
|
65
70
|
function displayLabel(o: Container): string {
|
|
66
71
|
const authored = authoredLabel(o);
|
|
@@ -71,13 +76,26 @@ function displayLabel(o: Container): string {
|
|
|
71
76
|
if (asset) return asset;
|
|
72
77
|
const ctor = usefulCtorName(o);
|
|
73
78
|
if (ctor) return ctor;
|
|
74
|
-
|
|
79
|
+
const kind = kindOf(o);
|
|
80
|
+
const childCount = o.children?.length ?? 0;
|
|
81
|
+
return childCount > 0 ? `${kind} (${childCount})` : kind;
|
|
75
82
|
}
|
|
76
83
|
|
|
77
|
-
/**
|
|
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
|
+
*/
|
|
78
94
|
function authoredLabel(o: Container): string | undefined {
|
|
79
95
|
const authored = o.label?.trim();
|
|
80
|
-
if (!authored
|
|
96
|
+
if (!authored) return undefined;
|
|
97
|
+
if (isMinifiedCtor(authored) && authored !== o.constructor?.name) return authored;
|
|
98
|
+
if (isGenericPixiCtor(authored)) return undefined;
|
|
81
99
|
return authored;
|
|
82
100
|
}
|
|
83
101
|
|
|
@@ -313,11 +331,52 @@ export const STRUCTURAL_CANVAS_IDENTITY: CanvasIdentity = {
|
|
|
313
331
|
idFor: (object, path) => `w2d:${path.join('/')}:${kindOf(object)}:${object.label ?? ''}`,
|
|
314
332
|
};
|
|
315
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
|
+
|
|
316
345
|
export class AuthoringAdapter2D {
|
|
317
346
|
private readonly byId = new Map<string, Container>();
|
|
318
347
|
private overlay: Overlay2D = {};
|
|
319
348
|
private dirty = false;
|
|
320
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>();
|
|
321
380
|
|
|
322
381
|
constructor(
|
|
323
382
|
private readonly root: Container,
|
|
@@ -378,12 +437,45 @@ export class AuthoringAdapter2D {
|
|
|
378
437
|
return this.byId.get(id) ?? null;
|
|
379
438
|
}
|
|
380
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
|
+
|
|
381
472
|
private toNode(o: Container): EditorNode2D {
|
|
382
473
|
const self = o as Container & { __authId?: string };
|
|
474
|
+
const { label, kind } = this.nameOf(o);
|
|
383
475
|
return {
|
|
384
476
|
id: self.__authId ?? '',
|
|
385
|
-
label
|
|
386
|
-
kind
|
|
477
|
+
label,
|
|
478
|
+
kind,
|
|
387
479
|
parentId: (o.parent as (Container & { __authId?: string }) | null)?.__authId ?? null,
|
|
388
480
|
childIds: o.children.map((c) => (c as Container & { __authId?: string }).__authId ?? ''),
|
|
389
481
|
};
|
|
@@ -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
|
|
@@ -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
|
|
95
|
-
*
|
|
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
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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',
|