@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,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WorldKind` — the kinds of render surface a world can be (T7.1/T7.3/T6.2).
|
|
3
|
+
*
|
|
4
|
+
* Moved here (T7.5, `docs/BACKBONE-TASKS.md`'s D6 row) from `runtime/game.ts`
|
|
5
|
+
* so `adapter/game-adapter.ts`'s kind-tagged `MountedWorld` types can name it
|
|
6
|
+
* without a value-level import cycle (`runtime/game.ts` type-imports from
|
|
7
|
+
* `adapter/game-adapter.ts` already). This is a leaf module — it imports
|
|
8
|
+
* nothing — so anything may import it with zero risk of a cycle.
|
|
9
|
+
* `runtime/game.ts` re-exports this SAME type (`export type { WorldKind }`),
|
|
10
|
+
* so no existing `import type { WorldKind } from '../runtime/game'` call site
|
|
11
|
+
* needed to change.
|
|
12
|
+
*/
|
|
13
|
+
export type WorldKind = 'threejs' | 'pixijs' | 'react';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Exhaustiveness guard for `WorldKind` dispatch (v4 architecture-review
|
|
17
|
+
* §7.4-2: a hypothetical 4th kind must fail to COMPILE at every kind-dispatch
|
|
18
|
+
* site, not silently contribute nothing or silently default to an existing
|
|
19
|
+
* kind). Lives here — colocated with the kind vocabulary itself, not in a
|
|
20
|
+
* generic util module — so the three call sites that need it
|
|
21
|
+
* (`runtime/create-runtime.ts`'s mount loop, `editor/adapter-resolver.ts`'s
|
|
22
|
+
* `resolveAllWorlds`, `editor/play-mode.ts`'s `installMultiWorldAuthoring`)
|
|
23
|
+
* import it from the same leaf module that defines `WorldKind`, keeping the
|
|
24
|
+
* type and its guard from drifting apart. The `never` parameter is the
|
|
25
|
+
* compile-time half of the guard (TS refuses to call this with anything the
|
|
26
|
+
* compiler hasn't already narrowed to zero remaining variants); the thrown
|
|
27
|
+
* Error is the runtime half, in case a value's static type lied (e.g. data
|
|
28
|
+
* crossing a JSON boundary).
|
|
29
|
+
*/
|
|
30
|
+
export function assertNever(value: never, context?: string): never {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Unreachable${context ? ` ${context}` : ''}: unexpected value ${JSON.stringify(value)}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavMeshManager — wraps recast-navigation for navmesh generation + crowd pathfinding.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* await init(); // WASM init (call once per app)
|
|
6
|
+
* const nav = new NavMeshManager();
|
|
7
|
+
* await nav.buildFromMeshes(meshes);
|
|
8
|
+
* const path = nav.findPath(start, end);
|
|
9
|
+
* const crowd = nav.createCrowd(10);
|
|
10
|
+
* // In game loop: nav.updateCrowd(dt);
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NavMeshHelper, threeToSoloNavMesh } from '@recast-navigation/three';
|
|
14
|
+
import type { CrowdAgent, NavMesh } from 'recast-navigation';
|
|
15
|
+
import { Crowd, exportNavMesh, importNavMesh, init, NavMeshQuery } from 'recast-navigation';
|
|
16
|
+
import type { Mesh, Scene } from 'three';
|
|
17
|
+
import { DEFAULTS } from '../scene/defaults';
|
|
18
|
+
|
|
19
|
+
export type { CrowdAgent };
|
|
20
|
+
|
|
21
|
+
export interface Vector3Like {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
z: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NavMeshBuildParams {
|
|
28
|
+
cellSize?: number | undefined;
|
|
29
|
+
cellHeight?: number | undefined;
|
|
30
|
+
walkableSlopeAngle?: number | undefined;
|
|
31
|
+
walkableHeight?: number | undefined;
|
|
32
|
+
walkableClimb?: number | undefined;
|
|
33
|
+
walkableRadius?: number | undefined;
|
|
34
|
+
maxEdgeLen?: number | undefined;
|
|
35
|
+
maxSimplificationError?: number | undefined;
|
|
36
|
+
minRegionArea?: number | undefined;
|
|
37
|
+
mergeRegionArea?: number | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let wasmInitialized = false;
|
|
41
|
+
|
|
42
|
+
export async function initNavigation(): Promise<void> {
|
|
43
|
+
if (wasmInitialized) return;
|
|
44
|
+
await init();
|
|
45
|
+
wasmInitialized = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class NavMeshManager {
|
|
49
|
+
private navMesh: NavMesh | null = null;
|
|
50
|
+
private query: NavMeshQuery | null = null;
|
|
51
|
+
private crowd: Crowd | null = null;
|
|
52
|
+
private helper: NavMeshHelper | null = null;
|
|
53
|
+
|
|
54
|
+
/** Build navmesh from Three.js Mesh objects (grounds, obstacles, etc.). */
|
|
55
|
+
buildFromMeshes(meshes: Mesh[], params?: NavMeshBuildParams): boolean {
|
|
56
|
+
const d = DEFAULTS.navigation;
|
|
57
|
+
const result = threeToSoloNavMesh(meshes, {
|
|
58
|
+
cs: params?.cellSize ?? d.cellSize,
|
|
59
|
+
ch: params?.cellHeight ?? d.cellHeight,
|
|
60
|
+
walkableSlopeAngle: params?.walkableSlopeAngle ?? d.walkableSlopeAngle,
|
|
61
|
+
walkableHeight: params?.walkableHeight ?? d.walkableHeight,
|
|
62
|
+
walkableClimb: params?.walkableClimb ?? d.walkableClimb,
|
|
63
|
+
walkableRadius: params?.walkableRadius ?? d.walkableRadius,
|
|
64
|
+
maxEdgeLen: params?.maxEdgeLen ?? d.maxEdgeLen,
|
|
65
|
+
maxSimplificationError: params?.maxSimplificationError ?? d.maxSimplificationError,
|
|
66
|
+
minRegionArea: params?.minRegionArea ?? d.minRegionArea,
|
|
67
|
+
mergeRegionArea: params?.mergeRegionArea ?? d.mergeRegionArea,
|
|
68
|
+
// Recast tuning knobs with no DEFAULTS counterpart (not modeled there).
|
|
69
|
+
maxVertsPerPoly: 6,
|
|
70
|
+
detailSampleDist: 6,
|
|
71
|
+
detailSampleMaxError: 1,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!result.success || !result.navMesh) return false;
|
|
75
|
+
|
|
76
|
+
// Free the previous recast objects before replacing them (WASM heap leak).
|
|
77
|
+
this.destroyRecast();
|
|
78
|
+
this.navMesh = result.navMesh;
|
|
79
|
+
this.query = new NavMeshQuery(this.navMesh);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Destroy all live recast handles (crowd, query, navmesh) and dispose the
|
|
85
|
+
* debug helper. Recast objects live on the WASM heap and are NOT GC'd —
|
|
86
|
+
* they must be explicitly `.destroy()`ed or they leak.
|
|
87
|
+
*/
|
|
88
|
+
private destroyRecast(): void {
|
|
89
|
+
if (this.helper) {
|
|
90
|
+
this.helper.parent?.remove(this.helper);
|
|
91
|
+
(this.helper as unknown as { dispose?: () => void }).dispose?.();
|
|
92
|
+
this.helper = null;
|
|
93
|
+
}
|
|
94
|
+
if (this.crowd) {
|
|
95
|
+
this.crowd.destroy();
|
|
96
|
+
this.crowd = null;
|
|
97
|
+
}
|
|
98
|
+
if (this.query) {
|
|
99
|
+
this.query.destroy();
|
|
100
|
+
this.query = null;
|
|
101
|
+
}
|
|
102
|
+
if (this.navMesh) {
|
|
103
|
+
this.navMesh.destroy();
|
|
104
|
+
this.navMesh = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True once a navmesh has been built (for the NavigationAdapter to gate on). */
|
|
109
|
+
hasNavMesh(): boolean {
|
|
110
|
+
return this.navMesh !== null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Find a straight path between two world positions. Returns [] if no path. */
|
|
114
|
+
findPath(start: Vector3Like, end: Vector3Like): Vector3Like[] {
|
|
115
|
+
if (!this.query) return [];
|
|
116
|
+
const result = this.query.computePath(start, end);
|
|
117
|
+
if (!result.success) return [];
|
|
118
|
+
return result.path;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Create a crowd with up to maxAgents agents. */
|
|
122
|
+
createCrowd(maxAgents: number): Crowd {
|
|
123
|
+
if (!this.navMesh) throw new Error('Build navmesh first');
|
|
124
|
+
this.crowd = new Crowd(this.navMesh, { maxAgents, maxAgentRadius: 0.6 });
|
|
125
|
+
return this.crowd;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Step the crowd simulation. Call once per frame. */
|
|
129
|
+
updateCrowd(dt: number): void {
|
|
130
|
+
this.crowd?.update(dt);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Get or create the wireframe debug mesh. Add to scene once, call update() after rebuilding. */
|
|
134
|
+
getDebugMesh(scene: Scene): NavMeshHelper | null {
|
|
135
|
+
if (!this.navMesh) return null;
|
|
136
|
+
if (!this.helper) {
|
|
137
|
+
this.helper = new NavMeshHelper(this.navMesh);
|
|
138
|
+
scene.add(this.helper);
|
|
139
|
+
}
|
|
140
|
+
return this.helper;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Serialize the current navmesh to a binary blob. */
|
|
144
|
+
exportData(): Uint8Array {
|
|
145
|
+
if (!this.navMesh) throw new Error('No navmesh to export');
|
|
146
|
+
return exportNavMesh(this.navMesh);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Load a navmesh from a previously exported binary blob. */
|
|
150
|
+
loadFromData(data: Uint8Array): void {
|
|
151
|
+
const { navMesh } = importNavMesh(data);
|
|
152
|
+
// Free the previous recast objects before replacing them.
|
|
153
|
+
this.destroyRecast();
|
|
154
|
+
this.navMesh = navMesh;
|
|
155
|
+
this.query = new NavMeshQuery(this.navMesh);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
dispose(scene: Scene): void {
|
|
159
|
+
// Ensure the helper is removed from the passed scene as well, then destroy
|
|
160
|
+
// every recast handle (crowd/query/navmesh) so nothing leaks on the WASM heap.
|
|
161
|
+
if (this.helper) scene.remove(this.helper);
|
|
162
|
+
this.destroyRecast();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The .animgraph.json file format */
|
|
2
|
+
export interface AnimGraphFile {
|
|
3
|
+
version: number;
|
|
4
|
+
parameters: Record<string, AnimParameter>;
|
|
5
|
+
layers: AnimLayer[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AnimParameter {
|
|
9
|
+
type: 'float' | 'int' | 'bool' | 'trigger';
|
|
10
|
+
default: number | boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AnimLayer {
|
|
14
|
+
name: string;
|
|
15
|
+
blendMode?: 'override' | 'additive';
|
|
16
|
+
weight?: number;
|
|
17
|
+
boneMask?: string[];
|
|
18
|
+
defaultState: string;
|
|
19
|
+
states: Record<string, AnimState>;
|
|
20
|
+
transitions: AnimTransition[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AnimState {
|
|
24
|
+
clip?: string;
|
|
25
|
+
loop?: boolean;
|
|
26
|
+
speed?: number;
|
|
27
|
+
blendTree?: BlendTreeDef;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BlendTreeDef {
|
|
31
|
+
type: '1D' | '2D' | 'direct';
|
|
32
|
+
parameter: string;
|
|
33
|
+
parameterY?: string; // for 2D
|
|
34
|
+
children: BlendChild[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BlendChild {
|
|
38
|
+
clip: string;
|
|
39
|
+
threshold: number;
|
|
40
|
+
thresholdY?: number; // for 2D
|
|
41
|
+
weight?: number; // for direct
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AnimTransition {
|
|
45
|
+
from: string; // '*' = any state (wildcard)
|
|
46
|
+
to: string;
|
|
47
|
+
conditions?: TransitionCondition[];
|
|
48
|
+
duration: number; // crossfade duration in seconds
|
|
49
|
+
exitTime?: number; // normalized time (0-1) — transition can happen after this point in the clip
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TransitionCondition {
|
|
53
|
+
param: string;
|
|
54
|
+
op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'trigger';
|
|
55
|
+
value?: number | boolean;
|
|
56
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type {
|
|
3
|
+
AnimGraphFile,
|
|
4
|
+
AnimLayer,
|
|
5
|
+
AnimState,
|
|
6
|
+
AnimTransition,
|
|
7
|
+
TransitionCondition,
|
|
8
|
+
} from './anim-graph-types';
|
|
9
|
+
import { evaluateBlendTree } from './blend-node';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Runtime animation graph for a single entity.
|
|
13
|
+
*
|
|
14
|
+
* This is the core custom code that Three.js doesn't provide.
|
|
15
|
+
* Three.js AnimationMixer handles clip playback and crossfading.
|
|
16
|
+
* This adds: state machines, parameter-driven transitions, blend trees.
|
|
17
|
+
*
|
|
18
|
+
* Weight management: all AnimationActions are pre-played at creation with
|
|
19
|
+
* weight 0. Weights are set explicitly every frame — we never use Three.js's
|
|
20
|
+
* fadeIn/fadeOut, which can cause total weight to dip below 1.0 and flash
|
|
21
|
+
* the bind pose (T-pose).
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* const graph = new AnimGraph(mixer, graphData, clips);
|
|
25
|
+
* graph.setParameter('speed', 5.0);
|
|
26
|
+
* graph.update(dt); // call each frame
|
|
27
|
+
*/
|
|
28
|
+
export class AnimGraph {
|
|
29
|
+
private mixer: THREE.AnimationMixer;
|
|
30
|
+
private layers: LayerRuntime[] = [];
|
|
31
|
+
private parameters = new Map<string, number | boolean>();
|
|
32
|
+
private triggers = new Set<string>();
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
mixer: THREE.AnimationMixer,
|
|
36
|
+
data: AnimGraphFile,
|
|
37
|
+
clips: Map<string, THREE.AnimationClip>,
|
|
38
|
+
) {
|
|
39
|
+
this.mixer = mixer;
|
|
40
|
+
|
|
41
|
+
// Initialize parameters with defaults
|
|
42
|
+
for (const [name, param] of Object.entries(data.parameters)) {
|
|
43
|
+
this.parameters.set(name, param.default);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Create runtime for each layer
|
|
47
|
+
for (const layerDef of data.layers) {
|
|
48
|
+
this.layers.push(new LayerRuntime(mixer, layerDef, clips));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The names of all declared parameters (for the AnimationAdapter to enumerate). */
|
|
53
|
+
parameterNames(): string[] {
|
|
54
|
+
return [...this.parameters.keys()];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Set a float/int/bool parameter */
|
|
58
|
+
setParameter(name: string, value: number | boolean) {
|
|
59
|
+
this.parameters.set(name, value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get a parameter value */
|
|
63
|
+
getParameter(name: string): number | boolean | undefined {
|
|
64
|
+
return this.parameters.get(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Fire a trigger (auto-resets after consumed by a transition) */
|
|
68
|
+
trigger(name: string) {
|
|
69
|
+
this.triggers.add(name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Get the current state name for a layer (default: first layer) */
|
|
73
|
+
getCurrentState(layerIndex = 0): string {
|
|
74
|
+
return this.layers[layerIndex]?.currentState ?? '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Update the animation graph. Call once per frame. */
|
|
78
|
+
update(dt: number) {
|
|
79
|
+
// Triggers persist until a transition actually consumes them — we do NOT
|
|
80
|
+
// clear every frame. A trigger fired mid-crossfade (when no transition can
|
|
81
|
+
// fire) survives until the layer is ready to act on it.
|
|
82
|
+
const consumed = new Set<string>();
|
|
83
|
+
for (const layer of this.layers) {
|
|
84
|
+
layer.update(dt, this.parameters, this.triggers, consumed);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Clear only triggers that were consumed by a transition this frame.
|
|
88
|
+
for (const name of consumed) {
|
|
89
|
+
this.triggers.delete(name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Advance the mixer
|
|
93
|
+
this.mixer.update(dt);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Runtime state for a single animation layer.
|
|
99
|
+
*/
|
|
100
|
+
class LayerRuntime {
|
|
101
|
+
private mixer: THREE.AnimationMixer;
|
|
102
|
+
private states: Map<string, AnimState>;
|
|
103
|
+
private transitions: AnimTransition[];
|
|
104
|
+
private actionMap = new Map<string, THREE.AnimationAction>();
|
|
105
|
+
currentState: string;
|
|
106
|
+
private previousState = '';
|
|
107
|
+
private stateTime = 0;
|
|
108
|
+
private stateDuration = 0;
|
|
109
|
+
private transitioning = false;
|
|
110
|
+
private transitionElapsed = 0;
|
|
111
|
+
private transitionDuration = 0;
|
|
112
|
+
|
|
113
|
+
constructor(
|
|
114
|
+
mixer: THREE.AnimationMixer,
|
|
115
|
+
layerDef: AnimLayer,
|
|
116
|
+
clips: Map<string, THREE.AnimationClip>,
|
|
117
|
+
) {
|
|
118
|
+
this.mixer = mixer;
|
|
119
|
+
this.states = new Map(Object.entries(layerDef.states));
|
|
120
|
+
this.transitions = layerDef.transitions;
|
|
121
|
+
this.currentState = layerDef.defaultState;
|
|
122
|
+
|
|
123
|
+
// Collect the (loop, speed) config each clip name is played with. A clip
|
|
124
|
+
// name is a key into a single shared THREE.AnimationAction (ensureAction
|
|
125
|
+
// dedupes by name), so two states — or a state and a blend-tree child —
|
|
126
|
+
// that reference the same clip with different loop/speed would silently
|
|
127
|
+
// let whichever state was processed first win, and the other's config
|
|
128
|
+
// would be dropped with no signal. Per-state clip config is deferred
|
|
129
|
+
// (no real need for it yet) — the correct behavior for now is a loud
|
|
130
|
+
// error at graph load, not silent last-write-wins.
|
|
131
|
+
const clipConfigs = new Map<string, { loop: boolean; speed: number; state: string }>();
|
|
132
|
+
for (const [stateName, state] of this.states) {
|
|
133
|
+
if (state.clip) {
|
|
134
|
+
this.registerClipConfig(
|
|
135
|
+
clipConfigs,
|
|
136
|
+
state.clip,
|
|
137
|
+
state.loop ?? true,
|
|
138
|
+
state.speed ?? 1,
|
|
139
|
+
stateName,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (state.blendTree) {
|
|
143
|
+
for (const child of state.blendTree.children) {
|
|
144
|
+
this.registerClipConfig(clipConfigs, child.clip, true, 1, stateName);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Create AnimationActions for all referenced clips.
|
|
150
|
+
// All actions are played immediately at weight 0 — they stay active in the
|
|
151
|
+
// mixer but contribute nothing visually until we raise their weight.
|
|
152
|
+
for (const [clipName, config] of clipConfigs) {
|
|
153
|
+
this.ensureAction(clipName, clips, config.loop, config.speed);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Start default state
|
|
157
|
+
this.enterState(this.currentState, new Map());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Record a clip's (loop, speed) config, throwing if a prior state disagrees. */
|
|
161
|
+
private registerClipConfig(
|
|
162
|
+
configs: Map<string, { loop: boolean; speed: number; state: string }>,
|
|
163
|
+
clipName: string,
|
|
164
|
+
loop: boolean,
|
|
165
|
+
speed: number,
|
|
166
|
+
stateName: string,
|
|
167
|
+
) {
|
|
168
|
+
const existing = configs.get(clipName);
|
|
169
|
+
if (existing && (existing.loop !== loop || existing.speed !== speed)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`AnimGraph: clip "${clipName}" is configured inconsistently across states — ` +
|
|
172
|
+
`state "${existing.state}" uses loop=${existing.loop}/speed=${existing.speed}, ` +
|
|
173
|
+
`but state "${stateName}" uses loop=${loop}/speed=${speed}. ` +
|
|
174
|
+
`Per-state clip config is not supported: every state (and blend-tree child) that ` +
|
|
175
|
+
`plays a given clip must agree on its loop/speed.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (!existing) configs.set(clipName, { loop, speed, state: stateName });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private ensureAction(
|
|
182
|
+
clipName: string,
|
|
183
|
+
clips: Map<string, THREE.AnimationClip>,
|
|
184
|
+
loop: boolean,
|
|
185
|
+
speed: number,
|
|
186
|
+
) {
|
|
187
|
+
if (this.actionMap.has(clipName)) return;
|
|
188
|
+
const clip = clips.get(clipName);
|
|
189
|
+
if (!clip) return;
|
|
190
|
+
|
|
191
|
+
const action = this.mixer.clipAction(clip);
|
|
192
|
+
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
|
|
193
|
+
action.clampWhenFinished = !loop;
|
|
194
|
+
action.timeScale = speed;
|
|
195
|
+
action.setEffectiveWeight(0);
|
|
196
|
+
action.play();
|
|
197
|
+
this.actionMap.set(clipName, action);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private enterState(stateName: string, parameters: Map<string, number | boolean>) {
|
|
201
|
+
const state = this.states.get(stateName);
|
|
202
|
+
if (!state) return;
|
|
203
|
+
|
|
204
|
+
this.currentState = stateName;
|
|
205
|
+
this.stateTime = 0;
|
|
206
|
+
this.transitioning = false;
|
|
207
|
+
|
|
208
|
+
// Zero all, then set the entering state to full weight
|
|
209
|
+
this.zeroAllWeights();
|
|
210
|
+
|
|
211
|
+
if (state.clip) {
|
|
212
|
+
const action = this.actionMap.get(state.clip);
|
|
213
|
+
if (action) {
|
|
214
|
+
action.reset();
|
|
215
|
+
action.setEffectiveWeight(1);
|
|
216
|
+
action.play();
|
|
217
|
+
this.stateDuration = action.getClip().duration;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (state.blendTree) {
|
|
222
|
+
const weights = evaluateBlendTree(state.blendTree, parameters);
|
|
223
|
+
this.setWeights(weights, 1);
|
|
224
|
+
const heaviest = weights.reduce((a, b) => (b.weight > a.weight ? b : a), weights[0]!);
|
|
225
|
+
const action = this.actionMap.get(heaviest.clip);
|
|
226
|
+
this.stateDuration = action?.getClip().duration ?? 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Zero all action weights. */
|
|
231
|
+
private zeroAllWeights() {
|
|
232
|
+
for (const action of this.actionMap.values()) {
|
|
233
|
+
action.setEffectiveWeight(0);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Set weights for a list of clip/weight pairs, multiplied by a scale factor. */
|
|
238
|
+
private setWeights(weights: { clip: string; weight: number }[], scale: number) {
|
|
239
|
+
for (const { clip, weight } of weights) {
|
|
240
|
+
const action = this.actionMap.get(clip);
|
|
241
|
+
if (action) {
|
|
242
|
+
action.setEffectiveWeight(weight * scale);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Get the weight list for a state (single clip at weight 1, or blend tree evaluated). */
|
|
248
|
+
private getStateWeights(
|
|
249
|
+
stateName: string,
|
|
250
|
+
parameters: Map<string, number | boolean>,
|
|
251
|
+
): { clip: string; weight: number }[] {
|
|
252
|
+
const state = this.states.get(stateName);
|
|
253
|
+
if (!state) return [];
|
|
254
|
+
if (state.clip) return [{ clip: state.clip, weight: 1 }];
|
|
255
|
+
if (state.blendTree) return evaluateBlendTree(state.blendTree, parameters);
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
update(
|
|
260
|
+
dt: number,
|
|
261
|
+
parameters: Map<string, number | boolean>,
|
|
262
|
+
triggers: Set<string>,
|
|
263
|
+
consumed: Set<string>,
|
|
264
|
+
) {
|
|
265
|
+
this.stateTime += dt;
|
|
266
|
+
const normalizedTime = this.stateDuration > 0 ? this.stateTime / this.stateDuration : 1;
|
|
267
|
+
|
|
268
|
+
// Zero all weights, then set them based on current (and previous) state.
|
|
269
|
+
// This guarantees total weight = 1.0 every frame — no bind-pose bleed.
|
|
270
|
+
this.zeroAllWeights();
|
|
271
|
+
|
|
272
|
+
if (this.transitioning) {
|
|
273
|
+
this.transitionElapsed += dt;
|
|
274
|
+
const alpha = Math.min(this.transitionElapsed / this.transitionDuration, 1);
|
|
275
|
+
|
|
276
|
+
// Crossfade: outgoing * (1 - alpha) + incoming * alpha — sums to exactly 1.0
|
|
277
|
+
this.setWeights(this.getStateWeights(this.previousState, parameters), 1 - alpha);
|
|
278
|
+
this.setWeights(this.getStateWeights(this.currentState, parameters), alpha);
|
|
279
|
+
|
|
280
|
+
if (alpha >= 1) {
|
|
281
|
+
this.transitioning = false;
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Not transitioning — current state at full weight
|
|
287
|
+
this.setWeights(this.getStateWeights(this.currentState, parameters), 1);
|
|
288
|
+
|
|
289
|
+
// Check transitions
|
|
290
|
+
for (const transition of this.transitions) {
|
|
291
|
+
// Match: exact state or wildcard
|
|
292
|
+
if (transition.from !== '*' && transition.from !== this.currentState) continue;
|
|
293
|
+
// A wildcard transition must never re-enter the state we're already in —
|
|
294
|
+
// otherwise a wildcard whose condition stays true fires every single
|
|
295
|
+
// frame (performTransition resets the action to time 0 each time), so
|
|
296
|
+
// the state is "entered" over and over and its animation never advances
|
|
297
|
+
// past frame 0. An explicit from:X→to:X self-transition is a deliberate
|
|
298
|
+
// restart and is allowed to fire.
|
|
299
|
+
if (transition.from === '*' && transition.to === this.currentState) continue;
|
|
300
|
+
|
|
301
|
+
// Check exit time
|
|
302
|
+
if (transition.exitTime !== undefined && normalizedTime < transition.exitTime) continue;
|
|
303
|
+
|
|
304
|
+
// Check conditions
|
|
305
|
+
if (
|
|
306
|
+
transition.conditions &&
|
|
307
|
+
!this.checkConditions(transition.conditions, parameters, triggers)
|
|
308
|
+
) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Transition! Mark any trigger conditions as consumed so the graph
|
|
313
|
+
// clears them after all layers have had a chance to react this frame.
|
|
314
|
+
if (transition.conditions) {
|
|
315
|
+
for (const cond of transition.conditions) {
|
|
316
|
+
if (cond.op === 'trigger') consumed.add(cond.param);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
this.performTransition(transition, parameters);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private checkConditions(
|
|
325
|
+
conditions: TransitionCondition[],
|
|
326
|
+
parameters: Map<string, number | boolean>,
|
|
327
|
+
triggers: Set<string>,
|
|
328
|
+
): boolean {
|
|
329
|
+
for (const cond of conditions) {
|
|
330
|
+
if (cond.op === 'trigger') {
|
|
331
|
+
if (!triggers.has(cond.param)) return false;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const paramVal = parameters.get(cond.param);
|
|
336
|
+
if (paramVal === undefined) return false;
|
|
337
|
+
|
|
338
|
+
const a = Number(paramVal);
|
|
339
|
+
const b = Number(cond.value ?? 0);
|
|
340
|
+
|
|
341
|
+
switch (cond.op) {
|
|
342
|
+
case '>':
|
|
343
|
+
if (!(a > b)) return false;
|
|
344
|
+
break;
|
|
345
|
+
case '<':
|
|
346
|
+
if (!(a < b)) return false;
|
|
347
|
+
break;
|
|
348
|
+
case '>=':
|
|
349
|
+
if (!(a >= b)) return false;
|
|
350
|
+
break;
|
|
351
|
+
case '<=':
|
|
352
|
+
if (!(a <= b)) return false;
|
|
353
|
+
break;
|
|
354
|
+
case '==':
|
|
355
|
+
if (paramVal !== cond.value && a !== b) return false;
|
|
356
|
+
break;
|
|
357
|
+
case '!=':
|
|
358
|
+
if (paramVal === cond.value || a === b) return false;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private performTransition(transition: AnimTransition, parameters: Map<string, number | boolean>) {
|
|
366
|
+
const toState = this.states.get(transition.to);
|
|
367
|
+
if (!toState) return;
|
|
368
|
+
|
|
369
|
+
this.previousState = this.currentState;
|
|
370
|
+
this.currentState = transition.to;
|
|
371
|
+
this.stateTime = 0;
|
|
372
|
+
this.transitioning = true;
|
|
373
|
+
this.transitionElapsed = 0;
|
|
374
|
+
this.transitionDuration = transition.duration;
|
|
375
|
+
|
|
376
|
+
// Reset incoming actions to time=0 so they start fresh.
|
|
377
|
+
// No fadeIn/fadeOut — weights are managed explicitly in update().
|
|
378
|
+
if (toState.clip) {
|
|
379
|
+
const action = this.actionMap.get(toState.clip);
|
|
380
|
+
if (action) {
|
|
381
|
+
action.reset();
|
|
382
|
+
action.play();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (toState.blendTree) {
|
|
386
|
+
for (const child of toState.blendTree.children) {
|
|
387
|
+
const action = this.actionMap.get(child.clip);
|
|
388
|
+
if (action) {
|
|
389
|
+
action.reset();
|
|
390
|
+
action.play();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Estimate state duration for exitTime calculations
|
|
396
|
+
if (toState.clip) {
|
|
397
|
+
const action = this.actionMap.get(toState.clip);
|
|
398
|
+
this.stateDuration = action?.getClip().duration ?? 1;
|
|
399
|
+
} else if (toState.blendTree) {
|
|
400
|
+
const weights = evaluateBlendTree(toState.blendTree, parameters);
|
|
401
|
+
const heaviest = weights.reduce((a, b) => (b.weight > a.weight ? b : a), weights[0]!);
|
|
402
|
+
const action = this.actionMap.get(heaviest.clip);
|
|
403
|
+
this.stateDuration = action?.getClip().duration ?? 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type * as THREE from 'three';
|
|
2
|
+
import type { AnimGraph } from './anim-graph';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registry of active animation graphs, keyed by Object3D (the entity).
|
|
6
|
+
*
|
|
7
|
+
* AnimGraph wraps a THREE.AnimationMixer (a class instance), so it can't live in
|
|
8
|
+
* a flat data store — we keep a simple Map<Object3D, AnimGraph> and update each
|
|
9
|
+
* one every frame. Each runtime owns its own map (see createGameRuntime) so that
|
|
10
|
+
* multiple concurrent runtimes never cross-contaminate each other's graphs.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const animGraphs: AnimGraphMap = new Map();
|
|
14
|
+
* animGraphs.set(playerObject3D, new AnimGraph(mixer, data, clips));
|
|
15
|
+
* // In system runner:
|
|
16
|
+
* systems.add('animation', (dt) => animationSystem(animGraphs, dt));
|
|
17
|
+
*/
|
|
18
|
+
export type AnimGraphMap = Map<THREE.Object3D, AnimGraph>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Updates all active animation graphs.
|
|
22
|
+
* Call this in the ANIMATION phase.
|
|
23
|
+
*/
|
|
24
|
+
export function animationSystem(graphs: AnimGraphMap, dt: number) {
|
|
25
|
+
for (const graph of graphs.values()) {
|
|
26
|
+
graph.update(dt);
|
|
27
|
+
}
|
|
28
|
+
}
|