sgl-core 0.5.0

Deterministic timing, grids, hashing, animation, and collision for SGL games
Documentation
//! Pure frame-animation driver over an explicit **frame order** array.
//!
//! Ping-pong and other patterns are authored as sheet-frame order data. The
//! driver owns a cursor into the order array
//! and an elapsed-time accumulator; the caller queries
//! [`current_frame`](FrameAnimation::current_frame) and cuts the sheet.
//!
//! Pure `core`: no render, no wgpu, no clocks — [`tick`](FrameAnimation::tick)
//! takes the caller's `dt`.
//!
//! [`AnimationSequence`] is the sibling driver for animations scripted as a
//! list of steps: frame runs, pauses, random pauses, and action hooks, with
//! ping-pong loop modes.

mod sequence;

pub use sequence::{AnimationSequence, SequenceBuilder, SequenceError, SequenceLoop, SequenceStep};

/// Loop behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoopMode {
    /// Play the order once, then hold the last frame and report finished.
    Once,
    /// Wrap back to the start of the order forever.
    Repeat,
}

/// A playing frame animation: an explicit frame-order array stepped at a
/// fixed fps.
///
/// Frame `order[i]` is displayed for `t ∈ [i·d, (i+1)·d)` where `d = 1/fps`;
/// accumulating **exactly** one frame duration advances the cursor.
#[derive(Debug, Clone)]
pub struct FrameAnimation {
    /// Sheet-frame indices in play order (e.g. `[0, 1, 2, 1]` ping-pong as
    /// data). Never empty.
    order: Vec<usize>,
    /// Seconds each order entry is displayed (`1 / fps`).
    frame_duration: f32,
    loop_mode: LoopMode,
    /// Cursor into `order`.
    position: usize,
    /// Elapsed time within the current order entry (seconds).
    timer: f32,
    finished: bool,
}

impl FrameAnimation {
    /// An animation playing `order` at `fps`. Starts at the first entry.
    ///
    /// # Panics
    /// If `order` is empty or `fps` is not finite and positive — both are
    /// authoring errors, not runtime conditions.
    pub fn new(order: impl Into<Vec<usize>>, fps: f32, loop_mode: LoopMode) -> Self {
        let order = order.into();
        assert!(!order.is_empty(), "FrameAnimation: empty frame order");
        assert!(
            fps.is_finite() && fps > 0.0,
            "FrameAnimation: fps must be finite and positive, got {fps}"
        );
        Self {
            order,
            frame_duration: 1.0 / fps,
            loop_mode,
            position: 0,
            timer: 0.0,
            finished: false,
        }
    }

    /// Start at `position` within the order (clamped to the last entry) —
    /// For example, an animation may start at order position 1.
    #[must_use]
    pub fn start_at(mut self, position: usize) -> Self {
        self.position = position.min(self.order.len() - 1);
        self
    }

    /// Advance by `dt` seconds. Returns `true` only on the tick where a
    /// [`LoopMode::Once`] animation finishes (a completion edge, once).
    ///
    /// Large `dt` steps multiple frames; the leftover carries into the new
    /// frame so long-run timing never drifts. A nonpositive or non-finite `dt`
    /// is ignored. A [`LoopMode::Repeat`] tick whose frame duration is too
    /// small for `f32` to subtract from the accumulated time drops the remainder.
    pub fn tick(&mut self, dt: f32) -> bool {
        if self.finished || !dt.is_finite() || dt <= 0.0 {
            return false;
        }
        self.timer += dt;
        while self.timer >= self.frame_duration {
            let rest = self.timer - self.frame_duration;
            if rest >= self.timer && self.loop_mode == LoopMode::Repeat {
                // The subtraction made no progress (a duration below the
                // timer's precision, or a timer overflowed to infinity), so
                // `Repeat` would spin. `Once` needs no guard: it finishes
                // within one pass.
                self.timer = 0.0;
                return false;
            }
            self.timer = rest;
            if self.position + 1 < self.order.len() {
                self.position += 1;
            } else {
                match self.loop_mode {
                    LoopMode::Repeat => self.position = 0,
                    LoopMode::Once => {
                        // Hold the last frame; drop leftover time.
                        self.finished = true;
                        self.timer = 0.0;
                        return true;
                    }
                }
            }
        }
        false
    }

