@aura3d/animation 1.1.0 → 1.3.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/dist/AnimationClipEvents.d.ts +55 -0
- package/dist/AnimationClipEvents.d.ts.map +1 -1
- package/dist/AnimationClipEvents.js +136 -0
- package/dist/AnimationClipEvents.js.map +1 -1
- package/dist/AnimationClipRegistry.d.ts +9 -3
- package/dist/AnimationClipRegistry.d.ts.map +1 -1
- package/dist/AnimationClipRegistry.js +52 -5
- package/dist/AnimationClipRegistry.js.map +1 -1
- package/dist/AnimationController.d.ts +6 -6
- package/dist/AnimationController.d.ts.map +1 -1
- package/dist/AnimationController.js +3 -3
- package/dist/AnimationController.js.map +1 -1
- package/dist/AnimationMixer.d.ts +10 -0
- package/dist/AnimationMixer.d.ts.map +1 -1
- package/dist/AnimationMixer.js +35 -0
- package/dist/AnimationMixer.js.map +1 -1
- package/dist/AnimationStateGraph.d.ts +14 -5
- package/dist/AnimationStateGraph.d.ts.map +1 -1
- package/dist/AnimationStateGraph.js +25 -2
- package/dist/AnimationStateGraph.js.map +1 -1
- package/dist/AnimationStateMachine.d.ts +26 -0
- package/dist/AnimationStateMachine.d.ts.map +1 -1
- package/dist/AnimationStateMachine.js +32 -0
- package/dist/AnimationStateMachine.js.map +1 -1
- package/dist/Bone.d.ts +4 -0
- package/dist/Bone.d.ts.map +1 -1
- package/dist/Bone.js +3 -0
- package/dist/Bone.js.map +1 -1
- package/dist/FighterAnimationAdapter.d.ts +49 -0
- package/dist/FighterAnimationAdapter.d.ts.map +1 -0
- package/dist/FighterAnimationAdapter.js +71 -0
- package/dist/FighterAnimationAdapter.js.map +1 -0
- package/dist/FootIk.d.ts +77 -0
- package/dist/FootIk.d.ts.map +1 -0
- package/dist/FootIk.js +153 -0
- package/dist/FootIk.js.map +1 -0
- package/dist/HumanoidBoneInference.d.ts +61 -0
- package/dist/HumanoidBoneInference.d.ts.map +1 -0
- package/dist/HumanoidBoneInference.js +239 -0
- package/dist/HumanoidBoneInference.js.map +1 -0
- package/dist/HumanoidRetargeting.d.ts +48 -4
- package/dist/HumanoidRetargeting.d.ts.map +1 -1
- package/dist/HumanoidRetargeting.js +149 -14
- package/dist/HumanoidRetargeting.js.map +1 -1
- package/dist/IK.d.ts +5 -0
- package/dist/IK.d.ts.map +1 -1
- package/dist/IK.js +9 -0
- package/dist/IK.js.map +1 -1
- package/dist/Inertialization.d.ts +65 -0
- package/dist/Inertialization.d.ts.map +1 -0
- package/dist/Inertialization.js +211 -0
- package/dist/Inertialization.js.map +1 -0
- package/dist/LocomotionController.d.ts +5 -0
- package/dist/LocomotionController.d.ts.map +1 -1
- package/dist/LocomotionController.js +3 -0
- package/dist/LocomotionController.js.map +1 -1
- package/dist/LocomotionKit.d.ts +52 -0
- package/dist/LocomotionKit.d.ts.map +1 -0
- package/dist/LocomotionKit.js +38 -0
- package/dist/LocomotionKit.js.map +1 -0
- package/dist/MotionQuality.d.ts +3 -3
- package/dist/MotionQuality.d.ts.map +1 -1
- package/dist/MotionQuality.js +2 -2
- package/dist/MotionQuality.js.map +1 -1
- package/dist/RigQuality.d.ts +31 -0
- package/dist/RigQuality.d.ts.map +1 -0
- package/dist/RigQuality.js +70 -0
- package/dist/RigQuality.js.map +1 -0
- package/dist/SecondaryAnimationFixtures.d.ts.map +1 -1
- package/dist/SecondaryAnimationFixtures.js +1 -3
- package/dist/SecondaryAnimationFixtures.js.map +1 -1
- package/dist/Skeleton.d.ts +16 -1
- package/dist/Skeleton.d.ts.map +1 -1
- package/dist/Skeleton.js +42 -0
- package/dist/Skeleton.js.map +1 -1
- package/dist/SpringBones.d.ts +60 -0
- package/dist/SpringBones.d.ts.map +1 -0
- package/dist/SpringBones.js +173 -0
- package/dist/SpringBones.js.map +1 -0
- package/dist/browser-index.d.ts +12 -3
- package/dist/browser-index.d.ts.map +1 -1
- package/dist/browser-index.js +11 -2
- package/dist/browser-index.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/library/performanceStateGraph.d.ts +42 -0
- package/dist/library/performanceStateGraph.d.ts.map +1 -0
- package/dist/library/performanceStateGraph.js +101 -0
- package/dist/library/performanceStateGraph.js.map +1 -0
- package/dist/library/standardHumanoidClips.d.ts +27 -0
- package/dist/library/standardHumanoidClips.d.ts.map +1 -0
- package/dist/library/standardHumanoidClips.js +357 -0
- package/dist/library/standardHumanoidClips.js.map +1 -0
- package/package.json +3 -3
- package/src/AnimationClipEvents.ts +181 -0
- package/src/AnimationClipRegistry.ts +65 -9
- package/src/AnimationController.ts +11 -11
- package/src/AnimationMixer.ts +36 -0
- package/src/AnimationStateGraph.ts +34 -5
- package/src/AnimationStateMachine.ts +48 -0
- package/src/Bone.ts +5 -0
- package/src/FighterAnimationAdapter.ts +124 -0
- package/src/FootIk.ts +251 -0
- package/src/HumanoidBoneInference.ts +323 -0
- package/src/HumanoidRetargeting.ts +180 -19
- package/src/IK.ts +10 -0
- package/src/Inertialization.ts +269 -0
- package/src/LocomotionController.ts +6 -0
- package/src/LocomotionKit.ts +89 -0
- package/src/MotionQuality.ts +5 -5
- package/src/RigQuality.ts +101 -0
- package/src/SecondaryAnimationFixtures.ts +1 -3
- package/src/Skeleton.ts +45 -1
- package/src/SpringBones.ts +243 -0
- package/src/browser-index.ts +13 -4
- package/src/index.ts +12 -4
- package/src/library/performanceStateGraph.ts +117 -0
- package/src/library/standardHumanoidClips.ts +442 -0
- package/src/Retargeting.ts +0 -29
|
@@ -88,14 +88,14 @@ export interface AnimationClipRegistryDiagnostic<TClipId extends string = string
|
|
|
88
88
|
readonly clipId?: TClipId;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
export interface
|
|
91
|
+
export interface AnimationClipMapReadinessOptions<TClipId extends string = string> {
|
|
92
92
|
readonly requiredActions?: readonly string[] | undefined;
|
|
93
93
|
readonly clipMap: Readonly<Record<string, TClipId | readonly TClipId[] | undefined>>;
|
|
94
94
|
readonly aliases?: Readonly<Record<string, string>> | undefined;
|
|
95
95
|
readonly segmentedFallbackDeclared?: boolean | undefined;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export interface
|
|
98
|
+
export interface AnimationClipMapReadiness<TClipId extends string = string> {
|
|
99
99
|
readonly ok: boolean;
|
|
100
100
|
readonly segmentedFallbackDeclared: boolean;
|
|
101
101
|
readonly requiredActions: readonly string[];
|
|
@@ -281,14 +281,14 @@ export function createAnimationClipRegistry<
|
|
|
281
281
|
return new AnimationClipRegistry(clips, options);
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
export function
|
|
284
|
+
export function validateAnimationClipMap<
|
|
285
285
|
TClipId extends string = string,
|
|
286
286
|
TEvent extends AnimationClipEvent = AnimationClipEvent,
|
|
287
287
|
TPose = unknown
|
|
288
288
|
>(
|
|
289
289
|
registry: AnimationClipRegistry<TClipId, TEvent, TPose>,
|
|
290
|
-
options:
|
|
291
|
-
):
|
|
290
|
+
options: AnimationClipMapReadinessOptions<TClipId>
|
|
291
|
+
): AnimationClipMapReadiness<TClipId> {
|
|
292
292
|
const requiredActions = options.requiredActions ?? ["speak", "listen", "gesture", "walk", "action"];
|
|
293
293
|
const missingActions: string[] = [];
|
|
294
294
|
const missingClipIds: TClipId[] = [];
|
|
@@ -303,8 +303,8 @@ export function validateCartoonClipMap<
|
|
|
303
303
|
missingActions.push(action);
|
|
304
304
|
diagnostics.push({
|
|
305
305
|
severity: options.segmentedFallbackDeclared ? "warning" : "error",
|
|
306
|
-
code: "
|
|
307
|
-
message: `
|
|
306
|
+
code: "ANIMATION_CLIP_ACTION_MISSING",
|
|
307
|
+
message: `Animation action "${action}" is missing a clip map entry.`
|
|
308
308
|
});
|
|
309
309
|
continue;
|
|
310
310
|
}
|
|
@@ -313,8 +313,64 @@ export function validateCartoonClipMap<
|
|
|
313
313
|
missingClipIds.push(clipId);
|
|
314
314
|
diagnostics.push({
|
|
315
315
|
severity: options.segmentedFallbackDeclared ? "warning" : "error",
|
|
316
|
-
code: "
|
|
317
|
-
message: `
|
|
316
|
+
code: "ANIMATION_CLIP_ID_MISSING",
|
|
317
|
+
message: `Animation action "${action}" references missing clip "${clipId}".`,
|
|
318
|
+
clipId
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
ok: options.segmentedFallbackDeclared ? missingActions.length === 0 : diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
|
|
326
|
+
segmentedFallbackDeclared: options.segmentedFallbackDeclared === true,
|
|
327
|
+
requiredActions,
|
|
328
|
+
missingActions,
|
|
329
|
+
missingClipIds,
|
|
330
|
+
aliasActions,
|
|
331
|
+
diagnostics
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Generic (non-animation) clip-map readiness validator for locomotion / fighter / Animation Studio
|
|
337
|
+
* clip sets. Same readiness shape as {@link validateAnimationClipMap} but with neutral diagnostic
|
|
338
|
+
* codes and a configurable required-action set (defaults to a locomotion set).
|
|
339
|
+
*/
|
|
340
|
+
export function validateAnimationStudioClipMap<
|
|
341
|
+
TClipId extends string = string,
|
|
342
|
+
TEvent extends AnimationClipEvent = AnimationClipEvent,
|
|
343
|
+
TPose = unknown
|
|
344
|
+
>(
|
|
345
|
+
registry: AnimationClipRegistry<TClipId, TEvent, TPose>,
|
|
346
|
+
options: AnimationClipMapReadinessOptions<TClipId>
|
|
347
|
+
): AnimationClipMapReadiness<TClipId> {
|
|
348
|
+
const requiredActions = options.requiredActions ?? ["idle", "walk", "run"];
|
|
349
|
+
const missingActions: string[] = [];
|
|
350
|
+
const missingClipIds: TClipId[] = [];
|
|
351
|
+
const aliasActions: string[] = [];
|
|
352
|
+
const diagnostics: AnimationClipRegistryDiagnostic<TClipId>[] = [];
|
|
353
|
+
|
|
354
|
+
for (const action of requiredActions) {
|
|
355
|
+
const mapped = options.clipMap[action] ?? options.clipMap[options.aliases?.[action] ?? ""];
|
|
356
|
+
if (options.aliases?.[action]) aliasActions.push(action);
|
|
357
|
+
const clipIds = Array.isArray(mapped) ? mapped : mapped ? [mapped] : [];
|
|
358
|
+
if (clipIds.length === 0) {
|
|
359
|
+
missingActions.push(action);
|
|
360
|
+
diagnostics.push({
|
|
361
|
+
severity: options.segmentedFallbackDeclared ? "warning" : "error",
|
|
362
|
+
code: "ANIMATION_CLIP_ACTION_MISSING",
|
|
363
|
+
message: `Required action "${action}" is missing a clip map entry.`
|
|
364
|
+
});
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
for (const clipId of clipIds) {
|
|
368
|
+
if (!registry.has(clipId)) {
|
|
369
|
+
missingClipIds.push(clipId);
|
|
370
|
+
diagnostics.push({
|
|
371
|
+
severity: options.segmentedFallbackDeclared ? "warning" : "error",
|
|
372
|
+
code: "ANIMATION_CLIP_ID_MISSING",
|
|
373
|
+
message: `Required action "${action}" references missing clip "${clipId}".`,
|
|
318
374
|
clipId
|
|
319
375
|
});
|
|
320
376
|
}
|
|
@@ -123,18 +123,18 @@ export interface AnimationControllerSnapshot<TClipId extends string = string> {
|
|
|
123
123
|
readonly clips: readonly AnimationClipPlaybackState<TClipId>[];
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
export type
|
|
126
|
+
export type AnimationAnimationAction = "speak" | "listen" | "gesture" | "walk" | "action";
|
|
127
127
|
|
|
128
|
-
export interface
|
|
129
|
-
readonly action:
|
|
128
|
+
export interface AnimationAnimationTimelineBinding<TClipId extends string = string> {
|
|
129
|
+
readonly action: AnimationAnimationAction;
|
|
130
130
|
readonly clipId: TClipId;
|
|
131
131
|
readonly loop: AnimationLoopMode | boolean;
|
|
132
132
|
readonly restartOnEnter: boolean;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
export interface
|
|
135
|
+
export interface AnimationAnimationTimelineSample<TClipId extends string = string> {
|
|
136
136
|
readonly time: number;
|
|
137
|
-
readonly action:
|
|
137
|
+
readonly action: AnimationAnimationAction;
|
|
138
138
|
readonly clipId: TClipId;
|
|
139
139
|
readonly playback: AnimationClipPlaybackState<TClipId>;
|
|
140
140
|
}
|
|
@@ -270,21 +270,21 @@ export class AnimationController<
|
|
|
270
270
|
});
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
|
|
274
|
-
action:
|
|
275
|
-
bindings: readonly
|
|
273
|
+
bindAnimationTimelineAction(
|
|
274
|
+
action: AnimationAnimationAction,
|
|
275
|
+
bindings: readonly AnimationAnimationTimelineBinding<TClipId>[],
|
|
276
276
|
time: number,
|
|
277
277
|
options: Omit<AnimationPlayOptions<TClipId>, "loop" | "restart"> = {}
|
|
278
|
-
):
|
|
278
|
+
): AnimationAnimationTimelineSample<TClipId> {
|
|
279
279
|
const binding = bindings.find((candidate) => candidate.action === action);
|
|
280
|
-
if (!binding) throw new Error(`No
|
|
280
|
+
if (!binding) throw new Error(`No animation animation binding registered for action "${action}".`);
|
|
281
281
|
const playback = this.play(binding.clipId, {
|
|
282
282
|
...options,
|
|
283
283
|
loop: binding.loop,
|
|
284
284
|
restart: binding.restartOnEnter,
|
|
285
285
|
metadata: {
|
|
286
286
|
...(options.metadata ?? {}),
|
|
287
|
-
|
|
287
|
+
animationAction: action,
|
|
288
288
|
timelineTime: time
|
|
289
289
|
}
|
|
290
290
|
});
|
package/src/AnimationMixer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AnimationClip } from "./AnimationClip.js";
|
|
|
3
3
|
import type { AnimationEvent } from "./AnimationEvents.js";
|
|
4
4
|
import type { AnimationLayer, AnimationLayerSnapshot } from "./AnimationLayer.js";
|
|
5
5
|
import { cloneAnimationValue, normalizeQuat, slerpQuat, type AnimationValue } from "./Keyframe.js";
|
|
6
|
+
import { DEFAULT_INERTIALIZATION_HALF_LIFE, inertializedTransitionWeight } from "./Inertialization.js";
|
|
6
7
|
import { applyRootMotion, extractRootMotion, type RootMotionSample, type RootMotionTarget } from "./RootMotion.js";
|
|
7
8
|
|
|
8
9
|
export type AnimationTarget = {
|
|
@@ -44,6 +45,7 @@ export class AnimationMixer {
|
|
|
44
45
|
private applyErrors: AnimationApplyError[] = [];
|
|
45
46
|
private readonly eventListeners = new Set<(event: AnimationEvent) => void>();
|
|
46
47
|
private disposed = false;
|
|
48
|
+
private inertialTransition: { from: AnimationAction; to: AnimationAction; elapsed: number; halfLife: number } | undefined;
|
|
47
49
|
|
|
48
50
|
constructor(target?: AnimationTarget, private readonly options: AnimationMixerOptions = {}) {
|
|
49
51
|
this.target = target;
|
|
@@ -84,11 +86,28 @@ export class AnimationMixer {
|
|
|
84
86
|
this.addAction(to);
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Inertialized transition between two actions: instead of the linear weight ramp of
|
|
91
|
+
* {@link crossFade}, the source action's weight follows a critically-damped decay (zero initial
|
|
92
|
+
* slope, momentum-preserving) so the blend carries through instead of dissolving. The transition
|
|
93
|
+
* is advanced deterministically inside {@link update}; weights always sum to 1 and the source is
|
|
94
|
+
* stopped once it has effectively faded. Pass a `halfLife` (seconds) to tune snappiness.
|
|
95
|
+
*/
|
|
96
|
+
inertialCrossFade(from: AnimationAction, to: AnimationAction, halfLife = DEFAULT_INERTIALIZATION_HALF_LIFE): void {
|
|
97
|
+
this.assertAlive();
|
|
98
|
+
to.play();
|
|
99
|
+
from.weight = 1;
|
|
100
|
+
to.weight = 0;
|
|
101
|
+
this.addAction(to);
|
|
102
|
+
this.inertialTransition = { from, to, elapsed: 0, halfLife };
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
update(delta: number): readonly AnimationEvent[] {
|
|
88
106
|
this.assertAlive();
|
|
89
107
|
if (!Number.isFinite(delta) || delta < 0) {
|
|
90
108
|
throw new Error("AnimationMixer delta must be finite and non-negative.");
|
|
91
109
|
}
|
|
110
|
+
this.advanceInertialTransition(delta * this.timeScale);
|
|
92
111
|
const events: AnimationEvent[] = [];
|
|
93
112
|
const accumulators = new Map<string, TargetAccumulator>();
|
|
94
113
|
for (const action of this.actions) {
|
|
@@ -164,6 +183,7 @@ export class AnimationMixer {
|
|
|
164
183
|
this.values.clear();
|
|
165
184
|
this.applyErrors = [];
|
|
166
185
|
this.eventListeners.clear();
|
|
186
|
+
this.inertialTransition = undefined;
|
|
167
187
|
this.disposed = true;
|
|
168
188
|
}
|
|
169
189
|
|
|
@@ -173,6 +193,22 @@ export class AnimationMixer {
|
|
|
173
193
|
}
|
|
174
194
|
}
|
|
175
195
|
|
|
196
|
+
private advanceInertialTransition(scaledDelta: number): void {
|
|
197
|
+
const transition = this.inertialTransition;
|
|
198
|
+
if (!transition) return;
|
|
199
|
+
transition.elapsed += scaledDelta;
|
|
200
|
+
const fromWeight = inertializedTransitionWeight(transition.elapsed, transition.halfLife);
|
|
201
|
+
transition.from.weight = fromWeight;
|
|
202
|
+
transition.to.weight = 1 - fromWeight;
|
|
203
|
+
// Retire the transition once the source has effectively faded (offset within 1e-3).
|
|
204
|
+
if (fromWeight <= 1e-3) {
|
|
205
|
+
transition.from.weight = 0;
|
|
206
|
+
transition.to.weight = 1;
|
|
207
|
+
transition.from.stop();
|
|
208
|
+
this.inertialTransition = undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
176
212
|
private applyActionRootMotion(action: AnimationAction, previousTime: number): void {
|
|
177
213
|
if (!this.options.applyRootMotion || action.weight <= 0) return;
|
|
178
214
|
const rootMotionTarget = this.options.rootMotionTrack ?? "root.position";
|
|
@@ -2,6 +2,7 @@ export {
|
|
|
2
2
|
AnimationStateMachine as AnimationStateGraph,
|
|
3
3
|
AnimationStateMachine,
|
|
4
4
|
type AnimationState,
|
|
5
|
+
type AnimationStateBlend,
|
|
5
6
|
type AnimationStateMachineGraphSnapshot,
|
|
6
7
|
type AnimationStateMachineGraphState,
|
|
7
8
|
type AnimationStateMachineGraphTransition,
|
|
@@ -10,16 +11,16 @@ export {
|
|
|
10
11
|
|
|
11
12
|
import { AnimationStateMachine, type AnimationState } from "./AnimationStateMachine.js";
|
|
12
13
|
|
|
13
|
-
export interface
|
|
14
|
+
export interface AnimationAnimationStateGraphOptions {
|
|
14
15
|
readonly idleState?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export interface
|
|
18
|
+
export interface AnimationAnimationStateGraphSample {
|
|
18
19
|
readonly frame: number;
|
|
19
20
|
readonly state: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export function
|
|
23
|
+
export function createAnimationAnimationStateGraph(options: AnimationAnimationStateGraphOptions = {}): AnimationStateMachine {
|
|
23
24
|
const idle = options.idleState ?? "idle";
|
|
24
25
|
const states: readonly AnimationState[] = [
|
|
25
26
|
{
|
|
@@ -41,17 +42,45 @@ export function createCartoonAnimationStateGraph(options: CartoonAnimationStateG
|
|
|
41
42
|
return new AnimationStateMachine(states, idle);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export function
|
|
45
|
+
export function sampleAnimationAnimationStateGraph(
|
|
45
46
|
graph: AnimationStateMachine,
|
|
46
47
|
inputs: readonly Readonly<Record<string, number | boolean | string>>[],
|
|
47
48
|
delta = 1 / 30
|
|
48
|
-
): readonly
|
|
49
|
+
): readonly AnimationAnimationStateGraphSample[] {
|
|
49
50
|
return inputs.map((parameters, frame) => {
|
|
50
51
|
for (const [key, value] of Object.entries(parameters)) graph.setParameter(key, value);
|
|
51
52
|
return { frame, state: graph.update(delta) };
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
export interface LocomotionAnimationStateGraphOptions {
|
|
57
|
+
readonly idleState?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reusable locomotion state graph (idle <-> walk <-> run) for character-controller and
|
|
62
|
+
* Animation Studio templates. Parameters: `isMoving` (walk), `isRunning` (run). Mirrors
|
|
63
|
+
* {@link createAnimationAnimationStateGraph} so both share the deterministic state-machine core.
|
|
64
|
+
*/
|
|
65
|
+
export function createLocomotionAnimationStateGraph(options: LocomotionAnimationStateGraphOptions = {}): AnimationStateMachine {
|
|
66
|
+
const idle = options.idleState ?? "idle";
|
|
67
|
+
const states: readonly AnimationState[] = [
|
|
68
|
+
{
|
|
69
|
+
name: idle,
|
|
70
|
+
transitions: [transition("run", "isRunning", 60), transition("walk", "isMoving", 40)]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "walk",
|
|
74
|
+
transitions: [transition("run", "isRunning", 60), transition(idle, "isMoving", 0, undefined, false)]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "run",
|
|
78
|
+
transitions: [transition("walk", "isRunning", 0, undefined, false), transition(idle, "isMoving", 0, undefined, false)]
|
|
79
|
+
}
|
|
80
|
+
];
|
|
81
|
+
return new AnimationStateMachine(states, idle);
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
function transition(
|
|
56
85
|
to: string,
|
|
57
86
|
parameter: string,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DEFAULT_INERTIALIZATION_HALF_LIFE, inertializedTransitionWeight } from "./Inertialization.js";
|
|
2
|
+
|
|
1
3
|
export type StateTransition = {
|
|
2
4
|
readonly to: string;
|
|
3
5
|
readonly label?: string;
|
|
@@ -47,11 +49,31 @@ export type AnimationStateMachineGraphSnapshot = {
|
|
|
47
49
|
readonly transitions: readonly AnimationStateMachineGraphTransition[];
|
|
48
50
|
};
|
|
49
51
|
|
|
52
|
+
/** Inertialized blend between the previously-active state and the current state. */
|
|
53
|
+
export type AnimationStateBlend = {
|
|
54
|
+
/** State exited at the last transition (equals `to` when no transition is in flight). */
|
|
55
|
+
readonly from: string;
|
|
56
|
+
/** Currently-active state. */
|
|
57
|
+
readonly to: string;
|
|
58
|
+
/** [fromWeight, toWeight], summing to 1; fromWeight decays critically-damped after a switch. */
|
|
59
|
+
readonly weights: readonly [number, number];
|
|
60
|
+
/** Seconds since the last state change. */
|
|
61
|
+
readonly elapsed: number;
|
|
62
|
+
/** True once the previous state has effectively faded out. */
|
|
63
|
+
readonly done: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
50
66
|
export class AnimationStateMachine {
|
|
51
67
|
readonly states = new Map<string, AnimationState>();
|
|
52
68
|
readonly parameters: Record<string, number | boolean | string> = {};
|
|
53
69
|
currentState: string;
|
|
54
70
|
stateTime = 0;
|
|
71
|
+
/** State active immediately before the most recent transition (for inertialized blending). */
|
|
72
|
+
previousState: string;
|
|
73
|
+
/** Seconds since the most recent state change. */
|
|
74
|
+
transitionElapsed = Number.POSITIVE_INFINITY;
|
|
75
|
+
/** Half-life (seconds) of the inertialized state-transition blend. */
|
|
76
|
+
transitionHalfLife = DEFAULT_INERTIALIZATION_HALF_LIFE;
|
|
55
77
|
|
|
56
78
|
constructor(states: readonly AnimationState[], initialState: string) {
|
|
57
79
|
for (const state of states) {
|
|
@@ -74,6 +96,7 @@ export class AnimationStateMachine {
|
|
|
74
96
|
}
|
|
75
97
|
}
|
|
76
98
|
this.currentState = initialState;
|
|
99
|
+
this.previousState = initialState;
|
|
77
100
|
this.resetParametersForState(this.requireState(initialState));
|
|
78
101
|
}
|
|
79
102
|
|
|
@@ -81,11 +104,34 @@ export class AnimationStateMachine {
|
|
|
81
104
|
this.parameters[name] = value;
|
|
82
105
|
}
|
|
83
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Inertialized blend between {@link previousState} and {@link currentState}. The previous
|
|
109
|
+
* state's weight decays critically-damped (zero initial slope, momentum-preserving) over
|
|
110
|
+
* {@link transitionHalfLife}; consumers crossfade the two states' clips by these weights instead
|
|
111
|
+
* of snapping or linearly ramping. Deterministic given the elapsed transition time.
|
|
112
|
+
*/
|
|
113
|
+
stateBlend(halfLife = this.transitionHalfLife): AnimationStateBlend {
|
|
114
|
+
const elapsed = this.transitionElapsed;
|
|
115
|
+
if (this.previousState === this.currentState || !Number.isFinite(elapsed)) {
|
|
116
|
+
return { from: this.currentState, to: this.currentState, weights: [0, 1], elapsed: Number.isFinite(elapsed) ? elapsed : 0, done: true };
|
|
117
|
+
}
|
|
118
|
+
const fromWeight = inertializedTransitionWeight(elapsed, halfLife);
|
|
119
|
+
const done = fromWeight <= 1e-3;
|
|
120
|
+
return {
|
|
121
|
+
from: this.previousState,
|
|
122
|
+
to: this.currentState,
|
|
123
|
+
weights: [done ? 0 : fromWeight, done ? 1 : 1 - fromWeight],
|
|
124
|
+
elapsed,
|
|
125
|
+
done
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
84
129
|
update(delta = 0): string {
|
|
85
130
|
if (!Number.isFinite(delta) || delta < 0) {
|
|
86
131
|
throw new Error("AnimationStateMachine delta must be finite and non-negative.");
|
|
87
132
|
}
|
|
88
133
|
this.stateTime += delta;
|
|
134
|
+
this.transitionElapsed += delta;
|
|
89
135
|
const state = this.requireState(this.currentState);
|
|
90
136
|
|
|
91
137
|
if (isStateComplete(state, this.stateTime)) {
|
|
@@ -182,8 +228,10 @@ export class AnimationStateMachine {
|
|
|
182
228
|
|
|
183
229
|
private transitionTo(name: string): void {
|
|
184
230
|
const state = this.requireState(name);
|
|
231
|
+
this.previousState = this.currentState;
|
|
185
232
|
this.currentState = name;
|
|
186
233
|
this.stateTime = 0;
|
|
234
|
+
this.transitionElapsed = this.previousState === name ? Number.POSITIVE_INFINITY : 0;
|
|
187
235
|
this.resetParametersForState(state);
|
|
188
236
|
}
|
|
189
237
|
|
package/src/Bone.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type BoneDescriptor = {
|
|
|
7
7
|
readonly rotation?: Quat;
|
|
8
8
|
readonly scale?: Vec3;
|
|
9
9
|
readonly inverseBindMatrix?: Mat4;
|
|
10
|
+
/** Tag this bone as the start (or a member) of a spring-bone chain (secondary dynamics). */
|
|
11
|
+
readonly springChain?: boolean;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export class Bone {
|
|
@@ -16,6 +18,8 @@ export class Bone {
|
|
|
16
18
|
rotation: Quat;
|
|
17
19
|
scale: Vec3;
|
|
18
20
|
inverseBindMatrix: Mat4;
|
|
21
|
+
/** True when this bone participates in a spring-bone chain solved by `createSpringChain`. */
|
|
22
|
+
springChain: boolean;
|
|
19
23
|
|
|
20
24
|
constructor(descriptor: BoneDescriptor) {
|
|
21
25
|
if (descriptor.name.trim().length === 0) {
|
|
@@ -27,6 +31,7 @@ export class Bone {
|
|
|
27
31
|
this.rotation = descriptor.rotation ?? [0, 0, 0, 1];
|
|
28
32
|
this.scale = descriptor.scale ?? [1, 1, 1];
|
|
29
33
|
this.inverseBindMatrix = descriptor.inverseBindMatrix ?? identityMat4();
|
|
34
|
+
this.springChain = descriptor.springChain ?? false;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
localMatrix(): Mat4 {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Shared fighter animation adapter: maps a combat state to a clip key + playback speed, and
|
|
2
|
+
// computes deterministic transition weights on state change. Pure (no engine/render import) so both
|
|
3
|
+
// the Aura Clash arena and the fighting-game template can reuse the same mapping instead of each
|
|
4
|
+
// re-deriving it inline. Transitions default to the inertialized (critically-damped) weight curve;
|
|
5
|
+
// the linear crossfade is retained as an explicit fallback.
|
|
6
|
+
|
|
7
|
+
import { inertializedTransitionWeight, DEFAULT_INERTIALIZATION_HALF_LIFE } from "./Inertialization.js";
|
|
8
|
+
|
|
9
|
+
export type FighterClipKey =
|
|
10
|
+
| "idle"
|
|
11
|
+
| "walk"
|
|
12
|
+
| "run"
|
|
13
|
+
| "air"
|
|
14
|
+
| "down"
|
|
15
|
+
| "guard"
|
|
16
|
+
| "hurt"
|
|
17
|
+
| "ko"
|
|
18
|
+
| "light"
|
|
19
|
+
| "heavy"
|
|
20
|
+
| "special";
|
|
21
|
+
|
|
22
|
+
export interface FighterAnimationStateInput {
|
|
23
|
+
/** High-level action label from the combat sim. */
|
|
24
|
+
readonly action: string;
|
|
25
|
+
/** Whether the fighter is on the ground (airborne overrides locomotion). */
|
|
26
|
+
readonly grounded: boolean;
|
|
27
|
+
/** Active attack clip key, if mid-attack (overrides everything except KO). */
|
|
28
|
+
readonly attackClipKey?: FighterClipKey | undefined;
|
|
29
|
+
/** KO takes precedence over all other states. */
|
|
30
|
+
readonly knockedOut?: boolean | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FighterClipResolution {
|
|
34
|
+
readonly clipKey: FighterClipKey;
|
|
35
|
+
readonly speed: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FighterAnimationSpeedTable {
|
|
39
|
+
readonly light?: number;
|
|
40
|
+
readonly heavy?: number;
|
|
41
|
+
readonly special?: number;
|
|
42
|
+
readonly run?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaultSpeeds: Required<FighterAnimationSpeedTable> = { light: 1.45, heavy: 1.06, special: 0.94, run: 1.18 };
|
|
46
|
+
|
|
47
|
+
const locomotionByAction: Readonly<Record<string, FighterClipKey>> = {
|
|
48
|
+
idle: "idle",
|
|
49
|
+
walk: "walk",
|
|
50
|
+
run: "run",
|
|
51
|
+
dash: "run",
|
|
52
|
+
guard: "guard",
|
|
53
|
+
block: "guard",
|
|
54
|
+
down: "down",
|
|
55
|
+
crouch: "down",
|
|
56
|
+
hurt: "hurt",
|
|
57
|
+
hit: "hurt"
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Resolve the clip key + speed for a fighter state. Mirrors the Aura Clash arena cascade. */
|
|
61
|
+
export function resolveFighterClip(input: FighterAnimationStateInput, speeds: FighterAnimationSpeedTable = {}): FighterClipResolution {
|
|
62
|
+
const table = { ...defaultSpeeds, ...speeds };
|
|
63
|
+
if (input.knockedOut) return { clipKey: "ko", speed: 1 };
|
|
64
|
+
if (input.attackClipKey) {
|
|
65
|
+
const speed = input.attackClipKey === "light" ? table.light : input.attackClipKey === "heavy" ? table.heavy : input.attackClipKey === "special" ? table.special : 1;
|
|
66
|
+
return { clipKey: input.attackClipKey, speed };
|
|
67
|
+
}
|
|
68
|
+
if (!input.grounded) return { clipKey: "air", speed: 1 };
|
|
69
|
+
const action = input.action.toLowerCase();
|
|
70
|
+
const clipKey = locomotionByAction[action] ?? "idle";
|
|
71
|
+
const speed = clipKey === "run" ? table.run : 1;
|
|
72
|
+
return { clipKey, speed };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FighterCrossfade<TClip extends string = string> {
|
|
76
|
+
readonly from: TClip;
|
|
77
|
+
readonly to: TClip;
|
|
78
|
+
readonly weights: readonly [number, number];
|
|
79
|
+
readonly done: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Deterministic crossfade weights from a previous clip to the current clip over a transition
|
|
84
|
+
* window. `elapsed`/`duration` are in seconds. Returns clamped [fromWeight, toWeight] summing to 1.
|
|
85
|
+
* Generic over clip identifier so callers using clip keys OR raw clip names can both use it.
|
|
86
|
+
*/
|
|
87
|
+
export function fighterCrossfadeWeights<TClip extends string = string>(from: TClip, to: TClip, elapsed: number, duration: number): FighterCrossfade<TClip> {
|
|
88
|
+
if (from === to || duration <= 0) {
|
|
89
|
+
return { from, to, weights: [0, 1], done: true };
|
|
90
|
+
}
|
|
91
|
+
const t = Math.max(0, Math.min(1, elapsed / duration));
|
|
92
|
+
return { from, to, weights: [1 - t, t], done: t >= 1 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inertialized transition weights from a previous clip to the current clip. This is the **default**
|
|
97
|
+
* fighter transition: the source weight follows a critically-damped decay (zero initial slope,
|
|
98
|
+
* momentum-preserving) instead of the straight linear ramp of {@link fighterCrossfadeWeights}, so
|
|
99
|
+
* move swaps carry through smoothly rather than dissolving. Weights still sum to 1, output is
|
|
100
|
+
* deterministic (pure function of `elapsed`/`duration`/`halfLife`), so deterministic combat replay
|
|
101
|
+
* is unaffected. `halfLife` defaults to ~40% of the transition window so the source has effectively
|
|
102
|
+
* faded by the time the window closes; the linear path remains available as a fallback.
|
|
103
|
+
*/
|
|
104
|
+
export function fighterInertializedWeights<TClip extends string = string>(
|
|
105
|
+
from: TClip,
|
|
106
|
+
to: TClip,
|
|
107
|
+
elapsed: number,
|
|
108
|
+
duration: number,
|
|
109
|
+
halfLife?: number
|
|
110
|
+
): FighterCrossfade<TClip> {
|
|
111
|
+
if (from === to || duration <= 0) {
|
|
112
|
+
return { from, to, weights: [0, 1], done: true };
|
|
113
|
+
}
|
|
114
|
+
const done = elapsed >= duration;
|
|
115
|
+
if (done) {
|
|
116
|
+
return { from, to, weights: [0, 1], done: true };
|
|
117
|
+
}
|
|
118
|
+
const life = halfLife ?? Math.max(1e-4, duration * 0.4);
|
|
119
|
+
const fromWeight = inertializedTransitionWeight(Math.max(0, elapsed), life);
|
|
120
|
+
return { from, to, weights: [fromWeight, 1 - fromWeight], done: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Default transition half-life re-exported for callers that want to align their own timing. */
|
|
124
|
+
export { DEFAULT_INERTIALIZATION_HALF_LIFE };
|