@aura3d/animation 1.0.6

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 (187) hide show
  1. package/README.md +19 -0
  2. package/dist/AnimationAction.d.ts +40 -0
  3. package/dist/AnimationAction.d.ts.map +1 -0
  4. package/dist/AnimationAction.js +143 -0
  5. package/dist/AnimationAction.js.map +1 -0
  6. package/dist/AnimationClip.d.ts +24 -0
  7. package/dist/AnimationClip.d.ts.map +1 -0
  8. package/dist/AnimationClip.js +45 -0
  9. package/dist/AnimationClip.js.map +1 -0
  10. package/dist/AnimationClipEvents.d.ts +57 -0
  11. package/dist/AnimationClipEvents.d.ts.map +1 -0
  12. package/dist/AnimationClipEvents.js +171 -0
  13. package/dist/AnimationClipEvents.js.map +1 -0
  14. package/dist/AnimationClipRegistry.d.ts +76 -0
  15. package/dist/AnimationClipRegistry.d.ts.map +1 -0
  16. package/dist/AnimationClipRegistry.js +130 -0
  17. package/dist/AnimationClipRegistry.js.map +1 -0
  18. package/dist/AnimationController.d.ts +168 -0
  19. package/dist/AnimationController.d.ts.map +1 -0
  20. package/dist/AnimationController.js +619 -0
  21. package/dist/AnimationController.js.map +1 -0
  22. package/dist/AnimationEvents.d.ts +10 -0
  23. package/dist/AnimationEvents.d.ts.map +1 -0
  24. package/dist/AnimationEvents.js +26 -0
  25. package/dist/AnimationEvents.js.map +1 -0
  26. package/dist/AnimationLayer.d.ts +27 -0
  27. package/dist/AnimationLayer.d.ts.map +1 -0
  28. package/dist/AnimationLayer.js +53 -0
  29. package/dist/AnimationLayer.js.map +1 -0
  30. package/dist/AnimationMixer.d.ts +54 -0
  31. package/dist/AnimationMixer.d.ts.map +1 -0
  32. package/dist/AnimationMixer.js +319 -0
  33. package/dist/AnimationMixer.js.map +1 -0
  34. package/dist/AnimationStateGraph.d.ts +2 -0
  35. package/dist/AnimationStateGraph.d.ts.map +1 -0
  36. package/dist/AnimationStateGraph.js +2 -0
  37. package/dist/AnimationStateGraph.js.map +1 -0
  38. package/dist/AnimationStateMachine.d.ts +59 -0
  39. package/dist/AnimationStateMachine.d.ts.map +1 -0
  40. package/dist/AnimationStateMachine.js +154 -0
  41. package/dist/AnimationStateMachine.js.map +1 -0
  42. package/dist/AnimationTrack.d.ts +23 -0
  43. package/dist/AnimationTrack.d.ts.map +1 -0
  44. package/dist/AnimationTrack.js +124 -0
  45. package/dist/AnimationTrack.js.map +1 -0
  46. package/dist/BlendTree.d.ts +23 -0
  47. package/dist/BlendTree.d.ts.map +1 -0
  48. package/dist/BlendTree.js +68 -0
  49. package/dist/BlendTree.js.map +1 -0
  50. package/dist/Bone.d.ts +20 -0
  51. package/dist/Bone.d.ts.map +1 -0
  52. package/dist/Bone.js +24 -0
  53. package/dist/Bone.js.map +1 -0
  54. package/dist/CrowdAnimation.d.ts +13 -0
  55. package/dist/CrowdAnimation.d.ts.map +1 -0
  56. package/dist/CrowdAnimation.js +14 -0
  57. package/dist/CrowdAnimation.js.map +1 -0
  58. package/dist/ECSAnimationBridge.d.ts +9 -0
  59. package/dist/ECSAnimationBridge.d.ts.map +1 -0
  60. package/dist/ECSAnimationBridge.js +24 -0
  61. package/dist/ECSAnimationBridge.js.map +1 -0
  62. package/dist/HumanoidRetargeting.d.ts +76 -0
  63. package/dist/HumanoidRetargeting.d.ts.map +1 -0
  64. package/dist/HumanoidRetargeting.js +331 -0
  65. package/dist/HumanoidRetargeting.js.map +1 -0
  66. package/dist/IK.d.ts +24 -0
  67. package/dist/IK.d.ts.map +1 -0
  68. package/dist/IK.js +76 -0
  69. package/dist/IK.js.map +1 -0
  70. package/dist/Keyframe.d.ts +50 -0
  71. package/dist/Keyframe.d.ts.map +1 -0
  72. package/dist/Keyframe.js +118 -0
  73. package/dist/Keyframe.js.map +1 -0
  74. package/dist/LocomotionController.d.ts +47 -0
  75. package/dist/LocomotionController.d.ts.map +1 -0
  76. package/dist/LocomotionController.js +106 -0
  77. package/dist/LocomotionController.js.map +1 -0
  78. package/dist/MotionMatchingFixtures.d.ts +58 -0
  79. package/dist/MotionMatchingFixtures.d.ts.map +1 -0
  80. package/dist/MotionMatchingFixtures.js +175 -0
  81. package/dist/MotionMatchingFixtures.js.map +1 -0
  82. package/dist/MotionQuality.d.ts +36 -0
  83. package/dist/MotionQuality.d.ts.map +1 -0
  84. package/dist/MotionQuality.js +117 -0
  85. package/dist/MotionQuality.js.map +1 -0
  86. package/dist/Retargeting.d.ts +12 -0
  87. package/dist/Retargeting.d.ts.map +1 -0
  88. package/dist/Retargeting.js +19 -0
  89. package/dist/Retargeting.js.map +1 -0
  90. package/dist/RootMotion.d.ts +21 -0
  91. package/dist/RootMotion.d.ts.map +1 -0
  92. package/dist/RootMotion.js +84 -0
  93. package/dist/RootMotion.js.map +1 -0
  94. package/dist/SceneAnimationBridge.d.ts +18 -0
  95. package/dist/SceneAnimationBridge.d.ts.map +1 -0
  96. package/dist/SceneAnimationBridge.js +69 -0
  97. package/dist/SceneAnimationBridge.js.map +1 -0
  98. package/dist/SecondaryAnimationFixtures.d.ts +55 -0
  99. package/dist/SecondaryAnimationFixtures.d.ts.map +1 -0
  100. package/dist/SecondaryAnimationFixtures.js +147 -0
  101. package/dist/SecondaryAnimationFixtures.js.map +1 -0
  102. package/dist/Skeleton.d.ts +9 -0
  103. package/dist/Skeleton.d.ts.map +1 -0
  104. package/dist/Skeleton.js +31 -0
  105. package/dist/Skeleton.js.map +1 -0
  106. package/dist/Skinning.d.ts +7 -0
  107. package/dist/Skinning.d.ts.map +1 -0
  108. package/dist/Skinning.js +12 -0
  109. package/dist/Skinning.js.map +1 -0
  110. package/dist/browser-index.d.ts +39 -0
  111. package/dist/browser-index.d.ts.map +1 -0
  112. package/dist/browser-index.js +34 -0
  113. package/dist/browser-index.js.map +1 -0
  114. package/dist/index.d.ts +40 -0
  115. package/dist/index.d.ts.map +1 -0
  116. package/dist/index.js +35 -0
  117. package/dist/index.js.map +1 -0
  118. package/dist/threejs-compatibility/AnimationAction.d.ts +16 -0
  119. package/dist/threejs-compatibility/AnimationAction.d.ts.map +1 -0
  120. package/dist/threejs-compatibility/AnimationAction.js +25 -0
  121. package/dist/threejs-compatibility/AnimationAction.js.map +1 -0
  122. package/dist/threejs-compatibility/AnimationClip.d.ts +14 -0
  123. package/dist/threejs-compatibility/AnimationClip.d.ts.map +1 -0
  124. package/dist/threejs-compatibility/AnimationClip.js +11 -0
  125. package/dist/threejs-compatibility/AnimationClip.js.map +1 -0
  126. package/dist/threejs-compatibility/AnimationDiagnostics.d.ts +44 -0
  127. package/dist/threejs-compatibility/AnimationDiagnostics.d.ts.map +1 -0
  128. package/dist/threejs-compatibility/AnimationDiagnostics.js +29 -0
  129. package/dist/threejs-compatibility/AnimationDiagnostics.js.map +1 -0
  130. package/dist/threejs-compatibility/AnimationMixer.d.ts +9 -0
  131. package/dist/threejs-compatibility/AnimationMixer.d.ts.map +1 -0
  132. package/dist/threejs-compatibility/AnimationMixer.js +24 -0
  133. package/dist/threejs-compatibility/AnimationMixer.js.map +1 -0
  134. package/dist/threejs-compatibility/MorphTargetMixer.d.ts +10 -0
  135. package/dist/threejs-compatibility/MorphTargetMixer.d.ts.map +1 -0
  136. package/dist/threejs-compatibility/MorphTargetMixer.js +10 -0
  137. package/dist/threejs-compatibility/MorphTargetMixer.js.map +1 -0
  138. package/dist/threejs-compatibility/Skeleton.d.ts +10 -0
  139. package/dist/threejs-compatibility/Skeleton.d.ts.map +1 -0
  140. package/dist/threejs-compatibility/Skeleton.js +8 -0
  141. package/dist/threejs-compatibility/Skeleton.js.map +1 -0
  142. package/dist/threejs-compatibility/SkinnedMesh.d.ts +11 -0
  143. package/dist/threejs-compatibility/SkinnedMesh.d.ts.map +1 -0
  144. package/dist/threejs-compatibility/SkinnedMesh.js +16 -0
  145. package/dist/threejs-compatibility/SkinnedMesh.js.map +1 -0
  146. package/dist/threejs-compatibility/index.d.ts +12 -0
  147. package/dist/threejs-compatibility/index.d.ts.map +1 -0
  148. package/dist/threejs-compatibility/index.js +8 -0
  149. package/dist/threejs-compatibility/index.js.map +1 -0
  150. package/package.json +17 -0
  151. package/src/AnimationAction.ts +166 -0
  152. package/src/AnimationClip.ts +63 -0
  153. package/src/AnimationClipEvents.ts +320 -0
  154. package/src/AnimationClipRegistry.ts +280 -0
  155. package/src/AnimationController.ts +967 -0
  156. package/src/AnimationEvents.ts +34 -0
  157. package/src/AnimationLayer.ts +73 -0
  158. package/src/AnimationMixer.ts +366 -0
  159. package/src/AnimationStateGraph.ts +8 -0
  160. package/src/AnimationStateMachine.ts +217 -0
  161. package/src/AnimationTrack.ts +176 -0
  162. package/src/BlendTree.ts +89 -0
  163. package/src/Bone.ts +35 -0
  164. package/src/CrowdAnimation.ts +25 -0
  165. package/src/ECSAnimationBridge.ts +30 -0
  166. package/src/HumanoidRetargeting.ts +476 -0
  167. package/src/IK.ts +107 -0
  168. package/src/Keyframe.ts +159 -0
  169. package/src/LocomotionController.ts +149 -0
  170. package/src/MotionMatchingFixtures.ts +254 -0
  171. package/src/MotionQuality.ts +156 -0
  172. package/src/Retargeting.ts +29 -0
  173. package/src/RootMotion.ts +124 -0
  174. package/src/SceneAnimationBridge.ts +80 -0
  175. package/src/SecondaryAnimationFixtures.ts +212 -0
  176. package/src/Skeleton.ts +34 -0
  177. package/src/Skinning.ts +18 -0
  178. package/src/browser-index.ts +55 -0
  179. package/src/index.ts +56 -0
  180. package/src/threejs-compatibility/AnimationAction.ts +27 -0
  181. package/src/threejs-compatibility/AnimationClip.ts +16 -0
  182. package/src/threejs-compatibility/AnimationDiagnostics.ts +42 -0
  183. package/src/threejs-compatibility/AnimationMixer.ts +24 -0
  184. package/src/threejs-compatibility/MorphTargetMixer.ts +16 -0
  185. package/src/threejs-compatibility/Skeleton.ts +9 -0
  186. package/src/threejs-compatibility/SkinnedMesh.ts +12 -0
  187. package/src/threejs-compatibility/index.ts +11 -0