    /// The sheet-frame index to display right now.
    #[must_use]
    pub fn current_frame(&self) -> usize {
        self.order[self.position]
    }

    /// The cursor position within the order array.
    #[must_use]
    pub fn position(&self) -> usize {
        self.position
    }

    /// Whether a [`LoopMode::Once`] animation has played out.
    #[must_use]
    pub fn is_finished(&self) -> bool {
        self.finished
    }

    /// Rewind to the first order entry (restarts a finished animation).
    pub fn reset(&mut self) {
        self.position = 0;
        self.timer = 0.0;
        self.finished = false;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use wasm_bindgen_test::wasm_bindgen_test;

    /// #249: `start_at` positions the cursor (clamped to the order's end) and
    /// `position` reports it; ticking continues from there.
    #[wasm_bindgen_test(unsupported = test)]
    fn start_at_positions_the_cursor() {
        let anim = FrameAnimation::new(vec![5, 6, 7, 8], 10.0, LoopMode::Repeat).start_at(2);
        assert_eq!(anim.position(), 2);
        assert_eq!(anim.current_frame(), 7);
        let mut anim = anim;
        anim.tick(0.1);
        assert_eq!(anim.position(), 3);
        let clamped = FrameAnimation::new(vec![5, 6], 10.0, LoopMode::Repeat).start_at(9);
        assert_eq!(clamped.position(), 1);
    }

    /// A `[0,1,2,1]` order at 10 fps, repeating. Exact 0.1 s
    /// ticks advance exactly one order entry each (boundary semantics).
    #[wasm_bindgen_test(unsupported = test)]
    fn repeat_order_cycles_at_exact_fps_boundaries() {
        let mut a = FrameAnimation::new([0, 1, 2, 1], 10.0, LoopMode::Repeat);
        let mut seen = vec![a.current_frame()];
        for _ in 0..8 {
            assert!(!a.tick(0.1), "Repeat never reports finished");
            seen.push(a.current_frame());
        }
        assert_eq!(seen, vec![0, 1, 2, 1, 0, 1, 2, 1, 0]);
    }

    /// Just below a frame boundary the frame must NOT advance; reaching it
    /// exactly must.
    #[wasm_bindgen_test(unsupported = test)]
    fn sub_boundary_tick_does_not_advance() {
        let mut a = FrameAnimation::new([0, 1], 10.0, LoopMode::Repeat);
        a.tick(0.099);
        assert_eq!(a.current_frame(), 0, "0.099 s < 0.1 s: still frame 0");
        a.tick(0.001);
        assert_eq!(a.current_frame(), 1, "accumulated exactly 0.1 s: frame 1");
    }

    /// A large dt steps several frames and carries the remainder (no drift).
    #[wasm_bindgen_test(unsupported = test)]
    fn large_dt_steps_multiple_frames() {
        let mut a = FrameAnimation::new([0, 1, 2, 1], 10.0, LoopMode::Repeat);
        a.tick(0.25); // 2 full frames + 0.05 leftover
        assert_eq!(a.current_frame(), 2);
        a.tick(0.05); // leftover reaches the 3rd boundary
        assert_eq!(a.current_frame(), 1);
    }

    /// `Once` holds the last frame, reports the completion edge exactly once,
    /// and stays finished.
    #[wasm_bindgen_test(unsupported = test)]
    fn once_finishes_on_the_edge_and_holds() {
        let mut a = FrameAnimation::new([0, 1, 2], 5.0, LoopMode::Once);
        assert!(!a.tick(0.2));
        assert_eq!(a.current_frame(), 1);
        assert!(!a.tick(0.2));
        assert_eq!(a.current_frame(), 2);
        // Stepping past the last frame finishes.
        assert!(a.tick(0.2), "completion edge");
        assert!(a.is_finished());
        assert_eq!(a.current_frame(), 2, "holds the last frame");
        // No re-fire, no movement.
        assert!(!a.tick(1.0));
        assert_eq!(a.current_frame(), 2);
    }

    /// An order `[0,2,1,2]` at 20 fps, starting at position 1.
    #[wasm_bindgen_test(unsupported = test)]
    fn chicken_starts_at_frame_two_and_follows_its_order() {
        let mut a = FrameAnimation::new([0, 2, 1, 2], 20.0, LoopMode::Repeat).start_at(1);
        let mut seen = vec![a.current_frame()];
        for _ in 0..5 {
            a.tick(0.05);
            seen.push(a.current_frame());
        }
        assert_eq!(seen, vec![2, 1, 2, 0, 2, 1]);
    }

    /// #313: a non-finite `dt` neither hangs a repeating animation nor
    /// poisons its clock — the next finite tick advances on time.
    #[wasm_bindgen_test(unsupported = test)]
    fn a_non_finite_dt_is_ignored() {
        let mut a = FrameAnimation::new([0, 1], 10.0, LoopMode::Repeat);
        for dt in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            assert!(!a.tick(dt));
            assert_eq!(a.current_frame(), 0);
        }
        a.tick(0.1);
        assert_eq!(a.current_frame(), 1);
    }

