ballin 0.1.2

A colorful interactive physics simulator with thousands of balls, but in your terminal.
Documentation
//! Frame timing and FPS management.
//!
//! This module provides frame rate control and FPS measurement for the
//! simulation. It uses a simple frame budget approach with sleep to maintain
//! consistent frame rates.

use std::collections::VecDeque;
use std::time::{Duration, Instant};

/// Maximum number of frame time samples to keep for FPS averaging.
const MAX_SAMPLES: usize = 60;

/// Manages frame timing for consistent FPS.
///
/// Uses a frame budget approach: tracks time since last frame and optionally
/// sleeps if ahead of target frame time. Also maintains a rolling average of
/// frame times for accurate FPS display.
///
/// # Example
///
/// ```rust,no_run
/// use ballin::timing::FrameTimer;
///
/// let mut timer = FrameTimer::new();
/// loop {
///     let delta = timer.begin_frame();
///     // ... update simulation ...
///     timer.end_frame();
///     println!("FPS: {:.1}", timer.current_fps());
/// }
/// ```
pub struct FrameTimer {
    /// Target duration per frame (when capped).
    target_frame_time: Duration,

    /// Whether frame rate capping is enabled.
    fps_cap_enabled: bool,

    /// Timestamp when the current frame started.
    frame_start: Instant,

    /// Timestamp of the previous frame start (for delta calculation).
    last_frame_start: Instant,

    /// Rolling window of recent frame durations for FPS averaging.
    frame_times: VecDeque<Duration>,

    /// Accumulator for physics timestep synchronization.
    /// Tracks leftover time from previous frames.
    physics_accumulator: f32,
}

impl FrameTimer {
    /// Creates a new frame timer.
    ///
    /// By default, runs uncapped (as fast as possible).
    /// Use `set_fps_cap` to enable 60 FPS capping.
    ///
    /// # Returns
    ///
    /// A new `FrameTimer` ready to begin timing frames.
    pub fn new() -> Self {
        let now = Instant::now();
        Self {
            target_frame_time: Duration::from_secs_f64(1.0 / 60.0),
            fps_cap_enabled: false,
            frame_start: now,
            last_frame_start: now,
            frame_times: VecDeque::with_capacity(MAX_SAMPLES),
            physics_accumulator: 0.0,
        }
    }

    /// Enables or disables 60 FPS cap at runtime.
    ///
    /// # Arguments
    ///
    /// * `enabled` - Whether to cap frame rate at 60 FPS
    pub fn set_fps_cap(&mut self, enabled: bool) {
        self.fps_cap_enabled = enabled;
    }

    /// Called at the start of each frame.
    ///
    /// Records the frame start time and calculates delta time since
    /// the last frame.
    ///
    /// # Returns
    ///
    /// The duration since the last frame started (delta time).
    pub fn begin_frame(&mut self) -> Duration {
        self.last_frame_start = self.frame_start;
        self.frame_start = Instant::now();
        self.frame_start.duration_since(self.last_frame_start)
    }

    /// Called at the end of each frame.
    ///
    /// Records the frame duration for FPS calculation. If FPS capping
    /// is enabled, sleeps to maintain 60 FPS target.
    pub fn end_frame(&mut self) {
        let elapsed = self.frame_start.elapsed();

        // Track frame time for FPS calculation
        self.frame_times.push_back(elapsed);
        if self.frame_times.len() > MAX_SAMPLES {
            self.frame_times.pop_front();
        }

        // Only sleep if FPS capping is enabled
        if self.fps_cap_enabled && elapsed < self.target_frame_time {
            std::thread::sleep(self.target_frame_time - elapsed);
        }
    }

    /// Returns the current average FPS.
    ///
    /// Calculated from the rolling window of recent frame times.
    /// Returns to two significant figures as per requirements.
    ///
    /// # Returns
    ///
    /// Average frames per second, or 0.0 if no samples yet.
    pub fn current_fps(&self) -> f32 {
        if self.frame_times.is_empty() {
            return 0.0;
        }

        let total: Duration = self.frame_times.iter().sum();
        let avg_frame_time = total.as_secs_f32() / self.frame_times.len() as f32;

        if avg_frame_time > 0.0 {
            1.0 / avg_frame_time
        } else {
            0.0
        }
    }

    /// Returns the physics accumulator value.
    ///
    /// The accumulator tracks leftover simulation time from previous
    /// frames for fixed-timestep physics integration.
    pub fn physics_accumulator(&self) -> f32 {
        self.physics_accumulator
    }

    /// Adds time to the physics accumulator.
    ///
    /// # Arguments
    ///
    /// * `delta` - Time to add in seconds
    pub fn add_physics_time(&mut self, delta: f32) {
        self.physics_accumulator += delta;
    }

    /// Subtracts a physics timestep from the accumulator.
    ///
    /// # Arguments
    ///
    /// * `dt` - Physics timestep to subtract
    pub fn consume_physics_step(&mut self, dt: f32) {
        self.physics_accumulator -= dt;
    }

    /// Clamps the physics accumulator to prevent spiral of death.
    ///
    /// If the simulation falls too far behind, clamp the accumulator
    /// to prevent an ever-growing backlog of physics steps.
    ///
    /// # Arguments
    ///
    /// * `max_steps` - Maximum number of physics steps worth of time to keep
    /// * `dt` - Physics timestep duration
    pub fn clamp_accumulator(&mut self, max_steps: u32, dt: f32) {
        let max_time = (max_steps as f32) * dt;
        if self.physics_accumulator > max_time {
            self.physics_accumulator = max_time;
        }
    }

    /// Returns the target frame duration.
    pub fn target_frame_time(&self) -> Duration {
        self.target_frame_time
    }
}

impl Default for FrameTimer {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn test_frame_timer_creation() {
        let timer = FrameTimer::new();
        assert_eq!(timer.current_fps(), 0.0); // No samples yet
    }

    #[test]
    fn test_fps_cap_toggle() {
        let mut timer = FrameTimer::new();
        // By default, FPS cap is disabled
        assert!(!timer.fps_cap_enabled);

        timer.set_fps_cap(true);
        assert!(timer.fps_cap_enabled);

        timer.set_fps_cap(false);
        assert!(!timer.fps_cap_enabled);
    }

    #[test]
    fn test_physics_accumulator() {
        let mut timer = FrameTimer::new();
        assert_eq!(timer.physics_accumulator(), 0.0);

        timer.add_physics_time(0.016);
        assert!((timer.physics_accumulator() - 0.016).abs() < 0.0001);

        timer.consume_physics_step(0.016);
        assert!(timer.physics_accumulator().abs() < 0.0001);
    }

    #[test]
    fn test_accumulator_clamping() {
        let mut timer = FrameTimer::new();
        timer.add_physics_time(1.0); // 1 second of accumulated time

        let dt = 1.0 / 60.0;
        timer.clamp_accumulator(5, dt);

        // Should be clamped to 5 * dt
        assert!(timer.physics_accumulator() <= 5.0 * dt + 0.0001);
    }
}