@vgai/engine 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +18 -11
  2. package/package.json +10 -6
  3. package/schemas/engine-api.json +1 -68
  4. package/schemas/engine-api.md +1 -32
  5. package/schemas/engine-capabilities.json +30 -42
  6. package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
  7. package/src/adapter/adapter-surface.ts +5 -5
  8. package/src/adapter/authoring.ts +168 -226
  9. package/src/adapter/colyseus-networking-adapter.ts +44 -5
  10. package/src/adapter/first-party-systems.ts +156 -42
  11. package/src/adapter/host-context.ts +177 -46
  12. package/src/adapter/index.ts +47 -51
  13. package/src/adapter/ingest/game-contract.ts +2 -2
  14. package/src/adapter/ingest/scene-capture.ts +18 -19
  15. package/src/adapter/ingest/structural-ids.ts +127 -0
  16. package/src/adapter/ingest/upstream-pin.ts +9 -12
  17. package/src/adapter/loop-gate-report.ts +11 -11
  18. package/src/adapter/rapier-physics-adapter.ts +27 -9
  19. package/src/adapter/root-adapter.ts +217 -0
  20. package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
  21. package/src/adapter/system-adapter.ts +80 -63
  22. package/src/ai/navigation.ts +1 -1
  23. package/src/animation/animation-clock.ts +1 -1
  24. package/src/animation/camera-ownership.ts +1 -2
  25. package/src/animation/cubic-spline-interpolant.ts +132 -0
  26. package/src/animation/theatre-clock-binding.ts +2 -2
  27. package/src/animation/theatre-object-binding.ts +4 -4
  28. package/src/animation/xstate-animation-binding.ts +75 -5
  29. package/src/{scene/schema → asset-formats}/camera.ts +2 -5
  30. package/src/{scene/schema → asset-formats}/collider.ts +2 -5
  31. package/src/asset-formats/index.ts +54 -0
  32. package/src/{scene/schema → asset-formats}/instances.ts +10 -6
  33. package/src/{scene/schema → asset-formats}/light.ts +3 -6
  34. package/src/{scene/schema → asset-formats}/material.ts +4 -7
  35. package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
  36. package/src/asset-formats/parse.ts +39 -0
  37. package/src/{scene/schema → asset-formats}/particles.ts +3 -6
  38. package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
  39. package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
  40. package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
  41. package/src/asset-parse-error.ts +33 -0
  42. package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
  43. package/src/assets.ts +1 -1
  44. package/src/audio/wav-encode.ts +9 -9
  45. package/src/canvas-react/engine-bridge.ts +59 -0
  46. package/src/canvas-react/index.ts +50 -0
  47. package/src/canvas-react/pixi-primitive.tsx +202 -0
  48. package/src/canvas-react/pixi-react-adapter.tsx +290 -0
  49. package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
  50. package/src/canvas-react/world-context.ts +328 -0
  51. package/src/core/frame-pacing.ts +100 -0
  52. package/src/core/game-loop.ts +50 -28
  53. package/src/core/seeded-random.ts +7 -7
  54. package/src/core/sim-clock.ts +388 -0
  55. package/src/core/system-runner.ts +17 -63
  56. package/src/core/types.ts +34 -15
  57. package/src/data/data-asset.ts +3 -3
  58. package/src/data/data-check-core.ts +6 -7
  59. package/src/data/data-ref.ts +11 -11
  60. package/src/data/vite-plugin-data.ts +10 -10
  61. package/src/{scene/defaults.ts → defaults.ts} +18 -40
  62. package/src/dev/render-debug-adapter.ts +1 -1
  63. package/src/dev/webgl-frame-capture.ts +1 -1
  64. package/src/ecs/scene-index.ts +439 -0
  65. package/src/ecs/scene-query.ts +43 -0
  66. package/src/{scene → ecs}/user-data.ts +17 -36
  67. package/src/index.ts +7 -9
  68. package/src/input/input-manager.ts +29 -32
  69. package/src/input/input-types.ts +2 -2
  70. package/src/input/schema.ts +5 -5
  71. package/src/loader.ts +57 -0
  72. package/src/manifest/editor-port.ts +69 -0
  73. package/src/manifest/filename.ts +49 -0
  74. package/src/manifest/index.ts +8 -2
  75. package/src/manifest/load-file.ts +11 -0
  76. package/src/manifest/load.ts +65 -77
  77. package/src/manifest/locate.ts +55 -0
  78. package/src/manifest/schema.ts +400 -233
  79. package/src/{scene → physics}/collider-dimensions.ts +3 -3
  80. package/src/physics/physics-registry.ts +1 -1
  81. package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
  82. package/src/pixi/index.ts +43 -0
  83. package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
  84. package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
  85. package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
  86. package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
  87. package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
  88. package/src/react/unmanaged-root-detector.ts +26 -1
  89. package/src/react/use-data.ts +5 -5
  90. package/src/react/use-selection.tsx +15 -42
  91. package/src/react/{game-state.tsx → world-state.tsx} +44 -46
  92. package/src/render/auto-batcher.ts +1 -2
  93. package/src/{scene → render}/instance-mesh.ts +1 -1
  94. package/src/{scene → render}/light-camera-factory.ts +14 -13
  95. package/src/render/lod.ts +17 -0
  96. package/src/{scene → render}/material-factory.ts +8 -7
  97. package/src/{scene → render}/particles-factory.ts +62 -12
  98. package/src/render/render-batch-system.ts +14 -41
  99. package/src/render/render-features.ts +1 -1
  100. package/src/render/render-settings.ts +1 -2
  101. package/src/render/spark-renderer-lifecycle.ts +1 -1
  102. package/src/runtime/create-runtime.ts +352 -463
  103. package/src/runtime/debug-bridge.ts +148 -93
  104. package/src/runtime/debug-registry.ts +79 -54
  105. package/src/runtime/dev-layers.ts +40 -0
  106. package/src/runtime/frame-selector-cache.ts +4 -4
  107. package/src/runtime/game.ts +618 -369
  108. package/src/runtime/gameplay-rng-trap.ts +6 -7
  109. package/src/runtime/input-router.ts +11 -11
  110. package/src/runtime/mount-game.ts +54 -55
  111. package/src/runtime/mount-manifest.ts +154 -150
  112. package/src/runtime/presentation.ts +141 -0
  113. package/src/runtime/render-audio-control.ts +64 -53
  114. package/src/runtime/render-control.ts +45 -51
  115. package/src/runtime/render-seed.ts +3 -4
  116. package/src/runtime/state-bridge.ts +17 -18
  117. package/src/runtime/types.ts +94 -61
  118. package/src/setup/setup-renderer.ts +11 -6
  119. package/src/world3d-react/engine-bridge.ts +46 -33
  120. package/src/world3d-react/index.ts +31 -26
  121. package/src/world3d-react/r3f-adapter.tsx +211 -96
  122. package/src/world3d-react/r3f-root-factory.tsx +91 -0
  123. package/src/world3d-react/renderer-config.ts +137 -0
  124. package/src/world3d-react/world-context.ts +141 -111
  125. package/schemas/entity2d.schema.json +0 -468
  126. package/schemas/prefab.schema.json +0 -9992
  127. package/schemas/scn2d.schema.json +0 -494
  128. package/schemas/vscn.schema.json +0 -10851
  129. package/src/adapter/game-adapter.ts +0 -164
  130. package/src/adapter/ingest/overlay-applier.ts +0 -207
  131. package/src/adapter/ingest/overlay-apply.ts +0 -168
  132. package/src/adapter/ingest/overlay-file.ts +0 -126
  133. package/src/adapter/ingest/overlay-report.ts +0 -176
  134. package/src/animation/gsap-registration.ts +0 -184
  135. package/src/audio/audio-introspection.ts +0 -290
  136. package/src/audio/index.ts +0 -39
  137. package/src/audio/tone-clock-binding.ts +0 -98
  138. package/src/audio/tone-context.ts +0 -175
  139. package/src/audio/tone-offline-render.ts +0 -167
  140. package/src/ecs/component-manager.ts +0 -814
  141. package/src/ecs/game-component.ts +0 -260
  142. package/src/ecs/hmr-swap-report.ts +0 -65
  143. package/src/physics/trigger-dispatch.ts +0 -97
  144. package/src/react/root-adapter.tsx +0 -49
  145. package/src/scene/asset-paths.ts +0 -121
  146. package/src/scene/asset-ref-check.ts +0 -248
  147. package/src/scene/component-registry.ts +0 -51
  148. package/src/scene/parse.ts +0 -204
  149. package/src/scene/scene-apply.ts +0 -407
  150. package/src/scene/scene-diff-schema.ts +0 -115
  151. package/src/scene/scene-diff-types.ts +0 -29
  152. package/src/scene/scene-loader.ts +0 -1526
  153. package/src/scene/scene-query.ts +0 -63
  154. package/src/scene/scene-types.ts +0 -33
  155. package/src/scene/scene-version.ts +0 -40
  156. package/src/scene/schema/animation.ts +0 -46
  157. package/src/scene/schema/audio.ts +0 -25
  158. package/src/scene/schema/entity-ref.ts +0 -78
  159. package/src/scene/schema/entity.ts +0 -189
  160. package/src/scene/schema/index.ts +0 -51
  161. package/src/scene/schema/joint.ts +0 -26
  162. package/src/scene/schema/physics.ts +0 -49
  163. package/src/scene/schema/scene-file.ts +0 -292
  164. package/src/scene/schema/shadow.ts +0 -24
  165. package/src/scene/schema/spline.ts +0 -21
  166. package/src/world2d/asset-paths2d.ts +0 -44
  167. package/src/world2d/capture-to-scene2d.ts +0 -52
  168. package/src/world2d/collision-2d.ts +0 -99
  169. package/src/world2d/entity2d-asset.ts +0 -22
  170. package/src/world2d/index.ts +0 -91
  171. package/src/world2d/physics2d-transform.ts +0 -173
  172. package/src/world2d/physics2d-units.ts +0 -10
  173. package/src/world2d/pixi-game-adapter.ts +0 -439
  174. package/src/world2d/pixi-surface.ts +0 -78
  175. package/src/world2d/scene2d-identity.ts +0 -49
  176. package/src/world2d/scene2d-loader.ts +0 -433
  177. package/src/world2d/schema/entity2d.ts +0 -163
  178. package/src/world2d/schema/physics2d.ts +0 -64
  179. package/src/world2d/schema/sprite.ts +0 -99
  180. package/src/world2d/schema/tilemap.ts +0 -39
  181. package/src/world2d/schema/tuples2d.ts +0 -25
  182. package/src/world2d/transform-writer-2d.ts +0 -42
  183. package/src/world2d/types.ts +0 -74
  184. package/src/world3d-react/behavior.tsx +0 -146
  185. /package/src/{scene → render}/mesh-shadow.ts +0 -0
