src/sim.rs 14 K · 434 lines · raw · history

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