Add fixed-tick simulation with speed controls and game clock
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 08:41 UTC
- commit
- 3f9920c94caabace2e63134512ccea491077dcc6
- parent
- 23fbcd7ecf
- tree
- browse at this commit
6 files changed +233 -35
| CLAUDE.md | +3 -2 | |
| src/colonist.rs | +12 -29 | |
| src/main.rs | +2 -0 | |
| src/selection.rs | +20 -2 | |
| src/sim.rs | added | +194 -0 |
| tasks.md | +2 -2 |
CLAUDE.md +3 -2
| @@ -16,9 +16,10 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 16 | 16 | - `src/worldgen.rs` `WorldGenPlugin` inserts `WorldSeed` + `WorldMap` (256x256) at build time and writes `WorldGenerated` message at Startup; R → seed+1, regenerate, write message. `generate(size, seed)`: elevation fbm → deep/shallow/sand/stone by **quantile** thresholds (fixed terrain shares per seed), fertility fbm top 20% → soil. `largest_region` (4-conn flood of walkable), `spawn_tiles(map, n)` = BFS from largest-region tile nearest map center. Everything reacting to a new world runs `.run_if(on_message::<WorldGenerated>)`: tilemap refresh, colonist respawn, camera recenter, selection clear. |
| 17 | 17 | - `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Chunks spawned once at Startup (`Chunk{origin}`); world changes overwrite `TilemapChunkTileData` in place (bevy's `update_tilemap_chunk_indices` re-uploads via change detection). |
| 18 | 18 | - `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect. |
| 19 | -- `src/selection.rs` `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (PAUSED, hover, selection). Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`. | |
| 19 | +- `src/selection.rs` `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, hover, selection). Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`. | |
| 20 | 20 | - `src/pathfinding.rs` `find_path(map, start, goal) -> Option<Vec<UVec2>>` A*, 8-dir, no corner cutting, cost = (10|14) * `Terrain::move_cost` of entered tile (100 = normal, None = impassable), octile heuristic. Path excludes start, includes goal; start==goal → empty. |
| 21 | -- `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (last reached tile), `Path(VecDeque)`, `Wander(Timer)`. Update chain: `handle_move_orders` (`repath` keeps tile being entered at front), `wander` (idle + timer → random goal within radius), `follow_path` (speed / terrain cost, `step_towards`). Space toggles `Time<Virtual>` pause; sim systems use `Res<Time>` (virtual), camera uses `Time<Real>` so it works while paused. Rendered as Mesh2d circles z=1. | |
| 21 | +- `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (last reached tile), `Path(VecDeque)`, `Wander(Timer)`. `handle_move_orders` in Update (orders apply instantly even while paused; `repath` keeps tile being entered at front). FixedUpdate chain: `wander` (idle + timer → random goal within radius), `follow_path` (moves `SimPos`, speed / terrain cost, `step_towards`). Camera uses `Time<Real>` so it works while paused. Rendered as Mesh2d circles z=1. | |
| 22 | +- `src/sim.rs` `SimPlugin`: fixed timestep `Time<Fixed>` 60 Hz = sim ticks; all sim logic in `FixedUpdate` (Res<Time> there = fixed delta). `SimTick` counter (FixedFirst), `format_clock` (600 ticks/hour = 10 s real at 1x, starts Day 1 06:00). `SimSpeed` Normal/Fast/Fastest = 1x/3x/6x via `Time<Virtual>::set_relative_speed`; keys 1/2/3 set speed + unpause, Space toggles pause. Render interpolation: sim entities hold `SimPos` (+ `PrevSimPos`, copied in FixedFirst); `interpolate_transforms` lerps Transform by `overstep_fraction` in `RunFixedMainLoop`/AfterFixedMainLoop. Spawn with `sim_pos(v)` so prev==cur. Never write Transform of sim entities directly. | |
| 22 | 23 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 23 | 24 | |
| 24 | 25 | ## Gotchas |
src/colonist.rs +12 -29
| @@ -5,6 +5,7 @@ use bevy::prelude::*; | ||
| 5 | 5 | use crate::{ |
| 6 | 6 | pathfinding::find_path, |
| 7 | 7 | rng::SimRng, |
| 8 | + sim::{SimPos, sim_pos}, | |
| 8 | 9 | world::{TILE_SIZE, WorldMap}, |
| 9 | 10 | worldgen::{WorldGenerated, spawn_tiles}, |
| 10 | 11 | }; |
| @@ -16,13 +17,8 @@ impl Plugin for ColonistPlugin { | ||
| 16 | 17 | app.insert_resource(SimRng::new(0x5EED)) |
| 17 | 18 | .add_message::<MoveOrder>() |
| 18 | 19 | .add_systems(Update, spawn_colonists.run_if(on_message::<WorldGenerated>)) |
| 19 | - .add_systems( | |
| 20 | - Update, | |
| 21 | - ( | |
| 22 | - toggle_pause, | |
| 23 | - (handle_move_orders, wander, follow_path).chain(), | |
| 24 | - ), | |
| 25 | - ); | |
| 20 | + .add_systems(Update, handle_move_orders) | |
| 21 | + .add_systems(FixedUpdate, (wander, follow_path).chain()); | |
| 26 | 22 | } |
| 27 | 23 | } |
| 28 | 24 | |
| @@ -37,7 +33,7 @@ const STARTING_COLONISTS: [(&str, Color); 3] = [ | ||
| 37 | 33 | ]; |
| 38 | 34 | |
| 39 | 35 | #[derive(Component, Debug)] |
| 40 | -#[require(Transform, TilePos, Path, Wander)] | |
| 36 | +#[require(Transform, SimPos, TilePos, Path, Wander)] | |
| 41 | 37 | pub struct Colonist { |
| 42 | 38 | pub name: String, |
| 43 | 39 | } |
| @@ -85,21 +81,12 @@ fn spawn_colonists( | ||
| 85 | 81 | TilePos(tile), |
| 86 | 82 | Mesh2d(mesh.clone()), |
| 87 | 83 | MeshMaterial2d(materials.add(color)), |
| 84 | + sim_pos(WorldMap::tile_center(tile)), | |
| 88 | 85 | Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)), |
| 89 | 86 | )); |
| 90 | 87 | } |
| 91 | 88 | } |
| 92 | 89 | |
| 93 | -fn toggle_pause(keys: Res<ButtonInput<KeyCode>>, mut time: ResMut<Time<Virtual>>) { | |
| 94 | - if keys.just_pressed(KeyCode::Space) { | |
| 95 | - if time.is_paused() { | |
| 96 | - time.unpause(); | |
| 97 | - } else { | |
| 98 | - time.pause(); | |
| 99 | - } | |
| 100 | - } | |
| 101 | -} | |
| 102 | - | |
| 103 | 90 | fn handle_move_orders( |
| 104 | 91 | mut orders: MessageReader<MoveOrder>, |
| 105 | 92 | map: Res<WorldMap>, |
| @@ -161,17 +148,17 @@ fn wander( | ||
| 161 | 148 | fn follow_path( |
| 162 | 149 | time: Res<Time>, |
| 163 | 150 | map: Res<WorldMap>, |
| 164 | - mut colonists: Query<(&mut TilePos, &mut Path, &mut Transform)>, | |
| 151 | + mut colonists: Query<(&mut TilePos, &mut Path, &mut SimPos)>, | |
| 165 | 152 | ) { |
| 166 | - for (mut tile, mut path, mut transform) in &mut colonists { | |
| 153 | + for (mut tile, mut path, mut pos) in &mut colonists { | |
| 167 | 154 | let Some(&next) = path.0.front() else { |
| 168 | 155 | continue; |
| 169 | 156 | }; |
| 170 | 157 | let cost = map.move_cost(next.as_ivec2()).unwrap_or(100) as f32 / 100.0; |
| 171 | 158 | let max_distance = TILES_PER_SECOND * TILE_SIZE / cost * time.delta_secs(); |
| 172 | 159 | let target = WorldMap::tile_center(next); |
| 173 | - let (pos, arrived) = step_towards(transform.translation.truncate(), target, max_distance); | |
| 174 | - transform.translation = pos.extend(transform.translation.z); | |
| 160 | + let arrived; | |
| 161 | + (pos.0, arrived) = step_towards(pos.0, target, max_distance); | |
| 175 | 162 | if arrived { |
| 176 | 163 | tile.0 = next; |
| 177 | 164 | path.0.pop_front(); |
| @@ -236,7 +223,7 @@ mod tests { | ||
| 236 | 223 | }, |
| 237 | 224 | TilePos(UVec2::new(1, 1)), |
| 238 | 225 | Path(VecDeque::from([UVec2::new(2, 1), UVec2::new(3, 1)])), |
| 239 | - Transform::from_translation(WorldMap::tile_center(UVec2::new(1, 1)).extend(0.0)), | |
| 226 | + sim_pos(WorldMap::tile_center(UVec2::new(1, 1))), | |
| 240 | 227 | )) |
| 241 | 228 | .id(); |
| 242 | 229 | for _ in 0..100 { |
| @@ -252,12 +239,8 @@ mod tests { | ||
| 252 | 239 | ); |
| 253 | 240 | assert!(world.get::<Path>(colonist).unwrap().0.is_empty()); |
| 254 | 241 | assert_eq!( |
| 255 | - world | |
| 256 | - .get::<Transform>(colonist) | |
| 257 | - .unwrap() | |
| 258 | - .translation | |
| 259 | - .truncate(), | |
| 260 | - WorldMap::tile_center(UVec2::new(3, 1)) | |
| 242 | + world.get::<SimPos>(colonist), | |
| 243 | + Some(&SimPos(WorldMap::tile_center(UVec2::new(3, 1)))) | |
| 261 | 244 | ); |
| 262 | 245 | } |
| 263 | 246 | } |
src/main.rs +2 -0
| @@ -4,6 +4,7 @@ mod noise; | ||
| 4 | 4 | mod pathfinding; |
| 5 | 5 | mod rng; |
| 6 | 6 | mod selection; |
| 7 | +mod sim; | |
| 7 | 8 | mod tilemap; |
| 8 | 9 | mod world; |
| 9 | 10 | mod worldgen; |
| @@ -27,6 +28,7 @@ fn main() { | ||
| 27 | 28 | ) |
| 28 | 29 | .insert_resource(ClearColor(Color::srgb(0.08, 0.08, 0.1))) |
| 29 | 30 | .add_plugins(( |
| 31 | + sim::SimPlugin, | |
| 30 | 32 | worldgen::WorldGenPlugin, |
| 31 | 33 | tilemap::TilemapPlugin, |
| 32 | 34 | camera::CameraPlugin, |
src/selection.rs +20 -2
| @@ -3,6 +3,7 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow}; | ||
| 3 | 3 | use crate::{ |
| 4 | 4 | camera::CameraController, |
| 5 | 5 | colonist::{Colonist, MoveOrder, Path, TilePos}, |
| 6 | + sim::{SimSpeed, SimTick, format_clock}, | |
| 6 | 7 | world::{TILE_SIZE, WorldMap}, |
| 7 | 8 | worldgen::{WorldGenerated, WorldSeed}, |
| 8 | 9 | }; |
| @@ -194,6 +195,8 @@ fn update_tile_info( | ||
| 194 | 195 | selection: Res<Selection>, |
| 195 | 196 | colonists: Query<(&Colonist, &TilePos, &Path)>, |
| 196 | 197 | time: Res<Time<Virtual>>, |
| 198 | + tick: Res<SimTick>, | |
| 199 | + speed: Res<SimSpeed>, | |
| 197 | 200 | seed: Res<WorldSeed>, |
| 198 | 201 | mut text: Single<&mut Text, With<TileInfoText>>, |
| 199 | 202 | ) { |
| @@ -205,9 +208,10 @@ fn update_tile_info( | ||
| 205 | 208 | .map(|(colonist, tile, path)| describe_colonist(colonist, tile.0, path.0.len())) |
| 206 | 209 | .unwrap_or_else(|_| "-".into()), |
| 207 | 210 | }; |
| 208 | - let paused = if time.is_paused() { "PAUSED\n" } else { "" }; | |
| 211 | + let clock = format_clock(*tick); | |
| 212 | + let speed = describe_speed(*speed, time.is_paused()); | |
| 209 | 213 | let info = format!( |
| 210 | - "{paused}Seed: {} (R: new world)\nHover: {}\nSelected: {selected}", | |
| 214 | + "{clock} {speed}\nSeed: {} (R: new world)\nHover: {}\nSelected: {selected}", | |
| 211 | 215 | seed.0, |
| 212 | 216 | describe_tile(&map, cursor.tile) |
| 213 | 217 | ); |
| @@ -223,6 +227,14 @@ pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String { | ||
| 223 | 227 | } |
| 224 | 228 | } |
| 225 | 229 | |
| 230 | +pub fn describe_speed(speed: SimSpeed, paused: bool) -> String { | |
| 231 | + if paused { | |
| 232 | + "PAUSED".into() | |
| 233 | + } else { | |
| 234 | + format!("{}x", speed.multiplier()) | |
| 235 | + } | |
| 236 | +} | |
| 237 | + | |
| 226 | 238 | pub fn describe_colonist(colonist: &Colonist, tile: UVec2, steps_left: usize) -> String { |
| 227 | 239 | let activity = match steps_left { |
| 228 | 240 | 0 => "idle".to_string(), |
| @@ -368,6 +380,12 @@ mod tests { | ||
| 368 | 380 | } |
| 369 | 381 | |
| 370 | 382 | #[test] |
| 383 | + fn describe_speed_shows_multiplier_or_paused() { | |
| 384 | + assert_eq!(describe_speed(SimSpeed::Fastest, false), "6x"); | |
| 385 | + assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED"); | |
| 386 | + } | |
| 387 | + | |
| 388 | + #[test] | |
| 371 | 389 | fn describe_colonist_shows_activity() { |
| 372 | 390 | let ada = Colonist { name: "Ada".into() }; |
| 373 | 391 | assert_eq!( |
src/sim.rs +194 -0
| @@ -0,0 +1,194 @@ | ||
| 1 | +use bevy::{app::RunFixedMainLoopSystems, prelude::*}; | |
| 2 | + | |
| 3 | +pub const TICKS_PER_SECOND: f64 = 60.0; | |
| 4 | +pub const TICKS_PER_HOUR: u64 = 600; | |
| 5 | +pub const HOURS_PER_DAY: u64 = 24; | |
| 6 | +const START_HOUR: u64 = 6; | |
| 7 | + | |
| 8 | +pub struct SimPlugin; | |
| 9 | + | |
| 10 | +impl Plugin for SimPlugin { | |
| 11 | + fn build(&self, app: &mut App) { | |
| 12 | + app.insert_resource(Time::<Fixed>::from_hz(TICKS_PER_SECOND)) | |
| 13 | + .init_resource::<SimTick>() | |
| 14 | + .init_resource::<SimSpeed>() | |
| 15 | + .add_systems(Update, speed_controls) | |
| 16 | + .add_systems(FixedFirst, (advance_tick, remember_positions)) | |
| 17 | + .add_systems( | |
| 18 | + RunFixedMainLoop, | |
| 19 | + interpolate_transforms.in_set(RunFixedMainLoopSystems::AfterFixedMainLoop), | |
| 20 | + ); | |
| 21 | + } | |
| 22 | +} | |
| 23 | + | |
| 24 | +#[derive(Component, Default, Debug, Clone, Copy, PartialEq)] | |
| 25 | +#[require(PrevSimPos)] | |
| 26 | +pub struct SimPos(pub Vec2); | |
| 27 | + | |
| 28 | +#[derive(Component, Default, Debug, Clone, Copy, PartialEq)] | |
| 29 | +pub struct PrevSimPos(pub Vec2); | |
| 30 | + | |
| 31 | +pub fn sim_pos(pos: Vec2) -> (SimPos, PrevSimPos) { | |
| 32 | + (SimPos(pos), PrevSimPos(pos)) | |
| 33 | +} | |
| 34 | + | |
| 35 | +fn remember_positions(mut query: Query<(&SimPos, &mut PrevSimPos)>) { | |
| 36 | + for (pos, mut prev) in &mut query { | |
| 37 | + prev.0 = pos.0; | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +fn interpolate_transforms( | |
| 42 | + time: Res<Time<Fixed>>, | |
| 43 | + mut query: Query<(&SimPos, &PrevSimPos, &mut Transform)>, | |
| 44 | +) { | |
| 45 | + let alpha = time.overstep_fraction(); | |
| 46 | + for (pos, prev, mut transform) in &mut query { | |
| 47 | + transform.translation = prev.0.lerp(pos.0, alpha).extend(transform.translation.z); | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)] | |
| 52 | +pub struct SimTick(pub u64); | |
| 53 | + | |
| 54 | +#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)] | |
| 55 | +pub enum SimSpeed { | |
| 56 | + #[default] | |
| 57 | + Normal, | |
| 58 | + Fast, | |
| 59 | + Fastest, | |
| 60 | +} | |
| 61 | + | |
| 62 | +impl SimSpeed { | |
| 63 | + pub fn multiplier(self) -> f32 { | |
| 64 | + match self { | |
| 65 | + SimSpeed::Normal => 1.0, | |
| 66 | + SimSpeed::Fast => 3.0, | |
| 67 | + SimSpeed::Fastest => 6.0, | |
| 68 | + } | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +fn advance_tick(mut tick: ResMut<SimTick>) { | |
| 73 | + tick.0 += 1; | |
| 74 | +} | |
| 75 | + | |
| 76 | +fn speed_controls( | |
| 77 | + keys: Res<ButtonInput<KeyCode>>, | |
| 78 | + mut speed: ResMut<SimSpeed>, | |
| 79 | + mut time: ResMut<Time<Virtual>>, | |
| 80 | +) { | |
| 81 | + if keys.just_pressed(KeyCode::Space) { | |
| 82 | + if time.is_paused() { | |
| 83 | + time.unpause(); | |
| 84 | + } else { | |
| 85 | + time.pause(); | |
| 86 | + } | |
| 87 | + } | |
| 88 | + for (key, new_speed) in [ | |
| 89 | + (KeyCode::Digit1, SimSpeed::Normal), | |
| 90 | + (KeyCode::Digit2, SimSpeed::Fast), | |
| 91 | + (KeyCode::Digit3, SimSpeed::Fastest), | |
| 92 | + ] { | |
| 93 | + if keys.just_pressed(key) { | |
| 94 | + *speed = new_speed; | |
| 95 | + time.set_relative_speed(new_speed.multiplier()); | |
| 96 | + time.unpause(); | |
| 97 | + } | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +pub fn format_clock(tick: SimTick) -> String { | |
| 102 | + let hours = tick.0 / TICKS_PER_HOUR + START_HOUR; | |
| 103 | + let day = hours / HOURS_PER_DAY + 1; | |
| 104 | + let hour = hours % HOURS_PER_DAY; | |
| 105 | + let minute = tick.0 % TICKS_PER_HOUR * 60 / TICKS_PER_HOUR; | |
| 106 | + format!("Day {day}, {hour:02}:{minute:02}") | |
| 107 | +} | |
| 108 | + | |
| 109 | +#[cfg(test)] | |
| 110 | +mod tests { | |
| 111 | + use super::*; | |
| 112 | + | |
| 113 | + #[test] | |
| 114 | + fn clock_starts_in_the_morning_of_day_one() { | |
| 115 | + assert_eq!(format_clock(SimTick(0)), "Day 1, 06:00"); | |
| 116 | + } | |
| 117 | + | |
| 118 | + #[test] | |
| 119 | + fn clock_advances_minutes_hours_and_days() { | |
| 120 | + assert_eq!(format_clock(SimTick(TICKS_PER_HOUR / 2)), "Day 1, 06:30"); | |
| 121 | + assert_eq!(format_clock(SimTick(TICKS_PER_HOUR * 18)), "Day 2, 00:00"); | |
| 122 | + assert_eq!( | |
| 123 | + format_clock(SimTick(TICKS_PER_HOUR * (18 + 24 + 5))), | |
| 124 | + "Day 3, 05:00" | |
| 125 | + ); | |
| 126 | + } | |
| 127 | + | |
| 128 | + // The simulation only moves things once per tick, but frames render more often (and at 6x | |
| 129 | + // speed several ticks run per frame); rendering interpolates between the last two tick | |
| 130 | + // positions so movement looks smooth regardless of frame rate. | |
| 131 | + #[test] | |
| 132 | + fn transforms_are_interpolated_between_ticks() { | |
| 133 | + let mut app = App::new(); | |
| 134 | + app.insert_resource(Time::<Fixed>::from_hz(TICKS_PER_SECOND)) | |
| 135 | + .add_systems(Update, interpolate_transforms); | |
| 136 | + let entity = app | |
| 137 | + .world_mut() | |
| 138 | + .spawn(( | |
| 139 | + SimPos(Vec2::new(10.0, 0.0)), | |
| 140 | + PrevSimPos(Vec2::ZERO), | |
| 141 | + Transform::from_xyz(0.0, 0.0, 1.0), | |
| 142 | + )) | |
| 143 | + .id(); | |
| 144 | + let half_tick = std::time::Duration::from_secs_f64(0.5 / TICKS_PER_SECOND); | |
| 145 | + app.world_mut() | |
| 146 | + .resource_mut::<Time<Fixed>>() | |
| 147 | + .accumulate_overstep(half_tick); | |
| 148 | + app.update(); | |
| 149 | + let translation = app.world().get::<Transform>(entity).unwrap().translation; | |
| 150 | + assert!(translation.abs_diff_eq(Vec3::new(5.0, 0.0, 1.0), 1e-3)); | |
| 151 | + } | |
| 152 | + | |
| 153 | + fn controls_app() -> App { | |
| 154 | + let mut app = App::new(); | |
| 155 | + app.init_resource::<ButtonInput<KeyCode>>() | |
| 156 | + .init_resource::<Time<Virtual>>() | |
| 157 | + .init_resource::<SimSpeed>() | |
| 158 | + .add_systems(Update, speed_controls); | |
| 159 | + app | |
| 160 | + } | |
| 161 | + | |
| 162 | + fn tap(app: &mut App, key: KeyCode) { | |
| 163 | + app.world_mut() | |
| 164 | + .resource_mut::<ButtonInput<KeyCode>>() | |
| 165 | + .press(key); | |
| 166 | + app.update(); | |
| 167 | + app.world_mut() | |
| 168 | + .resource_mut::<ButtonInput<KeyCode>>() | |
| 169 | + .clear(); | |
| 170 | + app.world_mut() | |
| 171 | + .resource_mut::<ButtonInput<KeyCode>>() | |
| 172 | + .release(key); | |
| 173 | + } | |
| 174 | + | |
| 175 | + #[test] | |
| 176 | + fn number_keys_set_speed_and_scale_virtual_time() { | |
| 177 | + let mut app = controls_app(); | |
| 178 | + tap(&mut app, KeyCode::Digit3); | |
| 179 | + assert_eq!(*app.world().resource::<SimSpeed>(), SimSpeed::Fastest); | |
| 180 | + assert_eq!( | |
| 181 | + app.world().resource::<Time<Virtual>>().relative_speed(), | |
| 182 | + 6.0 | |
| 183 | + ); | |
| 184 | + } | |
| 185 | + | |
| 186 | + #[test] | |
| 187 | + fn choosing_a_speed_while_paused_resumes() { | |
| 188 | + let mut app = controls_app(); | |
| 189 | + tap(&mut app, KeyCode::Space); | |
| 190 | + assert!(app.world().resource::<Time<Virtual>>().is_paused()); | |
| 191 | + tap(&mut app, KeyCode::Digit2); | |
| 192 | + assert!(!app.world().resource::<Time<Virtual>>().is_paused()); | |
| 193 | + } | |
| 194 | +} | |
tasks.md +2 -2
| @@ -8,12 +8,12 @@ | ||
| 8 | 8 | - [x] Tile hover + selection (outlines, info text, Esc to clear) |
| 9 | 9 | - [x] A* pathfinding with terrain costs |
| 10 | 10 | - [x] Colonists: wander, select, right-click move orders, path display |
| 11 | -- [x] Pause toggle (Space) | |
| 11 | +- [x] Simulation tick (60 Hz fixed timestep), game clock, speeds 1x/3x/6x (keys 1-3), pause (Space), render interpolation | |
| 12 | 12 | - [x] Procedural world generation (noise elevation/fertility, seeds, R to regenerate, spawn in largest walkable region) |
| 13 | 13 | |
| 14 | 14 | ## Planned |
| 15 | 15 | - [ ] Worldgen extras: rivers, ore/resources, trees/plants |
| 16 | -- [ ] Simulation tick / time controls (speeds, fixed timestep) | |
| 16 | +- [ ] Speed/pause buttons in UI | |
| 17 | 17 | - [ ] Ignore map clicks over UI |
| 18 | 18 | - [ ] Jobs: mining, building, hauling |
| 19 | 19 | - [ ] Basic UI (tile info, colonist panel) |