Add fixed-tick simulation with speed controls and game clock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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)