Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
fixed_step.rs

1//! Driving the game from display frames. A host owes the game some game
2//! minutes each frame, set by the game speed it shows; [`FrameStepper`] cuts
3//! them into whole steps sized from the game's own clock, so the tower comes
4//! out the same at any frame rate. The web game runs this same stepper, so a
5//! host that uses it matches the browser at the same speed.
6//!
7//! ```
8//! use verticopolis_engine::clock::GameMode;
9//! use verticopolis_engine::fixed_step::FrameStepper;
10//! use verticopolis_engine::sim::Simulation;
11//!
12//! let mut sim = Simulation::new_game(1, GameMode::Classic);
13//! let mut stepper = FrameStepper::default();
14//! let start = sim.clock.minutes;
15//! // One second of 60 Hz frames at 10 game minutes per real second, with
16//! // the canon day curve (steady_clock false).
17//! for _ in 0..60 {
18//!     stepper.advance(&mut sim, 1000.0 / 60.0, 10.0, false, true);
19//! }
20//! assert!(sim.clock.minutes > start);
21//! ```
22//!
23//! Two rules sit outside the stepper, in the host. Clamp a frame longer than
24//! 200 ms to 1 ms before passing it in (a tab coming back from the
25//! background), and set `stepper.acc_minutes = 0.0` when the player changes
26//! speed or the steady clock, while a blocking dialog is open, and once the
27//! game has stopped.
28
29use crate::sim::Simulation;
30use crate::tdt::pacing::pace_factor;
31
32#[doc(hidden)]
33/// The frame rate the quantum is sized for: about one step per frame.
34pub const REFERENCE_HZ: f64 = 60.0;
35/// At most this many steps run in one frame.
36pub const MAX_STEPS_PER_FRAME: f64 = 4.0;
37#[doc(hidden)]
38/// The largest step, in game minutes, below the host's 30-minute catch-up cap.
39pub const MAX_QUANTUM_MINUTES: f64 = 20.0;
40#[doc(hidden)]
41/// Float dust tolerated when deciding a whole quantum is owed.
42pub const OWED_EPSILON: f64 = 1e-6;
43/// Hard cap on owed minutes carried between frames (the host half).
44pub const MAX_CATCHUP_MINUTES: f64 = 30.0;
45
46/// What the stepper drives: [`Simulation`] implements it.
47pub trait StepSim {
48    /// Advances the game by `dt_minutes`.
49    fn step(&mut self, dt_minutes: f64);
50    /// The game clock's minute of the day, 0 up to 1,440.
51    fn minute_of_day(&self) -> f64;
52}
53
54impl StepSim for Simulation {
55    fn step(&mut self, dt_minutes: f64) {
56        self.tick(dt_minutes);
57    }
58    fn minute_of_day(&self) -> f64 {
59        self.clock.minute_of_day()
60    }
61}
62
63#[doc(hidden)]
64/// Minutes the host still owes the sim, and the step mode (speed and pacing)
65/// they were owed under.
66#[derive(Clone, Debug, Default, PartialEq)]
67pub struct StepDebt {
68    pub minutes: f64,
69    mode: Option<(f64, bool)>,
70}
71
72impl StepDebt {
73    /// A debt of `minutes` with no mode recorded yet.
74    pub fn owing(minutes: f64) -> StepDebt {
75        StepDebt {
76            minutes,
77            mode: None,
78        }
79    }
80}
81
82/// The same rate in the TypeScript's `${rate}|${steady}` mode string: equal
83/// numbers match (so does NaN with NaN), and zero matches negative zero.
84fn same_mode(a: (f64, bool), b: (f64, bool)) -> bool {
85    (a.0 == b.0 || (a.0.is_nan() && b.0.is_nan())) && a.1 == b.1
86}
87
88/// The step size, in whole game minutes, for a host running at
89/// `minutes_per_second` when the game clock reads `minute_of_day`.
90pub fn quantum_for(minutes_per_second: f64, minute_of_day: f64, steady_clock: bool) -> f64 {
91    let pace = if steady_clock {
92        1.0
93    } else {
94        pace_factor(minute_of_day)
95    };
96    let per_frame = (minutes_per_second * pace) / REFERENCE_HZ;
97    if !per_frame.is_finite() || per_frame < 1.0 {
98        return 1.0;
99    }
100    MAX_QUANTUM_MINUTES.min(per_frame.floor())
101}
102
103#[doc(hidden)]
104/// `syncStepMode`: record the mode the host is about to owe minutes under,
105/// dropping any owed under a different one.
106pub fn sync_step_mode(debt: &mut StepDebt, minutes_per_second: f64, steady_clock: bool) {
107    let mode = (minutes_per_second, steady_clock);
108    if let Some(old) = debt.mode {
109        if !same_mode(old, mode) {
110            debt.minutes = 0.0;
111        }
112    }
113    debt.mode = Some(mode);
114}
115
116#[doc(hidden)]
117/// `drainFixedSteps`: advance `sim` by whole quanta out of `debt.minutes`, at
118/// most `max_steps` of them (a value that is not a finite number of at least
119/// one falls back to [`MAX_STEPS_PER_FRAME`]). Returns the steps run.
120pub fn drain_fixed_steps<S: StepSim>(
121    sim: &mut S,
122    debt: &mut StepDebt,
123    minutes_per_second: f64,
124    steady_clock: bool,
125    max_steps: Option<f64>,
126) -> u32 {
127    let requested = max_steps.unwrap_or(MAX_STEPS_PER_FRAME);
128    let max_steps = if requested.is_finite() && requested >= 1.0 {
129        requested.floor()
130    } else {
131        MAX_STEPS_PER_FRAME
132    };
133    sync_step_mode(debt, minutes_per_second, steady_clock);
134    let owed_ok = debt.minutes >= -OWED_EPSILON && debt.minutes.is_finite();
135    let rate_ok = minutes_per_second > 0.0 && minutes_per_second.is_finite();
136    if !owed_ok || !rate_ok {
137        debt.minutes = 0.0;
138        return 0;
139    }
140    let mut q = quantum_for(minutes_per_second, sim.minute_of_day(), steady_clock);
141    let mut steps = 0u32;
142    while debt.minutes >= q - OWED_EPSILON && f64::from(steps) < max_steps {
143        sim.step(q);
144        debt.minutes = (-OWED_EPSILON).max(debt.minutes - q);
145        q = quantum_for(minutes_per_second, sim.minute_of_day(), steady_clock);
146        steps += 1;
147    }
148    steps
149}
150
151/// A host's frame-to-steps state. Keep one per game and call
152/// [`advance`](FrameStepper::advance) once per display frame.
153#[derive(Clone, Debug, Default, PartialEq)]
154pub struct FrameStepper {
155    /// The game minutes owed and not yet stepped, carried to the next frame.
156    pub acc_minutes: f64,
157    #[doc(hidden)]
158    pub debt: StepDebt,
159}
160
161impl FrameStepper {
162    /// Owes this frame's game minutes and steps what is owed. `dt_ms` is the
163    /// frame's real length and `minutes_per_second` the game speed. Without
164    /// `steady_clock`, time follows the canon day curve, whose pace changes
165    /// through the day as the 1994 game's clock does; with it, the rate is
166    /// flat. The carry never exceeds
167    /// [`MAX_CATCHUP_MINUTES`], and no frame runs more than
168    /// [`MAX_STEPS_PER_FRAME`] steps. With `step` false the minutes are owed
169    /// but not stepped (a host still busy with its last batch). Returns the
170    /// steps run.
171    pub fn advance<S: StepSim>(
172        &mut self,
173        sim: &mut S,
174        dt_ms: f64,
175        minutes_per_second: f64,
176        steady_clock: bool,
177        step: bool,
178    ) -> u32 {
179        self.debt.minutes = self.acc_minutes;
180        sync_step_mode(&mut self.debt, minutes_per_second, steady_clock);
181        self.acc_minutes = self.debt.minutes;
182        let pace = if steady_clock {
183            1.0
184        } else {
185            pace_factor(sim.minute_of_day())
186        };
187        self.acc_minutes += (dt_ms / 1000.0) * minutes_per_second * pace;
188        if !self.acc_minutes.is_finite() {
189            self.acc_minutes = 0.0;
190        }
191        if self.acc_minutes > MAX_CATCHUP_MINUTES {
192            self.acc_minutes = MAX_CATCHUP_MINUTES;
193        }
194        self.debt.minutes = self.acc_minutes;
195        if !step {
196            return 0;
197        }
198        let steps = drain_fixed_steps(sim, &mut self.debt, minutes_per_second, steady_clock, None);
199        self.acc_minutes = self.debt.minutes;
200        steps
201    }
202}