@vgai/engine 0.5.16 → 0.5.18

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 (109) hide show
  1. package/dist/adapter/adapter-module.d.ts +1 -16
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +1 -11
  4. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +0 -2
  5. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -1
  6. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +3 -6
  7. package/dist/adapter/renderer-config.d.ts +83 -0
  8. package/dist/adapter/renderer-config.d.ts.map +1 -0
  9. package/dist/adapter/renderer-config.js +121 -0
  10. package/dist/adapter/root-adapter.d.ts +6 -2
  11. package/dist/adapter/root-adapter.d.ts.map +1 -1
  12. package/dist/adapter/root-seam-contract.d.ts +5 -0
  13. package/dist/adapter/root-seam-contract.d.ts.map +1 -1
  14. package/dist/adapter/root-seam-contract.js +1 -0
  15. package/dist/adapter/setup-three-root-adapter.js +9 -9
  16. package/dist/canvas-react/pixi-react-adapter.d.ts.map +1 -1
  17. package/dist/canvas-react/pixi-react-adapter.js +92 -41
  18. package/dist/data/curve.d.ts +1 -1
  19. package/dist/data/curve.js +2 -2
  20. package/dist/dev/instruments.d.ts +4 -4
  21. package/dist/dev/instruments.js +4 -4
  22. package/dist/dev/register-render-vitals.d.ts +2 -2
  23. package/dist/dev/register-render-vitals.js +2 -2
  24. package/dist/dev/static-batch-advisor.d.ts +1 -1
  25. package/dist/dev/static-batch-advisor.js +2 -2
  26. package/dist/input/input-manager.d.ts +15 -0
  27. package/dist/input/input-manager.d.ts.map +1 -1
  28. package/dist/input/input-manager.js +22 -0
  29. package/dist/manifest/load.d.ts +0 -3
  30. package/dist/manifest/load.d.ts.map +1 -1
  31. package/dist/manifest/load.js +0 -4
  32. package/dist/manifest/schema.d.ts +0 -2
  33. package/dist/manifest/schema.d.ts.map +1 -1
  34. package/dist/manifest/schema.js +3 -24
  35. package/dist/pixi/authoring.d.ts +33 -0
  36. package/dist/pixi/authoring.d.ts.map +1 -1
  37. package/dist/pixi/authoring.js +86 -6
  38. package/dist/react/world-state.d.ts +12 -0
  39. package/dist/react/world-state.d.ts.map +1 -1
  40. package/dist/react/world-state.js +14 -0
  41. package/dist/render/soft-particle-depth.d.ts +3 -3
  42. package/dist/render/soft-particle-depth.d.ts.map +1 -1
  43. package/dist/render/soft-particle-depth.js +8 -13
  44. package/dist/runtime/create-runtime.d.ts +2 -16
  45. package/dist/runtime/create-runtime.d.ts.map +1 -1
  46. package/dist/runtime/create-runtime.js +36 -38
  47. package/dist/runtime/dev-build.d.ts +37 -0
  48. package/dist/runtime/dev-build.d.ts.map +1 -0
  49. package/dist/runtime/dev-build.js +49 -0
  50. package/dist/runtime/game.d.ts +9 -1
  51. package/dist/runtime/game.d.ts.map +1 -1
  52. package/dist/runtime/game.js +25 -1
  53. package/dist/runtime/mount-manifest.d.ts +0 -13
  54. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  55. package/dist/runtime/mount-manifest.js +2 -21
  56. package/dist/world3d-react/engine-bridge.d.ts.map +1 -1
  57. package/dist/world3d-react/engine-bridge.js +28 -3
  58. package/dist/world3d-react/index.d.ts +2 -2
  59. package/dist/world3d-react/index.d.ts.map +1 -1
  60. package/dist/world3d-react/index.js +2 -2
  61. package/dist/world3d-react/r3f-adapter.d.ts +2 -2
  62. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  63. package/dist/world3d-react/r3f-adapter.js +90 -15
  64. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  65. package/dist/world3d-react/r3f-root-factory.js +12 -2
  66. package/dist/world3d-react/rapier-physics-bridge.d.ts +4 -4
  67. package/dist/world3d-react/rapier-physics-bridge.js +3 -3
  68. package/dist/world3d-react/renderer-config.d.ts +7 -69
  69. package/dist/world3d-react/renderer-config.d.ts.map +1 -1
  70. package/dist/world3d-react/renderer-config.js +7 -108
  71. package/dist/world3d-react/world-context.d.ts +43 -0
  72. package/dist/world3d-react/world-context.d.ts.map +1 -1
  73. package/dist/world3d-react/world-context.js +89 -46
  74. package/dist-config/config.js.map +1 -1
  75. package/package.json +1 -1
  76. package/schemas/engine-capabilities.json +3 -3
  77. package/schemas/vgai-project.schema.json +2 -7
  78. package/src/adapter/adapter-module.ts +1 -25
  79. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +3 -8
  80. package/src/adapter/renderer-config.ts +178 -0
  81. package/src/adapter/root-adapter.ts +6 -2
  82. package/src/adapter/root-seam-contract.ts +1 -0
  83. package/src/adapter/setup-three-root-adapter.ts +9 -9
  84. package/src/canvas-react/pixi-react-adapter.tsx +98 -53
  85. package/src/data/curve.ts +2 -2
  86. package/src/dev/instruments.ts +4 -4
  87. package/src/dev/register-render-vitals.ts +2 -2
  88. package/src/dev/static-batch-advisor.ts +2 -2
  89. package/src/input/input-manager.ts +23 -0
  90. package/src/manifest/load.ts +0 -7
  91. package/src/manifest/schema.ts +3 -26
  92. package/src/pixi/authoring.ts +98 -6
  93. package/src/react/world-state.tsx +15 -0
  94. package/src/render/soft-particle-depth.ts +8 -13
  95. package/src/runtime/create-runtime.ts +38 -52
  96. package/src/runtime/dev-build.ts +49 -0
  97. package/src/runtime/game.ts +31 -2
  98. package/src/runtime/mount-manifest.ts +2 -33
  99. package/src/world3d-react/engine-bridge.ts +28 -3
  100. package/src/world3d-react/index.ts +5 -5
  101. package/src/world3d-react/r3f-adapter.tsx +89 -20
  102. package/src/world3d-react/r3f-root-factory.tsx +12 -2
  103. package/src/world3d-react/rapier-physics-bridge.tsx +4 -4
  104. package/src/world3d-react/renderer-config.ts +7 -164
  105. package/src/world3d-react/world-context.ts +100 -51
  106. package/dist/runtime/dev-layers.d.ts +0 -34
  107. package/dist/runtime/dev-layers.d.ts.map +0 -1
  108. package/dist/runtime/dev-layers.js +0 -46
  109. package/src/runtime/dev-layers.ts +0 -46
