jugar_core/
game_loop.rs

1//! Game loop with fixed timestep (Heijunka principle)
2//!
3//! Implements a fixed timestep game loop that ensures physics consistency
4//! across all frame rates (30fps mobile to 144Hz+ desktop).
5
6use core::fmt;
7
8use serde::{Deserialize, Serialize};
9
10/// Configuration for the game loop
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct GameLoopConfig {
13    /// Fixed timestep for physics updates (seconds)
14    pub fixed_dt: f32,
15    /// Maximum frame time to prevent spiral of death
16    pub max_frame_time: f32,
17    /// Target frames per second (0 = unlimited)
18    pub target_fps: u32,
19}
20
21impl GameLoopConfig {
22    /// Default configuration (60 FPS physics, 120 FPS cap)
23    #[must_use]
24    pub const fn default_60fps() -> Self {
25        Self {
26            fixed_dt: 1.0 / 60.0,
27            max_frame_time: 0.25, // Max 4 physics steps per frame
28            target_fps: 0,        // Unlimited (vsync controlled)
29        }
30    }
31
32    /// Configuration for mobile (30 FPS physics to save battery)
33    #[must_use]
34    pub const fn mobile() -> Self {
35        Self {
36            fixed_dt: 1.0 / 30.0,
37            max_frame_time: 0.1,
38            target_fps: 60,
39        }
40    }
41
42    /// Configuration for high refresh rate displays
43    #[must_use]
44    pub const fn high_refresh() -> Self {
45        Self {
46            fixed_dt: 1.0 / 120.0,
47            max_frame_time: 0.25,
48            target_fps: 0,
49        }
50    }
51}
52
53impl Default for GameLoopConfig {
54    fn default() -> Self {
55        Self::default_60fps()
56    }
57}
58
59/// State of the game loop accumulator
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct GameLoopState {
62    /// Accumulated time for fixed updates
63    accumulator: f32,
64    /// Time of last frame
65    last_frame_time: f32,
66    /// Total elapsed time
67    total_time: f32,
68    /// Frame counter
69    frame_count: u64,
70    /// Physics tick counter
71    tick_count: u64,
72}
73
74impl GameLoopState {
75    /// Creates a new game loop state
76    #[must_use]
77    pub const fn new() -> Self {
78        Self {
79            accumulator: 0.0,
80            last_frame_time: 0.0,
81            total_time: 0.0,
82            frame_count: 0,
83            tick_count: 0,
84        }
85    }
86
87    /// Returns the total elapsed time
88    #[must_use]
89    pub const fn total_time(&self) -> f32 {
90        self.total_time
91    }
92
93    /// Returns the frame count
94    #[must_use]
95    pub const fn frame_count(&self) -> u64 {
96        self.frame_count
97    }
98
99    /// Returns the physics tick count
100    #[must_use]
101    pub const fn tick_count(&self) -> u64 {
102        self.tick_count
103    }
104
105    /// Returns the interpolation alpha for rendering
106    ///
107    /// This value (0.0 to 1.0) represents how far between physics ticks
108    /// the current render frame is, allowing smooth interpolation.
109    #[must_use]
110    pub fn alpha(&self, fixed_dt: f32) -> f32 {
111        if fixed_dt <= 0.0 {
112            return 0.0;
113        }
114        self.accumulator / fixed_dt
115    }
116}
117
118impl Default for GameLoopState {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124/// Result of a game loop update
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct FrameResult {
127    /// Number of physics ticks that should run this frame
128    pub physics_ticks: u32,
129    /// Whether rendering should occur
130    pub should_render: bool,
131}
132
133impl FrameResult {
134    /// Creates a result with the given tick count
135    #[must_use]
136    pub const fn new(physics_ticks: u32) -> Self {
137        Self {
138            physics_ticks,
139            should_render: true,
140        }
141    }
142}
143
144/// Game loop manager implementing fixed timestep with interpolation
145///
146/// # Example
147///
148/// ```
149/// use jugar_core::{GameLoop, GameLoopConfig};
150///
151/// let mut game_loop = GameLoop::new(GameLoopConfig::default_60fps());
152///
153/// // Game loop
154/// let current_time = 0.1; // From platform
155/// let result = game_loop.update(current_time);
156///
157/// // Run physics ticks
158/// for _ in 0..result.physics_ticks {
159///     // physics.step(game_loop.config().fixed_dt);
160/// }
161///
162/// // Render with interpolation
163/// if result.should_render {
164///     let alpha = game_loop.alpha();
165///     // render(alpha);
166/// }
167/// ```
168pub struct GameLoop {
169    config: GameLoopConfig,
170    state: GameLoopState,
171}
172
173impl GameLoop {
174    /// Creates a new game loop with the given configuration
175    #[must_use]
176    pub const fn new(config: GameLoopConfig) -> Self {
177        Self {
178            config,
179            state: GameLoopState::new(),
180        }
181    }
182
183    /// Returns the configuration
184    #[must_use]
185    pub const fn config(&self) -> &GameLoopConfig {
186        &self.config
187    }
188
189    /// Returns the current state
190    #[must_use]
191    pub const fn state(&self) -> &GameLoopState {
192        &self.state
193    }
194
195    /// Returns the interpolation alpha for rendering
196    #[must_use]
197    pub fn alpha(&self) -> f32 {
198        self.state.alpha(self.config.fixed_dt)
199    }
200
201    /// Updates the game loop with the current time
202    ///
203    /// Returns the number of physics ticks to run.
204    pub fn update(&mut self, current_time: f32) -> FrameResult {
205        // Calculate frame time
206        let mut frame_time = current_time - self.state.last_frame_time;
207        self.state.last_frame_time = current_time;
208
209        // First frame handling
210        if self.state.frame_count == 0 {
211            frame_time = self.config.fixed_dt;
212        }
213
214        // Clamp to prevent spiral of death
215        if frame_time > self.config.max_frame_time {
216            frame_time = self.config.max_frame_time;
217        }
218
219        // Update state
220        self.state.total_time += frame_time;
221        self.state.frame_count += 1;
222        self.state.accumulator += frame_time;
223
224        // Count physics ticks
225        let mut ticks = 0u32;
226        #[allow(clippy::while_float)]
227        while self.state.accumulator >= self.config.fixed_dt {
228            self.state.accumulator -= self.config.fixed_dt;
229            self.state.tick_count += 1;
230            ticks += 1;
231        }
232
233        FrameResult::new(ticks)
234    }
235
236    /// Resets the game loop state
237    pub const fn reset(&mut self) {
238        self.state = GameLoopState::new();
239    }
240
241    /// Returns the fixed timestep
242    #[must_use]
243    pub const fn fixed_dt(&self) -> f32 {
244        self.config.fixed_dt
245    }
246
247    /// Returns the current accumulator value
248    ///
249    /// This is used by the probar introspection module.
250    #[must_use]
251    pub const fn accumulator(&self) -> f32 {
252        self.state.accumulator
253    }
254}
255
256impl Default for GameLoop {
257    fn default() -> Self {
258        Self::new(GameLoopConfig::default())
259    }
260}
261
262impl fmt::Debug for GameLoop {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        f.debug_struct("GameLoop")
265            .field("fixed_dt", &self.config.fixed_dt)
266            .field("frame_count", &self.state.frame_count)
267            .field("tick_count", &self.state.tick_count)
268            .finish()
269    }
270}
271
272/// Game state machine for managing game modes
273#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
274pub enum GameState {
275    /// Initial loading state
276    #[default]
277    Loading,
278    /// Main menu
279    Menu,
280    /// Active gameplay
281    Playing,
282    /// Game paused
283    Paused,
284    /// Game over screen
285    GameOver,
286}
287
288impl GameState {
289    /// Checks if this state allows gameplay updates
290    #[must_use]
291    pub const fn is_active(&self) -> bool {
292        matches!(self, Self::Playing)
293    }
294
295    /// Checks if this state should render the game world
296    #[must_use]
297    pub const fn should_render_world(&self) -> bool {
298        matches!(self, Self::Playing | Self::Paused | Self::GameOver)
299    }
300
301    /// Attempts to transition to a new state
302    ///
303    /// Returns true if the transition is valid.
304    #[must_use]
305    pub const fn can_transition_to(&self, target: &Self) -> bool {
306        matches!(
307            (self, target),
308            (Self::Loading, Self::Menu)
309                | (Self::Menu, Self::Playing | Self::Loading)
310                | (Self::Playing, Self::Paused | Self::GameOver | Self::Menu)
311                | (Self::Paused | Self::GameOver, Self::Playing | Self::Menu)
312        )
313    }
314}
315
316impl fmt::Display for GameState {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        match self {
319            Self::Loading => write!(f, "Loading"),
320            Self::Menu => write!(f, "Menu"),
321            Self::Playing => write!(f, "Playing"),
322            Self::Paused => write!(f, "Paused"),
323            Self::GameOver => write!(f, "GameOver"),
324        }
325    }
326}
327
328#[cfg(test)]
329#[allow(
330    clippy::unwrap_used,
331    clippy::expect_used,
332    clippy::let_underscore_must_use,
333    clippy::cast_precision_loss
334)]
335mod tests {
336    use super::*;
337
338    // ==================== CONFIG TESTS ====================
339
340    #[test]
341    fn test_config_default_60fps() {
342        let config = GameLoopConfig::default_60fps();
343        assert!((config.fixed_dt - 1.0 / 60.0).abs() < 0.001);
344        assert!((config.max_frame_time - 0.25).abs() < 0.001);
345        assert_eq!(config.target_fps, 0);
346    }
347
348    #[test]
349    fn test_config_mobile() {
350        let config = GameLoopConfig::mobile();
351        assert!((config.fixed_dt - 1.0 / 30.0).abs() < 0.001);
352        assert!((config.max_frame_time - 0.1).abs() < 0.001);
353        assert_eq!(config.target_fps, 60);
354    }
355
356    #[test]
357    fn test_config_high_refresh() {
358        let config = GameLoopConfig::high_refresh();
359        assert!((config.fixed_dt - 1.0 / 120.0).abs() < 0.001);
360        assert!((config.max_frame_time - 0.25).abs() < 0.001);
361        assert_eq!(config.target_fps, 0);
362    }
363
364    #[test]
365    fn test_config_default() {
366        let config = GameLoopConfig::default();
367        assert!((config.fixed_dt - 1.0 / 60.0).abs() < 0.001);
368    }
369
370    // ==================== GAME LOOP TESTS ====================
371
372    #[test]
373    fn test_game_loop_single_tick() {
374        let mut game_loop = GameLoop::new(GameLoopConfig::default_60fps());
375
376        // First frame at time 0
377        let result = game_loop.update(0.0);
378        assert_eq!(result.physics_ticks, 1); // First frame always runs one tick
379        assert!(result.should_render);
380    }
381
382    #[test]
383    fn test_game_loop_multiple_ticks() {
384        let mut game_loop = GameLoop::new(GameLoopConfig::default_60fps());
385        let fixed_dt = game_loop.fixed_dt();
386
387        // First frame
388        let _ = game_loop.update(0.0);
389
390        // Frame at 2x fixed_dt - should run 2 physics ticks
391        let result = game_loop.update(fixed_dt * 2.0);
392        assert_eq!(result.physics_ticks, 2);
393    }
394
395    #[test]
396    fn test_game_loop_accumulator() {
397        let mut game_loop = GameLoop::new(GameLoopConfig::default_60fps());
398        let fixed_dt = game_loop.fixed_dt();
399
400        let _ = game_loop.update(0.0);
401        let _ = game_loop.update(fixed_dt * 1.5);
402
403        // Should have run 1 tick with 0.5*fixed_dt remaining
404        let alpha = game_loop.alpha();
405        assert!(
406            alpha > 0.4 && alpha < 0.6,
407            "Alpha should be ~0.5, got {alpha}"
408        );
409    }
410
411    #[test]
412    fn test_game_loop_max_frame_time() {
413        let mut game_loop = GameLoop::new(GameLoopConfig {
414            fixed_dt: 1.0 / 60.0,
415            max_frame_time: 0.1, // Max 6 ticks at 60fps
416            target_fps: 0,
417        });
418
419        let _ = game_loop.update(0.0);
420
421        // Huge time jump should be clamped
422        let result = game_loop.update(10.0);
423
424        // Should be clamped to max_frame_time / fixed_dt ticks
425        assert!(result.physics_ticks <= 6);
426    }
427
428    #[test]
429    fn test_game_loop_reset() {
430        let mut game_loop = GameLoop::default();
431        let _ = game_loop.update(0.0);
432        let _ = game_loop.update(1.0);
433
434        game_loop.reset();
435
436        assert_eq!(game_loop.state().frame_count(), 0);
437        assert_eq!(game_loop.state().tick_count(), 0);
438    }
439
440    #[test]
441    fn test_game_loop_frame_count_increments() {
442        let mut game_loop = GameLoop::default();
443
444        for i in 0..10 {
445            let _ = game_loop.update(i as f32 * 0.016);
446        }
447
448        assert_eq!(game_loop.state().frame_count(), 10);
449    }
450
451    // ==================== GAME STATE TESTS ====================
452
453    #[test]
454    fn test_game_state_transitions() {
455        assert!(GameState::Loading.can_transition_to(&GameState::Menu));
456        assert!(GameState::Menu.can_transition_to(&GameState::Playing));
457        assert!(GameState::Playing.can_transition_to(&GameState::Paused));
458        assert!(GameState::Paused.can_transition_to(&GameState::Playing));
459        assert!(GameState::Playing.can_transition_to(&GameState::GameOver));
460        assert!(GameState::GameOver.can_transition_to(&GameState::Menu));
461    }
462
463    #[test]
464    fn test_game_state_invalid_transitions() {
465        assert!(!GameState::Loading.can_transition_to(&GameState::Playing));
466        assert!(!GameState::Paused.can_transition_to(&GameState::GameOver));
467    }
468
469    #[test]
470    fn test_game_state_is_active() {
471        assert!(!GameState::Loading.is_active());
472        assert!(!GameState::Menu.is_active());
473        assert!(GameState::Playing.is_active());
474        assert!(!GameState::Paused.is_active());
475    }
476
477    #[test]
478    fn test_game_state_should_render_world() {
479        assert!(!GameState::Loading.should_render_world());
480        assert!(GameState::Playing.should_render_world());
481        assert!(GameState::Paused.should_render_world());
482        assert!(GameState::GameOver.should_render_world());
483    }
484
485    #[test]
486    fn test_game_state_display() {
487        assert_eq!(format!("{}", GameState::Loading), "Loading");
488        assert_eq!(format!("{}", GameState::Menu), "Menu");
489        assert_eq!(format!("{}", GameState::Playing), "Playing");
490        assert_eq!(format!("{}", GameState::Paused), "Paused");
491        assert_eq!(format!("{}", GameState::GameOver), "GameOver");
492    }
493
494    #[test]
495    fn test_game_state_default() {
496        let state = GameState::default();
497        assert_eq!(state, GameState::Loading);
498    }
499
500    #[test]
501    fn test_game_state_menu_should_not_render_world() {
502        assert!(!GameState::Menu.should_render_world());
503    }
504
505    #[test]
506    fn test_game_state_menu_not_active() {
507        assert!(!GameState::Menu.is_active());
508    }
509
510    #[test]
511    fn test_game_state_paused_transitions() {
512        assert!(GameState::Paused.can_transition_to(&GameState::Menu));
513        assert!(GameState::GameOver.can_transition_to(&GameState::Playing));
514    }
515
516    #[test]
517    fn test_game_state_menu_to_loading() {
518        assert!(GameState::Menu.can_transition_to(&GameState::Loading));
519    }
520
521    // ==================== GAME LOOP STATE TESTS ====================
522
523    #[test]
524    fn test_game_loop_state_new() {
525        let state = GameLoopState::new();
526        assert!((state.total_time() - 0.0).abs() < f32::EPSILON);
527        assert_eq!(state.frame_count(), 0);
528        assert_eq!(state.tick_count(), 0);
529    }
530
531    #[test]
532    fn test_game_loop_state_default() {
533        let state = GameLoopState::default();
534        assert_eq!(state.frame_count(), 0);
535    }
536
537    #[test]
538    fn test_game_loop_state_alpha_zero_dt() {
539        let state = GameLoopState::new();
540        let alpha = state.alpha(0.0);
541        assert!((alpha - 0.0).abs() < f32::EPSILON);
542    }
543
544    #[test]
545    fn test_game_loop_state_alpha_negative_dt() {
546        let state = GameLoopState::new();
547        let alpha = state.alpha(-1.0);
548        assert!((alpha - 0.0).abs() < f32::EPSILON);
549    }
550
551    // ==================== FRAME RESULT TESTS ====================
552
553    #[test]
554    fn test_frame_result_new() {
555        let result = FrameResult::new(5);
556        assert_eq!(result.physics_ticks, 5);
557        assert!(result.should_render);
558    }
559
560    // ==================== GAME LOOP DEBUG TEST ====================
561
562    #[test]
563    fn test_game_loop_debug() {
564        let game_loop = GameLoop::default();
565        let debug_str = format!("{game_loop:?}");
566        assert!(debug_str.contains("GameLoop"));
567        assert!(debug_str.contains("fixed_dt"));
568    }
569
570    #[test]
571    fn test_game_loop_config_accessor() {
572        let game_loop = GameLoop::default();
573        let config = game_loop.config();
574        assert!((config.fixed_dt - 1.0 / 60.0).abs() < 0.001);
575    }
576
577    #[test]
578    fn test_game_loop_total_time_increases() {
579        let mut game_loop = GameLoop::default();
580        let _ = game_loop.update(0.0);
581        let _ = game_loop.update(1.0);
582        assert!(game_loop.state().total_time() > 0.0);
583    }
584
585    // ==================== BEHAVIORAL TESTS (MUTATION-RESISTANT) ====================
586
587    #[test]
588    fn test_physics_actually_runs_correct_times() {
589        let config = GameLoopConfig {
590            fixed_dt: 0.1, // 10 Hz for easy math
591            max_frame_time: 1.0,
592            target_fps: 0,
593        };
594        let mut game_loop = GameLoop::new(config);
595
596        // First frame
597        let _ = game_loop.update(0.0);
598
599        // After 0.35 seconds, should have 3 ticks (0.1, 0.2, 0.3) with 0.05 remaining
600        let result = game_loop.update(0.35);
601        assert_eq!(
602            result.physics_ticks, 3,
603            "Should run exactly 3 physics ticks for 0.35s at 0.1s timestep"
604        );
605
606        // Verify accumulator state
607        let alpha = game_loop.alpha();
608        assert!(
609            (alpha - 0.5).abs() < 0.01,
610            "Alpha should be 0.5 (0.05/0.1), got {alpha}"
611        );
612    }
613
614    #[test]
615    fn test_interpolation_actually_affects_rendering() {
616        let config = GameLoopConfig {
617            fixed_dt: 0.1,
618            max_frame_time: 1.0,
619            target_fps: 0,
620        };
621        let mut game_loop = GameLoop::new(config);
622
623        let _ = game_loop.update(0.0);
624        let _ = game_loop.update(0.15); // 1 tick, 0.05 remaining
625
626        let alpha = game_loop.alpha();
627
628        // Simulate position interpolation
629        let prev_pos: f32 = 0.0;
630        let curr_pos: f32 = 10.0;
631        let interpolated = (curr_pos - prev_pos).mul_add(alpha, prev_pos);
632
633        assert!(
634            (interpolated - 5.0).abs() < 0.1,
635            "Interpolated position should be ~5.0 at alpha=0.5, got {interpolated}"
636        );
637    }
638}