Add wandering colonists with move orders and pause
- 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), ¤t, 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) |