@@ -1,71 +1,74 @@
1
1
  /**
2
2
  * `createR3FAdapter` — wraps a react-three-fiber tree as a first-party
3
- * `GameAdapter` so an R3F scene mounts as an ordinary `kind: "threejs"`
4
- * world: the engine hands `mount()` a `HostContext` (ITS OWN renderer +
3
+ * `RootAdapter` so an R3F scene mounts as an ordinary `kind: "three"`
4
+ * world: the engine hands `mount()` a `ThreeHostContext` (ITS OWN renderer +
5
5
  * canvas + gated loop, per `../adapter/host-context.ts`); this returns a
6
- * `MountedThreeWorld` (`../adapter/game-adapter.ts`) whose `scene`/`camera`
6
+ * `MountedThreeRoot` (`../adapter/root-adapter.ts`) whose `scene`/`camera`
7
7
  * are fiber's REAL `THREE.Scene`/`THREE.Camera` instances.
8
8
  *
9
- * Upstreamed from `examples/r3f-first-party/src/r3f-adapter.tsx` (R4,
10
- * docs/R3F-FOLLOW-THROUGH-SPEC.md — the `@engine/world3d-react` opt-in
11
- * module R3F-FIRST-PARTY-DESIGN §1.B Phase 2 names, mirroring how `world2d/`
12
- * is pixi's opt-in home). The mount semantics are byte-for-byte the
13
- * example's live-proven Phase-1 bridge; the one addition is the optional
14
- * `components` registry threaded into `EngineBridge` so `<Behavior>` can
15
- * resolve project component classes without the engine importing project
16
- * code. External adopters (alien-stories-style) keep the exact same calling
17
- * contract.
9
+ * Upstreamed from `examples/r3f-first-party/src/r3f-adapter.tsx` (R4 — the
10
+ * `@engine/world3d-react` opt-in module, mirroring how `pixi/` is the
11
+ * PixiJS surface's opt-in home). The mount semantics
12
+ * are byte-for-byte the example's live-proven Phase-1 bridge. External adopters
13
+ * (alien-stories-style) keep the exact same calling contract.
18
14
  *
19
- * three.js identity (docs/ADAPTER-AUTHORING-GUIDE.md §2): this module never
20
- * imports `three` itself for scene objects — fiber's internal `import * as
21
- * THREE from 'three'` must resolve to the SAME instance the engine's
22
- * `HostContext.three` points at, which the importing project guarantees via
23
- * its Vite `resolve.dedupe: ['three', 'react', 'react-dom']` (see
24
- * `examples/r3f-first-party/vite.config.ts`).
15
+ * three.js identity: this module never imports `three` itself for scene objects
16
+ * — fiber's internal `import * as THREE from 'three'` must resolve to the SAME
17
+ * instance the engine's `ThreeHostContext.three` points at, which the importing
18
+ * project guarantees via its Vite `resolve.dedupe: ['three', 'react',
19
+ * 'react-dom']` (see `examples/r3f-first-party/vite.config.ts`).
25
20
  */