@@ -0,0 +1,967 @@
1
+ import {
2
+ animationClipEventKey,
3
+ AnimationEventDispatcher,
4
+ sampleClipEvents,
5
+ type AnimationClipEvent,
6
+ type AnimationClipEventInvocation,
7
+ type AnimationClipEventUnsubscribe,
8
+ type AnimationPlaybackDirection
9
+ } from "./AnimationClipEvents.js";
10
+ import {
11
+ AnimationClipRegistry,
12
+ type AnimationClipDefinition,
13
+ type RegisteredAnimationClip
14
+ } from "./AnimationClipRegistry.js";
15
+
16
+ export type AnimationLoopMode = "once" | "loop" | "pingpong";
17
+ export type AnimationPlaybackStatus = "idle" | "playing" | "paused" | "stopped" | "completed";
18
+
19
+ export interface AnimationVector3 {
20
+ readonly x: number;
21
+ readonly y: number;
22
+ readonly z: number;
23
+ }
24
+
25
+ export interface AnimationQuaternion {
26
+ readonly x: number;
27
+ readonly y: number;
28
+ readonly z: number;
29
+ readonly w: number;
30
+ }
31
+
32
+ export interface AnimationPoseTransform {
33
+ readonly position?: AnimationVector3;
34
+ readonly rotation?: AnimationQuaternion;
35
+ readonly scale?: AnimationVector3;
36
+ }
37
+
38
+ export interface AnimationRootMotion {
39
+ readonly translation?: AnimationVector3;
40
+ readonly rotation?: AnimationQuaternion;
41
+ }
42
+
43
+ export interface AnimationPose {
44
+ readonly bones: Record<string, AnimationPoseTransform>;
45
+ readonly morphTargets?: Record<string, number>;
46
+ readonly rootMotion?: AnimationRootMotion;
47
+ readonly metadata?: Record<string, unknown>;
48
+ }
49
+
50
+ export interface AnimationClipPlaybackState<TClipId extends string = string> {
51
+ readonly id: string;
52
+ readonly clipId: TClipId;
53
+ readonly status: AnimationPlaybackStatus;
54
+ readonly localTime: number;
55
+ readonly previousLocalTime: number;
56
+ readonly normalizedTime: number;
57
+ readonly duration: number;
58
+ readonly speed: number;
59
+ readonly weight: number;
60
+ readonly targetWeight: number;
61
+ readonly loopMode: AnimationLoopMode;
62
+ readonly loopCount: number;
63
+ readonly direction: AnimationPlaybackDirection;
64
+ readonly completed: boolean;
65
+ readonly fade?: AnimationFadeState;
66
+ }
67
+
68
+ export interface AnimationFadeState {
69
+ readonly kind: "in" | "out";
70
+ readonly elapsed: number;
71
+ readonly duration: number;
72
+ readonly fromWeight: number;
73
+ readonly toWeight: number;
74
+ }
75
+
76
+ export interface AnimationPlayOptions<TClipId extends string = string> {
77
+ readonly id?: string;
78
+ readonly restart?: boolean;
79
+ readonly exclusive?: boolean;
80
+ readonly loop?: AnimationLoopMode | boolean;
81
+ readonly speed?: number;
82
+ readonly weight?: number;
83
+ readonly startTime?: number;
84
+ readonly fadeIn?: number;
85
+ readonly paused?: boolean;
86
+ readonly direction?: AnimationPlaybackDirection;
87
+ readonly metadata?: Record<string, unknown>;
88
+ readonly fallbackClipId?: TClipId;
89
+ }
90
+
91
+ export interface AnimationStopOptions {
92
+ readonly fadeOut?: number;
93
+ }
94
+
95
+ export interface AnimationCrossfadeOptions<TClipId extends string = string> extends AnimationPlayOptions<TClipId> {
96
+ readonly fromClipId?: TClipId;
97
+ }
98
+
99
+ export type AnimationCrossFadeOptions<TClipId extends string = string> = AnimationCrossfadeOptions<TClipId>;
100
+
101
+ export interface AnimationScrubOptions<TClipId extends string = string> {
102
+ readonly clipId?: TClipId;
103
+ readonly emitEvents?: boolean;
104
+ readonly play?: boolean;
105
+ readonly createIfMissing?: boolean;
106
+ }
107
+
108
+ export interface AnimationPoseCaptureOptions<TClipId extends string = string> {
109
+ readonly clipId?: TClipId;
110
+ readonly time?: number;
111
+ readonly emitEvent?: boolean;
112
+ }
113
+
114
+ export interface AnimationPoseSnapshot<TClipId extends string = string> {
115
+ readonly time: number;
116
+ readonly pose: AnimationPose;
117
+ readonly clips: readonly AnimationClipPlaybackState<TClipId>[];
118
+ }
119
+
120
+ export interface AnimationControllerSnapshot<TClipId extends string = string> {
121
+ readonly time: number;
122
+ readonly activeClipId?: TClipId;
123
+ readonly clips: readonly AnimationClipPlaybackState<TClipId>[];
124
+ }
125
+
126
+ export interface AnimationClipLoopEvent<TClipId extends string = string> {
127
+ readonly clipId: TClipId;
128
+ readonly playbackId: string;
129
+ readonly loopCount: number;
130
+ readonly loopsPassed: number;
131
+ }
132
+
133
+ export interface AnimationCrossfadeEvent<TClipId extends string = string> {
134
+ readonly fromClipIds: readonly TClipId[];
135
+ readonly toClipId: TClipId;
136
+ readonly duration: number;
137
+ }
138
+
139
+ export type AnimationCrossFadeEvent<TClipId extends string = string> = AnimationCrossfadeEvent<TClipId>;
140
+
141
+ export interface AnimationScrubEvent<TClipId extends string = string> {
142
+ readonly clipId: TClipId;
143
+ readonly playbackId: string;
144
+ readonly fromTime: number;
145
+ readonly toTime: number;
146
+ }
147
+
148
+ export interface AnimationControllerEventMap<
149
+ TClipId extends string = string,
150
+ TEvent extends AnimationClipEvent = AnimationClipEvent
151
+ > {
152
+ readonly start: AnimationClipPlaybackState<TClipId>;
153
+ readonly end: AnimationClipPlaybackState<TClipId>;
154
+ readonly loop: AnimationClipLoopEvent<TClipId>;
155
+ readonly event: AnimationClipEventInvocation<TEvent, TClipId>;
156
+ readonly crossFadeStart: AnimationCrossFadeEvent<TClipId>;
157
+ readonly crossFadeEnd: AnimationCrossFadeEvent<TClipId>;
158
+ readonly crossfadeStart: AnimationCrossfadeEvent<TClipId>;
159
+ readonly crossfadeEnd: AnimationCrossfadeEvent<TClipId>;
160
+ readonly scrub: AnimationScrubEvent<TClipId>;
161
+ readonly poseCaptured: AnimationPoseSnapshot<TClipId>;
162
+ readonly stateChanged: AnimationControllerSnapshot<TClipId>;
163
+ }
164
+
165
+ interface InternalPlaybackState<TClipId extends string, TEvent extends AnimationClipEvent>
166
+ extends AnimationClipPlaybackState<TClipId> {
167
+ clip: RegisteredAnimationClip<TClipId, TEvent, AnimationPose>;
168
+ metadata?: Record<string, unknown>;
169
+ onceEvents: Set<string>;
170
+ playhead: number;
171
+ inputDirection: AnimationPlaybackDirection;
172
+ status: AnimationPlaybackStatus;
173
+ localTime: number;
174
+ previousLocalTime: number;
175
+ normalizedTime: number;
176
+ speed: number;
177
+ weight: number;
178
+ targetWeight: number;
179
+ loopCount: number;
180
+ direction: AnimationPlaybackDirection;
181
+ completed: boolean;
182
+ fade?: AnimationFadeState;
183
+ }
184
+
185
+ export class AnimationController<
186
+ TClipId extends string = string,
187
+ TEvent extends AnimationClipEvent = AnimationClipEvent
188
+ > {
189
+ readonly registry: AnimationClipRegistry<TClipId, TEvent, AnimationPose>;
190
+
191
+ private readonly dispatcher = new AnimationEventDispatcher<AnimationControllerEventMap<TClipId, TEvent>>();
192
+ private readonly states = new Map<string, InternalPlaybackState<TClipId, TEvent>>();
193
+ private clockTime = 0;
194
+ private nextPlaybackIndex = 0;
195
+ private pendingCrossfade?: AnimationCrossfadeEvent<TClipId>;
196
+
197
+ constructor(registry: AnimationClipRegistry<TClipId, TEvent, AnimationPose> = new AnimationClipRegistry()) {
198
+ this.registry = registry;
199
+ }
200
+
201
+ on<K extends Extract<keyof AnimationControllerEventMap<TClipId, TEvent>, string>>(
202
+ type: K,
203
+ listener: (payload: AnimationControllerEventMap<TClipId, TEvent>[K]) => void
204
+ ): AnimationClipEventUnsubscribe {
205
+ return this.dispatcher.on(type, listener);
206
+ }
207
+
208
+ once<K extends Extract<keyof AnimationControllerEventMap<TClipId, TEvent>, string>>(
209
+ type: K,
210
+ listener: (payload: AnimationControllerEventMap<TClipId, TEvent>[K]) => void
211
+ ): AnimationClipEventUnsubscribe {
212
+ return this.dispatcher.once(type, listener);
213
+ }
214
+
215
+ registerClip(
216
+ definition: AnimationClipDefinition<TClipId, TEvent, AnimationPose>
217
+ ): RegisteredAnimationClip<TClipId, TEvent, AnimationPose> {
218
+ return this.registry.register(definition);
219
+ }
220
+
221
+ play(clipId: TClipId, options: AnimationPlayOptions<TClipId> = {}): AnimationClipPlaybackState<TClipId> {
222
+ const resolvedClipId = this.registry.has(clipId) ? clipId : options.fallbackClipId;
223
+ if (!resolvedClipId) {
224
+ throw new Error(`Animation clip "${clipId}" is not registered.`);
225
+ }
226
+
227
+ const existing = this.findStateByClip(resolvedClipId);
228
+ if (existing && !options.restart) {
229
+ existing.status = options.paused ? "paused" : "playing";
230
+ existing.speed = sanitizeSpeed(options.speed ?? existing.speed);
231
+ existing.targetWeight = sanitizeWeight(options.weight ?? existing.targetWeight);
232
+ existing.inputDirection = options.direction ?? existing.inputDirection;
233
+ this.emitStateChanged();
234
+ return cloneState(existing);
235
+ }
236
+
237
+ if (options.exclusive ?? true) {
238
+ this.states.clear();
239
+ }
240
+
241
+ const clip = this.registry.require(resolvedClipId);
242
+ const playback = this.createPlaybackState(clip, options);
243
+ this.states.set(playback.id, playback);
244
+ this.dispatcher.emit("start", cloneState(playback));
245
+ this.emitStateChanged();
246
+ return cloneState(playback);
247
+ }
248
+
249
+ pause(clipId?: TClipId): void {
250
+ for (const state of this.selectStates(clipId)) {
251
+ if (state.status === "playing") {
252
+ state.status = "paused";
253
+ }
254
+ }
255
+
256
+ this.emitStateChanged();
257
+ }
258
+
259
+ resume(clipId?: TClipId): void {
260
+ for (const state of this.selectStates(clipId)) {
261
+ if (state.status === "paused") {
262
+ state.status = "playing";
263
+ }
264
+ }
265
+
266
+ this.emitStateChanged();
267
+ }
268
+
269
+ restart(clipId?: TClipId, options: AnimationPlayOptions<TClipId> = {}): AnimationClipPlaybackState<TClipId> {
270
+ const resolvedClipId = clipId ?? this.activeClipId();
271
+ if (!resolvedClipId) {
272
+ throw new Error("Cannot restart animation without an active clip or clipId.");
273
+ }
274
+
275
+ return this.play(resolvedClipId, {
276
+ ...options,
277
+ restart: true
278
+ });
279
+ }
280
+
281
+ stop(clipId?: TClipId, options: AnimationStopOptions = {}): void {
282
+ const targets = this.selectStates(clipId);
283
+ const fadeOut = sanitizeDuration(options.fadeOut ?? 0);
284
+
285
+ for (const state of targets) {
286
+ if (fadeOut > 0) {
287
+ state.fade = {
288
+ kind: "out",
289
+ elapsed: 0,
290
+ duration: fadeOut,
291
+ fromWeight: state.weight,
292
+ toWeight: 0
293
+ };
294
+ state.targetWeight = 0;
295
+ } else {
296
+ this.states.delete(state.id);
297
+ state.status = "stopped";
298
+ this.dispatcher.emit("end", cloneState(state));
299
+ }
300
+ }
301
+
302
+ this.emitStateChanged();
303
+ }
304
+
305
+ crossFade(
306
+ toClipId: TClipId,
307
+ duration: number,
308
+ options: AnimationCrossFadeOptions<TClipId> = {}
309
+ ): AnimationClipPlaybackState<TClipId> {
310
+ return this.crossfade(toClipId, duration, options);
311
+ }
312
+
313
+ crossfade(
314
+ toClipId: TClipId,
315
+ duration: number,
316
+ options: AnimationCrossfadeOptions<TClipId> = {}
317
+ ): AnimationClipPlaybackState<TClipId> {
318
+ const fadeDuration = sanitizeDuration(duration);
319
+ const fromStates = options.fromClipId ? this.selectStates(options.fromClipId) : this.getInternalStates();
320
+ const fromClipIds = fromStates.map((state) => state.clipId);
321
+ const event: AnimationCrossfadeEvent<TClipId> = {
322
+ fromClipIds,
323
+ toClipId,
324
+ duration: fadeDuration
325
+ };
326
+
327
+ this.dispatcher.emit("crossFadeStart", event);
328
+ this.dispatcher.emit("crossfadeStart", event);
329
+ this.pendingCrossfade = event;
330
+
331
+ for (const state of fromStates) {
332
+ state.fade = {
333
+ kind: "out",
334
+ elapsed: 0,
335
+ duration: fadeDuration,
336
+ fromWeight: state.weight,
337
+ toWeight: 0
338
+ };
339
+ state.targetWeight = 0;
340
+ }
341
+
342
+ const next = this.play(toClipId, {
343
+ ...options,
344
+ exclusive: false,
345
+ restart: true,
346
+ fadeIn: fadeDuration,
347
+ weight: options.weight ?? 1
348
+ });
349
+
350
+ if (fadeDuration === 0) {
351
+ this.finishCrossfade();
352
+ }
353
+
354
+ return next;
355
+ }
356
+
357
+ update(dt: number): AnimationControllerSnapshot<TClipId> {
358
+ if (!Number.isFinite(dt) || dt === 0) {
359
+ return this.snapshot();
360
+ }
361
+
362
+ this.clockTime += dt;
363
+ const endedStates: InternalPlaybackState<TClipId, TEvent>[] = [];
364
+
365
+ for (const state of this.getInternalStates()) {
366
+ this.updateFade(state, Math.abs(dt));
367
+
368
+ if (state.status !== "playing") {
369
+ continue;
370
+ }
371
+
372
+ const advance = advanceState(state, dt);
373
+ this.emitSampledEvents(state);
374
+
375
+ if (advance.loopsPassed > 0) {
376
+ this.dispatcher.emit("loop", {
377
+ clipId: state.clipId,
378
+ playbackId: state.id,
379
+ loopCount: state.loopCount,
380
+ loopsPassed: advance.loopsPassed
381
+ });
382
+ }
383
+
384
+ if (advance.completed) {
385
+ state.status = "completed";
386
+ state.completed = true;
387
+ endedStates.push(state);
388
+ }
389
+ }
390
+
391
+ for (const state of endedStates) {
392
+ this.dispatcher.emit("end", cloneState(state));
393
+ }
394
+
395
+ this.removeFinishedFadeOuts();
396
+ this.finishCrossfadeIfReady();
397
+ this.emitStateChanged();
398
+ return this.snapshot();
399
+ }
400
+
401
+ scrub(time: number, options?: AnimationScrubOptions<TClipId>): AnimationPoseSnapshot<TClipId>;
402
+ scrub(clipId: TClipId, time: number, options?: AnimationScrubOptions<TClipId>): AnimationPoseSnapshot<TClipId>;
403
+ scrub(
404
+ clipOrTime: TClipId | number,
405
+ timeOrOptions: number | AnimationScrubOptions<TClipId> = {},
406
+ maybeOptions: AnimationScrubOptions<TClipId> = {}
407
+ ): AnimationPoseSnapshot<TClipId> {
408
+ const hasClipId = typeof clipOrTime === "string";
409
+ const time = hasClipId ? Number(timeOrOptions) : clipOrTime;
410
+ const options = hasClipId ? maybeOptions : (timeOrOptions as AnimationScrubOptions<TClipId>);
411
+ const clipId = hasClipId ? clipOrTime : options.clipId ?? this.activeClipId();
412
+
413
+ if (!clipId) {
414
+ throw new Error("Cannot scrub without an active clip or clipId.");
415
+ }
416
+
417
+ let state = this.findStateByClip(clipId);
418
+ if (!state) {
419
+ if (options.createIfMissing === false) {
420
+ throw new Error(`Cannot scrub inactive animation clip "${clipId}".`);
421
+ }
422
+
423
+ this.play(clipId, {
424
+ startTime: time,
425
+ paused: true,
426
+ restart: true
427
+ });
428
+ state = this.findStateByClip(clipId);
429
+ }
430
+
431
+ if (!state) {
432
+ throw new Error(`Cannot scrub animation clip "${clipId}".`);
433
+ }
434
+
435
+ const previousTime = state.localTime;
436
+ state.previousLocalTime = previousTime;
437
+ state.localTime = normalizeStateTime(time, state.duration, state.loopMode);
438
+ state.playhead = state.localTime;
439
+ state.normalizedTime = state.duration > 0 ? state.localTime / state.duration : 0;
440
+ state.completed = false;
441
+ state.status = options.play ? "playing" : "paused";
442
+
443
+ if (options.emitEvents) {
444
+ this.emitSampledEvents(state);
445
+ }
446
+
447
+ this.dispatcher.emit("scrub", {
448
+ clipId,
449
+ playbackId: state.id,
450
+ fromTime: previousTime,
451
+ toTime: state.localTime
452
+ });
453
+ this.emitStateChanged();
454
+ return this.capturePose({ emitEvent: false });
455
+ }
456
+
457
+ capturePose(options: AnimationPoseCaptureOptions<TClipId> = {}): AnimationPoseSnapshot<TClipId> {
458
+ const states = options.clipId
459
+ ? this.selectStates(options.clipId)
460
+ : this.getInternalStates().filter((state) => state.weight > 0);
461
+
462
+ const pose = options.time !== undefined && options.clipId
463
+ ? this.sampleSinglePose(this.registry.require(options.clipId), options.time)
464
+ : this.blendStates(states);
465
+
466
+ const snapshot: AnimationPoseSnapshot<TClipId> = {
467
+ time: this.clockTime,
468
+ pose,
469
+ clips: states.map(cloneState)
470
+ };
471
+
472
+ if (options.emitEvent ?? true) {
473
+ this.dispatcher.emit("poseCaptured", snapshot);
474
+ }
475
+
476
+ return snapshot;
477
+ }
478
+
479
+ state(clipId?: TClipId): AnimationClipPlaybackState<TClipId> | undefined {
480
+ const state = clipId ? this.findStateByClip(clipId) : this.primaryState();
481
+ return state ? cloneState(state) : undefined;
482
+ }
483
+
484
+ snapshot(): AnimationControllerSnapshot<TClipId> {
485
+ return {
486
+ time: this.clockTime,
487
+ activeClipId: this.activeClipId(),
488
+ clips: this.getInternalStates().map(cloneState)
489
+ };
490
+ }
491
+
492
+ dispose(): void {
493
+ this.states.clear();
494
+ this.dispatcher.clear();
495
+ this.pendingCrossfade = undefined;
496
+ }
497
+
498
+ private createPlaybackState(
499
+ clip: RegisteredAnimationClip<TClipId, TEvent, AnimationPose>,
500
+ options: AnimationPlayOptions<TClipId>
501
+ ): InternalPlaybackState<TClipId, TEvent> {
502
+ const targetWeight = sanitizeWeight(options.weight ?? 1);
503
+ const fadeIn = sanitizeDuration(options.fadeIn ?? 0);
504
+ const localTime = normalizeStateTime(options.startTime ?? 0, clip.duration, normalizeLoopMode(options.loop, clip.loop));
505
+ const id = options.id ?? `${clip.id}:${this.nextPlaybackIndex}`;
506
+ this.nextPlaybackIndex += 1;
507
+
508
+ return {
509
+ id,
510
+ clip,
511
+ clipId: clip.id,
512
+ metadata: options.metadata,
513
+ status: options.paused ? "paused" : "playing",
514
+ localTime,
515
+ previousLocalTime: localTime,
516
+ normalizedTime: clip.duration > 0 ? localTime / clip.duration : 0,
517
+ duration: clip.duration,
518
+ speed: sanitizeSpeed(options.speed ?? 1),
519
+ weight: fadeIn > 0 ? 0 : targetWeight,
520
+ targetWeight,
521
+ loopMode: normalizeLoopMode(options.loop, clip.loop),
522
+ loopCount: 0,
523
+ direction: options.direction ?? 1,
524
+ inputDirection: options.direction ?? 1,
525
+ completed: false,
526
+ playhead: localTime,
527
+ onceEvents: new Set<string>(),
528
+ fade: fadeIn > 0
529
+ ? {
530
+ kind: "in",
531
+ elapsed: 0,
532
+ duration: fadeIn,
533
+ fromWeight: 0,
534
+ toWeight: targetWeight
535
+ }
536
+ : undefined
537
+ };
538
+ }
539
+
540
+ private emitSampledEvents(state: InternalPlaybackState<TClipId, TEvent>): void {
541
+ const invocations = sampleClipEvents(
542
+ {
543
+ id: state.clipId,
544
+ duration: state.duration,
545
+ events: state.clip.events
546
+ },
547
+ {
548
+ from: state.previousLocalTime,
549
+ to: state.localTime,
550
+ duration: state.duration,
551
+ loop: state.loopMode !== "once",
552
+ direction: state.direction,
553
+ loopCount: state.loopCount,
554
+ playbackTime: this.clockTime
555
+ }
556
+ );
557
+
558
+ for (const invocation of invocations) {
559
+ if (invocation.event.once) {
560
+ const key = animationClipEventKey(invocation.clipId, invocation.event);
561
+ if (state.onceEvents.has(key)) continue;
562
+ state.onceEvents.add(key);
563
+ }
564
+
565
+ this.dispatcher.emit("event", invocation);
566
+ }
567
+ }
568
+
569
+ private updateFade(state: InternalPlaybackState<TClipId, TEvent>, dt: number): void {
570
+ if (!state.fade) return;
571
+
572
+ if (state.fade.duration === 0) {
573
+ state.weight = state.fade.toWeight;
574
+ state.fade = undefined;
575
+ return;
576
+ }
577
+
578
+ const elapsed = Math.min(state.fade.duration, state.fade.elapsed + dt);
579
+ const alpha = elapsed / state.fade.duration;
580
+ state.weight = lerp(state.fade.fromWeight, state.fade.toWeight, alpha);
581
+ state.fade = elapsed >= state.fade.duration
582
+ ? undefined
583
+ : {
584
+ ...state.fade,
585
+ elapsed
586
+ };
587
+ }
588
+
589
+ private removeFinishedFadeOuts(): void {
590
+ for (const state of this.getInternalStates()) {
591
+ if (state.fade || state.weight > 0 || state.targetWeight > 0) continue;
592
+ this.states.delete(state.id);
593
+ state.status = "stopped";
594
+ this.dispatcher.emit("end", cloneState(state));
595
+ }
596
+ }
597
+
598
+ private finishCrossfadeIfReady(): void {
599
+ if (!this.pendingCrossfade) return;
600
+ const hasActiveFade = this.getInternalStates().some((state) => state.fade);
601
+ if (!hasActiveFade) {
602
+ this.finishCrossfade();
603
+ }
604
+ }
605
+
606
+ private finishCrossfade(): void {
607
+ if (!this.pendingCrossfade) return;
608
+ this.dispatcher.emit("crossFadeEnd", this.pendingCrossfade);
609
+ this.dispatcher.emit("crossfadeEnd", this.pendingCrossfade);
610
+ this.pendingCrossfade = undefined;
611
+ }
612
+
613
+ private blendStates(states: readonly InternalPlaybackState<TClipId, TEvent>[]): AnimationPose {
614
+ const weightedStates = states.filter((state) => state.weight > 0);
615
+ if (weightedStates.length === 0) {
616
+ return emptyPose();
617
+ }
618
+
619
+ const accumulators = new Map<string, BoneAccumulator>();
620
+ const morphTargets: Record<string, number> = {};
621
+ let totalWeight = 0;
622
+
623
+ for (const state of weightedStates) {
624
+ const pose = this.sampleSinglePose(state.clip, state.localTime, state);
625
+ const weight = state.weight;
626
+ totalWeight += weight;
627
+
628
+ for (const [boneName, transform] of Object.entries(pose.bones)) {
629
+ const accumulator = accumulators.get(boneName) ?? createBoneAccumulator();
630
+ accumulateTransform(accumulator, transform, weight);
631
+ accumulators.set(boneName, accumulator);
632
+ }
633
+
634
+ for (const [name, value] of Object.entries(pose.morphTargets ?? {})) {
635
+ morphTargets[name] = (morphTargets[name] ?? 0) + value * weight;
636
+ }
637
+ }
638
+
639
+ const bones: Record<string, AnimationPoseTransform> = {};
640
+ for (const [boneName, accumulator] of accumulators) {
641
+ bones[boneName] = resolveAccumulator(accumulator);
642
+ }
643
+
644
+ if (totalWeight > 0) {
645
+ for (const name of Object.keys(morphTargets)) {
646
+ morphTargets[name] /= totalWeight;
647
+ }
648
+ }
649
+
650
+ return {
651
+ bones,
652
+ morphTargets
653
+ };
654
+ }
655
+
656
+ private sampleSinglePose(
657
+ clip: RegisteredAnimationClip<TClipId, TEvent, AnimationPose>,
658
+ time: number,
659
+ state?: InternalPlaybackState<TClipId, TEvent>
660
+ ): AnimationPose {
661
+ if (!clip.sample) {
662
+ return emptyPose();
663
+ }
664
+
665
+ const localTime = normalizeStateTime(time, clip.duration, clip.loop ? "loop" : "once");
666
+ const pose = clip.sample({
667
+ clip,
668
+ time: localTime,
669
+ normalizedTime: clip.duration > 0 ? localTime / clip.duration : 0,
670
+ playbackState: state ? cloneState(state) : undefined
671
+ });
672
+
673
+ return isAnimationPose(pose) ? pose : emptyPose();
674
+ }
675
+
676
+ private selectStates(clipId?: TClipId): InternalPlaybackState<TClipId, TEvent>[] {
677
+ const states = this.getInternalStates();
678
+ if (!clipId) return states;
679
+ return states.filter((state) => state.clipId === clipId);
680
+ }
681
+
682
+ private getInternalStates(): InternalPlaybackState<TClipId, TEvent>[] {
683
+ return [...this.states.values()];
684
+ }
685
+
686
+ private findStateByClip(clipId: TClipId): InternalPlaybackState<TClipId, TEvent> | undefined {
687
+ return this.getInternalStates().find((state) => state.clipId === clipId);
688
+ }
689
+
690
+ private primaryState(): InternalPlaybackState<TClipId, TEvent> | undefined {
691
+ return this.getInternalStates().sort((a, b) => b.weight - a.weight)[0];
692
+ }
693
+
694
+ private activeClipId(): TClipId | undefined {
695
+ return this.primaryState()?.clipId;
696
+ }
697
+
698
+ private emitStateChanged(): void {
699
+ this.dispatcher.emit("stateChanged", this.snapshot());
700
+ }
701
+ }
702
+
703
+ interface AdvanceResult {
704
+ readonly completed: boolean;
705
+ readonly loopsPassed: number;
706
+ }
707
+
708
+ interface BoneAccumulator {
709
+ position?: MutableVector3;
710
+ rotation?: MutableQuaternion;
711
+ scale?: MutableVector3;
712
+ weight: number;
713
+ }
714
+
715
+ interface MutableVector3 {
716
+ x: number;
717
+ y: number;
718
+ z: number;
719
+ }
720
+
721
+ interface MutableQuaternion {
722
+ x: number;
723
+ y: number;
724
+ z: number;
725
+ w: number;
726
+ }
727
+
728
+ function advanceState<TClipId extends string, TEvent extends AnimationClipEvent>(
729
+ state: InternalPlaybackState<TClipId, TEvent>,
730
+ dt: number
731
+ ): AdvanceResult {
732
+ const previousPlayhead = state.playhead;
733
+ const delta = dt * state.speed * state.inputDirection;
734
+ state.previousLocalTime = state.localTime;
735
+ state.playhead += delta;
736
+
737
+ if (state.duration === 0) {
738
+ state.localTime = 0;
739
+ state.normalizedTime = 0;
740
+ state.direction = state.inputDirection;
741
+ return {
742
+ completed: state.loopMode === "once",
743
+ loopsPassed: 0
744
+ };
745
+ }
746
+
747
+ const loopsPassed = state.loopMode === "once"
748
+ ? 0
749
+ : countBoundariesBetween(previousPlayhead, state.playhead, state.duration);
750
+
751
+ if (state.loopMode === "once") {
752
+ state.localTime = clamp(state.playhead, 0, state.duration);
753
+ state.direction = state.inputDirection;
754
+ state.normalizedTime = state.localTime / state.duration;
755
+ return {
756
+ completed: state.playhead <= 0 || state.playhead >= state.duration,
757
+ loopsPassed: 0
758
+ };
759
+ }
760
+
761
+ if (state.loopMode === "loop") {
762
+ state.localTime = positiveModulo(state.playhead, state.duration);
763
+ state.direction = state.inputDirection;
764
+ } else {
765
+ const previousLocalTime = state.localTime;
766
+ state.localTime = pingPongTime(state.playhead, state.duration);
767
+ state.direction = state.localTime >= previousLocalTime ? 1 : -1;
768
+ }
769
+
770
+ state.loopCount += loopsPassed;
771
+ state.normalizedTime = state.localTime / state.duration;
772
+
773
+ return {
774
+ completed: false,
775
+ loopsPassed
776
+ };
777
+ }
778
+
779
+ function normalizeLoopMode(loop: AnimationLoopMode | boolean | undefined, clipLoops: boolean): AnimationLoopMode {
780
+ if (loop === true) return "loop";
781
+ if (loop === false) return "once";
782
+ if (loop) return loop;
783
+ return clipLoops ? "loop" : "once";
784
+ }
785
+
786
+ function normalizeStateTime(time: number, duration: number, loopMode: AnimationLoopMode): number {
787
+ if (!Number.isFinite(time) || duration <= 0) return 0;
788
+ if (loopMode === "once") return clamp(time, 0, duration);
789
+ if (loopMode === "pingpong") return pingPongTime(time, duration);
790
+ return positiveModulo(time, duration);
791
+ }
792
+
793
+ function cloneState<TClipId extends string>(
794
+ state: AnimationClipPlaybackState<TClipId>
795
+ ): AnimationClipPlaybackState<TClipId> {
796
+ return {
797
+ id: state.id,
798
+ clipId: state.clipId,
799
+ status: state.status,
800
+ localTime: state.localTime,
801
+ previousLocalTime: state.previousLocalTime,
802
+ normalizedTime: state.normalizedTime,
803
+ duration: state.duration,
804
+ speed: state.speed,
805
+ weight: state.weight,
806
+ targetWeight: state.targetWeight,
807
+ loopMode: state.loopMode,
808
+ loopCount: state.loopCount,
809
+ direction: state.direction,
810
+ completed: state.completed,
811
+ fade: state.fade
812
+ ? {
813
+ ...state.fade
814
+ }
815
+ : undefined
816
+ };
817
+ }
818
+
819
+ function emptyPose(): AnimationPose {
820
+ return {
821
+ bones: {}
822
+ };
823
+ }
824
+
825
+ function isAnimationPose(value: unknown): value is AnimationPose {
826
+ if (!value || typeof value !== "object") return false;
827
+ return "bones" in value && typeof (value as AnimationPose).bones === "object";
828
+ }
829
+
830
+ function createBoneAccumulator(): BoneAccumulator {
831
+ return {
832
+ weight: 0
833
+ };
834
+ }
835
+
836
+ function accumulateTransform(
837
+ accumulator: BoneAccumulator,
838
+ transform: AnimationPoseTransform,
839
+ weight: number
840
+ ): void {
841
+ accumulator.weight += weight;
842
+
843
+ if (transform.position) {
844
+ accumulator.position = addWeightedVector(accumulator.position, transform.position, weight);
845
+ }
846
+
847
+ if (transform.rotation) {
848
+ accumulator.rotation = addWeightedQuaternion(accumulator.rotation, transform.rotation, weight);
849
+ }
850
+
851
+ if (transform.scale) {
852
+ accumulator.scale = addWeightedVector(accumulator.scale, transform.scale, weight);
853
+ }
854
+ }
855
+
856
+ function resolveAccumulator(accumulator: BoneAccumulator): AnimationPoseTransform {
857
+ const divisor = accumulator.weight > 0 ? accumulator.weight : 1;
858
+ return {
859
+ position: accumulator.position ? divideVector(accumulator.position, divisor) : undefined,
860
+ rotation: accumulator.rotation ? normalizeQuaternion(divideQuaternion(accumulator.rotation, divisor)) : undefined,
861
+ scale: accumulator.scale ? divideVector(accumulator.scale, divisor) : undefined
862
+ };
863
+ }
864
+
865
+ function addWeightedVector(
866
+ current: MutableVector3 | undefined,
867
+ value: AnimationVector3,
868
+ weight: number
869
+ ): MutableVector3 {
870
+ return {
871
+ x: (current?.x ?? 0) + value.x * weight,
872
+ y: (current?.y ?? 0) + value.y * weight,
873
+ z: (current?.z ?? 0) + value.z * weight
874
+ };
875
+ }
876
+
877
+ function addWeightedQuaternion(
878
+ current: MutableQuaternion | undefined,
879
+ value: AnimationQuaternion,
880
+ weight: number
881
+ ): MutableQuaternion {
882
+ return {
883
+ x: (current?.x ?? 0) + value.x * weight,
884
+ y: (current?.y ?? 0) + value.y * weight,
885
+ z: (current?.z ?? 0) + value.z * weight,
886
+ w: (current?.w ?? 0) + value.w * weight
887
+ };
888
+ }
889
+
890
+ function divideVector(value: MutableVector3, divisor: number): AnimationVector3 {
891
+ return {
892
+ x: value.x / divisor,
893
+ y: value.y / divisor,
894
+ z: value.z / divisor
895
+ };
896
+ }
897
+
898
+ function divideQuaternion(value: MutableQuaternion, divisor: number): AnimationQuaternion {
899
+ return {
900
+ x: value.x / divisor,
901
+ y: value.y / divisor,
902
+ z: value.z / divisor,
903
+ w: value.w / divisor
904
+ };
905
+ }
906
+
907
+ function normalizeQuaternion(value: AnimationQuaternion): AnimationQuaternion {
908
+ const length = Math.hypot(value.x, value.y, value.z, value.w);
909
+ if (length === 0) {
910
+ return {
911
+ x: 0,
912
+ y: 0,
913
+ z: 0,
914
+ w: 1
915
+ };
916
+ }
917
+
918
+ return {
919
+ x: value.x / length,
920
+ y: value.y / length,
921
+ z: value.z / length,
922
+ w: value.w / length
923
+ };
924
+ }
925
+
926
+ function countBoundariesBetween(from: number, to: number, interval: number): number {
927
+ if (interval <= 0 || from === to) return 0;
928
+ const low = Math.min(from, to);
929
+ const high = Math.max(from, to);
930
+ const first = Math.floor(low / interval) + 1;
931
+ const last = Math.floor(high / interval);
932
+ return Math.max(0, last - first + 1);
933
+ }
934
+
935
+ function pingPongTime(time: number, duration: number): number {
936
+ if (duration <= 0) return 0;
937
+ const period = duration * 2;
938
+ const wrapped = positiveModulo(time, period);
939
+ return wrapped <= duration ? wrapped : period - wrapped;
940
+ }
941
+
942
+ function sanitizeSpeed(speed: number): number {
943
+ return Number.isFinite(speed) ? speed : 1;
944
+ }
945
+
946
+ function sanitizeWeight(weight: number): number {
947
+ if (!Number.isFinite(weight)) return 1;
948
+ return Math.max(0, weight);
949
+ }
950
+
951
+ function sanitizeDuration(duration: number): number {
952
+ if (!Number.isFinite(duration) || duration < 0) return 0;
953
+ return duration;
954
+ }
955
+
956
+ function lerp(from: number, to: number, alpha: number): number {
957
+ return from + (to - from) * clamp(alpha, 0, 1);
958
+ }
959
+
960
+ function positiveModulo(value: number, divisor: number): number {
961
+ if (divisor <= 0) return 0;
962
+ return ((value % divisor) + divisor) % divisor;
963
+ }
964
+
965
+ function clamp(value: number, min: number, max: number): number {
966
+ return Math.min(max, Math.max(min, value));
967
+ }