Skip to main content

sgl_core/anim/
sequence.rs

1//! Multi-step frame animation: frame runs, pauses, random pauses, and action
2//! hooks played as one sequence with four loop modes.
3//!
4//! [`FrameAnimation`](super::FrameAnimation) plays a single frame-order array
5//! at a fixed fps. [`AnimationSequence`] is its sibling for animations that are
6//! a *script*: "play the idle frames, then hold for two to five seconds, then
7//! start over".
8//!
9//! Pure `core`: [`tick`](AnimationSequence::tick) takes the caller's `dt` and
10//! the caller's [`Rng`] — random pauses never reach for global randomness, so a
11//! seeded run is reproducible.
12//!
13//! ## Ping-pong
14//!
15//! The reverse pass starts one element in, so neither end plays twice. With one
16//! step this counts frames — `[0, 1, 2]` under
17//! [`SequenceLoop::PingPongOnce`] plays `0, 1, 2, 1, 0` and completes. With
18//! several steps it counts steps: the reverse pass resumes at the
19//! second-to-last step and enters each reversed step at its *last* frame.
20
21use crate::random::Rng;
22
23/// One step of an [`AnimationSequence`].
24#[derive(Debug, Clone, PartialEq)]
25pub enum SequenceStep {
26    /// Play `frames` in order, holding each for `frame_duration` seconds.
27    PlayFrames {
28        /// Sheet-frame indices in play order. Never empty.
29        frames: Vec<usize>,
30        /// Seconds each frame is displayed.
31        frame_duration: f32,
32    },
33    /// Hold the previous frame for a fixed number of seconds.
34    Pause(f32),
35    /// Hold the previous frame for a duration drawn from `[min, max)` when the
36    /// step is entered (exactly `min` when `min == max`).
37    RandomPause {
38        /// Shortest possible hold, in seconds.
39        min: f32,
40        /// Exclusive upper bound of the hold, in seconds.
41        max: f32,
42    },
43    /// Queue a caller-defined action id and continue immediately. The caller
44    /// drains it with [`AnimationSequence::take_action`].
45    Action(u32),
46}
47
48/// Loop behavior for an [`AnimationSequence`].
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SequenceLoop {
51    /// Play the steps once, then report finished.
52    Once,
53    /// Wrap back to the first step forever, rewinding every step.
54    Repeat,
55    /// Play forward, then back, then forward again, forever.
56    PingPongRepeat,
57    /// Play forward, then back, then report finished.
58    PingPongOnce,
59}
60
61/// A playing multi-step animation.
62///
63/// The caller drives it with [`tick`](AnimationSequence::tick) and reads
64/// [`current_frame`](AnimationSequence::current_frame). Pauses, actions, and
65/// completed sequences hold the last frame reached during playback, including
66/// frames crossed within a single tick. The frame is `None` only until playback
67/// first reaches a frame after construction or [`reset`](Self::reset).
68#[derive(Debug, Clone)]
69pub struct AnimationSequence {
70    /// The authored steps. Never empty.
71    steps: Vec<SequenceStep>,
72    /// Per-step frame cursor, parallel to `steps`.
73    cursors: Vec<usize>,
74    /// Most recent frame traversed, held while no frame step is active.
75    held_frame: Option<usize>,
76    loop_mode: SequenceLoop,
77    /// Cursor into `steps`. Equals `steps.len()` once `Once` runs off the end.
78    step: usize,
79    /// Elapsed time within the current step element (seconds).
80    timer: f32,
81    /// Duration of the current step element, resolved lazily so that a random
82    /// pause draws from the caller's [`Rng`] on the tick that enters it.
83    step_duration: Option<f32>,
84    /// Ping-pong direction.
85    forward: bool,
86    /// Action ids queued by this tick, in the order they fired.
87    actions: std::collections::VecDeque<u32>,
88    finished: bool,
89    just_completed: bool,
90}
91
92impl AnimationSequence {
93    /// Start building a sequence.
94    #[must_use]
95    pub fn builder() -> SequenceBuilder {
96        SequenceBuilder::new()
97    }
98
99    /// Advance by `dt` seconds, drawing random-pause durations from `rng`.
100    /// Returns `true` only on the tick where a [`SequenceLoop::Once`] or
101    /// [`SequenceLoop::PingPongOnce`] sequence completes (a completion edge,
102    /// once).
103    ///
104    /// Large `dt` steps through as many frames and steps as it covers and
105    /// carries the remainder, so long-run timing never drifts. Zero-duration
106    /// steps (actions, zero-length pauses) cannot spin: a tick stops after one
107    /// full pass over the steps without consuming time, dropping the remainder.
108    pub fn tick(&mut self, dt: f32, rng: &mut Rng) -> bool {
109        self.just_completed = false;
110        self.actions.clear();
111        if self.finished {
112            return false;
113        }
114        self.timer += dt;
115
116        let mut idle_advances = 0;
117        loop {
118            let duration = self.resolved_duration(rng);
119            if self.timer < duration {
120                return false;
121            }
122            if duration > 0.0 {
123                idle_advances = 0;
124                self.timer -= duration;
125            } else {
126                idle_advances += 1;
127                if idle_advances > self.steps.len() {
128                    self.timer = 0.0;
129                    return false;
130                }
131            }
132            if self.advance() {
133                self.finished = true;
134                self.just_completed = true;
135                self.timer = 0.0;
136                return true;
137            }
138        }
139    }
140
141    /// The sheet-frame index to display right now. Pauses, actions, and
142    /// completed sequences hold the last frame reached during playback.
143    /// Returns `None` only before the first frame is reached after construction
144    /// or [`reset`](Self::reset).
145    #[must_use]
146    pub fn current_frame(&self) -> Option<usize> {
147        match self.steps.get(self.step) {
148            Some(SequenceStep::PlayFrames { frames, .. }) => {
149                frames.get(self.cursors[self.step]).copied()
150            }
151            _ => self.held_frame,
152        }
153    }
154
155    /// Whether the sequence completed on the most recent
156    /// [`tick`](AnimationSequence::tick).
157    #[must_use]
158    pub fn just_completed(&self) -> bool {
159        self.just_completed
160    }
161
162    /// Whether a `Once` or `PingPongOnce` sequence has played out.
163    #[must_use]
164    pub fn is_finished(&self) -> bool {
165        self.finished
166    }
167
168    /// Take the next action id queued by the most recent
169    /// [`tick`](AnimationSequence::tick), oldest first. Drain it in a loop: one
170    /// tick can cross several [`SequenceStep::Action`] steps. The queue is
171    /// cleared at the start of every tick.
172    pub fn take_action(&mut self) -> Option<u32> {
173        self.actions.pop_front()
174    }
175
176    /// Rewind to the first frame of the first step (restarts a finished
177    /// sequence).
178    pub fn reset(&mut self) {
179        self.step = 0;
180        self.timer = 0.0;
181        self.step_duration = None;
182        self.forward = true;
183        self.finished = false;
184        self.just_completed = false;
185        self.actions.clear();
186        self.cursors.fill(0);
187        self.held_frame = None;
188    }
189
190    /// The current step element's duration, resolving (and remembering) a
191    /// random pause on first use.
192    fn resolved_duration(&mut self, rng: &mut Rng) -> f32 {
193        if let Some(duration) = self.step_duration {
194            return duration;
195        }
196        let duration = match &self.steps[self.step] {
197            SequenceStep::PlayFrames { frame_duration, .. } => *frame_duration,
198            SequenceStep::Pause(duration) => *duration,
199            SequenceStep::RandomPause { min, max } => min + rng.f32() * (max - min),
200            SequenceStep::Action(_) => 0.0,
201        };
202        self.step_duration = Some(duration);
203        duration
204    }
205
206    /// Advance one frame within the current step, or move on to the next step.
207    /// Returns `true` when the sequence completes.
208    fn advance(&mut self) -> bool {
209        let cursor = self.cursors[self.step];
210        let next_frame = match &self.steps[self.step] {
211            SequenceStep::PlayFrames { frames, .. } => {
212                self.held_frame = Some(frames[cursor]);
213                if self.forward {
214                    (cursor + 1 < frames.len()).then_some(cursor + 1)
215                } else {
216                    cursor.checked_sub(1)
217                }
218            }
219            _ => None,
220        };
221        if let Some(cursor) = next_frame {
222            self.cursors[self.step] = cursor;
223            self.step_duration = None;
224            return false;
225        }
226        if let SequenceStep::Action(id) = &self.steps[self.step] {
227            self.actions.push_back(*id);
228        }
229        self.next_step()
230    }
231
232    /// Move the step cursor per the loop mode. Returns `true` when the sequence
233    /// completes.
234    fn next_step(&mut self) -> bool {
235        let previous_step = self.step;
236        let previous_forward = self.forward;
237
238        match self.loop_mode {
239            SequenceLoop::Once => {
240                self.step += 1;
241                if self.step >= self.steps.len() {
242                    return true;
243                }
244            }
245            SequenceLoop::Repeat => {
246                self.step += 1;
247                if self.step >= self.steps.len() {
248                    self.step = 0;
249                    self.cursors.fill(0);
250                }
251            }
252            SequenceLoop::PingPongRepeat | SequenceLoop::PingPongOnce => {
253                if self.forward {
254                    self.step += 1;
255                    if self.step >= self.steps.len() {
256                        // Turn around one step in: the last step does not play
257                        // twice.
258                        self.step = self.steps.len().saturating_sub(2);
259                        self.forward = false;
260                    }
261                } else if self.step > 0 {
262                    self.step -= 1;
263                } else if self.loop_mode == SequenceLoop::PingPongOnce {
264                    return true;
265                } else {
266                    self.step = usize::from(self.steps.len() > 1);
267                    self.forward = true;
268                    self.cursors.fill(0);
269                }
270            }
271        }
272
273        if self.step != previous_step || self.forward != previous_forward {
274            self.enter_step(previous_forward);
275        }
276        self.step_duration = None;
277        false
278    }
279
280    /// Place the frame cursor at the entry end of the step just moved to.
281    ///
282    /// A reversed step starts at its last frame — except the single-step
283    /// turnaround, where the end frame just played and the pass starts one
284    /// frame in.
285    fn enter_step(&mut self, previous_forward: bool) {
286        let single_step = self.steps.len() == 1;
287        let forward = self.forward;
288        let SequenceStep::PlayFrames { frames, .. } = &self.steps[self.step] else {
289            return;
290        };
291        let len = frames.len();
292        self.cursors[self.step] = if forward {
293            usize::from(!previous_forward && single_step && len > 1)
294        } else if previous_forward && single_step && len > 1 {
295            len - 2
296        } else {
297            len - 1
298        };
299    }
300}
301
302/// Why a sequence could not be built. Every variant is an authoring error.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub enum SequenceError {
305    /// The builder was given no steps.
306    NoSteps,
307    /// The step at `step` plays an empty frame list.
308    EmptyFrames { step: usize },
309    /// The step at `step` was given a negative or `NaN` duration.
310    BadDuration { step: usize },
311    /// The random pause at `step` has `min > max`, or a negative or `NaN`
312    /// bound.
313    BadRandomRange { step: usize },
314}
315
316impl std::fmt::Display for SequenceError {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        match self {
319            Self::NoSteps => write!(f, "animation sequence has no steps"),
320            Self::EmptyFrames { step } => {
321                write!(f, "animation sequence step {step} plays no frames")
322            }
323            Self::BadDuration { step } => write!(
324                f,
325                "animation sequence step {step} needs a finite, non-negative duration"
326            ),
327            Self::BadRandomRange { step } => write!(
328                f,
329                "animation sequence step {step} needs a finite random pause range with min <= max"
330            ),
331        }
332    }
333}
334
335impl std::error::Error for SequenceError {}
336
337/// Builder for an [`AnimationSequence`]. Steps play in the order they are
338/// added; [`build`](SequenceBuilder::build) rejects authoring errors.
339#[derive(Debug, Clone)]
340pub struct SequenceBuilder {
341    steps: Vec<SequenceStep>,
342    loop_mode: SequenceLoop,
343}
344
345impl SequenceBuilder {
346    fn new() -> Self {
347        Self {
348            steps: Vec::new(),
349            loop_mode: SequenceLoop::Once,
350        }
351    }
352
353    /// Set the loop mode (default [`SequenceLoop::Once`]).
354    #[must_use]
355    pub fn loop_mode(mut self, loop_mode: SequenceLoop) -> Self {
356        self.loop_mode = loop_mode;
357        self
358    }
359
360    /// Play an explicit frame order, holding each frame for `frame_duration`
361    /// seconds.
362    #[must_use]
363    pub fn play_frames(mut self, frames: impl Into<Vec<usize>>, frame_duration: f32) -> Self {
364        self.steps.push(SequenceStep::PlayFrames {
365            frames: frames.into(),
366            frame_duration,
367        });
368        self
369    }
370
371    /// Play the inclusive frame range `first..=last`.
372    #[must_use]
373    pub fn play_frame_range(self, first: usize, last: usize, frame_duration: f32) -> Self {
374        let frames: Vec<usize> = if first <= last {
375            (first..=last).collect()
376        } else {
377            Vec::new()
378        };
379        self.play_frames(frames, frame_duration)
380    }
381
382    /// Hold for `duration` seconds.
383    #[must_use]
384    pub fn pause(mut self, duration: f32) -> Self {
385        self.steps.push(SequenceStep::Pause(duration));
386        self
387    }
388
389    /// Hold for a duration drawn from `[min, max)` when the step is entered.
390    #[must_use]
391    pub fn random_pause(mut self, min: f32, max: f32) -> Self {
392        self.steps.push(SequenceStep::RandomPause { min, max });
393        self
394    }
395
396    /// Queue `id` for the caller when playback reaches this point.
397    #[must_use]
398    pub fn action(mut self, id: u32) -> Self {
399        self.steps.push(SequenceStep::Action(id));
400        self
401    }
402
403    /// Validate and build. An empty step list, an empty `PlayFrames` step, and
404    /// a negative or `NaN` duration are authoring errors, not runtime states.
405    pub fn build(self) -> Result<AnimationSequence, SequenceError> {
406        if self.steps.is_empty() {
407            return Err(SequenceError::NoSteps);
408        }
409        for (step, item) in self.steps.iter().enumerate() {
410            match item {
411                SequenceStep::PlayFrames {
412                    frames,
413                    frame_duration,
414                } => {
415                    if frames.is_empty() {
416                        return Err(SequenceError::EmptyFrames { step });
417                    }
418                    if !frame_duration.is_finite() || *frame_duration < 0.0 {
419                        return Err(SequenceError::BadDuration { step });
420                    }
421                }
422                SequenceStep::Pause(duration) => {
423                    if !duration.is_finite() || *duration < 0.0 {
424                        return Err(SequenceError::BadDuration { step });
425                    }
426                }
427                SequenceStep::RandomPause { min, max } => {
428                    if !min.is_finite() || !max.is_finite() || *min < 0.0 || max < min {
429                        return Err(SequenceError::BadRandomRange { step });
430                    }
431                }
432                SequenceStep::Action(_) => {}
433            }
434        }
435        Ok(AnimationSequence {
436            cursors: vec![0; self.steps.len()],
437            held_frame: None,
438            steps: self.steps,
439            loop_mode: self.loop_mode,
440            step: 0,
441            timer: 0.0,
442            step_duration: None,
443            forward: true,
444            actions: std::collections::VecDeque::new(),
445            finished: false,
446            just_completed: false,
447        })
448    }
449}
450
451impl Default for SequenceBuilder {
452    fn default() -> Self {
453        Self::new()
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use wasm_bindgen_test::wasm_bindgen_test;
461
462    /// #249: a tick over nothing but zero-duration steps makes exactly one
463    /// full pass — every action fires once per step per tick, and the
464    /// sequence keeps repeating on the next tick.
465    #[wasm_bindgen_test(unsupported = test)]
466    fn a_tick_over_zero_duration_steps_makes_one_full_pass() {
467        let mut sequence = AnimationSequence::builder()
468            .loop_mode(SequenceLoop::Repeat)
469            .action(1)
470            .action(2)
471            .build()
472            .unwrap();
473        let mut rng = rng();
474        for _ in 0..2 {
475            sequence.tick(0.5, &mut rng);
476            let mut fired = Vec::new();
477            while let Some(action) = sequence.take_action() {
478                fired.push(action);
479            }
480            assert_eq!(fired.len(), 2, "one action per step per tick: {fired:?}");
481        }
482    }
483
484    /// #249: a two-step ping-pong that repeats restarts its forward pass at
485    /// the second step, entered at its first frame, so the first step (which
486    /// just played in reverse) does not play twice in a row.
487    #[wasm_bindgen_test(unsupported = test)]
488    fn two_step_pingpong_repeat_restarts_one_step_in() {
489        let mut sequence = AnimationSequence::builder()
490            .loop_mode(SequenceLoop::PingPongRepeat)
491            .play_frames(vec![0, 1], 0.1)
492            .play_frames(vec![2, 3], 0.1)
493            .build()
494            .unwrap();
495        let mut rng = rng();
496        let mut seen = vec![sequence.current_frame()];
497        for _ in 0..9 {
498            sequence.tick(0.1, &mut rng);
499            seen.push(sequence.current_frame());
500        }
501        assert_eq!(seen, [0, 1, 2, 3, 1, 0, 2, 3, 1, 0].map(Some).to_vec());
502    }
503
504    /// #249: a tick past several frame boundaries keeps the remainder, so
505    /// the next small tick crosses the following boundary on time.
506    #[wasm_bindgen_test(unsupported = test)]
507    fn a_remainder_carries_into_the_next_tick() {
508        let mut sequence = AnimationSequence::builder()
509            .loop_mode(SequenceLoop::Repeat)
510            .play_frames(vec![0, 1, 2, 3, 4], 0.1)
511            .build()
512            .unwrap();
513        let mut rng = rng();
514        sequence.tick(0.25, &mut rng);
515        assert_eq!(sequence.current_frame(), Some(2));
516        sequence.tick(0.05, &mut rng);
517        assert_eq!(
518            sequence.current_frame(),
519            Some(3),
520            "0.05 s remainder carried"
521        );
522    }
523
524    /// #249: two ping-pong steps of two frames play 0 1 2 3, turn around one
525    /// step in entering it at its last frame, and finish: 1 0.
526    #[wasm_bindgen_test(unsupported = test)]
527    fn two_step_pingpong_enters_reversed_steps_at_their_last_frame() {
528        let mut sequence = AnimationSequence::builder()
529            .loop_mode(SequenceLoop::PingPongOnce)
530            .play_frames(vec![0, 1], 0.1)
531            .play_frames(vec![2, 3], 0.1)
532            .build()
533            .unwrap();
534        let mut rng = rng();
535        let mut seen = vec![sequence.current_frame()];
536        for _ in 0..6 {
537            sequence.tick(0.1, &mut rng);
538            seen.push(sequence.current_frame());
539        }
540        // The finished sequence keeps showing its last frame.
541        assert_eq!(
542            seen,
543            vec![
544                Some(0),
545                Some(1),
546                Some(2),
547                Some(3),
548                Some(1),
549                Some(0),
550                Some(0)
551            ]
552        );
553        assert!(sequence.is_finished());
554    }
555
556    /// #249: a single one-frame step ping-pongs without underflowing the
557    /// reversed cursor, and finishes.
558    #[wasm_bindgen_test(unsupported = test)]
559    fn a_single_one_frame_step_pingpongs_without_panicking() {
560        let mut sequence = AnimationSequence::builder()
561            .loop_mode(SequenceLoop::PingPongOnce)
562            .play_frames(vec![7], 0.1)
563            .build()
564            .unwrap();
565        let mut rng = rng();
566        for _ in 0..4 {
567            sequence.tick(0.1, &mut rng);
568            assert!(matches!(sequence.current_frame(), Some(7) | None));
569        }
570        assert!(sequence.is_finished());
571    }
572
573    /// #249: the builder's boundaries — a zero frame duration and a random
574    /// pause with equal bounds are valid; an inverted random range is not.
575    #[wasm_bindgen_test(unsupported = test)]
576    fn the_builder_accepts_zero_durations_and_equal_random_bounds() {
577        assert!(
578            AnimationSequence::builder()
579                .play_frames(vec![0], 0.0)
580                .random_pause(0.5, 0.5)
581                .build()
582                .is_ok()
583        );
584        assert_eq!(
585            AnimationSequence::builder()
586                .random_pause(0.6, 0.5)
587                .build()
588                .err(),
589            Some(SequenceError::BadRandomRange { step: 0 })
590        );
591    }
592
593    fn rng() -> Rng {
594        Rng::from_seed(1)
595    }
596
597    /// The frames displayed by `ticks` ticks of `dt`, starting with the frame
598    /// shown before the first tick. Stops at the completion edge.
599    fn play(seq: &mut AnimationSequence, dt: f32, ticks: usize) -> Vec<Option<usize>> {
600        let mut rng = rng();
601        let mut seen = vec![seq.current_frame()];
602        for _ in 0..ticks {
603            if seq.tick(dt, &mut rng) {
604                break;
605            }
606            seen.push(seq.current_frame());
607        }
608        seen
609    }
610
611    fn single(frames: [usize; 3], loop_mode: SequenceLoop) -> AnimationSequence {
612        AnimationSequence::builder()
613            .loop_mode(loop_mode)
614            .play_frames(frames, 0.1)
615            .build()
616            .expect("valid sequence")
617    }
618
619    /// Single-step ping-pong turns around one frame in, so neither end plays
620    /// twice: `0,1,2,1,0` then complete.
621    #[wasm_bindgen_test(unsupported = test)]
622    fn single_step_pingpong_once_plays_up_and_back() {
623        let mut seq = single([0, 1, 2], SequenceLoop::PingPongOnce);
624        assert_eq!(
625            play(&mut seq, 0.1, 10),
626            vec![Some(0), Some(1), Some(2), Some(1), Some(0)]
627        );
628        assert!(seq.is_finished() && seq.just_completed());
629        let mut rng = rng();
630        assert!(!seq.tick(0.1, &mut rng), "the completion edge fires once");
631        assert!(!seq.just_completed());
632    }
633
634    /// Ping-pong repeat keeps bouncing: `0,1,2,1,0,1,2,1,0,…`.
635    #[wasm_bindgen_test(unsupported = test)]
636    fn single_step_pingpong_repeat_bounces_forever() {
637        let mut seq = single([0, 1, 2], SequenceLoop::PingPongRepeat);
638        assert_eq!(
639            play(&mut seq, 0.1, 8),
640            vec![
641                Some(0),
642                Some(1),
643                Some(2),
644                Some(1),
645                Some(0),
646                Some(1),
647                Some(2),
648                Some(1),
649                Some(0),
650            ]
651        );
652        assert!(!seq.is_finished());
653    }
654
655    /// Multi-step ping-pong reverses the *step* order, turning around one step
656    /// in, and enters each reversed step at its last frame.
657    #[wasm_bindgen_test(unsupported = test)]
658    fn multi_step_pingpong_reverses_step_order() {
659        let mut seq = AnimationSequence::builder()
660            .loop_mode(SequenceLoop::PingPongOnce)
661            .play_frames([0, 1], 0.1)
662            .play_frames([2, 3], 0.1)
663            .play_frames([4, 5], 0.1)
664            .build()
665            .expect("valid sequence");
666        // Forward through all three steps, then back down through steps 1 and
667        // 0 — step 2 is the turnaround and does not replay — each entered at
668        // its last frame.
669        assert_eq!(
670            play(&mut seq, 0.1, 20),
671            [0, 1, 2, 3, 4, 5, 3, 2, 1, 0].map(Some).to_vec()
672        );
673        assert!(seq.just_completed());
674    }
675
676    /// `Repeat` wraps to the first step and rewinds every step's frame cursor,
677    /// so the second pass replays the first pass exactly.
678    #[wasm_bindgen_test(unsupported = test)]
679    fn repeat_rewinds_every_step_on_wrap() {
680        let mut seq = AnimationSequence::builder()
681            .loop_mode(SequenceLoop::Repeat)
682            .play_frames([0, 1], 0.1)
683            .play_frames([7, 8], 0.1)
684            .build()
685            .expect("valid sequence");
686        assert_eq!(
687            play(&mut seq, 0.1, 8),
688            [0, 1, 7, 8, 0, 1, 7, 8, 0].map(Some).to_vec()
689        );
690        assert!(!seq.is_finished());
691    }
692
693    /// A large `dt` steps several frames and carries the remainder instead of
694    /// discarding it, so a slow frame does not slow the animation down.
695    #[wasm_bindgen_test(unsupported = test)]
696    fn large_dt_steps_several_frames_and_carries_the_remainder() {
697        let mut seq = AnimationSequence::builder()
698            .loop_mode(SequenceLoop::Repeat)
699            .play_frames([0, 1, 2, 3], 0.1)
700            .build()
701            .expect("valid sequence");
702        let mut rng = rng();
703        // 0.35 s = three whole frames plus 0.05 s.
704        assert!(!seq.tick(0.35, &mut rng));
705        assert_eq!(seq.current_frame(), Some(3));
706        // The carried 0.05 s completes the fourth frame.
707        assert!(!seq.tick(0.05, &mut rng));
708        assert_eq!(seq.current_frame(), Some(0));
709    }
710
711    /// One tick's leftover time carries across step boundaries too.
712    #[wasm_bindgen_test(unsupported = test)]
713    fn leftover_time_carries_across_steps() {
714        let mut seq = AnimationSequence::builder()
715            .play_frames([0], 0.1)
716            .pause(0.1)
717            .play_frames([5, 6], 0.1)
718            .build()
719            .expect("valid sequence");
720        let mut rng = rng();
721        // 0.25 s = frame step (0.1) + pause (0.1) + 0.05 s into the last step.
722        assert!(!seq.tick(0.25, &mut rng));
723        assert_eq!(seq.current_frame(), Some(5));
724        assert!(!seq.tick(0.05, &mut rng));
725        assert_eq!(seq.current_frame(), Some(6));
726    }
727
728    /// The authored timeline shows frame 1 from 0.125 s through the pause,
729    /// even when no tick stops on that frame before entering the pause.
730    #[wasm_bindgen_test(unsupported = test)]
731    fn large_ticks_hold_the_last_frame_through_pauses() {
732        let frames = AnimationSequence::builder().play_frames([0, 1], 0.125);
733        for builder in [frames.clone().pause(1.0), frames.random_pause(1.0, 2.0)] {
734            for schedule in [&[0.25][..], &[0.125, 0.125][..]] {
735                let mut seq = builder.clone().build().unwrap();
736                let mut rng = rng();
737                for dt in schedule {
738                    assert!(!seq.tick(*dt, &mut rng));
739                }
740                assert_eq!(seq.current_frame(), Some(1), "entered the pause");
741                assert!(!seq.tick(0.5, &mut rng));
742                assert_eq!(seq.current_frame(), Some(1), "still in the pause");
743            }
744        }
745    }
746
747    /// Completion must retain the authored endpoint even if the entire run
748    /// was crossed in one tick; ping-pong ends back on its first frame.
749    #[wasm_bindgen_test(unsupported = test)]
750    fn completing_tick_holds_the_last_traversed_frame() {
751        for (loop_mode, endpoint) in [(SequenceLoop::Once, 5), (SequenceLoop::PingPongOnce, 3)] {
752            let mut seq = AnimationSequence::builder()
753                .loop_mode(loop_mode)
754                .play_frames([3, 4, 5], 0.125)
755                .build()
756                .unwrap();
757            let mut rng = rng();
758            assert!(seq.tick(1.0, &mut rng));
759            assert_eq!(seq.current_frame(), Some(endpoint));
760            assert!(!seq.tick(1.0, &mut rng));
761            assert_eq!(seq.current_frame(), Some(endpoint));
762        }
763    }
764
765    /// On the reverse pass a pause holds the frame just played, not the
766    /// last frame of the step that precedes the pause in authored order.
767    #[wasm_bindgen_test(unsupported = test)]
768    fn reversed_pause_holds_the_frame_at_the_turnaround() {
769        let mut seq = AnimationSequence::builder()
770            .loop_mode(SequenceLoop::PingPongOnce)
771            .play_frames([0, 1], 0.125)
772            .pause(0.5)
773            .play_frames([2, 3], 0.125)
774            .build()
775            .unwrap();
776        let mut rng = rng();
777        assert!(!seq.tick(1.0, &mut rng));
778        assert_eq!(seq.current_frame(), Some(3), "reverse pause at 1.0 s");
779        assert!(seq.tick(0.75, &mut rng));
780        assert_eq!(seq.current_frame(), Some(0), "reverse run ends at 1.75 s");
781    }
782
783    /// A leading pause has no frame to hold; resetting into it must discard
784    /// the completed run's endpoint until playback reaches a frame again.
785    #[wasm_bindgen_test(unsupported = test)]
786    fn reset_clears_the_held_frame_before_a_leading_pause() {
787        let mut seq = AnimationSequence::builder()
788            .pause(0.25)
789            .play_frames([4, 7], 0.125)
790            .build()
791            .unwrap();
792        let mut rng = rng();
793        assert!(seq.tick(0.5, &mut rng));
794        assert_eq!(seq.current_frame(), Some(7));
795        seq.reset();
796        assert!(!seq.tick(0.125, &mut rng));
797        assert_eq!(seq.current_frame(), None, "no frame reached after reset");
798        assert!(!seq.tick(0.125, &mut rng));
799        assert_eq!(seq.current_frame(), Some(4), "first frame reached again");
800    }
801
802    /// A `Repeat` sequence made only of zero-duration steps must not spin
803    /// inside one tick: it stops after one pass and drops the remainder.
804    #[wasm_bindgen_test(unsupported = test)]
805    fn zero_duration_steps_cannot_spin_within_a_tick() {
806        let mut seq = AnimationSequence::builder()
807            .loop_mode(SequenceLoop::Repeat)
808            .action(3)
809            .pause(0.0)
810            .random_pause(0.0, 0.0)
811            .build()
812            .expect("valid sequence");
813        let mut rng = rng();
814        assert!(!seq.tick(60.0, &mut rng), "Repeat never completes");
815        assert_eq!(seq.take_action(), Some(3));
816        assert_eq!(seq.take_action(), None, "one pass fires one action");
817        assert!(!seq.tick(60.0, &mut rng));
818    }
819
820    /// Actions queue in order and drain once; a tick that crosses two action
821    /// steps yields both.
822    #[wasm_bindgen_test(unsupported = test)]
823    fn actions_queue_in_order_and_drain_once() {
824        let mut seq = AnimationSequence::builder()
825            .play_frames([0], 0.1)
826            .action(7)
827            .action(9)
828            .play_frames([1], 0.1)
829            .build()
830            .expect("valid sequence");
831        let mut rng = rng();
832        assert_eq!(
833            seq.take_action(),
834            None,
835            "nothing fires before the first tick"
836        );
837        assert!(!seq.tick(0.1, &mut rng));
838        assert_eq!(seq.current_frame(), Some(1), "both actions were crossed");
839        assert_eq!(seq.take_action(), Some(7));
840        assert_eq!(seq.take_action(), Some(9));
841        assert_eq!(seq.take_action(), None);
842        assert!(seq.tick(0.1, &mut rng), "the last frame completes the run");
843        assert_eq!(seq.take_action(), None, "actions do not re-fire");
844    }
845
846    /// `min == max` pauses for exactly that long: a 0.5 s pause ends on the
847    /// fourth 0.125 s tick, not before.
848    #[wasm_bindgen_test(unsupported = test)]
849    fn an_exact_random_pause_lasts_exactly_min() {
850        let mut seq = AnimationSequence::builder()
851            .play_frames([4], 0.125)
852            .random_pause(0.5, 0.5)
853            .build()
854            .expect("valid sequence");
855        let mut rng = rng();
856        assert!(!seq.tick(0.125, &mut rng), "off the frame, onto the pause");
857        assert_eq!(
858            seq.current_frame(),
859            Some(4),
860            "a pause holds its prior frame"
861        );
862        for tick in 1..4 {
863            assert!(
864                !seq.tick(0.125, &mut rng),
865                "still pausing after {tick} ticks"
866            );
867        }
868        assert!(seq.tick(0.125, &mut rng), "0.5 s of pause has elapsed");
869    }
870
871    /// A random pause stays inside its authored bounds and is reproducible
872    /// from the seed: the same seed replays the same run, and a global source
873    /// of randomness could not.
874    #[wasm_bindgen_test(unsupported = test)]
875    fn random_pauses_are_bounded_and_seed_reproducible() {
876        let build = || {
877            AnimationSequence::builder()
878                .random_pause(0.4, 0.8)
879                .build()
880                .expect("valid sequence")
881        };
882        // Ticks of 0.05 s to cross a hold in [0.4, 0.8) → 8..=16 ticks.
883        let ticks_to_finish = |seed: u64| {
884            let mut seq = build();
885            let mut rng = Rng::from_seed(seed);
886            (1..=100)
887                .find(|_| seq.tick(0.05, &mut rng))
888                .expect("the pause ends")
889        };
890        for seed in 0..32 {
891            let ticks = ticks_to_finish(seed);
892            assert!(
893                (8..=16).contains(&ticks),
894                "seed {seed} paused {ticks} ticks"
895            );
896            assert_eq!(ticks, ticks_to_finish(seed), "seed {seed} is reproducible");
897        }
898    }
899
900    /// Only random pauses consume the caller's RNG, so adding an animation to
901    /// a frame cannot shift a seeded simulation's random stream.
902    #[wasm_bindgen_test(unsupported = test)]
903    fn steps_without_randomness_do_not_draw_from_the_rng() {
904        let mut seq = AnimationSequence::builder()
905            .loop_mode(SequenceLoop::Repeat)
906            .play_frames([0, 1], 0.1)
907            .pause(0.2)
908            .action(1)
909            .build()
910            .expect("valid sequence");
911        let mut used = Rng::from_seed(5);
912        for _ in 0..50 {
913            seq.tick(0.1, &mut used);
914        }
915        let mut untouched = Rng::from_seed(5);
916        assert_eq!(used.u32(0..u32::MAX), untouched.u32(0..u32::MAX));
917    }
918
919    /// `reset` rewinds a finished sequence to its first frame.
920    #[wasm_bindgen_test(unsupported = test)]
921    fn reset_restarts_a_finished_sequence() {
922        let mut seq = single([3, 4, 5], SequenceLoop::Once);
923        let mut rng = rng();
924        while !seq.is_finished() {
925            seq.tick(0.1, &mut rng);
926        }
927        assert_eq!(seq.current_frame(), Some(5), "Once holds its last frame");
928        seq.reset();
929        assert!(!seq.is_finished() && !seq.just_completed());
930        assert_eq!(seq.current_frame(), Some(3));
931        assert!(!seq.tick(0.1, &mut rng));
932        assert_eq!(seq.current_frame(), Some(4));
933    }
934
935    /// Authoring errors are rejected at build time rather than panicking or
936    /// stalling at runtime.
937    #[wasm_bindgen_test(unsupported = test)]
938    fn the_builder_rejects_authoring_errors() {
939        let err = |builder: SequenceBuilder| builder.build().expect_err("authoring error");
940        assert_eq!(err(AnimationSequence::builder()), SequenceError::NoSteps);
941        assert_eq!(
942            err(AnimationSequence::builder().play_frames(Vec::new(), 0.1)),
943            SequenceError::EmptyFrames { step: 0 }
944        );
945        assert_eq!(
946            err(AnimationSequence::builder().play_frame_range(4, 2, 0.1)),
947            SequenceError::EmptyFrames { step: 0 },
948            "a backwards range plays nothing"
949        );
950        assert_eq!(
951            err(AnimationSequence::builder().play_frames([0], -0.1)),
952            SequenceError::BadDuration { step: 0 }
953        );
954        assert_eq!(
955            err(AnimationSequence::builder()
956                .play_frames([0], 0.1)
957                .pause(f32::NAN)),
958            SequenceError::BadDuration { step: 1 }
959        );
960        assert_eq!(
961            err(AnimationSequence::builder().pause(f32::INFINITY)),
962            SequenceError::BadDuration { step: 0 }
963        );
964        assert_eq!(
965            err(AnimationSequence::builder().random_pause(2.0, 1.0)),
966            SequenceError::BadRandomRange { step: 0 }
967        );
968        assert_eq!(
969            err(AnimationSequence::builder().random_pause(f32::NAN, 1.0)),
970            SequenceError::BadRandomRange { step: 0 }
971        );
972        assert!(
973            AnimationSequence::builder()
974                .random_pause(0.0, 0.0)
975                .build()
976                .is_ok(),
977            "a zero-length random pause is legitimate"
978        );
979    }
980
981    /// `play_frame_range` is the inclusive range the Aseprite tags use.
982    #[wasm_bindgen_test(unsupported = test)]
983    fn play_frame_range_is_inclusive() {
984        let mut seq = AnimationSequence::builder()
985            .loop_mode(SequenceLoop::Once)
986            .play_frame_range(8, 11, 0.1)
987            .build()
988            .expect("valid sequence");
989        assert_eq!(play(&mut seq, 0.1, 10), [8, 9, 10, 11].map(Some).to_vec());
990    }
991}