src/sim.rs 12 K · 362 lines · raw · history

1 use std::time::Duration;
2
3 use bevy::{app::RunFixedMainLoopSystems, platform::time::Instant, prelude::*};
4
5 use crate::actions::Action;
6
7 pub const TICKS_PER_SECOND: f64 = 60.0;
8 pub const TICKS_PER_HOUR: u64 = 600;
9 pub const HOURS_PER_DAY: u64 = 24;
10 pub const START_HOUR: u64 = 6;
11
12 pub struct SimPlugin;
13
14 impl Plugin for SimPlugin {
15 fn build(&self, app: &mut App) {
16 app.insert_resource(Time::<Fixed>::from_hz(TICKS_PER_SECOND))
17 .init_resource::<SimTick>()
18 .init_resource::<SimSpeed>()
19 .configure_sets(
20 FixedUpdate,
21 (SimSystems::Decide, SimSystems::Act, SimSystems::Move).chain(),
22 )
23 .init_resource::<SimClock>()
24 .add_systems(Update, (speed_controls, adapt_max_speed).chain())
25 .add_systems(FixedFirst, (advance_tick, remember_positions))
26 .add_systems(
27 RunFixedMainLoop,
28 (
29 start_sim_clock.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
30 (stop_sim_clock, interpolate_transforms)
31 .in_set(RunFixedMainLoopSystems::AfterFixedMainLoop),
32 ),
33 );
34 }
35 }
36
37 #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
38 pub enum SimSystems {
39 Decide,
40 Act,
41 Move,
42 }
43
44 #[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
45 #[require(PrevSimPos)]
46 pub struct SimPos(pub Vec2);
47
48 #[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
49 pub struct PrevSimPos(pub Vec2);
50
51 pub fn sim_pos(pos: Vec2) -> (SimPos, PrevSimPos) {
52 (SimPos(pos), PrevSimPos(pos))
53 }
54
55 fn remember_positions(mut query: Query<(&SimPos, &mut PrevSimPos)>) {
56 for (pos, mut prev) in &mut query {
57 prev.0 = pos.0;
58 }
59 }
60
61 fn interpolate_transforms(
62 time: Res<Time<Fixed>>,
63 mut query: Query<(&SimPos, &PrevSimPos, &mut Transform)>,
64 ) {
65 let alpha = time.overstep_fraction();
66 for (pos, prev, mut transform) in &mut query {
67 transform.translation = prev.0.lerp(pos.0, alpha).extend(transform.translation.z);
68 }
69 }
70
71 #[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
72 pub struct SimTick(pub u64);
73
74 #[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
75 pub enum SimSpeed {
76 #[default]
77 Normal,
78 Fast,
79 Fastest,
80 Max,
81 }
82
83 impl SimSpeed {
84 pub fn multiplier(self) -> f32 {
85 match self {
86 SimSpeed::Normal => 1.0,
87 SimSpeed::Fast => 3.0,
88 SimSpeed::Fastest => 6.0,
89 SimSpeed::Max => MAX_SPEED_START,
90 }
91 }
92 }
93
94 pub const MAX_SPEED_START: f32 = 10.0;
95 pub const MAX_SPEED_LIMITS: (f32, f32) = (6.0, 2000.0);
96 pub const MAX_SPEED_FRAME_SECS: f32 = 1.0 / 30.0;
97 const DEFAULT_MAX_DELTA: Duration = Duration::from_millis(250);
98
99 pub fn max_mode_max_delta(speed: f32) -> Duration {
100 Duration::from_secs_f32(MAX_SPEED_FRAME_SECS * speed)
101 }
102
103 const MAX_SPEED_BUDGET_SHARE: f32 = 0.6;
104 const MAX_SPEED_SMOOTHING: f32 = 0.3;
105 const MAX_SPEED_RISE: f32 = 1.5;
106
107 pub fn next_max_speed(current: f32, clock: &SimClock, ticks_in_frame: u64) -> f32 {
108 let sim_target = if ticks_in_frame == 0 {
109 current * 1.1
110 } else {
111 let secs_per_tick = clock.last_sim_secs / ticks_in_frame as f32;
112 let affordable_ticks = MAX_SPEED_FRAME_SECS * MAX_SPEED_BUDGET_SHARE / secs_per_tick;
113 affordable_ticks / (TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS)
114 };
115 let frame_target = current * MAX_SPEED_FRAME_SECS / clock.last_frame_secs.max(f32::EPSILON);
116 let target = if clock.last_frame_secs > MAX_SPEED_FRAME_SECS {
117 sim_target.min(frame_target)
118 } else {
119 sim_target
120 };
121 (current + (target.min(current * MAX_SPEED_RISE) - current) * MAX_SPEED_SMOOTHING)
122 .clamp(MAX_SPEED_LIMITS.0, MAX_SPEED_LIMITS.1)
123 }
124
125 #[derive(Resource, Default)]
126 pub struct SimClock {
127 started: Option<Instant>,
128 frame_started: Option<Instant>,
129 pub last_sim_secs: f32,
130 pub last_frame_secs: f32,
131 }
132
133 fn start_sim_clock(mut clock: ResMut<SimClock>) {
134 let now = Instant::now();
135 if let Some(previous) = clock.frame_started {
136 clock.last_frame_secs = now.duration_since(previous).as_secs_f32();
137 }
138 clock.frame_started = Some(now);
139 clock.started = Some(now);
140 }
141
142 fn stop_sim_clock(mut clock: ResMut<SimClock>) {
143 if let Some(started) = clock.started.take() {
144 clock.last_sim_secs = started.elapsed().as_secs_f32();
145 }
146 }
147
148 fn adapt_max_speed(
149 speed: Res<SimSpeed>,
150 clock: Res<SimClock>,
151 tick: Res<SimTick>,
152 mut time: ResMut<Time<Virtual>>,
153 mut last_tick: Local<u64>,
154 ) {
155 let ticks = tick.0.saturating_sub(*last_tick);
156 *last_tick = tick.0;
157 if *speed != SimSpeed::Max || time.is_paused() {
158 return;
159 }
160 let next = next_max_speed(time.relative_speed(), &clock, ticks);
161 time.set_relative_speed(next);
162 time.set_max_delta(max_mode_max_delta(next));
163 }
164
165 fn advance_tick(mut tick: ResMut<SimTick>) {
166 tick.0 += 1;
167 }
168
169 fn speed_controls(
170 mut actions: MessageReader<Action>,
171 mut speed: ResMut<SimSpeed>,
172 mut time: ResMut<Time<Virtual>>,
173 ) {
174 for action in actions.read() {
175 match *action {
176 Action::TogglePause if time.is_paused() => time.unpause(),
177 Action::TogglePause => time.pause(),
178 Action::SetSpeed(new_speed) => {
179 *speed = new_speed;
180 time.set_relative_speed(new_speed.multiplier());
181 time.set_max_delta(if new_speed == SimSpeed::Max {
182 max_mode_max_delta(MAX_SPEED_START)
183 } else {
184 DEFAULT_MAX_DELTA
185 });
186 time.unpause();
187 }
188 _ => {}
189 }
190 }
191 }
192
193 pub fn hour_of_day(tick: SimTick) -> f32 {
194 let hours = tick.0 as f64 / TICKS_PER_HOUR as f64 + START_HOUR as f64;
195 (hours % HOURS_PER_DAY as f64) as f32
196 }
197
198 pub fn format_clock(tick: SimTick) -> String {
199 let hours = tick.0 / TICKS_PER_HOUR + START_HOUR;
200 let day = hours / HOURS_PER_DAY + 1;
201 let hour = hours % HOURS_PER_DAY;
202 let minute = tick.0 % TICKS_PER_HOUR * 60 / TICKS_PER_HOUR;
203 format!("Day {day}, {hour:02}:{minute:02}")
204 }
205
206 #[cfg(test)]
207 mod tests {
208 use super::*;
209
210 #[test]
211 fn clock_starts_in_the_morning_of_day_one() {
212 assert_eq!(format_clock(SimTick(0)), "Day 1, 06:00");
213 }
214
215 #[test]
216 fn hour_of_day_wraps_around_midnight() {
217 assert_eq!(hour_of_day(SimTick(0)), 6.0);
218 assert_eq!(hour_of_day(SimTick(TICKS_PER_HOUR * 20)), 2.0);
219 }
220
221 #[test]
222 fn clock_advances_minutes_hours_and_days() {
223 assert_eq!(format_clock(SimTick(TICKS_PER_HOUR / 2)), "Day 1, 06:30");
224 assert_eq!(format_clock(SimTick(TICKS_PER_HOUR * 18)), "Day 2, 00:00");
225 assert_eq!(
226 format_clock(SimTick(TICKS_PER_HOUR * (18 + 24 + 5))),
227 "Day 3, 05:00"
228 );
229 }
230
231 // The simulation only moves things once per tick, but frames render more often (and at 6x
232 // speed several ticks run per frame); rendering interpolates between the last two tick
233 // positions so movement looks smooth regardless of frame rate.
234 #[test]
235 fn transforms_are_interpolated_between_ticks() {
236 let mut app = App::new();
237 app.insert_resource(Time::<Fixed>::from_hz(TICKS_PER_SECOND))
238 .add_systems(Update, interpolate_transforms);
239 let entity = app
240 .world_mut()
241 .spawn((
242 SimPos(Vec2::new(10.0, 0.0)),
243 PrevSimPos(Vec2::ZERO),
244 Transform::from_xyz(0.0, 0.0, 1.0),
245 ))
246 .id();
247 let half_tick = std::time::Duration::from_secs_f64(0.5 / TICKS_PER_SECOND);
248 app.world_mut()
249 .resource_mut::<Time<Fixed>>()
250 .accumulate_overstep(half_tick);
251 app.update();
252 let translation = app.world().get::<Transform>(entity).unwrap().translation;
253 assert!(translation.abs_diff_eq(Vec3::new(5.0, 0.0, 1.0), 1e-3));
254 }
255
256 fn controls_app() -> App {
257 let mut app = App::new();
258 app.add_message::<Action>()
259 .init_resource::<Time<Virtual>>()
260 .init_resource::<SimTick>()
261 .init_resource::<SimClock>()
262 .init_resource::<SimSpeed>()
263 .add_systems(Update, (speed_controls, adapt_max_speed).chain());
264 app
265 }
266
267 fn trigger(app: &mut App, action: Action) {
268 app.world_mut().write_message(action);
269 app.update();
270 }
271
272 #[test]
273 fn speed_actions_scale_virtual_time() {
274 let mut app = controls_app();
275 trigger(&mut app, Action::SetSpeed(SimSpeed::Fastest));
276 assert_eq!(*app.world().resource::<SimSpeed>(), SimSpeed::Fastest);
277 assert_eq!(
278 app.world().resource::<Time<Virtual>>().relative_speed(),
279 6.0
280 );
281 }
282
283 fn clock(sim_secs: f32, frame_secs: f32) -> SimClock {
284 SimClock {
285 last_sim_secs: sim_secs,
286 last_frame_secs: frame_secs,
287 ..default()
288 }
289 }
290
291 // The controller aims at the speed the measured cost per tick can afford instead of nudging
292 // by a fixed step per frame: a fixed step overshot for many fast frames and then needed many
293 // slow frames to recover, freezing the browser for seconds at a time. It measures time spent
294 // in the fixed loop rather than whole frames, because whole frames include waiting for vsync,
295 // which made cheap ticks look expensive and pinned the speed at its minimum.
296 #[test]
297 fn max_speed_settles_on_what_the_machine_can_afford() {
298 let secs_per_tick = 0.0005;
299 let mut speed = MAX_SPEED_START;
300 for _ in 0..60 {
301 let ticks = (speed * TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS) as u64;
302 let sim = ticks as f32 * secs_per_tick;
303 speed = next_max_speed(speed, &clock(sim, sim.max(1.0 / 120.0)), ticks);
304 }
305 let frame = speed * TICKS_PER_SECOND as f32 * MAX_SPEED_FRAME_SECS * secs_per_tick;
306 assert!(
307 (frame - MAX_SPEED_FRAME_SECS * MAX_SPEED_BUDGET_SHARE).abs() < 0.002,
308 "{frame}"
309 );
310 }
311
312 // Work outside the fixed loop (rendering, tilemap refreshes) also grows with speed, so slow
313 // frames back the speed off even when ticks themselves look cheap.
314 #[test]
315 fn max_speed_backs_off_when_frames_are_slow() {
316 let slow = clock(0.001, MAX_SPEED_FRAME_SECS * 4.0);
317 assert!(next_max_speed(400.0, &slow, 800) < 400.0);
318 }
319
320 // Browsers coarsen timers, so a frame's ticks can measure as taking no time at all; that
321 // must not launch the speed into a range the next frames cannot afford.
322 #[test]
323 fn max_speed_rises_gradually_on_suspiciously_cheap_ticks() {
324 assert!(next_max_speed(10.0, &clock(0.0, 0.01), 7) <= 10.0 * MAX_SPEED_RISE);
325 }
326
327 #[test]
328 fn max_speed_stays_within_limits() {
329 assert_eq!(
330 next_max_speed(MAX_SPEED_LIMITS.1, &clock(0.0001, 0.001), 1000),
331 MAX_SPEED_LIMITS.1
332 );
333 assert_eq!(
334 next_max_speed(MAX_SPEED_LIMITS.0, &clock(10.0, 10.0), 10),
335 MAX_SPEED_LIMITS.0
336 );
337 assert!(next_max_speed(MAX_SPEED_START, &clock(0.0, 0.01), 0) > MAX_SPEED_START);
338 }
339
340 // Bevy drops virtual time beyond max_delta per frame; at high speeds that cap would silently
341 // limit the simulation, so Max mode lifts it and other speeds restore it.
342 #[test]
343 fn max_speed_lifts_the_per_frame_time_cap() {
344 let mut app = controls_app();
345 trigger(&mut app, Action::SetSpeed(SimSpeed::Max));
346 assert!(app.world().resource::<Time<Virtual>>().max_delta() > DEFAULT_MAX_DELTA);
347 trigger(&mut app, Action::SetSpeed(SimSpeed::Normal));
348 assert_eq!(
349 app.world().resource::<Time<Virtual>>().max_delta(),
350 DEFAULT_MAX_DELTA
351 );
352 }
353
354 #[test]
355 fn choosing_a_speed_while_paused_resumes() {
356 let mut app = controls_app();
357 trigger(&mut app, Action::TogglePause);
358 assert!(app.world().resource::<Time<Virtual>>().is_paused());
359 trigger(&mut app, Action::SetSpeed(SimSpeed::Fast));
360 assert!(!app.world().resource::<Time<Virtual>>().is_paused());
361 }
362 }