Skip to main content

engine/
time.rs

1/**--------------------------------------------------------------------------------
2*!  Fixed-timestep timing system for deterministic game logic.
3*?  Provides a monotonic tick counter and accumulator that drives fixed-rate
4*?  updates independently of the display framerate.
5*--------------------------------------------------------------------------------**/
6pub const DEFAULT_FIXED_HZ: u32 = 60;
7pub const MAX_STEPS: u32 = 5; //* Cap to prevent spiral of death on very slow frames.
8
9//? Fixed-timestep timing state.
10#[derive(Debug, Clone)]
11pub struct FixedTime {
12    //* Monotonic tick counter
13    pub tick: u64,
14    pub fixed_dt: f32,
15    tick_rate: u32,
16    //* Leftover time from the previous frame, carried into the next.
17    accumulator: f32,
18    //* Frame-perfect freeze counter. While > 0, the accumulator is not fed
19    //* and no fixed steps run. Decrements once per render frame.
20    freeze_frames: u16,
21}
22
23impl FixedTime {
24    pub fn new(hz: u32) -> Self {
25        Self {
26            tick: 0,
27            fixed_dt: 1.0 / hz as f32,
28            tick_rate: hz,
29            accumulator: 0.0,
30            freeze_frames: 0,
31        }
32    }
33
34    //? Feed wall-clock delta time. Returns the number of fixed steps to run
35    //? this frame. During a freeze, returns 0 and decrements the counter.
36    pub fn accumulate(&mut self, dt: f32) -> u32 {
37        if self.freeze_frames > 0 {
38            self.freeze_frames -= 1;
39            return 0;
40        }
41        self.accumulator += dt;
42        (self.accumulator / self.fixed_dt).min(MAX_STEPS as f32) as u32
43    }
44
45    //? Call this once per fixed_update invocation.
46    pub fn advance(&mut self) {
47        self.accumulator -= self.fixed_dt;
48        self.tick += 1;
49    }
50
51    //? Fraction of a fixed step remaining after all whole steps have been consumed.
52    //? Useful for render-time interpolation between physics states.
53    pub fn interpolation_alpha(&self) -> f32 {
54        self.accumulator / self.fixed_dt
55    }
56
57    pub fn tick_rate(&self) -> u32 {
58        self.tick_rate
59    }
60
61    pub fn set_tick_rate(&mut self, hz: u32) {
62        self.tick_rate = hz;
63        self.fixed_dt = 1.0 / hz as f32;
64    }
65
66    //? FSM and physics pause.
67    pub fn freeze(&mut self, frames: u16) {
68        self.freeze_frames = self.freeze_frames.max(frames);
69    }
70
71    pub fn is_frozen(&self) -> bool {
72        self.freeze_frames > 0
73    }
74
75    pub fn freeze_remaining(&self) -> u16 {
76        self.freeze_frames
77    }
78}
79
80impl Default for FixedTime {
81    fn default() -> Self {
82        Self::new(DEFAULT_FIXED_HZ)
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn single_step_at_exact_dt() {
92        let mut ft = FixedTime::new(60);
93        let steps = ft.accumulate(1.0 / 60.0);
94        assert_eq!(steps, 1);
95        ft.advance();
96        assert_eq!(ft.tick, 1);
97    }
98
99    #[test]
100    fn no_step_below_threshold() {
101        let mut ft = FixedTime::new(60);
102        let steps = ft.accumulate(0.005); //* ~5ms, less than 16.67ms
103        assert_eq!(steps, 0);
104        assert_eq!(ft.tick, 0);
105    }
106
107    #[test]
108    fn multiple_steps_on_slow_frame() {
109        let mut ft = FixedTime::new(60);
110        let steps = ft.accumulate(1.0 / 30.0); //* 33ms → 2 steps at 60Hz
111        assert_eq!(steps, 2);
112        ft.advance();
113        ft.advance();
114        assert_eq!(ft.tick, 2);
115    }
116
117    #[test]
118    fn accumulator_carries_remainder() {
119        let mut ft = FixedTime::new(60);
120        let dt = 1.0 / 60.0;
121        //* Feed 1.5 fixed steps worth of time
122        ft.accumulate(dt * 1.5);
123        ft.advance(); //* consume one step
124        assert_eq!(ft.tick, 1);
125        //* Remaining should be ~0.5 * dt
126        let alpha = ft.interpolation_alpha();
127        assert!((alpha - 0.5).abs() < 0.01, "alpha was {alpha}");
128    }
129
130    #[test]
131    fn set_tick_rate_changes_dt() {
132        let mut ft = FixedTime::new(60);
133        ft.set_tick_rate(30);
134        assert_eq!(ft.tick_rate(), 30);
135        assert!((ft.fixed_dt - 1.0 / 30.0).abs() < f32::EPSILON);
136    }
137
138    #[test]
139    fn tick_monotonically_increases() {
140        let mut ft = FixedTime::new(60);
141        let dt = 1.0 / 60.0;
142        for expected in 1..=100u64 {
143            ft.accumulate(dt);
144            ft.advance();
145            assert_eq!(ft.tick, expected);
146        }
147    }
148
149    #[test]
150    fn freeze_blocks_accumulation() {
151        let mut ft = FixedTime::new(60);
152        ft.freeze(3);
153        assert!(ft.is_frozen());
154        //? Each accumulate call during freeze returns 0 and decrements counter
155        assert_eq!(ft.accumulate(1.0 / 60.0), 0);
156        assert_eq!(ft.freeze_remaining(), 2);
157        assert_eq!(ft.accumulate(1.0 / 60.0), 0);
158        assert_eq!(ft.freeze_remaining(), 1);
159        assert_eq!(ft.accumulate(1.0 / 60.0), 0);
160        assert_eq!(ft.freeze_remaining(), 0);
161        assert!(!ft.is_frozen());
162        //? After freeze, normal accumulation resumes
163        assert_eq!(ft.accumulate(1.0 / 60.0), 1);
164    }
165
166    #[test]
167    fn freeze_stacks_by_max() {
168        let mut ft = FixedTime::new(60);
169        ft.freeze(3);
170        ft.freeze(5);
171        assert_eq!(ft.freeze_remaining(), 5);
172        ft.freeze(2); //* smaller, ignored
173        assert_eq!(ft.freeze_remaining(), 5);
174    }
175}