@@ -149,22 +149,6 @@ export interface RootMountSpecBase {
149
149
  readonly zOrder?: number | undefined;
150
150
  /** Whether play-mode pause/step applies to this world (D10). Defaults to `true`. */
151
151
  readonly pausable?: boolean | undefined;
152
- /**
153
- * `vgai.project.json`'s `dev: true` — this root is a DEV LAYER (the game's
154
- * own dev GUI), not shipped game content. Two things happen here: the
155
- * surface is stacked ABOVE every non-dev root (a dev layer sits topmost),
156
- * and it is marked `data-vgai-dev-layer="true"` so the play compositor can
157
- * leave it out of a capture (`composite-screenshot.ts`). Whether a dev root
158
- * reaches this function at all is the CALLER's gate —
159
- * `mountManifestRoots` asks `devLayersEnabled()` before building the spec.
160
- *
161
- * It steals no input from the game while closed for a reason that predates
162
- * this flag and needs no new mechanism: a dev layer is a `dom` root, DOM
163
- * layers are `pointer-events:none` (see `mountOneReactRoot`) with the
164
- * mounted tree opting individual elements back in, and the input router
165
- * never sees DOM roots at all.
166
- */
167
- readonly dev?: boolean | undefined;
168
152
  /**
169
153
  * Optional claim predicate for the delegating input router (D5 §2a), over
170
154
  * a point RELATIVE TO THE CONTAINER. Absent means: this world claims only
@@ -328,6 +312,8 @@ export interface RootsRuntimeConfig {
328
312
  */
329
313
  export interface GameSession {
330
314
  stop(): void;
315
+ /** Resolves after every root has finished the cleanup initiated by stop(). */
316
+ readonly stopComplete: Promise<void>;
331
317
  pause(): void;
332
318
  resume(): void;
333
319
  step(): void;
@@ -776,22 +762,12 @@ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameS
776
762
  // DOM-root layer shares this SAME stacking pass even though it is a `<div>`, not a
777
763
  // canvas, and carries no `hitTest` (the router never sees react entries at
778
764
  // all — see the dispatch loop below). ---
779
- // Dev layers sit TOPMOST, above every non-dev root, whatever zOrder either
780
- // declares: a dev GUI the game can paint over is not a dev GUI. Expressed as
781
- // two independent `stackOrder` passes concatenated rather than a magic
782
- // zOrder value, so `stackOrder`'s one rule (ascending zOrder, ties -> array
783
- // order) still governs WITHIN each band and nothing has to reason about what
784
- // number is "high enough".
785
765
  const claimEntries = mountSpecs.map((spec) => ({
786
766
  id: spec.id,
787
767
  zOrder: spec.zOrder ?? 0,
788
768
  hitTest: spec.hitTest,
789
- dev: spec.dev === true,
790
769
  }));
791
- const stacked = [
792
- ...stackOrder(claimEntries.filter((e) => !e.dev)),
793
- ...stackOrder(claimEntries.filter((e) => e.dev)),
794
- ];
770
+ const stacked = stackOrder(claimEntries);
795
771
  const bottomId = stacked[0]?.id;
796
772
  const kindById = new Map(mountSpecs.map((spec) => [spec.id, spec.kind] as const));
797
773
 
@@ -807,11 +783,6 @@ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameS
807
783
  // surface.ts`, which is the one door the capture/staleness/screenshot
808
784
  // sites now ask (ARCHITECTURE-CORE §The editor protocol, zero inference).
809
785
  surface.dataset['vgaiRootId'] = entry.id;
810
- // The capture's marker (`composite-screenshot.ts` reads exactly this
811
- // attribute): a dev layer is excluded from a play capture unless the
812
- // capture asks for it, because a contaminated "does the game look right"
813
- // frame is worthless.
814
- if (entry.dev) surface.dataset['vgaiDevLayer'] = 'true';
815
786
  if (!isReact) {
816
787
  const canvas = surface as unknown as HTMLCanvasElement;
817
788
  canvas.width = w;
@@ -917,33 +888,48 @@ async function createRootsGameRuntime(config: RootsRuntimeConfig): Promise<GameS
917
888
  ? installSessionRenderHarness(game, renderModeLocation, config.renderControl?.target)
918
889
  : undefined;
919
890
 
891
+ let resolveStopComplete!: () => void;
892
+ const stopComplete = new Promise<void>((resolve) => {
893
+ resolveStopComplete = resolve;
894
+ });
895
+ let stopping = false;
896
+
920
897
  function fullCleanup(): void {
921
- loop.stop();
922
- router.dispose();
923
- for (const entry of mountedEntries) {
924
- entry.mounted.dispose();
925
- entry.renderer?.dispose();
926
- entry.renderer?.forceContextLoss();
927
- // Remove from the element's CURRENT parent, not the mount-time
928
- // `container`: the editor's Game panel re-parents the live surfaces
929
- // when its mount element swaps (fill <-> device preset, W2c), and
930
- // `container.removeChild` would throw NotFoundError after such a move.
931
- // `container` remains the fallback for hosts whose element stand-ins
932
- // never wire `parentNode` (headless unit fixtures); an element already
933
- // detached by such a host is a no-op via the catch.
934
- try {
935
- (entry.element.parentNode ?? container).removeChild(entry.element);
936
- } catch {
937
- /* already detached — nothing to remove */
898
+ if (stopping) return;
899
+ stopping = true;
900
+ try {
901
+ loop.stop();
902
+ router.dispose();
903
+ for (const entry of mountedEntries) {
904
+ entry.mounted.dispose();
905
+ entry.renderer?.dispose();
906
+ entry.renderer?.forceContextLoss();
907
+ // Remove from the element's CURRENT parent, not the mount-time
908
+ // `container`: the editor's Game panel re-parents the live surfaces
909
+ // when its mount element swaps (fill <-> device preset, W2c), and
910
+ // `container.removeChild` would throw NotFoundError after such a move.
911
+ // `container` remains the fallback for hosts whose element stand-ins
912
+ // never wire `parentNode` (headless unit fixtures); an element already
913
+ // detached by such a host is a no-op via the catch.
914
+ try {
915
+ (entry.element.parentNode ?? container).removeChild(entry.element);
916
+ } catch {
917
+ /* already detached — nothing to remove */
918
+ }
938
919
  }
920
+ game.dispose();
921
+ retractDevGlobals();
922
+ retractRenderHarness?.();
923
+ } finally {
924
+ void Promise.allSettled(
925
+ mountedEntries.map((entry) => entry.mounted.disposeComplete ?? Promise.resolve()),
926
+ ).then(() => resolveStopComplete());
939
927
  }
940
- game.dispose();
941
- retractDevGlobals();
942
- retractRenderHarness?.();
943
928
  }
944
929
 
945
930
  return {
946
931
  stop: fullCleanup,
932
+ stopComplete,
947
933
  // `Game.play` owns per-world pause, loop-gate, and audio-gate semantics.
948
934
  pause() {
949
935
  game.play.pause();
@@ -0,0 +1,49 @@
1
+ /**
2
+ * THE one owner of "is this a development context". Every reader that has to
3
+ * answer that question calls {@link devBuildEnabled}; there is deliberately NO
4
+ * second source of truth — not a module-level cached boolean, not a
5
+ * `globalThis` flag, not a per-host copy of the `import.meta.env` read below.
6
+ * Instrumentation that ships to players because two places disagreed about
7
+ * what "dev" means is exactly the failure this single owner exists to make
8
+ * impossible.
9
+ *
10
+ * Ownership, stated in one place (the build rule):
11
+ * - OWNER: this function. It resolves the answer; nothing else derives it.
12
+ * - SHARERS: the two three-root adapters (`adapter/setup-three-root-adapter.ts`
13
+ * and `world3d-react/r3f-adapter.tsx`), which seed live render vitals only
14
+ * under it, and the `static-batch` capability's mutation watch and `Frozen`
15
+ * warning. Any future dev-only instrument calls this too, with its own
16
+ * `override`.
17
+ * - TEARDOWN: none. This is a pure predicate over build config and one
18
+ * caller-supplied argument — it owns no resource, allocates nothing, and
19
+ * has no lifecycle to end.
20
+ *
21
+ * The three inputs, highest precedence first:
22
+ * 1. `override` — the explicit per-call answer. A headless test, a capture
23
+ * harness, or a host that knows better passes `true`/`false` and gets
24
+ * exactly that. Passing `undefined` (or omitting it) means "decide for
25
+ * me" and falls through. The editor's own preview mount is the worked
26
+ * case: it is a dev session by definition even when the editor SPA it
27
+ * runs inside is a production build.
28
+ * 2. A dev build — `import.meta.env.DEV`. The ordinary local/editor case.
29
+ * 3. A production build's EXPLICIT opt-in — `VITE_VGAI_DEV_BUILD=true`.
30
+ * Instrumenting a production bundle is a real, legitimate choice (an
31
+ * internal playtest build, a QA build), and it must be an opt-in someone
32
+ * had to type, never something a default drifts into.
33
+ *
34
+ * Anything else — a production build with no opt-in — is `false`.
35
+ */
36
+ export function devBuildEnabled(override?: boolean | undefined): boolean {
37
+ if (override !== undefined) return override;
38
+ // `import.meta` is cast whole, not just its `.env`: this module is reachable
39
+ // from programs whose tsconfig does not pull in `vite/client` (the session
40
+ // client's, for one, which reaches the three adapter transitively), and there
41
+ // `ImportMeta` has no declared `env` at all. The cast keeps the single owner
42
+ // of the dev answer importable from ANY program rather than forcing every
43
+ // downstream tsconfig to adopt Vite's ambient types.
44
+ const env = (import.meta as unknown as { env?: unknown }).env as
45
+ | { DEV?: boolean | undefined; VITE_VGAI_DEV_BUILD?: string | undefined }
46
+ | undefined;
47
+ if (env?.DEV === true) return true;
48
+ return env?.VITE_VGAI_DEV_BUILD === 'true';
49
+ }
@@ -734,8 +734,14 @@ export interface GameInternal extends Game {
734
734
  * the conventional `/inputmaps/default.inputmap.json` (or the adapter's
735
735
  * configured `inputMapPath`); runtime additions go through
736
736
  * `ctx.input.registerAction` instead.
737
+ *
738
+ * `{ optional: true }` is the CONVENTIONAL-PATH probe a root uses when the
739
+ * project never named a map: an absent file resolves quietly (a brand-new
740
+ * scaffold declares no actions yet), pins nothing — so a sibling root may
741
+ * still declare its own map — and yields to a map another root already
742
+ * owns. A map that exists but fails to parse is as loud either way.
737
743
  */
738
- loadInputMap(path: string): Promise<void>;
744
+ loadInputMap(path: string, options?: { optional?: boolean }): Promise<void>;
739
745
  /** Release game-owned resources after every mounted root has disposed. */
740
746
  dispose(): void;
741
747
  }
@@ -856,6 +862,10 @@ export function createGame(opts: {
856
862
  input.setDebugEmit((event, detail) => debugRegistry.forRoot('(game)').emit(event, detail));
857
863
  let inputMapPath: string | null = null;
858
864
  let inputMapLoad: Promise<void> | null = null;
865
+ /** In-flight/settled conventional-path probes (`loadInputMap(path, {optional})`),
866
+ * so two roots probing the same path share one fetch. Separate from
867
+ * `inputMapLoad` because a probe that found nothing declares nothing. */
868
+ const inputMapProbes = new Map<string, Promise<void>>();
859
869
 
860
870
  function requireDefaultRoot(): RootInstance {
861
871
  if (roots.length === 0) {
@@ -1468,7 +1478,26 @@ export function createGame(opts: {
1468
1478
  runFrameImpl(fixedDt, { skipRenderPhases });
1469
1479
  }
1470
1480
  },
1471
- loadInputMap(path: string): Promise<void> {
1481
+ loadInputMap(path: string, options?: { optional?: boolean }): Promise<void> {
1482
+ if (options?.optional === true) {
1483
+ // The conventional-path probe. It never becomes the game's declared
1484
+ // map unless a file was actually there, so a root that DOES name a
1485
+ // map (a sibling canvas root, a later mount) is never pre-empted by a
1486
+ // probe that found nothing — the competing-path throw below would
1487
+ // otherwise fire on a project that declared exactly one map.
1488
+ if (inputMapPath) return inputMapLoad ?? Promise.resolve();
1489
+ let probe = inputMapProbes.get(path);
1490
+ if (!probe) {
1491
+ probe = input.loadMapIfPresent(path).then((loaded) => {
1492
+ if (loaded && !inputMapPath) {
1493
+ inputMapPath = path;
1494
+ inputMapLoad = Promise.resolve();
1495
+ }
1496
+ });
1497
+ inputMapProbes.set(path, probe);
1498
+ }
1499
+ return probe;
1500
+ }
1472
1501
  if (inputMapPath && inputMapPath !== path) {
1473
1502
  throw new Error(
1474
1503
  `Game.loadInputMap: input map is already "${inputMapPath}"; root requested "${path}". ` +
@@ -36,7 +36,6 @@ import {
36
36
  import { createGameRuntime, type GameSession, type RootMountSpec } from './create-runtime';
37
37
  import { type DebugBridgeWindowTarget, maybeInstallDebugBridge } from './debug-bridge';
38
38
  import { getDebugRegistry } from './debug-registry';
39
- import { devLayersEnabled } from './dev-layers';
40
39
  import { getGameplayRngTrapControl } from './gameplay-rng-trap';
41
40
  import { RENDER_SEED_QUERY_PARAM } from './render-seed';
42
41
  import type { GameSetupFn, PlaytestContext } from './types';
@@ -156,15 +155,6 @@ export interface MountManifestOptions {
156
155
  readonly seed?: number | undefined;
157
156
  /** Host identity for a private play run or coordinated Team Test. */
158
157
  readonly playtest?: PlaytestContext | null | undefined;
159
- /**
160
- * The explicit per-mount answer to "are dev layers enabled" — forwarded
161
- * verbatim to `devLayersEnabled` (`runtime/dev-layers.ts`), the ONE owner of
162
- * that question. Omit it and the build decides (dev build ⇒ on; production
163
- * build ⇒ off unless `VITE_VGAI_DEV_LAYERS=true`). A root declaring
164
- * `dev: true` is simply not mounted when the answer is `false`, so a shipped
165
- * build carries no dev GUI at all.
166
- */
167
- readonly devLayers?: boolean | undefined;
168
158
  /** Where to read `?vgai-seed=` from for the boot-time seed reader. Same
169
159
  * override precedent as `debugBridge.url` (defaults to `window.location`
170
160
  * when a real `window` exists; a headless caller with no override gets no
@@ -346,7 +336,6 @@ function buildRootMountSpec(
346
336
  id: world.id,
347
337
  zOrder: world.zOrder,
348
338
  pausable: world.pausable,
349
- dev: world.dev,
350
339
  };
351
340
 
352
341
  // `entry`'s `kind` is guaranteed to agree with `world.kind` past the guard
@@ -390,10 +379,6 @@ function buildRootMountSpec(
390
379
  * ingest }` adapter with no caller-supplied adapter, or an empty manifest all
391
380
  * throw a named `Error` identifying the world id, its kind, and what was
392
381
  * expected — never a silent skip or a partially-mounted session.
393
- *
394
- * The ONE root that IS skipped silently is a `dev: true` dev layer when dev
395
- * layers are disabled (`devLayersEnabled`) — that skip is the feature: it is
396
- * what makes a dev GUI safe to leave in the game.
397
382
  */
398
383
  export async function mountManifestRoots(opts: MountManifestOptions): Promise<GameSession> {
399
384
  const manifest = resolveManifest(opts.manifest);
@@ -402,23 +387,7 @@ export async function mountManifestRoots(opts: MountManifestOptions): Promise<Ga
402
387
  }
403
388
  const entries = opts.entries ?? {};
404
389
 
405
- // The SHIP GATE. A `dev: true` root is the game's own dev GUI; it mounts
406
- // only when dev layers are enabled, so a production build carries no dev
407
- // GUI. The answer comes from `devLayersEnabled` and nowhere else — see that
408
- // function's header for why there is exactly one owner. Dropping the root
409
- // here (rather than mounting it hidden) is deliberate: nothing of the dev
410
- // layer is constructed, so its entry module's cost and its DOM never exist
411
- // in a shipped build.
412
- const devEnabled = devLayersEnabled(opts.devLayers);
413
- const mountable = devEnabled ? manifest.roots : manifest.roots.filter((world) => !world.dev);
414
- if (mountable.length === 0) {
415
- throw new Error(
416
- 'mountManifestRoots: every root in this manifest is a dev layer (`dev: true`) and dev ' +
417
- 'layers are disabled for this build — there is no game to mount.',
418
- );
419
- }
420
-
421
- const roots: RootMountSpec[] = mountable.map((world) =>
390
+ const roots: RootMountSpec[] = manifest.roots.map((world) =>
422
391
  buildRootMountSpec(world, entries[world.id]),
423
392
  );
424
393
 
@@ -443,7 +412,7 @@ export async function mountManifestRoots(opts: MountManifestOptions): Promise<Ga
443
412
  headless: opts.headless,
444
413
  // `rendering.antialias` reaches the WebGL context at CONSTRUCTION and can be honoured
445
414
  // nowhere else — see the manifest schema's own `rendering` block and
446
- // `world3d-react/renderer-config.ts`'s header for why it is not a world-level declaration.
415
+ // `adapter/renderer-config.ts`'s header for why it is not a world-level declaration.
447
416
  ...(manifest.rendering === undefined ? {} : { antialias: manifest.rendering.antialias }),
448
417
  seed: resolvedSeed,
449
418
  playtest: opts.playtest,
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { createContext, useContext } from 'react';
18
+ import { useOptionalGame } from '../react/world-state';
18
19
  import type { R3FGameContext } from './world-context';
19
20
 
20
21
  export interface EngineBridgeValue {
@@ -50,11 +51,35 @@ export const EngineBridge = createContext<EngineBridgeValue | null>(null);
50
51
  */
51
52
  export function useGameContext(): R3FGameContext {
52
53
  const bridge = useContext(EngineBridge);
54
+ // Read unconditionally (rules of hooks) — used only to shape the throw below.
55
+ const hostedByGame = useOptionalGame() !== null;
53
56
  if (!bridge?.ctx) {
57
+ // NAME THE REAL CAUSE. This throw used to say "check that the component is
58
+ // rendered inside your world entry" — advice a scaffolded game has already
59
+ // followed, because the shape `vgai create` produces (`export default
60
+ // function World()`) mounts as a COMPONENT ROOT and receives no vgai
61
+ // runtime context at all: `r3f-root-factory.tsx` passes
62
+ // `engineRuntime: false` deliberately, so this hook can never resolve
63
+ // there however the component is nested. A world whose first render calls
64
+ // it dies with "fiber crashed before its first commit" and a fix
65
+ // instruction that cannot work. The `Game` in scope tells the two cases
66
+ // apart: present means a vgai-mounted world, absent means no root at all.
54
67
  throw new Error(
55
- 'useGameContext(): no engine root context. This hook only works inside an R3F ' +
56
- 'root mounted by createR3FAdapter (@engine/world3d-react) check that the ' +
57
- 'component is rendered inside your world entry, not in a standalone <Canvas>.',
68
+ hostedByGame
69
+ ? 'useGameContext(): this world mounts as a COMPONENT ROOT (the default-exported ' +
70
+ 'shape `vgai create` scaffolds) and receives no vgai runtime context by design, ' +
71
+ 'so nesting cannot fix it. Game-scoped services are on the Game handle instead: ' +
72
+ '`useGame().input` (from @vgai/engine/react/world-state) is the game-owned ' +
73
+ "InputManager the host already loaded this project's input map into and the same " +
74
+ 'one `game.input.hold/tap` drives; `useDebugProvider`/`useDebugCommand`/' +
75
+ '`useWorldState` are the state/command doors. If this world genuinely needs the ' +
76
+ 'imperative runtime (`ctx.systems`, adapter options like `inputMapPath`), export ' +
77
+ '`adapter = createR3FAdapter({ id, content: <World/> })` from the entry instead of ' +
78
+ 'default-exporting the component.'
79
+ : 'useGameContext(): no engine root context and no Game in scope — this component is ' +
80
+ 'rendering outside any mounted vgai root (a standalone <Canvas>, a story, or a ' +
81
+ "bare test render). Mount it through your project's three root, or read services " +
82
+ 'the component root way (native hooks and project-owned state).',
58
83
  );
59
84
  }
60
85
  return bridge.ctx;
@@ -33,22 +33,22 @@
33
33
  * Deliberately NOT re-exported here: `<RapierPhysicsBridge>`
34
34
  * (`@engine/world3d-react/rapier-physics-bridge`), which registers an R3F
35
35
  * world's `@react-three/rapier` physics as the game's
36
- * `SystemAdapters.physics` — so the dev menu's colliders instrument and the
36
+ * `SystemAdapters.physics` — so the colliders instrument and the
37
37
  * editor's transform coordination can see it. It imports
38
38
  * `@react-three/rapier`, and only a world that already depends on that library
39
39
  * should pull it into its graph; a deep import keeps this barrel's peer
40
40
  * contract to `react`/`@react-three/fiber`/`three` alone.
41
41
  */
42
42
 
43
- export { EngineBridge, type EngineBridgeValue, useGameContext } from './engine-bridge';
44
- export { type CreateR3FAdapterOptions, createR3FAdapter } from './r3f-adapter';
45
- export { r3fRootFactory, resolveR3FEntryAdapter } from './r3f-root-factory';
46
43
  export {
47
44
  applyWorldRendererConfig,
48
45
  type WorldOutputColorSpace,
49
46
  type WorldRendererConfig,
50
47
  type WorldToneMapping,
51
- } from './renderer-config';
48
+ } from '../adapter/renderer-config';
49
+ export { EngineBridge, type EngineBridgeValue, useGameContext } from './engine-bridge';
50
+ export { type CreateR3FAdapterOptions, createR3FAdapter } from './r3f-adapter';
51
+ export { r3fRootFactory, resolveR3FEntryAdapter } from './r3f-root-factory';
52
52
  export {
53
53
  createR3FRootContext,
54
54
  DEFAULT_INPUT_MAP_PATH,
@@ -30,6 +30,7 @@ import {
30
30
  } from '@react-three/fiber';
31
31
  import { createElement, Fragment, type ReactNode, useEffect, useMemo } from 'react';
32
32
  import type { MountedThreeRoot, RootAdapter, ThreeHostContext } from '../adapter';
33
+ import { applyWorldRendererConfig, type WorldRendererConfig } from '../adapter/renderer-config';
33
34
  import type { SystemAdapters } from '../adapter/system-adapter';
34
35
  import { type RenderVitalsRegistration, registerRenderVitals } from '../dev/register-render-vitals';
35
36
  import {
@@ -43,10 +44,9 @@ import { createWebGLFrameCapture } from '../dev/webgl-frame-capture';
43
44
  import { WorldProvider } from '../react/world-state';
44
45
  import { createSoftParticleDepthPass } from '../render/soft-particle-depth';
45
46
  import { getDebugRegistry } from '../runtime/debug-registry';
46
- import { devLayersEnabled } from '../runtime/dev-layers';
47
+ import { devBuildEnabled } from '../runtime/dev-build';
47
48
  import { EngineBridge, type EngineBridgeValue } from './engine-bridge';
48
- import { applyWorldRendererConfig, type WorldRendererConfig } from './renderer-config';
49
- import { createR3FRootContext } from './world-context';
49
+ import { createR3FRootContext, DEFAULT_INPUT_MAP_PATH, wireGameInputSeams } from './world-context';
50
50
 
51
51
  /** The slice of `WebGLRenderer.info` the vitals reporter reads. Declared
52
52
  * structurally rather than imported from `three`, per this module's own
@@ -77,6 +77,11 @@ interface RendererCounters {
77
77
  * sort is stable) — the tie-break among priority-0 callbacks is insertion
78
78
  * order.
79
79
  *
80
+ * It is mounted only when THIS host carries a real WebGL context. The editor's
81
+ * design-time host deliberately reconciles the scene without one because the
82
+ * editor viewport owns the real renderer and camera; that viewport runs the
83
+ * same generic depth pass immediately before it presents the adopted scene.
84
+ *
80
85
  * Costs nothing while no soft-particle system is armed — see
81
86
  * `../render/soft-particle-depth.ts` for what "armed" means and what the pass
82
87
  * spends when it is.
@@ -112,7 +117,7 @@ export interface CreateR3FAdapterOptions {
112
117
  /** The colour pipeline this world was AUTHORED for, applied to the host's renderer for the life
113
118
  * of the mount and restored on dispose. Omit it (every world here does) to keep the host's own
114
119
  * defaults; declare it when the world's colours were picked against a different engine's
115
- * pipeline — see `./renderer-config.ts`. */
120
+ * pipeline — see `../adapter/renderer-config.ts`. */
116
121
  readonly renderer?: WorldRendererConfig | undefined;
117
122
  }
118
123
 
@@ -185,10 +190,33 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
185
190
  // `ctx` rides the bridge so ordinary components can reach engine
186
191
  // services through `useGameContext()`.
187
192
  const bridge: EngineBridgeValue | null = runtime ? { ctx: runtime.ctx } : null;
193
+ const gameDebugRegistry = host.game ? getDebugRegistry(host.game) : null;
194
+ // THE HOST STILL WIRES THIS GAME'S INPUT WHEN THE WORLD TAKES NO CONTEXT.
195
+ //
196
+ // A default-exported world mounts with `engineRuntime: false` — no vgai
197
+ // runtime context enters its React tree, by design. That says nothing
198
+ // about the GAME-scoped input the host owns from the outside: the
199
+ // project's declared map, and this root's `game.input.*` seams on the
200
+ // debug registry. Skipping those made a fresh scaffold's bot/autoplay/
201
+ // `vgai eval` input doors throw `DEBUG_INPUT_UNAVAILABLE` and left the
202
+ // declared actions unloaded — in the ONE world shape `vgai create`
203
+ // produces. Same host-vs-context split as `WorldProvider` below.
204
+ //
205
+ // `inputMapPath` unset (what the default-export factory passes) means
206
+ // "the conventional path, if this project ships one" — quiet when it
207
+ // does not, since a brand-new project declares no actions yet.
208
+ const hostInputReady =
209
+ runtime || !gameDebugRegistry
210
+ ? null
211
+ : wireGameInputSeams(host, gameDebugRegistry, {
212
+ id,
213
+ inputMapPath: inputMapPath ?? DEFAULT_INPUT_MAP_PATH,
214
+ optionalInputMap: inputMapPath === undefined,
215
+ });
188
216
  // Actions must exist before any component's init()/first frame reads
189
217
  // them — wait for the (never-rejecting) input-map load before the first
190
218
  // commit.
191
- await runtime?.inputMapReady;
219
+ await (runtime?.inputMapReady ?? hostInputReady);
192
220
 
193
221
  // The world's own colour pipeline. Applied AFTER `root.configure()` below — see the comment
194
222
  // at its call site for why "before" is the version that silently does nothing.
@@ -320,24 +348,44 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
320
348
  // until this landed. `host.game` is genuinely absent in bare/headless
321
349
  // hosts, so the provider is conditional and those hooks stay the inert
322
350
  // no-ops they already document themselves to be.
323
- const game = runtime?.ctx.game;
351
+ //
352
+ // READ FROM `host.game`, NEVER FROM `runtime` — the two are not the same
353
+ // question, and conflating them silently dark-ended the seam for every
354
+ // scaffolded game. The IDIOMATIC three world (`export default function
355
+ // World()`) mounts through `resolveR3FEntryAdapter` with
356
+ // `engineRuntime: false` (`r3f-root-factory.tsx`), so it has no
357
+ // `runtime` and no `bridge` — by design, because the vgai runtime
358
+ // CONTEXT (`useGameContext`) must not enter an ecosystem-native tree.
359
+ // `WorldProvider` is not that context: it carries the Game handle the
360
+ // host already holds, and it is the only way a game's own
361
+ // `useDebugProvider('stat', …)` can reach the ONE game-scoped registry
362
+ // the editor/`vgai eval` read. Keyed off `runtime`, every
363
+ // default-exported world's registrations no-opped in silence and
364
+ // `vgai eval 'await game.providers()'` listed only the built-ins.
365
+ // The canvas lane never had the gap (`canvas-react/
366
+ // pixi-react-root-factory.tsx` keeps its entry runtime on).
367
+ const game = host.game;
324
368
  // `SoftParticleDepth` trails the world's own tree deliberately — see its
325
- // docblock for why the subscription must land last.
369
+ // docblock for why the subscription must land last. A no-context design
370
+ // mount omits this fiber callback because the adopting editor viewport
371
+ // runs the pass with its own renderer and camera instead.
326
372
  const world = createElement(
327
373
  Fragment,
328
374
  null,
329
375
  content,
330
- runtime ? createElement(SoftParticleDepth, { key: 'vgai-soft-particle-depth' }) : null,
376
+ runtime && typeof host.renderer.getContext === 'function'
377
+ ? createElement(SoftParticleDepth, { key: 'vgai-soft-particle-depth' })
378
+ : null,
331
379
  createElement(MountEffectsReady, { key: 'vgai-mount-effects-ready' }),
332
380
  );
381
+ // The two providers are INDEPENDENT: `EngineBridge` rides `bridge` (the
382
+ // vgai runtime context, absent for a native default-exported world),
383
+ // `WorldProvider` rides `host.game` (the React seam, present whenever a
384
+ // Game hosts this world). Nesting the second inside the first is what
385
+ // made the seam a hostage of the first.
386
+ const provided = game ? createElement(WorldProvider, { game }, world) : world;
333
387
  root.render(
334
- bridge
335
- ? createElement(
336
- EngineBridge.Provider,
337
- { value: bridge },
338
- game ? createElement(WorldProvider, { game }, world) : world,
339
- )
340
- : world,
388
+ bridge ? createElement(EngineBridge.Provider, { value: bridge }, provided) : provided,
341
389
  );
342
390
  // A reconcile-time crash (e.g. a missing `extend` catalogue entry)
343
391
  // surfaces as an uncaught window error and `onCreated` never fires —
@@ -349,6 +397,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
349
397
  cleanup();
350
398
  root.unmount();
351
399
  runtime?.dispose();
400
+ if (!runtime) gameDebugRegistry?.strip(id);
352
401
  restoreRendererConfig();
353
402
  reject(
354
403
  new Error(
@@ -361,6 +410,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
361
410
  cleanup();
362
411
  root.unmount();
363
412
  runtime?.dispose();
413
+ if (!runtime) gameDebugRegistry?.strip(id);
364
414
  restoreRendererConfig();
365
415
  reject(
366
416
  new Error(
@@ -420,7 +470,6 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
420
470
  // `RootState` for precisely this reason, so reading it per frame is the
421
471
  // supported way to see the live root. It is a plain map read.
422
472
  const live = (): RootState => state.get();
423
- const gameDebugRegistry = host.game ? getDebugRegistry(host.game) : null;
424
473
 
425
474
  // --- Live render vitals (issue #1504), for an R3F world -----------------
426
475
  // The SAME door the classic adapter seeds
@@ -432,11 +481,11 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
432
481
  // world is the only shape a scaffolded project's three root has, so a
433
482
  // door wired only into the classic path is a door no scaffolded project
434
483
  // ever gets (issue #1518: `game.state('render.vitals')` answered "no
435
- // state provider registered" in the field while every other dev layer of
436
- // the same world worked).
484
+ // state provider registered" in the field while every other debug
485
+ // reading of the same world worked).
437
486
  //
438
487
  // The gate is the same three conditions, minus the one that cannot
439
- // arise: `devLayersEnabled()` (`../runtime/dev-layers.ts`, the ONE owner
488
+ // arise: `devBuildEnabled()` (`../runtime/dev-build.ts`, the ONE owner
440
489
  // of "is this a dev context" — a ship build registers nothing and pays
441
490
  // nothing), and a `Game` shell (the readings are folded out of that
442
491
  // game's profiler frames). "Not headless" needs no term here: a headless
@@ -454,7 +503,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
454
503
  // (the fold) and `runtime.dispose()`'s scoped `strip(id)` (the
455
504
  // registrations) — the same split the classic adapter documents.
456
505
  let renderVitals: RenderVitalsRegistration | null = null;
457
- if (host.game && devLayersEnabled()) {
506
+ if (host.game && devBuildEnabled()) {
458
507
  // Same auto-enable as the classic adapter: the readings are folded out
459
508
  // of profiler frames, and the profiler is a flag the editor already
460
509
  // sets on play — under the dev gate this is the same cost arriving a
@@ -640,6 +689,26 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter
640
689
  renderVitals?.dispose();
641
690
  renderVitals = null;
642
691
  runtime?.dispose();
692
+ // The no-context path has no `runtime.dispose()` to carry its scoped
693
+ // strip, so it ends its own registrations here — the same
694
+ // `strip(id)`, at the same moment. That is what ends this world's
695
+ // PROVIDERS AND COMMANDS (the render-vitals door above is the one
696
+ // this path actually registers under `id`); sibling roots are
697
+ // untouched.
698
+ //
699
+ // It does NOT end the input seams `wireGameInputSeams` set, and
700
+ // nothing else does either: `strip` walks providers/commands only
701
+ // (`runtime/debug-registry.ts`), so `virtualInputTargets`/
702
+ // `inputActionsSources`/`inputTraceSources` keep this world's id
703
+ // until a remount of the same id overwrites it. The runtime path has
704
+ // always been identical, and the residue is inert rather than
705
+ // hidden: every one of those closures reads GAME-scoped state
706
+ // (`game.input`, `game.loop.fixedDt`, the game's seed), so a stale
707
+ // entry actuates exactly what a live one would. What it can still do
708
+ // is name a dead world in `DEBUG_INPUT_WORLD_NOT_FOUND`'s registered
709
+ // list. Do not write "the strip clears the input seams" here again
710
+ // without changing `strip` to actually do it.
711
+ if (!runtime) gameDebugRegistry?.strip(id);
643
712
  restoreRendererConfig();
644
713
  },
645
714
  };
@@ -70,8 +70,18 @@ export function resolveR3FEntryAdapter(entryModule: unknown, rootId: string): Ro
70
70
  id: rootId,
71
71
  content: createElement(mod.default),
72
72
  // A default-exported R3F component is an ordinary R3F app. The host
73
- // controls Fiber's native `frameloop: 'never'` scheduler; vgai runtime
74
- // context, systems and state providers never enter its React tree.
73
+ // controls Fiber's native `frameloop: 'never'` scheduler; the vgai
74
+ // runtime CONTEXT (`useGameContext`, `ctx.systems`) never enters its
75
+ // React tree.
76
+ //
77
+ // This flag governs that context and nothing else. It does NOT govern
78
+ // `WorldProvider`: `useDebugProvider`/`useDebugCommand`/`useWorldState`
79
+ // are the documented door a game declares its own state/commands
80
+ // through, and they must work in the idiomatic shape — which is the
81
+ // ONLY shape `vgai create` scaffolds. `r3f-adapter.tsx` therefore keys
82
+ // that provider off `host.game`, not off this flag; see the comment
83
+ // beside `const game = host.game` there for the measured symptom
84
+ // (`vgai eval 'await game.providers()'` listing only built-ins).
75
85
  engineRuntime: false,
76
86
  });
77
87
  }