@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.
Files changed (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -4,30 +4,30 @@ import type * as PIXI from 'pixi.js';
4
4
  import type * as THREE from 'three';
5
5
  import type { z } from 'zod';
6
6
  import type { SystemPhaseName } from '../core/types';
7
- import type { WorldInstance, WorldKind } from '../runtime/game';
7
+ import type { AdapterSurface, WorldInstance } from '../runtime/game';
8
8
  import type { GameContext } from '../runtime/types';
9
9
 
10
10
  /**
11
- * Map a {@link WorldKind} to its native world node type (T7.2, D8 —
11
+ * Map a {@link AdapterSurface} to its native world node type (T7.2, D8 —
12
12
  * `docs/GAME-COMPONENT-GENERALIZATION.md` §2). `react` maps to `never`: react
13
13
  * entities host no GameComponents (they render from game state via the T7.4
14
14
  * state bridge instead).
15
15
  */
16
- export type NodeOf<K extends WorldKind> = K extends 'threejs'
16
+ export type NodeOf<K extends AdapterSurface> = K extends 'threejs'
17
17
  ? THREE.Object3D
18
18
  : K extends 'pixijs'
19
19
  ? PIXI.Container
20
20
  : never;
21
21
 
22
- /** Map a {@link WorldKind} to its native Rapier rigid-body type. */
23
- export type BodyOf<K extends WorldKind> = K extends 'threejs'
22
+ /** Map a {@link AdapterSurface} to its native Rapier rigid-body type. */
23
+ export type BodyOf<K extends AdapterSurface> = K extends 'threejs'
24
24
  ? RAPIER3D.RigidBody
25
25
  : K extends 'pixijs'
26
26
  ? RAPIER2D.RigidBody
27
27
  : never;
28
28
 
29
- /** Map a {@link WorldKind} to its native Rapier collider type. */
30
- export type ColliderOf<K extends WorldKind> = K extends 'threejs'
29
+ /** Map a {@link AdapterSurface} to its native Rapier collider type. */
30
+ export type ColliderOf<K extends AdapterSurface> = K extends 'threejs'
31
31
  ? RAPIER3D.Collider
32
32
  : K extends 'pixijs'
33
33
  ? RAPIER2D.Collider
@@ -54,13 +54,97 @@ export type ColliderOf<K extends WorldKind> = K extends 'threejs'
54
54
  * State lives on `this` (instance properties). On HMR, the engine swaps the
55
55
  * prototype via Object.setPrototypeOf — instance state survives, method bodies
56
56
  * update to the new code.
57
+ *
58
+ * `update` is deliberately mandatory, not optional like `init`/`dispose`
59
+ * (owner decision, issue #101): components are for BEHAVIOR. If your
60
+ * subclass's `update(dt, ctx)` body is empty, that is a signal the class may
61
+ * not need to be a component at all — a plain state-holder with only
62
+ * `init`/event methods (score/hits counters, etc.) usually belongs as a
63
+ * plain object or a static-scenery/UI object instead (see the "GameComponents
64
+ * are for things with behavior only" rule).
65
+ */
66
+ /**
67
+ * Cross-copy brand (`Symbol.for` is process-global, so every physical copy of
68
+ * the engine shares ONE symbol): `x.prototype instanceof GameComponent` is
69
+ * FALSE when a project resolves TWO copies of the engine at once — the
70
+ * published `@vgai/engine/*` subpaths next to the `@engine/*` source alias,
71
+ * exactly what a scaffolded project's node-side scripts do (meteor-dodge
72
+ * dry-run friction: `validate-scenes --components` reported every field of a
73
+ * pristine scaffold's `SceneCamera` as unknown). Structural checks
74
+ * (`isGameComponentClass`) test this brand as well as `instanceof`.
57
75
  */
58
- export abstract class GameComponent<K extends WorldKind = 'threejs'> {
76
+ export const GAME_COMPONENT_BRAND = Symbol.for('vgai.GameComponent');
77
+
78
+ /**
79
+ * HMR class lineage. A true component hot-swap deliberately replaces a live
80
+ * instance's prototype with the newly imported class, but other already-live
81
+ * project modules can still hold the previous class object. Native
82
+ * `instanceof PreviousClass` would then become false even though the object is
83
+ * still the same component under the same hot-reload lineage. That broke both
84
+ * ordinary trigger guards (`component instanceof PlayerController`) and the
85
+ * canonical React-state selector (`game.queryByComponent(PlayerController)`).
86
+ *
87
+ * Keep this as class-to-class lineage rather than a string/name registry:
88
+ * minified classes, aliases, and two unrelated components with the same name
89
+ * remain distinct. The WeakMap owns no game/entity state and introduces no
90
+ * second identity model; it records only the exact constructor pairs the
91
+ * existing HMR boundary has actually swapped.
92
+ */
93
+ type HmrComponentConstructor = abstract new (...args: never[]) => object;
94
+
95
+ const hmrLineages = new WeakMap<HmrComponentConstructor, Set<HmrComponentConstructor>>();
96
+
97
+ /** @internal Called only by ComponentManager's successful HMR swap path. */
98
+ export function linkGameComponentHmrClasses(
99
+ previous: HmrComponentConstructor,
100
+ next: HmrComponentConstructor,
101
+ ): void {
102
+ const previousLineage = hmrLineages.get(previous);
103
+ const nextLineage = hmrLineages.get(next);
104
+ const merged = new Set<HmrComponentConstructor>([previous, next]);
105
+ if (previousLineage) {
106
+ for (const member of previousLineage) merged.add(member);
107
+ }
108
+ if (nextLineage) {
109
+ for (const member of nextLineage) merged.add(member);
110
+ }
111
+ for (const member of merged) hmrLineages.set(member, merged);
112
+ }
113
+
114
+ function classesShareHmrLineage(a: HmrComponentConstructor, b: HmrComponentConstructor): boolean {
115
+ return a === b || hmrLineages.get(a)?.has(b) === true;
116
+ }
117
+
118
+ export abstract class GameComponent<K extends AdapterSurface = 'threejs'> {
59
119
  /** Which phase this component's update runs in. Override in subclasses. */
60
120
  static phase: SystemPhaseName = 'gameLogic';
61
121
 
122
+ /** See {@link GAME_COMPONENT_BRAND} — inherited by every subclass. */
123
+ static readonly [GAME_COMPONENT_BRAND] = true;
124
+
125
+ /**
126
+ * Preserve ordinary JavaScript `instanceof` across a true HMR prototype
127
+ * swap. Subclasses inherit this static hook, so `value instanceof
128
+ * PlayerController` first performs the native prototype-chain check and,
129
+ * only when that fails, accepts an exact constructor lineage previously
130
+ * linked by ComponentManager.hotSwap(). Non-HMR values retain byte-for-byte
131
+ * native semantics.
132
+ */
133
+ static [Symbol.hasInstance](value: unknown): boolean {
134
+ // biome-ignore lint/complexity/noThisInStatic: Symbol.hasInstance is inherited; `this` must be the queried subclass, not the GameComponent base.
135
+ const expected = this as HmrComponentConstructor;
136
+ const nativeMatch = Function.prototype[Symbol.hasInstance].call(expected, value) as boolean;
137
+ if (nativeMatch) return true;
138
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false;
139
+ const actual = (value as { constructor?: unknown }).constructor;
140
+ return (
141
+ typeof actual === 'function' &&
142
+ classesShareHmrLineage(expected, actual as HmrComponentConstructor)
143
+ );
144
+ }
145
+
62
146
  /**
63
- * Runtime-checkable declaration of which {@link WorldKind} this component
147
+ * Runtime-checkable declaration of which {@link AdapterSurface} this component
64
148
  * is written for (T7.2, D8 §3 — `docs/GAME-COMPONENT-GENERALIZATION.md`).
65
149
  * TS's `K` type parameter is erased at runtime, so this static is what the
66
150
  * ComponentManager checks at attach time: a node whose world's kind
@@ -71,7 +155,7 @@ export abstract class GameComponent<K extends WorldKind = 'threejs'> {
71
155
  * any world components are legal in (every world except `'react'`, which
72
156
  * always throws regardless of `declaredKind` — see the ComponentManager).
73
157
  */
74
- static declaredKind: WorldKind | 'any' = 'threejs';
158
+ static declaredKind: AdapterSurface | 'any' = 'threejs';
75
159
 
76
160
  /**
77
161
  * Optional Zod object schema for this component's authored fields. When set,
@@ -88,7 +172,7 @@ export abstract class GameComponent<K extends WorldKind = 'threejs'> {
88
172
  world!: WorldInstance;
89
173
 
90
174
  /**
91
- * Typed accessor, threejs worlds only — kept for compatibility. On a
175
+ * Typed accessor, threejs roots only — kept for compatibility. On a
92
176
  * `GameComponent` (default `K`) this types as `THREE.Object3D`, so every
93
177
  * existing component compiles unchanged. On a non-threejs instance it
94
178
  * THROWS a descriptive error rather than ever returning a wrong-kind node —
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Package-contributed Project Command for baking the procedural humanoid into
3
+ * ordinary project assets. The editor discovers this module through
4
+ * @vgai/engine's package.json; the editor itself contains no humanoid branch.
5
+ */
6
+
7
+ import { createHash } from 'node:crypto';
8
+ import { readFile, realpath } from 'node:fs/promises';
9
+ import { dirname, resolve, sep } from 'node:path';
10
+ import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
11
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
12
+ import { z } from 'zod';
13
+ import { createHumanoidClipLibrary, retargetClipToHumanoid } from './clips';
14
+ import { generateHumanoid } from './generate';
15
+ import { HumanoidParamsSchema } from './schema';
16
+
17
+ const InputSchema = z.object({
18
+ name: z
19
+ .string()
20
+ .regex(/^[a-z0-9][a-z0-9-_]{0,63}$/)
21
+ .default('humanoid')
22
+ .describe(
23
+ 'Stable lowercase asset name used for the generated GLB, prefab, and provenance files.',
24
+ ),
25
+ params: HumanoidParamsSchema.default(HumanoidParamsSchema.parse({})).describe(
26
+ 'Procedural body proportions.',
27
+ ),
28
+ clipSource: z
29
+ .string()
30
+ .optional()
31
+ .describe(
32
+ 'Optional project-public GLB path containing Mixamo-named clips. When supplied, Idle, Walk, and Run are retargeted and baked into the generated humanoid GLB.',
33
+ ),
34
+ clipNames: z
35
+ .array(z.string())
36
+ .min(1)
37
+ .default(['Idle', 'Walk', 'Run'])
38
+ .describe('Exact standard clip names to retarget from clipSource.'),
39
+ dryRun: z
40
+ .boolean()
41
+ .default(false)
42
+ .describe('Generate and validate the complete output batch without writing project files.'),
43
+ });
44
+
45
+ const OutputFileSchema = z.object({
46
+ path: z.string(),
47
+ bytes: z.number(),
48
+ mediaType: z.string().optional(),
49
+ role: z.enum(['asset', 'prefab', 'provenance', 'other']).optional(),
50
+ });
51
+
52
+ const ResultSchema = z.object({
53
+ model: z.string(),
54
+ prefab: z.string(),
55
+ provenance: z.string(),
56
+ vertexCount: z.number(),
57
+ clips: z.array(z.string()),
58
+ files: z.array(OutputFileSchema),
59
+ totalBytes: z.number(),
60
+ dryRun: z.boolean(),
61
+ });
62
+
63
+ interface OutputWriter {
64
+ write(
65
+ files: ReadonlyArray<{
66
+ path: string;
67
+ content: string | Uint8Array;
68
+ mediaType?: string;
69
+ role?: 'asset' | 'prefab' | 'provenance' | 'other';
70
+ }>,
71
+ options?: { dryRun?: boolean },
72
+ ): Promise<{
73
+ files: z.infer<typeof OutputFileSchema>[];
74
+ totalBytes: number;
75
+ dryRun: boolean;
76
+ }>;
77
+ }
78
+
79
+ interface ProjectOperationContext {
80
+ projectRoot?: string;
81
+ projectOutputs?: OutputWriter;
82
+ signal?: AbortSignal;
83
+ }
84
+
85
+ const NODE_THREE_POLYFILL_KEYS = [
86
+ 'self',
87
+ 'ProgressEvent',
88
+ 'createImageBitmap',
89
+ 'FileReader',
90
+ ] as const;
91
+ let nodeThreePolyfillDepth = 0;
92
+ let nodeThreePreviousDescriptors: Record<
93
+ (typeof NODE_THREE_POLYFILL_KEYS)[number],
94
+ PropertyDescriptor | undefined
95
+ > | null = null;
96
+
97
+ /**
98
+ * Three's official GLTFLoader/GLTFExporter addons expect a small browser API
99
+ * surface even when invoked by this declared Node-only project operation.
100
+ * Install that surface for the duration of overlapping bakes and restore the
101
+ * editor-server process exactly when the last caller releases it.
102
+ */
103
+ function installNodeThreePolyfills(): () => void {
104
+ const target = globalThis as unknown as Record<string, unknown>;
105
+ if (nodeThreePolyfillDepth === 0) {
106
+ nodeThreePreviousDescriptors = Object.fromEntries(
107
+ NODE_THREE_POLYFILL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(target, key)]),
108
+ ) as NonNullable<typeof nodeThreePreviousDescriptors>;
109
+ if (!target['self']) target['self'] = globalThis;
110
+ if (!target['ProgressEvent']) {
111
+ target['ProgressEvent'] = class {
112
+ readonly type: string;
113
+ constructor(type: string, init: Record<string, unknown> = {}) {
114
+ this.type = type;
115
+ Object.assign(this, init);
116
+ }
117
+ };
118
+ }
119
+ if (!target['createImageBitmap']) {
120
+ target['createImageBitmap'] = async () => ({ close() {} });
121
+ }
122
+ if (!target['FileReader']) {
123
+ target['FileReader'] = class {
124
+ result: string | ArrayBuffer | null = null;
125
+ onloadend: (() => void) | null = null;
126
+ readAsArrayBuffer(blob: Blob): void {
127
+ void blob.arrayBuffer().then((value) => {
128
+ this.result = value;
129
+ this.onloadend?.();
130
+ });
131
+ }
132
+ readAsDataURL(blob: Blob): void {
133
+ void blob.arrayBuffer().then((value) => {
134
+ this.result = `data:${blob.type || 'application/octet-stream'};base64,${Buffer.from(value).toString('base64')}`;
135
+ this.onloadend?.();
136
+ });
137
+ }
138
+ };
139
+ }
140
+ }
141
+ nodeThreePolyfillDepth++;
142
+ let released = false;
143
+ return () => {
144
+ if (released) return;
145
+ released = true;
146
+ nodeThreePolyfillDepth--;
147
+ if (nodeThreePolyfillDepth !== 0 || !nodeThreePreviousDescriptors) return;
148
+ for (const key of NODE_THREE_POLYFILL_KEYS) {
149
+ const descriptor = nodeThreePreviousDescriptors[key];
150
+ if (descriptor) Object.defineProperty(target, key, descriptor);
151
+ else delete target[key];
152
+ }
153
+ nodeThreePreviousDescriptors = null;
154
+ };
155
+ }
156
+
157
+ async function resolvePublicFile(projectRoot: string, path: string): Promise<string> {
158
+ const relativePath = path.replace(/^\//, '');
159
+ const publicRoot = await realpath(resolve(projectRoot, 'public'));
160
+ const candidate = await realpath(resolve(publicRoot, relativePath));
161
+ if (candidate !== publicRoot && !candidate.startsWith(`${publicRoot}${sep}`)) {
162
+ throw new Error(`clipSource must resolve inside the project's public/ directory: ${path}`);
163
+ }
164
+ return candidate;
165
+ }
166
+
167
+ async function loadRetargetedClips(
168
+ projectRoot: string,
169
+ sourcePath: string,
170
+ names: string[],
171
+ rig: ReturnType<typeof generateHumanoid>,
172
+ ) {
173
+ const releasePolyfills = installNodeThreePolyfills();
174
+ try {
175
+ const absolute = await resolvePublicFile(projectRoot, sourcePath);
176
+ const bytes = await readFile(absolute);
177
+ const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
178
+ const gltf = await new GLTFLoader().parseAsync(arrayBuffer, `${dirname(absolute)}${sep}`);
179
+ const library = createHumanoidClipLibrary(gltf.animations);
180
+ const clips = names.map((name) => {
181
+ const clip = retargetClipToHumanoid(library.get(name), rig);
182
+ clip.name = name;
183
+ return clip;
184
+ });
185
+ gltf.scene.traverse((object) => {
186
+ const mesh = object as typeof object & {
187
+ geometry?: { dispose(): void };
188
+ material?: { dispose(): void } | Array<{ dispose(): void }>;
189
+ };
190
+ mesh.geometry?.dispose();
191
+ for (const material of Array.isArray(mesh.material)
192
+ ? mesh.material
193
+ : mesh.material
194
+ ? [mesh.material]
195
+ : []) {
196
+ material.dispose();
197
+ }
198
+ });
199
+ return { clips, sourceSha256: createHash('sha256').update(bytes).digest('hex') };
200
+ } finally {
201
+ releasePolyfills();
202
+ }
203
+ }
204
+
205
+ async function exportGlb(
206
+ root: ReturnType<typeof generateHumanoid>['root'],
207
+ animations: Awaited<ReturnType<typeof loadRetargetedClips>>['clips'],
208
+ ): Promise<Uint8Array> {
209
+ const releasePolyfills = installNodeThreePolyfills();
210
+ try {
211
+ const result = await new Promise<ArrayBuffer>((resolveResult, reject) => {
212
+ new GLTFExporter().parse(
213
+ root,
214
+ (value) => {
215
+ if (value instanceof ArrayBuffer) resolveResult(value);
216
+ else reject(new Error('GLTFExporter returned JSON while binary output was requested.'));
217
+ },
218
+ reject,
219
+ {
220
+ binary: true,
221
+ animations,
222
+ onlyVisible: false,
223
+ },
224
+ );
225
+ });
226
+ return new Uint8Array(result);
227
+ } finally {
228
+ releasePolyfills();
229
+ }
230
+ }
231
+
232
+ export const operation = {
233
+ name: 'project.humanoid.bake',
234
+ summary: 'Bake a procedural humanoid into an ordinary GLB asset and prefab.',
235
+ description:
236
+ 'Generates VGAI’s skinned procedural humanoid from body parameters. Optionally retargets ' +
237
+ 'Mixamo-named clips from a project-public GLB, then atomically writes the generated GLB, ' +
238
+ 'a normal .prefab.json referencing it, and reproducibility provenance. Use the prefab as ' +
239
+ 'the authored scene object; do not regenerate the visible body every gameplay run. The ' +
240
+ 'baked body keeps the multicolored engineering-reference material: it is not a styled ' +
241
+ 'production character. A prominent player character should receive an intentional, ' +
242
+ 'project-owned material/composition treatment unless that reference look is deliberate.',
243
+ input: InputSchema,
244
+ result: ResultSchema,
245
+ errors: [],
246
+ requires: { project: true },
247
+ host: 'node' as const,
248
+ mutates: true,
249
+ supportsDryRun: true,
250
+ longRunning: true,
251
+ permission: {
252
+ risk: 'write' as const,
253
+ summary: 'Writes or replaces generated model, prefab, and provenance files under public/.',
254
+ },
255
+ async impl(input: z.infer<typeof InputSchema>, ctx: ProjectOperationContext) {
256
+ if (!ctx.projectRoot || !ctx.projectOutputs) {
257
+ throw new Error('project.humanoid.bake requires a project root and generated-output writer.');
258
+ }
259
+ if (ctx.signal?.aborted) throw new Error('project.humanoid.bake was cancelled.');
260
+
261
+ const rig = generateHumanoid(input.params);
262
+ try {
263
+ const loaded = input.clipSource
264
+ ? await loadRetargetedClips(ctx.projectRoot, input.clipSource, input.clipNames, rig)
265
+ : { clips: [], sourceSha256: null };
266
+ const glb = await exportGlb(rig.root, loaded.clips);
267
+ const base = input.name;
268
+ const model = `public/models/generated/${base}.glb`;
269
+ const prefab = `public/prefabs/generated/${base}.prefab.json`;
270
+ const provenance = `public/models/generated/${base}.provenance.json`;
271
+ const modelUrl = `/models/generated/${base}.glb`;
272
+ const prefabDocument = {
273
+ version: 1,
274
+ name: base,
275
+ root: {
276
+ name: base,
277
+ mesh: { type: 'gltf', src: modelUrl },
278
+ ...(loaded.clips.length > 0 ? { animation: {} } : {}),
279
+ shadow: { enabled: true },
280
+ },
281
+ };
282
+ const provenanceDocument = {
283
+ version: 1,
284
+ generator: '@vgai/engine:project.humanoid.bake',
285
+ params: rig.params,
286
+ clipSource: input.clipSource ?? null,
287
+ clipSourceSha256: loaded.sourceSha256,
288
+ clips: loaded.clips.map((clip) => clip.name),
289
+ modelSha256: createHash('sha256').update(glb).digest('hex'),
290
+ };
291
+ const output = await ctx.projectOutputs.write(
292
+ [
293
+ {
294
+ path: model,
295
+ content: glb,
296
+ mediaType: 'model/gltf-binary',
297
+ role: 'asset',
298
+ },
299
+ {
300
+ path: prefab,
301
+ content: `${JSON.stringify(prefabDocument, null, 2)}\n`,
302
+ mediaType: 'application/json',
303
+ role: 'prefab',
304
+ },
305
+ {
306
+ path: provenance,
307
+ content: `${JSON.stringify(provenanceDocument, null, 2)}\n`,
308
+ mediaType: 'application/json',
309
+ role: 'provenance',
310
+ },
311
+ ],
312
+ { dryRun: input.dryRun },
313
+ );
314
+ return {
315
+ model,
316
+ prefab,
317
+ provenance,
318
+ vertexCount: rig.vertexCount,
319
+ clips: loaded.clips.map((clip) => clip.name),
320
+ ...output,
321
+ };
322
+ } finally {
323
+ rig.dispose();
324
+ }
325
+ },
326
+ };