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