Skip to main content

sgl_core/
anim.rs

1//! Pure frame-animation driver over an explicit **frame order** array.
2//!
3//! Ping-pong and other patterns are authored as sheet-frame order data. The
4//! driver owns a cursor into the order array
5//! and an elapsed-time accumulator; the caller queries
6//! [`current_frame`](FrameAnimation::current_frame) and cuts the sheet.
7//!
8//! Pure `core`: no render, no wgpu, no clocks — [`tick`](FrameAnimation::tick)
9//! takes the caller's `dt`.
10//!
11//! [`AnimationSequence`] is the sibling driver for animations scripted as a
12//! list of steps: frame runs, pauses, random pauses, and action hooks, with
13//! ping-pong loop modes.
14
15mod sequence;
16
17pub use sequence::{AnimationSequence, SequenceBuilder, SequenceError, SequenceLoop, SequenceStep};
18
19/// Loop behavior.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LoopMode {
22    /// Play the order once, then hold the last frame and report finished.
23    Once,
24    /// Wrap back to the start of the order forever.
25    Repeat,
26}
27
28/// A playing frame animation: an explicit frame-order array stepped at a
29/// fixed fps.
30///
31/// Frame `order[i]` is displayed for `t ∈ [i·d, (i+1)·d)` where `d = 1/fps`;
32/// accumulating **exactly** one frame duration advances the cursor.
33#[derive(Debug, Clone)]
34pub struct FrameAnimation {
35    /// Sheet-frame indices in play order (e.g. `[0, 1, 2, 1]` ping-pong as
36    /// data). Never empty.
37    order: Vec<usize>,
38    /// Seconds each order entry is displayed (`1 / fps`).
39    frame_duration: f32,
40    loop_mode: LoopMode,
41    /// Cursor into `order`.
42    position: usize,
43    /// Elapsed time within the current order entry (seconds).
44    timer: f32,
45    finished: bool,
46}
47
48impl FrameAnimation {
49    /// An animation playing `order` at `fps`. Starts at the first entry.
50    ///
51    /// # Panics
52    /// If `order` is empty or `fps` is not finite and positive — both are
53    /// authoring errors, not runtime conditions.
54    pub fn new(order: impl Into<Vec<usize>>, fps: f32, loop_mode: LoopMode) -> Self {
55        let order = order.into();
56        assert!(!order.is_empty(), "FrameAnimation: empty frame order");
57        assert!(
58            fps.is_finite() && fps > 0.0,
59            "FrameAnimation: fps must be finite and positive, got {fps}"
60        );
61        Self {
62            order,
63            frame_duration: 1.0 / fps,
64            loop_mode,
65            position: 0,
66            timer: 0.0,
67            finished: false,
68        }
69    }
70
71    /// Start at `position` within the order (clamped to the last entry) —
72    /// For example, an animation may start at order position 1.
73    #[must_use]
74    pub fn start_at(mut self, position: usize) -> Self {
75        self.position = position.min(self.order.len() - 1);
76        self
77    }
78
79    /// Advance by `dt` seconds. Returns `true` only on the tick where a
80    /// [`LoopMode::Once`] animation finishes (a completion edge, once).
81    ///
82    /// Large `dt` steps multiple frames; the leftover carries into the new
83    /// frame so long-run timing never drifts. A nonpositive or non-finite `dt`
84    /// is ignored. A [`LoopMode::Repeat`] tick whose frame duration is too
85    /// small for `f32` to subtract from the accumulated time drops the remainder.
86    pub fn tick(&mut self, dt: f32) -> bool {
87        if self.finished || !dt.is_finite() || dt <= 0.0 {
88            return false;
89        }
90        self.timer += dt;
91        while self.timer >= self.frame_duration {
92            let rest = self.timer - self.frame_duration;
93            if rest >= self.timer && self.loop_mode == LoopMode::Repeat {
94                // The subtraction made no progress (a duration below the
95                // timer's precision, or a timer overflowed to infinity), so
96                // `Repeat` would spin. `Once` needs no guard: it finishes
97                // within one pass.
98                self.timer = 0.0;
99                return false;
100            }
101            self.timer = rest;
102            if self.position + 1 < self.order.len() {
103                self.position += 1;
104            } else {
105                match self.loop_mode {
106                    LoopMode::Repeat => self.position = 0,
107                    LoopMode::Once => {
108                        // Hold the last frame; drop leftover time.
109                        self.finished = true;
110                        self.timer = 0.0;
111                        return true;
112                    }
113                }
114            }
115        }
116        false
117    }
118
119    /// The sheet-frame index to display right now.
120    #[must_use]
121    pub fn current_frame(&self) -> usize {
122        self.order[self.position]
123    }
124
125    /// The cursor position within the order array.
126    #[must_use]
127    pub fn position(&self) -> usize {
128        self.position
129    }
130
131    /// Whether a [`LoopMode::Once`] animation has played out.
132    #[must_use]
133    pub fn is_finished(&self) -> bool {
134        self.finished
135    }
136
137    /// Rewind to the first order entry (restarts a finished animation).
138    pub fn reset(&mut self) {
139        self.position = 0;
140        self.timer = 0.0;
141        self.finished = false;
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use wasm_bindgen_test::wasm_bindgen_test;
149
150    /// #249: `start_at` positions the cursor (clamped to the order's end) and
151    /// `position` reports it; ticking continues from there.
152    #[wasm_bindgen_test(unsupported = test)]
153    fn start_at_positions_the_cursor() {
154        let anim = FrameAnimation::new(vec![5, 6, 7, 8], 10.0, LoopMode::Repeat).start_at(2);
155        assert_eq!(anim.position(), 2);
156        assert_eq!(anim.current_frame(), 7);
157        let mut anim = anim;
158        anim.tick(0.1);
159        assert_eq!(anim.position(), 3);
160        let clamped = FrameAnimation::new(vec![5, 6], 10.0, LoopMode::Repeat).start_at(9);
161        assert_eq!(clamped.position(), 1);
162    }
163
164    /// A `[0,1,2,1]` order at 10 fps, repeating. Exact 0.1 s
165    /// ticks advance exactly one order entry each (boundary semantics).
166    #[wasm_bindgen_test(unsupported = test)]
167    fn repeat_order_cycles_at_exact_fps_boundaries() {
168        let mut a = FrameAnimation::new([0, 1, 2, 1], 10.0, LoopMode::Repeat);
169        let mut seen = vec![a.current_frame()];
170        for _ in 0..8 {
171            assert!(!a.tick(0.1), "Repeat never reports finished");
172            seen.push(a.current_frame());
173        }
174        assert_eq!(seen, vec![0, 1, 2, 1, 0, 1, 2, 1, 0]);
175    }
176
177    /// Just below a frame boundary the frame must NOT advance; reaching it
178    /// exactly must.
179    #[wasm_bindgen_test(unsupported = test)]
180    fn sub_boundary_tick_does_not_advance() {
181        let mut a = FrameAnimation::new([0, 1], 10.0, LoopMode::Repeat);
182        a.tick(0.099);
183        assert_eq!(a.current_frame(), 0, "0.099 s < 0.1 s: still frame 0");
184        a.tick(0.001);
185        assert_eq!(a.current_frame(), 1, "accumulated exactly 0.1 s: frame 1");
186    }
187
188    /// A large dt steps several frames and carries the remainder (no drift).
189    #[wasm_bindgen_test(unsupported = test)]
190    fn large_dt_steps_multiple_frames() {
191        let mut a = FrameAnimation::new([0, 1, 2, 1], 10.0, LoopMode::Repeat);
192        a.tick(0.25); // 2 full frames + 0.05 leftover
193        assert_eq!(a.current_frame(), 2);
194        a.tick(0.05); // leftover reaches the 3rd boundary
195        assert_eq!(a.current_frame(), 1);
196    }
197
198    /// `Once` holds the last frame, reports the completion edge exactly once,
199    /// and stays finished.
200    #[wasm_bindgen_test(unsupported = test)]
201    fn once_finishes_on_the_edge_and_holds() {
202        let mut a = FrameAnimation::new([0, 1, 2], 5.0, LoopMode::Once);
203        assert!(!a.tick(0.2));
204        assert_eq!(a.current_frame(), 1);
205        assert!(!a.tick(0.2));
206        assert_eq!(a.current_frame(), 2);
207        // Stepping past the last frame finishes.
208        assert!(a.tick(0.2), "completion edge");
209        assert!(a.is_finished());
210        assert_eq!(a.current_frame(), 2, "holds the last frame");
211        // No re-fire, no movement.
212        assert!(!a.tick(1.0));
213        assert_eq!(a.current_frame(), 2);
214    }
215
216    /// An order `[0,2,1,2]` at 20 fps, starting at position 1.
217    #[wasm_bindgen_test(unsupported = test)]
218    fn chicken_starts_at_frame_two_and_follows_its_order() {
219        let mut a = FrameAnimation::new([0, 2, 1, 2], 20.0, LoopMode::Repeat).start_at(1);
220        let mut seen = vec![a.current_frame()];
221        for _ in 0..5 {
222            a.tick(0.05);
223            seen.push(a.current_frame());
224        }
225        assert_eq!(seen, vec![2, 1, 2, 0, 2, 1]);
226    }
227
228    /// #313: a non-finite `dt` neither hangs a repeating animation nor
229    /// poisons its clock — the next finite tick advances on time.
230    #[wasm_bindgen_test(unsupported = test)]
231    fn a_non_finite_dt_is_ignored() {
232        let mut a = FrameAnimation::new([0, 1], 10.0, LoopMode::Repeat);
233        for dt in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
234            assert!(!a.tick(dt));
235            assert_eq!(a.current_frame(), 0);
236        }
237        a.tick(0.1);
238        assert_eq!(a.current_frame(), 1);
239    }
240
241    /// A backward clock step must neither delay the next frame nor erase
242    /// time already accumulated toward it.
243    #[wasm_bindgen_test(unsupported = test)]
244    fn nonpositive_dt_does_not_build_time_debt() {
245        for loop_mode in [LoopMode::Once, LoopMode::Repeat] {
246            let mut anim = FrameAnimation::new([3, 4, 5], 10.0, loop_mode);
247            assert!(!anim.tick(-1.0));
248            assert!(!anim.tick(0.1));
249            assert_eq!(anim.current_frame(), 4);
250            assert!(!anim.tick(0.05));
251            for dt in [-1.0, 0.0, -0.0] {
252                assert!(!anim.tick(dt));
253            }
254            assert!(!anim.tick(0.05));
255            assert_eq!(anim.current_frame(), 5);
256            assert_eq!(anim.tick(0.1), loop_mode == LoopMode::Once);
257            assert_eq!(anim.is_finished(), loop_mode == LoopMode::Once);
258        }
259    }
260
261    /// #313: a frame duration below the timer's `f32` precision (2^-30 s
262    /// against a 1 s tick) returns, dropping the remainder, and the next
263    /// frame's worth of time advances the cursor.
264    #[wasm_bindgen_test(unsupported = test)]
265    fn a_frame_duration_below_the_timer_precision_drops_the_remainder() {
266        let fps = 2f32.powi(30);
267        let mut a = FrameAnimation::new([0, 1], fps, LoopMode::Repeat);
268        a.tick(1.0);
269        assert_eq!(a.current_frame(), 0);
270        a.tick(1.0 / fps);
271        assert_eq!(a.current_frame(), 1);
272    }
273
274    /// #313: frame `i` shows for `[i·d, (i+1)·d)`, so 0.4 s into `[0, 1, 2]`
275    /// at 10 fps is frame 1 of the second pass.
276    #[wasm_bindgen_test(unsupported = test)]
277    fn a_tick_past_a_whole_pass_lands_on_the_spec_frame() {
278        let mut a = FrameAnimation::new([0, 1, 2], 10.0, LoopMode::Repeat);
279        a.tick(0.4);
280        assert_eq!(a.current_frame(), 1);
281    }
282
283    /// #313: an infinite fps (a zero frame duration) is an authoring error.
284    #[cfg(not(target_arch = "wasm32"))]
285    #[test]
286    #[should_panic(expected = "fps must be finite and positive")]
287    fn infinite_fps_is_rejected() {
288        let _ = FrameAnimation::new([0], f32::INFINITY, LoopMode::Repeat);
289    }
290
291    /// `reset` rewinds and un-finishes.
292    #[wasm_bindgen_test(unsupported = test)]
293    fn reset_restarts_a_finished_animation() {
294        let mut a = FrameAnimation::new([3, 4], 10.0, LoopMode::Once);
295        a.tick(1.0);
296        assert!(a.is_finished());
297        a.reset();
298        assert!(!a.is_finished());
299        assert_eq!(a.current_frame(), 3);
300        assert!(!a.tick(0.1));
301        assert_eq!(a.current_frame(), 4);
302    }
303}