@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0
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/README.md +3 -1
- package/package.json +24 -4
- package/schemas/engine-api.json +124 -0
- package/schemas/engine-api.md +53 -0
- package/schemas/engine-capabilities.json +124 -0
- package/schemas/inputmap.schema.json +314 -0
- package/schemas/mat.schema.json +286 -0
- package/schemas/prefab.schema.json +10148 -0
- package/schemas/scn2d.schema.json +475 -0
- package/schemas/vgai-game.schema.json +383 -0
- package/schemas/vscn.schema.json +11007 -0
- package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
- package/src/adapter/authoring.ts +77 -0
- package/src/adapter/first-party-systems.ts +23 -34
- package/src/adapter/game-adapter.ts +8 -8
- package/src/adapter/host-context.ts +2 -4
- package/src/adapter/index.ts +4 -4
- package/src/adapter/system-adapter.ts +88 -22
- package/src/adapter/vgai-scene-game-adapter.ts +244 -194
- package/src/animation/anim-graph-types.ts +12 -43
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/camera-ownership.ts +467 -0
- package/src/animation/cinematic-cues.ts +451 -0
- package/src/animation/clip-map.ts +41 -0
- package/src/animation/gsap-registration.ts +184 -0
- package/src/animation/theatre-clock-binding.ts +111 -0
- package/src/animation/theatre-director.ts +347 -0
- package/src/animation/theatre-object-binding.ts +661 -0
- package/src/animation/xstate-animation-binding.ts +436 -0
- package/src/animation/xstate-animation-meta.ts +319 -0
- package/src/audio/index.ts +39 -7
- package/src/audio/tone-clock-binding.ts +98 -0
- package/src/audio/tone-context.ts +129 -0
- package/src/audio/tone-offline-render.ts +167 -0
- package/src/audio/wav-encode.ts +119 -0
- package/src/character/cloth-sim.ts +533 -0
- package/src/character/spring-chain.ts +307 -0
- package/src/core/game-loop.ts +57 -2
- package/src/core/seeded-random.ts +161 -0
- package/src/core/system-runner.ts +20 -3
- package/src/core/types.ts +50 -0
- package/src/data/data-asset.ts +167 -0
- package/src/data/data-check-core.ts +242 -0
- package/src/data/data-ref.ts +145 -0
- package/src/data/vite-plugin-data.ts +290 -0
- package/src/dev/performance-profiler.ts +213 -0
- package/src/dev/webgl-gpu-timer.ts +53 -0
- package/src/ecs/component-manager.ts +45 -12
- package/src/ecs/game-component.ts +95 -11
- package/src/humanoid/bake.operation.ts +326 -0
- package/src/humanoid/body.ts +663 -0
- package/src/humanoid/clips.ts +149 -0
- package/src/humanoid/compose.ts +209 -0
- package/src/humanoid/generate.ts +189 -0
- package/src/humanoid/index.ts +36 -0
- package/src/humanoid/schema.ts +108 -0
- package/src/humanoid/skeleton.ts +345 -0
- package/src/index.ts +48 -0
- package/src/input/input-manager.ts +1886 -33
- package/src/input/input-types.ts +158 -3
- package/src/input/prompt-labels.ts +122 -0
- package/src/input/rebind-controller.ts +105 -0
- package/src/input/schema.ts +206 -52
- package/src/manifest/index.ts +5 -5
- package/src/manifest/load.ts +125 -72
- package/src/manifest/schema.ts +362 -255
- package/src/react/game-state.tsx +135 -32
- package/src/react/root-adapter.tsx +49 -0
- package/src/react/unmanaged-root-detector.ts +66 -0
- package/src/react/use-data.ts +124 -0
- package/src/react/use-selection.tsx +135 -0
- package/src/runtime/create-runtime.ts +112 -273
- package/src/runtime/debug-bridge.ts +483 -0
- package/src/runtime/debug-registry.ts +856 -0
- package/src/runtime/game.ts +342 -93
- package/src/runtime/gameplay-rng-trap.ts +134 -0
- package/src/runtime/input-router.ts +7 -7
- package/src/runtime/mount-game.ts +40 -38
- package/src/runtime/mount-manifest.ts +169 -37
- package/src/runtime/render-audio-control.ts +168 -0
- package/src/runtime/render-control.ts +522 -0
- package/src/runtime/render-seed.ts +79 -0
- package/src/runtime/state-bridge.ts +24 -10
- package/src/runtime/types.ts +110 -33
- package/src/scene/asset-loaders.ts +10 -36
- package/src/scene/asset-paths.ts +0 -2
- package/src/scene/asset-ref-check.ts +248 -0
- package/src/scene/asset-registry.ts +22 -0
- package/src/scene/component-registry.ts +14 -3
- package/src/scene/defaults.ts +1 -0
- package/src/scene/light-camera-factory.ts +11 -3
- package/src/scene/parse.ts +133 -0
- package/src/scene/scene-apply.ts +55 -4
- package/src/scene/scene-loader.ts +91 -123
- package/src/scene/scene-types.ts +0 -1
- package/src/scene/schema/animation.ts +30 -79
- package/src/scene/schema/entity.ts +20 -0
- package/src/scene/schema/index.ts +2 -46
- package/src/scene/schema/light.ts +16 -1
- package/src/scene/schema/material.ts +96 -91
- package/src/scene/schema/scene-file.ts +1 -7
- package/src/scene/user-data.ts +22 -10
- package/src/setup/setup-renderer.ts +10 -3
- package/src/tools/define-tool.ts +191 -0
- package/src/world2d/authoring-2d.ts +17 -1
- package/src/world2d/collision-2d.ts +1 -1
- package/src/world2d/pixi-game-adapter.ts +19 -17
- package/src/world2d/scene2d-loader.ts +1 -0
- package/src/world2d/types.ts +8 -2
- package/src/animation/anim-graph.ts +0 -406
- package/src/animation/anim-system.ts +0 -28
- package/src/animation/property-track.ts +0 -178
- package/src/animation/schema.ts +0 -204
- package/src/audio/ambient.ts +0 -300
- package/src/audio/impacts.ts +0 -212
- package/src/audio/movement.ts +0 -140
- package/src/audio/musical.ts +0 -200
- package/src/audio/ui-sounds.ts +0 -171
- package/src/audio/vehicle.ts +0 -235
- package/src/audio/weapons.ts +0 -152
- package/src/runtime/scene-ui-bridge.ts +0 -86
- package/src/runtime/scene-ui-data.ts +0 -119
- package/src/scene/schema/ui.ts +0 -602
package/src/react/game-state.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Canonical `GameProvider`/`useGame`/`useGameState`/`useWorldObservation`
|
|
3
|
+
* entry for React adapter roots. Also home to `useDebugProvider` and
|
|
4
|
+
* `useDebugCommand`/`useDebugEmit`, the React-facing debug seam.
|
|
5
|
+
*
|
|
6
|
+
* This is a SEPARATE, react-value-importing module under
|
|
6
7
|
* `packages/engine/src/react/` — colocated per REACT-STATE-BRIDGE §3's own
|
|
7
8
|
* "colocate the react-facing hooks with the HUD seam, not the react-free
|
|
8
9
|
* core" rule, just now living IN the engine package rather than only in the
|
|
@@ -11,34 +12,26 @@
|
|
|
11
12
|
* invariant is proved by `packages/engine/test/react-core-import-ban.test.ts`
|
|
12
13
|
* (AC-F1).
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* project gets ONE canonical context, while existing projects with their own
|
|
18
|
-
* full copy (their own `createContext`) keep working untouched — adoption of
|
|
19
|
-
* THIS module is an option, never a forced migration (D-Z5).
|
|
20
|
-
*
|
|
21
|
-
* Context-identity landmine (unchanged from the template file's own history,
|
|
22
|
-
* still the reason `loadProjectGameProvider`/the virtual-module plugin exist
|
|
23
|
-
* in `packages/editor/src/adapter-resolver.ts` /
|
|
24
|
-
* `packages/editor/vite-plugin-react-world-provider.ts`): `<GameProvider>`
|
|
25
|
-
* and `useGame()` must resolve against the SAME `createContext()` call. A
|
|
26
|
-
* project that ships its OWN `src/ui/game-state.tsx` (its own
|
|
27
|
-
* `createContext`) must be wrapped by ITS OWN `GameProvider` — wrapping it
|
|
28
|
-
* with THIS module's `GameProvider` instead would split context and
|
|
29
|
-
* `useGame()` would throw "no Game in context" even though a `<GameProvider>`
|
|
30
|
-
* genuinely wraps the tree (proved by
|
|
31
|
-
* `packages/editor/test/game-state-context-split.test.tsx`, AC-F1). This is
|
|
32
|
-
* exactly why the engine-published fallback (`resolveIngestReactAdapter`'s
|
|
33
|
-
* D-Y3 upgrade, `default-react`'s optional fallback) only ever fires when the
|
|
34
|
-
* project's OWN `src/ui/game-state.tsx` is ABSENT — an unmodified foreign
|
|
35
|
-
* entry that imports no vgai hooks has no context to split.
|
|
15
|
+
* Every host and game imports this one module, so provider and hooks share
|
|
16
|
+
* one `createContext()` identity. The engine core outside `src/react/`
|
|
17
|
+
* remains React-free, enforced by `react-core-import-ban.test.ts`.
|
|
36
18
|
*/
|
|
37
19
|
|
|
38
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
createContext,
|
|
22
|
+
type PropsWithChildren,
|
|
23
|
+
useCallback,
|
|
24
|
+
useContext,
|
|
25
|
+
useEffect,
|
|
26
|
+
useRef,
|
|
27
|
+
useSyncExternalStore,
|
|
28
|
+
} from 'react';
|
|
29
|
+
import type { z } from 'zod';
|
|
39
30
|
import type { WorldStateObserver } from '../adapter';
|
|
31
|
+
import { getDebugRegistry } from '../runtime/debug-registry';
|
|
40
32
|
import { createFrameSelectorCache, type Equals, shallow } from '../runtime/frame-selector-cache';
|
|
41
33
|
import type { Game } from '../runtime/game';
|
|
34
|
+
import type { DebugCommandArgs } from '../runtime/types';
|
|
42
35
|
|
|
43
36
|
/** Re-exported for convenience — the opt-in equality for selectors that
|
|
44
37
|
* return a fresh object/array/tuple every call (default is `Object.is`). */
|
|
@@ -47,10 +40,21 @@ export { shallow };
|
|
|
47
40
|
const GameContext = createContext<Game | null>(null);
|
|
48
41
|
|
|
49
42
|
/** Provide the `Game` to `useGameState`/`useGame` for everything mounted
|
|
50
|
-
* beneath it.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
|
|
43
|
+
* beneath it. Adapter-root hosts install this provider automatically — see the module doc
|
|
44
|
+
* comment above for the context-identity rule this hook family depends on.
|
|
45
|
+
*
|
|
46
|
+
* Props use `PropsWithChildren` (children optional in the TYPE, not
|
|
47
|
+
* runtime-optional in practice — you always want children mounted under
|
|
48
|
+
* the provider) rather than a bare `{ children: ReactNode }` field: a
|
|
49
|
+
* required `children` in `P` makes TS's `createElement<P>(type, props?:
|
|
50
|
+
* Attributes & P, ...children)` overload reject the plain 3-arg call
|
|
51
|
+
* `React.createElement(GameProvider, { game }, child)` (no JSX transform,
|
|
52
|
+
* e.g. a `.ts` main composing a sibling `.tsx` HUD) with "Property
|
|
53
|
+
* 'children' is missing" even though the 3rd arg supplies it — issue #97.
|
|
54
|
+
* Making `children` optional in the type lets that overload resolve; the
|
|
55
|
+
* rest-arg children are still wired through to `children` at runtime by
|
|
56
|
+
* React itself, unchanged. See `test/game-state-create-element-types.test.tsx`. */
|
|
57
|
+
export function GameProvider({ game, children }: PropsWithChildren<{ game: Game }>) {
|
|
54
58
|
return <GameContext.Provider value={game}>{children}</GameContext.Provider>;
|
|
55
59
|
}
|
|
56
60
|
|
|
@@ -109,7 +113,7 @@ export function requireWorldObserver(game: Game, worldId: string): WorldStateObs
|
|
|
109
113
|
const world = game.world(worldId);
|
|
110
114
|
if (!world) {
|
|
111
115
|
throw new Error(
|
|
112
|
-
`useWorldObservation: no world registered with id "${worldId}" — check game.
|
|
116
|
+
`useWorldObservation: no world registered with id "${worldId}" — check game.roots for the ` +
|
|
113
117
|
'ids actually registered.',
|
|
114
118
|
);
|
|
115
119
|
}
|
|
@@ -170,3 +174,102 @@ export function useWorldObservation<T>(
|
|
|
170
174
|
() => cacheRef.current!.get(versionRef.current, () => selector(observer.snapshot())),
|
|
171
175
|
);
|
|
172
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The debug seam's react-world registration door (Task 1.5,
|
|
180
|
+
* `docs/SYNTHETIC-PLAYER-SPEC.md` §3.1): react roots have no `setup()`/
|
|
181
|
+
* `ctx.debug` of their own, so this hook registers a named state provider
|
|
182
|
+
* into the SAME game-scoped registry every `GameComponent`'s `ctx.debug`
|
|
183
|
+
* feeds (`getDebugRegistry(game)`, `runtime/debug-registry.ts`), readable
|
|
184
|
+
* through `game.systemAdapters.debug.state(name)` exactly like a
|
|
185
|
+
* `ctx.debug.registerStateProvider` call.
|
|
186
|
+
*
|
|
187
|
+
* Registers ONCE per mount (keyed on `name`) and unregisters on unmount —
|
|
188
|
+
* silently, like hot-reload's `strip()`. Re-renders never re-register or
|
|
189
|
+
* re-warn: `fn` is kept in a ref updated every render, so the registered
|
|
190
|
+
* closure always calls the CURRENT `fn` without touching the registry.
|
|
191
|
+
* Outside a `<GameProvider>` (or against a bare test `Game` built without a
|
|
192
|
+
* registry) this is an inert no-op — it never throws, matching the "no seam
|
|
193
|
+
* without a consumer" degrade every optional `ctx.*` surface follows.
|
|
194
|
+
*/
|
|
195
|
+
export function useDebugProvider(
|
|
196
|
+
name: string,
|
|
197
|
+
fn: () => unknown,
|
|
198
|
+
opts?: { tier?: 'observable' | 'assisted' },
|
|
199
|
+
): void {
|
|
200
|
+
const game = useContext(GameContext);
|
|
201
|
+
const fnRef = useRef(fn);
|
|
202
|
+
fnRef.current = fn;
|
|
203
|
+
const tier = opts?.tier;
|
|
204
|
+
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const registry = game && getDebugRegistry(game);
|
|
207
|
+
if (!registry) return;
|
|
208
|
+
return registry.registerReactProvider(name, () => fnRef.current(), { tier });
|
|
209
|
+
// `tier` is deliberately NOT a dep (same reasoning as `useDebugCommand`'s
|
|
210
|
+
// `spec`) — captured once at mount, so it never forces re-registration.
|
|
211
|
+
}, [game, name]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The command-side counterpart of {@link useDebugProvider} — registers an
|
|
216
|
+
* invokable debug command into the same game-scoped registry, readable
|
|
217
|
+
* through `game.systemAdapters.debug.commands()`/`invoke(name, args)`.
|
|
218
|
+
*
|
|
219
|
+
* Same mount/unmount/ref-latest contract as {@link useDebugProvider}: `spec`
|
|
220
|
+
* (description/args/locus) is captured at registration time, `fn` always
|
|
221
|
+
* calls through to the latest render's closure via a ref, and the hook is an
|
|
222
|
+
* inert no-op with no `<GameProvider>` in scope.
|
|
223
|
+
*
|
|
224
|
+
* Generic over the declared `args` Zod tuple, same as
|
|
225
|
+
* `DebugCtxSurface.registerCommand` ({@link DebugCommandArgs}, dry-run
|
|
226
|
+
* finding — `docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md` Wave 6 ledger): declaring
|
|
227
|
+
* `args: z.tuple([z.number(), z.string()])` types `fn`'s parameters as
|
|
228
|
+
* `(n: number, s: string) => ...` with no `unknown[]` cast; omitting `args`
|
|
229
|
+
* keeps `fn` typed `(...args: unknown[]) => ...` as before.
|
|
230
|
+
*/
|
|
231
|
+
export function useDebugCommand<T extends z.ZodTuple | undefined = undefined>(
|
|
232
|
+
name: string,
|
|
233
|
+
spec: { description?: string; args?: T; locus?: 'client' | 'server' },
|
|
234
|
+
fn: (...args: DebugCommandArgs<T>) => unknown | Promise<unknown>,
|
|
235
|
+
): void {
|
|
236
|
+
const game = useContext(GameContext);
|
|
237
|
+
const fnRef = useRef(fn);
|
|
238
|
+
fnRef.current = fn;
|
|
239
|
+
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
const registry = game && getDebugRegistry(game);
|
|
242
|
+
if (!registry) return;
|
|
243
|
+
return registry.registerReactCommand(name, spec, (...args: DebugCommandArgs<T>) =>
|
|
244
|
+
fnRef.current(...args),
|
|
245
|
+
);
|
|
246
|
+
// `spec` is deliberately NOT a dep (like `equals` above) — captured once
|
|
247
|
+
// at mount, so a fresh inline object literal every render never forces
|
|
248
|
+
// re-registration.
|
|
249
|
+
}, [game, name]);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The event-side counterpart of {@link useDebugProvider} and
|
|
254
|
+
* {@link useDebugCommand}. Returns a stable callback that writes a
|
|
255
|
+
* tick-stamped event to the game-scoped debug flight recorder:
|
|
256
|
+
*
|
|
257
|
+
* ```tsx
|
|
258
|
+
* const emit = useDebugEmit();
|
|
259
|
+
* emit('score-changed', { score });
|
|
260
|
+
* ```
|
|
261
|
+
*
|
|
262
|
+
* It is an inert no-op outside a `<GameProvider>`, matching the optional
|
|
263
|
+
* behavior of the other React debug hooks. Games never need to import the
|
|
264
|
+
* debug registry or know its internal React provenance id.
|
|
265
|
+
*/
|
|
266
|
+
export function useDebugEmit(): (event: string, detail?: unknown) => void {
|
|
267
|
+
const game = useContext(GameContext);
|
|
268
|
+
return useCallback(
|
|
269
|
+
(event: string, detail?: unknown) => {
|
|
270
|
+
if (!game) return;
|
|
271
|
+
getDebugRegistry(game)?.emitReact(event, detail);
|
|
272
|
+
},
|
|
273
|
+
[game],
|
|
274
|
+
);
|
|
275
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type ComponentType, createElement } from 'react';
|
|
2
|
+
import { flushSync } from 'react-dom';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import type { MountedReactGame, ReactRootAdapter, ReactWorldHost } from '../runtime/create-runtime';
|
|
5
|
+
import { isAdapterRegistered, registerAdapter } from '../runtime/mount-game';
|
|
6
|
+
import { GameProvider } from './game-state';
|
|
7
|
+
|
|
8
|
+
interface ReactEntryModule {
|
|
9
|
+
readonly default?: ComponentType;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Construct one canonical React root adapter for direct host composition. */
|
|
13
|
+
export function createReactRootAdapter(id: string, Entry: ComponentType): ReactRootAdapter {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
async mount(host: ReactWorldHost): Promise<MountedReactGame> {
|
|
17
|
+
if (!host.game) throw new Error(`React root "${id}" requires a Game host.`);
|
|
18
|
+
const reactRoot = createRoot(host.container);
|
|
19
|
+
flushSync(() => {
|
|
20
|
+
reactRoot.render(createElement(GameProvider, { game: host.game! }, createElement(Entry)));
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
kind: 'react',
|
|
24
|
+
container: host.container,
|
|
25
|
+
drivesOwnLoop: false,
|
|
26
|
+
dispose: () => reactRoot.unmount(),
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register the engine's React adapter-root factory. This module is an
|
|
34
|
+
* explicit optional React entry point: engine core never imports it, while a
|
|
35
|
+
* React game host imports it once and receives the canonical root lifecycle.
|
|
36
|
+
*/
|
|
37
|
+
export function registerReactAdapter(): void {
|
|
38
|
+
if (isAdapterRegistered('react')) return;
|
|
39
|
+
registerAdapter('react', (root, { entryModule }) => {
|
|
40
|
+
const Entry = (entryModule as ReactEntryModule | undefined)?.default;
|
|
41
|
+
if (!Entry) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`React root "${root.id}" entry "${root.entry ?? '(missing)'}" must default-export a component.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { kind: 'react', adapter: createReactRootAdapter(root.id, Entry) };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface UnmanagedReactRootFinding {
|
|
2
|
+
readonly file: string;
|
|
3
|
+
readonly line: number;
|
|
4
|
+
readonly api: 'createRoot' | 'hydrateRoot' | 'ReactDOM.render';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function lineAt(source: string, offset: number): number {
|
|
8
|
+
return source.slice(0, offset).split('\n').length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Find project-owned React DOM root creation. VGAI hosts React through
|
|
13
|
+
* manifest adapter roots, so any of these calls in game source creates an
|
|
14
|
+
* unmanaged second lifecycle and is a hard validation error.
|
|
15
|
+
*/
|
|
16
|
+
export function detectUnmanagedReactRoots(
|
|
17
|
+
source: string,
|
|
18
|
+
file: string,
|
|
19
|
+
): UnmanagedReactRootFinding[] {
|
|
20
|
+
const findings: UnmanagedReactRootFinding[] = [];
|
|
21
|
+
const aliases = new Map<string, 'createRoot' | 'hydrateRoot'>();
|
|
22
|
+
|
|
23
|
+
for (const match of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]react-dom\/client['"]/g)) {
|
|
24
|
+
for (const member of (match[1] ?? '').split(',')) {
|
|
25
|
+
const parsed = member.trim().match(/^(createRoot|hydrateRoot)(?:\s+as\s+([\w$]+))?$/);
|
|
26
|
+
if (parsed) aliases.set(parsed[2] ?? parsed[1]!, parsed[1] as 'createRoot' | 'hydrateRoot');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const [alias, api] of aliases) {
|
|
31
|
+
const call = new RegExp(`\\b${alias.replace(/[$]/g, '\\$&')}\\s*\\(`, 'g');
|
|
32
|
+
for (const match of source.matchAll(call)) {
|
|
33
|
+
findings.push({ file, line: lineAt(source, match.index ?? 0), api });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const namespaceNames = new Set<string>();
|
|
38
|
+
for (const match of source.matchAll(
|
|
39
|
+
/import\s+(?:\*\s+as\s+|)([\w$]+)\s+from\s*['"]react-dom(?:\/client)?['"]/g,
|
|
40
|
+
)) {
|
|
41
|
+
namespaceNames.add(match[1]!);
|
|
42
|
+
}
|
|
43
|
+
namespaceNames.add('ReactDOM');
|
|
44
|
+
for (const name of namespaceNames) {
|
|
45
|
+
for (const api of ['createRoot', 'hydrateRoot', 'render'] as const) {
|
|
46
|
+
const call = new RegExp(`\\b${name}\\.${api}\\s*\\(`, 'g');
|
|
47
|
+
for (const match of source.matchAll(call)) {
|
|
48
|
+
findings.push({
|
|
49
|
+
file,
|
|
50
|
+
line: lineAt(source, match.index ?? 0),
|
|
51
|
+
api: api === 'render' ? 'ReactDOM.render' : api,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return findings.filter(
|
|
58
|
+
(finding, index) =>
|
|
59
|
+
findings.findIndex(
|
|
60
|
+
(candidate) =>
|
|
61
|
+
candidate.file === finding.file &&
|
|
62
|
+
candidate.line === finding.line &&
|
|
63
|
+
candidate.api === finding.api,
|
|
64
|
+
) === index,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hooks for data assets — the tool-hooks half of
|
|
3
|
+
* `docs/DATA-TOOLS-DESIGN.md` §3.3, shipped with W4 (dock tools).
|
|
4
|
+
*
|
|
5
|
+
* Lives under `packages/engine/src/react/` deliberately: this is the ONE
|
|
6
|
+
* directory of the engine allowed to value-import react (colocated with
|
|
7
|
+
* `game-state.tsx` per the react-free-core rule proved by
|
|
8
|
+
* `test/react-core-import-ban.test.ts`). Game roots that aren't react keep
|
|
9
|
+
* using `DataHandle.get()`/`subscribe()` directly (§2.3).
|
|
10
|
+
*
|
|
11
|
+
* - {@link useData} — subscribe a component to any W1 `DataHandle`; works in
|
|
12
|
+
* project tools, react roots, and HUD overlays alike.
|
|
13
|
+
* - {@link writeData} — EDITOR-TOOL plumbing: write a whole `.data.json`
|
|
14
|
+
* back through the editor dev server, closing the live-tuning loop
|
|
15
|
+
* (widget → file write → Vite HMR → `hotSwap` → {@link useData} re-render
|
|
16
|
+
* → the running game reads the new value). Not for game code.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useSyncExternalStore } from 'react';
|
|
20
|
+
import type { DataHandle } from '../data/data-asset';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read a data asset's current values and re-render whenever a hot edit lands
|
|
24
|
+
* (§3.3). A thin `useSyncExternalStore` over the handle's
|
|
25
|
+
* `subscribe`/`get` — any `defineData` handle works, no provider needed:
|
|
26
|
+
*
|
|
27
|
+
* ```tsx
|
|
28
|
+
* import { tuning } from '../data/tuning';
|
|
29
|
+
* const values = useData(tuning); // fresh parsed object after every hotSwap
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* `get()` returns the same reference between successful hot swaps, so this
|
|
33
|
+
* re-renders exactly once per accepted edit and never in between.
|
|
34
|
+
*/
|
|
35
|
+
export function useData<T>(handle: DataHandle<T>): T {
|
|
36
|
+
return useSyncExternalStore(
|
|
37
|
+
(onStoreChange) => handle.subscribe(() => onStoreChange()),
|
|
38
|
+
() => handle.get(),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write a data asset's ENTIRE new value back to its `.data.json` — the write
|
|
44
|
+
* half of a project tool's live-tuning loop (§3.3, D1: writes are file
|
|
45
|
+
* writes, always). POSTs to the editor dev server's `/__editor/data-file`
|
|
46
|
+
* route (see its doc comment in `packages/editor/server/editor-server.ts`),
|
|
47
|
+
* which accepts only `src/data/**\/*.data.json` and writes immediately; the
|
|
48
|
+
* running game then receives the change through the normal `.data.json` HMR
|
|
49
|
+
* path, exactly as if VS Code had saved the file.
|
|
50
|
+
*
|
|
51
|
+
* ```tsx
|
|
52
|
+
* // inside a tool's onChange — spread the live values, replace one field:
|
|
53
|
+
* void writeData('src/data/tuning.data.json', { ...tuning.get(), gravity: v });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* FOR TOOLS, NOT GAME CODE: data assets are immutable at runtime (§2.1) —
|
|
57
|
+
* runtime state belongs in your game's own store, never written back into
|
|
58
|
+
* data files. Outside a running editor there is no `/__editor` server, so
|
|
59
|
+
* this rejects with a teaching error (a shipped game could never reach the
|
|
60
|
+
* route anyway — builds strip `src/tools/` entirely, §4).
|
|
61
|
+
*
|
|
62
|
+
* Serialization matches the editor Data panel's diff-minimal contract:
|
|
63
|
+
* `"$schema"` first, 2-space indent, trailing newline. When `value` carries
|
|
64
|
+
* no `"$schema"` key (the common case — `DataHandle.get()` strips it), the
|
|
65
|
+
* conventional sibling reference (`./<name>.schema.json`, §2.1) is restored
|
|
66
|
+
* so a tool write never silently drops VS Code validation from the file.
|
|
67
|
+
*/
|
|
68
|
+
export async function writeData(
|
|
69
|
+
projectRelativePath: string,
|
|
70
|
+
value: Record<string, unknown>,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
if (!projectRelativePath.startsWith('src/data/') || !projectRelativePath.endsWith('.data.json')) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`writeData: invalid path ${JSON.stringify(projectRelativePath)} — data assets live at ` +
|
|
75
|
+
'src/data/**/*.data.json, addressed project-relative (docs/DATA-TOOLS-DESIGN.md §2.1), ' +
|
|
76
|
+
"e.g. writeData('src/data/tuning.data.json', next).",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// "$schema" first for minimal diffs; restore the conventional sibling
|
|
81
|
+
// reference when the caller's object (typically `{ ...handle.get() }`,
|
|
82
|
+
// which never carries it) omits the line.
|
|
83
|
+
const { $schema, ...rest } = value;
|
|
84
|
+
const schemaRef =
|
|
85
|
+
$schema ??
|
|
86
|
+
`./${projectRelativePath
|
|
87
|
+
.split('/')
|
|
88
|
+
.pop()
|
|
89
|
+
?.replace(/\.data\.json$/, '')}.schema.json`;
|
|
90
|
+
const content = `${JSON.stringify({ $schema: schemaRef, ...rest }, null, 2)}\n`;
|
|
91
|
+
|
|
92
|
+
const transactionalWriter = (
|
|
93
|
+
globalThis as typeof globalThis & {
|
|
94
|
+
__vgaiHistoryWriteData?: (path: string, content: string) => Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
).__vgaiHistoryWriteData;
|
|
97
|
+
if (transactionalWriter) {
|
|
98
|
+
await transactionalWriter(projectRelativePath, content);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let res: Response;
|
|
103
|
+
try {
|
|
104
|
+
res = await fetch('/__editor/data-file', {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ path: projectRelativePath, content }),
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'writeData: could not reach the editor dev server (/__editor/data-file) — this helper is ' +
|
|
112
|
+
'editor-tool plumbing (docs/DATA-TOOLS-DESIGN.md §3.3) and only works inside the running ' +
|
|
113
|
+
'editor. Game code must not write data assets: they are immutable at runtime (§2.1) — ' +
|
|
114
|
+
`keep runtime state in your game's own store. (${String(err)})`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const detail = await res.text().catch(() => '');
|
|
119
|
+
throw new Error(
|
|
120
|
+
`writeData: the editor rejected the write to "${projectRelativePath}" ` +
|
|
121
|
+
`(HTTP ${res.status}${detail ? `: ${detail}` : ''}).`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W3.1 of docs/DATA-TOOLS-DESIGN.md §3.3 — `useSelection()`, the third
|
|
3
|
+
* tool-hooks contract member (alongside `useData`/`writeData` from
|
|
4
|
+
* `use-data.ts`). Where those two hooks are about DATA ASSETS, this one is
|
|
5
|
+
* about the currently-selected ENTITY and the components authored on it —
|
|
6
|
+
* the gap a real consumer (`critter-types-w3b`'s `critter-type.tool.tsx`,
|
|
7
|
+
* §9.4) hit hard: with no way to read a component's own authored config, it
|
|
8
|
+
* had to recover species from `node.label` (a display string, not data).
|
|
9
|
+
*
|
|
10
|
+
* Scope, v1:
|
|
11
|
+
* - INSPECTOR-placement tools only (`placement: 'inspector'`,
|
|
12
|
+
* `define-tool.ts`). `EditorSelectionProvider` is host plumbing —
|
|
13
|
+
* `InspectorToolSection` wraps every tool render in it; dock tools
|
|
14
|
+
* (`ToolHost`/`BottomPanel`) have no adapter/selection in scope and never
|
|
15
|
+
* get a provider. Calling `useSelection()` there throws a teaching error,
|
|
16
|
+
* contained by the tool's `ToolErrorBoundary` (the editor survives).
|
|
17
|
+
* - READ-only. `config` is a shallow copy of the authored record; write
|
|
18
|
+
* through `InspectorProvider.set('components.<Type>.<field>', v)` /
|
|
19
|
+
* `writeData` (the existing paths), never by mutating what this hook
|
|
20
|
+
* returns. Per-entity WRITE-from-tool is deferred with `useTunable` (§10).
|
|
21
|
+
* - Honest degrades, not errors, when the adapter is thinner than the vgai
|
|
22
|
+
* first-party one: no `components` provider at all ⇒ `components: []`;
|
|
23
|
+
* a `components` provider present but with no optional `config()`
|
|
24
|
+
* accessor ⇒ each entry still appears (from `list()`) but with
|
|
25
|
+
* `config: null`. Both mirror the `AuthoringAdapter` optional-provider
|
|
26
|
+
* doctrine (§4) — an ingest/UI adapter that never implements `components`
|
|
27
|
+
* was already legal, and stays legal.
|
|
28
|
+
*
|
|
29
|
+
* Liveness — two existing paths reused, no new mechanism (§4's "reuse that
|
|
30
|
+
* path" rule):
|
|
31
|
+
* 1. Selection changes: `Inspector.tsx`'s own
|
|
32
|
+
* `useSyncExternalStore(store.subscribe, store.getSnapshot)` re-renders
|
|
33
|
+
* the panel, each matched `Section` gets the new `nodeId` prop, and
|
|
34
|
+
* `InspectorToolSection` re-creates this provider's context value.
|
|
35
|
+
* 2. Config edits while the SAME node stays selected: `adapter.subscribe`
|
|
36
|
+
* fires (the vgai adapter's store notifies on every `updateEntity`,
|
|
37
|
+
* which is what `InspectorProvider.set` goes through) — this hook keeps
|
|
38
|
+
* a local version counter bumped per notification (exactly
|
|
39
|
+
* `game-state.tsx`'s `useWorldObservation` pattern: the adapter has no
|
|
40
|
+
* snapshot/version of its own, `subscribe` is fire-only) and recomputes.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
createContext,
|
|
45
|
+
type ReactNode,
|
|
46
|
+
useContext,
|
|
47
|
+
useMemo,
|
|
48
|
+
useRef,
|
|
49
|
+
useSyncExternalStore,
|
|
50
|
+
} from 'react';
|
|
51
|
+
import type { AuthoringAdapter, EditorNode } from '../adapter';
|
|
52
|
+
|
|
53
|
+
interface SelectionContextValue {
|
|
54
|
+
adapter: AuthoringAdapter;
|
|
55
|
+
nodeId: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SelectionContext = createContext<SelectionContextValue | null>(null);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* EDITOR-side host plumbing: `InspectorToolSection` wraps a tool's rendered
|
|
62
|
+
* `<Component/>` in this, passing the adapter + nodeId it already holds.
|
|
63
|
+
* Project tools never render this themselves — it exists so `useSelection()`
|
|
64
|
+
* has something to read.
|
|
65
|
+
*/
|
|
66
|
+
export function EditorSelectionProvider({
|
|
67
|
+
adapter,
|
|
68
|
+
nodeId,
|
|
69
|
+
children,
|
|
70
|
+
}: {
|
|
71
|
+
adapter: AuthoringAdapter;
|
|
72
|
+
nodeId: string | null;
|
|
73
|
+
children: ReactNode;
|
|
74
|
+
}) {
|
|
75
|
+
const value = useMemo(() => ({ adapter, nodeId }), [adapter, nodeId]);
|
|
76
|
+
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One component attached to the selected entity, with its authored config
|
|
80
|
+
* if the adapter can produce one (see the module doc's degrade rules). */
|
|
81
|
+
export interface SelectedComponent {
|
|
82
|
+
type: string;
|
|
83
|
+
config: Record<string, unknown> | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface EditorSelection {
|
|
87
|
+
nodeId: string | null;
|
|
88
|
+
node: EditorNode | null;
|
|
89
|
+
components: SelectedComponent[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The selected entity, live: its node, and every component attached to it
|
|
94
|
+
* with that component's own authored config (read-only — see module doc).
|
|
95
|
+
*
|
|
96
|
+
* Throws outside an `EditorSelectionProvider` — inspector tools always render
|
|
97
|
+
* inside one; a dock tool or game-code caller does not, and the error names
|
|
98
|
+
* the fix rather than failing silently.
|
|
99
|
+
*/
|
|
100
|
+
export function useSelection(): EditorSelection {
|
|
101
|
+
const ctx = useContext(SelectionContext);
|
|
102
|
+
if (!ctx) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
'useSelection: no editor selection in context — this hook only works inside an editor-hosted ' +
|
|
105
|
+
"INSPECTOR tool (placement: 'inspector', docs/DATA-TOOLS-DESIGN.md §3.3/§3.2). Dock tools and " +
|
|
106
|
+
'game code have no selection to read.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const { adapter, nodeId } = ctx;
|
|
110
|
+
|
|
111
|
+
// Local version counter bumped per adapter notification — the adapter has
|
|
112
|
+
// no snapshot/version of its own, `subscribe` is fire-only (same pattern
|
|
113
|
+
// as `useWorldObservation`'s `versionRef`, game-state.tsx).
|
|
114
|
+
const versionRef = useRef(0);
|
|
115
|
+
const version = useSyncExternalStore(
|
|
116
|
+
(onStoreChange) =>
|
|
117
|
+
adapter.subscribe?.(() => {
|
|
118
|
+
versionRef.current++;
|
|
119
|
+
onStoreChange();
|
|
120
|
+
}) ?? (() => {}),
|
|
121
|
+
() => versionRef.current,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return useMemo(() => {
|
|
125
|
+
const node = nodeId ? adapter.hierarchy.node(nodeId) : null;
|
|
126
|
+
const types = nodeId ? (adapter.components?.list(nodeId) ?? []) : [];
|
|
127
|
+
const components: SelectedComponent[] = types.map(({ type }) => ({
|
|
128
|
+
type,
|
|
129
|
+
config: nodeId ? (adapter.components?.config?.(nodeId, type) ?? null) : null,
|
|
130
|
+
}));
|
|
131
|
+
return { nodeId, node, components };
|
|
132
|
+
// `version` has no value of its own — only its CHANGE matters, as the
|
|
133
|
+
// liveness signal described above.
|
|
134
|
+
}, [adapter, nodeId, version]);
|
|
135
|
+
}
|