@aura3d/animation 1.1.0 → 1.3.1

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 +9 -3
  6. package/dist/AnimationClipRegistry.d.ts.map +1 -1
  7. package/dist/AnimationClipRegistry.js +52 -5
  8. package/dist/AnimationClipRegistry.js.map +1 -1
  9. package/dist/AnimationController.d.ts +6 -6
  10. package/dist/AnimationController.d.ts.map +1 -1
  11. package/dist/AnimationController.js +3 -3
  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 +14 -5
  18. package/dist/AnimationStateGraph.d.ts.map +1 -1
  19. package/dist/AnimationStateGraph.js +25 -2
  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 +48 -4
  42. package/dist/HumanoidRetargeting.d.ts.map +1 -1
  43. package/dist/HumanoidRetargeting.js +149 -14
  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 +3 -3
  62. package/dist/MotionQuality.d.ts.map +1 -1
  63. package/dist/MotionQuality.js +2 -2
  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 +65 -9
  99. package/src/AnimationController.ts +11 -11
  100. package/src/AnimationMixer.ts +36 -0
  101. package/src/AnimationStateGraph.ts +34 -5
  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 +180 -19
  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 +5 -5
  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 -4
  118. package/src/index.ts +12 -4
  119. package/src/library/performanceStateGraph.ts +117 -0
  120. package/src/library/standardHumanoidClips.ts +442 -0
  121. 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 CartoonClipMapReadinessOptions<TClipId extends string = string> {
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 CartoonClipMapReadiness<TClipId extends string = string> {
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 validateCartoonClipMap<
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: CartoonClipMapReadinessOptions<TClipId>
291
- ): CartoonClipMapReadiness<TClipId> {
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: "CARTOON_CLIP_ACTION_MISSING",
307
- message: `Cartoon action "${action}" is missing a clip map entry.`
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: "CARTOON_CLIP_ID_MISSING",
317
- message: `Cartoon action "${action}" references missing clip "${clipId}".`,
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 CartoonAnimationAction = "speak" | "listen" | "gesture" | "walk" | "action";
126
+ export type AnimationAnimationAction = "speak" | "listen" | "gesture" | "walk" | "action";
127
127
 
128
- export interface CartoonAnimationTimelineBinding<TClipId extends string = string> {
129
- readonly action: CartoonAnimationAction;
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 CartoonAnimationTimelineSample<TClipId extends string = string> {
135
+ export interface AnimationAnimationTimelineSample<TClipId extends string = string> {
136
136
  readonly time: number;
137
- readonly action: CartoonAnimationAction;
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
- bindCartoonTimelineAction(
274
- action: CartoonAnimationAction,
275
- bindings: readonly CartoonAnimationTimelineBinding<TClipId>[],
273
+ bindAnimationTimelineAction(
274
+ action: AnimationAnimationAction,
275
+ bindings: readonly AnimationAnimationTimelineBinding<TClipId>[],
276
276
  time: number,
277
277
  options: Omit<AnimationPlayOptions<TClipId>, "loop" | "restart"> = {}
278
- ): CartoonAnimationTimelineSample<TClipId> {
278
+ ): AnimationAnimationTimelineSample<TClipId> {
279
279
  const binding = bindings.find((candidate) => candidate.action === action);
280
- if (!binding) throw new Error(`No cartoon animation binding registered for action "${action}".`);
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
- cartoonAction: action,
287
+ animationAction: action,
288
288
  timelineTime: time
289
289
  }
290
290
  });
@@ -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 CartoonAnimationStateGraphOptions {
14
+ export interface AnimationAnimationStateGraphOptions {
14
15
  readonly idleState?: string;
15
16
  }
16
17
 
17
- export interface CartoonAnimationStateGraphSample {
18
+ export interface AnimationAnimationStateGraphSample {
18
19
  readonly frame: number;
19
20
  readonly state: string;
20
21
  }
21
22
 
22
- export function createCartoonAnimationStateGraph(options: CartoonAnimationStateGraphOptions = {}): AnimationStateMachine {
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 sampleCartoonAnimationStateGraph(
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 CartoonAnimationStateGraphSample[] {
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 };