Skip to main content

repame_sim/
lib.rs

1//! Headless sim: `bevy_ecs` world stepped without a renderer.
2//! Game owns `Sim`, feeds wall time via `Sim::step`,
3//! then reads a snapshot for the viewport.
4//!
5//! Determinism stance: `bevy_ecs` query iteration follows entity allocation
6//! history, so a replay with an identical spawn history iterates
7//! identically. The sim does NOT sort systems' iteration for you: any
8//! gameplay outcome that depends on iteration order (damage application,
9//! projectile-vs-multiple-enemies resolution) must sort explicitly in the
10//! affected system, or replays diverge the moment allocation history does.
11//! Cross-platform float equality (notably `Perlin`/`sin`/`powf` in fx) is
12//! additionally *not* guaranteed between native libm and wasm; same-binary
13//! replay is the supported claim, cross-target equality needs a harness
14//! (run one trace twice, hash the world per tick, diff).
15
16use web_time::Duration;
17
18use bevy_ecs::prelude::*;
19use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule};
20use bevy_ecs::system::ScheduleSystem;
21
22pub use bevy_ecs;
23
24pub mod state;
25pub mod task;
26pub mod timer;
27
28pub use state::{NextState, State, States, in_state};
29pub use task::{Task, TaskPool};
30pub use timer::{StepTimer, TimerMode};
31
32/// Fixed-step sim state. Game registers systems,
33/// feeds wall time with `step`, reads `world` for the snapshot.
34pub struct Sim {
35    /// Entity/component store. Game crates add their own components here.
36    pub world: World,
37    schedule: Schedule,
38    /// Per-state transition appliers, run before `schedule` each tick.
39    state_appliers: Vec<Box<dyn FnMut(&mut World) + Send + Sync>>,
40    accumulator: Duration,
41    /// Fixed step size. Default 60 Hz.
42    ///
43    /// Each tick adds this to `SimTime`. Set before first `step`.
44    pub step: Duration,
45    /// Sim speed multiplier. Zero or negative pauses the sim; a non-finite
46    /// value runs at normal speed.
47    pub time_scale: f32,
48    /// Max ticks per `step` call. Default `8`.
49    ///
50    /// Caps catch-up after hitches. Excess wall time is dropped.
51    /// Clamped to a minimum of `1` so the sim keeps moving.
52    pub max_steps: u32,
53}
54
55/// Sim time, advanced once per fixed step.
56#[derive(Resource, Clone, Copy, Debug, Default)]
57pub struct SimTime {
58    /// Total simulated seconds.
59    pub elapsed_secs: f64,
60    /// Fixed delta of the current step.
61    pub delta_secs: f32,
62}
63
64impl Sim {
65    pub fn new(step: Duration) -> Self {
66        let mut world = World::new();
67        world.init_resource::<SimTime>();
68        Self {
69            world,
70            schedule: Schedule::default(),
71            state_appliers: Vec::new(),
72            accumulator: Duration::ZERO,
73            step,
74            time_scale: 1.0,
75            max_steps: 8,
76        }
77    }
78
79    /// 60 Hz fixed-step sim.
80    pub fn with_default_step() -> Self {
81        Self::new(Duration::from_secs_f64(1.0 / 60.0))
82    }
83
84    /// Register a system into the per-step schedule.
85    pub fn add_system<M>(&mut self, system: impl IntoSystem<(), (), M>) -> &mut Self {
86        self.schedule.add_systems(system);
87        self
88    }
89
90    /// Register systems with a fixed execution order.
91    ///
92    /// Use one `(a, b, c).chain()` tuple for pipelines that need order.
93    pub fn add_chained_systems<M>(
94        &mut self,
95        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
96    ) -> &mut Self {
97        self.schedule.add_systems(systems);
98        self
99    }
100
101    /// Register a state set with its initial value.
102    ///
103    /// Call once per state type, before `on_enter`/`on_exit`/`on_transition`.
104    /// Transitions are applied at the head of each step, before the main
105    /// schedule, so gameplay never observes a half-switched state.
106    pub fn add_state<S: States>(&mut self, initial: S) -> &mut Self {
107        self.world.insert_resource(State::<S>::new(initial));
108        self.world.insert_resource(NextState::<S>::new());
109        state::init_schedules::<S>(&mut self.world);
110        self.state_appliers.push(Box::new(state::apply::<S>));
111        self
112    }
113
114    /// Systems that run when `state` is entered.
115    ///
116    /// Panics if `add_state` was not called for this state set first.
117    pub fn on_enter<S, M>(
118        &mut self,
119        state: S,
120        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
121    ) -> &mut Self
122    where
123        S: States,
124    {
125        state::add_enter(&mut self.world, state, systems);
126        self
127    }
128
129    /// Systems that run when `state` is exited.
130    pub fn on_exit<S, M>(
131        &mut self,
132        state: S,
133        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
134    ) -> &mut Self
135    where
136        S: States,
137    {
138        state::add_exit(&mut self.world, state, systems);
139        self
140    }
141
142    /// Systems that run on the `exited -> entered` edge.
143    pub fn on_transition<S, M>(
144        &mut self,
145        exited: S,
146        entered: S,
147        systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
148    ) -> &mut Self
149    where
150        S: States,
151    {
152        state::add_transition(&mut self.world, exited, entered, systems);
153        self
154    }
155
156    /// Request a state change, equivalent to writing `NextState<S>` from a
157    /// system. Panics if the state set was not registered.
158    pub fn set_state<S: States>(&mut self, next: S) -> &mut Self {
159        self.world.resource_mut::<NextState<S>>().set(next);
160        self
161    }
162
163    /// The current value of a state set.
164    pub fn state<S: States>(&self) -> &S {
165        self.world.resource::<State<S>>().get()
166    }
167
168    /// Advance by `dt` wall time. Returns ticks run.
169    ///
170    /// Small `dt` banks for later. Large `dt` runs up to `max_steps`
171    /// ticks, then drops leftover whole steps. Example:
172    ///
173    /// ```ignore
174    /// let steps = sim.step(frame_dt);
175    /// ```
176    pub fn step(&mut self, dt: Duration) -> u32 {
177        self.step_with(dt, |_| {})
178    }
179
180    /// Whether the clock is stopped. Zero or negative pauses; a non-finite
181    /// value is a mistake, so it runs at normal speed instead of stalling.
182    fn paused(&self) -> bool {
183        self.time_scale.is_finite() && self.time_scale <= 0.0
184    }
185
186    pub fn step_with(&mut self, dt: Duration, mut after_tick: impl FnMut(&mut World)) -> u32 {
187        if self.step.is_zero() {
188            return 0;
189        }
190        if self.paused() {
191            // Drop the wall-clock time rather than bank it, so resuming after a
192            // long pause does not fire a burst of catch-up ticks.
193            self.accumulator = Duration::ZERO;
194            return 0;
195        }
196        self.accumulator += dt;
197        let mut ran = 0;
198        while self.accumulator >= self.step && ran < self.max_steps.max(1) {
199            self.accumulator -= self.step;
200            self.tick_with(&mut after_tick);
201            ran += 1;
202        }
203        if self.accumulator >= self.step {
204            // Hitch: keep only the sub-step fraction for alpha continuity.
205            let step_ns = self.step.as_nanos();
206            if step_ns > 0 {
207                let acc_ns = self.accumulator.as_nanos() % step_ns;
208                self.accumulator = Duration::from_nanos(acc_ns as u64);
209            } else {
210                self.accumulator = Duration::ZERO;
211            }
212        }
213        ran
214    }
215
216    /// Unconsumed time carried to the next frame. Below one step.
217    pub fn leftover(&self) -> Duration {
218        self.accumulator
219    }
220
221    /// Blend factor `accumulator / step`, clamped to `0..1`.
222    /// Zero step returns `0.0`. For render blending between snapshots: keep
223    /// the previous snapshot and lerp toward the current one by this alpha
224    /// (NT motion at 30 Hz sim on 144 Hz display needs it; without it every
225    /// fast mover judders on 4-5 identical frames then jumps).
226    pub fn alpha(&self) -> f32 {
227        let step = self.step.as_secs_f64();
228        if step <= 0.0 {
229            return 0.0;
230        }
231        (self.accumulator.as_secs_f64() / step).clamp(0.0, 1.0) as f32
232    }
233
234    /// Run the schedule once, advancing sim time by one step.
235    /// Tick-model games drive this directly instead of `step`.
236    pub fn tick(&mut self) {
237        self.tick_with(|_| {});
238    }
239
240    fn tick_with(&mut self, after_tick: impl FnOnce(&mut World)) {
241        if self.paused() {
242            return;
243        }
244        let time_scale = if self.time_scale.is_finite() {
245            self.time_scale
246        } else {
247            1.0
248        };
249        {
250            let mut time = self.world.resource_mut::<SimTime>();
251            time.delta_secs = self.step.as_secs_f32() * time_scale;
252            time.elapsed_secs += self.step.as_secs_f64() * time_scale as f64;
253        }
254        let Sim {
255            state_appliers,
256            world,
257            schedule,
258            ..
259        } = self;
260        for apply in state_appliers.iter_mut() {
261            apply(world);
262        }
263        schedule.run(world);
264        after_tick(world);
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn fixed_steps_accumulate() {
274        let mut sim = Sim::with_default_step();
275        let ran = sim.step(Duration::from_nanos(50_000_001));
276        assert_eq!(ran, 3);
277        let time = sim.world.resource::<SimTime>();
278        assert!((time.elapsed_secs - 3.0 / 60.0).abs() < 1e-9);
279    }
280
281    #[test]
282    fn zero_time_scale_pauses_without_banking_wall_time() {
283        let per_frame = Sim::with_default_step().step(Duration::from_millis(50));
284        assert!(per_frame > 0);
285
286        let mut sim = Sim::with_default_step();
287        sim.step(Duration::from_millis(50));
288        let before = sim.world.resource::<SimTime>().elapsed_secs;
289
290        sim.time_scale = 0.0;
291        assert_eq!(
292            sim.step(Duration::from_secs(5)),
293            0,
294            "paused sim runs no ticks"
295        );
296        sim.tick();
297        assert_eq!(
298            sim.world.resource::<SimTime>().elapsed_secs,
299            before,
300            "paused sim does not advance its clock"
301        );
302
303        sim.time_scale = 1.0;
304        assert_eq!(
305            sim.step(Duration::from_millis(50)),
306            per_frame,
307            "resuming must not fire a catch-up burst"
308        );
309    }
310
311    #[test]
312    fn non_finite_time_scale_runs_at_normal_speed() {
313        let mut sim = Sim::with_default_step();
314        sim.time_scale = f32::NAN;
315        sim.step(Duration::from_millis(51));
316        let time = sim.world.resource::<SimTime>();
317        assert!(
318            (time.delta_secs - 1.0 / 60.0).abs() < 1e-6,
319            "a non-finite scale must not stall the delta, got {}",
320            time.delta_secs
321        );
322        assert!(time.elapsed_secs > 0.0, "the clock still advances");
323    }
324
325    #[test]
326    fn post_tick_runs_after_each_fixed_step() {
327        use std::sync::{Arc, Mutex};
328
329        let log = Arc::new(Mutex::new(Vec::new()));
330        let sim_log = log.clone();
331        let mut sim = Sim::with_default_step();
332        sim.add_system(move || sim_log.lock().unwrap().push("sim"));
333        let post_log = log.clone();
334
335        let ran = sim.step_with(Duration::from_millis(51), move |_| {
336            post_log.lock().unwrap().push("post");
337        });
338
339        assert_eq!(ran, 3);
340        assert_eq!(
341            *log.lock().unwrap(),
342            ["sim", "post", "sim", "post", "sim", "post"]
343        );
344    }
345
346    #[test]
347    fn zero_step_never_ticks() {
348        let mut sim = Sim::new(Duration::ZERO);
349        let ran = sim.step(Duration::from_secs(10));
350        assert_eq!(ran, 0);
351        let time = sim.world.resource::<SimTime>();
352        assert_eq!(time.elapsed_secs, 0.0);
353    }
354
355    #[test]
356    fn spiral_of_death_is_capped() {
357        let mut sim = Sim::with_default_step();
358        sim.max_steps = 4;
359        let ran = sim.step(Duration::from_secs(10));
360        assert_eq!(ran, 4);
361        assert!(
362            sim.leftover() < sim.step,
363            "keeps only fraction, got {:?}",
364            sim.leftover()
365        );
366    }
367
368    #[test]
369    fn alpha_is_interpolation_fraction() {
370        let mut sim = Sim::new(Duration::from_millis(16));
371        sim.step(Duration::from_millis(8));
372        assert!((sim.alpha() - 0.5).abs() < 1e-6, "got {}", sim.alpha());
373        sim.step(Duration::from_millis(8));
374        assert!(sim.alpha() < 1e-6, "step boundary, got {}", sim.alpha());
375    }
376
377    #[test]
378    fn time_scale_changes_simulated_delta_without_changing_wall_accumulation() {
379        let mut sim = Sim::with_default_step();
380        sim.time_scale = 0.25;
381        let ran = sim.step(Duration::from_nanos(16_666_667));
382        let time = sim.world.resource::<SimTime>();
383        assert_eq!(ran, 1);
384        assert!((time.delta_secs - 1.0 / 240.0).abs() < 1e-6);
385        assert!((time.elapsed_secs - 1.0 / 240.0).abs() < 1e-9);
386    }
387
388    #[test]
389    fn chained_systems_run_in_order() {
390        use std::sync::{Arc, Mutex};
391        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
392        let (a_log, b_log, c_log) = (log.clone(), log.clone(), log.clone());
393        let mut sim = Sim::with_default_step();
394        sim.add_chained_systems(
395            (
396                move || a_log.lock().unwrap().push("a"),
397                move || b_log.lock().unwrap().push("b"),
398                move || c_log.lock().unwrap().push("c"),
399            )
400                .chain(),
401        );
402        sim.tick();
403        assert_eq!(*log.lock().unwrap(), vec!["a", "b", "c"]);
404    }
405}