Add wandering colonists with move orders and pause

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 08:14 UTC
commit
6d1da60b36286d9d440a63ee7858bb05fef70132
parent
efb6b0abff
tree
browse at this commit

7 files changed +535 -53

CLAUDE.md +6 -3
src/camera.rs +1 -1
src/colonist.rs added +243 -0
src/main.rs +3 -0
src/rng.rs added +53 -0
src/selection.rs +224 -47
tasks.md +5 -2

CLAUDE.md +6 -3

@@ -13,17 +13,20 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
13 13 - `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters), `WorldMap` resource (row-major, (0,0)=bottom-left, y up), `TILE_SIZE`=16 world units, `hash2` deterministic int hash, `static_world()` handmade 192x128 map (placeholder until procgen).
14 14 - `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`.
15 15 - `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.
16 -- `src/selection.rs` `HoveredTile`/`SelectedTile` resources (Option<UVec2>). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform): cursor→world→`map.tile_at`; LMB selects hovered (off-map clears), Esc clears; gizmo `rect_2d` outlines (white hover, yellow selected); top-left UI text via `describe_tile`. Selection logic tested with headless `App` + `ButtonInput` resources.
16 +- `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`.
17 +- `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.
18 +- `src/colonist.rs` `Colonist{name}` 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.
19 +- `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
17 20
18 21 ## Gotchas
19 22 - WebGL2 (wgpu GLES) guesses array texture view dim from layer count: 1→D2, 6→Cube, 6k→CubeArray. Tileset layer count padded via `tileset_layer_count`. Symptom: black screen + "wgpu-hal heuristics assumed ... Cube" errors.
20 23 - `UVec2::div_ceil` doesn't exist in this glam.
21 -- No `rand` dep yet; use `hash2` for deterministic noise.
24 +- No `rand` dep; use `SimRng` (stateful) or `hash2` (positional noise).
22 25
23 26 ## Build / run
24 27 - Native: `cargo run`. Tests: `cargo test`.
25 28 - Web: `./scripts/build-web.sh [profile]` (default `wasm-release`, ~3.5 min cold) → `target/web/`. Needs wasm-bindgen-cli matching Cargo.lock `wasm-bindgen` version (currently 0.2.129, installed in ~/.cargo/bin).
26 -- Browser verification: `.claude/launch.json` config `web` (python http.server :8080 serving target/web) → `preview_start name=web`, then screenshot/scroll in built-in browser. Synthetic JS pointer events are ignored by winit; real drags only via computer tool left_click_drag (left button) — temporarily bind Left to test drag code.
29 +- Browser verification: `.claude/launch.json` config `web` (python http.server :8080 serving target/web) → `preview_start name=web`, then screenshot/scroll in built-in browser. Synthetic JS pointer events are ignored by winit; real drags only via computer tool left_click_drag (left button) — temporarily bind Left to test drag code. computer tool `right_click` works for RMB clicks. Pause (Space) before clicking moving colonists.
27 30 - Bevy logs in browser console; check for ERROR after load. Console log persists across reloads in the pane — count "Creating new window" lines to tell which load an error belongs to.
28 31
29 32 ## Docs

src/camera.rs +1 -1

