Add job system with autonomous and ordered woodcutting
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 09:11 UTC
- commit
- ad48ec64efc6e9c0a27c7fc655dc207d2d28b897
- parent
- 312e46fde3
- tree
- browse at this commit
10 files changed +795 -106
| CLAUDE.md | +8 -5 | |
| src/colonist.rs | +9 -76 | |
| src/items.rs | added | +155 -0 |
| src/jobs.rs | added | +463 -0 |
| src/main.rs | +4 -0 | |
| src/selection.rs | +96 -21 | |
| src/sim.rs | +15 -0 | |
| src/tilemap.rs | +34 -3 | |
| src/world.rs | +6 -0 | |
| tasks.md | +5 -1 |
CLAUDE.md +8 -5
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — check `docs/bevy/` before guessing APIs). |
| 4 | 4 | |
| 5 | 5 | ## User rules |
| 6 | +- Vision: fully simulated colony. Every job colonists can be ordered to do, they must also be able to decide to do autonomously (driven by colony demand); manual orders are a bonus/override. | |
| 6 | 7 | - Code is `cargo fmt`ed; run it before committing. |
| 7 | 8 | - Commit after each logical chunk; short commit msgs. |
| 8 | 9 | - Minimal code comments. "Why" explanations go into a test asserting the behaviour, comment lives in the test. |
| @@ -14,12 +15,14 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 14 | 15 | - `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable), `Plant` enum (Tree; separate layer, adds move cost). `WorldMap` resource: per-tile terrain, `Option<Plant>`, height in meters (0..`MAX_HEIGHT`=100); row-major, (0,0)=bottom-left, y up. `move_cost(pos)` = terrain + plant cost (None = impassable); `step_cost(from,to)` adds `UPHILL_COST_PER_METER` × climb — use step_cost for anything that moves. `TILE_SIZE`=16 world units, `hash2` deterministic int hash. |
| 15 | 16 | - `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1]. |
| 16 | 17 | - `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 → height (min-max normalised to meters) and deep/shallow/sand/rock by **quantile** thresholds (fixed terrain shares per seed); ore = quantiles of two ore noises among rock tiles (gold 3%, iron 10%); fertility fbm top 20% → soil; rivers; trees (forest noise top 35% → 45% tree chance, else 3%; only Grass/Soil, placed after rivers). Rivers: `FlowField` = priority flood from water + non-rock edge tiles over non-rock tiles, each tile gets a downstream neighbour (spills out of depressions at lowest rim); `pick_rivers` traces 40 random mountain-foot sources, keeps the 4 longest spaced ≥40 apart; `carve_river` → ShallowWater, wider downstream, never over rock/deep water. `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 | -- `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`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (bevy's `update_tilemap_chunk_indices` re-uploads via change detection). | |
| 18 | +- `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`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection). | |
| 18 | 19 | - `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 (clock + speed/PAUSED, seed, hover, selection). Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`. | |
| 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)`. `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. | |
| 20 | +- `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, wood stock/goal, hover incl. items, selection incl. job). Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`. | |
| 21 | +- `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)`, 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles. Paths exclude start, include goal. | |
| 22 | +- `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (world.rs; last reached tile), `Path(VecDeque)`, `CurrentJob`, `IdleTimer`. `follow_path` (SimSystems::Move) moves `SimPos`, speed / step cost. `repath` keeps the tile being entered at the front. | |
| 23 | +- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Chop{tree, progress}}`. `Reservations` (tile → entity) so colonists never share a target; cleared on WorldGenerated. `ColonyGoals{wood}` = demand. `choose_jobs` (SimSystems::Decide): idle (no job, empty path, IdleTimer done) → first work type with demand + reachable target (woodcutting: `wood_wanted` counts stock + trees being chopped × WOOD_PER_TREE; `find_tree` nearest unreserved), else wander. `run_jobs` (SimSystems::Act): per task state machine; Chop walks adjacent (`find_path_adjacent`), ticks progress to CHOP_TICKS, removes tree, writes `TileChanged`, drops wood; abandons if tree gone/reservation lost. `Order{colonist, tile}` (Update, applies while paused): tree tile → ordered Chop (steals reservation, ignores demand), else ordered MoveTo; ordered move completion → 10 s idle before autonomy resumes. Adding a job = new Task variant + demand check in choose_jobs + arm in run_jobs. | |
| 24 | +- `src/items.rs` `Item{kind, count}` + `TilePos` entities (Mesh2d rect, z=0.7), `drop_items` stacks same kind on a tile, `stock()` sums; cleared on WorldGenerated. Camera uses `Time<Real>` so it works while paused. Rendered as Mesh2d circles z=1. | |
| 25 | +- `src/sim.rs` `SimPlugin`: `SimSystems::{Decide, Act, Move}` chained sets in FixedUpdate; 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. | |
| 23 | 26 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 24 | 27 | |
| 25 | 28 | ## Gotchas |
src/colonist.rs +9 -76
| @@ -1,12 +1,12 @@ | ||
| 1 | -use std::{collections::VecDeque, time::Duration}; | |
| 1 | +use std::collections::VecDeque; | |
| 2 | 2 | |
| 3 | 3 | use bevy::prelude::*; |
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | + jobs::{CurrentJob, IdleTimer}, | |
| 6 | 7 | pathfinding::find_path, |
| 7 | - rng::SimRng, | |
| 8 | - sim::{SimPos, sim_pos}, | |
| 9 | - world::{TILE_SIZE, WorldMap}, | |
| 8 | + sim::{SimPos, SimSystems, sim_pos}, | |
| 9 | + world::{TILE_SIZE, TilePos, WorldMap}, | |
| 10 | 10 | worldgen::{WorldGenerated, spawn_tiles}, |
| 11 | 11 | }; |
| 12 | 12 | |
| @@ -14,17 +14,12 @@ pub struct ColonistPlugin; | ||
| 14 | 14 | |
| 15 | 15 | impl Plugin for ColonistPlugin { |
| 16 | 16 | fn build(&self, app: &mut App) { |
| 17 | - app.insert_resource(SimRng::new(0x5EED)) | |
| 18 | - .add_message::<MoveOrder>() | |
| 19 | - .add_systems(Update, spawn_colonists.run_if(on_message::<WorldGenerated>)) | |
| 20 | - .add_systems(Update, handle_move_orders) | |
| 21 | - .add_systems(FixedUpdate, (wander, follow_path).chain()); | |
| 17 | + app.add_systems(Update, spawn_colonists.run_if(on_message::<WorldGenerated>)) | |
| 18 | + .add_systems(FixedUpdate, follow_path.in_set(SimSystems::Move)); | |
| 22 | 19 | } |
| 23 | 20 | } |
| 24 | 21 | |
| 25 | 22 | const TILES_PER_SECOND: f32 = 4.0; |
| 26 | -const WANDER_RADIUS: i32 = 8; | |
| 27 | -const WANDER_PAUSE_SECS: std::ops::Range<f32> = 1.0..4.0; | |
| 28 | 23 | |
| 29 | 24 | const STARTING_COLONISTS: [(&str, Color); 3] = [ |
| 30 | 25 | ("Ada", Color::srgb(0.9, 0.3, 0.3)), |
| @@ -33,35 +28,14 @@ const STARTING_COLONISTS: [(&str, Color); 3] = [ | ||
| 33 | 28 | ]; |
| 34 | 29 | |
| 35 | 30 | #[derive(Component, Debug)] |
| 36 | -#[require(Transform, SimPos, TilePos, Path, Wander)] | |
| 31 | +#[require(Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer)] | |
| 37 | 32 | pub struct Colonist { |
| 38 | 33 | pub name: String, |
| 39 | 34 | } |
| 40 | 35 | |
| 41 | -#[derive(Component, Default, Debug, Clone, Copy, PartialEq)] | |
| 42 | -pub struct TilePos(pub UVec2); | |
| 43 | - | |
| 44 | 36 | #[derive(Component, Default, Debug)] |
| 45 | 37 | pub struct Path(pub VecDeque<UVec2>); |
| 46 | 38 | |
| 47 | -#[derive(Component, Debug)] | |
| 48 | -pub struct Wander(Timer); | |
| 49 | - | |
| 50 | -impl Default for Wander { | |
| 51 | - fn default() -> Self { | |
| 52 | - Self(Timer::from_seconds( | |
| 53 | - WANDER_PAUSE_SECS.start, | |
| 54 | - TimerMode::Once, | |
| 55 | - )) | |
| 56 | - } | |
| 57 | -} | |
| 58 | - | |
| 59 | -#[derive(Message, Debug, Clone, Copy)] | |
| 60 | -pub struct MoveOrder { | |
| 61 | - pub colonist: Entity, | |
| 62 | - pub goal: UVec2, | |
| 63 | -} | |
| 64 | - | |
| 65 | 39 | fn spawn_colonists( |
| 66 | 40 | mut commands: Commands, |
| 67 | 41 | map: Res<WorldMap>, |
| @@ -87,21 +61,6 @@ fn spawn_colonists( | ||
| 87 | 61 | } |
| 88 | 62 | } |
| 89 | 63 | |
| 90 | -fn handle_move_orders( | |
| 91 | - mut orders: MessageReader<MoveOrder>, | |
| 92 | - map: Res<WorldMap>, | |
| 93 | - mut colonists: Query<(&TilePos, &mut Path)>, | |
| 94 | -) { | |
| 95 | - for order in orders.read() { | |
| 96 | - let Ok((tile, mut path)) = colonists.get_mut(order.colonist) else { | |
| 97 | - continue; | |
| 98 | - }; | |
| 99 | - if let Some(new_path) = repath(&map, tile.0, &path.0, order.goal) { | |
| 100 | - path.0 = new_path; | |
| 101 | - } | |
| 102 | - } | |
| 103 | -} | |
| 104 | - | |
| 105 | 64 | pub fn repath( |
| 106 | 65 | map: &WorldMap, |
| 107 | 66 | tile: UVec2, |
| @@ -118,34 +77,7 @@ pub fn repath( | ||
| 118 | 77 | } |
| 119 | 78 | } |
| 120 | 79 | |
| 121 | -fn wander( | |
| 122 | - time: Res<Time>, | |
| 123 | - map: Res<WorldMap>, | |
| 124 | - mut rng: ResMut<SimRng>, | |
| 125 | - mut colonists: Query<(&TilePos, &mut Path, &mut Wander)>, | |
| 126 | -) { | |
| 127 | - for (tile, mut path, mut wander) in &mut colonists { | |
| 128 | - if !path.0.is_empty() || !wander.0.tick(time.delta()).is_finished() { | |
| 129 | - continue; | |
| 130 | - } | |
| 131 | - let offset = IVec2::new( | |
| 132 | - rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1), | |
| 133 | - rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1), | |
| 134 | - ); | |
| 135 | - let goal = tile.0.as_ivec2() + offset; | |
| 136 | - if !map.in_bounds(goal) { | |
| 137 | - continue; | |
| 138 | - } | |
| 139 | - if let Some(new_path) = find_path(&map, tile.0, goal.as_uvec2()) { | |
| 140 | - path.0 = new_path.into(); | |
| 141 | - let pause = rng.range_f32(WANDER_PAUSE_SECS); | |
| 142 | - wander.0.set_duration(Duration::from_secs_f32(pause)); | |
| 143 | - wander.0.reset(); | |
| 144 | - } | |
| 145 | - } | |
| 146 | -} | |
| 147 | - | |
| 148 | -fn follow_path( | |
| 80 | +pub fn follow_path( | |
| 149 | 81 | time: Res<Time>, |
| 150 | 82 | map: Res<WorldMap>, |
| 151 | 83 | mut colonists: Query<(&mut TilePos, &mut Path, &mut SimPos)>, |
| @@ -182,6 +114,7 @@ pub fn step_towards(pos: Vec2, target: Vec2, max_distance: f32) -> (Vec2, bool) | ||
| 182 | 114 | mod tests { |
| 183 | 115 | use super::*; |
| 184 | 116 | use crate::world::Terrain; |
| 117 | + use std::time::Duration; | |
| 185 | 118 | |
| 186 | 119 | #[test] |
| 187 | 120 | fn step_towards_stops_exactly_on_target() { |
src/items.rs +155 -0
| @@ -0,0 +1,155 @@ | ||
| 1 | +use bevy::prelude::*; | |
| 2 | + | |
| 3 | +use crate::{ | |
| 4 | + world::{TILE_SIZE, TilePos, WorldMap}, | |
| 5 | + worldgen::WorldGenerated, | |
| 6 | +}; | |
| 7 | + | |
| 8 | +pub struct ItemsPlugin; | |
| 9 | + | |
| 10 | +impl Plugin for ItemsPlugin { | |
| 11 | + fn build(&self, app: &mut App) { | |
| 12 | + app.add_systems(Startup, create_item_assets) | |
| 13 | + .add_systems(Update, clear_items.run_if(on_message::<WorldGenerated>)); | |
| 14 | + } | |
| 15 | +} | |
| 16 | + | |
| 17 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 18 | +pub enum ItemKind { | |
| 19 | + Wood, | |
| 20 | +} | |
| 21 | + | |
| 22 | +impl ItemKind { | |
| 23 | + pub const ALL: [ItemKind; 1] = [ItemKind::Wood]; | |
| 24 | + | |
| 25 | + fn color(self) -> Color { | |
| 26 | + match self { | |
| 27 | + ItemKind::Wood => Color::srgb_u8(150, 104, 62), | |
| 28 | + } | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +#[derive(Component, Debug, Clone, Copy, PartialEq)] | |
| 33 | +#[require(Transform)] | |
| 34 | +pub struct Item { | |
| 35 | + pub kind: ItemKind, | |
| 36 | + pub count: u32, | |
| 37 | +} | |
| 38 | + | |
| 39 | +#[derive(Resource, Default)] | |
| 40 | +pub struct ItemAssets { | |
| 41 | + mesh: Handle<Mesh>, | |
| 42 | + materials: Vec<(ItemKind, Handle<ColorMaterial>)>, | |
| 43 | +} | |
| 44 | + | |
| 45 | +impl ItemAssets { | |
| 46 | + fn material(&self, kind: ItemKind) -> Handle<ColorMaterial> { | |
| 47 | + self.materials | |
| 48 | + .iter() | |
| 49 | + .find(|(k, _)| *k == kind) | |
| 50 | + .map(|(_, m)| m.clone()) | |
| 51 | + .unwrap_or_default() | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +fn create_item_assets( | |
| 56 | + mut commands: Commands, | |
| 57 | + mut meshes: ResMut<Assets<Mesh>>, | |
| 58 | + mut materials: ResMut<Assets<ColorMaterial>>, | |
| 59 | +) { | |
| 60 | + commands.insert_resource(ItemAssets { | |
| 61 | + mesh: meshes.add(Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.4)), | |
| 62 | + materials: ItemKind::ALL | |
| 63 | + .iter() | |
| 64 | + .map(|&kind| (kind, materials.add(kind.color()))) | |
| 65 | + .collect(), | |
| 66 | + }); | |
| 67 | +} | |
| 68 | + | |
| 69 | +fn clear_items(mut commands: Commands, items: Query<Entity, With<Item>>) { | |
| 70 | + for entity in &items { | |
| 71 | + commands.entity(entity).despawn(); | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +pub fn drop_items( | |
| 76 | + commands: &mut Commands, | |
| 77 | + items: &mut Query<(&mut Item, &TilePos)>, | |
| 78 | + assets: &ItemAssets, | |
| 79 | + tile: UVec2, | |
| 80 | + kind: ItemKind, | |
| 81 | + count: u32, | |
| 82 | +) { | |
| 83 | + if let Some((mut item, _)) = items | |
| 84 | + .iter_mut() | |
| 85 | + .find(|(item, pos)| pos.0 == tile && item.kind == kind) | |
| 86 | + { | |
| 87 | + item.count += count; | |
| 88 | + return; | |
| 89 | + } | |
| 90 | + commands.spawn(( | |
| 91 | + Name::new(format!("{kind:?}")), | |
| 92 | + Item { kind, count }, | |
| 93 | + TilePos(tile), | |
| 94 | + Mesh2d(assets.mesh.clone()), | |
| 95 | + MeshMaterial2d(assets.material(kind)), | |
| 96 | + Transform::from_translation(WorldMap::tile_center(tile).extend(0.7)), | |
| 97 | + )); | |
| 98 | +} | |
| 99 | + | |
| 100 | +pub fn stock<'a>(items: impl Iterator<Item = &'a Item>, kind: ItemKind) -> u32 { | |
| 101 | + items.filter(|i| i.kind == kind).map(|i| i.count).sum() | |
| 102 | +} | |
| 103 | + | |
| 104 | +#[cfg(test)] | |
| 105 | +mod tests { | |
| 106 | + use super::*; | |
| 107 | + | |
| 108 | + fn drop_system(mut commands: Commands, mut items: Query<(&mut Item, &TilePos)>) { | |
| 109 | + let assets = ItemAssets::default(); | |
| 110 | + drop_items( | |
| 111 | + &mut commands, | |
| 112 | + &mut items, | |
| 113 | + &assets, | |
| 114 | + UVec2::ONE, | |
| 115 | + ItemKind::Wood, | |
| 116 | + 5, | |
| 117 | + ); | |
| 118 | + } | |
| 119 | + | |
| 120 | + #[test] | |
| 121 | + fn dropping_on_a_tile_with_the_same_item_stacks() { | |
| 122 | + let mut app = App::new(); | |
| 123 | + app.add_systems(Update, drop_system); | |
| 124 | + app.update(); | |
| 125 | + app.update(); | |
| 126 | + let items: Vec<Item> = app | |
| 127 | + .world_mut() | |
| 128 | + .query::<&Item>() | |
| 129 | + .iter(app.world()) | |
| 130 | + .copied() | |
| 131 | + .collect(); | |
| 132 | + assert_eq!( | |
| 133 | + items, | |
| 134 | + vec![Item { | |
| 135 | + kind: ItemKind::Wood, | |
| 136 | + count: 10 | |
| 137 | + }] | |
| 138 | + ); | |
| 139 | + } | |
| 140 | + | |
| 141 | + #[test] | |
| 142 | + fn stock_sums_counts_of_one_kind() { | |
| 143 | + let items = [ | |
| 144 | + Item { | |
| 145 | + kind: ItemKind::Wood, | |
| 146 | + count: 3, | |
| 147 | + }, | |
| 148 | + Item { | |
| 149 | + kind: ItemKind::Wood, | |
| 150 | + count: 4, | |
| 151 | + }, | |
| 152 | + ]; | |
| 153 | + assert_eq!(stock(items.iter(), ItemKind::Wood), 7); | |
| 154 | + } | |
| 155 | +} | |
src/jobs.rs +463 -0
| @@ -0,0 +1,463 @@ | ||
| 1 | +use std::{collections::HashMap, ops::Range, time::Duration}; | |
| 2 | + | |
| 3 | +use bevy::prelude::*; | |
| 4 | + | |
| 5 | +use crate::{ | |
| 6 | + colonist::{Colonist, Path, repath}, | |
| 7 | + items::{Item, ItemAssets, ItemKind, drop_items, stock}, | |
| 8 | + pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile}, | |
| 9 | + rng::SimRng, | |
| 10 | + sim::SimSystems, | |
| 11 | + world::{Plant, TileChanged, TilePos, WorldMap}, | |
| 12 | + worldgen::WorldGenerated, | |
| 13 | +}; | |
| 14 | + | |
| 15 | +pub struct JobsPlugin; | |
| 16 | + | |
| 17 | +impl Plugin for JobsPlugin { | |
| 18 | + fn build(&self, app: &mut App) { | |
| 19 | + app.insert_resource(SimRng::new(0x5EED)) | |
| 20 | + .init_resource::<Reservations>() | |
| 21 | + .init_resource::<ColonyGoals>() | |
| 22 | + .add_message::<Order>() | |
| 23 | + .add_message::<TileChanged>() | |
| 24 | + .add_systems( | |
| 25 | + Update, | |
| 26 | + ( | |
| 27 | + clear_reservations.run_if(on_message::<WorldGenerated>), | |
| 28 | + handle_orders, | |
| 29 | + ), | |
| 30 | + ) | |
| 31 | + .add_systems(FixedUpdate, choose_jobs.in_set(SimSystems::Decide)) | |
| 32 | + .add_systems(FixedUpdate, run_jobs.in_set(SimSystems::Act)); | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +pub const CHOP_TICKS: u32 = 240; | |
| 37 | +pub const WOOD_PER_TREE: u32 = 10; | |
| 38 | +const TREE_SEARCH_TILES: usize = 6000; | |
| 39 | +const WANDER_RADIUS: i32 = 8; | |
| 40 | +const IDLE_SECS: Range<f32> = 1.0..4.0; | |
| 41 | +const IDLE_AFTER_ORDER_SECS: f32 = 10.0; | |
| 42 | + | |
| 43 | +#[derive(Component, Default, Debug, Clone, PartialEq)] | |
| 44 | +pub struct CurrentJob(pub Option<Job>); | |
| 45 | + | |
| 46 | +#[derive(Debug, Clone, Copy, PartialEq)] | |
| 47 | +pub struct Job { | |
| 48 | + pub task: Task, | |
| 49 | + pub ordered: bool, | |
| 50 | +} | |
| 51 | + | |
| 52 | +#[derive(Debug, Clone, Copy, PartialEq)] | |
| 53 | +pub enum Task { | |
| 54 | + MoveTo(UVec2), | |
| 55 | + Chop { tree: UVec2, progress: u32 }, | |
| 56 | +} | |
| 57 | + | |
| 58 | +#[derive(Component, Debug)] | |
| 59 | +pub struct IdleTimer(Timer); | |
| 60 | + | |
| 61 | +impl Default for IdleTimer { | |
| 62 | + fn default() -> Self { | |
| 63 | + Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once)) | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +impl IdleTimer { | |
| 68 | + fn restart(&mut self, secs: f32) { | |
| 69 | + self.0.set_duration(Duration::from_secs_f32(secs)); | |
| 70 | + self.0.reset(); | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +#[derive(Resource, Default, Debug)] | |
| 75 | +pub struct Reservations(HashMap<UVec2, Entity>); | |
| 76 | + | |
| 77 | +impl Reservations { | |
| 78 | + pub fn reserve(&mut self, tile: UVec2, by: Entity) { | |
| 79 | + self.0.insert(tile, by); | |
| 80 | + } | |
| 81 | + | |
| 82 | + pub fn holder(&self, tile: UVec2) -> Option<Entity> { | |
| 83 | + self.0.get(&tile).copied() | |
| 84 | + } | |
| 85 | + | |
| 86 | + pub fn release_all(&mut self, by: Entity) { | |
| 87 | + self.0.retain(|_, holder| *holder != by); | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +#[derive(Resource, Debug)] | |
| 92 | +pub struct ColonyGoals { | |
| 93 | + pub wood: u32, | |
| 94 | +} | |
| 95 | + | |
| 96 | +impl Default for ColonyGoals { | |
| 97 | + fn default() -> Self { | |
| 98 | + Self { wood: 50 } | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +#[derive(Message, Debug, Clone, Copy)] | |
| 103 | +pub struct Order { | |
| 104 | + pub colonist: Entity, | |
| 105 | + pub tile: UVec2, | |
| 106 | +} | |
| 107 | + | |
| 108 | +fn clear_reservations(mut reservations: ResMut<Reservations>) { | |
| 109 | + reservations.0.clear(); | |
| 110 | +} | |
| 111 | + | |
| 112 | +fn handle_orders( | |
| 113 | + mut orders: MessageReader<Order>, | |
| 114 | + map: Res<WorldMap>, | |
| 115 | + mut reservations: ResMut<Reservations>, | |
| 116 | + mut colonists: Query<(&TilePos, &mut Path, &mut CurrentJob)>, | |
| 117 | +) { | |
| 118 | + for order in orders.read() { | |
| 119 | + let Ok((tile, mut path, mut job)) = colonists.get_mut(order.colonist) else { | |
| 120 | + continue; | |
| 121 | + }; | |
| 122 | + if map.plant(order.tile.as_ivec2()) == Some(Plant::Tree) { | |
| 123 | + reservations.release_all(order.colonist); | |
| 124 | + reservations.reserve(order.tile, order.colonist); | |
| 125 | + path.0.truncate(1); | |
| 126 | + job.0 = Some(Job { | |
| 127 | + task: Task::Chop { | |
| 128 | + tree: order.tile, | |
| 129 | + progress: 0, | |
| 130 | + }, | |
| 131 | + ordered: true, | |
| 132 | + }); | |
| 133 | + } else if let Some(new_path) = repath(&map, tile.0, &path.0, order.tile) { | |
| 134 | + reservations.release_all(order.colonist); | |
| 135 | + path.0 = new_path; | |
| 136 | + job.0 = Some(Job { | |
| 137 | + task: Task::MoveTo(order.tile), | |
| 138 | + ordered: true, | |
| 139 | + }); | |
| 140 | + } | |
| 141 | + } | |
| 142 | +} | |
| 143 | + | |
| 144 | +pub fn wood_wanted(goals: &ColonyGoals, in_stock: u32, trees_being_chopped: u32) -> bool { | |
| 145 | + in_stock + trees_being_chopped * WOOD_PER_TREE < goals.wood | |
| 146 | +} | |
| 147 | + | |
| 148 | +pub fn find_tree( | |
| 149 | + map: &WorldMap, | |
| 150 | + reservations: &Reservations, | |
| 151 | + from: UVec2, | |
| 152 | +) -> Option<(UVec2, Vec<UVec2>)> { | |
| 153 | + let tree = nearest_tile(map, from, TREE_SEARCH_TILES, |p| { | |
| 154 | + map.plant(p.as_ivec2()) == Some(Plant::Tree) && reservations.holder(p).is_none() | |
| 155 | + })?; | |
| 156 | + let path = find_path_adjacent(map, from, tree)?; | |
| 157 | + Some((tree, path)) | |
| 158 | +} | |
| 159 | + | |
| 160 | +#[allow(clippy::too_many_arguments)] | |
| 161 | +fn choose_jobs( | |
| 162 | + time: Res<Time>, | |
| 163 | + map: Res<WorldMap>, | |
| 164 | + goals: Res<ColonyGoals>, | |
| 165 | + mut rng: ResMut<SimRng>, | |
| 166 | + mut reservations: ResMut<Reservations>, | |
| 167 | + items: Query<&Item>, | |
| 168 | + mut colonists: Query< | |
| 169 | + (Entity, &TilePos, &mut Path, &mut CurrentJob, &mut IdleTimer), | |
| 170 | + With<Colonist>, | |
| 171 | + >, | |
| 172 | +) { | |
| 173 | + let wood = stock(items.iter(), ItemKind::Wood); | |
| 174 | + let mut chopping = colonists | |
| 175 | + .iter() | |
| 176 | + .filter(|(.., job, _)| { | |
| 177 | + matches!( | |
| 178 | + job.0, | |
| 179 | + Some(Job { | |
| 180 | + task: Task::Chop { .. }, | |
| 181 | + .. | |
| 182 | + }) | |
| 183 | + ) | |
| 184 | + }) | |
| 185 | + .count() as u32; | |
| 186 | + for (entity, tile, mut path, mut job, mut idle) in &mut colonists { | |
| 187 | + if job.0.is_some() || !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() { | |
| 188 | + continue; | |
| 189 | + } | |
| 190 | + if wood_wanted(&goals, wood, chopping) | |
| 191 | + && let Some((tree, tree_path)) = find_tree(&map, &reservations, tile.0) | |
| 192 | + { | |
| 193 | + reservations.reserve(tree, entity); | |
| 194 | + path.0 = tree_path.into(); | |
| 195 | + job.0 = Some(Job { | |
| 196 | + task: Task::Chop { tree, progress: 0 }, | |
| 197 | + ordered: false, | |
| 198 | + }); | |
| 199 | + chopping += 1; | |
| 200 | + continue; | |
| 201 | + } | |
| 202 | + let goal = tile.0.as_ivec2() | |
| 203 | + + IVec2::new( | |
| 204 | + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1), | |
| 205 | + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1), | |
| 206 | + ); | |
| 207 | + if map.in_bounds(goal) | |
| 208 | + && let Some(wander_path) = find_path(&map, tile.0, goal.as_uvec2()) | |
| 209 | + { | |
| 210 | + path.0 = wander_path.into(); | |
| 211 | + idle.restart(rng.range_f32(IDLE_SECS)); | |
| 212 | + } | |
| 213 | + } | |
| 214 | +} | |
| 215 | + | |
| 216 | +#[allow(clippy::too_many_arguments)] | |
| 217 | +fn run_jobs( | |
| 218 | + mut commands: Commands, | |
| 219 | + mut map: ResMut<WorldMap>, | |
| 220 | + mut reservations: ResMut<Reservations>, | |
| 221 | + mut rng: ResMut<SimRng>, | |
| 222 | + item_assets: Res<ItemAssets>, | |
| 223 | + mut items: Query<(&mut Item, &TilePos)>, | |
| 224 | + mut changed: MessageWriter<TileChanged>, | |
| 225 | + mut colonists: Query<(Entity, &TilePos, &mut Path, &mut CurrentJob, &mut IdleTimer)>, | |
| 226 | +) { | |
| 227 | + for (entity, tile, mut path, mut current, mut idle) in &mut colonists { | |
| 228 | + let Some(mut job) = current.0 else { | |
| 229 | + continue; | |
| 230 | + }; | |
| 231 | + if !path.0.is_empty() { | |
| 232 | + continue; | |
| 233 | + } | |
| 234 | + let done = match &mut job.task { | |
| 235 | + Task::MoveTo(_) => true, | |
| 236 | + Task::Chop { tree, progress } => { | |
| 237 | + let tree = *tree; | |
| 238 | + let still_ours = map.plant(tree.as_ivec2()) == Some(Plant::Tree) | |
| 239 | + && reservations.holder(tree) == Some(entity); | |
| 240 | + if !still_ours { | |
| 241 | + true | |
| 242 | + } else if !is_adjacent(tile.0.as_ivec2(), tree.as_ivec2()) { | |
| 243 | + match find_path_adjacent(&map, tile.0, tree) { | |
| 244 | + Some(p) if !p.is_empty() => { | |
| 245 | + path.0 = p.into(); | |
| 246 | + false | |
| 247 | + } | |
| 248 | + _ => true, | |
| 249 | + } | |
| 250 | + } else { | |
| 251 | + *progress += 1; | |
| 252 | + if *progress >= CHOP_TICKS { | |
| 253 | + map.set_plant(tree, None); | |
| 254 | + changed.write(TileChanged(tree)); | |
| 255 | + drop_items( | |
| 256 | + &mut commands, | |
| 257 | + &mut items, | |
| 258 | + &item_assets, | |
| 259 | + tree, | |
| 260 | + ItemKind::Wood, | |
| 261 | + WOOD_PER_TREE, | |
| 262 | + ); | |
| 263 | + } | |
| 264 | + *progress >= CHOP_TICKS | |
| 265 | + } | |
| 266 | + } | |
| 267 | + }; | |
| 268 | + if done { | |
| 269 | + reservations.release_all(entity); | |
| 270 | + current.0 = None; | |
| 271 | + let ordered_move = matches!( | |
| 272 | + job, | |
| 273 | + Job { | |
| 274 | + task: Task::MoveTo(_), | |
| 275 | + ordered: true | |
| 276 | + } | |
| 277 | + ); | |
| 278 | + idle.restart(if ordered_move { | |
| 279 | + IDLE_AFTER_ORDER_SECS | |
| 280 | + } else { | |
| 281 | + rng.range_f32(IDLE_SECS) | |
| 282 | + }); | |
| 283 | + } else { | |
| 284 | + current.0 = Some(job); | |
| 285 | + } | |
| 286 | + } | |
| 287 | +} | |
| 288 | + | |
| 289 | +#[cfg(test)] | |
| 290 | +mod tests { | |
| 291 | + use super::*; | |
| 292 | + use crate::{ | |
| 293 | + colonist::follow_path, | |
| 294 | + sim::{SimPos, sim_pos}, | |
| 295 | + world::Terrain, | |
| 296 | + }; | |
| 297 | + | |
| 298 | + const TICK: Duration = Duration::from_nanos(1_000_000_000 / 60); | |
| 299 | + | |
| 300 | + fn test_app(map: WorldMap) -> App { | |
| 301 | + let mut app = App::new(); | |
| 302 | + app.insert_resource(map) | |
| 303 | + .insert_resource(SimRng::new(1)) | |
| 304 | + .init_resource::<Time>() | |
| 305 | + .init_resource::<Reservations>() | |
| 306 | + .init_resource::<ColonyGoals>() | |
| 307 | + .init_resource::<ItemAssets>() | |
| 308 | + .add_message::<Order>() | |
| 309 | + .add_message::<TileChanged>() | |
| 310 | + .add_systems( | |
| 311 | + Update, | |
| 312 | + (handle_orders, choose_jobs, run_jobs, follow_path).chain(), | |
| 313 | + ); | |
| 314 | + app | |
| 315 | + } | |
| 316 | + | |
| 317 | + fn spawn_colonist(app: &mut App, tile: UVec2) -> Entity { | |
| 318 | + app.world_mut() | |
| 319 | + .spawn(( | |
| 320 | + Colonist { | |
| 321 | + name: "Test".into(), | |
| 322 | + }, | |
| 323 | + TilePos(tile), | |
| 324 | + sim_pos(WorldMap::tile_center(tile)), | |
| 325 | + )) | |
| 326 | + .id() | |
| 327 | + } | |
| 328 | + | |
| 329 | + fn run_ticks(app: &mut App, ticks: u32) { | |
| 330 | + for _ in 0..ticks { | |
| 331 | + app.world_mut().resource_mut::<Time>().advance_by(TICK); | |
| 332 | + app.update(); | |
| 333 | + } | |
| 334 | + } | |
| 335 | + | |
| 336 | + fn map_with_trees(trees: &[UVec2]) -> WorldMap { | |
| 337 | + let mut map = WorldMap::filled(UVec2::new(20, 20), Terrain::Grass); | |
| 338 | + for &t in trees { | |
| 339 | + map.set_plant(t, Some(Plant::Tree)); | |
| 340 | + } | |
| 341 | + map | |
| 342 | + } | |
| 343 | + | |
| 344 | + fn wood_on_map(app: &mut App) -> u32 { | |
| 345 | + let world = app.world_mut(); | |
| 346 | + let items: Vec<Item> = world.query::<&Item>().iter(world).copied().collect(); | |
| 347 | + stock(items.iter(), ItemKind::Wood) | |
| 348 | + } | |
| 349 | + | |
| 350 | + fn job(app: &App, colonist: Entity) -> Option<Job> { | |
| 351 | + app.world().get::<CurrentJob>(colonist).unwrap().0 | |
| 352 | + } | |
| 353 | + | |
| 354 | + #[test] | |
| 355 | + fn idle_colonists_cut_trees_when_the_colony_needs_wood() { | |
| 356 | + let tree = UVec2::new(8, 5); | |
| 357 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 358 | + spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 359 | + run_ticks(&mut app, 60 * 15); | |
| 360 | + assert_eq!( | |
| 361 | + app.world().resource::<WorldMap>().plant(tree.as_ivec2()), | |
| 362 | + None | |
| 363 | + ); | |
| 364 | + assert_eq!(wood_on_map(&mut app), WOOD_PER_TREE); | |
| 365 | + assert!(app.world().resource::<Reservations>().0.is_empty()); | |
| 366 | + } | |
| 367 | + | |
| 368 | + #[test] | |
| 369 | + fn colonists_leave_trees_alone_when_there_is_enough_wood() { | |
| 370 | + let tree = UVec2::new(8, 5); | |
| 371 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 372 | + app.world_mut().resource_mut::<ColonyGoals>().wood = 0; | |
| 373 | + spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 374 | + run_ticks(&mut app, 60 * 15); | |
| 375 | + assert_eq!( | |
| 376 | + app.world().resource::<WorldMap>().plant(tree.as_ivec2()), | |
| 377 | + Some(Plant::Tree) | |
| 378 | + ); | |
| 379 | + } | |
| 380 | + | |
| 381 | + #[test] | |
| 382 | + fn colonists_never_pick_the_same_tree() { | |
| 383 | + let trees = [UVec2::new(10, 10), UVec2::new(10, 11)]; | |
| 384 | + let mut app = test_app(map_with_trees(&trees)); | |
| 385 | + let a = spawn_colonist(&mut app, UVec2::new(5, 10)); | |
| 386 | + let b = spawn_colonist(&mut app, UVec2::new(5, 11)); | |
| 387 | + run_ticks(&mut app, 61); | |
| 388 | + let target = |c| match job(&app, c) { | |
| 389 | + Some(Job { | |
| 390 | + task: Task::Chop { tree, .. }, | |
| 391 | + .. | |
| 392 | + }) => tree, | |
| 393 | + other => panic!("expected chop job, got {other:?}"), | |
| 394 | + }; | |
| 395 | + assert_ne!(target(a), target(b)); | |
| 396 | + } | |
| 397 | + | |
| 398 | + // Each chopped tree will add WOOD_PER_TREE; counting trees already being chopped as future | |
| 399 | + // wood keeps the whole colony from felling a forest when only a little more wood is needed. | |
| 400 | + #[test] | |
| 401 | + fn trees_already_being_cut_count_towards_the_wood_goal() { | |
| 402 | + let goals = ColonyGoals { wood: 25 }; | |
| 403 | + assert!(wood_wanted(&goals, 10, 1)); | |
| 404 | + assert!(!wood_wanted(&goals, 10, 2)); | |
| 405 | + assert!(!wood_wanted(&goals, 30, 0)); | |
| 406 | + } | |
| 407 | + | |
| 408 | + #[test] | |
| 409 | + fn ordered_chop_ignores_the_wood_goal() { | |
| 410 | + let tree = UVec2::new(8, 5); | |
| 411 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 412 | + app.world_mut().resource_mut::<ColonyGoals>().wood = 0; | |
| 413 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 414 | + app.world_mut().write_message(Order { | |
| 415 | + colonist, | |
| 416 | + tile: tree, | |
| 417 | + }); | |
| 418 | + run_ticks(&mut app, 60 * 15); | |
| 419 | + assert_eq!( | |
| 420 | + app.world().resource::<WorldMap>().plant(tree.as_ivec2()), | |
| 421 | + None | |
| 422 | + ); | |
| 423 | + } | |
| 424 | + | |
| 425 | + #[test] | |
| 426 | + fn ordering_a_move_cancels_the_current_job_and_frees_its_tree() { | |
| 427 | + let tree = UVec2::new(15, 5); | |
| 428 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 429 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 430 | + run_ticks(&mut app, 61); | |
| 431 | + assert!(matches!( | |
| 432 | + job(&app, colonist), | |
| 433 | + Some(Job { | |
| 434 | + task: Task::Chop { .. }, | |
| 435 | + .. | |
| 436 | + }) | |
| 437 | + )); | |
| 438 | + app.world_mut().write_message(Order { | |
| 439 | + colonist, | |
| 440 | + tile: UVec2::new(2, 15), | |
| 441 | + }); | |
| 442 | + run_ticks(&mut app, 1); | |
| 443 | + assert_eq!( | |
| 444 | + job(&app, colonist).map(|j| j.task), | |
| 445 | + Some(Task::MoveTo(UVec2::new(2, 15))) | |
| 446 | + ); | |
| 447 | + assert_eq!(app.world().resource::<Reservations>().holder(tree), None); | |
| 448 | + } | |
| 449 | + | |
| 450 | + #[test] | |
| 451 | + fn colonists_chop_from_a_neighbouring_tile() { | |
| 452 | + let tree = UVec2::new(8, 5); | |
| 453 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 454 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 455 | + run_ticks(&mut app, 60 * 5); | |
| 456 | + let tile = app.world().get::<TilePos>(colonist).unwrap().0; | |
| 457 | + assert!(is_adjacent(tile.as_ivec2(), tree.as_ivec2())); | |
| 458 | + assert_eq!( | |
| 459 | + app.world().get::<SimPos>(colonist).unwrap().0, | |
| 460 | + WorldMap::tile_center(tile) | |
| 461 | + ); | |
| 462 | + } | |
| 463 | +} | |
src/main.rs +4 -0
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | mod camera; |
| 2 | 2 | mod colonist; |
| 3 | +mod items; | |
| 4 | +mod jobs; | |
| 3 | 5 | mod noise; |
| 4 | 6 | mod pathfinding; |
| 5 | 7 | mod rng; |
| @@ -33,6 +35,8 @@ fn main() { | ||
| 33 | 35 | tilemap::TilemapPlugin, |
| 34 | 36 | camera::CameraPlugin, |
| 35 | 37 | colonist::ColonistPlugin, |
| 38 | + items::ItemsPlugin, | |
| 39 | + jobs::JobsPlugin, | |
| 36 | 40 | selection::SelectionPlugin, |
| 37 | 41 | )) |
| 38 | 42 | .run(); |
src/selection.rs +96 -21
| @@ -2,9 +2,11 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow}; | ||
| 2 | 2 | |
| 3 | 3 | use crate::{ |
| 4 | 4 | camera::CameraController, |
| 5 | - colonist::{Colonist, MoveOrder, Path, TilePos}, | |
| 5 | + colonist::{Colonist, Path}, | |
| 6 | + items::{Item, ItemKind, stock}, | |
| 7 | + jobs::{CHOP_TICKS, ColonyGoals, CurrentJob, Job, Order, Task}, | |
| 6 | 8 | sim::{SimSpeed, SimTick, format_clock}, |
| 7 | - world::{TILE_SIZE, WorldMap}, | |
| 9 | + world::{TILE_SIZE, TilePos, WorldMap}, | |
| 8 | 10 | worldgen::{WorldGenerated, WorldSeed}, |
| 9 | 11 | }; |
| 10 | 12 | |
| @@ -51,6 +53,8 @@ struct TileInfoText; | ||
| 51 | 53 | |
| 52 | 54 | const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6); |
| 53 | 55 | const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2); |
| 56 | +const PROGRESS_COLOR: Color = Color::srgb(0.4, 0.9, 0.4); | |
| 57 | +const PROGRESS_BACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.6); | |
| 54 | 58 | const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7); |
| 55 | 59 | const PICK_RADIUS: f32 = TILE_SIZE * 0.6; |
| 56 | 60 | const CLICK_SLOP_PX: f32 = 4.0; |
| @@ -116,7 +120,7 @@ fn issue_move_orders( | ||
| 116 | 120 | buttons: Res<ButtonInput<MouseButton>>, |
| 117 | 121 | cursor: Res<Cursor>, |
| 118 | 122 | selection: Res<Selection>, |
| 119 | - mut orders: MessageWriter<MoveOrder>, | |
| 123 | + mut orders: MessageWriter<Order>, | |
| 120 | 124 | mut pressed_at: Local<Option<Vec2>>, |
| 121 | 125 | ) { |
| 122 | 126 | if buttons.just_pressed(MouseButton::Right) { |
| @@ -128,10 +132,10 @@ fn issue_move_orders( | ||
| 128 | 132 | let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else { |
| 129 | 133 | return; |
| 130 | 134 | }; |
| 131 | - if let (true, Selection::Colonist(colonist), Some(goal)) = | |
| 135 | + if let (true, Selection::Colonist(colonist), Some(tile)) = | |
| 132 | 136 | (is_click(press, release), *selection, cursor.tile) |
| 133 | 137 | { |
| 134 | - orders.write(MoveOrder { colonist, goal }); | |
| 138 | + orders.write(Order { colonist, tile }); | |
| 135 | 139 | } |
| 136 | 140 | } |
| 137 | 141 | |
| @@ -143,8 +147,23 @@ fn draw_highlights( | ||
| 143 | 147 | mut gizmos: Gizmos, |
| 144 | 148 | cursor: Res<Cursor>, |
| 145 | 149 | selection: Res<Selection>, |
| 146 | - colonists: Query<(&Transform, &Path), With<Colonist>>, | |
| 150 | + colonists: Query<(Entity, &Transform, &Path, &CurrentJob), With<Colonist>>, | |
| 147 | 151 | ) { |
| 152 | + for (_, transform, _, job) in &colonists { | |
| 153 | + if let Some(Job { | |
| 154 | + task: Task::Chop { progress, .. }, | |
| 155 | + .. | |
| 156 | + }) = job.0 | |
| 157 | + && progress > 0 | |
| 158 | + { | |
| 159 | + let left = | |
| 160 | + transform.translation.truncate() + Vec2::new(-TILE_SIZE * 0.4, TILE_SIZE * 0.6); | |
| 161 | + let full = TILE_SIZE * 0.8; | |
| 162 | + let done = full * progress as f32 / CHOP_TICKS as f32; | |
| 163 | + gizmos.line_2d(left, left + Vec2::X * full, PROGRESS_BACK_COLOR); | |
| 164 | + gizmos.line_2d(left, left + Vec2::X * done, PROGRESS_COLOR); | |
| 165 | + } | |
| 166 | + } | |
| 148 | 167 | if let Some(tile) = cursor.tile { |
| 149 | 168 | gizmos.rect_2d( |
| 150 | 169 | WorldMap::tile_center(tile), |
| @@ -162,9 +181,20 @@ fn draw_highlights( | ||
| 162 | 181 | ); |
| 163 | 182 | } |
| 164 | 183 | Selection::Colonist(entity) => { |
| 165 | - let Ok((transform, path)) = colonists.get(entity) else { | |
| 184 | + let Ok((_, transform, path, job)) = colonists.get(entity) else { | |
| 166 | 185 | return; |
| 167 | 186 | }; |
| 187 | + if let Some(Job { | |
| 188 | + task: Task::Chop { tree, .. }, | |
| 189 | + .. | |
| 190 | + }) = job.0 | |
| 191 | + { | |
| 192 | + gizmos.rect_2d( | |
| 193 | + WorldMap::tile_center(tree), | |
| 194 | + Vec2::splat(TILE_SIZE), | |
| 195 | + PATH_COLOR, | |
| 196 | + ); | |
| 197 | + } | |
| 168 | 198 | let pos = transform.translation.truncate(); |
| 169 | 199 | gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR); |
| 170 | 200 | let points = path.0.iter().map(|&t| WorldMap::tile_center(t)); |
| @@ -193,7 +223,9 @@ fn update_tile_info( | ||
| 193 | 223 | map: Res<WorldMap>, |
| 194 | 224 | cursor: Res<Cursor>, |
| 195 | 225 | selection: Res<Selection>, |
| 196 | - colonists: Query<(&Colonist, &TilePos, &Path)>, | |
| 226 | + colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob)>, | |
| 227 | + items: Query<(&Item, &TilePos)>, | |
| 228 | + goals: Res<ColonyGoals>, | |
| 197 | 229 | time: Res<Time<Virtual>>, |
| 198 | 230 | tick: Res<SimTick>, |
| 199 | 231 | speed: Res<SimSpeed>, |
| @@ -205,14 +237,23 @@ fn update_tile_info( | ||
| 205 | 237 | Selection::Tile(tile) => describe_tile(&map, Some(tile)), |
| 206 | 238 | Selection::Colonist(entity) => colonists |
| 207 | 239 | .get(entity) |
| 208 | - .map(|(colonist, tile, path)| describe_colonist(colonist, tile.0, path.0.len())) | |
| 240 | + .map(|(colonist, tile, path, job)| { | |
| 241 | + describe_colonist(colonist, tile.0, job.0, path.0.len()) | |
| 242 | + }) | |
| 209 | 243 | .unwrap_or_else(|_| "-".into()), |
| 210 | 244 | }; |
| 211 | 245 | let clock = format_clock(*tick); |
| 212 | 246 | let speed = describe_speed(*speed, time.is_paused()); |
| 247 | + let wood = stock(items.iter().map(|(item, _)| item), ItemKind::Wood); | |
| 248 | + let hovered_items: String = items | |
| 249 | + .iter() | |
| 250 | + .filter(|(_, pos)| Some(pos.0) == cursor.tile) | |
| 251 | + .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count)) | |
| 252 | + .collect(); | |
| 213 | 253 | let info = format!( |
| 214 | - "{clock} {speed}\nSeed: {} (R: new world)\nHover: {}\nSelected: {selected}", | |
| 254 | + "{clock} {speed}\nSeed: {} (R: new world)\nWood: {wood} / {}\nHover: {}{hovered_items}\nSelected: {selected}", | |
| 215 | 255 | seed.0, |
| 256 | + goals.wood, | |
| 216 | 257 | describe_tile(&map, cursor.tile) |
| 217 | 258 | ); |
| 218 | 259 | if text.0 != info { |
| @@ -242,12 +283,30 @@ pub fn describe_speed(speed: SimSpeed, paused: bool) -> String { | ||
| 242 | 283 | } |
| 243 | 284 | } |
| 244 | 285 | |
| 245 | -pub fn describe_colonist(colonist: &Colonist, tile: UVec2, steps_left: usize) -> String { | |
| 246 | - let activity = match steps_left { | |
| 247 | - 0 => "idle".to_string(), | |
| 248 | - n => format!("moving, {n} steps left"), | |
| 286 | +pub fn describe_colonist( | |
| 287 | + colonist: &Colonist, | |
| 288 | + tile: UVec2, | |
| 289 | + job: Option<Job>, | |
| 290 | + steps_left: usize, | |
| 291 | +) -> String { | |
| 292 | + let activity = match (job.map(|j| j.task), steps_left) { | |
| 293 | + (None, 0) => "idle".to_string(), | |
| 294 | + (None, _) => "wandering".to_string(), | |
| 295 | + (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"), | |
| 296 | + (Some(Task::Chop { .. }), n) if n > 0 => "walking to a tree".to_string(), | |
| 297 | + (Some(Task::Chop { progress, .. }), _) => { | |
| 298 | + format!("chopping a tree, {}%", progress * 100 / CHOP_TICKS) | |
| 299 | + } | |
| 300 | + }; | |
| 301 | + let ordered = if job.is_some_and(|j| j.ordered) { | |
| 302 | + " (ordered)" | |
| 303 | + } else { | |
| 304 | + "" | |
| 249 | 305 | }; |
| 250 | - format!("{} at ({}, {}), {activity}", colonist.name, tile.x, tile.y) | |
| 306 | + format!( | |
| 307 | + "{} at ({}, {}), {activity}{ordered}", | |
| 308 | + colonist.name, tile.x, tile.y | |
| 309 | + ) | |
| 251 | 310 | } |
| 252 | 311 | |
| 253 | 312 | #[cfg(test)] |
| @@ -261,7 +320,7 @@ mod tests { | ||
| 261 | 320 | .init_resource::<ButtonInput<KeyCode>>() |
| 262 | 321 | .insert_resource(cursor) |
| 263 | 322 | .insert_resource(selection) |
| 264 | - .add_message::<MoveOrder>() | |
| 323 | + .add_message::<Order>() | |
| 265 | 324 | .add_systems(Update, (update_selection, issue_move_orders)); |
| 266 | 325 | app |
| 267 | 326 | } |
| @@ -294,9 +353,9 @@ mod tests { | ||
| 294 | 353 | |
| 295 | 354 | fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> { |
| 296 | 355 | app.world_mut() |
| 297 | - .resource_mut::<Messages<MoveOrder>>() | |
| 356 | + .resource_mut::<Messages<Order>>() | |
| 298 | 357 | .drain() |
| 299 | - .map(|o| (o.colonist, o.goal)) | |
| 358 | + .map(|o| (o.colonist, o.tile)) | |
| 300 | 359 | .collect() |
| 301 | 360 | } |
| 302 | 361 | |
| @@ -404,13 +463,29 @@ mod tests { | ||
| 404 | 463 | #[test] |
| 405 | 464 | fn describe_colonist_shows_activity() { |
| 406 | 465 | let ada = Colonist { name: "Ada".into() }; |
| 466 | + let tile = UVec2::new(1, 2); | |
| 407 | 467 | assert_eq!( |
| 408 | - describe_colonist(&ada, UVec2::new(1, 2), 0), | |
| 468 | + describe_colonist(&ada, tile, None, 0), | |
| 409 | 469 | "Ada at (1, 2), idle" |
| 410 | 470 | ); |
| 471 | + let ordered_move = Job { | |
| 472 | + task: Task::MoveTo(UVec2::ZERO), | |
| 473 | + ordered: true, | |
| 474 | + }; | |
| 475 | + assert_eq!( | |
| 476 | + describe_colonist(&ada, tile, Some(ordered_move), 3), | |
| 477 | + "Ada at (1, 2), moving, 3 steps left (ordered)" | |
| 478 | + ); | |
| 479 | + let chop = Job { | |
| 480 | + task: Task::Chop { | |
| 481 | + tree: UVec2::ZERO, | |
| 482 | + progress: CHOP_TICKS / 2, | |
| 483 | + }, | |
| 484 | + ordered: false, | |
| 485 | + }; | |
| 411 | 486 | assert_eq!( |
| 412 | - describe_colonist(&ada, UVec2::new(1, 2), 3), | |
| 413 | - "Ada at (1, 2), moving, 3 steps left" | |
| 487 | + describe_colonist(&ada, tile, Some(chop), 0), | |
| 488 | + "Ada at (1, 2), chopping a tree, 50%" | |
| 414 | 489 | ); |
| 415 | 490 | } |
| 416 | 491 | } |
src/sim.rs +15 -0
| @@ -12,6 +12,10 @@ impl Plugin for SimPlugin { | ||
| 12 | 12 | app.insert_resource(Time::<Fixed>::from_hz(TICKS_PER_SECOND)) |
| 13 | 13 | .init_resource::<SimTick>() |
| 14 | 14 | .init_resource::<SimSpeed>() |
| 15 | + .configure_sets( | |
| 16 | + FixedUpdate, | |
| 17 | + (SimSystems::Decide, SimSystems::Act, SimSystems::Move).chain(), | |
| 18 | + ) | |
| 15 | 19 | .add_systems(Update, speed_controls) |
| 16 | 20 | .add_systems(FixedFirst, (advance_tick, remember_positions)) |
| 17 | 21 | .add_systems( |
| @@ -21,6 +25,13 @@ impl Plugin for SimPlugin { | ||
| 21 | 25 | } |
| 22 | 26 | } |
| 23 | 27 | |
| 28 | +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 29 | +pub enum SimSystems { | |
| 30 | + Decide, | |
| 31 | + Act, | |
| 32 | + Move, | |
| 33 | +} | |
| 34 | + | |
| 24 | 35 | #[derive(Component, Default, Debug, Clone, Copy, PartialEq)] |
| 25 | 36 | #[require(PrevSimPos)] |
| 26 | 37 | pub struct SimPos(pub Vec2); |
| @@ -155,6 +166,10 @@ mod tests { | ||
| 155 | 166 | app.init_resource::<ButtonInput<KeyCode>>() |
| 156 | 167 | .init_resource::<Time<Virtual>>() |
| 157 | 168 | .init_resource::<SimSpeed>() |
| 169 | + .configure_sets( | |
| 170 | + FixedUpdate, | |
| 171 | + (SimSystems::Decide, SimSystems::Act, SimSystems::Move).chain(), | |
| 172 | + ) | |
| 158 | 173 | .add_systems(Update, speed_controls); |
| 159 | 174 | app |
| 160 | 175 | } |
src/tilemap.rs +34 -3
| @@ -6,7 +6,7 @@ use bevy::{ | ||
| 6 | 6 | }; |
| 7 | 7 | |
| 8 | 8 | use crate::{ |
| 9 | - world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, WorldMap, hash2}, | |
| 9 | + world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, TileChanged, WorldMap, hash2}, | |
| 10 | 10 | worldgen::WorldGenerated, |
| 11 | 11 | }; |
| 12 | 12 | |
| @@ -21,8 +21,13 @@ pub struct TilemapPlugin; | ||
| 21 | 21 | |
| 22 | 22 | impl Plugin for TilemapPlugin { |
| 23 | 23 | fn build(&self, app: &mut App) { |
| 24 | - app.add_systems(Startup, spawn_chunks) | |
| 25 | - .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>)); | |
| 24 | + app.add_systems(Startup, spawn_chunks).add_systems( | |
| 25 | + Update, | |
| 26 | + ( | |
| 27 | + refresh_chunks.run_if(on_message::<WorldGenerated>), | |
| 28 | + refresh_changed_chunks, | |
| 29 | + ), | |
| 30 | + ); | |
| 26 | 31 | } |
| 27 | 32 | } |
| 28 | 33 | |
| @@ -90,6 +95,26 @@ fn refresh_chunks(map: Res<WorldMap>, mut chunks: Query<(&Chunk, &mut TilemapChu | ||
| 90 | 95 | } |
| 91 | 96 | } |
| 92 | 97 | |
| 98 | +fn refresh_changed_chunks( | |
| 99 | + map: Res<WorldMap>, | |
| 100 | + mut changes: MessageReader<TileChanged>, | |
| 101 | + mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>, | |
| 102 | +) { | |
| 103 | + let origins: Vec<UVec2> = changes | |
| 104 | + .read() | |
| 105 | + .map(|TileChanged(tile)| chunk_origin(*tile)) | |
| 106 | + .collect(); | |
| 107 | + for (chunk, mut data) in &mut chunks { | |
| 108 | + if origins.contains(&chunk.origin) { | |
| 109 | + data.0 = chunk.layer.tiles(&map, chunk.origin); | |
| 110 | + } | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +pub fn chunk_origin(tile: UVec2) -> UVec2 { | |
| 115 | + tile / CHUNK_SIZE * CHUNK_SIZE | |
| 116 | +} | |
| 117 | + | |
| 93 | 118 | fn chunk_positions(origin: UVec2) -> impl Iterator<Item = UVec2> { |
| 94 | 119 | (0..CHUNK_SIZE).flat_map(move |y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y))) |
| 95 | 120 | } |
| @@ -273,6 +298,12 @@ mod tests { | ||
| 273 | 298 | } |
| 274 | 299 | |
| 275 | 300 | #[test] |
| 301 | + fn chunk_origin_rounds_down_to_chunk_grid() { | |
| 302 | + assert_eq!(chunk_origin(UVec2::new(0, 63)), UVec2::new(0, 0)); | |
| 303 | + assert_eq!(chunk_origin(UVec2::new(64, 130)), UVec2::new(64, 128)); | |
| 304 | + } | |
| 305 | + | |
| 306 | + #[test] | |
| 276 | 307 | fn tileset_has_a_layer_for_every_texture() { |
| 277 | 308 | let textures = Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(); |
| 278 | 309 | let image = stack_tileset(textures); |
src/world.rs +6 -0
| @@ -88,6 +88,12 @@ impl Plant { | ||
| 88 | 88 | } |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | +#[derive(Component, Default, Debug, Clone, Copy, PartialEq)] | |
| 92 | +pub struct TilePos(pub UVec2); | |
| 93 | + | |
| 94 | +#[derive(Message, Debug, Clone, Copy, PartialEq)] | |
| 95 | +pub struct TileChanged(pub UVec2); | |
| 96 | + | |
| 91 | 97 | #[derive(Resource, Clone, Debug)] |
| 92 | 98 | pub struct WorldMap { |
| 93 | 99 | pub size: UVec2, |
tasks.md +5 -1
| @@ -12,10 +12,14 @@ | ||
| 12 | 12 | - [x] Procedural world generation (noise elevation/fertility, seeds, R to regenerate, spawn in largest walkable region) |
| 13 | 13 | - [x] Heightmap: hill shading, uphill movement cost, height in tile info |
| 14 | 14 | - [x] Rivers (flow field), iron/gold ore veins, trees |
| 15 | +- [x] Job system: autonomous job choice from colony demand, reservations, manual orders override | |
| 16 | +- [x] Woodcutting (auto when wood < goal, or right-click a tree), wood item piles | |
| 15 | 17 | |
| 16 | 18 | ## Planned |
| 17 | 19 | - [ ] Speed/pause buttons in UI |
| 18 | 20 | - [ ] Ignore map clicks over UI |
| 19 | -- [ ] Jobs: mining (stone/ore), woodcutting, building, hauling | |
| 21 | +- [ ] Jobs: mining (stone/ore), building, hauling (wood to stockpile) | |
| 22 | +- [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities | |
| 23 | +- [ ] UI to edit colony goals (e.g. wood target) | |
| 20 | 24 | - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky) |
| 21 | 25 | - [ ] Basic UI (tile info, colonist panel) |