use std::time::Duration; use bevy::{app::RunFixedMainLoopSystems, platform::time::Instant, prelude::*}; use crate::actions::Action; pub const TICKS_PER_SECOND: f64 = 60.0; pub const TICKS_PER_HOUR: u64 = 600; pub const HOURS_PER_DAY: u64 = 24; pub const START_HOUR: u64 = 6; pub struct SimPlugin; impl Plugin for SimPlugin { fn build(&self, app: &mut App) { app.insert_resource(Time::::from_hz(TICKS_PER_SECOND)) .init_resource::() .init_resource::() .configure_sets( FixedUpdate, (SimSystems::Decide, SimSystems::Act, SimSystems::Move).chain(), ) .init_resource::() .add_systems(Update, (speed_controls, adapt_max_speed).chain()) .add_systems(FixedFirst, (advance_tick, remember_positions)) .add_systems( RunFixedMainLoop, ( start_sim_clock.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop), (stop_sim_clock, interpolate_transforms) .in_set(RunFixedMainLoopSystems::AfterFixedMainLoop), ), ); } } #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SimSystems { Decide, Act, Move, } #[derive(Component, Default, Debug, Clone, Copy, PartialEq)] #[require(PrevSimPos)] pub struct SimPos(pub Vec2); #[derive(Component, Default, Debug, Clone, Copy, PartialEq)] pub struct PrevSimPos(pub Vec2); pub fn sim_pos(pos: Vec2) -> (SimPos, PrevSimPos) { (SimPos(pos), PrevSimPos(pos)) } fn remember_positions(mut query: Query<(&SimPos, &mut PrevSimPos)>) { for (pos, mut prev) in &mut query { prev.0 = pos.0; } } fn interpolate_transforms( time: Res>, mut query: Query<(&SimPos, &PrevSimPos, &mut Transform)>, ) { let alpha = time.overstep_fraction(); for (pos, prev, mut transform) in &mut query { transform.translation = prev.0.lerp(pos.0, alpha).extend(transform.translation.z); } } #[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)] pub struct SimTick(pub u64); #[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)] pub enum SimSpeed { #[default] Normal, Fast, Fastest, Max, } impl SimSpeed { pub fn multiplier(self) -> f32 { match self { SimSpeed::Normal => 1.0, SimSpeed::Fast => 3.0, SimSpeed::Fastest => 6.0, SimSpeed::Max => MAX_SPEED_START, } } } pub const MAX_SPEED_START: f32 = 10.0; pub const MAX_SPEED_LIMITS: (f32, f32) = (6.0, 2000.0); pub const MAX_SPEED_FRAME_SECS: f32 = 1.0 / 30.0; const DEFAULT_MAX_DELTA: Duration = Duration::from_millis(250); pub fn max_mode_max_delta(speed: f32) -> Duration { Duration::from_secs_f32(MAX_SPEED_FRAME_SECS * speed) } const MAX_SPEED_BUDGET_SHARE: f32 = 0.6; const MAX_SPEED_SMOOTHING: f32 = 0.3; const MAX_SPEED_RISE: f32 = 1.5; pub fn next_max_speed(current: f32, clock: &SimClock, ticks_in_frame: u64) -> f32 { let sim_target = if ticks_in_frame == 0 { current * 1.1 } else { let secs_per_tick = clock.last_sim_secs / ticks_in_frame as f32; let affordable_ticks = MAX_SPEED_FRAME_SECS * MAX_SPEED_BUDGET_SHARE / secs_per_tick; affordable_ticks / (TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS) }; let frame_target = current * MAX_SPEED_FRAME_SECS / clock.last_frame_secs.max(f32::EPSILON); let target = if clock.last_frame_secs > MAX_SPEED_FRAME_SECS { sim_target.min(frame_target) } else { sim_target }; (current + (target.min(current * MAX_SPEED_RISE) - current) * MAX_SPEED_SMOOTHING) .clamp(MAX_SPEED_LIMITS.0, MAX_SPEED_LIMITS.1) } #[derive(Resource, Default)] pub struct SimClock { started: Option, frame_started: Option, pub last_sim_secs: f32, pub last_frame_secs: f32, } fn start_sim_clock(mut clock: ResMut) { let now = Instant::now(); if let Some(previous) = clock.frame_started { clock.last_frame_secs = now.duration_since(previous).as_secs_f32(); } clock.frame_started = Some(now); clock.started = Some(now); } fn stop_sim_clock(mut clock: ResMut) { if let Some(started) = clock.started.take() { clock.last_sim_secs = started.elapsed().as_secs_f32(); } } fn adapt_max_speed( speed: Res, clock: Res, tick: Res, mut time: ResMut>, mut last_tick: Local, ) { let ticks = tick.0.saturating_sub(*last_tick); *last_tick = tick.0; if *speed != SimSpeed::Max || time.is_paused() { return; } let next = next_max_speed(time.relative_speed(), &clock, ticks); time.set_relative_speed(next); time.set_max_delta(max_mode_max_delta(next)); } fn advance_tick(mut tick: ResMut) { tick.0 += 1; } fn speed_controls( mut actions: MessageReader, mut speed: ResMut, mut time: ResMut>, ) { for action in actions.read() { match *action { Action::TogglePause if time.is_paused() => time.unpause(), Action::TogglePause => time.pause(), Action::SetSpeed(new_speed) => { *speed = new_speed; time.set_relative_speed(new_speed.multiplier()); time.set_max_delta(if new_speed == SimSpeed::Max { max_mode_max_delta(MAX_SPEED_START) } else { DEFAULT_MAX_DELTA }); time.unpause(); } _ => {} } } } pub fn hour_of_day(tick: SimTick) -> f32 { let hours = tick.0 as f64 / TICKS_PER_HOUR as f64 + START_HOUR as f64; (hours % HOURS_PER_DAY as f64) as f32 } pub fn format_clock(tick: SimTick) -> String { let hours = tick.0 / TICKS_PER_HOUR + START_HOUR; let day = hours / HOURS_PER_DAY + 1; let hour = hours % HOURS_PER_DAY; let minute = tick.0 % TICKS_PER_HOUR * 60 / TICKS_PER_HOUR; format!("Day {day}, {hour:02}:{minute:02}") } #[cfg(test)] mod tests { use super::*; #[test] fn clock_starts_in_the_morning_of_day_one() { assert_eq!(format_clock(SimTick(0)), "Day 1, 06:00"); } #[test] fn hour_of_day_wraps_around_midnight() { assert_eq!(hour_of_day(SimTick(0)), 6.0); assert_eq!(hour_of_day(SimTick(TICKS_PER_HOUR * 20)), 2.0); } #[test] fn clock_advances_minutes_hours_and_days() { assert_eq!(format_clock(SimTick(TICKS_PER_HOUR / 2)), "Day 1, 06:30"); assert_eq!(format_clock(SimTick(TICKS_PER_HOUR * 18)), "Day 2, 00:00"); assert_eq!( format_clock(SimTick(TICKS_PER_HOUR * (18 + 24 + 5))), "Day 3, 05:00" ); } // The simulation only moves things once per tick, but frames render more often (and at 6x // speed several ticks run per frame); rendering interpolates between the last two tick // positions so movement looks smooth regardless of frame rate. #[test] fn transforms_are_interpolated_between_ticks() { let mut app = App::new(); app.insert_resource(Time::::from_hz(TICKS_PER_SECOND)) .add_systems(Update, interpolate_transforms); let entity = app .world_mut() .spawn(( SimPos(Vec2::new(10.0, 0.0)), PrevSimPos(Vec2::ZERO), Transform::from_xyz(0.0, 0.0, 1.0), )) .id(); let half_tick = std::time::Duration::from_secs_f64(0.5 / TICKS_PER_SECOND); app.world_mut() .resource_mut::>() .accumulate_overstep(half_tick); app.update(); let translation = app.world().get::(entity).unwrap().translation; assert!(translation.abs_diff_eq(Vec3::new(5.0, 0.0, 1.0), 1e-3)); } fn controls_app() -> App { let mut app = App::new(); app.add_message::() .init_resource::>() .init_resource::() .init_resource::() .init_resource::() .add_systems(Update, (speed_controls, adapt_max_speed).chain()); app } fn trigger(app: &mut App, action: Action) { app.world_mut().write_message(action); app.update(); } #[test] fn speed_actions_scale_virtual_time() { let mut app = controls_app(); trigger(&mut app, Action::SetSpeed(SimSpeed::Fastest)); assert_eq!(*app.world().resource::(), SimSpeed::Fastest); assert_eq!( app.world().resource::>().relative_speed(), 6.0 ); } fn clock(sim_secs: f32, frame_secs: f32) -> SimClock { SimClock { last_sim_secs: sim_secs, last_frame_secs: frame_secs, ..default() } } // The controller aims at the speed the measured cost per tick can afford instead of nudging // by a fixed step per frame: a fixed step overshot for many fast frames and then needed many // slow frames to recover, freezing the browser for seconds at a time. It measures time spent // in the fixed loop rather than whole frames, because whole frames include waiting for vsync, // which made cheap ticks look expensive and pinned the speed at its minimum. #[test] fn max_speed_settles_on_what_the_machine_can_afford() { let secs_per_tick = 0.0005; let mut speed = MAX_SPEED_START; for _ in 0..60 { let ticks = (speed * TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS) as u64; let sim = ticks as f32 * secs_per_tick; speed = next_max_speed(speed, &clock(sim, sim.max(1.0 / 120.0)), ticks); } let frame = speed * TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS * secs_per_tick; assert!( (frame - MAX_SPEED_FRAME_SECS * MAX_SPEED_BUDGET_SHARE).abs() < 0.002, "{frame}" ); } // Work outside the fixed loop (rendering, tilemap refreshes) also grows with speed, so slow // frames back the speed off even when ticks themselves look cheap. #[test] fn max_speed_backs_off_when_frames_are_slow() { let slow = clock(0.001, MAX_SPEED_FRAME_SECS * 4.0); assert!(next_max_speed(400.0, &slow, 800) < 400.0); } // Browsers coarsen timers, so a frame's ticks can measure as taking no time at all; that // must not launch the speed into a range the next frames cannot afford. #[test] fn max_speed_rises_gradually_on_suspiciously_cheap_ticks() { assert!(next_max_speed(10.0, &clock(0.0, 0.01), 7) <= 10.0 * MAX_SPEED_RISE); } #[test] fn max_speed_stays_within_limits() { assert_eq!( next_max_speed(MAX_SPEED_LIMITS.1, &clock(0.0001, 0.001), 1000), MAX_SPEED_LIMITS.1 ); assert_eq!( next_max_speed(MAX_SPEED_LIMITS.0, &clock(10.0, 10.0), 10), MAX_SPEED_LIMITS.0 ); assert!(next_max_speed(MAX_SPEED_START, &clock(0.0, 0.01), 0) > MAX_SPEED_START); } // Bevy drops virtual time beyond max_delta per frame; at high speeds that cap would silently // limit the simulation, so Max mode lifts it and other speeds restore it. #[test] fn max_speed_lifts_the_per_frame_time_cap() { let mut app = controls_app(); trigger(&mut app, Action::SetSpeed(SimSpeed::Max)); assert!(app.world().resource::>().max_delta() > DEFAULT_MAX_DELTA); trigger(&mut app, Action::SetSpeed(SimSpeed::Normal)); assert_eq!( app.world().resource::>().max_delta(), DEFAULT_MAX_DELTA ); } #[test] fn choosing_a_speed_while_paused_resumes() { let mut app = controls_app(); trigger(&mut app, Action::TogglePause); assert!(app.world().resource::>().is_paused()); trigger(&mut app, Action::SetSpeed(SimSpeed::Fast)); assert!(!app.world().resource::>().is_paused()); } }