@zephyr3d/scene 0.9.9 → 0.9.10

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.
@@ -1,4 +1,4 @@
1
- import { Disposable, weightedAverage, Quaternion, Interpolator, Vector3 } from '@zephyr3d/base';
1
+ import { makeObservable, Disposable, weightedAverage, Quaternion, Observable, Interpolator, Vector3 } from '@zephyr3d/base';
2
2
  import { AnimationClip } from './animation.js';
3
3
  import { NodeRotationTrack } from './rotationtrack.js';
4
4
  import { NodeEulerRotationTrack } from './eulerrotationtrack.js';
@@ -7,6 +7,224 @@ import { NodeScaleTrack } from './scaletrack.js';
7
7
  import { HumanoidBodyRig } from './skeleton.js';
8
8
  import { createSkeletalMaskedAnimationClip } from './animationmask.js';
9
9
 
10
+ let nextAnimationPlaybackId = 1;
11
+ /**
12
+ * Runtime handle for one playback of an AnimationClip.
13
+ * @public
14
+ */ class AnimationPlayback extends Observable {
15
+ /** @internal */ _animationSet;
16
+ /** @internal */ _clip;
17
+ /** @internal */ _options;
18
+ /** @internal */ _id;
19
+ /** @internal */ _frameWaiters;
20
+ /** @internal */ _state;
21
+ /** @internal */ _time;
22
+ /** @internal */ _weight;
23
+ /** @internal */ _speedRatio;
24
+ /** @internal */ _layer;
25
+ /** @internal */ _priority;
26
+ /** @internal */ _interruptible;
27
+ constructor(animationSet, clip, options){
28
+ super();
29
+ this._animationSet = animationSet;
30
+ this._clip = clip;
31
+ this._options = {
32
+ ...options ?? {}
33
+ };
34
+ this._id = this._options.id ?? `animation-playback-${nextAnimationPlaybackId++}`;
35
+ this._state = 'scheduled';
36
+ this._speedRatio = this._options.speedRatio ?? 1;
37
+ this._weight = this._options.weight ?? clip.weight ?? 1;
38
+ this._time = this._speedRatio < 0 ? clip.timeDuration : 0;
39
+ this._layer = this._options.layer ?? 'default';
40
+ this._priority = this._options.priority ?? 0;
41
+ this._interruptible = this._options.interruptible ?? true;
42
+ this._frameWaiters = new Map();
43
+ }
44
+ get id() {
45
+ return this._id;
46
+ }
47
+ get animationSet() {
48
+ return this._animationSet;
49
+ }
50
+ get clip() {
51
+ return this._clip;
52
+ }
53
+ get state() {
54
+ return this._state;
55
+ }
56
+ get time() {
57
+ return this._time;
58
+ }
59
+ set time(value) {
60
+ this.seek(value);
61
+ }
62
+ get normalizedTime() {
63
+ return this._clip.timeDuration > 0 ? this._time / this._clip.timeDuration : 0;
64
+ }
65
+ set normalizedTime(value) {
66
+ this.seek(value * this._clip.timeDuration);
67
+ }
68
+ get weight() {
69
+ return this._weight;
70
+ }
71
+ set weight(value) {
72
+ this._animationSet.setPlaybackWeight(this, value);
73
+ }
74
+ get speedRatio() {
75
+ return this._speedRatio;
76
+ }
77
+ set speedRatio(value) {
78
+ this._animationSet.setPlaybackSpeedRatio(this, value);
79
+ }
80
+ get layer() {
81
+ return this._layer;
82
+ }
83
+ get priority() {
84
+ return this._priority;
85
+ }
86
+ get interruptible() {
87
+ return this._interruptible;
88
+ }
89
+ play() {
90
+ this._animationSet.startPlayback(this);
91
+ return this;
92
+ }
93
+ pause() {
94
+ this._animationSet.pausePlayback(this);
95
+ return this;
96
+ }
97
+ resume() {
98
+ this._animationSet.resumePlayback(this);
99
+ return this;
100
+ }
101
+ stop(options) {
102
+ this._animationSet.stopPlayback(this, options);
103
+ return this;
104
+ }
105
+ seek(time, options) {
106
+ this._animationSet.seekPlayback(this, time, options);
107
+ return this;
108
+ }
109
+ fadeTo(weight, duration) {
110
+ this._animationSet.fadePlaybackTo(this, weight, duration);
111
+ return this;
112
+ }
113
+ crossFadeTo(name, options) {
114
+ const duration = Math.max(options?.duration ?? 0, 0);
115
+ this.stop({
116
+ fadeOut: duration,
117
+ reason: 'interrupted'
118
+ });
119
+ return this._animationSet.play(name, {
120
+ ...options,
121
+ fadeIn: duration
122
+ });
123
+ }
124
+ waitForComplete() {
125
+ return new Promise((resolve)=>{
126
+ this.once('complete', ()=>resolve(this));
127
+ });
128
+ }
129
+ waitForMarker(idOrName) {
130
+ if (this._state === 'stopped' || this._state === 'completed') {
131
+ return Promise.resolve(undefined);
132
+ }
133
+ return new Promise((resolve)=>{
134
+ const handler = (event)=>{
135
+ if (event.marker.id === idOrName || event.marker.name === idOrName) {
136
+ this.off('marker', handler);
137
+ this.off('stop', stop);
138
+ resolve(event);
139
+ }
140
+ };
141
+ const stop = ()=>{
142
+ this.off('marker', handler);
143
+ this.off('stop', stop);
144
+ resolve(undefined);
145
+ };
146
+ this.on('marker', handler);
147
+ this.on('stop', stop);
148
+ });
149
+ }
150
+ waitForFrame(frame) {
151
+ if (this._state === 'stopped' || this._state === 'completed') {
152
+ return Promise.resolve(undefined);
153
+ }
154
+ return new Promise((resolve)=>{
155
+ const waiters = this._frameWaiters.get(frame) ?? [];
156
+ let settled = false;
157
+ let stop;
158
+ const wrappedResolve = (event)=>{
159
+ if (settled) {
160
+ return;
161
+ }
162
+ settled = true;
163
+ this.off('stop', stop);
164
+ resolve(event);
165
+ };
166
+ stop = ()=>{
167
+ const current = this._frameWaiters.get(frame);
168
+ if (current) {
169
+ this._frameWaiters.set(frame, current.filter((item)=>item !== wrappedResolve));
170
+ }
171
+ wrappedResolve(undefined);
172
+ };
173
+ waiters.push(wrappedResolve);
174
+ this._frameWaiters.set(frame, waiters);
175
+ this.on('stop', stop);
176
+ });
177
+ }
178
+ /** @internal */ _getOptions() {
179
+ return this._options;
180
+ }
181
+ /** @internal */ _setState(state) {
182
+ this._state = state;
183
+ }
184
+ /** @internal */ _setTime(time) {
185
+ this._time = time;
186
+ }
187
+ /** @internal */ _setWeight(weight) {
188
+ this._weight = weight;
189
+ }
190
+ /** @internal */ _setSpeedRatio(speedRatio) {
191
+ this._speedRatio = speedRatio;
192
+ }
193
+ /** @internal */ _getWatchedFrames() {
194
+ return [
195
+ ...this._frameWaiters.keys()
196
+ ];
197
+ }
198
+ /** @internal */ _emitStart(event) {
199
+ this.dispatchEvent('start', event);
200
+ }
201
+ /** @internal */ _emitLoop(event) {
202
+ this.dispatchEvent('loop', event);
203
+ }
204
+ /** @internal */ _emitMarker(event) {
205
+ this.dispatchEvent('marker', event);
206
+ }
207
+ /** @internal */ _emitFrame(event) {
208
+ this.dispatchEvent('frame', event);
209
+ const waiters = this._frameWaiters.get(event.frame);
210
+ if (waiters) {
211
+ this._frameWaiters.delete(event.frame);
212
+ waiters.forEach((resolve)=>resolve(event));
213
+ }
214
+ }
215
+ /** @internal */ _emitComplete(event) {
216
+ this.dispatchEvent('complete', event);
217
+ }
218
+ /** @internal */ _emitStop(event) {
219
+ this.dispatchEvent('stop', event);
220
+ }
221
+ /** @internal */ _emitPause(event) {
222
+ this.dispatchEvent('pause', event);
223
+ }
224
+ /** @internal */ _emitResume(event) {
225
+ this.dispatchEvent('resume', event);
226
+ }
227
+ }
10
228
  function cloneInterpolator(src) {
11
229
  return new Interpolator(src.mode, src.target, src.inputs instanceof Float32Array ? new Float32Array(src.inputs) : [
12
230
  ...src.inputs
@@ -388,26 +606,7 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
388
606
  dstClip.addTrack(remap.dstNode, track);
389
607
  }
390
608
  }
391
- /**
392
- * Animation set
393
- *
394
- * Manages a collection of named animation clips for a model and orchestrates:
395
- * - Playback state (time, loops, speed, weights, fade-in/out).
396
- * - Blending across multiple tracks targeting the same property via weighted averages.
397
- * - Skeleton usage and application for clips that drive skeletal animation.
398
- * - Active track registration and cleanup as clips start/stop.
399
- *
400
- * Usage:
401
- * - Create or retrieve `AnimationClip`s by name.
402
- * - Start playback with `playAnimation(name, options)`.
403
- * - Advance animation with `update(deltaSeconds)`.
404
- * - Optionally adjust weight while playing with `setAnimationWeight(name, weight)`.
405
- *
406
- * Lifetime:
407
- * - Disposing the set releases references to the model, clips, and clears active state.
408
- *
409
- * @public
410
- */ class AnimationSet extends Disposable {
609
+ class AnimationSet extends makeObservable(Disposable)() {
411
610
  /** @internal */ _model;
412
611
  /** @internal */ _animations;
413
612
  /** @internal */ _skeletons;
@@ -416,10 +615,18 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
416
615
  /** @internal */ _activeSkinBindings;
417
616
  /** @internal */ _activeRigs;
418
617
  /** @internal */ _activeAnimations;
419
- /**
420
- * Create an AnimationSet controlling the provided model.
421
- *
422
- * @param model - The SceneNode (model root) controlled by this animation set.
618
+ /**
619
+ * Callbacks driven by the same logical clock as the animations (see {@link update}).
620
+ *
621
+ * Used by higher-level constructs (e.g. timeline runners) that need to advance
622
+ * wall-clock-independent timers in sync with playback, so they pause/scale with the
623
+ * game clock instead of running on real time.
624
+ * @internal
625
+ */ _timelineTickers;
626
+ /**
627
+ * Create an AnimationSet controlling the provided model.
628
+ *
629
+ * @param model - The SceneNode (model root) controlled by this animation set.
423
630
  */ constructor(model){
424
631
  super();
425
632
  this._model = model;
@@ -430,29 +637,42 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
430
637
  this._activeAnimations = new Map();
431
638
  this._skeletons = [];
432
639
  this._rigs = [];
640
+ this._timelineTickers = new Set();
641
+ }
642
+ /**
643
+ * Register a callback advanced by {@link update} using the same delta time as the animations.
644
+ * @internal
645
+ */ _registerTimelineTicker(ticker) {
646
+ this._timelineTickers.add(ticker);
433
647
  }
434
- /**
435
- * The model (SceneNode) controlled by this animation set.
648
+ /**
649
+ * Unregister a callback previously registered with {@link _registerTimelineTicker}.
650
+ * @internal
651
+ */ _unregisterTimelineTicker(ticker) {
652
+ this._timelineTickers.delete(ticker);
653
+ }
654
+ /**
655
+ * The model (SceneNode) controlled by this animation set.
436
656
  */ get model() {
437
657
  return this._model;
438
658
  }
439
- /**
440
- * Number of animation clips registered in this set.
659
+ /**
660
+ * Number of animation clips registered in this set.
441
661
  */ get numAnimations() {
442
662
  return Object.getOwnPropertyNames(this._animations).length;
443
663
  }
444
- /**
445
- * The skeletons used by animations in this set.
664
+ /**
665
+ * The skeletons used by animations in this set.
446
666
  */ get skeletons() {
447
667
  return this._skeletons;
448
668
  }
449
- /**
450
- * The shared rigs used by animations in this set.
669
+ /**
670
+ * The shared rigs used by animations in this set.
451
671
  */ get rigs() {
452
672
  return this._rigs;
453
673
  }
454
- /**
455
- * Per-skin bindings used by skinned meshes in this set.
674
+ /**
675
+ * Per-skin bindings used by skinned meshes in this set.
456
676
  */ get skinBindings() {
457
677
  return this._skeletons;
458
678
  }
@@ -474,20 +694,20 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
474
694
  }
475
695
  return rig;
476
696
  }
477
- /**
478
- * Retrieve an animation clip by name.
479
- *
480
- * @param name - Name of the animation.
481
- * @returns The clip if present; otherwise null.
697
+ /**
698
+ * Retrieve an animation clip by name.
699
+ *
700
+ * @param name - Name of the animation.
701
+ * @returns The clip if present; otherwise null.
482
702
  */ get(name) {
483
703
  return this._animations[name] ?? null;
484
704
  }
485
- /**
486
- * Create and register a new animation clip.
487
- *
488
- * @param name - Unique name for the animation clip.
489
- * @param embedded - Whether the clip is embedded/owned (implementation-specific). Default false.
490
- * @returns The created clip, or null if the name is empty or not unique.
705
+ /**
706
+ * Create and register a new animation clip.
707
+ *
708
+ * @param name - Unique name for the animation clip.
709
+ * @param embedded - Whether the clip is embedded/owned (implementation-specific). Default false.
710
+ * @returns The created clip, or null if the name is empty or not unique.
491
711
  */ createAnimation(name, embedded = false) {
492
712
  if (!name || this._animations[name]) {
493
713
  console.error('Animation must have unique name');
@@ -499,12 +719,12 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
499
719
  return animation;
500
720
  }
501
721
  }
502
- /**
503
- * Delete and dispose an animation clip by name.
504
- *
505
- * - If the animation is currently playing, it is first stopped (immediately).
506
- *
507
- * @param name - Name of the animation to remove.
722
+ /**
723
+ * Delete and dispose an animation clip by name.
724
+ *
725
+ * - If the animation is currently playing, it is first stopped (immediately).
726
+ *
727
+ * @param name - Name of the animation to remove.
508
728
  */ deleteAnimation(name) {
509
729
  const animation = this._animations[name];
510
730
  if (animation) {
@@ -513,50 +733,119 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
513
733
  animation.dispose();
514
734
  }
515
735
  }
516
- /**
517
- * Get the list of all registered animation names.
518
- *
519
- * @returns An array of clip names.
736
+ /**
737
+ * Get the list of all registered animation names.
738
+ *
739
+ * @returns An array of clip names.
520
740
  */ getAnimationNames() {
521
741
  return Object.keys(this._animations);
522
742
  }
523
- /**
524
- * Advance and apply active animations.
525
- *
526
- * Responsibilities per call:
527
- * - Update time cursor for each active clip (respecting speedRatio and looping).
528
- * - Enforce repeat limits and apply fade-out termination if configured.
529
- * - For each animated target, blend active tracks (weighted by clip weight × fade-in × fade-out)
530
- * and apply the resulting state to the target.
531
- * - Apply shared rig modifiers once, then update skin binding palettes.
532
- *
533
- * @param deltaInSeconds - Time step in seconds since last update.
743
+ /**
744
+ * Advance and apply active animations.
745
+ *
746
+ * Responsibilities per call:
747
+ * - Update time cursor for each active clip (respecting speedRatio and looping).
748
+ * - Enforce repeat limits and apply fade-out termination if configured.
749
+ * - For each animated target, blend active tracks (weighted by clip weight × fade-in × fade-out)
750
+ * and apply the resulting state to the target.
751
+ * - Apply shared rig modifiers once, then update skin binding palettes.
752
+ *
753
+ * @param deltaInSeconds - Time step in seconds since last update.
534
754
  */ update(deltaInSeconds) {
535
755
  this._activeAnimations.forEach((v, k)=>{
756
+ if (!v.started) {
757
+ v.started = true;
758
+ const event = this.createPlaybackEvent(v, k);
759
+ v.playback._emitStart(event);
760
+ this.dispatchEvent('playbackstart', event);
761
+ }
762
+ if (v.paused) {
763
+ return;
764
+ }
536
765
  if (v.fadeOut > 0 && v.fadeOutStart < 0) {
537
766
  v.fadeOutStart = v.animateTime;
538
767
  }
768
+ if (v.completed) {
769
+ v.animateTime += Math.abs(deltaInSeconds * v.speedRatio);
770
+ if (v.fadeOut > 0 && v.animateTime - v.fadeOutStart >= v.fadeOut) {
771
+ this.stopAnimation(k.name, {
772
+ reason: v.stopReason
773
+ });
774
+ }
775
+ return;
776
+ }
539
777
  // Update animation time
540
778
  if (v.firstFrame) {
541
779
  v.firstFrame = false;
542
780
  } else {
781
+ const previousTime = v.currentTime;
543
782
  const timeAdvance = deltaInSeconds * v.speedRatio;
544
783
  v.currentTime += timeAdvance;
545
784
  v.animateTime += timeAdvance;
785
+ const direction = timeAdvance >= 0 ? 1 : -1;
546
786
  if (k.timeDuration > 0) {
547
- if (v.currentTime > k.timeDuration) {
548
- v.repeatCounter += Math.max(1, Math.floor(v.currentTime / k.timeDuration));
787
+ if (v.rangeEnd !== null && v.speedRatio >= 0 && v.currentTime >= v.rangeEnd) {
788
+ v.currentTime = v.rangeEnd;
789
+ v.playback._setTime(v.currentTime);
790
+ this.completePlayback(k, v);
791
+ return;
792
+ } else if (v.rangeStart !== null && v.speedRatio < 0 && v.currentTime <= v.rangeStart) {
793
+ v.currentTime = v.rangeStart;
794
+ v.playback._setTime(v.currentTime);
795
+ this.completePlayback(k, v);
796
+ return;
797
+ } else if (v.currentTime > k.timeDuration) {
798
+ const loops = Math.max(1, Math.floor(v.currentTime / k.timeDuration));
799
+ if (v.repeat !== 0 && v.repeatCounter + loops >= v.repeat) {
800
+ v.repeatCounter += loops;
801
+ v.currentTime = k.timeDuration;
802
+ v.playback._setTime(v.currentTime);
803
+ this.emitCrossedMarkers(v, k, previousTime, v.currentTime, direction);
804
+ this.emitCrossedFrames(v, k, previousTime, v.currentTime, direction);
805
+ this.completePlayback(k, v);
806
+ return;
807
+ }
808
+ v.repeatCounter += loops;
549
809
  v.currentTime %= k.timeDuration;
810
+ const event = this.createPlaybackEvent(v, k);
811
+ v.playback._emitLoop(event);
812
+ this.dispatchEvent('playbackloop', event);
550
813
  } else if (v.currentTime < 0) {
551
- v.repeatCounter += Math.max(1, Math.ceil(-v.currentTime / k.timeDuration));
814
+ const loops = Math.max(1, Math.ceil(-v.currentTime / k.timeDuration));
815
+ if (v.repeat !== 0 && v.repeatCounter + loops >= v.repeat) {
816
+ v.repeatCounter += loops;
817
+ v.currentTime = 0;
818
+ v.playback._setTime(v.currentTime);
819
+ this.emitCrossedMarkers(v, k, previousTime, v.currentTime, direction);
820
+ this.emitCrossedFrames(v, k, previousTime, v.currentTime, direction);
821
+ this.completePlayback(k, v);
822
+ return;
823
+ }
824
+ v.repeatCounter += loops;
552
825
  v.currentTime = (v.currentTime % k.timeDuration + k.timeDuration) % k.timeDuration;
826
+ const event = this.createPlaybackEvent(v, k);
827
+ v.playback._emitLoop(event);
828
+ this.dispatchEvent('playbackloop', event);
553
829
  }
554
830
  }
831
+ v.playback._setTime(v.currentTime);
832
+ this.emitCrossedMarkers(v, k, previousTime, v.currentTime, direction);
833
+ this.emitCrossedFrames(v, k, previousTime, v.currentTime, direction);
555
834
  if (v.repeat !== 0 && v.repeatCounter >= v.repeat) {
556
- this.stopAnimation(k.name);
835
+ this.completePlayback(k, v);
557
836
  } else if (v.fadeOut > 0) {
558
837
  if (v.animateTime - v.fadeOutStart >= v.fadeOut) {
559
- this.stopAnimation(k.name);
838
+ this.stopAnimation(k.name, {
839
+ reason: v.stopReason
840
+ });
841
+ }
842
+ }
843
+ if (v.fadeToDuration > 0) {
844
+ const t = Math.min(1, (v.animateTime - v.fadeToStart) / v.fadeToDuration);
845
+ v.weight = v.fadeFromWeight + (v.fadeTargetWeight - v.fadeFromWeight) * t;
846
+ v.playback._setWeight(v.weight);
847
+ if (t >= 1) {
848
+ v.fadeToDuration = 0;
560
849
  }
561
850
  }
562
851
  }
@@ -596,12 +885,17 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
596
885
  this._skeletons.forEach((v)=>{
597
886
  v.get()?.apply();
598
887
  });
888
+ // Advance logical-clock timers (e.g. timeline `wait` steps) with the same delta time,
889
+ // so they stay in sync with playback and pause/scale with the game clock.
890
+ if (this._timelineTickers.size > 0) {
891
+ this._timelineTickers.forEach((ticker)=>ticker(deltaInSeconds));
892
+ }
599
893
  }
600
- /**
601
- * Check whether an animation is currently playing.
602
- *
603
- * @param name - Optional animation name. If omitted, returns true if any animation is playing.
604
- * @returns True if playing; otherwise false.
894
+ /**
895
+ * Check whether an animation is currently playing.
896
+ *
897
+ * @param name - Optional animation name. If omitted, returns true if any animation is playing.
898
+ * @returns True if playing; otherwise false.
605
899
  */ isPlayingAnimation(name) {
606
900
  if (name) {
607
901
  const animation = this._animations[name];
@@ -609,23 +903,23 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
609
903
  }
610
904
  return this._activeAnimations.size > 0;
611
905
  }
612
- /**
613
- * Get an animation clip by name.
614
- *
615
- * Alias of `get(name)` returning a nullable type.
616
- *
617
- * @param name - Name of the animation.
618
- * @returns The clip if present; otherwise null.
906
+ /**
907
+ * Get an animation clip by name.
908
+ *
909
+ * Alias of `get(name)` returning a nullable type.
910
+ *
911
+ * @param name - Name of the animation.
912
+ * @returns The clip if present; otherwise null.
619
913
  */ getAnimationClip(name) {
620
914
  return this._animations[name] ?? null;
621
915
  }
622
- /**
623
- * Set the runtime blend weight for a currently playing animation.
624
- *
625
- * Has no effect if the clip is not active.
626
- *
627
- * @param name - Name of the playing animation.
628
- * @param weight - New weight value used during blending.
916
+ /**
917
+ * Set the runtime blend weight for a currently playing animation.
918
+ *
919
+ * Has no effect if the clip is not active.
920
+ *
921
+ * @param name - Name of the playing animation.
922
+ * @param weight - New weight value used during blending.
629
923
  */ setAnimationWeight(name, weight) {
630
924
  const ani = this._animations[name];
631
925
  if (!ani) {
@@ -635,42 +929,104 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
635
929
  const info = this._activeAnimations.get(ani);
636
930
  if (info) {
637
931
  info.weight = weight;
932
+ info.targetWeight = weight;
933
+ info.playback._setWeight(weight);
638
934
  }
639
935
  }
640
- /**
641
- * Start (or update) playback of an animation clip.
642
- *
643
- * Behavior:
644
- * - If the clip is already playing, stops it first.
645
- * - Otherwise initializes playback state (repeat counter, speed, weight, initial time).
646
- * - Registers clip tracks and skeletons into the active sets for blending and application.
647
- *
648
- * @param name - Name of the animation to play.
649
- * @param options - Playback options (repeat, speedRatio, fadeIn).
650
- */ playAnimation(name, options) {
936
+ /**
937
+ * Create a playback handle without starting it.
938
+ */ createPlayback(name, options) {
651
939
  const ani = this._animations[name];
652
940
  if (!ani) {
653
941
  console.error(`Animation ${name} not exists`);
654
- return;
942
+ return null;
655
943
  }
656
- if (this.isPlayingAnimation(name)) {
657
- this.stopAnimation(name);
944
+ return new AnimationPlayback(this, ani, options);
945
+ }
946
+ /**
947
+ * Start an animation and return its playback handle.
948
+ */ play(name, options) {
949
+ const playback = this.createPlayback(name, options);
950
+ playback?.play();
951
+ return playback;
952
+ }
953
+ /**
954
+ * Get currently active playbacks.
955
+ */ getPlaybacks(name) {
956
+ const playbacks = [];
957
+ this._activeAnimations.forEach((info, clip)=>{
958
+ if (!name || clip.name === name) {
959
+ playbacks.push(info.playback);
960
+ }
961
+ });
962
+ return playbacks;
963
+ }
964
+ /**
965
+ * Get the currently active playback for a clip.
966
+ */ getPlayback(name) {
967
+ const clip = this._animations[name];
968
+ return clip ? this._activeAnimations.get(clip)?.playback ?? null : null;
969
+ }
970
+ /**
971
+ * Start (or update) playback of an animation clip.
972
+ *
973
+ * Behavior:
974
+ * - If the clip is already playing, stops it first.
975
+ * - Otherwise initializes playback state (repeat counter, speed, weight, initial time).
976
+ * - Registers clip tracks and skeletons into the active sets for blending and application.
977
+ *
978
+ * @param name - Name of the animation to play.
979
+ * @param options - Playback options (repeat, speedRatio, fadeIn).
980
+ */ playAnimation(name, options) {
981
+ this.play(name, options);
982
+ }
983
+ /**
984
+ * Start a previously created playback.
985
+ * @internal
986
+ */ startPlayback(playback) {
987
+ const ani = playback.clip;
988
+ if (!ani) {
989
+ return;
658
990
  }
991
+ const options = playback._getOptions();
659
992
  const fadeIn = Math.max(options?.fadeIn ?? 0, 0);
660
993
  const repeat = options?.repeat ?? 0;
661
- const speedRatio = options?.speedRatio ?? 1;
662
- const weight = options?.weight ?? ani.weight ?? 1;
994
+ const speedRatio = playback.speedRatio;
995
+ const weight = playback.weight;
996
+ const rangeStart = ani.resolveTimeRef(options?.range?.start);
997
+ const rangeEnd = ani.resolveTimeRef(options?.range?.end);
998
+ const syncSource = this.resolvePlaybackSyncSource(options?.sync);
999
+ const initialTime = this.computePlaybackInitialTime(ani, speedRatio, rangeStart, rangeEnd, options?.sync, syncSource);
1000
+ if (this.isPlayingAnimation(ani.name)) {
1001
+ this.stopAnimation(ani.name, {
1002
+ reason: 'replaced'
1003
+ });
1004
+ }
1005
+ playback._setState('playing');
1006
+ playback._setTime(initialTime);
663
1007
  this._activeAnimations.set(ani, {
1008
+ playback,
664
1009
  repeat,
665
1010
  weight,
1011
+ targetWeight: weight,
666
1012
  speedRatio,
667
1013
  fadeIn,
668
1014
  fadeOut: 0,
669
1015
  repeatCounter: 0,
670
- currentTime: speedRatio < 0 ? ani.timeDuration : 0,
1016
+ currentTime: initialTime,
671
1017
  animateTime: 0,
672
1018
  fadeOutStart: 0,
673
- firstFrame: true
1019
+ fadeToStart: 0,
1020
+ fadeToDuration: 0,
1021
+ fadeFromWeight: weight,
1022
+ fadeTargetWeight: weight,
1023
+ firstFrame: true,
1024
+ started: false,
1025
+ paused: false,
1026
+ stopReason: 'manual',
1027
+ completed: false,
1028
+ rangeStart,
1029
+ rangeEnd
674
1030
  });
675
1031
  ani.tracks?.forEach((v, k)=>{
676
1032
  let nodeTracks = this._activeTracks.get(k);
@@ -707,18 +1063,257 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
707
1063
  }
708
1064
  });
709
1065
  }
710
- /**
711
- * Stop playback of an animation clip.
712
- *
713
- * Behavior:
714
- * - If `options.fadeOut > 0`, marks the clip for fade-out; actual removal occurs after fade completes.
715
- * - If `fadeOut` is 0 or omitted, immediately:
716
- * - Removes the clip from active animations.
717
- * - Unregisters its tracks from active track maps.
718
- * - Decrements skeleton reference counts; resets and removes skeletons when refcount reaches 0.
719
- *
720
- * @param name - Name of the animation to stop.
721
- * @param options - Optional fade-out configuration.
1066
+ resolvePlaybackSyncSource(sync) {
1067
+ if (!sync?.target) {
1068
+ return null;
1069
+ }
1070
+ const playback = this.resolveActivePlayback(sync.target);
1071
+ if (!playback) {
1072
+ return null;
1073
+ }
1074
+ const info = this._activeAnimations.get(playback.clip);
1075
+ return info?.playback === playback ? {
1076
+ playback,
1077
+ info,
1078
+ clip: playback.clip
1079
+ } : null;
1080
+ }
1081
+ resolveActivePlayback(target) {
1082
+ const named = this.getPlayback(target);
1083
+ if (named) {
1084
+ return named;
1085
+ }
1086
+ for (const info of this._activeAnimations.values()){
1087
+ if (info.playback.id === target) {
1088
+ return info.playback;
1089
+ }
1090
+ }
1091
+ return null;
1092
+ }
1093
+ computePlaybackInitialTime(clip, speedRatio, rangeStart, rangeEnd, sync, syncSource) {
1094
+ const fallback = speedRatio < 0 ? rangeEnd ?? rangeStart ?? clip.timeDuration : rangeStart ?? 0;
1095
+ if (!sync || !syncSource) {
1096
+ return fallback;
1097
+ }
1098
+ const destination = this.getPlaybackEffectiveRange(clip, rangeStart, rangeEnd);
1099
+ if (destination.duration <= 0) {
1100
+ return fallback;
1101
+ }
1102
+ const offset = Number.isFinite(sync.offset) ? sync.offset ?? 0 : 0;
1103
+ const wrap = sync.wrap ?? true;
1104
+ if ((sync.mode ?? 'normalized') === 'time') {
1105
+ return this.constrainPlaybackTime(syncSource.info.currentTime + offset, destination, wrap);
1106
+ }
1107
+ const source = this.getPlaybackEffectiveRange(syncSource.clip, syncSource.info.rangeStart, syncSource.info.rangeEnd);
1108
+ if (source.duration <= 0) {
1109
+ return fallback;
1110
+ }
1111
+ const phase = (syncSource.info.currentTime - source.start) / source.duration + offset;
1112
+ return this.constrainPlaybackTime(destination.start + phase * destination.duration, destination, wrap);
1113
+ }
1114
+ getPlaybackEffectiveRange(clip, rangeStart, rangeEnd) {
1115
+ const start = rangeStart ?? 0;
1116
+ const end = rangeEnd ?? clip.timeDuration;
1117
+ return start <= end ? {
1118
+ start,
1119
+ end,
1120
+ duration: end - start
1121
+ } : {
1122
+ start: end,
1123
+ end: start,
1124
+ duration: start - end
1125
+ };
1126
+ }
1127
+ constrainPlaybackTime(time, range, wrap) {
1128
+ if (!Number.isFinite(time)) {
1129
+ return range.start;
1130
+ }
1131
+ if (!wrap) {
1132
+ return Math.max(range.start, Math.min(range.end, time));
1133
+ }
1134
+ if (time >= range.start && time < range.end) {
1135
+ return time;
1136
+ }
1137
+ const offset = ((time - range.start) % range.duration + range.duration) % range.duration;
1138
+ return range.start + offset;
1139
+ }
1140
+ /**
1141
+ * Stop a playback handle.
1142
+ * @internal
1143
+ */ stopPlayback(playback, options) {
1144
+ if (this._activeAnimations.get(playback.clip)?.playback === playback) {
1145
+ this.stopAnimation(playback.clip.name, options);
1146
+ }
1147
+ }
1148
+ /**
1149
+ * Pause a playback handle.
1150
+ * @internal
1151
+ */ pausePlayback(playback) {
1152
+ const info = this._activeAnimations.get(playback.clip);
1153
+ if (info?.playback === playback && !info.paused) {
1154
+ info.paused = true;
1155
+ playback._setState('paused');
1156
+ const event = this.createPlaybackEvent(info, playback.clip);
1157
+ playback._emitPause(event);
1158
+ this.dispatchEvent('playbackpause', event);
1159
+ }
1160
+ }
1161
+ /**
1162
+ * Resume a playback handle.
1163
+ * @internal
1164
+ */ resumePlayback(playback) {
1165
+ const info = this._activeAnimations.get(playback.clip);
1166
+ if (info?.playback === playback && info.paused) {
1167
+ info.paused = false;
1168
+ playback._setState('playing');
1169
+ const event = this.createPlaybackEvent(info, playback.clip);
1170
+ playback._emitResume(event);
1171
+ this.dispatchEvent('playbackresume', event);
1172
+ }
1173
+ }
1174
+ /**
1175
+ * Seek a playback handle.
1176
+ * @internal
1177
+ */ seekPlayback(playback, time, options) {
1178
+ const clip = playback.clip;
1179
+ const info = this._activeAnimations.get(clip);
1180
+ const duration = clip.timeDuration;
1181
+ const clampedTime = duration > 0 ? Math.max(0, Math.min(duration, time)) : Math.max(0, time);
1182
+ const previousTime = info?.currentTime ?? playback.time;
1183
+ playback._setTime(clampedTime);
1184
+ if (info?.playback === playback) {
1185
+ info.currentTime = clampedTime;
1186
+ if (options?.emitEvents) {
1187
+ const direction = clampedTime >= previousTime ? 1 : -1;
1188
+ this.emitCrossedMarkers(info, clip, previousTime, clampedTime, direction);
1189
+ this.emitCrossedFrames(info, clip, previousTime, clampedTime, direction);
1190
+ }
1191
+ if (options?.apply) {
1192
+ this.update(0);
1193
+ }
1194
+ }
1195
+ }
1196
+ /**
1197
+ * Set playback weight.
1198
+ * @internal
1199
+ */ setPlaybackWeight(playback, weight) {
1200
+ playback._setWeight(weight);
1201
+ const info = this._activeAnimations.get(playback.clip);
1202
+ if (info?.playback === playback) {
1203
+ info.weight = weight;
1204
+ info.targetWeight = weight;
1205
+ }
1206
+ }
1207
+ /**
1208
+ * Set playback speed ratio.
1209
+ * @internal
1210
+ */ setPlaybackSpeedRatio(playback, speedRatio) {
1211
+ playback._setSpeedRatio(speedRatio);
1212
+ const info = this._activeAnimations.get(playback.clip);
1213
+ if (info?.playback === playback) {
1214
+ info.speedRatio = speedRatio;
1215
+ }
1216
+ }
1217
+ /**
1218
+ * Fade playback weight to a target value.
1219
+ * @internal
1220
+ */ fadePlaybackTo(playback, weight, duration) {
1221
+ const info = this._activeAnimations.get(playback.clip);
1222
+ if (info?.playback === playback) {
1223
+ info.fadeFromWeight = info.weight;
1224
+ info.fadeTargetWeight = weight;
1225
+ info.targetWeight = weight;
1226
+ info.fadeToStart = info.animateTime;
1227
+ info.fadeToDuration = Math.max(duration, 0);
1228
+ if (info.fadeToDuration === 0) {
1229
+ info.weight = weight;
1230
+ playback._setWeight(weight);
1231
+ }
1232
+ } else {
1233
+ playback._setWeight(weight);
1234
+ }
1235
+ }
1236
+ createPlaybackEvent(info, clip) {
1237
+ return {
1238
+ playback: info.playback,
1239
+ animationSet: this,
1240
+ clip,
1241
+ time: info.currentTime,
1242
+ normalizedTime: clip.timeDuration > 0 ? info.currentTime / clip.timeDuration : 0
1243
+ };
1244
+ }
1245
+ completePlayback(clip, info) {
1246
+ if (info.completed) {
1247
+ return;
1248
+ }
1249
+ const event = this.createPlaybackEvent(info, clip);
1250
+ const completionFadeOut = Math.max(info.playback._getOptions().completionFadeOut ?? 0, 0);
1251
+ info.playback._setState('completed');
1252
+ info.playback._emitComplete(event);
1253
+ this.dispatchEvent('playbackcomplete', event);
1254
+ if (completionFadeOut > 0) {
1255
+ info.completed = true;
1256
+ info.fadeOut = completionFadeOut;
1257
+ info.fadeOutStart = info.animateTime;
1258
+ info.stopReason = 'completed';
1259
+ return;
1260
+ }
1261
+ this.stopAnimation(clip.name, {
1262
+ reason: 'completed'
1263
+ });
1264
+ }
1265
+ emitCrossedMarkers(info, clip, fromTime, toTime, direction) {
1266
+ for (const marker of this.getCrossedMarkers(clip, fromTime, toTime, direction)){
1267
+ const event = {
1268
+ ...this.createPlaybackEvent(info, clip),
1269
+ marker,
1270
+ direction
1271
+ };
1272
+ info.playback._emitMarker(event);
1273
+ this.dispatchEvent('marker', event);
1274
+ }
1275
+ }
1276
+ emitCrossedFrames(info, clip, fromTime, toTime, direction) {
1277
+ for (const frame of info.playback._getWatchedFrames()){
1278
+ const frameTime = frame / clip.frameRate;
1279
+ const crossed = direction >= 0 ? this.crossedForward(fromTime, toTime, frameTime, clip.timeDuration) : this.crossedBackward(fromTime, toTime, frameTime, clip.timeDuration);
1280
+ if (crossed) {
1281
+ const event = {
1282
+ ...this.createPlaybackEvent(info, clip),
1283
+ frame
1284
+ };
1285
+ info.playback._emitFrame(event);
1286
+ this.dispatchEvent('frame', event);
1287
+ }
1288
+ }
1289
+ }
1290
+ getCrossedMarkers(clip, fromTime, toTime, direction) {
1291
+ return clip.markers.filter((marker)=>{
1292
+ const markerTime = clip.resolveMarkerTime(marker);
1293
+ if (markerTime === null) {
1294
+ return false;
1295
+ }
1296
+ return direction >= 0 ? this.crossedForward(fromTime, toTime, markerTime, clip.timeDuration) : this.crossedBackward(fromTime, toTime, markerTime, clip.timeDuration);
1297
+ });
1298
+ }
1299
+ crossedForward(fromTime, toTime, targetTime, duration) {
1300
+ return toTime >= fromTime ? targetTime > fromTime && targetTime <= toTime : duration > 0 && targetTime > fromTime && targetTime <= duration || targetTime <= toTime;
1301
+ }
1302
+ crossedBackward(fromTime, toTime, targetTime, duration) {
1303
+ return toTime <= fromTime ? targetTime < fromTime && targetTime >= toTime : duration > 0 && targetTime < fromTime && targetTime >= 0 || targetTime >= toTime;
1304
+ }
1305
+ /**
1306
+ * Stop playback of an animation clip.
1307
+ *
1308
+ * Behavior:
1309
+ * - If `options.fadeOut > 0`, marks the clip for fade-out; actual removal occurs after fade completes.
1310
+ * - If `fadeOut` is 0 or omitted, immediately:
1311
+ * - Removes the clip from active animations.
1312
+ * - Unregisters its tracks from active track maps.
1313
+ * - Decrements skeleton reference counts; resets and removes skeletons when refcount reaches 0.
1314
+ *
1315
+ * @param name - Name of the animation to stop.
1316
+ * @param options - Optional fade-out configuration.
722
1317
  */ stopAnimation(name, options) {
723
1318
  const ani = this._animations[name];
724
1319
  if (!ani) {
@@ -728,9 +1323,12 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
728
1323
  const info = this._activeAnimations.get(ani);
729
1324
  if (info) {
730
1325
  const fadeOut = Math.max(options?.fadeOut ?? 0, 0);
1326
+ const reason = options?.reason ?? 'manual';
731
1327
  if (fadeOut !== 0) {
732
1328
  info.fadeOut = fadeOut;
733
1329
  info.fadeOutStart = -1;
1330
+ info.stopReason = reason;
1331
+ info.playback._setState('stopping');
734
1332
  } else {
735
1333
  this._activeAnimations.delete(ani);
736
1334
  this._activeTracks.forEach((v)=>{
@@ -768,20 +1366,29 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
768
1366
  }
769
1367
  }
770
1368
  });
1369
+ const event = {
1370
+ ...this.createPlaybackEvent(info, ani),
1371
+ reason
1372
+ };
1373
+ if (reason !== 'completed') {
1374
+ info.playback._setState('stopped');
1375
+ }
1376
+ info.playback._emitStop(event);
1377
+ this.dispatchEvent('playbackstop', event);
771
1378
  }
772
1379
  }
773
1380
  }
774
- /**
775
- * Create a skeletal-only masked clip from an existing clip in this set.
776
- *
777
- * The generated clip is a regular `AnimationClip`: it contains cloned node transform tracks
778
- * for the selected rig joints and can be played/blended through the normal animation system.
779
- * Non-skeletal tracks are skipped by default.
780
- *
781
- * @param sourceName - Name of the source clip.
782
- * @param targetName - Name of the generated clip.
783
- * @param options - Humanoid semantic or joint-name based mask options.
784
- * @returns The generated clip, or null on failure.
1381
+ /**
1382
+ * Create a skeletal-only masked clip from an existing clip in this set.
1383
+ *
1384
+ * The generated clip is a regular `AnimationClip`: it contains cloned node transform tracks
1385
+ * for the selected rig joints and can be played/blended through the normal animation system.
1386
+ * Non-skeletal tracks are skipped by default.
1387
+ *
1388
+ * @param sourceName - Name of the source clip.
1389
+ * @param targetName - Name of the generated clip.
1390
+ * @param options - Humanoid semantic or joint-name based mask options.
1391
+ * @returns The generated clip, or null on failure.
785
1392
  */ createSkeletalMaskedAnimation(sourceName, targetName, options) {
786
1393
  const sourceClip = this.get(sourceName);
787
1394
  if (!sourceClip) {
@@ -988,21 +1595,21 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
988
1595
  }
989
1596
  return dstClip;
990
1597
  }
991
- /**
992
- * Copy an animation clip from another AnimationSet into this one.
993
- *
994
- * Prerequisites:
995
- * - Both sets must reference skeletons with identical joint names and counts.
996
- * - The source clip must exist in `sourceSet`.
997
- *
998
- * @param sourceSet - The AnimationSet to copy from.
999
- * @param animationName - Name of the clip to copy.
1000
- * @param targetName - Name for the new clip in this set. Defaults to `animationName`.
1001
- * @param excludeJoint - Optional predicate; joints whose name returns true are excluded from
1002
- * skeleton structure matching.
1003
- * @returns The newly created AnimationClip, or null on failure.
1004
- *
1005
- * @deprecated Use AnimationSet.copyHumanoidAnimationFrom instead.
1598
+ /**
1599
+ * Copy an animation clip from another AnimationSet into this one.
1600
+ *
1601
+ * Prerequisites:
1602
+ * - Both sets must reference skeletons with identical joint names and counts.
1603
+ * - The source clip must exist in `sourceSet`.
1604
+ *
1605
+ * @param sourceSet - The AnimationSet to copy from.
1606
+ * @param animationName - Name of the clip to copy.
1607
+ * @param targetName - Name for the new clip in this set. Defaults to `animationName`.
1608
+ * @param excludeJoint - Optional predicate; joints whose name returns true are excluded from
1609
+ * skeleton structure matching.
1610
+ * @returns The newly created AnimationClip, or null on failure.
1611
+ *
1612
+ * @deprecated Use AnimationSet.copyHumanoidAnimationFrom instead.
1006
1613
  */ copyAnimationFrom(sourceSet, animationName, targetName, excludeJoint) {
1007
1614
  const destName = targetName ?? animationName;
1008
1615
  const sourceClip = sourceSet.get(animationName);
@@ -1148,8 +1755,8 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
1148
1755
  return ordered;
1149
1756
  }
1150
1757
  }
1151
- /**
1152
- * Reset all skeleton modifiers
1758
+ /**
1759
+ * Reset all skeleton modifiers
1153
1760
  */ resetSkeletonModifiers() {
1154
1761
  for (const sk of this._skeletons){
1155
1762
  for (const modifier of sk.get().modifiers){
@@ -1157,12 +1764,12 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
1157
1764
  }
1158
1765
  }
1159
1766
  }
1160
- /**
1161
- * Dispose the animation set and release owned resources.
1162
- *
1163
- * - Disposes the weak reference to the model.
1164
- * - Disposes all registered animation clips.
1165
- * - Clears active animations, tracks, and skeleton references.
1767
+ /**
1768
+ * Dispose the animation set and release owned resources.
1769
+ *
1770
+ * - Disposes the weak reference to the model.
1771
+ * - Disposes all registered animation clips.
1772
+ * - Clears active animations, tracks, and skeleton references.
1166
1773
  */ onDispose() {
1167
1774
  super.onDispose();
1168
1775
  for(const k in this._animations){
@@ -1176,5 +1783,5 @@ function bakeHumanoidRotationTracks(sourceClip, srcSkeleton, dstSkeleton, srcRoo
1176
1783
  }
1177
1784
  }
1178
1785
 
1179
- export { AnimationSet };
1786
+ export { AnimationPlayback, AnimationSet };
1180
1787
  //# sourceMappingURL=animationset.js.map