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