@aura3d/animation 1.0.6 → 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 +22 -0
- package/dist/AnimationClipRegistry.d.ts.map +1 -1
- package/dist/AnimationClipRegistry.js +89 -0
- package/dist/AnimationClipRegistry.js.map +1 -1
- package/dist/AnimationController.d.ts +15 -0
- package/dist/AnimationController.d.ts.map +1 -1
- package/dist/AnimationController.js +28 -0
- 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 +20 -1
- package/dist/AnimationStateGraph.d.ts.map +1 -1
- package/dist/AnimationStateGraph.js +73 -1
- 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 +57 -0
- package/dist/HumanoidRetargeting.d.ts.map +1 -1
- package/dist/HumanoidRetargeting.js +184 -6
- 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 +6 -0
- package/dist/MotionQuality.d.ts.map +1 -1
- package/dist/MotionQuality.js +16 -0
- 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 +124 -0
- package/src/AnimationController.ts +50 -0
- package/src/AnimationMixer.ts +36 -0
- package/src/AnimationStateGraph.ts +97 -0
- 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 +230 -6
- 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 +26 -0
- 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 -2
- package/src/index.ts +12 -2
- package/src/library/performanceStateGraph.ts +117 -0
- package/src/library/standardHumanoidClips.ts +442 -0
- package/src/Retargeting.ts +0 -29
|
@@ -88,6 +88,23 @@ export interface AnimationClipRegistryDiagnostic<TClipId extends string = string
|
|
|
88
88
|
readonly clipId?: TClipId;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
export interface AnimationClipMapReadinessOptions<TClipId extends string = string> {
|
|
92
|
+
readonly requiredActions?: readonly string[] | undefined;
|
|
93
|
+
readonly clipMap: Readonly<Record<string, TClipId | readonly TClipId[] | undefined>>;
|
|
94
|
+
readonly aliases?: Readonly<Record<string, string>> | undefined;
|
|
95
|
+
readonly segmentedFallbackDeclared?: boolean | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AnimationClipMapReadiness<TClipId extends string = string> {
|
|
99
|
+
readonly ok: boolean;
|
|
100
|
+
readonly segmentedFallbackDeclared: boolean;
|
|
101
|
+
readonly requiredActions: readonly string[];
|
|
102
|
+
readonly missingActions: readonly string[];
|
|
103
|
+
readonly missingClipIds: readonly TClipId[];
|
|
104
|
+
readonly aliasActions: readonly string[];
|
|
105
|
+
readonly diagnostics: readonly AnimationClipRegistryDiagnostic<TClipId>[];
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
export interface AnimationClipManifest<
|
|
92
109
|
TClipId extends string = string,
|
|
93
110
|
TEvent extends AnimationClipEvent = AnimationClipEvent,
|
|
@@ -264,6 +281,113 @@ export function createAnimationClipRegistry<
|
|
|
264
281
|
return new AnimationClipRegistry(clips, options);
|
|
265
282
|
}
|
|
266
283
|
|
|
284
|
+
export function validateAnimationClipMap<
|
|
285
|
+
TClipId extends string = string,
|
|
286
|
+
TEvent extends AnimationClipEvent = AnimationClipEvent,
|
|
287
|
+
TPose = unknown
|
|
288
|
+
>(
|
|
289
|
+
registry: AnimationClipRegistry<TClipId, TEvent, TPose>,
|
|
290
|
+
options: AnimationClipMapReadinessOptions<TClipId>
|
|
291
|
+
): AnimationClipMapReadiness<TClipId> {
|
|
292
|
+
const requiredActions = options.requiredActions ?? ["speak", "listen", "gesture", "walk", "action"];
|
|
293
|
+
const missingActions: string[] = [];
|
|
294
|
+
const missingClipIds: TClipId[] = [];
|
|
295
|
+
const aliasActions: string[] = [];
|
|
296
|
+
const diagnostics: AnimationClipRegistryDiagnostic<TClipId>[] = [];
|
|
297
|
+
|
|
298
|
+
for (const action of requiredActions) {
|
|
299
|
+
const mapped = options.clipMap[action] ?? options.clipMap[options.aliases?.[action] ?? ""];
|
|
300
|
+
if (options.aliases?.[action]) aliasActions.push(action);
|
|
301
|
+
const clipIds = Array.isArray(mapped) ? mapped : mapped ? [mapped] : [];
|
|
302
|
+
if (clipIds.length === 0) {
|
|
303
|
+
missingActions.push(action);
|
|
304
|
+
diagnostics.push({
|
|
305
|
+
severity: options.segmentedFallbackDeclared ? "warning" : "error",
|
|
306
|
+
code: "ANIMATION_CLIP_ACTION_MISSING",
|
|
307
|
+
message: `Animation action "${action}" is missing a clip map entry.`
|
|
308
|
+
});
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
for (const clipId of clipIds) {
|
|
312
|
+
if (!registry.has(clipId)) {
|
|
313
|
+
missingClipIds.push(clipId);
|
|
314
|
+
diagnostics.push({
|
|
315
|
+
severity: options.segmentedFallbackDeclared ? "warning" : "error",
|
|
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}".`,
|
|
374
|
+
clipId
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
ok: options.segmentedFallbackDeclared ? missingActions.length === 0 : diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
|
|
382
|
+
segmentedFallbackDeclared: options.segmentedFallbackDeclared === true,
|
|
383
|
+
requiredActions,
|
|
384
|
+
missingActions,
|
|
385
|
+
missingClipIds,
|
|
386
|
+
aliasActions,
|
|
387
|
+
diagnostics
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
267
391
|
function validateClipDefinition(definition: AnimationClipDefinition): void {
|
|
268
392
|
if (!definition.id) {
|
|
269
393
|
throw new Error("Animation clips must have a stable id.");
|
|
@@ -123,6 +123,22 @@ export interface AnimationControllerSnapshot<TClipId extends string = string> {
|
|
|
123
123
|
readonly clips: readonly AnimationClipPlaybackState<TClipId>[];
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
export type AnimationAnimationAction = "speak" | "listen" | "gesture" | "walk" | "action";
|
|
127
|
+
|
|
128
|
+
export interface AnimationAnimationTimelineBinding<TClipId extends string = string> {
|
|
129
|
+
readonly action: AnimationAnimationAction;
|
|
130
|
+
readonly clipId: TClipId;
|
|
131
|
+
readonly loop: AnimationLoopMode | boolean;
|
|
132
|
+
readonly restartOnEnter: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface AnimationAnimationTimelineSample<TClipId extends string = string> {
|
|
136
|
+
readonly time: number;
|
|
137
|
+
readonly action: AnimationAnimationAction;
|
|
138
|
+
readonly clipId: TClipId;
|
|
139
|
+
readonly playback: AnimationClipPlaybackState<TClipId>;
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
export interface AnimationClipLoopEvent<TClipId extends string = string> {
|
|
127
143
|
readonly clipId: TClipId;
|
|
128
144
|
readonly playbackId: string;
|
|
@@ -246,6 +262,40 @@ export class AnimationController<
|
|
|
246
262
|
return cloneState(playback);
|
|
247
263
|
}
|
|
248
264
|
|
|
265
|
+
playOnce(clipId: TClipId, options: Omit<AnimationPlayOptions<TClipId>, "loop"> = {}): AnimationClipPlaybackState<TClipId> {
|
|
266
|
+
return this.play(clipId, {
|
|
267
|
+
...options,
|
|
268
|
+
loop: "once",
|
|
269
|
+
restart: options.restart ?? true
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
bindAnimationTimelineAction(
|
|
274
|
+
action: AnimationAnimationAction,
|
|
275
|
+
bindings: readonly AnimationAnimationTimelineBinding<TClipId>[],
|
|
276
|
+
time: number,
|
|
277
|
+
options: Omit<AnimationPlayOptions<TClipId>, "loop" | "restart"> = {}
|
|
278
|
+
): AnimationAnimationTimelineSample<TClipId> {
|
|
279
|
+
const binding = bindings.find((candidate) => candidate.action === action);
|
|
280
|
+
if (!binding) throw new Error(`No animation animation binding registered for action "${action}".`);
|
|
281
|
+
const playback = this.play(binding.clipId, {
|
|
282
|
+
...options,
|
|
283
|
+
loop: binding.loop,
|
|
284
|
+
restart: binding.restartOnEnter,
|
|
285
|
+
metadata: {
|
|
286
|
+
...(options.metadata ?? {}),
|
|
287
|
+
animationAction: action,
|
|
288
|
+
timelineTime: time
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
time,
|
|
293
|
+
action,
|
|
294
|
+
clipId: binding.clipId,
|
|
295
|
+
playback
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
249
299
|
pause(clipId?: TClipId): void {
|
|
250
300
|
for (const state of this.selectStates(clipId)) {
|
|
251
301
|
if (state.status === "playing") {
|
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";
|
|
@@ -1,8 +1,105 @@
|
|
|
1
1
|
export {
|
|
2
2
|
AnimationStateMachine as AnimationStateGraph,
|
|
3
|
+
AnimationStateMachine,
|
|
3
4
|
type AnimationState,
|
|
5
|
+
type AnimationStateBlend,
|
|
4
6
|
type AnimationStateMachineGraphSnapshot,
|
|
5
7
|
type AnimationStateMachineGraphState,
|
|
6
8
|
type AnimationStateMachineGraphTransition,
|
|
7
9
|
type StateTransition
|
|
8
10
|
} from "./AnimationStateMachine.js";
|
|
11
|
+
|
|
12
|
+
import { AnimationStateMachine, type AnimationState } from "./AnimationStateMachine.js";
|
|
13
|
+
|
|
14
|
+
export interface AnimationAnimationStateGraphOptions {
|
|
15
|
+
readonly idleState?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AnimationAnimationStateGraphSample {
|
|
19
|
+
readonly frame: number;
|
|
20
|
+
readonly state: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createAnimationAnimationStateGraph(options: AnimationAnimationStateGraphOptions = {}): AnimationStateMachine {
|
|
24
|
+
const idle = options.idleState ?? "idle";
|
|
25
|
+
const states: readonly AnimationState[] = [
|
|
26
|
+
{
|
|
27
|
+
name: idle,
|
|
28
|
+
transitions: [
|
|
29
|
+
transition("speak", "isSpeaking", 50),
|
|
30
|
+
transition("gesture", "gesture", 40, ["gesture"]),
|
|
31
|
+
transition("walk", "isWalking", 30),
|
|
32
|
+
transition("action", "action", 60, ["action"]),
|
|
33
|
+
transition("listen", "isListening", 20)
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{ name: "listen", transitions: [transition("speak", "isSpeaking", 50), transition(idle, "isListening", 0, undefined, false)] },
|
|
37
|
+
{ name: "speak", transitions: [transition("gesture", "gesture", 60, ["gesture"]), transition(idle, "isSpeaking", 0, undefined, false)] },
|
|
38
|
+
{ name: "gesture", duration: 0.4, oneShot: true, onComplete: idle },
|
|
39
|
+
{ name: "walk", transitions: [transition(idle, "isWalking", 0, undefined, false), transition("action", "action", 60, ["action"])] },
|
|
40
|
+
{ name: "action", duration: 0.5, oneShot: true, onComplete: idle }
|
|
41
|
+
];
|
|
42
|
+
return new AnimationStateMachine(states, idle);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sampleAnimationAnimationStateGraph(
|
|
46
|
+
graph: AnimationStateMachine,
|
|
47
|
+
inputs: readonly Readonly<Record<string, number | boolean | string>>[],
|
|
48
|
+
delta = 1 / 30
|
|
49
|
+
): readonly AnimationAnimationStateGraphSample[] {
|
|
50
|
+
return inputs.map((parameters, frame) => {
|
|
51
|
+
for (const [key, value] of Object.entries(parameters)) graph.setParameter(key, value);
|
|
52
|
+
return { frame, state: graph.update(delta) };
|
|
53
|
+
});
|
|
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
|
+
|
|
84
|
+
function transition(
|
|
85
|
+
to: string,
|
|
86
|
+
parameter: string,
|
|
87
|
+
priority: number,
|
|
88
|
+
consumeParameters?: readonly string[],
|
|
89
|
+
expected = true
|
|
90
|
+
) {
|
|
91
|
+
return {
|
|
92
|
+
to,
|
|
93
|
+
priority,
|
|
94
|
+
label: `${parameter}->${to}`,
|
|
95
|
+
consumeParameters,
|
|
96
|
+
condition: (parameters: Readonly<Record<string, number | boolean | string>>) => {
|
|
97
|
+
const value = parameters[parameter];
|
|
98
|
+
if (expected !== true) return value === expected;
|
|
99
|
+
if (typeof value === "boolean") return value;
|
|
100
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
101
|
+
if (typeof value === "number") return value > 0;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -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 };
|