@vgai/engine 0.5.21 → 0.5.22
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/manifest-interpreter.d.ts +59 -0
- package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
- package/dist/adapter/manifest-interpreter.js +50 -0
- package/dist/adapter/native-debug-module.d.ts +45 -3
- package/dist/adapter/native-debug-module.d.ts.map +1 -1
- package/dist/adapter/native-debug-module.js +128 -10
- package/dist/adapter/renderer-config.d.ts +1 -1
- package/dist/adapter/renderer-config.js +1 -1
- package/dist/canvas-react/index.d.ts +5 -12
- package/dist/canvas-react/index.d.ts.map +1 -1
- package/dist/canvas-react/index.js +5 -12
- package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
- package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
- package/dist/canvas-react/pixi-react-root-factory.js +274 -36
- package/dist/core/countdown-timer.d.ts +118 -0
- package/dist/core/countdown-timer.d.ts.map +1 -0
- package/dist/core/countdown-timer.js +137 -0
- package/dist/core/deferred-commands.d.ts +127 -0
- package/dist/core/deferred-commands.d.ts.map +1 -0
- package/dist/core/deferred-commands.js +132 -0
- package/dist/core/sim-clock.d.ts +14 -2
- package/dist/core/sim-clock.d.ts.map +1 -1
- package/dist/core/sim-clock.js +14 -2
- package/dist/dev/instruments.d.ts +38 -11
- package/dist/dev/instruments.d.ts.map +1 -1
- package/dist/dev/instruments.js +81 -11
- 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 +15 -0
- package/dist/render/directional-shadow-fit.d.ts +88 -0
- package/dist/render/directional-shadow-fit.d.ts.map +1 -0
- package/dist/render/directional-shadow-fit.js +133 -0
- package/dist/render/environment-capture.d.ts +68 -0
- package/dist/render/environment-capture.d.ts.map +1 -0
- package/dist/render/environment-capture.js +90 -0
- package/dist/render/ibl-override-material.d.ts +97 -0
- package/dist/render/ibl-override-material.d.ts.map +1 -0
- package/dist/render/ibl-override-material.js +114 -0
- package/dist/runtime/game-input-seams.d.ts +51 -0
- package/dist/runtime/game-input-seams.d.ts.map +1 -0
- package/dist/runtime/game-input-seams.js +86 -0
- package/dist/runtime/game.d.ts +15 -0
- package/dist/runtime/game.d.ts.map +1 -1
- package/dist/runtime/game.js +40 -8
- package/dist/runtime/mount-game.d.ts.map +1 -1
- package/dist/runtime/mount-game.js +18 -2
- package/dist/runtime/mount-manifest.d.ts.map +1 -1
- package/dist/runtime/mount-manifest.js +4 -2
- package/dist/runtime/types.d.ts +1 -1
- package/dist/world3d-react/index.d.ts +15 -25
- package/dist/world3d-react/index.d.ts.map +1 -1
- package/dist/world3d-react/index.js +15 -25
- package/dist/world3d-react/r3f-root-factory.d.ts +19 -25
- package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
- package/dist/world3d-react/r3f-root-factory.js +396 -50
- package/dist/world3d-react/rapier-physics-bridge.d.ts +24 -12
- package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
- package/dist/world3d-react/rapier-physics-bridge.js +82 -49
- package/package.json +1 -1
- package/schemas/engine-capabilities.json +3 -3
- package/src/adapter/manifest-interpreter.ts +64 -0
- package/src/adapter/native-debug-module.ts +212 -15
- package/src/adapter/renderer-config.ts +1 -1
- package/src/canvas-react/index.ts +5 -21
- package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
- package/src/core/countdown-timer.ts +188 -0
- package/src/core/deferred-commands.ts +174 -0
- package/src/core/sim-clock.ts +14 -2
- package/src/dev/instruments.ts +108 -11
- package/src/input/input-manager.ts +15 -0
- package/src/render/directional-shadow-fit.ts +156 -0
- package/src/render/environment-capture.ts +102 -0
- package/src/render/ibl-override-material.ts +170 -0
- package/src/runtime/game-input-seams.ts +108 -0
- package/src/runtime/game.ts +65 -7
- package/src/runtime/mount-game.ts +23 -1
- package/src/runtime/mount-manifest.ts +4 -2
- package/src/runtime/types.ts +1 -1
- package/src/world3d-react/index.ts +15 -31
- package/src/world3d-react/r3f-root-factory.tsx +444 -53
- package/src/world3d-react/rapier-physics-bridge.tsx +88 -49
- package/src/canvas-react/engine-bridge.ts +0 -59
- package/src/canvas-react/pixi-react-adapter.tsx +0 -356
- package/src/canvas-react/world-context.ts +0 -253
- package/src/world3d-react/engine-bridge.ts +0 -86
- package/src/world3d-react/r3f-adapter.tsx +0 -717
- package/src/world3d-react/world-context.ts +0 -358
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace Three's image-based-lighting functions on a `MeshStandardMaterial`,
|
|
3
|
+
* per fragment, without forking three.
|
|
4
|
+
*
|
|
5
|
+
* A local-probe system cannot express itself as `material.envMap`: one envMap
|
|
6
|
+
* per object picks ONE environment for the whole draw, and the decisive work —
|
|
7
|
+
* which probe volume contains this fragment, how it fades at the boundary,
|
|
8
|
+
* what happens where volumes overlap — is per-fragment and can differ across a
|
|
9
|
+
* single `InstancedMesh`. Every such system therefore does the same three
|
|
10
|
+
* things to three's own program, and this module is that shared mechanism:
|
|
11
|
+
*
|
|
12
|
+
* 1. take `ShaderChunk.envmap_physical_pars_fragment` and rename its two
|
|
13
|
+
* entry points, so the caller's replacement can still call them as its
|
|
14
|
+
* fallback ({@link baseIblFunctions});
|
|
15
|
+
* 2. publish the fragment's world position as a varying, spliced into
|
|
16
|
+
* `<worldpos_vertex>` under `USE_ENVMAP` ({@link IBL_WORLD_POSITION});
|
|
17
|
+
* 3. swap the fragment chunk for the caller's own GLSL and extend the
|
|
18
|
+
* program cache key so the variant is not shared with an unpatched
|
|
19
|
+
* material ({@link overrideMaterialIbl}).
|
|
20
|
+
*
|
|
21
|
+
* What the caller keeps is exactly what makes its system ITS system: the
|
|
22
|
+
* lighting model in the GLSL body, the uniforms behind it, and how probes are
|
|
23
|
+
* discovered, scheduled and captured. This module decides none of that — it
|
|
24
|
+
* hands back three's own shader object and gets out of the way.
|
|
25
|
+
*
|
|
26
|
+
* Consumers today: the `reflections` capability's native probe system, and
|
|
27
|
+
* `godot-compat`'s Godot 3 `ReflectionProbe`, whose per-fragment models are
|
|
28
|
+
* deliberately different renderers over this one splice.
|
|
29
|
+
*/
|
|
30
|
+
import { type Mesh, type MeshStandardMaterial, ShaderChunk } from 'three';
|
|
31
|
+
|
|
32
|
+
/** The shader object three hands to `onBeforeCompile`. */
|
|
33
|
+
export type IblOverrideShader = Parameters<MeshStandardMaterial['onBeforeCompile']>[0];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The `varying vec3` the vertex splice publishes and the fragment body reads.
|
|
37
|
+
*
|
|
38
|
+
* It is ONE name across every consumer on purpose: the splice that declares it
|
|
39
|
+
* lives here, so a per-consumer name would be a string two files have to agree
|
|
40
|
+
* on with nothing checking that they do.
|
|
41
|
+
*/
|
|
42
|
+
export const IBL_WORLD_POSITION = 'vgaiIblWorldPosition';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Three's own IBL chunk with `getIBLIrradiance`/`getIBLRadiance` renamed to
|
|
46
|
+
* `getBaseIBLIrradiance`/`getBaseIBLRadiance`.
|
|
47
|
+
*
|
|
48
|
+
* A replacement chunk defines the original names itself; calling the renamed
|
|
49
|
+
* pair is how it falls back to the scene environment where no probe applies.
|
|
50
|
+
*/
|
|
51
|
+
export function baseIblFunctions(): string {
|
|
52
|
+
return ShaderChunk.envmap_physical_pars_fragment
|
|
53
|
+
.replaceAll('getIBLIrradiance', 'getBaseIBLIrradiance')
|
|
54
|
+
.replaceAll('getIBLRadiance', 'getBaseIBLRadiance');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every material a mesh draws with that {@link overrideMaterialIbl} accepts.
|
|
59
|
+
*
|
|
60
|
+
* `isMeshStandardMaterial` is true for `MeshPhysicalMaterial` too, which is the
|
|
61
|
+
* intent: both compile the physical IBL chunk this module replaces.
|
|
62
|
+
*/
|
|
63
|
+
export function standardMaterialsOf(mesh: Mesh): readonly MeshStandardMaterial[] {
|
|
64
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
65
|
+
return materials.filter((material): material is MeshStandardMaterial =>
|
|
66
|
+
Boolean((material as MeshStandardMaterial).isMeshStandardMaterial),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IblOverrideOptions {
|
|
71
|
+
/**
|
|
72
|
+
* The GLSL that replaces `#include <envmap_physical_pars_fragment>`. Read at
|
|
73
|
+
* COMPILE time, not at install time, so a caller whose probe count is still
|
|
74
|
+
* settling does not have to reinstall to change it.
|
|
75
|
+
*/
|
|
76
|
+
readonly fragment: () => string;
|
|
77
|
+
/**
|
|
78
|
+
* Appended to the material's own `customProgramCacheKey`, after a `|`. Must
|
|
79
|
+
* distinguish every shape of {@link IblOverrideOptions.fragment} the caller
|
|
80
|
+
* can produce — a probe COUNT belongs in here, because it changes the
|
|
81
|
+
* declared uniform array sizes.
|
|
82
|
+
*/
|
|
83
|
+
readonly cacheKey: () => string;
|
|
84
|
+
/**
|
|
85
|
+
* Runs at the end of `onBeforeCompile`, with the shader three is compiling.
|
|
86
|
+
* Seed uniforms here: this is the only moment the caller is handed the
|
|
87
|
+
* object whose `uniforms` map the program will read.
|
|
88
|
+
*/
|
|
89
|
+
readonly onCompile?: (shader: IblOverrideShader) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface IblOverride {
|
|
93
|
+
readonly material: MeshStandardMaterial;
|
|
94
|
+
/**
|
|
95
|
+
* The shader three compiled this material with, or null before the first
|
|
96
|
+
* compile and after {@link IblOverride.restore}. Uniform writes go through
|
|
97
|
+
* `shader.uniforms`.
|
|
98
|
+
*/
|
|
99
|
+
readonly shader: IblOverrideShader | null;
|
|
100
|
+
/** Put the material's own `onBeforeCompile`/`customProgramCacheKey` back. */
|
|
101
|
+
restore(): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Install the override. The returned handle is the only way to reach the
|
|
106
|
+
* compiled shader or to undo the patch; the material is otherwise untouched
|
|
107
|
+
* (in particular this never assigns `envMap`, which stays the caller's).
|
|
108
|
+
*
|
|
109
|
+
* The caller's own `onBeforeCompile` runs FIRST and its result is what gets
|
|
110
|
+
* spliced, so an override composes over a material that already had one.
|
|
111
|
+
*/
|
|
112
|
+
export function overrideMaterialIbl(
|
|
113
|
+
material: MeshStandardMaterial,
|
|
114
|
+
options: IblOverrideOptions,
|
|
115
|
+
): IblOverride {
|
|
116
|
+
const previousCompile = material.onBeforeCompile;
|
|
117
|
+
const previousCacheKey = material.customProgramCacheKey;
|
|
118
|
+
let compiled: IblOverrideShader | null = null;
|
|
119
|
+
|
|
120
|
+
material.onBeforeCompile = (shader, renderer) => {
|
|
121
|
+
previousCompile.call(material, shader, renderer);
|
|
122
|
+
requireChunk(shader.vertexShader, '#include <worldpos_vertex>');
|
|
123
|
+
requireChunk(shader.fragmentShader, '#include <envmap_physical_pars_fragment>');
|
|
124
|
+
shader.vertexShader = `varying vec3 ${IBL_WORLD_POSITION};\n${shader.vertexShader.replace(
|
|
125
|
+
'#include <worldpos_vertex>',
|
|
126
|
+
`#include <worldpos_vertex>
|
|
127
|
+
#if defined(USE_ENVMAP)
|
|
128
|
+
${IBL_WORLD_POSITION} = worldPosition.xyz;
|
|
129
|
+
#endif`,
|
|
130
|
+
)}`;
|
|
131
|
+
shader.fragmentShader = `varying vec3 ${IBL_WORLD_POSITION};\n${shader.fragmentShader.replace(
|
|
132
|
+
'#include <envmap_physical_pars_fragment>',
|
|
133
|
+
options.fragment(),
|
|
134
|
+
)}`;
|
|
135
|
+
compiled = shader;
|
|
136
|
+
options.onCompile?.(shader);
|
|
137
|
+
};
|
|
138
|
+
material.customProgramCacheKey = () => `${previousCacheKey.call(material)}|${options.cacheKey()}`;
|
|
139
|
+
material.needsUpdate = true;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
material,
|
|
143
|
+
get shader() {
|
|
144
|
+
return compiled;
|
|
145
|
+
},
|
|
146
|
+
restore() {
|
|
147
|
+
material.onBeforeCompile = previousCompile;
|
|
148
|
+
material.customProgramCacheKey = previousCacheKey;
|
|
149
|
+
material.needsUpdate = true;
|
|
150
|
+
compiled = null;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A missing chunk is a NAMED error rather than a silent no-op.
|
|
157
|
+
*
|
|
158
|
+
* `String.replace` with no match returns the string unchanged, which would
|
|
159
|
+
* leave the varying declared and never assigned — a shader that links and
|
|
160
|
+
* renders the wrong thing. The only way to reach this today is a preceding
|
|
161
|
+
* `onBeforeCompile` in the same chain having already replaced the chunk (two
|
|
162
|
+
* IBL overrides stacked on one material), and that is worth a message naming
|
|
163
|
+
* the chunk instead of a black frame.
|
|
164
|
+
*/
|
|
165
|
+
function requireChunk(source: string, chunk: string): void {
|
|
166
|
+
if (source.includes(chunk)) return;
|
|
167
|
+
throw new Error(
|
|
168
|
+
`overrideMaterialIbl needs three's ${chunk} shader chunk, and this material's program no longer contains it.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GAME-SCOPED input wiring one world mount owns — HOST work performed from
|
|
3
|
+
* outside the world's own tree, shared verbatim by the three and canvas lanes
|
|
4
|
+
* (a project has ONE input map and one bot-input doctrine, whatever surface
|
|
5
|
+
* reads them).
|
|
6
|
+
*
|
|
7
|
+
* Two things happen here:
|
|
8
|
+
*
|
|
9
|
+
* 1. the project's input map is loaded through `Game.loadInputMap` (game-owned,
|
|
10
|
+
* load-once), so the actions the project declared exist;
|
|
11
|
+
* 2. this root's seams are registered on the game-scoped debug registry
|
|
12
|
+
* (`setVirtualInputTarget`/`setInputActionsSource`/`setInputTraceSource`),
|
|
13
|
+
* which is what makes `game.input.hold/tap/...` (the bot/`vgai eval` input
|
|
14
|
+
* doctrine) and the built-in `input.actions`/`input.trace` providers
|
|
15
|
+
* resolve to this world instead of throwing `DEBUG_INPUT_UNAVAILABLE`.
|
|
16
|
+
*
|
|
17
|
+
* A world's React tree receives no vgai context — this wiring is precisely the
|
|
18
|
+
* part that never needed one: every closure below reads GAME-scoped state the
|
|
19
|
+
* host owns (`game.input`, `game.loop.fixedDt`, the game's seed).
|
|
20
|
+
*
|
|
21
|
+
* `optionalInputMap` is the CONVENTIONAL-PATH probe used when the project
|
|
22
|
+
* never named a map: an absent file is the ordinary state of a brand-new
|
|
23
|
+
* project, so it resolves quietly instead of printing an error on every boot.
|
|
24
|
+
* A map that exists and fails to parse is as loud as ever.
|
|
25
|
+
*
|
|
26
|
+
* LIFETIME, honestly: `registry.strip(id)` runs on the mount's dispose, but it
|
|
27
|
+
* walks providers/commands only — the three seams set here survive it, keyed
|
|
28
|
+
* by this world's id, until a remount of the same id overwrites them. That
|
|
29
|
+
* residue is inert rather than hidden: every closure reads game-scoped state,
|
|
30
|
+
* so a stale entry actuates exactly what a live one would. What it can still
|
|
31
|
+
* do is name a dead world in `DEBUG_INPUT_WORLD_NOT_FOUND`'s registered list.
|
|
32
|
+
* Do not write "the strip clears the input seams" anywhere without changing
|
|
33
|
+
* `strip` to actually do it.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { getSeededRandom } from '../core/seeded-random';
|
|
37
|
+
import type { DebugRegistry } from './debug-registry';
|
|
38
|
+
import type { GameInternal } from './game';
|
|
39
|
+
|
|
40
|
+
/** The conventional input-map location every scaffolded project ships
|
|
41
|
+
* (`public/inputmaps/default.inputmap.json`). */
|
|
42
|
+
export const DEFAULT_INPUT_MAP_PATH = '/inputmaps/default.inputmap.json';
|
|
43
|
+
|
|
44
|
+
/** The slice of a host context this wiring reads — satisfied structurally by
|
|
45
|
+
* both `ThreeHostContext` and `CanvasHostContext`. */
|
|
46
|
+
export interface GameInputSeamHost {
|
|
47
|
+
readonly game?: GameInternal | null | undefined;
|
|
48
|
+
readonly headless?: boolean | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function wireGameInputSeams(
|
|
52
|
+
host: GameInputSeamHost,
|
|
53
|
+
registry: DebugRegistry,
|
|
54
|
+
options: {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly inputMapPath?: string | null | undefined;
|
|
57
|
+
readonly optionalInputMap?: boolean | undefined;
|
|
58
|
+
},
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
const { id, inputMapPath = DEFAULT_INPUT_MAP_PATH, optionalInputMap = false } = options;
|
|
61
|
+
if (!host.game) return Promise.resolve();
|
|
62
|
+
const game = host.game;
|
|
63
|
+
const input = game.input;
|
|
64
|
+
registry.setInputActionsSource(id, () =>
|
|
65
|
+
input.actionNames().map((name) => ({ name, valueType: input.getActionValueType(name) })),
|
|
66
|
+
);
|
|
67
|
+
registry.setInputTraceSource(id, () => {
|
|
68
|
+
const raw = input.getInputTrace();
|
|
69
|
+
return {
|
|
70
|
+
version: raw.version,
|
|
71
|
+
seed: getSeededRandom(game)?.seed ?? null,
|
|
72
|
+
fixedDt: game.loop.fixedDt,
|
|
73
|
+
ticks: raw.ticks,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
registry.setVirtualInputTarget(id, {
|
|
77
|
+
setVirtualAction: (action, value) => input.setVirtualAction(action, value),
|
|
78
|
+
tapVirtualAction: (action) => input.tapVirtualAction(action),
|
|
79
|
+
clearVirtualActions: () => input.clearVirtualActions(),
|
|
80
|
+
scheduleActionAtTick: (tick, action, value) => input.scheduleActionAtTick(tick, action, value),
|
|
81
|
+
startInputRecording: () => input.startInputRecording(),
|
|
82
|
+
stopInputRecording: () => input.stopInputRecording(),
|
|
83
|
+
isInputRecording: () => input.isInputRecording(),
|
|
84
|
+
injectAxis: (sourceId, value) => input.injectAxis(sourceId, value),
|
|
85
|
+
injectVector2: (sourceId, value) => input.injectVector2(sourceId, value),
|
|
86
|
+
injectPointerDelta: (sourceId, delta) => input.injectPointerDelta(sourceId, delta),
|
|
87
|
+
injectPointerPosition: (sourceId, value) => input.injectPointerPosition(sourceId, value),
|
|
88
|
+
});
|
|
89
|
+
if (host.headless || inputMapPath === null) return Promise.resolve();
|
|
90
|
+
// Load-once through the game-owned path (competing paths across roots throw
|
|
91
|
+
// THERE, loudly). A FAILED load (missing/bad file) must not fail the mount:
|
|
92
|
+
// a world with no declared actions is legal. It degrades loudly instead —
|
|
93
|
+
// naming exactly what breaks.
|
|
94
|
+
// The non-optional call passes ONE argument, exactly as it always has — a
|
|
95
|
+
// trailing `undefined` is a different call to any observer of it.
|
|
96
|
+
const load = optionalInputMap
|
|
97
|
+
? game.loadInputMap(inputMapPath, { optional: true })
|
|
98
|
+
: game.loadInputMap(inputMapPath);
|
|
99
|
+
return load.catch((err: unknown) => {
|
|
100
|
+
// biome-ignore lint/suspicious/noConsole: deliberate loud degrade — the documented alternative to failing the mount (see comment above)
|
|
101
|
+
console.error(
|
|
102
|
+
`world "${id}": failed to load input map "${inputMapPath}" — declared input ` +
|
|
103
|
+
'actions and `game.input.*` (bot/virtual input) will not work until a valid map ' +
|
|
104
|
+
`loads. Ship one at the conventional path (${DEFAULT_INPUT_MAP_PATH}). ` +
|
|
105
|
+
`Cause: ${err instanceof Error ? err.message : String(err)}`,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
}
|
package/src/runtime/game.ts
CHANGED
|
@@ -538,6 +538,21 @@ export interface Game {
|
|
|
538
538
|
readonly systemAdapters: SystemAdapters;
|
|
539
539
|
/** Subscribe when a mounted root registers or replaces a system adapter. */
|
|
540
540
|
subscribeSystemAdapters?(listener: () => void): () => void;
|
|
541
|
+
/**
|
|
542
|
+
* Install a root's STATICALLY DECLARED system-adapter slots — the native
|
|
543
|
+
* module surface's `export const systems` (adapter/native-debug-module.ts),
|
|
544
|
+
* already validated by `contract-system-adapters.ts`. The host calls this
|
|
545
|
+
* once per declaring root at mount; project components never do
|
|
546
|
+
* (ARCHITECTURE-CORE §System adapters: "Components never call
|
|
547
|
+
* `registerSystemAdapter`" — this door is the replacement, and the slots it
|
|
548
|
+
* installs take precedence over a component registration for the same root
|
|
549
|
+
* in {@link systemAdapters}'s merge, with the standard collision warning).
|
|
550
|
+
* Throws on an unmounted root id or a second declaration for the same root.
|
|
551
|
+
* Optional for the same reason {@link subscribeSystemAdapters} is: partial
|
|
552
|
+
* `Game` doubles in tests; `installNativeSystemsBindings` refuses loudly
|
|
553
|
+
* when the hosting Game lacks it.
|
|
554
|
+
*/
|
|
555
|
+
installDeclaredSystemAdapters?(rootId: string, slots: Readonly<Partial<SystemAdapters>>): void;
|
|
541
556
|
/** The one game-owned `InputManager`, shared by every first-party root. */
|
|
542
557
|
readonly input: InputManager;
|
|
543
558
|
/**
|
|
@@ -881,7 +896,13 @@ export function createGame(opts: {
|
|
|
881
896
|
// above's "once per game instance" idiom.
|
|
882
897
|
const warnedSystemAdapterKeys = new Set<string>();
|
|
883
898
|
const systemAdapterListeners = new Set<() => void>();
|
|
884
|
-
|
|
899
|
+
/** Per-root slots installed by `installDeclaredSystemAdapters` — the native
|
|
900
|
+
* module surface's static `systems` declaration. Merged BEFORE the same
|
|
901
|
+
* root's live `mounted.systems` below, so during migration a lingering
|
|
902
|
+
* component registration for a declared slot is shadowed (with the
|
|
903
|
+
* standard warning) rather than silently winning by running later. */
|
|
904
|
+
const declaredSystemAdapters = new Map<string, Readonly<Partial<SystemAdapters>>>();
|
|
905
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive merge-with-collision-report walk (per-world × per-source × per-key); splitting the collision-warn branch out would obscure that it's part of the same pass, not reduce real complexity
|
|
885
906
|
function computeSystemAdapters(): SystemAdapters {
|
|
886
907
|
// Debug is game-scoped: React hooks, probes, and the built-in time
|
|
887
908
|
// provider all register with the one registry created above, regardless
|
|
@@ -891,9 +912,11 @@ export function createGame(opts: {
|
|
|
891
912
|
// intentional shared registration it is.
|
|
892
913
|
const result: SystemAdapters = { debug: debugRegistry.adapter };
|
|
893
914
|
const ownerRootId = new Map<string, string>([['debug', '(game)']]);
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
915
|
+
const merge = (
|
|
916
|
+
owner: string,
|
|
917
|
+
adapters: Readonly<Partial<SystemAdapters>>,
|
|
918
|
+
quietKeys?: ReadonlySet<string>,
|
|
919
|
+
): void => {
|
|
897
920
|
for (const key of Object.keys(adapters) as (keyof SystemAdapters)[]) {
|
|
898
921
|
if (adapters[key] === undefined) continue;
|
|
899
922
|
const existingOwner = ownerRootId.get(key);
|
|
@@ -903,12 +926,19 @@ export function createGame(opts: {
|
|
|
903
926
|
// object under `systems.debug` — that is by design, not a
|
|
904
927
|
// collision, so it must never warn.
|
|
905
928
|
if (result[key] === adapters[key]) continue;
|
|
929
|
+
// A world's own DECLARED slot overriding that same world's
|
|
930
|
+
// substrate-seeded default (the three lane pre-seeds physics/audio/
|
|
931
|
+
// renderDebug into `mounted.systems`) is the door working as
|
|
932
|
+
// intended, not a collision — same silence `ctx.
|
|
933
|
+
// registerSystemAdapter` gives the same override. Cross-root
|
|
934
|
+
// collisions still warn.
|
|
935
|
+
if (quietKeys?.has(key)) continue;
|
|
906
936
|
if (!warnedSystemAdapterKeys.has(key)) {
|
|
907
937
|
warnedSystemAdapterKeys.add(key);
|
|
908
938
|
// biome-ignore lint/suspicious/noConsole: structured, greppable — mirrors this file's own reportGateShortfallOnce's deliberate direct console.warn just above
|
|
909
939
|
console.warn(
|
|
910
|
-
`[game] systemAdapters: "${key}" is registered by both
|
|
911
|
-
|
|
940
|
+
`[game] systemAdapters: "${key}" is registered by both ${existingOwner} and ` +
|
|
941
|
+
`${owner} — the FIRST registration (${existingOwner}) wins; the later ` +
|
|
912
942
|
'one is shadowed (game-scoped system adapters).',
|
|
913
943
|
);
|
|
914
944
|
}
|
|
@@ -916,7 +946,19 @@ export function createGame(opts: {
|
|
|
916
946
|
}
|
|
917
947
|
// biome-ignore lint/suspicious/noExplicitAny: SystemAdapters is a plain optional-field record; the per-key copy is correct by construction (same key on both sides), just not expressible without a cast
|
|
918
948
|
(result as any)[key] = adapters[key];
|
|
919
|
-
ownerRootId.set(key,
|
|
949
|
+
ownerRootId.set(key, owner);
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
for (const world of roots) {
|
|
953
|
+
const declared = declaredSystemAdapters.get(world.id);
|
|
954
|
+
if (declared) merge(`world "${world.id}" (declared systems export)`, declared);
|
|
955
|
+
const adapters = world.mounted.systems;
|
|
956
|
+
if (adapters) {
|
|
957
|
+
merge(
|
|
958
|
+
`world "${world.id}"`,
|
|
959
|
+
adapters,
|
|
960
|
+
declared ? new Set(Object.keys(declared)) : undefined,
|
|
961
|
+
);
|
|
920
962
|
}
|
|
921
963
|
}
|
|
922
964
|
return result;
|
|
@@ -1292,6 +1334,22 @@ export function createGame(opts: {
|
|
|
1292
1334
|
notifySystemAdaptersChanged() {
|
|
1293
1335
|
for (const listener of systemAdapterListeners) listener();
|
|
1294
1336
|
},
|
|
1337
|
+
installDeclaredSystemAdapters(rootId: string, slots: Readonly<Partial<SystemAdapters>>) {
|
|
1338
|
+
if (!roots.some((w) => w.id === rootId)) {
|
|
1339
|
+
throw new Error(
|
|
1340
|
+
`installDeclaredSystemAdapters: no mounted root is named "${rootId}" ` +
|
|
1341
|
+
`(mounted: ${roots.map((w) => `"${w.id}"`).join(', ') || 'none'}).`,
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
if (declaredSystemAdapters.has(rootId)) {
|
|
1345
|
+
throw new Error(
|
|
1346
|
+
`installDeclaredSystemAdapters: root "${rootId}" already installed its declared ` +
|
|
1347
|
+
'systems — a declaration is static and installs exactly once per mount.',
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
declaredSystemAdapters.set(rootId, slots);
|
|
1351
|
+
gameInternal.notifySystemAdaptersChanged();
|
|
1352
|
+
},
|
|
1295
1353
|
input,
|
|
1296
1354
|
state: stateBridge,
|
|
1297
1355
|
play: {
|
|
@@ -17,12 +17,17 @@
|
|
|
17
17
|
// even the 'three' convenience export is a plain function the CALLER
|
|
18
18
|
// registers, not something this module wires up automatically.
|
|
19
19
|
|
|
20
|
+
import { declaredRoots } from '../adapter/manifest-interpreter';
|
|
20
21
|
import {
|
|
21
22
|
installNativeDebugBindings,
|
|
23
|
+
installNativeSystemsBindings,
|
|
22
24
|
type NativeDebugBinding,
|
|
25
|
+
type NativeSystemsBinding,
|
|
23
26
|
nativeDebugBindingFromEntryModule,
|
|
27
|
+
nativeSystemsBindingFromEntryModule,
|
|
24
28
|
} from '../adapter/native-debug-module';
|
|
25
29
|
import type { RootAdapter } from '../adapter/root-adapter';
|
|
30
|
+
import { publishDevInstruments } from '../dev/instruments';
|
|
26
31
|
import type { ResolvedAdapterRoot } from '../manifest/load';
|
|
27
32
|
import type { GameSession } from './create-runtime';
|
|
28
33
|
import { type MountEntry, mountManifestRoots, resolveManifest } from './mount-manifest';
|
|
@@ -223,6 +228,15 @@ function nativeDebugForEntry(
|
|
|
223
228
|
return binding ? [binding] : [];
|
|
224
229
|
}
|
|
225
230
|
|
|
231
|
+
function nativeSystemsForEntry(
|
|
232
|
+
world: ResolvedAdapterRoot,
|
|
233
|
+
entryModule: unknown,
|
|
234
|
+
): NativeSystemsBinding[] {
|
|
235
|
+
if (entryModule === undefined || world.adapter.type !== 'builtin') return [];
|
|
236
|
+
const binding = nativeSystemsBindingFromEntryModule(world.id, entryModule, world.surface);
|
|
237
|
+
return binding ? [binding] : [];
|
|
238
|
+
}
|
|
239
|
+
|
|
226
240
|
/**
|
|
227
241
|
* Mount every world declared by a `vgai.project.json` manifest onto `host
|
|
228
242
|
* .container`, resolving each world's kind through the `registerAdapter`
|
|
@@ -255,8 +269,9 @@ export async function mountGameFromManifest(
|
|
|
255
269
|
const manifest = resolveManifest(manifestInput);
|
|
256
270
|
const entries: Record<string, MountEntry> = { ...opts.entries };
|
|
257
271
|
const nativeDebug: NativeDebugBinding[] = [];
|
|
272
|
+
const nativeSystems: NativeSystemsBinding[] = [];
|
|
258
273
|
|
|
259
|
-
for (const world of manifest
|
|
274
|
+
for (const world of declaredRoots(manifest)) {
|
|
260
275
|
if (entries[world.id] !== undefined) continue; // explicit entry always wins
|
|
261
276
|
|
|
262
277
|
const factory = registry.get(world.adapter.identity);
|
|
@@ -266,6 +281,7 @@ export async function mountGameFromManifest(
|
|
|
266
281
|
? await host.loadEntryModule(world.entry)
|
|
267
282
|
: undefined;
|
|
268
283
|
nativeDebug.push(...nativeDebugForEntry(world, entryModule));
|
|
284
|
+
nativeSystems.push(...nativeSystemsForEntry(world, entryModule));
|
|
269
285
|
entries[world.id] = await factory(world, { host, entryModule });
|
|
270
286
|
continue;
|
|
271
287
|
}
|
|
@@ -290,5 +306,11 @@ export async function mountGameFromManifest(
|
|
|
290
306
|
headless: host.headless,
|
|
291
307
|
});
|
|
292
308
|
installNativeDebugBindings(session.game, nativeDebug);
|
|
309
|
+
installNativeSystemsBindings(session.game, nativeSystems);
|
|
310
|
+
// The universal instruments are host furniture on EVERY mount path — the
|
|
311
|
+
// editor installs them in its own bindings pass; the standalone composer
|
|
312
|
+
// installs them here. The disposer is deliberately dropped: the set's one
|
|
313
|
+
// resource lives on this Game and dies with it.
|
|
314
|
+
publishDevInstruments(session.game);
|
|
293
315
|
return session;
|
|
294
316
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// `fromSetup` here adds no NEW bundle weight — only pixi/react are avoided.
|
|
25
25
|
|
|
26
26
|
import { assertNever } from '../adapter/adapter-surface';
|
|
27
|
+
import { declaredRoots } from '../adapter/manifest-interpreter';
|
|
27
28
|
import type { RootAdapter } from '../adapter/root-adapter';
|
|
28
29
|
// Value-imported from the implementer's OWN path, not the type-only
|
|
29
30
|
// `../adapter` barrel (P-6).
|
|
@@ -382,12 +383,13 @@ function buildRootMountSpec(
|
|
|
382
383
|
*/
|
|
383
384
|
export async function mountManifestRoots(opts: MountManifestOptions): Promise<GameSession> {
|
|
384
385
|
const manifest = resolveManifest(opts.manifest);
|
|
385
|
-
|
|
386
|
+
const mountPlan = declaredRoots(manifest);
|
|
387
|
+
if (mountPlan.length === 0) {
|
|
386
388
|
throw new Error('mountManifestRoots: manifest declares no roots — nothing to mount.');
|
|
387
389
|
}
|
|
388
390
|
const entries = opts.entries ?? {};
|
|
389
391
|
|
|
390
|
-
const roots: RootMountSpec[] =
|
|
392
|
+
const roots: RootMountSpec[] = mountPlan.map((world) =>
|
|
391
393
|
buildRootMountSpec(world, entries[world.id]),
|
|
392
394
|
);
|
|
393
395
|
|
package/src/runtime/types.ts
CHANGED
|
@@ -299,7 +299,7 @@ export interface EditorPreview {
|
|
|
299
299
|
*
|
|
300
300
|
* Its one former reader — `browser-play-entry.ts`'s whole-bundle `setup()`,
|
|
301
301
|
* which dispatched over the generated `SETUP_BY_EXAMPLE_ID` table — is
|
|
302
|
-
* DELETED. Hosted dispatch is per-root (`
|
|
302
|
+
* DELETED. Hosted dispatch is per-root (`THREE_BY_EXAMPLE_ROOT` /
|
|
303
303
|
* `REACT_BY_EXAMPLE_ROOT`, resolved in `adapter-resolver.ts`) and never
|
|
304
304
|
* consults this field; `exampleDispatchFor` still sets it as the marker for
|
|
305
305
|
* "play the project's own shipped game" rather than a `?scene=` override
|
|
@@ -2,11 +2,8 @@
|
|
|
2
2
|
* world3d-react — the react-three-fiber bridge for vgai's three surface
|
|
3
3
|
* (`@engine/world3d-react`).
|
|
4
4
|
*
|
|
5
|
-
* An OPT-IN module,
|
|
6
|
-
*
|
|
7
|
-
* decision D24 — R3F TSX is the blessed three.js authoring path). Mirrors how
|
|
8
|
-
* `pixi/` is the PixiJS surface's opt-in home: a peer surface module the engine
|
|
9
|
-
* CORE never imports (enforced by
|
|
5
|
+
* An OPT-IN module, mirroring how `pixi/` is the PixiJS surface's opt-in home:
|
|
6
|
+
* a peer surface module the engine CORE never imports (enforced by
|
|
10
7
|
* `packages/engine/test/react-core-import-ban.test.ts` — this directory is an
|
|
11
8
|
* allowed react-importing entry alongside `react/`, and core files may not
|
|
12
9
|
* import it).
|
|
@@ -14,27 +11,23 @@
|
|
|
14
11
|
* Peer contract: an importing PROJECT already depends on `react`,
|
|
15
12
|
* `@react-three/fiber`, and `three` (fiber's three must be deduped to the
|
|
16
13
|
* host's single instance — `resolve.dedupe: ['three', 'react', 'react-dom']`
|
|
17
|
-
* in the project's Vite config
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* in the project's Vite config). The engine package deliberately declares no
|
|
15
|
+
* hard dependency on fiber: only projects that already mount through fiber
|
|
16
|
+
* ever import this module.
|
|
20
17
|
*
|
|
21
18
|
* Surface:
|
|
22
|
-
* - `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* does not build are absent from that type — see its doc comment for what
|
|
29
|
-
* to use instead.
|
|
30
|
-
* - `EngineBridge` / `EngineBridgeValue` — the context contract, exported
|
|
31
|
-
* for advanced adopters building their own bridge.
|
|
19
|
+
* - `r3fRootFactory` / `resolveR3FEntryAdapter` — what a three entry module
|
|
20
|
+
* MEANS: a default-exported component (`export default function World()`),
|
|
21
|
+
* mounted as a first-party `kind: "three"` world under the host's gated
|
|
22
|
+
* loop. The world's vgai surface is its entry module's static exports
|
|
23
|
+
* (`export { debug, systems } from './commands'`), connected by the host
|
|
24
|
+
* at mount.
|
|
32
25
|
*
|
|
33
26
|
* Deliberately NOT re-exported here: `<RapierPhysicsBridge>`
|
|
34
|
-
* (`@engine/world3d-react/rapier-physics-bridge`), which
|
|
35
|
-
* world's `@react-three/rapier` physics
|
|
36
|
-
* `
|
|
37
|
-
* editor's transform coordination can see it. It imports
|
|
27
|
+
* (`@engine/world3d-react/rapier-physics-bridge`), which publishes an R3F
|
|
28
|
+
* world's `@react-three/rapier` physics to the module slot the entry's
|
|
29
|
+
* `systems.physics` declaration forwards — so the colliders instrument and
|
|
30
|
+
* the editor's transform coordination can see it. It imports
|
|
38
31
|
* `@react-three/rapier`, and only a world that already depends on that library
|
|
39
32
|
* should pull it into its graph; a deep import keeps this barrel's peer
|
|
40
33
|
* contract to `react`/`@react-three/fiber`/`three` alone.
|
|
@@ -46,13 +39,4 @@ export {
|
|
|
46
39
|
type WorldRendererConfig,
|
|
47
40
|
type WorldToneMapping,
|
|
48
41
|
} from '../adapter/renderer-config';
|
|
49
|
-
export { EngineBridge, type EngineBridgeValue, useGameContext } from './engine-bridge';
|
|
50
|
-
export { type CreateR3FAdapterOptions, createR3FAdapter } from './r3f-adapter';
|
|
51
42
|
export { r3fRootFactory, resolveR3FEntryAdapter } from './r3f-root-factory';
|
|
52
|
-
export {
|
|
53
|
-
createR3FRootContext,
|
|
54
|
-
DEFAULT_INPUT_MAP_PATH,
|
|
55
|
-
type R3FGameContext,
|
|
56
|
-
type R3FRootContextOptions,
|
|
57
|
-
type R3FRootRuntime,
|
|
58
|
-
} from './world-context';
|