Skip to main content

jugar_web/
time.rs

1//! Time utilities for browser integration.
2//!
3//! This module handles conversion from JavaScript `DOMHighResTimeStamp` (milliseconds)
4//! to the engine's internal time representation (seconds as f64).
5
6/// Converts a `DOMHighResTimeStamp` (milliseconds from `performance.now()`) to seconds.
7///
8/// # Arguments
9///
10/// * `timestamp_ms` - Timestamp in milliseconds from `performance.now()`
11///
12/// # Returns
13///
14/// Time in seconds as f64
15///
16/// # Example
17///
18/// ```
19/// use jugar_web::time::dom_timestamp_to_seconds;
20///
21/// let ms = 1234.567;
22/// let seconds = dom_timestamp_to_seconds(ms);
23/// assert!((seconds - 1.234567).abs() < 1e-9);
24/// ```
25#[must_use]
26pub const fn dom_timestamp_to_seconds(timestamp_ms: f64) -> f64 {
27    timestamp_ms / 1000.0
28}
29
30/// Converts seconds to milliseconds (for passing back to JavaScript if needed).
31///
32/// # Arguments
33///
34/// * `seconds` - Time in seconds
35///
36/// # Returns
37///
38/// Time in milliseconds as f64
39#[must_use]
40pub const fn seconds_to_dom_timestamp(seconds: f64) -> f64 {
41    seconds * 1000.0
42}
43
44/// Calculates delta time from two consecutive `DOMHighResTimeStamp` values.
45///
46/// # Arguments
47///
48/// * `current_ms` - Current frame's timestamp in milliseconds
49/// * `previous_ms` - Previous frame's timestamp in milliseconds
50///
51/// # Returns
52///
53/// Delta time in seconds
54///
55/// # Example
56///
57/// ```
58/// use jugar_web::time::calculate_delta_time;
59///
60/// let prev = 1000.0;  // 1 second
61/// let curr = 1016.67; // ~16.67ms later (60 FPS)
62/// let dt = calculate_delta_time(curr, prev);
63/// assert!((dt - 0.01667).abs() < 0.001);
64/// ```
65#[must_use]
66pub const fn calculate_delta_time(current_ms: f64, previous_ms: f64) -> f64 {
67    (current_ms - previous_ms) / 1000.0
68}
69
70/// Clamps delta time to prevent physics explosions from large time gaps.
71///
72/// This is important when the tab is backgrounded and then restored,
73/// which can produce very large delta times.
74///
75/// # Arguments
76///
77/// * `dt` - Raw delta time in seconds
78/// * `max_dt` - Maximum allowed delta time in seconds (typical: 0.1 = 100ms)
79///
80/// # Returns
81///
82/// Clamped delta time in seconds
83#[must_use]
84#[allow(clippy::missing_const_for_fn)] // f64::min/max are not const
85pub fn clamp_delta_time(dt: f64, max_dt: f64) -> f64 {
86    dt.min(max_dt).max(0.0)
87}
88
89/// Default maximum delta time (100ms = 10 FPS minimum).
90///
91/// When delta time exceeds this, the game will slow down rather than
92/// trying to simulate a huge time gap.
93pub const DEFAULT_MAX_DELTA_TIME: f64 = 0.1;
94
95/// Target delta time for 60 FPS.
96pub const TARGET_DT_60FPS: f64 = 1.0 / 60.0;
97
98/// Target delta time for 30 FPS.
99pub const TARGET_DT_30FPS: f64 = 1.0 / 30.0;
100
101/// Target delta time for 120 FPS.
102pub const TARGET_DT_120FPS: f64 = 1.0 / 120.0;
103
104/// Frame time tracker for computing frame statistics.
105#[derive(Debug, Clone)]
106pub struct FrameTimer {
107    /// Last frame's timestamp in milliseconds
108    last_timestamp_ms: Option<f64>,
109    /// Accumulated delta time for fixed timestep accumulation
110    accumulator: f64,
111    /// Fixed timestep interval in seconds
112    fixed_dt: f64,
113    /// Maximum delta time before clamping
114    max_dt: f64,
115    /// Total elapsed time since start in seconds
116    total_time: f64,
117    /// Frame count since start
118    frame_count: u64,
119}
120
121impl Default for FrameTimer {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl FrameTimer {
128    /// Creates a new frame timer with default settings.
129    #[must_use]
130    pub const fn new() -> Self {
131        Self {
132            last_timestamp_ms: None,
133            accumulator: 0.0,
134            fixed_dt: TARGET_DT_60FPS,
135            max_dt: DEFAULT_MAX_DELTA_TIME,
136            total_time: 0.0,
137            frame_count: 0,
138        }
139    }
140
141    /// Creates a frame timer with custom fixed timestep.
142    #[must_use]
143    pub const fn with_fixed_dt(fixed_dt: f64) -> Self {
144        Self {
145            last_timestamp_ms: None,
146            accumulator: 0.0,
147            fixed_dt,
148            max_dt: DEFAULT_MAX_DELTA_TIME,
149            total_time: 0.0,
150            frame_count: 0,
151        }
152    }
153
154    /// Sets the maximum allowed delta time.
155    #[allow(clippy::missing_const_for_fn)] // const fn with mutable references not yet stable
156    pub fn set_max_dt(&mut self, max_dt: f64) {
157        self.max_dt = max_dt;
158    }
159
160    /// Sets the fixed timestep interval.
161    #[allow(clippy::missing_const_for_fn)] // const fn with mutable references not yet stable
162    pub fn set_fixed_dt(&mut self, fixed_dt: f64) {
163        self.fixed_dt = fixed_dt;
164    }
165
166    /// Updates the timer with a new frame timestamp.
167    ///
168    /// # Arguments
169    ///
170    /// * `timestamp_ms` - Current timestamp from `requestAnimationFrame` in milliseconds
171    ///
172    /// # Returns
173    ///
174    /// The clamped delta time in seconds
175    pub fn update(&mut self, timestamp_ms: f64) -> f64 {
176        let dt = self
177            .last_timestamp_ms
178            .map_or(0.0, |last| calculate_delta_time(timestamp_ms, last));
179
180        self.last_timestamp_ms = Some(timestamp_ms);
181        let clamped_dt = clamp_delta_time(dt, self.max_dt);
182        self.total_time += clamped_dt;
183        self.frame_count += 1;
184        self.accumulator += clamped_dt;
185
186        clamped_dt
187    }
188
189    /// Consumes accumulated time in fixed timestep chunks.
190    ///
191    /// Call this in a loop to run physics updates at fixed intervals:
192    ///
193    /// ```ignore
194    /// while let Some(fixed_dt) = timer.consume_fixed_step() {
195    ///     physics.step(fixed_dt);
196    /// }
197    /// ```
198    ///
199    /// # Returns
200    ///
201    /// `Some(fixed_dt)` if enough time has accumulated, `None` otherwise
202    pub fn consume_fixed_step(&mut self) -> Option<f64> {
203        if self.accumulator >= self.fixed_dt {
204            self.accumulator -= self.fixed_dt;
205            Some(self.fixed_dt)
206        } else {
207            None
208        }
209    }
210
211    /// Returns the interpolation alpha for rendering between physics steps.
212    ///
213    /// This value (0.0 to 1.0) indicates how far between the last and next
214    /// physics step we are, useful for interpolating visual positions.
215    #[must_use]
216    pub fn interpolation_alpha(&self) -> f64 {
217        if self.fixed_dt > 0.0 {
218            self.accumulator / self.fixed_dt
219        } else {
220            0.0
221        }
222    }
223
224    /// Returns the total elapsed time since the timer was created.
225    #[must_use]
226    pub const fn total_time(&self) -> f64 {
227        self.total_time
228    }
229
230    /// Returns the total number of frames processed.
231    #[must_use]
232    pub const fn frame_count(&self) -> u64 {
233        self.frame_count
234    }
235
236    /// Returns the average frames per second.
237    #[must_use]
238    pub fn average_fps(&self) -> f64 {
239        if self.total_time > 0.0 {
240            self.frame_count as f64 / self.total_time
241        } else {
242            0.0
243        }
244    }
245
246    /// Returns the remaining accumulator time.
247    #[must_use]
248    pub const fn accumulator(&self) -> f64 {
249        self.accumulator
250    }
251
252    /// Returns the fixed timestep interval.
253    #[must_use]
254    pub const fn fixed_dt(&self) -> f64 {
255        self.fixed_dt
256    }
257
258    /// Resets the timer to its initial state.
259    #[allow(clippy::missing_const_for_fn)] // const fn with mutable references not yet stable
260    pub fn reset(&mut self) {
261        self.last_timestamp_ms = None;
262        self.accumulator = 0.0;
263        self.total_time = 0.0;
264        self.frame_count = 0;
265    }
266}
267
268#[cfg(test)]
269#[allow(
270    clippy::unwrap_used,
271    clippy::expect_used,
272    clippy::cast_lossless,
273    clippy::manual_range_contains
274)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_dom_timestamp_to_seconds() {
280        assert!((dom_timestamp_to_seconds(0.0) - 0.0).abs() < f64::EPSILON);
281        assert!((dom_timestamp_to_seconds(1000.0) - 1.0).abs() < f64::EPSILON);
282        assert!((dom_timestamp_to_seconds(16.667) - 0.016_667).abs() < 1e-9);
283        assert!((dom_timestamp_to_seconds(100_000.0) - 100.0).abs() < f64::EPSILON);
284    }
285
286    #[test]
287    fn test_seconds_to_dom_timestamp() {
288        assert!((seconds_to_dom_timestamp(0.0) - 0.0).abs() < f64::EPSILON);
289        assert!((seconds_to_dom_timestamp(1.0) - 1000.0).abs() < f64::EPSILON);
290        assert!((seconds_to_dom_timestamp(0.016_667) - 16.667).abs() < 1e-6);
291    }
292
293    #[test]
294    fn test_roundtrip_conversion() {
295        let original = 12345.678;
296        let seconds = dom_timestamp_to_seconds(original);
297        let back = seconds_to_dom_timestamp(seconds);
298        assert!((back - original).abs() < 1e-9);
299    }
300
301    #[test]
302    fn test_calculate_delta_time() {
303        // 60 FPS frame
304        let dt = calculate_delta_time(1016.667, 1000.0);
305        assert!((dt - 0.016_667).abs() < 1e-6);
306
307        // 30 FPS frame
308        let dt = calculate_delta_time(1033.333, 1000.0);
309        assert!((dt - 0.033_333).abs() < 1e-6);
310
311        // Same timestamp
312        let dt = calculate_delta_time(1000.0, 1000.0);
313        assert!(dt.abs() < f64::EPSILON);
314    }
315
316    #[test]
317    fn test_clamp_delta_time() {
318        // Normal delta time passes through
319        assert!((clamp_delta_time(0.016_667, 0.1) - 0.016_667).abs() < 1e-9);
320
321        // Large delta time gets clamped
322        assert!((clamp_delta_time(0.5, 0.1) - 0.1).abs() < f64::EPSILON);
323
324        // Negative delta time becomes zero
325        assert!((clamp_delta_time(-0.1, 0.1) - 0.0).abs() < f64::EPSILON);
326
327        // Zero passes through
328        assert!(clamp_delta_time(0.0, 0.1).abs() < f64::EPSILON);
329    }
330
331    #[test]
332    fn test_constants() {
333        assert!((DEFAULT_MAX_DELTA_TIME - 0.1).abs() < f64::EPSILON);
334        assert!((TARGET_DT_60FPS - 1.0 / 60.0).abs() < 1e-9);
335        assert!((TARGET_DT_30FPS - 1.0 / 30.0).abs() < 1e-9);
336        assert!((TARGET_DT_120FPS - 1.0 / 120.0).abs() < 1e-9);
337    }
338
339    #[test]
340    fn test_frame_timer_new() {
341        let timer = FrameTimer::new();
342        assert!(timer.last_timestamp_ms.is_none());
343        assert!(timer.accumulator.abs() < f64::EPSILON);
344        assert!((timer.fixed_dt - TARGET_DT_60FPS).abs() < f64::EPSILON);
345        assert!((timer.max_dt - DEFAULT_MAX_DELTA_TIME).abs() < f64::EPSILON);
346        assert!(timer.total_time.abs() < f64::EPSILON);
347        assert_eq!(timer.frame_count, 0);
348    }
349
350    #[test]
351    fn test_frame_timer_default() {
352        let timer = FrameTimer::default();
353        assert!(timer.last_timestamp_ms.is_none());
354    }
355
356    #[test]
357    fn test_frame_timer_with_fixed_dt() {
358        let timer = FrameTimer::with_fixed_dt(TARGET_DT_30FPS);
359        assert!((timer.fixed_dt - TARGET_DT_30FPS).abs() < f64::EPSILON);
360    }
361
362    #[test]
363    fn test_frame_timer_set_max_dt() {
364        let mut timer = FrameTimer::new();
365        timer.set_max_dt(0.05);
366        assert!((timer.max_dt - 0.05).abs() < f64::EPSILON);
367    }
368
369    #[test]
370    fn test_frame_timer_set_fixed_dt() {
371        let mut timer = FrameTimer::new();
372        timer.set_fixed_dt(TARGET_DT_30FPS);
373        assert!((timer.fixed_dt - TARGET_DT_30FPS).abs() < f64::EPSILON);
374    }
375
376    #[test]
377    fn test_frame_timer_first_update() {
378        let mut timer = FrameTimer::new();
379        let dt = timer.update(1000.0);
380
381        // First frame should return 0
382        assert!(dt.abs() < f64::EPSILON);
383        assert_eq!(timer.frame_count, 1);
384        assert!(timer.last_timestamp_ms.is_some());
385    }
386
387    #[test]
388    fn test_frame_timer_normal_updates() {
389        let mut timer = FrameTimer::new();
390
391        // First frame
392        let _ = timer.update(0.0);
393
394        // Simulate 60 FPS frames
395        let dt1 = timer.update(16.667);
396        assert!((dt1 - 0.016_667).abs() < 1e-6);
397
398        let dt2 = timer.update(33.333);
399        assert!((dt2 - 0.016_666).abs() < 1e-5);
400
401        assert_eq!(timer.frame_count, 3);
402        assert!(timer.total_time > 0.03);
403    }
404
405    #[test]
406    fn test_frame_timer_clamps_large_dt() {
407        let mut timer = FrameTimer::new();
408        let _ = timer.update(0.0);
409
410        // Simulate a 500ms gap (tab backgrounded)
411        let dt = timer.update(500.0);
412
413        // Should be clamped to max_dt
414        assert!((dt - DEFAULT_MAX_DELTA_TIME).abs() < f64::EPSILON);
415    }
416
417    #[test]
418    fn test_frame_timer_total_time() {
419        let mut timer = FrameTimer::new();
420        let _ = timer.update(0.0);
421        let _ = timer.update(1000.0); // 1 second later
422
423        assert!((timer.total_time() - 0.1).abs() < f64::EPSILON); // Clamped to 0.1
424    }
425
426    #[test]
427    fn test_frame_timer_consume_fixed_step() {
428        let mut timer = FrameTimer::with_fixed_dt(TARGET_DT_60FPS);
429        let _ = timer.update(0.0);
430
431        // Add ~34ms (slightly more than 2 fixed steps to account for float precision)
432        let _ = timer.update(34.0);
433
434        // Should be able to consume 2 steps
435        let step1 = timer.consume_fixed_step();
436        assert!(step1.is_some());
437        assert!((step1.unwrap() - TARGET_DT_60FPS).abs() < f64::EPSILON);
438
439        let step2 = timer.consume_fixed_step();
440        assert!(step2.is_some());
441
442        // Third step should fail (34ms = 0.034s, 2 steps = ~0.0333s, remainder ~0.0007s)
443        let step3 = timer.consume_fixed_step();
444        assert!(step3.is_none());
445    }
446
447    #[test]
448    fn test_frame_timer_interpolation_alpha() {
449        let mut timer = FrameTimer::with_fixed_dt(TARGET_DT_60FPS);
450        let _ = timer.update(0.0);
451
452        // Add half a fixed step
453        timer.accumulator = TARGET_DT_60FPS / 2.0;
454
455        let alpha = timer.interpolation_alpha();
456        assert!((alpha - 0.5).abs() < 1e-6);
457    }
458
459    #[test]
460    fn test_frame_timer_interpolation_alpha_zero_fixed_dt() {
461        let mut timer = FrameTimer::new();
462        timer.fixed_dt = 0.0;
463
464        let alpha = timer.interpolation_alpha();
465        assert!(alpha.abs() < f64::EPSILON);
466    }
467
468    #[test]
469    fn test_frame_timer_average_fps() {
470        let mut timer = FrameTimer::new();
471        let _ = timer.update(0.0);
472
473        // Simulate 60 frames at 60 FPS (1 second total)
474        for i in 1..=60 {
475            let _ = timer.update((i as f64) * 16.667);
476        }
477
478        let fps = timer.average_fps();
479        // Should be close to 61 FPS (60 frames + 1 initial in ~1 second)
480        assert!(fps > 50.0 && fps < 70.0);
481    }
482
483    #[test]
484    fn test_frame_timer_average_fps_no_time() {
485        let timer = FrameTimer::new();
486        let fps = timer.average_fps();
487        assert!(fps.abs() < f64::EPSILON);
488    }
489
490    #[test]
491    fn test_frame_timer_accumulator() {
492        let mut timer = FrameTimer::new();
493        let _ = timer.update(0.0);
494        let _ = timer.update(10.0); // 10ms
495
496        assert!((timer.accumulator() - 0.01).abs() < 1e-6);
497    }
498
499    #[test]
500    fn test_frame_timer_fixed_dt_getter() {
501        let timer = FrameTimer::with_fixed_dt(0.02);
502        assert!((timer.fixed_dt() - 0.02).abs() < f64::EPSILON);
503    }
504
505    #[test]
506    fn test_frame_timer_reset() {
507        let mut timer = FrameTimer::new();
508        let _ = timer.update(0.0);
509        let _ = timer.update(1000.0);
510
511        timer.reset();
512
513        assert!(timer.last_timestamp_ms.is_none());
514        assert!(timer.accumulator.abs() < f64::EPSILON);
515        assert!(timer.total_time.abs() < f64::EPSILON);
516        assert_eq!(timer.frame_count, 0);
517        // fixed_dt and max_dt are preserved
518        assert!((timer.fixed_dt - TARGET_DT_60FPS).abs() < f64::EPSILON);
519    }
520
521    #[test]
522    fn test_frame_timer_game_loop_simulation() {
523        let mut timer = FrameTimer::with_fixed_dt(TARGET_DT_60FPS);
524        let mut physics_steps = 0;
525
526        // Simulate game loop
527        let timestamps: [f64; 5] = [0.0, 16.0, 32.0, 48.0, 64.0];
528
529        for &ts in &timestamps {
530            let _dt = timer.update(ts);
531
532            // Run physics at fixed timestep
533            while timer.consume_fixed_step().is_some() {
534                physics_steps += 1;
535            }
536        }
537
538        // Should have run roughly 4 physics steps for 64ms at 60 FPS
539        assert!(physics_steps >= 3 && physics_steps <= 5);
540    }
541}
542
543#[cfg(test)]
544#[allow(clippy::manual_range_contains, clippy::unwrap_used)]
545mod property_tests {
546    use super::*;
547    use proptest::prelude::*;
548
549    proptest! {
550        /// Property: Timestamp conversion is bijective (roundtrip)
551        #[test]
552        fn property_timestamp_roundtrip(ms in 0.0f64..1_000_000.0) {
553            let seconds = dom_timestamp_to_seconds(ms);
554            let back = seconds_to_dom_timestamp(seconds);
555            prop_assert!((ms - back).abs() < 1e-9, "Roundtrip failed: {} -> {} -> {}", ms, seconds, back);
556        }
557
558        /// Property: Delta time is always non-negative after clamping
559        #[test]
560        fn property_clamped_dt_non_negative(dt in -10.0f64..10.0) {
561            let clamped = clamp_delta_time(dt, DEFAULT_MAX_DELTA_TIME);
562            prop_assert!(clamped >= 0.0, "Clamped dt should be non-negative: {}", clamped);
563        }
564
565        /// Property: Clamped delta time never exceeds max
566        #[test]
567        fn property_clamped_dt_bounded(dt in 0.0f64..1.0, max_dt in 0.001f64..0.5) {
568            let clamped = clamp_delta_time(dt, max_dt);
569            prop_assert!(clamped <= max_dt, "Clamped {} should be <= {}", clamped, max_dt);
570        }
571
572        /// Property: Frame timer total time is monotonically increasing
573        #[test]
574        fn property_total_time_monotonic(timestamps in proptest::collection::vec(0.0f64..10000.0, 2..20)) {
575            let mut timer = FrameTimer::new();
576            let mut prev_total = 0.0;
577
578            // Sort timestamps to simulate realistic frame sequence
579            let mut sorted = timestamps;
580            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
581
582            for ts in sorted {
583                let _ = timer.update(ts);
584                prop_assert!(timer.total_time >= prev_total,
585                    "Total time should be monotonic: {} -> {}", prev_total, timer.total_time);
586                prev_total = timer.total_time;
587            }
588        }
589
590        /// Property: Frame count always increments
591        #[test]
592        fn property_frame_count_increments(n in 1usize..100) {
593            let mut timer = FrameTimer::new();
594
595            for i in 0..n {
596                let _ = timer.update(i as f64 * 16.667);
597            }
598
599            prop_assert_eq!(timer.frame_count, n as u64);
600        }
601
602        /// Property: Interpolation alpha is non-negative and bounded when accumulator < fixed_dt
603        #[test]
604        fn property_interpolation_alpha_bounded(fixed_dt in 0.001f64..0.1) {
605            let mut timer = FrameTimer::new();
606            // Accumulator should be less than fixed_dt for normal operation
607            timer.accumulator = fixed_dt * 0.5;
608            timer.fixed_dt = fixed_dt;
609
610            let alpha = timer.interpolation_alpha();
611            prop_assert!(alpha >= 0.0 && alpha <= 1.0,
612                "Alpha {} should be in [0, 1] for acc={}, fixed_dt={}", alpha, timer.accumulator, fixed_dt);
613        }
614
615        /// Property: Interpolation alpha is always non-negative
616        #[test]
617        fn property_interpolation_alpha_non_negative(accumulator in 0.0f64..1.0, fixed_dt in 0.001f64..0.1) {
618            let mut timer = FrameTimer::new();
619            timer.accumulator = accumulator;
620            timer.fixed_dt = fixed_dt;
621
622            let alpha = timer.interpolation_alpha();
623            prop_assert!(alpha >= 0.0, "Alpha {} should be non-negative", alpha);
624        }
625    }
626}