@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.
Files changed (121) hide show
  1. package/dist/AnimationClipEvents.d.ts +55 -0
  2. package/dist/AnimationClipEvents.d.ts.map +1 -1
  3. package/dist/AnimationClipEvents.js +136 -0
  4. package/dist/AnimationClipEvents.js.map +1 -1
  5. package/dist/AnimationClipRegistry.d.ts +22 -0
  6. package/dist/AnimationClipRegistry.d.ts.map +1 -1
  7. package/dist/AnimationClipRegistry.js +89 -0
  8. package/dist/AnimationClipRegistry.js.map +1 -1
  9. package/dist/AnimationController.d.ts +15 -0
  10. package/dist/AnimationController.d.ts.map +1 -1
  11. package/dist/AnimationController.js +28 -0
  12. package/dist/AnimationController.js.map +1 -1
  13. package/dist/AnimationMixer.d.ts +10 -0
  14. package/dist/AnimationMixer.d.ts.map +1 -1
  15. package/dist/AnimationMixer.js +35 -0
  16. package/dist/AnimationMixer.js.map +1 -1
  17. package/dist/AnimationStateGraph.d.ts +20 -1
  18. package/dist/AnimationStateGraph.d.ts.map +1 -1
  19. package/dist/AnimationStateGraph.js +73 -1
  20. package/dist/AnimationStateGraph.js.map +1 -1
  21. package/dist/AnimationStateMachine.d.ts +26 -0
  22. package/dist/AnimationStateMachine.d.ts.map +1 -1
  23. package/dist/AnimationStateMachine.js +32 -0
  24. package/dist/AnimationStateMachine.js.map +1 -1
  25. package/dist/Bone.d.ts +4 -0
  26. package/dist/Bone.d.ts.map +1 -1
  27. package/dist/Bone.js +3 -0
  28. package/dist/Bone.js.map +1 -1
  29. package/dist/FighterAnimationAdapter.d.ts +49 -0
  30. package/dist/FighterAnimationAdapter.d.ts.map +1 -0
  31. package/dist/FighterAnimationAdapter.js +71 -0
  32. package/dist/FighterAnimationAdapter.js.map +1 -0
  33. package/dist/FootIk.d.ts +77 -0
  34. package/dist/FootIk.d.ts.map +1 -0
  35. package/dist/FootIk.js +153 -0
  36. package/dist/FootIk.js.map +1 -0
  37. package/dist/HumanoidBoneInference.d.ts +61 -0
  38. package/dist/HumanoidBoneInference.d.ts.map +1 -0
  39. package/dist/HumanoidBoneInference.js +239 -0
  40. package/dist/HumanoidBoneInference.js.map +1 -0
  41. package/dist/HumanoidRetargeting.d.ts +57 -0
  42. package/dist/HumanoidRetargeting.d.ts.map +1 -1
  43. package/dist/HumanoidRetargeting.js +184 -6
  44. package/dist/HumanoidRetargeting.js.map +1 -1
  45. package/dist/IK.d.ts +5 -0
  46. package/dist/IK.d.ts.map +1 -1
  47. package/dist/IK.js +9 -0
  48. package/dist/IK.js.map +1 -1
  49. package/dist/Inertialization.d.ts +65 -0
  50. package/dist/Inertialization.d.ts.map +1 -0
  51. package/dist/Inertialization.js +211 -0
  52. package/dist/Inertialization.js.map +1 -0
  53. package/dist/LocomotionController.d.ts +5 -0
  54. package/dist/LocomotionController.d.ts.map +1 -1
  55. package/dist/LocomotionController.js +3 -0
  56. package/dist/LocomotionController.js.map +1 -1
  57. package/dist/LocomotionKit.d.ts +52 -0
  58. package/dist/LocomotionKit.d.ts.map +1 -0
  59. package/dist/LocomotionKit.js +38 -0
  60. package/dist/LocomotionKit.js.map +1 -0
  61. package/dist/MotionQuality.d.ts +6 -0
  62. package/dist/MotionQuality.d.ts.map +1 -1
  63. package/dist/MotionQuality.js +16 -0
  64. package/dist/MotionQuality.js.map +1 -1
  65. package/dist/RigQuality.d.ts +31 -0
  66. package/dist/RigQuality.d.ts.map +1 -0
  67. package/dist/RigQuality.js +70 -0
  68. package/dist/RigQuality.js.map +1 -0
  69. package/dist/SecondaryAnimationFixtures.d.ts.map +1 -1
  70. package/dist/SecondaryAnimationFixtures.js +1 -3
  71. package/dist/SecondaryAnimationFixtures.js.map +1 -1
  72. package/dist/Skeleton.d.ts +16 -1
  73. package/dist/Skeleton.d.ts.map +1 -1
  74. package/dist/Skeleton.js +42 -0
  75. package/dist/Skeleton.js.map +1 -1
  76. package/dist/SpringBones.d.ts +60 -0
  77. package/dist/SpringBones.d.ts.map +1 -0
  78. package/dist/SpringBones.js +173 -0
  79. package/dist/SpringBones.js.map +1 -0
  80. package/dist/browser-index.d.ts +12 -3
  81. package/dist/browser-index.d.ts.map +1 -1
  82. package/dist/browser-index.js +11 -2
  83. package/dist/browser-index.js.map +1 -1
  84. package/dist/index.d.ts +11 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +10 -2
  87. package/dist/index.js.map +1 -1
  88. package/dist/library/performanceStateGraph.d.ts +42 -0
  89. package/dist/library/performanceStateGraph.d.ts.map +1 -0
  90. package/dist/library/performanceStateGraph.js +101 -0
  91. package/dist/library/performanceStateGraph.js.map +1 -0
  92. package/dist/library/standardHumanoidClips.d.ts +27 -0
  93. package/dist/library/standardHumanoidClips.d.ts.map +1 -0
  94. package/dist/library/standardHumanoidClips.js +357 -0
  95. package/dist/library/standardHumanoidClips.js.map +1 -0
  96. package/package.json +3 -3
  97. package/src/AnimationClipEvents.ts +181 -0
  98. package/src/AnimationClipRegistry.ts +124 -0
  99. package/src/AnimationController.ts +50 -0
  100. package/src/AnimationMixer.ts +36 -0
  101. package/src/AnimationStateGraph.ts +97 -0
  102. package/src/AnimationStateMachine.ts +48 -0
  103. package/src/Bone.ts +5 -0
  104. package/src/FighterAnimationAdapter.ts +124 -0
  105. package/src/FootIk.ts +251 -0
  106. package/src/HumanoidBoneInference.ts +323 -0
  107. package/src/HumanoidRetargeting.ts +230 -6
  108. package/src/IK.ts +10 -0
  109. package/src/Inertialization.ts +269 -0
  110. package/src/LocomotionController.ts +6 -0
  111. package/src/LocomotionKit.ts +89 -0
  112. package/src/MotionQuality.ts +26 -0
  113. package/src/RigQuality.ts +101 -0
  114. package/src/SecondaryAnimationFixtures.ts +1 -3
  115. package/src/Skeleton.ts +45 -1
  116. package/src/SpringBones.ts +243 -0
  117. package/src/browser-index.ts +13 -2
  118. package/src/index.ts +12 -2
  119. package/src/library/performanceStateGraph.ts +117 -0
  120. package/src/library/standardHumanoidClips.ts +442 -0
  121. 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") {
@@ -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 };