@@ -57,7 +57,7 @@ fn ortho(projection: &mut Projection) -> &mut OrthographicProjection {
57 57
58 58 fn keyboard_pan(
59 59 keys: Res<ButtonInput<KeyCode>>,
60 - time: Res<Time>,
60 + time: Res<Time<Real>>,
61 61 camera: Single<(&mut Transform, &mut Projection, &CameraController)>,
62 62 ) {
63 63 let (mut transform, mut projection, controller) = camera.into_inner();

src/colonist.rs +243 -0

@@ -0,0 +1,243 @@
1 +use std::{collections::VecDeque, time::Duration};
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + pathfinding::find_path,
7 + rng::SimRng,
8 + world::{TILE_SIZE, WorldMap},
9 +};
10 +
11 +pub struct ColonistPlugin;
12 +
13 +impl Plugin for ColonistPlugin {
14 + fn build(&self, app: &mut App) {
15 + app.insert_resource(SimRng::new(0x5EED))
16 + .add_message::<MoveOrder>()
17 + .add_systems(Startup, spawn_colonists)
18 + .add_systems(Update, (toggle_pause, (handle_move_orders, wander, follow_path).chain()));
19 + }
20 +}
21 +
22 +const TILES_PER_SECOND: f32 = 4.0;
23 +const WANDER_RADIUS: i32 = 8;
24 +const WANDER_PAUSE_SECS: std::ops::Range<f32> = 1.0..4.0;
25 +
26 +pub const SPAWNS: [(&str, UVec2, Color); 3] = [
27 + ("Ada", UVec2::new(96, 64), Color::srgb(0.9, 0.3, 0.3)),
28 + ("Bram", UVec2::new(99, 62), Color::srgb(0.3, 0.5, 0.95)),
29 + ("Cleo", UVec2::new(94, 60), Color::srgb(0.95, 0.75, 0.3)),
30 +];
31 +
32 +#[derive(Component, Debug)]
33 +#[require(Transform, TilePos, Path, Wander)]
34 +pub struct Colonist {
35 + pub name: String,
36 +}
37 +
38 +#[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
39 +pub struct TilePos(pub UVec2);
40 +
41 +#[derive(Component, Default, Debug)]
42 +pub struct Path(pub VecDeque<UVec2>);
43 +
44 +#[derive(Component, Debug)]
45 +pub struct Wander(Timer);
46 +
47 +impl Default for Wander {
48 + fn default() -> Self {
49 + Self(Timer::from_seconds(WANDER_PAUSE_SECS.start, TimerMode::Once))
50 + }
51 +}
52 +
53 +#[derive(Message, Debug, Clone, Copy)]
54 +pub struct MoveOrder {
55 + pub colonist: Entity,
56 + pub goal: UVec2,
57 +}
58 +
59 +fn spawn_colonists(
60 + mut commands: Commands,
61 + mut meshes: ResMut<Assets<Mesh>>,
62 + mut materials: ResMut<Assets<ColorMaterial>>,
63 +) {
64 + let mesh = meshes.add(Circle::new(TILE_SIZE * 0.35));
65 + for (name, tile, color) in SPAWNS {
66 + commands.spawn((
67 + Name::new(name),
68 + Colonist { name: name.into() },
69 + TilePos(tile),
70 + Mesh2d(mesh.clone()),
71 + MeshMaterial2d(materials.add(color)),
72 + Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
73 + ));
74 + }
75 +}
76 +
77 +fn toggle_pause(keys: Res<ButtonInput<KeyCode>>, mut time: ResMut<Time<Virtual>>) {
78 + if keys.just_pressed(KeyCode::Space) {
79 + if time.is_paused() {
80 + time.unpause();
81 + } else {
82 + time.pause();
83 + }
84 + }
85 +}
86 +
87 +fn handle_move_orders(
88 + mut orders: MessageReader<MoveOrder>,
89 + map: Res<WorldMap>,
90 + mut colonists: Query<(&TilePos, &mut Path)>,
91 +) {
92 + for order in orders.read() {
93 + let Ok((tile, mut path)) = colonists.get_mut(order.colonist) else {
94 + continue;
95 + };
96 + if let Some(new_path) = repath(&map, tile.0, &path.0, order.goal) {
97 + path.0 = new_path;
98 + }
99 + }
100 +}
101 +
102 +pub fn repath(
103 + map: &WorldMap,
104 + tile: UVec2,
105 + current: &VecDeque<UVec2>,
106 + goal: UVec2,
107 +) -> Option<VecDeque<UVec2>> {
108 + match current.front() {
109 + Some(&next) => {
110 + let mut path: VecDeque<_> = find_path(map, next, goal)?.into();
111 + path.push_front(next);
112 + Some(path)
113 + }
114 + None => find_path(map, tile, goal).map(VecDeque::from),
115 + }
116 +}
117 +
118 +fn wander(
119 + time: Res<Time>,
120 + map: Res<WorldMap>,
121 + mut rng: ResMut<SimRng>,
122 + mut colonists: Query<(&TilePos, &mut Path, &mut Wander)>,
123 +) {
124 + for (tile, mut path, mut wander) in &mut colonists {
125 + if !path.0.is_empty() || !wander.0.tick(time.delta()).is_finished() {
126 + continue;
127 + }
128 + let offset = IVec2::new(
129 + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
130 + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
131 + );
132 + let goal = tile.0.as_ivec2() + offset;
133 + if !map.in_bounds(goal) {
134 + continue;
135 + }
136 + if let Some(new_path) = find_path(&map, tile.0, goal.as_uvec2()) {
137 + path.0 = new_path.into();
138 + let pause = rng.range_f32(WANDER_PAUSE_SECS);
139 + wander.0.set_duration(Duration::from_secs_f32(pause));
140 + wander.0.reset();
141 + }
142 + }
143 +}
144 +
145 +fn follow_path(
146 + time: Res<Time>,
147 + map: Res<WorldMap>,
148 + mut colonists: Query<(&mut TilePos, &mut Path, &mut Transform)>,
149 +) {
150 + for (mut tile, mut path, mut transform) in &mut colonists {
151 + let Some(&next) = path.0.front() else {
152 + continue;
153 + };
154 + let cost = map.move_cost(next.as_ivec2()).unwrap_or(100) as f32 / 100.0;
155 + let max_distance = TILES_PER_SECOND * TILE_SIZE / cost * time.delta_secs();
156 + let target = WorldMap::tile_center(next);
157 + let (pos, arrived) = step_towards(transform.translation.truncate(), target, max_distance);
158 + transform.translation = pos.extend(transform.translation.z);
159 + if arrived {
160 + tile.0 = next;
161 + path.0.pop_front();
162 + }
163 + }
164 +}
165 +
166 +pub fn step_towards(pos: Vec2, target: Vec2, max_distance: f32) -> (Vec2, bool) {
167 + let delta = target - pos;
168 + if delta.length() <= max_distance {
169 + (target, true)
170 + } else {
171 + (pos + delta.normalize() * max_distance, false)
172 + }
173 +}
174 +
175 +#[cfg(test)]
176 +mod tests {
177 + use super::*;
178 + use crate::world::{Terrain, static_world};
179 +
180 + #[test]
181 + fn spawn_tiles_are_walkable() {
182 + let map = static_world();
183 + for (name, tile, _) in SPAWNS {
184 + assert!(map.move_cost(tile.as_ivec2()).is_some(), "{name}");
185 + }
186 + }
187 +
188 + #[test]
189 + fn step_towards_stops_exactly_on_target() {
190 + assert_eq!(step_towards(Vec2::ZERO, Vec2::new(3.0, 4.0), 10.0), (Vec2::new(3.0, 4.0), true));
191 + let (pos, arrived) = step_towards(Vec2::ZERO, Vec2::new(3.0, 4.0), 2.5);
192 + assert!(!arrived);
193 + assert!(pos.abs_diff_eq(Vec2::new(1.5, 2.0), 1e-5));
194 + }
195 +
196 + // A colonist halfway between two tiles keeps heading to the tile it is entering; repathing
197 + // from its last reached tile would make it turn around mid-step or walk diagonally past walls.
198 + #[test]
199 + fn repath_while_moving_keeps_the_tile_being_entered() {
200 + let map = WorldMap::filled(UVec2::new(10, 10), Terrain::Grass);
201 + let current = VecDeque::from([UVec2::new(3, 2), UVec2::new(4, 2)]);
202 + let path = repath(&map, UVec2::new(2, 2), &current, UVec2::new(3, 5)).unwrap();
203 + assert_eq!(path.front(), Some(&UVec2::new(3, 2)));
204 + assert_eq!(path.back(), Some(&UVec2::new(3, 5)));
205 + }
206 +
207 + #[test]
208 + fn repath_when_idle_starts_from_current_tile() {
209 + let map = WorldMap::filled(UVec2::new(10, 10), Terrain::Grass);
210 + let path = repath(&map, UVec2::new(2, 2), &VecDeque::new(), UVec2::new(4, 2)).unwrap();
211 + assert_eq!(path, VecDeque::from([UVec2::new(3, 2), UVec2::new(4, 2)]));
212 + }
213 +
214 + #[test]
215 + fn colonists_walk_their_path_and_update_tile_pos() {
216 + let mut app = App::new();
217 + app.insert_resource(WorldMap::filled(UVec2::new(10, 10), Terrain::Grass))
218 + .init_resource::<Time>()
219 + .add_systems(Update, follow_path);
220 + let colonist = app
221 + .world_mut()
222 + .spawn((
223 + Colonist { name: "Test".into() },
224 + TilePos(UVec2::new(1, 1)),
225 + Path(VecDeque::from([UVec2::new(2, 1), UVec2::new(3, 1)])),
226 + Transform::from_translation(WorldMap::tile_center(UVec2::new(1, 1)).extend(0.0)),
227 + ))
228 + .id();
229 + for _ in 0..100 {
230 + app.world_mut()
231 + .resource_mut::<Time>()
232 + .advance_by(Duration::from_millis(50));
233 + app.update();
234 + }
235 + let world = app.world();
236 + assert_eq!(world.get::<TilePos>(colonist), Some(&TilePos(UVec2::new(3, 1))));
237 + assert!(world.get::<Path>(colonist).unwrap().0.is_empty());
238 + assert_eq!(
239 + world.get::<Transform>(colonist).unwrap().translation.truncate(),
240 + WorldMap::tile_center(UVec2::new(3, 1))
241 + );
242 + }
243 +}

src/main.rs +3 -0

@@ -1,5 +1,7 @@
1 1 mod camera;
2 +mod colonist;
2 3 mod pathfinding;
4 +mod rng;
3 5 mod selection;
4 6 mod tilemap;
5 7 mod world;
@@ -26,6 +28,7 @@ fn main() {
26 28 .add_plugins((
27 29 tilemap::TilemapPlugin,
28 30 camera::CameraPlugin,
31 + colonist::ColonistPlugin,
29 32 selection::SelectionPlugin,
30 33 ))
31 34 .run();

src/rng.rs +53 -0

@@ -0,0 +1,53 @@
1 +use std::ops::Range;
2 +
3 +use bevy::prelude::*;
4 +
5 +#[derive(Resource, Clone, Debug)]
6 +pub struct SimRng(u64);
7 +
8 +impl SimRng {
9 + pub fn new(seed: u64) -> Self {
10 + Self(seed)
11 + }
12 +
13 + pub fn next_u64(&mut self) -> u64 {
14 + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
15 + let mut z = self.0;
16 + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
17 + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
18 + z ^ (z >> 31)
19 + }
20 +
21 + pub fn range_i32(&mut self, range: Range<i32>) -> i32 {
22 + let span = (range.end - range.start) as u64;
23 + range.start + (self.next_u64() % span) as i32
24 + }
25 +
26 + pub fn range_f32(&mut self, range: Range<f32>) -> f32 {
27 + let unit = (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32;
28 + range.start + unit * (range.end - range.start)
29 + }
30 +}
31 +
32 +#[cfg(test)]
33 +mod tests {
34 + use super::*;
35 +
36 + #[test]
37 + fn same_seed_gives_same_sequence_so_simulations_are_reproducible() {
38 + let mut a = SimRng::new(7);
39 + let mut b = SimRng::new(7);
40 + for _ in 0..100 {
41 + assert_eq!(a.next_u64(), b.next_u64());
42 + }
43 + }
44 +
45 + #[test]
46 + fn ranges_stay_within_bounds() {
47 + let mut rng = SimRng::new(1);
48 + for _ in 0..1000 {
49 + assert!((-3..4).contains(&rng.range_i32(-3..4)));
50 + assert!((1.0..2.5).contains(&rng.range_f32(1.0..2.5)));
51 + }
52 + }
53 +}

src/selection.rs +224 -47

@@ -2,6 +2,7 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2 2
3 3 use crate::{
4 4 camera::CameraController,
5 + colonist::{Colonist, MoveOrder, Path, TilePos},
5 6 world::{TILE_SIZE, WorldMap},
6 7 };
7 8
@@ -9,12 +10,18 @@ pub struct SelectionPlugin;
9 10
10 11 impl Plugin for SelectionPlugin {
11 12 fn build(&self, app: &mut App) {
12 - app.init_resource::<HoveredTile>()
13 - .init_resource::<SelectedTile>()
13 + app.init_resource::<Cursor>()
14 + .init_resource::<Selection>()
14 15 .add_systems(Startup, spawn_tile_info)
15 16 .add_systems(
16 17 PostUpdate,
17 - (update_hovered, update_selected, draw_highlights, update_tile_info)
18 + (
19 + update_cursor,
20 + update_selection,
21 + issue_move_orders,
22 + draw_highlights,
23 + update_tile_info,
24 + )
18 25 .chain()
19 26 .after(TransformSystems::Propagate),
20 27 );
@@ -22,48 +29,121 @@ impl Plugin for SelectionPlugin {
22 29 }
23 30
24 31 #[derive(Resource, Default, Debug, PartialEq)]
25 -pub struct HoveredTile(pub Option<UVec2>);
32 +pub struct Cursor {
33 + pub screen: Option<Vec2>,
34 + pub world: Option<Vec2>,
35 + pub tile: Option<UVec2>,
36 +}
26 37
27 -#[derive(Resource, Default, Debug, PartialEq)]
28 -pub struct SelectedTile(pub Option<UVec2>);
38 +#[derive(Resource, Default, Debug, PartialEq, Clone, Copy)]
39 +pub enum Selection {
40 + #[default]
41 + None,
42 + Tile(UVec2),
43 + Colonist(Entity),
44 +}
29 45
30 46 #[derive(Component)]
31 47 struct TileInfoText;
32 48
33 49 const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6);
34 50 const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
51 +const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
52 +const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
53 +const CLICK_SLOP_PX: f32 = 4.0;
35 54
36 -fn update_hovered(
55 +fn update_cursor(
37 56 map: Res<WorldMap>,
38 57 window: Single<&Window, With<PrimaryWindow>>,
39 58 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
40 - mut hovered: ResMut<HoveredTile>,
59 + mut cursor: ResMut<Cursor>,
41 60 ) {
42 61 let (camera, global) = *camera;
43 - let tile = window
44 - .cursor_position()
45 - .and_then(|cursor| camera.viewport_to_world_2d(global, cursor).ok())
46 - .and_then(|world| map.tile_at(world));
47 - hovered.set_if_neq(HoveredTile(tile));
62 + let screen = window.cursor_position();
63 + let world = screen.and_then(|s| camera.viewport_to_world_2d(global, s).ok());
64 + let tile = world.and_then(|w| map.tile_at(w));
65 + cursor.set_if_neq(Cursor { screen, world, tile });
48 66 }
49 67
50 -fn update_selected(
68 +fn update_selection(
51 69 buttons: Res<ButtonInput<MouseButton>>,
52 70 keys: Res<ButtonInput<KeyCode>>,
53 - hovered: Res<HoveredTile>,
54 - mut selected: ResMut<SelectedTile>,
71 + cursor: Res<Cursor>,
72 + colonists: Query<(Entity, &Transform), With<Colonist>>,
73 + mut selection: ResMut<Selection>,
55 74 ) {
56 75 if keys.just_pressed(KeyCode::Escape) {
57 - selected.set_if_neq(SelectedTile(None));
76 + selection.set_if_neq(Selection::None);
58 77 } else if buttons.just_pressed(MouseButton::Left) {
59 - selected.set_if_neq(SelectedTile(hovered.0));
78 + let picked = cursor.world.and_then(|world| {
79 + pick_nearest(world, colonists.iter().map(|(e, t)| (e, t.translation.truncate())))
80 + });
81 + let new = match (picked, cursor.tile) {
82 + (Some(entity), _) => Selection::Colonist(entity),
83 + (None, Some(tile)) => Selection::Tile(tile),
84 + (None, None) => Selection::None,
85 + };
86 + selection.set_if_neq(new);
87 + }
88 +}
89 +
90 +pub fn pick_nearest(point: Vec2, candidates: impl Iterator<Item = (Entity, Vec2)>) -> Option<Entity> {
91 + candidates
92 + .map(|(e, pos)| (e, pos.distance(point)))
93 + .filter(|&(_, d)| d <= PICK_RADIUS)
94 + .min_by(|a, b| a.1.total_cmp(&b.1))
95 + .map(|(e, _)| e)
96 +}
97 +
98 +fn issue_move_orders(
99 + buttons: Res<ButtonInput<MouseButton>>,
100 + cursor: Res<Cursor>,
101 + selection: Res<Selection>,
102 + mut orders: MessageWriter<MoveOrder>,
103 + mut pressed_at: Local<Option<Vec2>>,
104 +) {
105 + if buttons.just_pressed(MouseButton::Right) {
106 + *pressed_at = cursor.screen;
107 + }
108 + if !buttons.just_released(MouseButton::Right) {
109 + return;
110 + }
111 + let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else {
112 + return;
113 + };
114 + if let (true, Selection::Colonist(colonist), Some(goal)) =
115 + (is_click(press, release), *selection, cursor.tile)
116 + {
117 + orders.write(MoveOrder { colonist, goal });
60 118 }
61 119 }
62 120
63 -fn draw_highlights(mut gizmos: Gizmos, hovered: Res<HoveredTile>, selected: Res<SelectedTile>) {
64 - for (tile, color) in [(hovered.0, HOVER_COLOR), (selected.0, SELECT_COLOR)] {
65 - if let Some(tile) = tile {
66 - gizmos.rect_2d(WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), color);
121 +pub fn is_click(press: Vec2, release: Vec2) -> bool {
122 + press.distance(release) <= CLICK_SLOP_PX
123 +}
124 +
125 +fn draw_highlights(
126 + mut gizmos: Gizmos,
127 + cursor: Res<Cursor>,
128 + selection: Res<Selection>,
129 + colonists: Query<(&Transform, &Path), With<Colonist>>,
130 +) {
131 + if let Some(tile) = cursor.tile {
132 + gizmos.rect_2d(WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), HOVER_COLOR);
133 + }
134 + match *selection {
135 + Selection::None => {}
136 + Selection::Tile(tile) => {
137 + gizmos.rect_2d(WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), SELECT_COLOR);
138 + }
139 + Selection::Colonist(entity) => {
140 + let Ok((transform, path)) = colonists.get(entity) else {
141 + return;
142 + };
143 + let pos = transform.translation.truncate();
144 + gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR);
145 + let points = path.0.iter().map(|&t| WorldMap::tile_center(t));
146 + gizmos.linestrip_2d(std::iter::once(pos).chain(points), PATH_COLOR);
67 147 }
68 148 }
69 149 }
@@ -86,18 +166,28 @@ fn spawn_tile_info(mut commands: Commands) {
86 166
87 167 fn update_tile_info(
88 168 map: Res<WorldMap>,
89 - hovered: Res<HoveredTile>,
90 - selected: Res<SelectedTile>,
169 + cursor: Res<Cursor>,
170 + selection: Res<Selection>,
171 + colonists: Query<(&Colonist, &TilePos, &Path)>,
172 + time: Res<Time<Virtual>>,
91 173 mut text: Single<&mut Text, With<TileInfoText>>,
92 174 ) {
93 - if !hovered.is_changed() && !selected.is_changed() {
94 - return;
95 - }
96 - text.0 = format!(
97 - "Hover: {}\nSelected: {}",
98 - describe_tile(&map, hovered.0),
99 - describe_tile(&map, selected.0)
175 + let selected = match *selection {
176 + Selection::None => "-".to_string(),
177 + Selection::Tile(tile) => describe_tile(&map, Some(tile)),
178 + Selection::Colonist(entity) => colonists
179 + .get(entity)
180 + .map(|(colonist, tile, path)| describe_colonist(colonist, tile.0, path.0.len()))
181 + .unwrap_or_else(|_| "-".into()),
182 + };
183 + let paused = if time.is_paused() { "PAUSED\n" } else { "" };
184 + let info = format!(
185 + "{paused}Hover: {}\nSelected: {selected}",
186 + describe_tile(&map, cursor.tile)
100 187 );
188 + if text.0 != info {
189 + text.0 = info;
190 + }
101 191 }
102 192
103 193 pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
@@ -107,50 +197,127 @@ pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
107 197 }
108 198 }
109 199
200 +pub fn describe_colonist(colonist: &Colonist, tile: UVec2, steps_left: usize) -> String {
201 + let activity = match steps_left {
202 + 0 => "idle".to_string(),
203 + n => format!("moving, {n} steps left"),
204 + };
205 + format!("{} at ({}, {}), {activity}", colonist.name, tile.x, tile.y)
206 +}
207 +
110 208 #[cfg(test)]
111 209 mod tests {
112 210 use super::*;
113 211 use crate::world::Terrain;
114 212
115 - fn selection_app(hovered: Option<UVec2>, selected: Option<UVec2>) -> App {
213 + fn selection_app(cursor: Cursor, selection: Selection) -> App {
116 214 let mut app = App::new();
117 215 app.init_resource::<ButtonInput<MouseButton>>()
118 216 .init_resource::<ButtonInput<KeyCode>>()
119 - .insert_resource(HoveredTile(hovered))
120 - .insert_resource(SelectedTile(selected))
121 - .add_systems(Update, update_selected);
217 + .insert_resource(cursor)
218 + .insert_resource(selection)
219 + .add_message::<MoveOrder>()
220 + .add_systems(Update, (update_selection, issue_move_orders));
122 221 app
123 222 }
124 223
125 - fn click(app: &mut App) {
126 - app.world_mut()
127 - .resource_mut::<ButtonInput<MouseButton>>()
128 - .press(MouseButton::Left);
224 + fn cursor_on_tile(tile: UVec2) -> Cursor {
225 + Cursor {
226 + screen: Some(Vec2::new(50.0, 50.0)),
227 + world: Some(WorldMap::tile_center(tile)),
228 + tile: Some(tile),
229 + }
230 + }
231 +
232 + fn press(app: &mut App, button: MouseButton) {
233 + app.world_mut().resource_mut::<ButtonInput<MouseButton>>().press(button);
129 234 app.update();
130 235 }
131 236
237 + fn release(app: &mut App, button: MouseButton) {
238 + let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
239 + input.clear();
240 + input.release(button);
241 + app.update();
242 + }
243 +
244 + fn selection(app: &App) -> Selection {
245 + *app.world().resource::<Selection>()
246 + }
247 +
248 + fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> {
249 + app.world_mut()
250 + .resource_mut::<Messages<MoveOrder>>()
251 + .drain()
252 + .map(|o| (o.colonist, o.goal))
253 + .collect()
254 + }
255 +
132 256 #[test]
133 257 fn clicking_selects_the_hovered_tile() {
134 - let mut app = selection_app(Some(UVec2::new(3, 4)), None);
135 - click(&mut app);
136 - assert_eq!(app.world().resource::<SelectedTile>().0, Some(UVec2::new(3, 4)));
258 + let mut app = selection_app(cursor_on_tile(UVec2::new(3, 4)), Selection::None);
259 + press(&mut app, MouseButton::Left);
260 + assert_eq!(selection(&app), Selection::Tile(UVec2::new(3, 4)));
261 + }
262 +
263 + #[test]
264 + fn clicking_a_colonist_selects_it_instead_of_its_tile() {
265 + let tile = UVec2::new(3, 4);
266 + let mut app = selection_app(cursor_on_tile(tile), Selection::None);
267 + let colonist = app
268 + .world_mut()
269 + .spawn((
270 + Colonist { name: "Ada".into() },
271 + Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
272 + ))
273 + .id();
274 + press(&mut app, MouseButton::Left);
275 + assert_eq!(selection(&app), Selection::Colonist(colonist));
137 276 }
138 277
139 278 #[test]
140 279 fn clicking_outside_the_world_clears_the_selection() {
141 - let mut app = selection_app(None, Some(UVec2::new(3, 4)));
142 - click(&mut app);
143 - assert_eq!(app.world().resource::<SelectedTile>().0, None);
280 + let mut app = selection_app(Cursor::default(), Selection::Tile(UVec2::new(3, 4)));
281 + press(&mut app, MouseButton::Left);
282 + assert_eq!(selection(&app), Selection::None);
144 283 }
145 284
146 285 #[test]
147 286 fn escape_clears_the_selection() {
148 - let mut app = selection_app(Some(UVec2::ONE), Some(UVec2::new(3, 4)));
287 + let mut app = selection_app(cursor_on_tile(UVec2::ONE), Selection::Tile(UVec2::new(3, 4)));
149 288 app.world_mut()
150 289 .resource_mut::<ButtonInput<KeyCode>>()
151 290 .press(KeyCode::Escape);
152 291 app.update();
153 - assert_eq!(app.world().resource::<SelectedTile>().0, None);
292 + assert_eq!(selection(&app), Selection::None);
293 + }
294 +
295 + #[test]
296 + fn right_click_orders_selected_colonist_to_hovered_tile() {
297 + let colonist = Entity::from_raw_u32(7).unwrap();
298 + let mut app = selection_app(cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist));
299 + press(&mut app, MouseButton::Right);
300 + release(&mut app, MouseButton::Right);
301 + assert_eq!(sent_orders(&mut app), vec![(colonist, UVec2::new(5, 6))]);
302 + }
303 +
304 + #[test]
305 + fn right_drag_does_not_order_because_it_pans_the_camera() {
306 + let colonist = Entity::from_raw_u32(7).unwrap();
307 + let mut app = selection_app(cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist));
308 + press(&mut app, MouseButton::Right);
309 + app.world_mut().resource_mut::<Cursor>().screen = Some(Vec2::new(200.0, 50.0));
310 + release(&mut app, MouseButton::Right);
311 + assert!(sent_orders(&mut app).is_empty());
312 + }
313 +
314 + #[test]
315 + fn pick_nearest_ignores_far_candidates() {
316 + let a = Entity::from_raw_u32(1).unwrap();
317 + let b = Entity::from_raw_u32(2).unwrap();
318 + let candidates = [(a, Vec2::new(5.0, 0.0)), (b, Vec2::new(2.0, 0.0))];
319 + assert_eq!(pick_nearest(Vec2::ZERO, candidates.into_iter()), Some(b));
320 + assert_eq!(pick_nearest(Vec2::new(100.0, 0.0), candidates.into_iter()), None);
154 321 }
155 322
156 323 #[test]
@@ -159,4 +326,14 @@ mod tests {
159 326 assert_eq!(describe_tile(&map, Some(UVec2::new(1, 2))), "(1, 2) Sand");
160 327 assert_eq!(describe_tile(&map, None), "-");
161 328 }
329 +
330 + #[test]
331 + fn describe_colonist_shows_activity() {
332 + let ada = Colonist { name: "Ada".into() };
333 + assert_eq!(describe_colonist(&ada, UVec2::new(1, 2), 0), "Ada at (1, 2), idle");
334 + assert_eq!(
335 + describe_colonist(&ada, UVec2::new(1, 2), 3),
336 + "Ada at (1, 2), moving, 3 steps left"
337 + );
338 + }
162 339 }

tasks.md +5 -2

@@ -6,10 +6,13 @@
6 6 - [x] Camera: WASD/arrow pan, right/middle-drag pan, scroll zoom toward cursor, clamped to world
7 7 - [x] WebAssembly build (`scripts/build-web.sh`)
8 8 - [x] Tile hover + selection (outlines, info text, Esc to clear)
9 +- [x] A* pathfinding with terrain costs
10 +- [x] Colonists: wander, select, right-click move orders, path display
11 +- [x] Pause toggle (Space)
9 12
10 13 ## Planned
11 14 - [ ] Procedural world generation (noise-based terrain, seeds)
12 -- [ ] Colonists (pawns) with movement and pathfinding
13 -- [ ] Simulation tick / time controls (pause, speeds)
15 +- [ ] Simulation tick / time controls (speeds, fixed timestep)
16 +- [ ] Ignore map clicks over UI
14 17 - [ ] Jobs: mining, building, hauling
15 18 - [ ] Basic UI (tile info, colonist panel)