    /// A backward clock step must neither delay the next frame nor erase
    /// time already accumulated toward it.
    #[wasm_bindgen_test(unsupported = test)]
    fn nonpositive_dt_does_not_build_time_debt() {
        for loop_mode in [LoopMode::Once, LoopMode::Repeat] {
            let mut anim = FrameAnimation::new([3, 4, 5], 10.0, loop_mode);
            assert!(!anim.tick(-1.0));
            assert!(!anim.tick(0.1));
            assert_eq!(anim.current_frame(), 4);
            assert!(!anim.tick(0.05));
            for dt in [-1.0, 0.0, -0.0] {
                assert!(!anim.tick(dt));
            }
            assert!(!anim.tick(0.05));
            assert_eq!(anim.current_frame(), 5);
            assert_eq!(anim.tick(0.1), loop_mode == LoopMode::Once);
            assert_eq!(anim.is_finished(), loop_mode == LoopMode::Once);
        }
    }

    /// #313: a frame duration below the timer's `f32` precision (2^-30 s
    /// against a 1 s tick) returns, dropping the remainder, and the next
    /// frame's worth of time advances the cursor.
    #[wasm_bindgen_test(unsupported = test)]
    fn a_frame_duration_below_the_timer_precision_drops_the_remainder() {
        let fps = 2f32.powi(30);
        let mut a = FrameAnimation::new([0, 1], fps, LoopMode::Repeat);
        a.tick(1.0);
        assert_eq!(a.current_frame(), 0);
        a.tick(1.0 / fps);
        assert_eq!(a.current_frame(), 1);
    }

    /// #313: frame `i` shows for `[i·d, (i+1)·d)`, so 0.4 s into `[0, 1, 2]`
    /// at 10 fps is frame 1 of the second pass.
    #[wasm_bindgen_test(unsupported = test)]
    fn a_tick_past_a_whole_pass_lands_on_the_spec_frame() {
        let mut a = FrameAnimation::new([0, 1, 2], 10.0, LoopMode::Repeat);
        a.tick(0.4);
        assert_eq!(a.current_frame(), 1);
    }

    /// #313: an infinite fps (a zero frame duration) is an authoring error.
    #[cfg(not(target_arch = "wasm32"))]
    #[test]
    #[should_panic(expected = "fps must be finite and positive")]
    fn infinite_fps_is_rejected() {
        let _ = FrameAnimation::new([0], f32::INFINITY, LoopMode::Repeat);
    }

    /// `reset` rewinds and un-finishes.
    #[wasm_bindgen_test(unsupported = test)]
    fn reset_restarts_a_finished_animation() {
        let mut a = FrameAnimation::new([3, 4], 10.0, LoopMode::Once);
        a.tick(1.0);
        assert!(a.is_finished());
        a.reset();
        assert!(!a.is_finished());
        assert_eq!(a.current_frame(), 3);
        assert!(!a.tick(0.1));
        assert_eq!(a.current_frame(), 4);
    }
}