1use core::fmt;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct GameLoopConfig {
13 pub fixed_dt: f32,
15 pub max_frame_time: f32,
17 pub target_fps: u32,
19}
20
21impl GameLoopConfig {
22 #[must_use]
24 pub const fn default_60fps() -> Self {
25 Self {
26 fixed_dt: 1.0 / 60.0,
27 max_frame_time: 0.25, target_fps: 0, }
30 }
31
32 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct GameLoopState {
62 accumulator: f32,
64 last_frame_time: f32,
66 total_time: f32,
68 frame_count: u64,
70 tick_count: u64,
72}
73
74impl GameLoopState {
75 #[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 #[must_use]
89 pub const fn total_time(&self) -> f32 {
90 self.total_time
91 }
92
93 #[must_use]
95 pub const fn frame_count(&self) -> u64 {
96 self.frame_count
97 }
98
99 #[must_use]
101 pub const fn tick_count(&self) -> u64 {
102 self.tick_count
103 }
104
105 #[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#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct FrameResult {
127 pub physics_ticks: u32,
129 pub should_render: bool,
131}
132
133impl FrameResult {
134 #[must_use]
136 pub const fn new(physics_ticks: u32) -> Self {
137 Self {
138 physics_ticks,
139 should_render: true,
140 }
141 }
142}
143
144pub struct GameLoop {
169 config: GameLoopConfig,
170 state: GameLoopState,
171}
172
173impl GameLoop {
174 #[must_use]
176 pub const fn new(config: GameLoopConfig) -> Self {
177 Self {
178 config,
179 state: GameLoopState::new(),
180 }
181 }
182
183 #[must_use]
185 pub const fn config(&self) -> &GameLoopConfig {
186 &self.config
187 }
188
189 #[must_use]
191 pub const fn state(&self) -> &GameLoopState {
192 &self.state
193 }
194
195 #[must_use]
197 pub fn alpha(&self) -> f32 {
198 self.state.alpha(self.config.fixed_dt)
199 }
200
201 pub fn update(&mut self, current_time: f32) -> FrameResult {
205 let mut frame_time = current_time - self.state.last_frame_time;
207 self.state.last_frame_time = current_time;
208
209 if self.state.frame_count == 0 {
211 frame_time = self.config.fixed_dt;
212 }
213
214 if frame_time > self.config.max_frame_time {
216 frame_time = self.config.max_frame_time;
217 }
218
219 self.state.total_time += frame_time;
221 self.state.frame_count += 1;
222 self.state.accumulator += frame_time;
223
224 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 pub const fn reset(&mut self) {
238 self.state = GameLoopState::new();
239 }
240
241 #[must_use]
243 pub const fn fixed_dt(&self) -> f32 {
244 self.config.fixed_dt
245 }
246
247 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
274pub enum GameState {
275 #[default]
277 Loading,
278 Menu,
280 Playing,
282 Paused,
284 GameOver,
286}
287
288impl GameState {
289 #[must_use]
291 pub const fn is_active(&self) -> bool {
292 matches!(self, Self::Playing)
293 }
294
295 #[must_use]
297 pub const fn should_render_world(&self) -> bool {
298 matches!(self, Self::Playing | Self::Paused | Self::GameOver)
299 }
300
301 #[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 #[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 #[test]
373 fn test_game_loop_single_tick() {
374 let mut game_loop = GameLoop::new(GameLoopConfig::default_60fps());
375
376 let result = game_loop.update(0.0);
378 assert_eq!(result.physics_ticks, 1); 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 let _ = game_loop.update(0.0);
389
390 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 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, target_fps: 0,
417 });
418
419 let _ = game_loop.update(0.0);
420
421 let result = game_loop.update(10.0);
423
424 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 #[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 #[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 #[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 #[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 #[test]
588 fn test_physics_actually_runs_correct_times() {
589 let config = GameLoopConfig {
590 fixed_dt: 0.1, max_frame_time: 1.0,
592 target_fps: 0,
593 };
594 let mut game_loop = GameLoop::new(config);
595
596 let _ = game_loop.update(0.0);
598
599 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 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); let alpha = game_loop.alpha();
627
628 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}