26
21
 
27
- import { advance, createRoot, extend, type RootState } from '@react-three/fiber';
22
+ import {
23
+ advance,
24
+ createRoot,
25
+ extend,
26
+ events as pointerEvents,
27
+ type RootState,
28
+ } from '@react-three/fiber';
28
29
  import { createElement, type ReactNode } from 'react';
29
- import type { GameAdapter, HostContext, MountedThreeWorld } from '../adapter';
30
- import { type BehaviorRegistry, EngineBridge, type EngineBridgeValue } from './engine-bridge';
31
- import { createR3FWorldContext } from './world-context';
30
+ import type { MountedThreeRoot, RootAdapter, ThreeHostContext } from '../adapter';
31
+ import { WorldProvider } from '../react/world-state';
32
+ import { EngineBridge, type EngineBridgeValue } from './engine-bridge';
33
+ import { applyWorldRendererConfig, type WorldRendererConfig } from './renderer-config';
34
+ import { createR3FRootContext } from './world-context';
32
35
 
33
- /** What {@link createR3FAdapter} needs to build one `GameAdapter`. */
36
+ /** What {@link createR3FAdapter} needs to build one `RootAdapter`. */
34
37
  export interface CreateR3FAdapterOptions {
35
- /** Stable id (telemetry/registry/conformance) — `GameAdapter.id`. */
38
+ /** Stable id (telemetry/registry/conformance) — `RootAdapter.id`. */
36
39
  readonly id: string;
37
40
  /** The R3F scene tree to mount — drei helpers, `useFrame` hooks, etc. all
38
41
  * work unchanged (design §1.C: "drei works unchanged (it is fiber-context
39
42
  * userland)"). */
40
43
  readonly content: ReactNode;
41
- /** The project's behavior registry — `<Behavior type="…">` resolves against
42
- * it through `EngineBridge`. Optional: a tree with no `<Behavior>` usage
43
- * needs none; a `<Behavior>` rendered without it degrades loudly (warn,
44
- * no attach) rather than crashing. */
45
- readonly components?: BehaviorRegistry | undefined;
46
44
  /** Input map loaded through the game-owned `Game.loadInputMap` (load-once
47
45
  * across roots). Defaults to the classic adapter's conventional path,
48
46
  * `/inputmaps/default.inputmap.json`; `null` opts out (a world with no
49
47
  * actions). A missing/invalid map degrades loudly without failing the
50
48
  * mount — see `world-context.ts`. */
51
49
  readonly inputMapPath?: string | null | undefined;
50
+ /** The colour pipeline this world was AUTHORED for, applied to the host's renderer for the life
51
+ * of the mount and restored on dispose. Omit it (every world here does) to keep the host's own
52
+ * defaults; declare it when the world's colours were picked against a different engine's
53
+ * pipeline — see `./renderer-config.ts`. */
54
+ readonly renderer?: WorldRendererConfig | undefined;
52
55
  }
