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 positive — both are authoring
53    /// 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!(fps > 0.0, "FrameAnimation: fps must be positive, got {fps}");
58        Self {
59            order,
60            frame_duration: 1.0 / fps,
61            loop_mode,
62            position: 0,
63            timer: 0.0,
64            finished: false,
65        }
66    }
67
68    /// Start at `position` within the order (clamped to the last entry) —
69    /// For example, an animation may start at order position 1.
70    #[must_use]
71    pub fn start_at(mut self, position: usize) -> Self {
72        self.position = position.min(self.order.len() - 1);
73        self
74    }
75
76    /// Advance by `dt` seconds. Returns `true` only on the tick where a
77    /// [`LoopMode::Once`] animation finishes (a completion edge, once).
78    ///
79    /// Large `dt` steps multiple frames; the leftover carries into the new
80    /// frame so long-run timing never drifts.
81    pub fn tick(&mut self, dt: f32) -> bool {
82        if self.finished {
83            return false;
84        }
85        self.timer += dt;
86        while self.timer >= self.frame_duration {
87            self.timer -= self.frame_duration;
88            if self.position + 1 < self.order.len() {
89                self.position += 1;
90            } else {
91                match self.loop_mode {
92                    LoopMode::Repeat => self.position = 0,
93                    LoopMode::Once => {
94                        // Hold the last frame; drop leftover time.
95                        self.finished = true;
96                        self.timer = 0.0;
97                        return true;
98                    }
99                }
100            }
101        }
102        false
103    }
104
105    /// The sheet-frame index to display right now.
106    #[must_use]
107    pub fn current_frame(&self) -> usize {
108        self.order[self.position]
109    }
110
111    /// The cursor position within the order array.
112    #[must_use]
113    pub fn position(&self) -> usize {
114        self.position
115    }
116
117    /// Whether a [`LoopMode::Once`] animation has played out.
118    #[must_use]
119    pub fn is_finished(&self) -> bool {
120        self.finished
121    }
122
123    /// Rewind to the first order entry (restarts a finished animation).
124    pub fn reset(&mut self) {
125        self.position = 0;
126        self.timer = 0.0;
127        self.finished = false;
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use wasm_bindgen_test::wasm_bindgen_test;
135
136    /// #249: `start_at` positions the cursor (clamped to the order's end) and
137    /// `position` reports it; ticking continues from there.
138    #[wasm_bindgen_test(unsupported = test)]
139    fn start_at_positions_the_cursor() {
140        let anim = FrameAnimation::new(vec![5, 6, 7, 8], 10.0, LoopMode::Repeat).start_at(2);
141        assert_eq!(anim.position(), 2);
142        assert_eq!(anim.current_frame(), 7);
143        let mut anim = anim;
144        anim.tick(0.1);
145        assert_eq!(anim.position(), 3);
146        let clamped = FrameAnimation::new(vec![5, 6], 10.0, LoopMode::Repeat).start_at(9);
147        assert_eq!(clamped.position(), 1);
148    }
149
150    /// A `[0,1,2,1]` order at 10 fps, repeating. Exact 0.1 s
151    /// ticks advance exactly one order entry each (boundary semantics).
152    #[wasm_bindgen_test(unsupported = test)]
153    fn repeat_order_cycles_at_exact_fps_boundaries() {
154        let mut a = FrameAnimation::new([0, 1, 2, 1], 10.0, LoopMode::Repeat);
155        let mut seen = vec![a.current_frame()];
156        for _ in 0..8 {
157            assert!(!a.tick(0.1), "Repeat never reports finished");
158            seen.push(a.current_frame());
159        }
160        assert_eq!(seen, vec![0, 1, 2, 1, 0, 1, 2, 1, 0]);
161    }
162
163    /// Just below a frame boundary the frame must NOT advance; reaching it
164    /// exactly must.
165    #[wasm_bindgen_test(unsupported = test)]
166    fn sub_boundary_tick_does_not_advance() {
167        let mut a = FrameAnimation::new([0, 1], 10.0, LoopMode::Repeat);
168        a.tick(0.099);
169        assert_eq!(a.current_frame(), 0, "0.099 s < 0.1 s: still frame 0");
170        a.tick(0.001);
171        assert_eq!(a.current_frame(), 1, "accumulated exactly 0.1 s: frame 1");
172    }
173
174    /// A large dt steps several frames and carries the remainder (no drift).
175    #[wasm_bindgen_test(unsupported = test)]
176    fn large_dt_steps_multiple_frames() {
177        let mut a = FrameAnimation::new([0, 1, 2, 1], 10.0, LoopMode::Repeat);
178        a.tick(0.25); // 2 full frames + 0.05 leftover
179        assert_eq!(a.current_frame(), 2);
180        a.tick(0.05); // leftover reaches the 3rd boundary
181        assert_eq!(a.current_frame(), 1);
182    }
183
184    /// `Once` holds the last frame, reports the completion edge exactly once,
185    /// and stays finished.
186    #[wasm_bindgen_test(unsupported = test)]
187    fn once_finishes_on_the_edge_and_holds() {
188        let mut a = FrameAnimation::new([0, 1, 2], 5.0, LoopMode::Once);
189        assert!(!a.tick(0.2));
190        assert_eq!(a.current_frame(), 1);
191        assert!(!a.tick(0.2));
192        assert_eq!(a.current_frame(), 2);
193        // Stepping past the last frame finishes.
194        assert!(a.tick(0.2), "completion edge");
195        assert!(a.is_finished());
196        assert_eq!(a.current_frame(), 2, "holds the last frame");
197        // No re-fire, no movement.
198        assert!(!a.tick(1.0));
199        assert_eq!(a.current_frame(), 2);
200    }
201
202    /// An order `[0,2,1,2]` at 20 fps, starting at position 1.
203    #[wasm_bindgen_test(unsupported = test)]
204    fn chicken_starts_at_frame_two_and_follows_its_order() {
205        let mut a = FrameAnimation::new([0, 2, 1, 2], 20.0, LoopMode::Repeat).start_at(1);
206        let mut seen = vec![a.current_frame()];
207        for _ in 0..5 {
208            a.tick(0.05);
209            seen.push(a.current_frame());
210        }
211        assert_eq!(seen, vec![2, 1, 2, 0, 2, 1]);
212    }
213
214    /// `reset` rewinds and un-finishes.
215    #[wasm_bindgen_test(unsupported = test)]
216    fn reset_restarts_a_finished_animation() {
217        let mut a = FrameAnimation::new([3, 4], 10.0, LoopMode::Once);
218        a.tick(1.0);
219        assert!(a.is_finished());
220        a.reset();
221        assert!(!a.is_finished());
222        assert_eq!(a.current_frame(), 3);
223        assert!(!a.tick(0.1));
224        assert_eq!(a.current_frame(), 4);
225    }
226}