Skip to main content

gizmo_animation/
player.rs

1use std::sync::Arc;
2use crate::clip::AnimationClip;
3use std::collections::HashMap;
4use gizmo_core::entity::Entity;
5
6/// Marker component inserted onto entities that are currently targeted by an
7/// [`AnimationPlayer`], so the animation system can query only animated transforms.
8#[derive(Clone, Copy, Debug, Default)]
9pub struct Animated;
10
11impl gizmo_core::component::Component for Animated {
12    fn storage_type() -> gizmo_core::component::StorageType {
13        gizmo_core::component::StorageType::Table
14    }
15}
16
17
18/// Component that drives an [`AnimationClip`] over time.
19///
20/// Attach this to the root entity of a hierarchy; the animation system advances
21/// [`Self::elapsed_time`], resolves each track's `target_name` to a child entity
22/// (caching the result in [`Self::target_entities`]), and writes the sampled
23/// transform values to those entities.
24#[derive(Clone, Debug)]
25#[non_exhaustive]
26pub struct AnimationPlayer {
27    /// The clip currently being played, if any.
28    pub clip: Option<Arc<AnimationClip>>,
29    /// Current playback position, in seconds.
30    pub elapsed_time: f32,
31    /// Playback speed multiplier (`1.0` is real time).
32    pub speed: f32,
33    /// Whether playback is currently advancing.
34    pub playing: bool,
35    /// Whether the clip restarts from the beginning when it ends.
36    pub looping: bool,
37    /// Cache mapping each track's `target_name` to the resolved entity.
38    pub target_entities: HashMap<String, Entity>,
39}
40
41impl Default for AnimationPlayer {
42    fn default() -> Self {
43        Self {
44            clip: None,
45            elapsed_time: 0.0,
46            speed: 1.0,
47            playing: true,
48            looping: true,
49            target_entities: HashMap::new(),
50        }
51    }
52}
53
54impl gizmo_core::component::Component for AnimationPlayer {
55    fn storage_type() -> gizmo_core::component::StorageType {
56        gizmo_core::component::StorageType::Table
57    }
58}
59
60impl AnimationPlayer {
61    /// Creates a player with default settings (equivalent to [`Default::default`]).
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Starts playing `clip` from the beginning, clearing any cached target entities.
67    pub fn play(&mut self, clip: Arc<AnimationClip>) -> &mut Self {
68        self.clip = Some(clip);
69        self.elapsed_time = 0.0;
70        self.playing = true;
71        self.target_entities.clear(); // Need to re-resolve targets for the new clip
72        self
73    }
74
75    /// Pauses playback, leaving [`Self::elapsed_time`] untouched.
76    pub fn pause(&mut self) -> &mut Self {
77        self.playing = false;
78        self
79    }
80
81    /// Resumes playback from the current position.
82    pub fn resume(&mut self) -> &mut Self {
83        self.playing = true;
84        self
85    }
86
87    /// Builder: sets the playback [`Self::speed`] multiplier.
88    ///
89    /// A non-finite `speed` (`NaN`/`±∞`) is rejected and falls back to `1.0` so
90    /// it cannot poison [`Self::elapsed_time`] during playback.
91    pub fn with_speed(mut self, speed: f32) -> Self {
92        self.speed = if speed.is_finite() { speed } else { 1.0 };
93        self
94    }
95
96    /// Builder: sets whether playback [`Self::looping`].
97    pub fn looping(mut self, looping: bool) -> Self {
98        self.looping = looping;
99        self
100    }
101
102    /// Advances playback by `dt` seconds against a clip of `duration` seconds.
103    ///
104    /// A non-finite [`Self::speed`] (`NaN`/`±∞`) falls back to `1.0` so it cannot
105    /// poison [`Self::elapsed_time`]. When [`Self::looping`], `elapsed_time` wraps
106    /// within the clip length; otherwise it is clamped to `[0, duration]` and
107    /// playback stops the instant it *reaches* an end, so a non-looping clip does
108    /// not overshoot its final frame by one tick.
109    ///
110    /// Reverse playback (`speed < 0`) is supported: looping wraps with
111    /// `rem_euclid` (a plain `%` keeps the dividend's sign, so a negative time
112    /// stays negative and the sampler pins the pose at frame 0 — see the sibling
113    /// skeletal fix in `gizmo-renderer::animation_system`); non-looping reverse
114    /// completes at the clip start (`elapsed_time <= 0`) instead of running
115    /// unbounded-negative with `playing` stuck true.
116    pub fn advance(&mut self, dt: f32, duration: f32) {
117        let safe_speed = if self.speed.is_finite() { self.speed } else { 1.0 };
118        self.elapsed_time += dt * safe_speed;
119
120        if self.looping {
121            if duration > 0.0 {
122                // rem_euclid, not `%`: wraps negative (reverse-playback) times
123                // back into [0, duration) rather than leaving them negative.
124                self.elapsed_time = self.elapsed_time.rem_euclid(duration);
125            }
126        } else if safe_speed < 0.0 {
127            // Reverse, non-looping: complete at the start of the clip. Gated on
128            // `speed < 0` so a frozen (`speed == 0`) or forward clip sitting at 0
129            // is not spuriously stopped.
130            if self.elapsed_time <= 0.0 {
131                self.elapsed_time = 0.0;
132                self.playing = false;
133            }
134        } else if self.elapsed_time >= duration {
135            self.elapsed_time = duration;
136            self.playing = false;
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn with_speed_rejects_non_finite() {
147        // NaN / Inf must not survive into `speed`, or they would poison
148        // `elapsed_time` in the animation system.
149        assert_eq!(AnimationPlayer::new().with_speed(f32::NAN).speed, 1.0);
150        assert_eq!(AnimationPlayer::new().with_speed(f32::INFINITY).speed, 1.0);
151        assert_eq!(
152            AnimationPlayer::new().with_speed(f32::NEG_INFINITY).speed,
153            1.0
154        );
155    }
156
157    #[test]
158    fn with_speed_keeps_finite_values() {
159        assert_eq!(AnimationPlayer::new().with_speed(2.5).speed, 2.5);
160        assert_eq!(AnimationPlayer::new().with_speed(-1.0).speed, -1.0);
161        assert_eq!(AnimationPlayer::new().with_speed(0.0).speed, 0.0);
162    }
163
164    #[test]
165    fn advance_non_looping_stops_exactly_at_duration() {
166        // Regression for the off-by-one termination: reaching duration exactly must
167        // stop playback (`>=`), not overshoot by one tick (which `>` would allow).
168        let mut p = AnimationPlayer::new().looping(false);
169        p.elapsed_time = 0.0;
170        p.advance(1.0, 1.0); // lands exactly on duration
171        assert_eq!(p.elapsed_time, 1.0);
172        assert!(!p.playing, "non-looping clip must stop the instant it reaches duration");
173    }
174
175    #[test]
176    fn advance_non_looping_does_not_stop_early() {
177        let mut p = AnimationPlayer::new().looping(false);
178        p.elapsed_time = 0.0;
179        p.advance(1.0, 2.0); // still mid-clip
180        assert_eq!(p.elapsed_time, 1.0);
181        assert!(p.playing, "clip must keep playing before it reaches duration");
182    }
183
184    #[test]
185    fn advance_non_finite_speed_does_not_poison_elapsed_time() {
186        let mut p = AnimationPlayer::new().looping(false).with_speed(1.0);
187        p.speed = f32::NAN; // simulate a directly-mutated player
188        p.advance(0.5, 10.0);
189        assert!(p.elapsed_time.is_finite());
190        assert_eq!(p.elapsed_time, 0.5, "NaN speed must fall back to 1.0");
191    }
192
193    #[test]
194    fn advance_looping_wraps_within_duration() {
195        let mut p = AnimationPlayer::new().looping(true);
196        p.elapsed_time = 0.0;
197        p.advance(1.5, 1.0);
198        assert!((p.elapsed_time - 0.5).abs() < 1e-6, "looping time must wrap into [0, duration)");
199        assert!(p.playing);
200    }
201
202    #[test]
203    fn advance_looping_reverse_wraps_to_end_not_frame_zero() {
204        // Regression: reverse looping playback used `%=`, which keeps the sign, so a
205        // negative time stayed negative and the sampler pinned the pose at frame 0
206        // forever. `rem_euclid` must wrap it back near the end of the clip.
207        let mut p = AnimationPlayer::new().looping(true).with_speed(-1.0);
208        p.elapsed_time = 0.0;
209        p.advance(0.1, 2.0); // 0 - 0.1 = -0.1 → should wrap to 1.9, NOT stay at -0.1
210        assert!(
211            (p.elapsed_time - 1.9).abs() < 1e-6,
212            "reverse looping time must wrap near the clip end, got {}",
213            p.elapsed_time
214        );
215        assert!(p.elapsed_time >= 0.0, "wrapped time must never be negative");
216        assert!(p.playing, "looping clip keeps playing");
217    }
218
219    #[test]
220    fn advance_non_looping_reverse_stops_at_start() {
221        // Regression: non-looping reverse playback ran `elapsed_time` unbounded-negative
222        // and `playing` never became false (only `>= duration` was checked). It must
223        // complete at the clip start.
224        let mut p = AnimationPlayer::new().looping(false).with_speed(-1.0);
225        p.elapsed_time = 0.5;
226        p.advance(1.0, 2.0); // 0.5 - 1.0 = -0.5 → clamp to 0 and stop
227        assert_eq!(p.elapsed_time, 0.0, "reverse non-looping must clamp to the start");
228        assert!(!p.playing, "reverse non-looping must stop when it reaches the start");
229    }
230
231    #[test]
232    fn advance_non_looping_zero_speed_does_not_stop() {
233        // A frozen clip (speed == 0) sitting mid-clip must not be spuriously stopped by
234        // the reverse-completion branch.
235        let mut p = AnimationPlayer::new().looping(false).with_speed(0.0);
236        p.elapsed_time = 0.0;
237        p.advance(1.0, 2.0);
238        assert_eq!(p.elapsed_time, 0.0);
239        assert!(p.playing, "speed==0 must keep playing, not trip the reverse-stop branch");
240    }
241}