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}