@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
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vgaiDataCheck` — build-path validation for data assets and project tools
|
|
3
|
+
* (docs/DATA-TOOLS-DESIGN.md §6.7, the enforcement half of W5). A Vite plugin
|
|
4
|
+
* for the PROJECT's own `vite.config.ts` (the template wires it; the editor
|
|
5
|
+
* dev server boots Vite from the ENGINE's config and never runs this — its
|
|
6
|
+
* surface is covered by `vgai doctor` instead). Build-only (`apply: 'build'`):
|
|
7
|
+
* in dev, `defineData`'s parse-on-load already fails loud in the console.
|
|
8
|
+
*
|
|
9
|
+
* Three checks, all failing the build loud:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Schema integrity** (`buildStart`): every registered asset's
|
|
12
|
+
* `.data.json` is read fresh from disk and validated through
|
|
13
|
+
* {@link parseDataJson} — the EXACT parse `defineData` runs at load (§9.1:
|
|
14
|
+
* an emitted-JSON-Schema validator here could disagree with runtime; the
|
|
15
|
+
* Zod schema itself never can). The schemas arrive EXECUTABLE because
|
|
16
|
+
* `vite.config.ts` statically imports the project's data-asset registry
|
|
17
|
+
* (`src/data/assets.ts` in the template) — Vite's config loader bundles
|
|
18
|
+
* config-relative TS imports, which is what makes this the one build-side
|
|
19
|
+
* place project Zod schemas can run.
|
|
20
|
+
* 2. **Ref integrity** (`buildStart`): every `file#key(.field)*` string in
|
|
21
|
+
* every `src/data/*.data.json` (registered or not) must resolve — shared
|
|
22
|
+
* definition with `vgai doctor` via {@link findDanglingDataRefs}, so the
|
|
23
|
+
* two surfaces can never disagree about what "dangling" means. A second,
|
|
24
|
+
* narrower ref check runs alongside it for REGISTERED assets only: any
|
|
25
|
+
* field declared with `dataRef(target)` (`./data-ref.ts`) whose `target`
|
|
26
|
+
* names no asset currently on disk fails loud too (§9.4's "renamed the
|
|
27
|
+
* target file" hole, closed for declared refs — see
|
|
28
|
+
* `collectDeclaredRefFields`/`findMissingRefTargets` in `data-check-core.ts`).
|
|
29
|
+
* 3. **Tools never ship** (`generateBundle`): no emitted chunk may contain a
|
|
30
|
+
* module from `src/tools/` or a `*.tool.*` file (§4: tester serves and
|
|
31
|
+
* standalone builds strip tools entirely; W4 made this true by
|
|
32
|
+
* construction — nothing imports tools — and this check PINS it against
|
|
33
|
+
* the day some game module imports a tool "just for a helper").
|
|
34
|
+
*
|
|
35
|
+
* Unregistered data files (a `.data.json` with no entry in the passed
|
|
36
|
+
* `assets` list) are a WARNING, not an error: they still get ref-integrity
|
|
37
|
+
* and runtime parse-on-load, but no build-time schema check — the warning
|
|
38
|
+
* names the registry file to fix.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
42
|
+
import { join, relative } from 'node:path';
|
|
43
|
+
import type { Plugin } from 'vite';
|
|
44
|
+
import type { z } from 'zod';
|
|
45
|
+
import { parseDataJson, toDataJsonSchema } from './data-asset';
|
|
46
|
+
import {
|
|
47
|
+
collectDeclaredRefFields,
|
|
48
|
+
findDanglingDataRefs,
|
|
49
|
+
findMissingRefTargets,
|
|
50
|
+
} from './data-check-core';
|
|
51
|
+
|
|
52
|
+
/** One registered data asset: its executable Zod schema + the project-relative path of its `.data.json`. */
|
|
53
|
+
export interface DataCheckAsset {
|
|
54
|
+
readonly schema: z.ZodType;
|
|
55
|
+
/** e.g. `'src/data/tuning.data.json'` — read fresh from disk at buildStart. */
|
|
56
|
+
readonly sourcePath: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VgaiDataCheckOptions {
|
|
60
|
+
readonly assets: readonly DataCheckAsset[];
|
|
61
|
+
/** Project root the `sourcePath`s resolve against. Default: `process.cwd()` (vite build's cwd). */
|
|
62
|
+
readonly root?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Recursively collect `*.data.json` under `dir` (absent dir → empty — a project with no data assets builds fine). */
|
|
66
|
+
function findDataFiles(dir: string): string[] {
|
|
67
|
+
let entries: string[];
|
|
68
|
+
try {
|
|
69
|
+
entries = readdirSync(dir);
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const out: string[] = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const full = join(dir, entry);
|
|
76
|
+
if (statSync(full).isDirectory()) out.push(...findDataFiles(full));
|
|
77
|
+
else if (entry.endsWith('.data.json')) out.push(full);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `src/data/tuning.data.json` → `tuning` (normalizing either slash direction). */
|
|
83
|
+
function assetStem(sourcePath: string): string {
|
|
84
|
+
const rel = sourcePath.split('\\').join('/');
|
|
85
|
+
return (
|
|
86
|
+
rel
|
|
87
|
+
.split('/')
|
|
88
|
+
.pop()
|
|
89
|
+
?.replace(/\.data\.json$/, '') ?? rel
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse every data file in the project once — the registered subset gets
|
|
95
|
+
* schema validation, the whole set feeds ref integrity. Unparseable files
|
|
96
|
+
* become errors; unregistered files become warnings naming the registry.
|
|
97
|
+
*
|
|
98
|
+
* DUPLICATE-STEM GUARD: `findDataFiles` walks `src/data/` recursively, so two
|
|
99
|
+
* files in different subdirectories can share a stem (e.g.
|
|
100
|
+
* `src/data/enemies.data.json` and `src/data/legacy/enemies.data.json`) — the
|
|
101
|
+
* exact identity `file#key` refs AND the registry's `sourcePath` lookup both
|
|
102
|
+
* address by stem alone (§2.2). Without this guard `parsedByStem.set` would
|
|
103
|
+
* silently last-wins the collision, and check 1 below could validate a
|
|
104
|
+
* REGISTERED asset's schema against a totally unrelated file's JSON. Detected
|
|
105
|
+
* here (one error per colliding path, naming every path) and excluded from
|
|
106
|
+
* `parsedByStem` entirely — any resolution against a duplicate stem would be
|
|
107
|
+
* an arbitrary, possibly-wrong pick, so neither check 1 nor check 2 (ref
|
|
108
|
+
* integrity) resolves anything through it once it's flagged.
|
|
109
|
+
*/
|
|
110
|
+
interface ParsedDataFiles {
|
|
111
|
+
parsedByStem: Map<string, unknown>;
|
|
112
|
+
/** Stems the duplicate-stem guard already flagged — check 1 skips its own
|
|
113
|
+
* redundant "not found on disk" for these (the duplicate-stem error
|
|
114
|
+
* already explains why the stem has no single resolvable payload). */
|
|
115
|
+
ambiguousStems: Set<string>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseAllDataFiles(
|
|
119
|
+
root: string,
|
|
120
|
+
registered: ReadonlySet<string>,
|
|
121
|
+
errors: string[],
|
|
122
|
+
warnings: string[],
|
|
123
|
+
): ParsedDataFiles {
|
|
124
|
+
const parsedByStem = new Map<string, unknown>();
|
|
125
|
+
const pathsByStem = new Map<string, string[]>();
|
|
126
|
+
for (const abs of findDataFiles(join(root, 'src', 'data'))) {
|
|
127
|
+
const rel = relative(root, abs).split('\\').join('/');
|
|
128
|
+
const stem = assetStem(rel);
|
|
129
|
+
const paths = pathsByStem.get(stem);
|
|
130
|
+
if (paths) paths.push(rel);
|
|
131
|
+
else pathsByStem.set(stem, [rel]);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
parsedByStem.set(stem, JSON.parse(readFileSync(abs, 'utf-8')));
|
|
135
|
+
} catch (err) {
|
|
136
|
+
errors.push(
|
|
137
|
+
`"${rel}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!registered.has(rel)) {
|
|
142
|
+
warnings.push(
|
|
143
|
+
`"${rel}" is not registered in the data-asset registry (src/data/assets.ts), so it gets ` +
|
|
144
|
+
'no build-time schema check (runtime parse-on-load still applies). Register it there — ' +
|
|
145
|
+
'the template ships the worked example.',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ambiguousStems = new Set<string>();
|
|
151
|
+
for (const [stem, paths] of pathsByStem) {
|
|
152
|
+
if (paths.length < 2) continue;
|
|
153
|
+
ambiguousStems.add(stem);
|
|
154
|
+
errors.push(
|
|
155
|
+
`asset stem "${stem}" is claimed by ${paths.length} files (${paths.join(', ')}) — ` +
|
|
156
|
+
'"file#key" refs and the data-asset registry address a file by its stem alone (§2.2), so ' +
|
|
157
|
+
'this is an unresolvable identity collision. Rename one — asset stems must be unique ' +
|
|
158
|
+
'across the whole project, not just per directory.',
|
|
159
|
+
);
|
|
160
|
+
parsedByStem.delete(stem); // arbitrary otherwise — neither check below may resolve through it
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { parsedByStem, ambiguousStems };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The fs half of checks 1 + 2, extracted from the plugin hook so it is
|
|
168
|
+
* directly unit-testable against a fixture folder (no rollup context needed).
|
|
169
|
+
*/
|
|
170
|
+
export function collectDataCheckProblems(options: VgaiDataCheckOptions): {
|
|
171
|
+
errors: string[];
|
|
172
|
+
warnings: string[];
|
|
173
|
+
} {
|
|
174
|
+
const root = options.root ?? process.cwd();
|
|
175
|
+
const errors: string[] = [];
|
|
176
|
+
const warnings: string[] = [];
|
|
177
|
+
const registered = new Set(options.assets.map((a) => a.sourcePath.split('\\').join('/')));
|
|
178
|
+
const { parsedByStem, ambiguousStems } = parseAllDataFiles(root, registered, errors, warnings);
|
|
179
|
+
|
|
180
|
+
// Check 1 — registered assets validate through the exact runtime parse.
|
|
181
|
+
for (const asset of options.assets) {
|
|
182
|
+
const rel = asset.sourcePath.split('\\').join('/');
|
|
183
|
+
if (!parsedByStem.has(assetStem(rel))) {
|
|
184
|
+
// An AMBIGUOUS stem already has its own duplicate-stem error above,
|
|
185
|
+
// which fully explains why there's no single payload to validate
|
|
186
|
+
// against — a second "not found on disk" here would just be
|
|
187
|
+
// confusing noise about a file that plainly IS on disk.
|
|
188
|
+
if (!ambiguousStems.has(assetStem(rel))) {
|
|
189
|
+
errors.push(
|
|
190
|
+
`registered data asset "${rel}" was not found on disk — fix the path in the registry ` +
|
|
191
|
+
'(src/data/assets.ts) or restore the file.',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
parseDataJson(asset.schema, parsedByStem.get(assetStem(rel)), rel);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Check 2 — no dangling refs anywhere.
|
|
204
|
+
for (const dangling of findDanglingDataRefs(parsedByStem)) {
|
|
205
|
+
errors.push(
|
|
206
|
+
`dangling data ref in "src/data/${dangling.inAsset}.data.json" at ${dangling.atPath}: ` +
|
|
207
|
+
`"${dangling.ref}" — "${dangling.missingSegment}" does not exist in ` +
|
|
208
|
+
`src/data/${dangling.targetAsset}.data.json. Fix the key or remove the ref ` +
|
|
209
|
+
'(refs are "file#key" strings — docs/DATA-TOOLS-DESIGN.md §2.2).',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check 2b — every declared `dataRef(target)` field (registered assets
|
|
214
|
+
// only, same set check 1 already validates) must name a target that's
|
|
215
|
+
// actually on disk — the §9.4 fix: this fires even if NO current value
|
|
216
|
+
// happens to look like a ref, unlike check 2 above (see
|
|
217
|
+
// `data-check-core.ts`'s module doc).
|
|
218
|
+
const assetNames = new Set(parsedByStem.keys());
|
|
219
|
+
for (const asset of options.assets) {
|
|
220
|
+
const rel = asset.sourcePath.split('\\').join('/');
|
|
221
|
+
const declared = collectDeclaredRefFields(toDataJsonSchema(asset.schema));
|
|
222
|
+
for (const missing of findMissingRefTargets(declared, assetNames)) {
|
|
223
|
+
errors.push(
|
|
224
|
+
`"${rel}" declares field "${missing.fieldPath}" as dataRef('${missing.targetStem}'), but ` +
|
|
225
|
+
`no "src/data/${missing.targetStem}.data.json" exists. Restore/rename the target file, ` +
|
|
226
|
+
`or update the dataRef('${missing.targetStem}') call in the schema.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { errors, warnings };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** True iff a bundled module id is tool code that must never ship (§4). Pure, unit-tested. */
|
|
235
|
+
export function isToolModuleId(id: string): boolean {
|
|
236
|
+
const normalized = id.split('\\').join('/');
|
|
237
|
+
return /\/src\/tools\//.test(normalized) || /\.tool\.[tj]sx?(\?|$)/.test(normalized);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Build-path validation plugin — see the module doc. Wire it in the project's
|
|
242
|
+
* `vite.config.ts`, passing the data-asset registry:
|
|
243
|
+
*
|
|
244
|
+
* ```ts
|
|
245
|
+
* import { vgaiDataCheck } from '@vgai/engine/data/vite-plugin-data';
|
|
246
|
+
* import { dataAssets } from './src/data/assets';
|
|
247
|
+
* export default defineConfig({
|
|
248
|
+
* plugins: [vgaiDataCheck({
|
|
249
|
+
* root: __dirname,
|
|
250
|
+
* assets: dataAssets.map((a) => ({ schema: a.schema, sourcePath: `src/data/${a.name}.data.json` })),
|
|
251
|
+
* })],
|
|
252
|
+
* });
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
export function vgaiDataCheck(options: VgaiDataCheckOptions): Plugin {
|
|
256
|
+
return {
|
|
257
|
+
name: 'vgai:data-check',
|
|
258
|
+
apply: 'build',
|
|
259
|
+
buildStart() {
|
|
260
|
+
const { errors, warnings } = collectDataCheckProblems(options);
|
|
261
|
+
for (const warning of warnings) this.warn(warning);
|
|
262
|
+
if (errors.length > 0) {
|
|
263
|
+
// ONE error carrying every problem — fail loud with the full picture,
|
|
264
|
+
// not a fix-one-rebuild-see-the-next loop.
|
|
265
|
+
this.error(
|
|
266
|
+
`data-asset validation failed (${errors.length} problem${errors.length === 1 ? '' : 's'} ` +
|
|
267
|
+
`— docs/DATA-TOOLS-DESIGN.md §6.7):\n${errors.map((e) => ` - ${e}`).join('\n')}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
generateBundle(_outputOptions, bundle) {
|
|
272
|
+
const leaked = new Set<string>();
|
|
273
|
+
for (const output of Object.values(bundle)) {
|
|
274
|
+
if (output.type !== 'chunk') continue;
|
|
275
|
+
for (const id of Object.keys(output.modules)) {
|
|
276
|
+
if (isToolModuleId(id)) leaked.add(id.split('\\').join('/'));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (leaked.size > 0) {
|
|
280
|
+
this.error(
|
|
281
|
+
'project tool code reached the game build — tools are editor-only and must never ship ' +
|
|
282
|
+
'to players (docs/DATA-TOOLS-DESIGN.md §4). Remove every game-code import of these ' +
|
|
283
|
+
`modules (tools may import game code, never the reverse):\n${[...leaked]
|
|
284
|
+
.map((id) => ` - ${id}`)
|
|
285
|
+
.join('\n')}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
export interface PerformanceTiming {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly ms: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface PerformanceFrame {
|
|
7
|
+
readonly id: number;
|
|
8
|
+
readonly timestamp: number;
|
|
9
|
+
readonly intervalMs: number;
|
|
10
|
+
readonly cpuMs: number;
|
|
11
|
+
readonly phases: readonly PerformanceTiming[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PerformanceSnapshot {
|
|
15
|
+
readonly enabled: boolean;
|
|
16
|
+
readonly recording: boolean;
|
|
17
|
+
readonly fps: number;
|
|
18
|
+
readonly cpuMs: number;
|
|
19
|
+
readonly p95Ms: number;
|
|
20
|
+
readonly p99Ms: number;
|
|
21
|
+
readonly frames: readonly PerformanceFrame[];
|
|
22
|
+
readonly phases: readonly PerformanceTiming[];
|
|
23
|
+
readonly systems: readonly PerformanceTiming[];
|
|
24
|
+
readonly components: readonly PerformanceTiming[];
|
|
25
|
+
readonly render: {
|
|
26
|
+
readonly gpuMs: number | null;
|
|
27
|
+
readonly drawCalls: number;
|
|
28
|
+
readonly triangles: number;
|
|
29
|
+
readonly geometries: number;
|
|
30
|
+
readonly textures: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const now = () => globalThis.performance?.now() ?? Date.now();
|
|
35
|
+
|
|
36
|
+
function percentile(values: readonly number[], fraction: number): number {
|
|
37
|
+
if (values.length === 0) return 0;
|
|
38
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
39
|
+
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction))] ?? 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Game-owned, bounded external store for browser-native performance diagnostics. */
|
|
43
|
+
export function createPerformanceProfiler(initiallyEnabled = false) {
|
|
44
|
+
const listeners = new Set<() => void>();
|
|
45
|
+
const frames: PerformanceFrame[] = [];
|
|
46
|
+
const currentPhases = new Map<string, number>();
|
|
47
|
+
const currentSystems = new Map<string, number>();
|
|
48
|
+
const currentComponents = new Map<string, number>();
|
|
49
|
+
let enabled = initiallyEnabled;
|
|
50
|
+
let recording = false;
|
|
51
|
+
let frameId = 0;
|
|
52
|
+
let frameStart = 0;
|
|
53
|
+
let lastFrameStart = 0;
|
|
54
|
+
let phaseStart = 0;
|
|
55
|
+
let systemStart = 0;
|
|
56
|
+
let componentStart = 0;
|
|
57
|
+
let render = {
|
|
58
|
+
gpuMs: null as number | null,
|
|
59
|
+
drawCalls: 0,
|
|
60
|
+
triangles: 0,
|
|
61
|
+
geometries: 0,
|
|
62
|
+
textures: 0,
|
|
63
|
+
};
|
|
64
|
+
let snapshot: PerformanceSnapshot = {
|
|
65
|
+
enabled,
|
|
66
|
+
recording,
|
|
67
|
+
fps: 0,
|
|
68
|
+
cpuMs: 0,
|
|
69
|
+
p95Ms: 0,
|
|
70
|
+
p99Ms: 0,
|
|
71
|
+
frames,
|
|
72
|
+
phases: [],
|
|
73
|
+
systems: [],
|
|
74
|
+
components: [],
|
|
75
|
+
render,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function publish(): void {
|
|
79
|
+
const latest = frames.at(-1);
|
|
80
|
+
const intervals = frames.map((frame) => frame.intervalMs).filter((value) => value > 0);
|
|
81
|
+
snapshot = {
|
|
82
|
+
enabled,
|
|
83
|
+
recording,
|
|
84
|
+
fps: latest?.intervalMs ? 1000 / latest.intervalMs : 0,
|
|
85
|
+
cpuMs: latest?.cpuMs ?? 0,
|
|
86
|
+
p95Ms: percentile(intervals, 0.95),
|
|
87
|
+
p99Ms: percentile(intervals, 0.99),
|
|
88
|
+
frames,
|
|
89
|
+
phases: latest?.phases ?? [],
|
|
90
|
+
systems: [...currentSystems].map(([name, ms]) => ({ name, ms })).sort((a, b) => b.ms - a.ms),
|
|
91
|
+
components: [...currentComponents]
|
|
92
|
+
.map(([name, ms]) => ({ name, ms }))
|
|
93
|
+
.sort((a, b) => b.ms - a.ms),
|
|
94
|
+
render,
|
|
95
|
+
};
|
|
96
|
+
for (const listener of listeners) listener();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
get enabled() {
|
|
101
|
+
return enabled;
|
|
102
|
+
},
|
|
103
|
+
set enabled(value: boolean) {
|
|
104
|
+
if (enabled === value) return;
|
|
105
|
+
enabled = value;
|
|
106
|
+
publish();
|
|
107
|
+
},
|
|
108
|
+
get recording() {
|
|
109
|
+
return recording;
|
|
110
|
+
},
|
|
111
|
+
startRecording() {
|
|
112
|
+
enabled = true;
|
|
113
|
+
recording = true;
|
|
114
|
+
frames.length = 0;
|
|
115
|
+
publish();
|
|
116
|
+
},
|
|
117
|
+
stopRecording() {
|
|
118
|
+
recording = false;
|
|
119
|
+
publish();
|
|
120
|
+
},
|
|
121
|
+
clear() {
|
|
122
|
+
frames.length = 0;
|
|
123
|
+
publish();
|
|
124
|
+
},
|
|
125
|
+
beginFrame() {
|
|
126
|
+
if (!enabled) return;
|
|
127
|
+
currentPhases.clear();
|
|
128
|
+
currentSystems.clear();
|
|
129
|
+
currentComponents.clear();
|
|
130
|
+
render = { gpuMs: null, drawCalls: 0, triangles: 0, geometries: 0, textures: 0 };
|
|
131
|
+
frameStart = now();
|
|
132
|
+
},
|
|
133
|
+
beginPhase() {
|
|
134
|
+
if (!enabled) return;
|
|
135
|
+
phaseStart = now();
|
|
136
|
+
},
|
|
137
|
+
endPhase(name: string) {
|
|
138
|
+
if (!enabled) return;
|
|
139
|
+
const elapsed = now() - phaseStart;
|
|
140
|
+
currentPhases.set(name, (currentPhases.get(name) ?? 0) + elapsed);
|
|
141
|
+
if (recording)
|
|
142
|
+
performance.measure(`vgai.phase.${name}`, { start: phaseStart, duration: elapsed });
|
|
143
|
+
},
|
|
144
|
+
systemObserver: {
|
|
145
|
+
beginSystem() {
|
|
146
|
+
if (!enabled) return;
|
|
147
|
+
systemStart = now();
|
|
148
|
+
},
|
|
149
|
+
endSystem(scope: string, phase: string, name: string) {
|
|
150
|
+
if (!enabled) return;
|
|
151
|
+
const end = now();
|
|
152
|
+
const key = `${scope} / ${phase} / ${name}`;
|
|
153
|
+
currentSystems.set(key, (currentSystems.get(key) ?? 0) + end - systemStart);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
beginComponent() {
|
|
157
|
+
if (!recording) return;
|
|
158
|
+
componentStart = now();
|
|
159
|
+
},
|
|
160
|
+
endComponent(name: string) {
|
|
161
|
+
if (!recording) return;
|
|
162
|
+
currentComponents.set(name, (currentComponents.get(name) ?? 0) + now() - componentStart);
|
|
163
|
+
},
|
|
164
|
+
reportRender(stats: {
|
|
165
|
+
gpuMs: number | null;
|
|
166
|
+
drawCalls: number;
|
|
167
|
+
triangles: number;
|
|
168
|
+
geometries: number;
|
|
169
|
+
textures: number;
|
|
170
|
+
}) {
|
|
171
|
+
if (!enabled) return;
|
|
172
|
+
render = {
|
|
173
|
+
gpuMs: stats.gpuMs ?? render.gpuMs,
|
|
174
|
+
drawCalls: render.drawCalls + stats.drawCalls,
|
|
175
|
+
triangles: render.triangles + stats.triangles,
|
|
176
|
+
geometries: render.geometries + stats.geometries,
|
|
177
|
+
textures: render.textures + stats.textures,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
endFrame() {
|
|
181
|
+
if (!enabled) return;
|
|
182
|
+
const timestamp = now();
|
|
183
|
+
const intervalMs = lastFrameStart === 0 ? 0 : frameStart - lastFrameStart;
|
|
184
|
+
lastFrameStart = frameStart;
|
|
185
|
+
const frame: PerformanceFrame = {
|
|
186
|
+
id: ++frameId,
|
|
187
|
+
timestamp,
|
|
188
|
+
intervalMs,
|
|
189
|
+
cpuMs: timestamp - frameStart,
|
|
190
|
+
phases: [...currentPhases].map(([name, ms]) => ({ name, ms })).sort((a, b) => b.ms - a.ms),
|
|
191
|
+
};
|
|
192
|
+
frames.push(frame);
|
|
193
|
+
if (frames.length > 600) frames.splice(0, frames.length - 600);
|
|
194
|
+
if (recording)
|
|
195
|
+
performance.measure('vgai.frame', { start: frameStart, duration: frame.cpuMs });
|
|
196
|
+
publish();
|
|
197
|
+
},
|
|
198
|
+
subscribe(listener: () => void) {
|
|
199
|
+
listeners.add(listener);
|
|
200
|
+
return () => {
|
|
201
|
+
listeners.delete(listener);
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
getSnapshot() {
|
|
205
|
+
return snapshot;
|
|
206
|
+
},
|
|
207
|
+
exportJSON() {
|
|
208
|
+
return JSON.stringify({ version: 1, capturedAt: new Date().toISOString(), frames }, null, 2);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type PerformanceProfiler = ReturnType<typeof createPerformanceProfiler>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
interface DisjointTimerQueryExtension {
|
|
2
|
+
readonly TIME_ELAPSED_EXT: number;
|
|
3
|
+
readonly GPU_DISJOINT_EXT: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Non-blocking WebGL2 timer query; results are consumed on later frames. */
|
|
7
|
+
export function createWebGLGpuTimer(context: WebGLRenderingContext | WebGL2RenderingContext) {
|
|
8
|
+
const gl = context as WebGL2RenderingContext;
|
|
9
|
+
const extension = gl.getExtension(
|
|
10
|
+
'EXT_disjoint_timer_query_webgl2',
|
|
11
|
+
) as DisjointTimerQueryExtension | null;
|
|
12
|
+
const supported = extension !== null && typeof gl.createQuery === 'function';
|
|
13
|
+
const pending: WebGLQuery[] = [];
|
|
14
|
+
let active: WebGLQuery | null = null;
|
|
15
|
+
let latestMs: number | null = null;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
begin() {
|
|
19
|
+
if (!supported || active) return;
|
|
20
|
+
active = gl.createQuery();
|
|
21
|
+
if (active) gl.beginQuery(extension!.TIME_ELAPSED_EXT, active);
|
|
22
|
+
},
|
|
23
|
+
end() {
|
|
24
|
+
if (!supported || !active) return;
|
|
25
|
+
gl.endQuery(extension!.TIME_ELAPSED_EXT);
|
|
26
|
+
pending.push(active);
|
|
27
|
+
active = null;
|
|
28
|
+
},
|
|
29
|
+
poll(): number | null {
|
|
30
|
+
if (!supported) return null;
|
|
31
|
+
if (gl.getParameter(extension!.GPU_DISJOINT_EXT)) {
|
|
32
|
+
for (const query of pending.splice(0)) gl.deleteQuery(query);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
while (pending.length > 0) {
|
|
36
|
+
const query = pending[0]!;
|
|
37
|
+
if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) break;
|
|
38
|
+
pending.shift();
|
|
39
|
+
latestMs = (gl.getQueryParameter(query, gl.QUERY_RESULT) as number) / 1_000_000;
|
|
40
|
+
gl.deleteQuery(query);
|
|
41
|
+
}
|
|
42
|
+
return latestMs;
|
|
43
|
+
},
|
|
44
|
+
dispose() {
|
|
45
|
+
if (active) {
|
|
46
|
+
gl.endQuery(extension!.TIME_ELAPSED_EXT);
|
|
47
|
+
gl.deleteQuery(active);
|
|
48
|
+
active = null;
|
|
49
|
+
}
|
|
50
|
+
for (const query of pending.splice(0)) gl.deleteQuery(query);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -2,23 +2,28 @@ import type * as PIXI from 'pixi.js';
|
|
|
2
2
|
import type * as THREE from 'three';
|
|
3
3
|
import { PHASE_ORDER, type SystemPhaseName } from '../core/types';
|
|
4
4
|
import type { PhysicsRefs, PhysicsRegistry } from '../physics/physics-registry';
|
|
5
|
-
import { isFirstPartyMounted, type WorldInstance
|
|
5
|
+
import { type AdapterSurface, isFirstPartyMounted, type WorldInstance } from '../runtime/game';
|
|
6
6
|
import type { GameContext } from '../runtime/types';
|
|
7
7
|
import type { Physics2DRefs, Physics2DRegistry } from '../world2d/physics2d-registry';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
GameComponent,
|
|
10
|
+
type GameComponentClass,
|
|
11
|
+
linkGameComponentHmrClasses,
|
|
12
|
+
type NodeOf,
|
|
13
|
+
} from './game-component';
|
|
9
14
|
import { logHmrSwapMiss } from './hmr-swap-report';
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
|
-
* The manager's node/instance types, widened over every `
|
|
13
|
-
* slice 2, D8 §3). `NodeOf<
|
|
17
|
+
* The manager's node/instance types, widened over every `AdapterSurface` (T7.2
|
|
18
|
+
* slice 2, D8 §3). `NodeOf<AdapterSurface>` distributes to `THREE.Object3D |
|
|
14
19
|
* PIXI.Container` (react contributes `never`, which drops out of the
|
|
15
20
|
* union) — this is what lets a single manager instance be constructed for
|
|
16
21
|
* ANY one kind (threejs, pixijs, or react) while still type-checking
|
|
17
22
|
* `attach()` calls made with a default-kind (`GameComponent`, K =
|
|
18
23
|
* 'threejs') instance, unmodified, from every existing call site.
|
|
19
24
|
*/
|
|
20
|
-
type AnyNode = NodeOf<
|
|
21
|
-
type AnyComponent = GameComponent<
|
|
25
|
+
type AnyNode = NodeOf<AdapterSurface>;
|
|
26
|
+
type AnyComponent = GameComponent<AdapterSurface>;
|
|
22
27
|
|
|
23
28
|
/**
|
|
24
29
|
* Optional identity a caller of `attach()` can supply for a component instance
|
|
@@ -42,10 +47,10 @@ export interface RegistryAttachInfo {
|
|
|
42
47
|
* ctx` identity (the same probe `isFirstPartyMounted` narrows for) rather
|
|
43
48
|
* than by id, so it works unmodified whether this manager belongs to the
|
|
44
49
|
* default world or a second/third registered world (`test/
|
|
45
|
-
* game-two-
|
|
50
|
+
* game-two-roots.test.ts`).
|
|
46
51
|
*
|
|
47
52
|
* WORLD-WIRING STATE (T7.2 slice 2 — was a KNOWN GAP in slice 1, now closed
|
|
48
|
-
* two ways): `ctx.game`/`ctx.
|
|
53
|
+
* two ways): `ctx.game`/`ctx.roots` exist from the start of `mount()`, but
|
|
49
54
|
* the `WorldInstance` itself is constructed by `registerThreeWorld` in
|
|
50
55
|
* `create-runtime.ts` only AFTER `mount()` returns — i.e. AFTER
|
|
51
56
|
* scene-authored components have already been attached by the scene loader
|
|
@@ -65,7 +70,7 @@ export interface RegistryAttachInfo {
|
|
|
65
70
|
*/
|
|
66
71
|
function resolveWorldInstance(ctx: GameContext): WorldInstance | undefined {
|
|
67
72
|
if (!ctx.game) return undefined;
|
|
68
|
-
for (const w of ctx.game.
|
|
73
|
+
for (const w of ctx.game.roots) {
|
|
69
74
|
if (isFirstPartyMounted(w.mounted) && w.mounted.ctx === ctx) return w;
|
|
70
75
|
}
|
|
71
76
|
return undefined;
|
|
@@ -98,7 +103,7 @@ function describeNode(node: AnyNode): string {
|
|
|
98
103
|
* `test/system-runs-before-component-tick.test.ts`).
|
|
99
104
|
*
|
|
100
105
|
* Kind (T7.2 slice 2, D8 §3): a manager is constructed for exactly ONE
|
|
101
|
-
* `
|
|
106
|
+
* `AdapterSurface` (`opts.kind`, default `'threejs'` — every existing call site
|
|
102
107
|
* omits it and gets the same manager slice 1 built). `attach()` enforces two
|
|
103
108
|
* rules against that kind, loudly:
|
|
104
109
|
* - a `'react'`-kind manager throws on EVERY attach (react entities host no
|
|
@@ -118,9 +123,9 @@ function describeNode(node: AnyNode): string {
|
|
|
118
123
|
export function createComponentManager(
|
|
119
124
|
ctx: GameContext,
|
|
120
125
|
physics?: PhysicsRegistry,
|
|
121
|
-
opts?: { kind?:
|
|
126
|
+
opts?: { kind?: AdapterSurface; physics2d?: Physics2DRegistry },
|
|
122
127
|
) {
|
|
123
|
-
const kind:
|
|
128
|
+
const kind: AdapterSurface = opts?.kind ?? 'threejs';
|
|
124
129
|
const physics2d = opts?.physics2d;
|
|
125
130
|
|
|
126
131
|
const byPhase = new Map<SystemPhaseName, AnyComponent[]>();
|
|
@@ -322,9 +327,12 @@ export function createComponentManager(
|
|
|
322
327
|
// flushed). Failures are loud — logged with the component name —
|
|
323
328
|
// never swallowed silently.
|
|
324
329
|
try {
|
|
330
|
+
ctx.game?.profiler.beginComponent();
|
|
325
331
|
inst.update(dt, ctx);
|
|
326
332
|
} catch (err) {
|
|
327
333
|
console.error(`[component-manager] ${inst.constructor.name}.update() threw:`, err);
|
|
334
|
+
} finally {
|
|
335
|
+
ctx.game?.profiler.endComponent(inst.constructor.name);
|
|
328
336
|
}
|
|
329
337
|
}
|
|
330
338
|
} finally {
|
|
@@ -362,6 +370,7 @@ export function createComponentManager(
|
|
|
362
370
|
if (!isMatch) return false;
|
|
363
371
|
const oldPhase = (inst.constructor as typeof GameComponent).phase ?? 'gameLogic';
|
|
364
372
|
const newPhase = (NewClass as unknown as typeof GameComponent).phase ?? 'gameLogic';
|
|
373
|
+
linkGameComponentHmrClasses(inst.constructor as unknown as GameComponentClass, NewClass);
|
|
365
374
|
Object.setPrototypeOf(inst, NewClass.prototype);
|
|
366
375
|
migratePhase(inst, oldPhase, newPhase);
|
|
367
376
|
reparseSchemaOnSwap(inst, NewClass, name);
|
|
@@ -601,6 +610,30 @@ export function createComponentManager(
|
|
|
601
610
|
return byEntity.get(node) ?? [];
|
|
602
611
|
},
|
|
603
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Get the first attached instance of a given component class on an
|
|
615
|
+
* entity node — the typed single-component sibling of `getComponents`
|
|
616
|
+
* (issue #99: `onTriggerEnter`'s `other` is a raw node; this is how a
|
|
617
|
+
* handler asks "does my trigger partner have a `Collectible` (or
|
|
618
|
+
* whatever marker) component?" instead of an untyped `getComponents`
|
|
619
|
+
* walk or a fragile `other.name === '...'` display-name check).
|
|
620
|
+
* Matches via `instanceof`, so a subclass of `cls` is returned too
|
|
621
|
+
* (same subclass handling as `queryByComponent`). Same ordering as
|
|
622
|
+
* `getComponents` (attach order for that node) and, like
|
|
623
|
+
* `getComponents`, does NOT exclude instances pending-detach this tick
|
|
624
|
+
* — a caller inside a phase tick that cares should filter itself, same
|
|
625
|
+
* as any other `getComponents` consumer. `undefined` if the node has no
|
|
626
|
+
* attached instance of `cls`.
|
|
627
|
+
*/
|
|
628
|
+
getComponent<T extends AnyComponent>(node: AnyNode, cls: new () => T): T | undefined {
|
|
629
|
+
const instances = byEntity.get(node);
|
|
630
|
+
if (!instances) return undefined;
|
|
631
|
+
for (const inst of instances) {
|
|
632
|
+
if (inst instanceof cls) return inst;
|
|
633
|
+
}
|
|
634
|
+
return undefined;
|
|
635
|
+
},
|
|
636
|
+
|
|
604
637
|
/**
|
|
605
638
|
* Query all live attached instances of a given component class within
|
|
606
639
|
* THIS manager — the per-world half of the §5 cross-world query
|