@vgai/engine 0.2.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/LICENSE +202 -0
- package/README.md +35 -0
- package/package.json +55 -0
- package/src/adapter/authoring.ts +402 -0
- package/src/adapter/colyseus-networking-adapter.ts +72 -0
- package/src/adapter/first-party-systems.ts +103 -0
- package/src/adapter/game-adapter.ts +151 -0
- package/src/adapter/host-context.ts +77 -0
- package/src/adapter/index.ts +85 -0
- package/src/adapter/ingest/game-contract.ts +59 -0
- package/src/adapter/ingest/overlay-applier.ts +207 -0
- package/src/adapter/ingest/overlay-apply.ts +124 -0
- package/src/adapter/ingest/overlay-file.ts +126 -0
- package/src/adapter/ingest/overlay-report.ts +176 -0
- package/src/adapter/ingest/scene-capture.ts +307 -0
- package/src/adapter/ingest/upstream-pin.ts +52 -0
- package/src/adapter/loop-gate-report.ts +54 -0
- package/src/adapter/rapier-physics-adapter.ts +56 -0
- package/src/adapter/system-adapter.ts +154 -0
- package/src/adapter/transform.ts +18 -0
- package/src/adapter/vgai-scene-game-adapter.ts +886 -0
- package/src/adapter/world-kind.ts +34 -0
- package/src/ai/navigation.ts +164 -0
- package/src/animation/anim-graph-types.ts +56 -0
- package/src/animation/anim-graph.ts +406 -0
- package/src/animation/anim-system.ts +28 -0
- package/src/animation/blend-node.ts +119 -0
- package/src/animation/property-track.ts +178 -0
- package/src/animation/schema.ts +204 -0
- package/src/assets.ts +80 -0
- package/src/audio/ambient.ts +300 -0
- package/src/audio/impacts.ts +212 -0
- package/src/audio/index.ts +7 -0
- package/src/audio/movement.ts +140 -0
- package/src/audio/musical.ts +200 -0
- package/src/audio/ui-sounds.ts +171 -0
- package/src/audio/vehicle.ts +235 -0
- package/src/audio/weapons.ts +152 -0
- package/src/core/game-loop.ts +127 -0
- package/src/core/system-runner.ts +298 -0
- package/src/core/types.ts +58 -0
- package/src/dev/console-bridge.ts +83 -0
- package/src/dev/debug-draw.ts +80 -0
- package/src/dev/logger.ts +119 -0
- package/src/ecs/component-manager.ts +748 -0
- package/src/ecs/game-component.ts +147 -0
- package/src/ecs/hmr-swap-report.ts +65 -0
- package/src/input/input-manager.ts +439 -0
- package/src/input/input-types.ts +19 -0
- package/src/input/schema.ts +129 -0
- package/src/loader.ts +70 -0
- package/src/manifest/index.ts +24 -0
- package/src/manifest/load-file.ts +16 -0
- package/src/manifest/load.ts +378 -0
- package/src/manifest/schema.ts +375 -0
- package/src/physics/collision-system.ts +76 -0
- package/src/physics/physics-registry.ts +83 -0
- package/src/physics/transform-writer.ts +41 -0
- package/src/physics/trigger-dispatch.ts +97 -0
- package/src/react/game-state.tsx +172 -0
- package/src/render/auto-batcher.ts +169 -0
- package/src/render/render-batch-system.ts +268 -0
- package/src/render/render-features.ts +146 -0
- package/src/render/render-settings.ts +72 -0
- package/src/runtime/create-runtime.ts +1152 -0
- package/src/runtime/frame-selector-cache.ts +81 -0
- package/src/runtime/game.ts +1003 -0
- package/src/runtime/input-router.ts +213 -0
- package/src/runtime/mount-game.ts +269 -0
- package/src/runtime/mount-manifest.ts +361 -0
- package/src/runtime/scene-ui-bridge.ts +86 -0
- package/src/runtime/scene-ui-data.ts +119 -0
- package/src/runtime/state-bridge.ts +79 -0
- package/src/runtime/types.ts +196 -0
- package/src/scene/asset-loaders.ts +195 -0
- package/src/scene/asset-paths.ts +123 -0
- package/src/scene/asset-registry.ts +67 -0
- package/src/scene/collider-dimensions.ts +125 -0
- package/src/scene/component-registry.ts +40 -0
- package/src/scene/defaults.ts +164 -0
- package/src/scene/geometries/index.ts +7 -0
- package/src/scene/geometries/terrain.ts +42 -0
- package/src/scene/geometry-registry.ts +42 -0
- package/src/scene/instance-registry.ts +84 -0
- package/src/scene/instancers/grid.ts +38 -0
- package/src/scene/instancers/index.ts +7 -0
- package/src/scene/light-camera-factory.ts +97 -0
- package/src/scene/material-factory.ts +211 -0
- package/src/scene/material-registry.ts +73 -0
- package/src/scene/materials/index.ts +7 -0
- package/src/scene/materials/water.ts +56 -0
- package/src/scene/parse.ts +71 -0
- package/src/scene/particles-factory.ts +383 -0
- package/src/scene/scene-apply.ts +356 -0
- package/src/scene/scene-diff-schema.ts +115 -0
- package/src/scene/scene-diff-types.ts +29 -0
- package/src/scene/scene-loader.ts +1533 -0
- package/src/scene/scene-query.ts +63 -0
- package/src/scene/scene-types.ts +34 -0
- package/src/scene/scene-version.ts +40 -0
- package/src/scene/schema/animation.ts +95 -0
- package/src/scene/schema/audio.ts +25 -0
- package/src/scene/schema/camera.ts +21 -0
- package/src/scene/schema/collider.ts +69 -0
- package/src/scene/schema/entity-ref.ts +78 -0
- package/src/scene/schema/entity.ts +169 -0
- package/src/scene/schema/environment.ts +384 -0
- package/src/scene/schema/index.ts +95 -0
- package/src/scene/schema/instances.ts +35 -0
- package/src/scene/schema/joint.ts +26 -0
- package/src/scene/schema/light.ts +38 -0
- package/src/scene/schema/material.ts +113 -0
- package/src/scene/schema/mesh.ts +108 -0
- package/src/scene/schema/particles.ts +398 -0
- package/src/scene/schema/physics.ts +49 -0
- package/src/scene/schema/scene-file.ts +299 -0
- package/src/scene/schema/shadow.ts +24 -0
- package/src/scene/schema/spline.ts +21 -0
- package/src/scene/schema/tuples.ts +21 -0
- package/src/scene/schema/ui.ts +602 -0
- package/src/scene/user-data.ts +203 -0
- package/src/setup/setup-audio.ts +60 -0
- package/src/setup/setup-particles.ts +23 -0
- package/src/setup/setup-physics.ts +67 -0
- package/src/setup/setup-renderer.ts +529 -0
- package/src/types-n8ao.d.ts +37 -0
- package/src/types-realism-effects.d.ts +61 -0
- package/src/world2d/authoring-2d.ts +208 -0
- package/src/world2d/capture-to-scene2d.ts +52 -0
- package/src/world2d/collision-2d.ts +106 -0
- package/src/world2d/components-2d.ts +86 -0
- package/src/world2d/index.ts +66 -0
- package/src/world2d/ingest-iframe-2d.ts +255 -0
- package/src/world2d/ingest2d.ts +131 -0
- package/src/world2d/physics2d-registry.ts +49 -0
- package/src/world2d/pixi-game-adapter.ts +325 -0
- package/src/world2d/pixi-surface.ts +78 -0
- package/src/world2d/scene-capture-2d.ts +117 -0
- package/src/world2d/scene2d-loader.ts +308 -0
- package/src/world2d/schema/entity2d.ts +145 -0
- package/src/world2d/schema/physics2d.ts +53 -0
- package/src/world2d/schema/sprite.ts +71 -0
- package/src/world2d/schema/tilemap.ts +22 -0
- package/src/world2d/schema/tuples2d.ts +25 -0
- package/src/world2d/system-adapters-2d.ts +49 -0
- package/src/world2d/transform-writer-2d.ts +24 -0
- package/src/world2d/types.ts +55 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { BlendTreeDef } from './anim-graph-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Evaluates a blend tree and returns weights for each child clip.
|
|
5
|
+
*
|
|
6
|
+
* 1D blend: interpolates between sorted blend points based on a single parameter.
|
|
7
|
+
* Example: speed=3 with children at [walk:1, run:5] → weights [0.5, 0.5]
|
|
8
|
+
*
|
|
9
|
+
* 2D blend: uses two parameters (e.g., strafe locomotion). Weights are computed by
|
|
10
|
+
* inverse-distance weighting (IDW): each child's weight is proportional to
|
|
11
|
+
* 1/distance from the (parameter, parameterY) sample point to that child's
|
|
12
|
+
* (threshold, thresholdY) position, then normalized so all weights sum to 1.
|
|
13
|
+
* A sample landing exactly on a child gives that child ~full weight. This is a
|
|
14
|
+
* smooth approximation, not a Delaunay/barycentric blend (so non-adjacent
|
|
15
|
+
* children still receive a small share); it is intentionally kept simple.
|
|
16
|
+
*
|
|
17
|
+
* Direct: each child has an explicit weight.
|
|
18
|
+
*/
|
|
19
|
+
export function evaluateBlendTree(
|
|
20
|
+
def: BlendTreeDef,
|
|
21
|
+
parameters: Map<string, number | boolean>,
|
|
22
|
+
): { clip: string; weight: number }[] {
|
|
23
|
+
switch (def.type) {
|
|
24
|
+
case '1D':
|
|
25
|
+
return evaluate1D(def, parameters);
|
|
26
|
+
case '2D':
|
|
27
|
+
return evaluate2D(def, parameters);
|
|
28
|
+
case 'direct':
|
|
29
|
+
return evaluateDirect(def);
|
|
30
|
+
default:
|
|
31
|
+
return def.children.map((c) => ({ clip: c.clip, weight: 1 / def.children.length }));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function evaluate1D(
|
|
36
|
+
def: BlendTreeDef,
|
|
37
|
+
parameters: Map<string, number | boolean>,
|
|
38
|
+
): { clip: string; weight: number }[] {
|
|
39
|
+
const paramValue = Number(parameters.get(def.parameter) ?? 0);
|
|
40
|
+
const children = [...def.children].sort((a, b) => a.threshold - b.threshold);
|
|
41
|
+
const results = children.map((c) => ({ clip: c.clip, weight: 0 }));
|
|
42
|
+
|
|
43
|
+
if (children.length === 0) return results;
|
|
44
|
+
if (children.length === 1) {
|
|
45
|
+
results[0]!.weight = 1;
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Below first threshold
|
|
50
|
+
if (paramValue <= children[0]!.threshold) {
|
|
51
|
+
results[0]!.weight = 1;
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Above last threshold
|
|
56
|
+
if (paramValue >= children[children.length - 1]!.threshold) {
|
|
57
|
+
results[results.length - 1]!.weight = 1;
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Between two thresholds — linear interpolation
|
|
62
|
+
for (let i = 0; i < children.length - 1; i++) {
|
|
63
|
+
const lo = children[i]!;
|
|
64
|
+
const hi = children[i + 1]!;
|
|
65
|
+
if (paramValue >= lo.threshold && paramValue <= hi.threshold) {
|
|
66
|
+
const range = hi.threshold - lo.threshold;
|
|
67
|
+
const t = range > 0 ? (paramValue - lo.threshold) / range : 0;
|
|
68
|
+
results[i]!.weight = 1 - t;
|
|
69
|
+
results[i + 1]!.weight = t;
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function evaluate2D(
|
|
78
|
+
def: BlendTreeDef,
|
|
79
|
+
parameters: Map<string, number | boolean>,
|
|
80
|
+
): { clip: string; weight: number }[] {
|
|
81
|
+
const px = Number(parameters.get(def.parameter) ?? 0);
|
|
82
|
+
const py = Number(parameters.get(def.parameterY ?? '') ?? 0);
|
|
83
|
+
|
|
84
|
+
// Inverse-distance weighting: weight each child by 1/distance to its 2D point,
|
|
85
|
+
// then normalize below so the returned weights sum to 1.
|
|
86
|
+
const children = def.children;
|
|
87
|
+
const results = children.map((c) => ({ clip: c.clip, weight: 0 }));
|
|
88
|
+
|
|
89
|
+
let totalInvDist = 0;
|
|
90
|
+
const invDists: number[] = [];
|
|
91
|
+
|
|
92
|
+
for (const child of children) {
|
|
93
|
+
const dx = px - child.threshold;
|
|
94
|
+
const dy = py - (child.thresholdY ?? 0);
|
|
95
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
96
|
+
const invDist = dist < 0.001 ? 1000 : 1 / dist;
|
|
97
|
+
invDists.push(invDist);
|
|
98
|
+
totalInvDist += invDist;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (totalInvDist > 0) {
|
|
102
|
+
for (let i = 0; i < results.length; i++) {
|
|
103
|
+
results[i]!.weight = invDists[i]! / totalInvDist;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return results;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function evaluateDirect(def: BlendTreeDef): { clip: string; weight: number }[] {
|
|
111
|
+
const results = def.children.map((c) => ({ clip: c.clip, weight: c.weight ?? 0 }));
|
|
112
|
+
// Normalize so weights sum to exactly 1.0 — otherwise the leftover weight
|
|
113
|
+
// bleeds in the bind pose / T-pose (the actions add up to <1 or >1).
|
|
114
|
+
const total = results.reduce((sum, r) => sum + r.weight, 0);
|
|
115
|
+
if (total > 0) {
|
|
116
|
+
for (const r of results) r.weight /= total;
|
|
117
|
+
}
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F5 — Declarative property tracks (deterministic timeline).
|
|
3
|
+
* docs/VSCN-STRUCTURAL-GAPS-DESIGN.md
|
|
4
|
+
*
|
|
5
|
+
* A property track is DATA: a pure function of time (keyframes + named
|
|
6
|
+
* easing), never behavior. `samplePropertyTrack` is the pure sampler (time →
|
|
7
|
+
* value) the unit test asserts determinism against directly.
|
|
8
|
+
* `PropertyTrackRunner` is a thin stateful shell that accumulates elapsed
|
|
9
|
+
* time from the fixed-step `dt` already threaded through the scene tick (the
|
|
10
|
+
* SAME `dt` the AnimationMixers get — see scene-loader.ts's `tickFns`) and
|
|
11
|
+
* writes the sampled value onto the target Object3D property each frame. It
|
|
12
|
+
* introduces NO new clock: a paused frame passes `dt = 0`, and time simply
|
|
13
|
+
* does not advance — pause/timeScale are honored for free.
|
|
14
|
+
*
|
|
15
|
+
* Anything state-dependent ("move WHEN the player steps on it") stays a
|
|
16
|
+
* GameComponent, which may start/stop/seek a track. This module only adds the
|
|
17
|
+
* time→value data + a deterministic evaluator (G1 — data, not behavior).
|
|
18
|
+
*/
|
|
19
|
+
import type * as THREE from 'three';
|
|
20
|
+
import { log } from '../dev/logger';
|
|
21
|
+
import type { PropertyTrack, PropertyTrackKeyframe } from '../scene/schema/animation';
|
|
22
|
+
import type { UIEasing } from '../scene/schema/ui';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Easing — mirrors packages/scene-ui/src/animation.ts's `ease` (same enum,
|
|
26
|
+
// same semantics). NOT imported from there: scene-ui depends on the engine,
|
|
27
|
+
// so importing it back here would be circular. A ~10-line parallel
|
|
28
|
+
// implementation over the shared UIEasingSchema enum is the correct fix.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function ease(t: number, kind: UIEasing | undefined): number {
|
|
32
|
+
const x = Math.max(0, Math.min(1, t));
|
|
33
|
+
switch (kind) {
|
|
34
|
+
case 'ease-in':
|
|
35
|
+
return x * x;
|
|
36
|
+
case 'ease-out':
|
|
37
|
+
return 1 - (1 - x) * (1 - x);
|
|
38
|
+
case 'ease-in-out':
|
|
39
|
+
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
|
40
|
+
case 'step':
|
|
41
|
+
return x < 1 ? 0 : 1; // hold the previous value until the next keyframe
|
|
42
|
+
default:
|
|
43
|
+
return x; // linear
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sample a track at `time` seconds from track start. `time` is assumed
|
|
49
|
+
* already wrapped/clamped by the caller (see {@link PropertyTrackRunner});
|
|
50
|
+
* this function is a pure, side-effect-free keyframe interpolation and is
|
|
51
|
+
* safe to call directly from tests.
|
|
52
|
+
*/
|
|
53
|
+
export function samplePropertyTrack(track: PropertyTrack, time: number): number {
|
|
54
|
+
const kfs = track.keyframes;
|
|
55
|
+
if (kfs.length === 0) return 0;
|
|
56
|
+
const first = kfs[0] as PropertyTrackKeyframe;
|
|
57
|
+
if (time <= first.time) return first.value;
|
|
58
|
+
const last = kfs[kfs.length - 1] as PropertyTrackKeyframe;
|
|
59
|
+
if (time >= last.time) return last.value;
|
|
60
|
+
for (let i = 0; i < kfs.length - 1; i++) {
|
|
61
|
+
const k0 = kfs[i] as PropertyTrackKeyframe;
|
|
62
|
+
const k1 = kfs[i + 1] as PropertyTrackKeyframe;
|
|
63
|
+
if (time >= k0.time && time <= k1.time) {
|
|
64
|
+
const span = k1.time - k0.time || 1;
|
|
65
|
+
const raw = (time - k0.time) / span;
|
|
66
|
+
const t = ease(raw, k0.easing);
|
|
67
|
+
return k0.value + (k1.value - k0.value) * t;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return last.value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Wrap/clamp elapsed time against a track's `duration`/`loop`. */
|
|
74
|
+
function resolveTrackTime(track: PropertyTrack, elapsed: number): number {
|
|
75
|
+
if (track.duration <= 0) return elapsed;
|
|
76
|
+
if (track.loop) return elapsed % track.duration;
|
|
77
|
+
return Math.min(elapsed, track.duration);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type TargetSetter = (object3D: THREE.Object3D, value: number, warnOnce: () => void) => void;
|
|
81
|
+
|
|
82
|
+
// Allowlisted target → setter. Keys are exactly PropertyTrackTargetSchema's
|
|
83
|
+
// enum values — the allowlist IS the schema; no arbitrary path resolution.
|
|
84
|
+
const TARGET_SETTERS: Record<string, TargetSetter> = {
|
|
85
|
+
'position.x': (o, v) => {
|
|
86
|
+
o.position.x = v;
|
|
87
|
+
},
|
|
88
|
+
'position.y': (o, v) => {
|
|
89
|
+
o.position.y = v;
|
|
90
|
+
},
|
|
91
|
+
'position.z': (o, v) => {
|
|
92
|
+
o.position.z = v;
|
|
93
|
+
},
|
|
94
|
+
'rotation.x': (o, v) => {
|
|
95
|
+
o.rotation.x = v;
|
|
96
|
+
},
|
|
97
|
+
'rotation.y': (o, v) => {
|
|
98
|
+
o.rotation.y = v;
|
|
99
|
+
},
|
|
100
|
+
'rotation.z': (o, v) => {
|
|
101
|
+
o.rotation.z = v;
|
|
102
|
+
},
|
|
103
|
+
'scale.x': (o, v) => {
|
|
104
|
+
o.scale.x = v;
|
|
105
|
+
},
|
|
106
|
+
'scale.y': (o, v) => {
|
|
107
|
+
o.scale.y = v;
|
|
108
|
+
},
|
|
109
|
+
'scale.z': (o, v) => {
|
|
110
|
+
o.scale.z = v;
|
|
111
|
+
},
|
|
112
|
+
'material.opacity': (o, v, warnOnce) => {
|
|
113
|
+
const mesh = o as THREE.Mesh;
|
|
114
|
+
const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
|
|
115
|
+
if (!material) {
|
|
116
|
+
warnOnce();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const mat of Array.isArray(material) ? material : [material]) {
|
|
120
|
+
mat.transparent = true;
|
|
121
|
+
mat.opacity = v;
|
|
122
|
+
mat.needsUpdate = true;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
'light.intensity': (o, v, warnOnce) => {
|
|
126
|
+
const light = o as THREE.Light;
|
|
127
|
+
if (!light.isLight) {
|
|
128
|
+
warnOnce();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
light.intensity = v;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Drives 1..N property tracks against a single Object3D. Constructed once per
|
|
137
|
+
* entity that declares `animation.tracks`; `update(dt)` is registered in the
|
|
138
|
+
* SAME tickFns list AnimationMixers use (scene-loader.ts), so it runs in the
|
|
139
|
+
* animation phase on the same deterministic clock.
|
|
140
|
+
*/
|
|
141
|
+
export class PropertyTrackRunner {
|
|
142
|
+
private readonly object3D: THREE.Object3D;
|
|
143
|
+
private readonly tracks: PropertyTrack[];
|
|
144
|
+
private elapsed: number[];
|
|
145
|
+
private readonly warned = new Set<string>();
|
|
146
|
+
|
|
147
|
+
constructor(object3D: THREE.Object3D, tracks: PropertyTrack[]) {
|
|
148
|
+
this.object3D = object3D;
|
|
149
|
+
this.tracks = tracks;
|
|
150
|
+
this.elapsed = tracks.map(() => 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Advance every track by `dt` seconds and write the sampled value. A paused
|
|
154
|
+
* frame (`dt = 0`) leaves every track's value unchanged — no wall-clock, no
|
|
155
|
+
* new clock; determinism follows directly from the caller's `dt`. */
|
|
156
|
+
update(dt: number): void {
|
|
157
|
+
for (let i = 0; i < this.tracks.length; i++) {
|
|
158
|
+
const track = this.tracks[i] as PropertyTrack;
|
|
159
|
+
this.elapsed[i] = (this.elapsed[i] ?? 0) + dt;
|
|
160
|
+
const time = resolveTrackTime(track, this.elapsed[i] ?? 0);
|
|
161
|
+
const value = samplePropertyTrack(track, time);
|
|
162
|
+
const setter = TARGET_SETTERS[track.target];
|
|
163
|
+
// Unreachable when `target` was parsed through PropertyTrackTargetSchema
|
|
164
|
+
// (the allowlist enum) — guarded defensively since TARGET_SETTERS is a
|
|
165
|
+
// separate table that must stay in sync with the schema.
|
|
166
|
+
if (!setter) continue;
|
|
167
|
+
setter(this.object3D, value, () => {
|
|
168
|
+
if (this.warned.has(track.target)) return;
|
|
169
|
+
this.warned.add(track.target);
|
|
170
|
+
log.scene.warn(
|
|
171
|
+
`Property track target "${track.target}" has no effect on entity "${this.object3D.name}" ` +
|
|
172
|
+
'(no material / not a light) — no-op.',
|
|
173
|
+
{ entityName: this.object3D.name, target: track.target },
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// T4.6 — the `.animgraph.json` schema.
|
|
2
|
+
//
|
|
3
|
+
// Was TS-types-only (`anim-graph-types.ts`) until T4.6 authored this Zod
|
|
4
|
+
// schema — see docs/BACKBONE-TASKS.md T4.6. `asset-loaders.ts`'s
|
|
5
|
+
// `loadAnimGraphData` is this schema's runtime reader: it parses via this
|
|
6
|
+
// schema before handing the data to `AnimGraph`/`LayerRuntime` (anim-graph.ts).
|
|
7
|
+
//
|
|
8
|
+
// Every field has a `.describe()` (repo policy — powers
|
|
9
|
+
// `scripts/generate-schema.ts` and the T4.1/T4.6 schema-walk coverage test).
|
|
10
|
+
// No runtime dependency beyond `zod`, matching the `scene/schema/` and
|
|
11
|
+
// `manifest/schema.ts` precedent.
|
|
12
|
+
//
|
|
13
|
+
// T4.1 inheritance + one NEW finding (T4.6 audit, same pattern as `ui.locked`/
|
|
14
|
+
// `ui.tags` being found beyond BACKBONE-TASKS.md's originally-named three):
|
|
15
|
+
// `LayerRuntime`'s constructor (anim-graph.ts) only ever destructures
|
|
16
|
+
// `layerDef.states`/`.transitions`/`.defaultState` — `blendMode` and
|
|
17
|
+
// `boneMask` are confirmed dead (named in T4.1/docs/BACKBONE-TASKS.md), and
|
|
18
|
+
// `weight` (an optional per-layer field, presumably meant for compositing
|
|
19
|
+
// multiple layers) is ALSO never read anywhere — there is no cross-layer
|
|
20
|
+
// weight combination in `AnimGraph.update()`, each layer's actions are
|
|
21
|
+
// weighted independently. All three are REJECTED at parse, mirroring the
|
|
22
|
+
// `scene/schema/ui.ts` dead-field mechanism (a `.superRefine` that throws
|
|
23
|
+
// naming the field). `packages/editor/src/components/asset-viewers/
|
|
24
|
+
// AnimGraphViewer.tsx` echoes `layer.blendMode` back verbatim in a read-only
|
|
25
|
+
// raw-JSON asset preview — that is not a behavioral reader (it fetches raw
|
|
26
|
+
// JSON directly, bypassing this schema, and merely echoes whatever value is
|
|
27
|
+
// present with no downstream effect), so it does not change the verdict —
|
|
28
|
+
// same distinction `schema-consumption-map.ts` draws for `ui.locked`.
|
|
29
|
+
//
|
|
30
|
+
// `layers[].name` is REQUIRED (not optional) and has no reader either — but
|
|
31
|
+
// the T4.1 "throw when authored" mechanism only fits OPTIONAL fields (you
|
|
32
|
+
// cannot reject a value that must always be present). It's structural
|
|
33
|
+
// identity metadata (distinguishing layers when there is more than one, and
|
|
34
|
+
// the natural key a future multi-layer inspector/editor would use), the same
|
|
35
|
+
// role the file-format `version` field plays — so it is RESERVED, not dead,
|
|
36
|
+
// matching the `2d.version`/`2d.surface` convention in
|
|
37
|
+
// `schema-consumption-map.ts`.
|
|
38
|
+
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
import type {
|
|
41
|
+
AnimGraphFile,
|
|
42
|
+
AnimLayer,
|
|
43
|
+
AnimParameter,
|
|
44
|
+
AnimState,
|
|
45
|
+
AnimTransition,
|
|
46
|
+
BlendChild,
|
|
47
|
+
BlendTreeDef,
|
|
48
|
+
TransitionCondition,
|
|
49
|
+
} from './anim-graph-types';
|
|
50
|
+
|
|
51
|
+
export const AnimParameterSchema: z.ZodType<AnimParameter> = z.object({
|
|
52
|
+
type: z
|
|
53
|
+
.enum(['float', 'int', 'bool', 'trigger'])
|
|
54
|
+
.describe('Parameter kind — drives which inspector widget/comparison semantics apply'),
|
|
55
|
+
default: z
|
|
56
|
+
.union([z.number(), z.boolean()])
|
|
57
|
+
.describe('Initial value (AnimGraph constructor seeds `this.parameters` with this)'),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// NOTE: the `as z.ZodType<X>` casts below (mirroring `UIRootSchema`'s pattern
|
|
61
|
+
// in `scene/schema/ui.ts`) are needed because the hand-written TS interfaces
|
|
62
|
+
// in `anim-graph-types.ts` declare optional fields as `foo?: T` rather than
|
|
63
|
+
// `foo?: T | undefined` — under this repo's `exactOptionalPropertyTypes`,
|
|
64
|
+
// Zod's `.optional()` output (`T | undefined`) isn't structurally assignable
|
|
65
|
+
// to that without the cast, even though the two are semantically identical
|
|
66
|
+
// (an omittable field). The runtime schema is unaffected; this is purely a
|
|
67
|
+
// pure tsc-strictness accommodation.
|
|
68
|
+
|
|
69
|
+
const TransitionConditionSchema: z.ZodType<TransitionCondition> = z.object({
|
|
70
|
+
param: z.string().describe('Parameter (or trigger) name this condition tests'),
|
|
71
|
+
op: z
|
|
72
|
+
.enum(['>', '<', '>=', '<=', '==', '!=', 'trigger'])
|
|
73
|
+
.describe('Comparison operator, or "trigger" to test a fired-trigger flag'),
|
|
74
|
+
value: z
|
|
75
|
+
.union([z.number(), z.boolean()])
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Value to compare against (unused when op is "trigger")'),
|
|
78
|
+
}) as z.ZodType<TransitionCondition>;
|
|
79
|
+
|
|
80
|
+
const AnimTransitionSchema: z.ZodType<AnimTransition> = z.object({
|
|
81
|
+
from: z.string().describe('Source state name, or "*" to match any current state'),
|
|
82
|
+
to: z.string().describe('Destination state name'),
|
|
83
|
+
conditions: z
|
|
84
|
+
.array(TransitionConditionSchema)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe('All conditions must hold for this transition to fire'),
|
|
87
|
+
duration: z.number().describe('Crossfade duration in seconds'),
|
|
88
|
+
exitTime: z
|
|
89
|
+
.number()
|
|
90
|
+
.optional()
|
|
91
|
+
.describe(
|
|
92
|
+
'Normalized time (0-1) into the source state before this transition is eligible to fire',
|
|
93
|
+
),
|
|
94
|
+
}) as z.ZodType<AnimTransition>;
|
|
95
|
+
|
|
96
|
+
const BlendChildSchema: z.ZodType<BlendChild> = z.object({
|
|
97
|
+
clip: z.string().describe('Animation clip name this child plays'),
|
|
98
|
+
threshold: z.number().describe('1D/2D blend position on the primary parameter axis'),
|
|
99
|
+
thresholdY: z.number().optional().describe('2D blend position on the secondary parameter axis'),
|
|
100
|
+
weight: z.number().optional().describe('Explicit weight for "direct" blend trees'),
|
|
101
|
+
}) as z.ZodType<BlendChild>;
|
|
102
|
+
|
|
103
|
+
const BlendTreeDefSchema: z.ZodType<BlendTreeDef> = z.object({
|
|
104
|
+
type: z.enum(['1D', '2D', 'direct']).describe('Blend tree evaluation mode'),
|
|
105
|
+
parameter: z.string().describe('Primary parameter driving the blend'),
|
|
106
|
+
parameterY: z.string().optional().describe('Secondary parameter driving a 2D blend'),
|
|
107
|
+
children: z.array(BlendChildSchema).describe('Clips (and their blend positions/weights)'),
|
|
108
|
+
}) as z.ZodType<BlendTreeDef>;
|
|
109
|
+
|
|
110
|
+
const AnimStateSchema: z.ZodType<AnimState> = z.object({
|
|
111
|
+
clip: z
|
|
112
|
+
.string()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe('Single clip to play in this state (mutually exclusive with blendTree in practice)'),
|
|
115
|
+
loop: z.boolean().optional().describe('Loop the clip (default true)'),
|
|
116
|
+
speed: z.number().optional().describe('Playback speed multiplier (default 1)'),
|
|
117
|
+
blendTree: BlendTreeDefSchema.optional().describe(
|
|
118
|
+
'Blend tree to evaluate instead of a single clip',
|
|
119
|
+
),
|
|
120
|
+
}) as z.ZodType<AnimState>;
|
|
121
|
+
|
|
122
|
+
const AnimLayerSchema: z.ZodType<AnimLayer> = z
|
|
123
|
+
.object({
|
|
124
|
+
name: z
|
|
125
|
+
.string()
|
|
126
|
+
.describe(
|
|
127
|
+
'Layer identifier (RESERVED — no reader today; structural identity metadata, same role ' +
|
|
128
|
+
'as the file-format `version` field. See the file doc comment.)',
|
|
129
|
+
),
|
|
130
|
+
blendMode: z
|
|
131
|
+
.enum(['override', 'additive'])
|
|
132
|
+
.optional()
|
|
133
|
+
.describe(
|
|
134
|
+
'How this layer composites with others. REJECTED at parse (T4.1): declared but has no ' +
|
|
135
|
+
"runtime reader — LayerRuntime never reads it, each layer's actions are weighted " +
|
|
136
|
+
'independently. Authoring this field throws. Do not author.',
|
|
137
|
+
),
|
|
138
|
+
weight: z
|
|
139
|
+
.number()
|
|
140
|
+
.optional()
|
|
141
|
+
.describe(
|
|
142
|
+
'Overall layer weight (for compositing multiple layers). REJECTED at parse (T4.6 audit, ' +
|
|
143
|
+
'new finding): declared but has no runtime reader — AnimGraph.update() never combines ' +
|
|
144
|
+
'per-layer weights. Authoring this field throws. Do not author.',
|
|
145
|
+
),
|
|
146
|
+
boneMask: z
|
|
147
|
+
.array(z.string())
|
|
148
|
+
.optional()
|
|
149
|
+
.describe(
|
|
150
|
+
'Bone names this layer is restricted to. REJECTED at parse (T4.1): declared but has no ' +
|
|
151
|
+
'runtime reader anywhere. Authoring this field throws. Do not author.',
|
|
152
|
+
),
|
|
153
|
+
defaultState: z.string().describe('State name entered on graph creation'),
|
|
154
|
+
states: z.record(z.string(), AnimStateSchema).describe('Named states this layer can be in'),
|
|
155
|
+
transitions: z.array(AnimTransitionSchema).describe('State-machine transitions for this layer'),
|
|
156
|
+
})
|
|
157
|
+
.superRefine((layer, ctx) => {
|
|
158
|
+
if (layer.blendMode !== undefined) {
|
|
159
|
+
ctx.addIssue({
|
|
160
|
+
code: z.ZodIssueCode.custom,
|
|
161
|
+
message:
|
|
162
|
+
'`blendMode` is authored but not implemented — LayerRuntime (anim-graph.ts) never reads ' +
|
|
163
|
+
"it; each layer's actions are weighted independently with no cross-layer compositing " +
|
|
164
|
+
'mode. See T4.1/T4.6.',
|
|
165
|
+
path: ['blendMode'],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (layer.weight !== undefined) {
|
|
169
|
+
ctx.addIssue({
|
|
170
|
+
code: z.ZodIssueCode.custom,
|
|
171
|
+
message:
|
|
172
|
+
'`weight` is authored but not implemented — no reader combines per-layer weights ' +
|
|
173
|
+
'(AnimGraph.update() ticks each layer independently). See T4.6.',
|
|
174
|
+
path: ['weight'],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (layer.boneMask !== undefined) {
|
|
178
|
+
ctx.addIssue({
|
|
179
|
+
code: z.ZodIssueCode.custom,
|
|
180
|
+
message:
|
|
181
|
+
'`boneMask` is authored but not implemented — no reader restricts a layer to a bone ' +
|
|
182
|
+
'subset anywhere. See T4.1/T4.6.',
|
|
183
|
+
path: ['boneMask'],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}) as z.ZodType<AnimLayer>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The `.animgraph.json` file format (`asset-loaders.ts`'s `loadAnimGraphData`
|
|
190
|
+
* + `AnimGraph`'s constructor).
|
|
191
|
+
*
|
|
192
|
+
* `version` is RESERVED (no range-check reader exists yet for this format —
|
|
193
|
+
* same convention `2d.version`/`InputMapFile.version` use; see
|
|
194
|
+
* `schema-consumption-map.ts`).
|
|
195
|
+
*/
|
|
196
|
+
export const AnimGraphFileSchema: z.ZodType<AnimGraphFile> = z.object({
|
|
197
|
+
version: z.number().describe('Anim graph file format version (RESERVED — no range check yet)'),
|
|
198
|
+
parameters: z
|
|
199
|
+
.record(z.string(), AnimParameterSchema)
|
|
200
|
+
.describe('Named parameters and their defaults (AnimGraph constructor)'),
|
|
201
|
+
layers: z
|
|
202
|
+
.array(AnimLayerSchema)
|
|
203
|
+
.describe('Independent animation layers, each its own state machine'),
|
|
204
|
+
}) as z.ZodType<AnimGraphFile>;
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared asset cache — URL-keyed, deduplicating loader for GLTF and JSON.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by Unity's Resources.Load / Godot's load():
|
|
5
|
+
* assets.load<T>(url) — async, returns Promise<T>, deduplicates concurrent loads
|
|
6
|
+
* assets.get<T>(url) — sync, returns T, throws if not yet loaded
|
|
7
|
+
*
|
|
8
|
+
* GLTF files (.glb/.gltf) resolve to GLTFResult.
|
|
9
|
+
* JSON files resolve to the parsed object (caller provides type via generic).
|
|
10
|
+
*
|
|
11
|
+
* Cache lives for the app lifetime — no dispose needed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type * as THREE from 'three';
|
|
15
|
+
import { gltfLoader, resolveUrl } from './loader';
|
|
16
|
+
|
|
17
|
+
/** Result of loading a .glb/.gltf file. */
|
|
18
|
+
export interface GLTFResult {
|
|
19
|
+
scene: THREE.Group;
|
|
20
|
+
animations: THREE.AnimationClip[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AssetCache {
|
|
24
|
+
/** Load an asset by URL. Returns a cached promise if already loading/loaded. */
|
|
25
|
+
load<T = unknown>(url: string): Promise<T>;
|
|
26
|
+
|
|
27
|
+
/** Get a previously-loaded asset synchronously. Throws if not yet loaded. */
|
|
28
|
+
get<T = unknown>(url: string): T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createAssetCache(): AssetCache {
|
|
32
|
+
const cache = new Map<string, Promise<unknown>>();
|
|
33
|
+
const resolved = new Map<string, unknown>();
|
|
34
|
+
// Use the shared, DRACO-wired GLTFLoader (A2 / CB2) — no second loader instance.
|
|
35
|
+
// This cache keeps the parsed source GLTFResult (load/get semantics for gameplay
|
|
36
|
+
// code); it does NOT clone. Callers that need disposal-safe clones use
|
|
37
|
+
// scene/asset-loaders `loadGLTF`, which clones + tags `__sharedGeometry`.
|
|
38
|
+
|
|
39
|
+
function load<T = unknown>(url: string): Promise<T> {
|
|
40
|
+
const existing = cache.get(url);
|
|
41
|
+
if (existing) return existing as Promise<T>;
|
|
42
|
+
|
|
43
|
+
let promise: Promise<unknown>;
|
|
44
|
+
|
|
45
|
+
if (url.endsWith('.glb') || url.endsWith('.gltf')) {
|
|
46
|
+
promise = new Promise<GLTFResult>((resolve, reject) => {
|
|
47
|
+
gltfLoader.load(
|
|
48
|
+
url,
|
|
49
|
+
(gltf) => resolve({ scene: gltf.scene, animations: gltf.animations }),
|
|
50
|
+
undefined,
|
|
51
|
+
reject,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
} else if (url.endsWith('.json')) {
|
|
55
|
+
promise = fetch(resolveUrl(url)).then((r) => {
|
|
56
|
+
if (!r.ok) throw new Error(`Failed to fetch ${url}: ${r.status}`);
|
|
57
|
+
return r.json();
|
|
58
|
+
});
|
|
59
|
+
} else {
|
|
60
|
+
throw new Error(`AssetCache: unsupported file type for "${url}"`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tracked = promise.then((value) => {
|
|
64
|
+
resolved.set(url, value);
|
|
65
|
+
return value;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
cache.set(url, tracked);
|
|
69
|
+
return tracked as Promise<T>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function get<T = unknown>(url: string): T {
|
|
73
|
+
if (!resolved.has(url)) {
|
|
74
|
+
throw new Error(`AssetCache: "${url}" not loaded. Call load() first and await it.`);
|
|
75
|
+
}
|
|
76
|
+
return resolved.get(url) as T;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { load, get };
|
|
80
|
+
}
|