53
56
 
54
57
  /**
55
- * Build a `GameAdapter` that mounts `options.content` through
58
+ * Build a `RootAdapter` that mounts `options.content` through
56
59
  * react-three-fiber, gated entirely by the host's own loop and rendering
57
60
  * through the host's own `WebGLRenderer` — never a second renderer, never a
58
61
  * second `requestAnimationFrame` loop (design §1.C).
59
62
  */
60
- export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter {
61
- const { id, content, components: registry, inputMapPath } = options;
63
+ export function createR3FAdapter(options: CreateR3FAdapterOptions): RootAdapter {
64
+ const { id, content, inputMapPath, renderer: rendererConfig } = options;
62
65
 
63
66
  return {
64
67
  id,
65
68
 
66
- async mount(host: HostContext): Promise<MountedThreeWorld> {
67
- // Headless honesty (design §1.D; `docs/ADAPTER-AUTHORING-GUIDE.md` §2's
68
- // "Your `mount()` must work headless" rule): the conformance kit / CI
69
+ async mount(host: ThreeHostContext): Promise<MountedThreeRoot> {
70
+ // Headless honesty every `mount()` must work headless: the
71
+ // conformance kit / CI
69
72
  // runs `mount()` in Node — no canvas, no WebGL, no fiber reconciler.
70
73
  // Guard the ENTIRE fiber mount behind `!host.headless` and return a
71
74
  // bare, real scene + camera instead (still `instanceof
@@ -78,7 +81,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
78
81
  const scene = new host.three.Scene();
79
82
  const camera = new host.three.PerspectiveCamera();
80
83
  return {
81
- kind: 'threejs',
84
+ kind: 'three',
82
85
  scene,
83
86
  camera,
84
87
  drivesOwnLoop: false,
@@ -99,31 +102,37 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
99
102
  // per mount is idempotent.
100
103
  extend(host.three as unknown as Parameters<typeof extend>[0]);
101
104
 
102
- // W3 (docs/R3F-SURFACE-SPIKE.md) the engine component runtime this
103
- // world OWNS: a real SystemRunner + ComponentManager pair, provided to
104
- // the R3F tree via `EngineBridge` so `<Behavior>` (./behavior.tsx) can
105
- // attach registry GameComponents with the engine's REAL attach path.
106
- // `update(dt)` below runs the phases under the host loop, so attached
107
- // components tick in engine phase order and freeze under pause exactly
108
- // like `useFrame` work does.
105
+ // The engine runtime this world OWNS: a real SystemRunner,
106
+ // provided to the R3F tree via `EngineBridge`.
107
+ // `update(dt)` below runs the phases under the host loop, so registered
108
+ // systems tick in engine phase order and freeze under
109
+ // pause exactly like `useFrame` work does.
109
110
  //
110
- // HONESTY NOTE: the manager's ctx is still a PARTIAL GameContext, but
111
- // the shared game-scoped subsystems are now REAL classic-adapter parity
112
- // (`./world-context.ts`): `ctx.debug`/`ctx.random`/`ctx.game`/`ctx.roots`,
113
- // and `ctx.input` (the game-owned InputManager, with this world's
114
- // virtual-input/actions/trace debug seams registered and the project's
115
- // input map loaded). The subsystems an R3F world genuinely does not
116
- // build (Rapier, composer, audio, particles, debugDraw) are THROWING
117
- // getters naming the limitation never a silent `undefined`.
118
- // `scene`/`camera` are backfilled right after fiber's first commit
119
- // resolves them, before any tick can run.
120
- const runtime = createR3FWorldContext(host, { id, inputMapPath });
121
- const { systems, components } = runtime;
122
- const bridge: EngineBridgeValue = { components, registry };
123
- // Actions must exist before any Behavior component's init() reads them —
124
- // wait for the (never-rejecting) input-map load before the first commit.
111
+ // HONESTY NOTE: the manager's ctx is an `R3FGameContext`, not the full
112
+ // `GameContext`. The shared game-scoped subsystems are REAL
113
+ // classic-adapter parity (`./world-context.ts`):
114
+ // `ctx.debug`/`ctx.random`/`ctx.game`/`ctx.roots`, and `ctx.input` (the
115
+ // game-owned InputManager, with this world's virtual-input/actions/trace
116
+ // debug seams registered and the project's input map loaded). The
117
+ // subsystems an R3F world genuinely does not build (Rapier, composer,
118
+ // audio, particles, debugDraw) are absent from that TYPE, so reaching
119
+ // for one is a compile error rather than anything that can happen at
120
+ // runtime. `scene`/`camera` are backfilled right after fiber's first
121
+ // commit resolves them, before any tick can run.
122
+ const runtime = createR3FRootContext(host, { id, inputMapPath });
123
+ const { systems } = runtime;
124
+ // `ctx` rides the bridge so ordinary components can reach engine
125
+ // services through `useGameContext()`.
126
+ const bridge: EngineBridgeValue = { ctx: runtime.ctx };
127
+ // Actions must exist before any component's init()/first frame reads
128
+ // them — wait for the (never-rejecting) input-map load before the first
129
+ // commit.
125
130
  await runtime.inputMapReady;
126
131
 
132
+ // The world's own colour pipeline. Applied AFTER `root.configure()` below — see the comment
133
+ // at its call site for why "before" is the version that silently does nothing.
134
+ let restoreRendererConfig: () => void = () => {};
135
+
127
136
  const canvas = host.surface.canvas;
128
137
  const root = createRoot(canvas);
129
138
 
@@ -149,6 +158,28 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
149
158
  // renders THROUGH `host.renderer` instead of constructing its own
150
159
  // `WebGLRenderer`.
151
160
  gl: host.renderer,
161
+ // …and the host's shadow map, not fiber's.
162
+ //
163
+ // `configure()` runs `gl.shadowMap.enabled = !!shadows`
164
+ // UNCONDITIONALLY, against its own default of `false`. Passing nothing
165
+ // therefore does not mean "leave it alone" — it means "turn shadows
166
+ // off on the renderer the host just configured". Every three root
167
+ // silently did that (FX-5): `applyRendererSettings` sets
168
+ // `shadowMap.enabled` from the `shadows` render feature, which
169
+ // defaults ON, and the setting had no surviving reader on this path —
170
+ // so `castShadow`/`receiveShadow` all over a world produced nothing
171
+ // and the scene read flat, with no error anywhere.
172
+ //
173
+ // Reading it back off the renderer is exactly "don't clobber": a
174
+ // boolean also pins `shadowMap.type` to `PCFSoftShadowMap`, which is
175
+ // what `createHostRenderer` already sets, so the round trip is
176
+ // lossless and the HOST stays the one deciding.
177
+ //
178
+ // Optional-chained even though `WebGLRenderer.shadowMap` is not
179
+ // optional: a headless/stub host renderer is a real shape in this
180
+ // repo's tests, and fiber's own `if (gl.shadowMap)` guard means the
181
+ // value it reads there is ignored anyway.
182
+ shadows: host.renderer.shadowMap?.enabled ?? false,
152
183
  // The engine's gated loop is the ONLY loop — fiber must never run
153
184
  // its own rAF (that would defeat editor pause; design §1.C's
154
185
  // `drivesOwnLoop: false` contract, guide §2's loop-model note).
@@ -159,20 +190,70 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
159
190
  // to 0 (this bridge always fills its whole canvas; no offset
160
191
  // viewport in v1).
161
192
  size: { width: host.surface.width, height: host.surface.height, top: 0, left: 0 },
162
- // `events` deliberately left at fiber's default (binds to
163
- // `gl.domElement`, i.e. `host.surface.canvas`) — design O1's open
164
- // question about a delegating multi-canvas input router only
165
- // matters for a shell+net composite; this bridge is a genuine
166
- // single-canvas world, the case O1 already says is unaffected.
193
+ // Fiber's pointer-event manager, passed EXPLICITLY.
194
+ //
195
+ // This comment used to say `events` was "deliberately left at fiber's
196
+ // default (binds to `gl.domElement`)". That was false, and it cost
197
+ // every R3F world in this engine its mesh-level pointer props —
198
+ // `onClick`, `onPointerOver`, `onPointerMissed` were dead everywhere.
199
+ // There IS no default for a bare `createRoot`: the
200
+ // `events = createPointerEvents` default lives in the `<Canvas>`
201
+ // COMPONENT, which this bridge does not use, and `configure` gates on
202
+ // `if (events && !state.events.handlers)`. With `events` undefined the
203
+ // store kept its initial `{ priority, enabled, connected: false }` —
204
+ // no handlers, nothing bound to the canvas, no error.
205
+ //
206
+ // Found by the WO-P6 builder, whose control experiment isolated the
207
+ // variable properly: the same stub renderer, the same
208
+ // `frameloop: 'never'`, the same jsdom canvas and pointer sequence,
209
+ // differing ONLY in passing `events` — and `onClick` fired. Fiber is
210
+ // fine under a host-driven loop; we omitted one property.
211
+ //
212
+ // (Design O1's open question about a delegating multi-canvas input
213
+ // router still only matters for a shell+net composite; this bridge is
214
+ // the single-canvas world O1 says is unaffected.)
215
+ events: pointerEvents,
167
216
  onCreated: (state) => resolveState(state),
168
217
  });
218
+
219
+ // THE WORLD'S OWN COLOUR PIPELINE — and it must land HERE, after `configure()`.
220
+ //
221
+ // `configure()` writes `outputColorSpace` and `toneMapping` onto the renderer
222
+ // UNCONDITIONALLY, from its own `linear`/`flat` props whose defaults are `false`/`false` —
223
+ // i.e. sRGB output and ACES. That is the SAME trap as the `shadows` note above, and it is
224
+ // silent: applying a world's config before `configure()` leaves every field set and every
225
+ // pixel unchanged (measured on the squash-the-creeps Godot port — the ground stayed at byte
226
+ // 138 with `toneMapping: 'none'` declared and applied).
227
+ //
228
+ // Applying it here is still before any frame: `advance()` only runs from `update()` below,
229
+ // and `render()` on the next line merely reconciles.
230
+ if (rendererConfig !== undefined) {
231
+ restoreRendererConfig = applyWorldRendererConfig(host.three, host.renderer, rendererConfig);
232
+ }
169
233
  // No `<StrictMode>` (design §1.C: "no StrictMode: host mounts once")
170
234
  // — this bridge mounts exactly once per `mount()` call; StrictMode's
171
235
  // deliberate double-invoke of effects would double-subscribe
172
236
  // `useFrame` callbacks against a host loop that only ticks once per
173
237
  // frame. The `EngineBridge` provider threads the component runtime
174
- // (above) to any `<Behavior>` wrapper in the tree (W3).
175
- root.render(createElement(EngineBridge.Provider, { value: bridge }, content));
238
+ // (above) into the tree (W3), where `useGameContext()` reads it.
239
+ // R3F IS React, so the React seam every dom root already has —
240
+ // `useWorldState`, `useDebugProvider`/`useDebugCommand`/`useDebugEmit`
241
+ // (`../react/world-state`) — should work identically inside a three root.
242
+ // Without this provider a behavior-as-a-hook could only reach the debug
243
+ // registry by destructuring `useGameContext()` and calling
244
+ // `debug?.registerStateProvider(...)` by hand, which has no unmount
245
+ // story: the template's follow-camera hook leaked a provider per remount
246
+ // until this landed. `host.game` is genuinely absent in bare/headless
247
+ // hosts, so the provider is conditional and those hooks stay the inert
248
+ // no-ops they already document themselves to be.
249
+ const game = runtime.ctx.game;
250
+ root.render(
251
+ createElement(
252
+ EngineBridge.Provider,
253
+ { value: bridge },
254
+ game ? createElement(WorldProvider, { game }, content) : content,
255
+ ),
256
+ );
176
257
  // A reconcile-time crash (e.g. a missing `extend` catalogue entry)
177
258
  // surfaces as an uncaught window error and `onCreated` never fires —
178
259
  // without this guard, `mount()` would await `statePromise` FOREVER and
@@ -183,6 +264,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
183
264
  cleanup();
184
265
  root.unmount();
185
266
  runtime.dispose();
267
+ restoreRendererConfig();
186
268
  reject(
187
269
  new Error(
188
270
  `r3f-adapter: fiber crashed before its first commit — ${event.message} ` +
@@ -194,6 +276,7 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
194
276
  cleanup();
195
277
  root.unmount();
196
278
  runtime.dispose();
279
+ restoreRendererConfig();
197
280
  reject(
198
281
  new Error(
199
282
  'r3f-adapter: onCreated did not fire within 10s — the R3F tree never reached ' +
@@ -228,26 +311,43 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
228
311
  // `update(dt)` tick, so no component ever observes them missing.
229
312
  runtime.setSceneCamera(state.scene, state.camera);
230
313
 
231
- // Match the classic scene-loader readiness contract: a mounted world is
232
- // not ready until every scene-authored component has completed init().
233
- // Deterministic warm-ups may advance many fixed substeps synchronously;
234
- // an init deferred to the first frame cannot make progress until after
235
- // that entire warm-up has already ended. `<Behavior>` attaches during
236
- // the commit layout phase, so initAll() sees the complete component set.
237
- await components.initAll();
314
+ // THE STORE IS THE TRUTH, NOT THE `onCreated` SNAPSHOT.
315
+ //
316
+ // `state` is one immutable zustand snapshot: fiber's store is
317
+ // `create<RootState>()`, and every `set(...)` produces a NEW state
318
+ // object rather than mutating this one. So a world that legitimately
319
+ // REPLACES the default camera `useThree(s => s.set)({ camera })`,
320
+ // which is exactly what drei's `makeDefault` does — never reaches
321
+ // anything read off `state` directly.
322
+ //
323
+ // That is not cosmetic: fiber's own `update()` renders
324
+ // `state.gl.render(state.scene, state.camera)` off the state object it
325
+ // is HANDED, so `advance(t, true, state)` below pinned the render
326
+ // camera to fiber's default `PerspectiveCamera` at (0, 0, 5) forever.
327
+ // Measured on the squash-the-creeps Godot port (WO-G14): the world set
328
+ // its authored `OrthographicCamera` and the frame still rendered from
329
+ // (0, 0, 5) — a point INSIDE that game's 60x2x60 ground box, so every
330
+ // ground polygon was back-facing, was culled, and the "ground" was the
331
+ // cleared canvas. Nothing anywhere reported an error; edit mode looked
332
+ // right because the editor renders with its own camera.
333
+ //
334
+ // `state.get()` is fiber's own `StoreApi.getState`, carried on
335
+ // `RootState` for precisely this reason, so reading it per frame is the
336
+ // supported way to see the live root. It is a plain map read.
337
+ const live = (): RootState => state.get();
238
338
 
239
339
  // The engine drives every `useFrame` through the mounted world's
240
- // `update(dt)` hook NOT `host.loop.onUpdate`. The distinction is the
340
+ // `update(dt)` hook, never off a raw host-loop callback. That is the
241
341
  // whole pause story: `runFrameImpl` (`runtime/game.ts`) calls
242
342
  // `mounted.update?.(dt)` per substep for a host-driven
243
343
  // (`drivesOwnLoop: false`) world and SKIPS it while that world is
244
- // frozen, and `Game.play.step()` ticks it exactly once — whereas the
245
- // raw loop's `onUpdate` extraUpdaters fire unconditionally, pause or
246
- // not (they exist for host-level concerns; `create-runtime.ts` runs
247
- // them outside `game.runFrame`'s gate). Advancing fiber from
248
- // `onUpdate` therefore LOOKS right and silently breaks acceptance
249
- // gate 2 proven by the 36-r3f-first-party e2e, whose paused
250
- // instance-matrix samples kept moving until this moved to `update`.
344
+ // frozen, and `Game.play.step()` ticks it exactly once — whereas any
345
+ // callback run outside `game.runFrame`'s gate would fire
346
+ // unconditionally, pause or not. Advancing fiber from such a callback
347
+ // LOOKS right and silently breaks acceptance gate 2 proven by the
348
+ // 36-r3f-first-party e2e, whose paused instance-matrix samples kept
349
+ // moving until this moved to `update`. (The former `ThreeHostContext.loop`
350
+ // seam that made the wrong path reachable was deleted, zero consumers.)
251
351
  //
252
352
  // `advance(timestamp, runGlobalEffects, state)`'s `timestamp` is
253
353
  // consumed as `THREE.Clock.elapsedTime` DIRECTLY when
@@ -263,9 +363,11 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
263
363
  // resumed frame as one giant `useFrame` delta — the exact "time passed
264
364
  // while frozen" illusion acceptance gate 2 forbids.
265
365
  let elapsed = 0;
366
+ /** The camera `ctx` was last backfilled with — see `update()`. */
367
+ let backfilledCamera = state.camera;
266
368
 
267
369
  return {
268
- kind: 'threejs',
370
+ kind: 'three',
269
371
  // Fiber's REAL `THREE.Scene`/`THREE.Camera`. Checkout development
270
372
  // dedupes `three`; a registry-installed project and the prebundled
271
373
  // editor can legitimately have distinct constructor identities, so
@@ -274,36 +376,49 @@ export function createR3FAdapter(options: CreateR3FAdapterOptions): GameAdapter
274
376
  // `state.camera`'s type (`Camera = (OrthographicCamera |
275
377
  // PerspectiveCamera) & { manual?: boolean }`) is a structural subtype
276
378
  // of `THREE.Camera`, so no cast is needed.
277
- scene: state.scene,
278
- camera: state.camera,
379
+ //
380
+ // Both are GETTERS over the live store, not snapshot fields: the host
381
+ // and the editor must see the camera the world actually renders
382
+ // through, including one the world swapped in after the first commit
383
+ // (see `live` above).
384
+ get scene() {
385
+ return live().scene;
386
+ },
387
+ get camera() {
388
+ return live().camera;
389
+ },
279
390
  drivesOwnLoop: false,
280
- // The engine's REAL ComponentManager for this world — what makes
281
- // `Game.queryByComponent` see `<Behavior>`-attached components
282
- // (`runtime/game.ts` aggregates this optional capability for every
283
- // non-first-party mount that exposes one).
284
- components,
285
391
  // Adapter surface: `debug` pre-seeded (the shared game registry's
286
392
  // adapter); game code adds capabilities via
287
393
  // `ctx.registerSystemAdapter` exactly as in a classic world.
288
394
  systems: runtime.systemAdapters,
289
395
  update(dt: number): void {
290
396
  elapsed += dt;
291
- // Engine phases FIRST (attached GameComponents mutate transforms),
397
+ const current = live();
398
+ // A world that swapped the default camera gets it onto `ctx` too —
399
+ // `ctx.camera` and the rendered camera are the same fact, and a
400
+ // component reading one while the frame uses the other is the
401
+ // silent kind of wrong. Identity-gated, so this is one comparison
402
+ // per frame in the overwhelmingly common no-swap case.
403
+ if (current.camera !== backfilledCamera) {
404
+ backfilledCamera = current.camera;
405
+ runtime.setSceneCamera(current.scene, current.camera);
406
+ }
407
+ // Engine phases FIRST (registered systems mutate transforms),
292
408
  // then fiber's advance (useFrame callbacks + the actual render see
293
- // the fresh state). Both behavior models `useFrame` and
294
- // `<Behavior>` — are host-gated: neither runs while frozen.
409
+ // the fresh state). Both are host-gated: neither runs while frozen.
295
410
  systems.run(dt);
296
- advance(elapsed, true, state);
411
+ advance(elapsed, true, current);
297
412
  },
298
413
  resize(width: number, height: number): void {
299
- state.setSize(width, height);
414
+ live().setSize(width, height);
300
415
  },
301
416
  dispose(): void {
302
- // Unmount FIRST so <Behavior> effect cleanups detach through the
303
- // live manager, then tear the runtime down (components.clear +
304
- // scoped debug-registry strip).
417
+ // Unmount FIRST so component effect cleanups run, then tear the
418
+ // runtime down (scoped debug-registry strip).
305
419
  root.unmount();
306
420
  runtime.dispose();
421
+ restoreRendererConfig();
307
422
  },
308
423
  };
309
424
  },
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `r3fRootFactory` — mount a `three` root whose entry module DEFAULT-EXPORTS a
3
+ * React component.
4
+ *
5
+ * The idiomatic-shape fix (D26). A model asked for an R3F world writes:
6
+ *
7
+ * ```tsx
8
+ * export default function World() {
9
+ * return <><ambientLight /><mesh>…</mesh></>;
10
+ * }
11
+ * ```
12
+ *
13
+ * It does NOT write `export const adapter = createR3FAdapter({ id, content })`.
14
+ * That export is pure vgai ceremony wrapped around a component the author
15
+ * already wrote, and it is the second-largest non-idiomatic thing left in a
16
+ * world file (the largest is the missing `<Canvas>`, which is a separate
17
+ * question about who owns the canvas — see D26).
18
+ *
19
+ * The `dom` root already settled this convention: its factory reads
20
+ * `entryModule.default` and wraps it. This is the same contract for three
21
+ * roots, so both surfaces answer "what do I export?" the same way.
22
+ *
23
+ * ## Why this is NOT in `mount-game.ts`
24
+ *
25
+ * `defaultThreeAdapterFactory` lives beside the registry, and the registry
26
+ * module is deliberately dependency-free: its own doc notes threejs is safe to
27
+ * ship there because `three` is already an unconditional engine dependency.
28
+ * `@react-three/fiber` is NOT — it is the PROJECT's dependency, and
29
+ * `world3d-react` is the opt-in module that owns it. Putting this factory
30
+ * there would make R3F unconditional for every game, including ones with no
31
+ * React at all.
32
+ *
33
+ * ## Compatibility
34
+ *
35
+ * An `adapter` export still wins, so existing worlds are untouched and a game
36
+ * needing full control over its own `RootAdapter` keeps it.
37
+ */
38
+
39
+ import { type ComponentType, createElement } from 'react';
40
+ import type { RootAdapter } from '../adapter/root-adapter';
41
+ import type { AdapterSurfaceFactory } from '../runtime/mount-game';
42
+ import { createR3FAdapter } from './r3f-adapter';
43
+
44
+ interface R3FEntryModuleExports {
45
+ /** The idiomatic shape: the world IS a component. */
46
+ readonly default?: ComponentType;
47
+ /** Full control, and still the winner when present. */
48
+ readonly adapter?: RootAdapter;
49
+ }
50
+
51
+ /**
52
+ * What a three entry module MEANS, in one place.
53
+ *
54
+ * Two callers need this answer and must never disagree about it: the runtime
55
+ * mount ({@link r3fRootFactory}, below) and the EDITOR's design session
56
+ * (`packages/editor/src/authoring/r3f-design-session.ts`), which design-mounts
57
+ * the same entry so edit mode authors the live fiber scene. When only the
58
+ * runtime knew about the default-export shape, the converted starter played
59
+ * fine under `npm run game` and edit mode showed "(no scene loaded)".
60
+ *
61
+ * Returns `null` when the module is neither shape — the callers differ on what
62
+ * to do about that (the factory throws, the design session declines the world
63
+ * and leaves existing paths alone).
64
+ */
65
+ export function resolveR3FEntryAdapter(entryModule: unknown, rootId: string): RootAdapter | null {
66
+ const mod = entryModule as R3FEntryModuleExports | undefined;
67
+ if (mod?.adapter) return mod.adapter;
68
+ if (typeof mod?.default === 'function') {
69
+ return createR3FAdapter({
70
+ id: rootId,
71
+ content: createElement(mod.default),
72
+ });
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Register with `registerAdapter('three', r3fRootFactory)`.
79
+ *
80
+ * Resolution order mirrors `defaultThreeAdapterFactory`'s (explicit adapter
81
+ * wins) and then accepts the default-exported component.
82
+ */
83
+ export const r3fRootFactory: AdapterSurfaceFactory = (root, ctx) => {
84
+ const adapter = resolveR3FEntryAdapter(ctx.entryModule, root.id);
85
+ if (adapter) return { kind: 'three', adapter };
86
+ throw new Error(
87
+ `r3fRootFactory: entry module "${root.entry ?? '(none)'}" for root "${root.id}" must ` +
88
+ 'default-export a React component (`export default function World() { … }`) — or export ' +
89
+ 'an `adapter` for full control.',
90
+ );
91
+ };