Add stockpile zones and hauling job

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 09:25 UTC
commit
c5927789b130d14394df14ab7ecdc6b9f64aec80
parent
ad48ec64ef
tree
browse at this commit

9 files changed +907 -93

CLAUDE.md +5 -3

@@ -17,11 +17,12 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
17 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.
18 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).
19 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.
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`.
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 + stored, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). 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 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 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.
23 +- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Chop{tree, progress}, Haul{pickup: Option<tile>, to}}`. `Reservations` keyed by `Target::{Tree, Pickup, Storage}(tile)` → entity, so colonists never share a target; cleared on WorldGenerated. `ColonyGoals{wood}` = demand. `choose_jobs` (SimSystems::Decide), for idle colonists (no job, empty path, IdleTimer done), in priority order: deliver what they're carrying (or drop it if no storage) → chop if `wood_wanted` (stock incl. carried + trees being chopped × WOOD_PER_TREE) → haul nearest loose item (not on a stockpile) to nearest stockpile tile with room (`find_haul`/`find_storage`) → wander. `run_jobs` (SimSystems::Act) per-task state machine returning `Step::{Continue, Done}`; Done releases all reservations and restarts the idle timer (short after work, 10 s after an ordered move). Chop walks adjacent, ticks to CHOP_TICKS, removes tree, `TileChanged`, drops wood. Haul walks onto the pickup tile, `take_items` up to min(CARRY_CAPACITY, room at dest), walks to dest, drops what fits (remainder stays in `Carrying` and gets delivered by the next choice). `Order{colonist, tile}` (Update, applies while paused): tree → ordered Chop; loose item → ordered Haul; else ordered MoveTo. Orders cancel the job but keep `Carrying`. Adding a job = Task variant + reserve_task arm + demand check in choose_jobs + run_jobs arm.
24 +- `src/items.rs` `Item{kind, count}` + `TilePos` entities (Mesh2d rect, z=0.7), one stack per tile, `STACK_LIMIT`=50; `ItemQuery` alias; `drop_items` stacks, `take_items`, `room_for(tile, kind)` (0 if other kind present), `stock()`. `Carrying(Option<Item>)` on colonists, shown via hidden/visible `CarriedVisual` child. Items cleared on WorldGenerated.
25 +- `src/zones.rs` `Zones` resource (stockpile tile set; only walkable dry land). New worlds get a 4x4 starting stockpile next to spawn. `ZoneTool` (Z toggles, Esc exits): left-drag adds, shift-drag removes (`zone_tool` input + `draw_zone_preview` gizmo, PostUpdate after `update_cursor`); `update_selection` is skipped while the tool is active. Rendered as tilemap `Layer::Zones` (z=0.3), refreshed on `resource_changed::<Zones>`.
25 26 - `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.
26 27 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
27 28
@@ -33,6 +34,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
33 34 ## Build / run
34 35 - Native: `cargo run`. Tests: `cargo test`.
35 36 - 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).
37 +- Shift-held drags can't be driven in the browser pane (computer-tool modifiers / synthetic Shift keydown don't reach Bevy as a held key during a real drag); test modifier logic headlessly.
36 38 - 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.
37 39 - Browser pane screenshots can lag 1-2s behind the canvas (verified: rAF gaps ≤51ms while screenshot still showed old frame). Wait ≥3s after an action, or cross-check via hover text before concluding a render bug.
38 40 - 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.

src/colonist.rs +4 -1

@@ -3,6 +3,7 @@ use std::collections::VecDeque;
3 3 use bevy::prelude::*;
4 4
5 5 use crate::{
6 + items::{Carrying, ItemAssets},
6 7 jobs::{CurrentJob, IdleTimer},
7 8 pathfinding::find_path,
8 9 sim::{SimPos, SimSystems, sim_pos},
@@ -28,7 +29,7 @@ const STARTING_COLONISTS: [(&str, Color); 3] = [
28 29 ];
29 30
30 31 #[derive(Component, Debug)]
31 -#[require(Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer)]
32 +#[require(Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying)]
32 33 pub struct Colonist {
33 34 pub name: String,
34 35 }
@@ -40,6 +41,7 @@ fn spawn_colonists(
40 41 mut commands: Commands,
41 42 map: Res<WorldMap>,
42 43 existing: Query<Entity, With<Colonist>>,
44 + item_assets: Res<ItemAssets>,
43 45 mut meshes: ResMut<Assets<Mesh>>,
44 46 mut materials: ResMut<Assets<ColorMaterial>>,
45 47 ) {
@@ -57,6 +59,7 @@ fn spawn_colonists(
57 59 MeshMaterial2d(materials.add(color)),
58 60 sim_pos(WorldMap::tile_center(tile)),
59 61 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
62 + children![item_assets.carried_visual()],
60 63 ));
61 64 }
62 65 }

src/items.rs +120 -6

@@ -9,8 +9,13 @@ pub struct ItemsPlugin;
9 9
10 10 impl Plugin for ItemsPlugin {
11 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>));
12 + app.add_systems(Startup, create_item_assets).add_systems(
13 + Update,
14 + (
15 + clear_items.run_if(on_message::<WorldGenerated>),
16 + show_carried,
17 + ),
18 + );
14 19 }
15 20 }
16 21
@@ -36,6 +41,13 @@ pub struct Item {
36 41 pub count: u32,
37 42 }
38 43
44 +#[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
45 +pub struct Carrying(pub Option<Item>);
46 +
47 +#[derive(Component)]
48 +#[require(Transform, Visibility)]
49 +pub struct CarriedVisual;
50 +
39 51 #[derive(Resource, Default)]
40 52 pub struct ItemAssets {
41 53 mesh: Handle<Mesh>,
@@ -43,6 +55,16 @@ pub struct ItemAssets {
43 55 }
44 56
45 57 impl ItemAssets {
58 + pub fn carried_visual(&self) -> impl Bundle {
59 + (
60 + CarriedVisual,
61 + Mesh2d(self.mesh.clone()),
62 + MeshMaterial2d(self.material(ItemKind::Wood)),
63 + Transform::from_xyz(0.0, TILE_SIZE * 0.3, 0.1).with_scale(Vec3::splat(0.7)),
64 + Visibility::Hidden,
65 + )
66 + }
67 +
46 68 fn material(&self, kind: ItemKind) -> Handle<ColorMaterial> {
47 69 self.materials
48 70 .iter()
@@ -66,23 +88,45 @@ fn create_item_assets(
66 88 });
67 89 }
68 90
91 +fn show_carried(
92 + colonists: Query<(&Carrying, &Children), Changed<Carrying>>,
93 + mut visuals: Query<&mut Visibility, With<CarriedVisual>>,
94 +) {
95 + for (carrying, children) in &colonists {
96 + for &child in children {
97 + if let Ok(mut visibility) = visuals.get_mut(child) {
98 + *visibility = if carrying.0.is_some() {
99 + Visibility::Inherited
100 + } else {
101 + Visibility::Hidden
102 + };
103 + }
104 + }
105 + }
106 +}
107 +
69 108 fn clear_items(mut commands: Commands, items: Query<Entity, With<Item>>) {
70 109 for entity in &items {
71 110 commands.entity(entity).despawn();
72 111 }
73 112 }
74 113
114 +pub const STACK_LIMIT: u32 = 50;
115 +pub const CARRY_CAPACITY: u32 = 25;
116 +
117 +pub type ItemQuery<'w, 's> = Query<'w, 's, (Entity, &'static mut Item, &'static TilePos)>;
118 +
75 119 pub fn drop_items(
76 120 commands: &mut Commands,
77 - items: &mut Query<(&mut Item, &TilePos)>,
121 + items: &mut ItemQuery,
78 122 assets: &ItemAssets,
79 123 tile: UVec2,
80 124 kind: ItemKind,
81 125 count: u32,
82 126 ) {
83 - if let Some((mut item, _)) = items
127 + if let Some((_, mut item, _)) = items
84 128 .iter_mut()
85 - .find(|(item, pos)| pos.0 == tile && item.kind == kind)
129 + .find(|(_, item, pos)| pos.0 == tile && item.kind == kind)
86 130 {
87 131 item.count += count;
88 132 return;
@@ -97,6 +141,39 @@ pub fn drop_items(
97 141 ));
98 142 }
99 143
144 +pub fn take_items(
145 + commands: &mut Commands,
146 + items: &mut ItemQuery,
147 + tile: UVec2,
148 + max: u32,
149 +) -> Option<Item> {
150 + let (entity, mut item, _) = items.iter_mut().find(|(_, _, pos)| pos.0 == tile)?;
151 + let taken = item.count.min(max);
152 + item.count -= taken;
153 + if item.count == 0 {
154 + commands.entity(entity).despawn();
155 + }
156 + Some(Item {
157 + kind: item.kind,
158 + count: taken,
159 + })
160 +}
161 +
162 +pub fn room_for<'a>(
163 + items: impl Iterator<Item = (&'a Item, &'a TilePos)>,
164 + tile: UVec2,
165 + kind: ItemKind,
166 +) -> u32 {
167 + let mut room = STACK_LIMIT;
168 + for (item, _) in items.filter(|(_, pos)| pos.0 == tile) {
169 + if item.kind != kind {
170 + return 0;
171 + }
172 + room = room.saturating_sub(item.count);
173 + }
174 + room
175 +}
176 +
100 177 pub fn stock<'a>(items: impl Iterator<Item = &'a Item>, kind: ItemKind) -> u32 {
101 178 items.filter(|i| i.kind == kind).map(|i| i.count).sum()
102 179 }
@@ -105,7 +182,7 @@ pub fn stock<'a>(items: impl Iterator<Item = &'a Item>, kind: ItemKind) -> u32 {
105 182 mod tests {
106 183 use super::*;
107 184
108 - fn drop_system(mut commands: Commands, mut items: Query<(&mut Item, &TilePos)>) {
185 + fn drop_system(mut commands: Commands, mut items: ItemQuery) {
109 186 let assets = ItemAssets::default();
110 187 drop_items(
111 188 &mut commands,
@@ -138,6 +215,43 @@ mod tests {
138 215 );
139 216 }
140 217
218 + fn take_system(mut commands: Commands, mut items: ItemQuery) {
219 + take_items(&mut commands, &mut items, UVec2::ONE, 4);
220 + }
221 +
222 + fn wood(count: u32) -> Item {
223 + Item {
224 + kind: ItemKind::Wood,
225 + count,
226 + }
227 + }
228 +
229 + #[test]
230 + fn taking_removes_up_to_max_and_despawns_empty_stacks() {
231 + let mut app = App::new();
232 + let stack = app.world_mut().spawn((wood(6), TilePos(UVec2::ONE))).id();
233 + app.add_systems(Update, take_system);
234 + app.update();
235 + assert_eq!(app.world().get::<Item>(stack), Some(&wood(2)));
236 + app.update();
237 + assert!(app.world().get_entity(stack).is_err());
238 + }
239 +
240 + #[test]
241 + fn room_for_respects_stack_limit_and_other_kinds() {
242 + let tile = UVec2::ONE;
243 + let pos = TilePos(tile);
244 + assert_eq!(
245 + room_for(std::iter::empty(), tile, ItemKind::Wood),
246 + STACK_LIMIT
247 + );
248 + let partial = [(wood(STACK_LIMIT - 5), pos)];
249 + assert_eq!(
250 + room_for(partial.iter().map(|(i, p)| (i, p)), tile, ItemKind::Wood),
251 + 5
252 + );
253 + }
254 +
141 255 #[test]
142 256 fn stock_sums_counts_of_one_kind() {
143 257 let items = [

src/jobs.rs +411 -62

@@ -1,15 +1,23 @@
1 -use std::{collections::HashMap, ops::Range, time::Duration};
1 +use std::{
2 + collections::{HashMap, HashSet},
3 + ops::Range,
4 + time::Duration,
5 +};
2 6
3 7 use bevy::prelude::*;
4 8
5 9 use crate::{
6 10 colonist::{Colonist, Path, repath},
7 - items::{Item, ItemAssets, ItemKind, drop_items, stock},
11 + items::{
12 + CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for,
13 + stock, take_items,
14 + },
8 15 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile},
9 16 rng::SimRng,
10 17 sim::SimSystems,
11 18 world::{Plant, TileChanged, TilePos, WorldMap},
12 19 worldgen::WorldGenerated,
20 + zones::Zones,
13 21 };
14 22
15 23 pub struct JobsPlugin;
@@ -35,10 +43,11 @@ impl Plugin for JobsPlugin {
35 43
36 44 pub const CHOP_TICKS: u32 = 240;
37 45 pub const WOOD_PER_TREE: u32 = 10;
38 -const TREE_SEARCH_TILES: usize = 6000;
46 +const SEARCH_TILES: usize = 6000;
39 47 const WANDER_RADIUS: i32 = 8;
40 48 const IDLE_SECS: Range<f32> = 1.0..4.0;
41 49 const IDLE_AFTER_ORDER_SECS: f32 = 10.0;
50 +const WORK_PAUSE_SECS: Range<f32> = 0.2..0.8;
42 51
43 52 #[derive(Component, Default, Debug, Clone, PartialEq)]
44 53 pub struct CurrentJob(pub Option<Job>);
@@ -53,6 +62,7 @@ pub struct Job {
53 62 pub enum Task {
54 63 MoveTo(UVec2),
55 64 Chop { tree: UVec2, progress: u32 },
65 + Haul { pickup: Option<UVec2>, to: UVec2 },
56 66 }
57 67
58 68 #[derive(Component, Debug)]
@@ -71,16 +81,31 @@ impl IdleTimer {
71 81 }
72 82 }
73 83
84 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85 +pub enum Target {
86 + Tree(UVec2),
87 + Pickup(UVec2),
88 + Storage(UVec2),
89 +}
90 +
74 91 #[derive(Resource, Default, Debug)]
75 -pub struct Reservations(HashMap<UVec2, Entity>);
92 +pub struct Reservations(HashMap<Target, Entity>);
76 93
77 94 impl Reservations {
78 - pub fn reserve(&mut self, tile: UVec2, by: Entity) {
79 - self.0.insert(tile, by);
95 + pub fn reserve(&mut self, target: Target, by: Entity) {
96 + self.0.insert(target, by);
97 + }
98 +
99 + pub fn holder(&self, target: Target) -> Option<Entity> {
100 + self.0.get(&target).copied()
101 + }
102 +
103 + pub fn is_free(&self, target: Target) -> bool {
104 + self.holder(target).is_none()
80 105 }
81 106
82 - pub fn holder(&self, tile: UVec2) -> Option<Entity> {
83 - self.0.get(&tile).copied()
107 + pub fn release(&mut self, target: Target) {
108 + self.0.remove(&target);
84 109 }
85 110
86 111 pub fn release_all(&mut self, by: Entity) {
@@ -112,6 +137,8 @@ fn clear_reservations(mut reservations: ResMut<Reservations>) {
112 137 fn handle_orders(
113 138 mut orders: MessageReader<Order>,
114 139 map: Res<WorldMap>,
140 + zones: Res<Zones>,
141 + items: Query<(&Item, &TilePos)>,
115 142 mut reservations: ResMut<Reservations>,
116 143 mut colonists: Query<(&TilePos, &mut Path, &mut CurrentJob)>,
117 144 ) {
@@ -119,15 +146,30 @@ fn handle_orders(
119 146 let Ok((tile, mut path, mut job)) = colonists.get_mut(order.colonist) else {
120 147 continue;
121 148 };
122 - if map.plant(order.tile.as_ivec2()) == Some(Plant::Tree) {
149 + let loose_item = items
150 + .iter()
151 + .find(|(_, pos)| pos.0 == order.tile && !zones.is_stockpile(pos.0));
152 + let task = if map.plant(order.tile.as_ivec2()) == Some(Plant::Tree) {
153 + Some(Task::Chop {
154 + tree: order.tile,
155 + progress: 0,
156 + })
157 + } else if let Some((item, _)) = loose_item {
158 + find_storage(&map, &zones, &reservations, &items, order.tile, item.kind).map(|to| {
159 + Task::Haul {
160 + pickup: Some(order.tile),
161 + to,
162 + }
163 + })
164 + } else {
165 + None
166 + };
167 + if let Some(task) = task {
123 168 reservations.release_all(order.colonist);
124 - reservations.reserve(order.tile, order.colonist);
169 + reserve_task(&mut reservations, task, order.colonist);
125 170 path.0.truncate(1);
126 171 job.0 = Some(Job {
127 - task: Task::Chop {
128 - tree: order.tile,
129 - progress: 0,
130 - },
172 + task,
131 173 ordered: true,
132 174 });
133 175 } else if let Some(new_path) = repath(&map, tile.0, &path.0, order.tile) {
@@ -141,41 +183,104 @@ fn handle_orders(
141 183 }
142 184 }
143 185
186 +fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
187 + match task {
188 + Task::MoveTo(_) => {}
189 + Task::Chop { tree, .. } => reservations.reserve(Target::Tree(tree), by),
190 + Task::Haul { pickup, to } => {
191 + if let Some(pickup) = pickup {
192 + reservations.reserve(Target::Pickup(pickup), by);
193 + }
194 + reservations.reserve(Target::Storage(to), by);
195 + }
196 + }
197 +}
198 +
144 199 pub fn wood_wanted(goals: &ColonyGoals, in_stock: u32, trees_being_chopped: u32) -> bool {
145 200 in_stock + trees_being_chopped * WOOD_PER_TREE < goals.wood
146 201 }
147 202
148 -pub fn find_tree(
203 +pub fn find_tree(map: &WorldMap, reservations: &Reservations, from: UVec2) -> Option<UVec2> {
204 + let tree = nearest_tile(map, from, SEARCH_TILES, |p| {
205 + map.plant(p.as_ivec2()) == Some(Plant::Tree) && reservations.is_free(Target::Tree(p))
206 + })?;
207 + find_path_adjacent(map, from, tree)?;
208 + Some(tree)
209 +}
210 +
211 +pub fn find_storage<'a>(
149 212 map: &WorldMap,
213 + zones: &Zones,
150 214 reservations: &Reservations,
215 + items: impl IntoIterator<Item = (&'a Item, &'a TilePos)> + Clone,
151 216 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))
217 + kind: ItemKind,
218 +) -> Option<UVec2> {
219 + nearest_tile(map, from, SEARCH_TILES, |p| {
220 + zones.is_stockpile(p)
221 + && reservations.is_free(Target::Storage(p))
222 + && room_for(items.clone().into_iter(), p, kind) > 0
223 + })
224 +}
225 +
226 +pub fn find_haul(
227 + map: &WorldMap,
228 + zones: &Zones,
229 + reservations: &Reservations,
230 + items: &[(Item, TilePos)],
231 + from: UVec2,
232 +) -> Option<Task> {
233 + let loose: HashSet<UVec2> = items
234 + .iter()
235 + .map(|(_, pos)| pos.0)
236 + .filter(|&p| !zones.is_stockpile(p) && reservations.is_free(Target::Pickup(p)))
237 + .collect();
238 + if loose.is_empty() {
239 + return None;
240 + }
241 + let pickup = nearest_tile(map, from, SEARCH_TILES, |p| loose.contains(&p))?;
242 + let (item, _) = items.iter().find(|(_, pos)| pos.0 == pickup)?;
243 + let refs = items.iter().map(|(i, p)| (i, p));
244 + let to = find_storage(map, zones, reservations, refs, pickup, item.kind)?;
245 + Some(Task::Haul {
246 + pickup: Some(pickup),
247 + to,
248 + })
158 249 }
159 250
160 251 #[allow(clippy::too_many_arguments)]
161 252 fn choose_jobs(
253 + mut commands: Commands,
162 254 time: Res<Time>,
163 255 map: Res<WorldMap>,
256 + zones: Res<Zones>,
164 257 goals: Res<ColonyGoals>,
258 + item_assets: Res<ItemAssets>,
165 259 mut rng: ResMut<SimRng>,
166 260 mut reservations: ResMut<Reservations>,
167 - items: Query<&Item>,
261 + mut items: ItemQuery,
168 262 mut colonists: Query<
169 - (Entity, &TilePos, &mut Path, &mut CurrentJob, &mut IdleTimer),
263 + (
264 + Entity,
265 + &TilePos,
266 + &mut Path,
267 + &mut CurrentJob,
268 + &mut IdleTimer,
269 + &mut Carrying,
270 + ),
170 271 With<Colonist>,
171 272 >,
172 273 ) {
173 - let wood = stock(items.iter(), ItemKind::Wood);
274 + let carried: Vec<Item> = colonists.iter().filter_map(|c| c.5.0).collect();
275 + let wood = stock(
276 + items.iter().map(|(_, item, _)| item).chain(carried.iter()),
277 + ItemKind::Wood,
278 + );
174 279 let mut chopping = colonists
175 280 .iter()
176 - .filter(|(.., job, _)| {
281 + .filter(|c| {
177 282 matches!(
178 - job.0,
283 + c.3.0,
179 284 Some(Job {
180 285 task: Task::Chop { .. },
181 286 ..
@@ -183,20 +288,41 @@ fn choose_jobs(
183 288 )
184 289 })
185 290 .count() as u32;
186 - for (entity, tile, mut path, mut job, mut idle) in &mut colonists {
291 + for (entity, tile, mut path, mut job, mut idle, mut carrying) in &mut colonists {
187 292 if job.0.is_some() || !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
188 293 continue;
189 294 }
190 - if wood_wanted(&goals, wood, chopping)
191 - && let Some((tree, tree_path)) = find_tree(&map, &reservations, tile.0)
295 + let snapshot: Vec<(Item, TilePos)> =
296 + items.iter().map(|(_, item, pos)| (*item, *pos)).collect();
297 + let refs = snapshot.iter().map(|(i, p)| (i, p));
298 + let task = if let Some(load) = carrying.0 {
299 + let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind);
300 + if storage.is_none() {
301 + drop_items(
302 + &mut commands,
303 + &mut items,
304 + &item_assets,
305 + tile.0,
306 + load.kind,
307 + load.count,
308 + );
309 + carrying.0 = None;
310 + }
311 + storage.map(|to| Task::Haul { pickup: None, to })
312 + } else if wood_wanted(&goals, wood, chopping)
313 + && let Some(tree) = find_tree(&map, &reservations, tile.0)
192 314 {
193 - reservations.reserve(tree, entity);
194 - path.0 = tree_path.into();
315 + chopping += 1;
316 + Some(Task::Chop { tree, progress: 0 })
317 + } else {
318 + find_haul(&map, &zones, &reservations, &snapshot, tile.0)
319 + };
320 + if let Some(task) = task {
321 + reserve_task(&mut reservations, task, entity);
195 322 job.0 = Some(Job {
196 - task: Task::Chop { tree, progress: 0 },
323 + task,
197 324 ordered: false,
198 325 });
199 - chopping += 1;
200 326 continue;
201 327 }
202 328 let goal = tile.0.as_ivec2()
@@ -213,43 +339,68 @@ fn choose_jobs(
213 339 }
214 340 }
215 341
342 +enum Step {
343 + Continue,
344 + Done,
345 +}
346 +
347 +fn walk_to(map: &WorldMap, from: UVec2, to: UVec2, path: &mut Path) -> Step {
348 + match find_path(map, from, to) {
349 + Some(p) if !p.is_empty() => {
350 + path.0 = p.into();
351 + Step::Continue
352 + }
353 + _ => Step::Done,
354 + }
355 +}
356 +
216 357 #[allow(clippy::too_many_arguments)]
217 358 fn run_jobs(
218 359 mut commands: Commands,
219 360 mut map: ResMut<WorldMap>,
361 + zones: Res<Zones>,
220 362 mut reservations: ResMut<Reservations>,
221 363 mut rng: ResMut<SimRng>,
222 364 item_assets: Res<ItemAssets>,
223 - mut items: Query<(&mut Item, &TilePos)>,
365 + mut items: ItemQuery,
224 366 mut changed: MessageWriter<TileChanged>,
225 - mut colonists: Query<(Entity, &TilePos, &mut Path, &mut CurrentJob, &mut IdleTimer)>,
367 + mut colonists: Query<(
368 + Entity,
369 + &TilePos,
370 + &mut Path,
371 + &mut CurrentJob,
372 + &mut IdleTimer,
373 + &mut Carrying,
374 + )>,
226 375 ) {
227 - for (entity, tile, mut path, mut current, mut idle) in &mut colonists {
376 + for (entity, tile, mut path, mut current, mut idle, mut carrying) in &mut colonists {
228 377 let Some(mut job) = current.0 else {
229 378 continue;
230 379 };
231 380 if !path.0.is_empty() {
232 381 continue;
233 382 }
234 - let done = match &mut job.task {
235 - Task::MoveTo(_) => true,
383 + let step = match &mut job.task {
384 + Task::MoveTo(_) => Step::Done,
236 385 Task::Chop { tree, progress } => {
237 386 let tree = *tree;
238 387 let still_ours = map.plant(tree.as_ivec2()) == Some(Plant::Tree)
239 - && reservations.holder(tree) == Some(entity);
388 + && reservations.holder(Target::Tree(tree)) == Some(entity);
240 389 if !still_ours {
241 - true
390 + Step::Done
242 391 } else if !is_adjacent(tile.0.as_ivec2(), tree.as_ivec2()) {
243 392 match find_path_adjacent(&map, tile.0, tree) {
244 393 Some(p) if !p.is_empty() => {
245 394 path.0 = p.into();
246 - false
395 + Step::Continue
247 396 }
248 - _ => true,
397 + _ => Step::Done,
249 398 }
250 399 } else {
251 400 *progress += 1;
252 - if *progress >= CHOP_TICKS {
401 + if *progress < CHOP_TICKS {
402 + Step::Continue
403 + } else {
253 404 map.set_plant(tree, None);
254 405 changed.write(TileChanged(tree));
255 406 drop_items(
@@ -260,28 +411,86 @@ fn run_jobs(
260 411 ItemKind::Wood,
261 412 WOOD_PER_TREE,
262 413 );
414 + Step::Done
263 415 }
264 - *progress >= CHOP_TICKS
265 416 }
266 417 }
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
418 + Task::Haul { pickup, to } => {
419 + let to = *to;
420 + let destination_valid = zones.is_stockpile(to)
421 + && reservations.holder(Target::Storage(to)) == Some(entity);
422 + if !destination_valid {
423 + Step::Done
424 + } else if let Some(from) = *pickup {
425 + if reservations.holder(Target::Pickup(from)) != Some(entity) {
426 + Step::Done
427 + } else if tile.0 != from {
428 + walk_to(&map, tile.0, from, &mut path)
429 + } else {
430 + let kind = items
431 + .iter()
432 + .find(|(_, _, pos)| pos.0 == from)
433 + .map(|(_, item, _)| item.kind);
434 + let room = kind.map_or(0, |kind| {
435 + room_for(items.iter().map(|(_, i, p)| (i, p)), to, kind)
436 + });
437 + match take_items(&mut commands, &mut items, from, CARRY_CAPACITY.min(room))
438 + .filter(|load| load.count > 0)
439 + {
440 + Some(load) => {
441 + carrying.0 = Some(load);
442 + reservations.release(Target::Pickup(from));
443 + *pickup = None;
444 + Step::Continue
445 + }
446 + None => Step::Done,
447 + }
448 + }
449 + } else if let Some(load) = carrying.0 {
450 + if tile.0 != to {
451 + walk_to(&map, tile.0, to, &mut path)
452 + } else {
453 + let room = room_for(items.iter().map(|(_, i, p)| (i, p)), to, load.kind);
454 + let dropped = load.count.min(room);
455 + if dropped > 0 {
456 + drop_items(
457 + &mut commands,
458 + &mut items,
459 + &item_assets,
460 + to,
461 + load.kind,
462 + dropped,
463 + );
464 + }
465 + carrying.0 = (load.count > dropped).then_some(Item {
466 + count: load.count - dropped,
467 + ..load
468 + });
469 + Step::Done
470 + }
471 + } else {
472 + Step::Done
276 473 }
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);
474 + }
475 + };
476 + match step {
477 + Step::Continue => current.0 = Some(job),
478 + Step::Done => {
479 + reservations.release_all(entity);
480 + current.0 = None;
481 + let ordered_move = matches!(
482 + job,
483 + Job {
484 + task: Task::MoveTo(_),
485 + ordered: true
486 + }
487 + );
488 + idle.restart(if ordered_move {
489 + IDLE_AFTER_ORDER_SECS
490 + } else {
491 + rng.range_f32(WORK_PAUSE_SECS)
492 + });
493 + }
285 494 }
286 495 }
287 496 }
@@ -291,6 +500,7 @@ mod tests {
291 500 use super::*;
292 501 use crate::{
293 502 colonist::follow_path,
503 + items::STACK_LIMIT,
294 504 sim::{SimPos, sim_pos},
295 505 world::Terrain,
296 506 };
@@ -305,6 +515,7 @@ mod tests {
305 515 .init_resource::<Reservations>()
306 516 .init_resource::<ColonyGoals>()
307 517 .init_resource::<ItemAssets>()
518 + .init_resource::<Zones>()
308 519 .add_message::<Order>()
309 520 .add_message::<TileChanged>()
310 521 .add_systems(
@@ -347,6 +558,41 @@ mod tests {
347 558 stock(items.iter(), ItemKind::Wood)
348 559 }
349 560
561 + fn items_at(app: &mut App) -> Vec<(UVec2, u32)> {
562 + let world = app.world_mut();
563 + let mut items: Vec<(UVec2, u32)> = world
564 + .query::<(&Item, &TilePos)>()
565 + .iter(world)
566 + .map(|(item, pos)| (pos.0, item.count))
567 + .collect();
568 + items.sort_by_key(|(p, _)| (p.y, p.x));
569 + items
570 + }
571 +
572 + fn put_wood(app: &mut App, tile: UVec2, count: u32) {
573 + app.world_mut().spawn((
574 + Item {
575 + kind: ItemKind::Wood,
576 + count,
577 + },
578 + TilePos(tile),
579 + ));
580 + }
581 +
582 + fn hauling_app(stockpile: (IVec2, IVec2)) -> App {
583 + let mut app = test_app(map_with_trees(&[]));
584 + app.world_mut().resource_mut::<ColonyGoals>().wood = 0;
585 + let map = app.world().resource::<WorldMap>().clone();
586 + app.world_mut()
587 + .resource_mut::<Zones>()
588 + .add_stockpile(&map, stockpile.0, stockpile.1);
589 + app
590 + }
591 +
592 + fn carrying(app: &App, colonist: Entity) -> Option<Item> {
593 + app.world().get::<Carrying>(colonist).unwrap().0
594 + }
595 +
350 596 fn job(app: &App, colonist: Entity) -> Option<Job> {
351 597 app.world().get::<CurrentJob>(colonist).unwrap().0
352 598 }
@@ -444,7 +690,110 @@ mod tests {
444 690 job(&app, colonist).map(|j| j.task),
445 691 Some(Task::MoveTo(UVec2::new(2, 15)))
446 692 );
447 - assert_eq!(app.world().resource::<Reservations>().holder(tree), None);
693 + assert!(
694 + app.world()
695 + .resource::<Reservations>()
696 + .is_free(Target::Tree(tree))
697 + );
698 + }
699 +
700 + #[test]
701 + fn idle_colonists_haul_loose_items_into_the_stockpile() {
702 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(15, 15)));
703 + put_wood(&mut app, UVec2::new(5, 5), 20);
704 + spawn_colonist(&mut app, UVec2::new(2, 5));
705 + run_ticks(&mut app, 60 * 15);
706 + assert_eq!(items_at(&mut app), vec![(UVec2::new(15, 15), 20)]);
707 + }
708 +
709 + #[test]
710 + fn large_stacks_take_several_trips_and_spill_into_more_stockpile_tiles() {
711 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(16, 15)));
712 + put_wood(&mut app, UVec2::new(5, 5), STACK_LIMIT + 10);
713 + spawn_colonist(&mut app, UVec2::new(2, 5));
714 + run_ticks(&mut app, 60 * 40);
715 + assert_eq!(
716 + items_at(&mut app),
717 + vec![(UVec2::new(15, 15), STACK_LIMIT), (UVec2::new(16, 15), 10)]
718 + );
719 + }
720 +
721 + #[test]
722 + fn items_already_in_a_stockpile_stay_put() {
723 + let mut app = hauling_app((IVec2::new(5, 5), IVec2::new(16, 16)));
724 + put_wood(&mut app, UVec2::new(5, 5), 20);
725 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
726 + run_ticks(&mut app, 60 * 5);
727 + assert_eq!(items_at(&mut app), vec![(UVec2::new(5, 5), 20)]);
728 + assert_eq!(carrying(&app, colonist), None);
729 + }
730 +
731 + #[test]
732 + fn without_a_stockpile_nothing_is_hauled() {
733 + let mut app = test_app(map_with_trees(&[]));
734 + app.world_mut().resource_mut::<ColonyGoals>().wood = 0;
735 + put_wood(&mut app, UVec2::new(5, 5), 20);
736 + spawn_colonist(&mut app, UVec2::new(2, 5));
737 + run_ticks(&mut app, 60 * 5);
738 + assert_eq!(items_at(&mut app), vec![(UVec2::new(5, 5), 20)]);
739 + }
740 +
741 + #[test]
742 + fn two_colonists_never_haul_the_same_stack() {
743 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(16, 16)));
744 + put_wood(&mut app, UVec2::new(5, 5), 10);
745 + put_wood(&mut app, UVec2::new(5, 6), 10);
746 + let a = spawn_colonist(&mut app, UVec2::new(2, 5));
747 + let b = spawn_colonist(&mut app, UVec2::new(2, 6));
748 + run_ticks(&mut app, 61);
749 + let pickup = |c| match job(&app, c).map(|j| j.task) {
750 + Some(Task::Haul { pickup, .. }) => pickup,
751 + other => panic!("expected haul, got {other:?}"),
752 + };
753 + assert_ne!(pickup(a), pickup(b));
754 + }
755 +
756 + // Orders interrupt whatever a colonist is doing, but whatever it is carrying stays in its
757 + // hands; once it is idle again it finishes delivering it instead of dropping it anywhere.
758 + #[test]
759 + fn interrupted_haulers_deliver_their_load_later() {
760 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(15, 15)));
761 + put_wood(&mut app, UVec2::new(4, 5), 20);
762 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
763 + let mut ticks = 0;
764 + while carrying(&app, colonist).is_none() && ticks < 60 * 10 {
765 + run_ticks(&mut app, 1);
766 + ticks += 1;
767 + }
768 + app.world_mut().write_message(Order {
769 + colonist,
770 + tile: UVec2::new(2, 2),
771 + });
772 + run_ticks(&mut app, 60 * 3);
773 + assert!(carrying(&app, colonist).is_some());
774 + run_ticks(&mut app, 60 * 20);
775 + assert_eq!(carrying(&app, colonist), None);
776 + assert_eq!(items_at(&mut app), vec![(UVec2::new(15, 15), 20)]);
777 + }
778 +
779 + #[test]
780 + fn right_clicking_loose_items_orders_a_haul() {
781 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(15, 15)));
782 + app.world_mut().resource_mut::<ColonyGoals>().wood = 1000;
783 + put_wood(&mut app, UVec2::new(5, 5), 20);
784 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
785 + app.world_mut().write_message(Order {
786 + colonist,
787 + tile: UVec2::new(5, 5),
788 + });
789 + run_ticks(&mut app, 1);
790 + assert!(matches!(
791 + job(&app, colonist),
792 + Some(Job {
793 + task: Task::Haul { .. },
794 + ordered: true
795 + })
796 + ));
448 797 }
449 798
450 799 #[test]

src/main.rs +2 -0

@@ -10,6 +10,7 @@ mod sim;
10 10 mod tilemap;
11 11 mod world;
12 12 mod worldgen;
13 +mod zones;
13 14
14 15 use bevy::prelude::*;
15 16
@@ -38,6 +39,7 @@ fn main() {
38 39 items::ItemsPlugin,
39 40 jobs::JobsPlugin,
40 41 selection::SelectionPlugin,
42 + zones::ZonesPlugin,
41 43 ))
42 44 .run();
43 45 }

src/selection.rs +61 -12

@@ -3,11 +3,12 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
3 3 use crate::{
4 4 camera::CameraController,
5 5 colonist::{Colonist, Path},
6 - items::{Item, ItemKind, stock},
6 + items::{Carrying, Item, ItemKind, stock},
7 7 jobs::{CHOP_TICKS, ColonyGoals, CurrentJob, Job, Order, Task},
8 8 sim::{SimSpeed, SimTick, format_clock},
9 9 world::{TILE_SIZE, TilePos, WorldMap},
10 10 worldgen::{WorldGenerated, WorldSeed},
11 + zones::{ZoneTool, Zones},
11 12 };
12 13
13 14 pub struct SelectionPlugin;
@@ -22,7 +23,7 @@ impl Plugin for SelectionPlugin {
22 23 PostUpdate,
23 24 (
24 25 update_cursor,
25 - update_selection,
26 + update_selection.run_if(|tool: Res<ZoneTool>| !tool.active),
26 27 issue_move_orders,
27 28 draw_highlights,
28 29 update_tile_info,
@@ -59,7 +60,7 @@ const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
59 60 const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
60 61 const CLICK_SLOP_PX: f32 = 4.0;
61 62
62 -fn update_cursor(
63 +pub fn update_cursor(
63 64 map: Res<WorldMap>,
64 65 window: Single<&Window, With<PrimaryWindow>>,
65 66 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
@@ -223,9 +224,11 @@ fn update_tile_info(
223 224 map: Res<WorldMap>,
224 225 cursor: Res<Cursor>,
225 226 selection: Res<Selection>,
226 - colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob)>,
227 + colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying)>,
227 228 items: Query<(&Item, &TilePos)>,
228 229 goals: Res<ColonyGoals>,
230 + zones: Res<Zones>,
231 + tool: Res<ZoneTool>,
229 232 time: Res<Time<Virtual>>,
230 233 tick: Res<SimTick>,
231 234 speed: Res<SimSpeed>,
@@ -237,21 +240,41 @@ fn update_tile_info(
237 240 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
238 241 Selection::Colonist(entity) => colonists
239 242 .get(entity)
240 - .map(|(colonist, tile, path, job)| {
241 - describe_colonist(colonist, tile.0, job.0, path.0.len())
243 + .map(|(colonist, tile, path, job, carrying)| {
244 + describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0)
242 245 })
243 246 .unwrap_or_else(|_| "-".into()),
244 247 };
245 248 let clock = format_clock(*tick);
246 249 let speed = describe_speed(*speed, time.is_paused());
247 - let wood = stock(items.iter().map(|(item, _)| item), ItemKind::Wood);
250 + let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();
251 + let wood = stock(
252 + items.iter().map(|(item, _)| item).chain(carried.iter()),
253 + ItemKind::Wood,
254 + );
255 + let stored = stock(
256 + items
257 + .iter()
258 + .filter(|(_, pos)| zones.is_stockpile(pos.0))
259 + .map(|(item, _)| item),
260 + ItemKind::Wood,
261 + );
262 + let hovered_zone = match cursor.tile {
263 + Some(tile) if zones.is_stockpile(tile) => ", Stockpile",
264 + _ => "",
265 + };
248 266 let hovered_items: String = items
249 267 .iter()
250 268 .filter(|(_, pos)| Some(pos.0) == cursor.tile)
251 269 .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count))
252 270 .collect();
271 + let tool_hint = if tool.active {
272 + "STOCKPILE TOOL: drag to add, shift-drag to remove, Z/Esc to exit"
273 + } else {
274 + "Z: stockpile tool"
275 + };
253 276 let info = format!(
254 - "{clock} {speed}\nSeed: {} (R: new world)\nWood: {wood} / {}\nHover: {}{hovered_items}\nSelected: {selected}",
277 + "{clock} {speed}\nSeed: {} (R: new world)\nWood: {wood} / {} ({stored} stored)\n{tool_hint}\nHover: {}{hovered_zone}{hovered_items}\nSelected: {selected}",
255 278 seed.0,
256 279 goals.wood,
257 280 describe_tile(&map, cursor.tile)
@@ -288,6 +311,7 @@ pub fn describe_colonist(
288 311 tile: UVec2,
289 312 job: Option<Job>,
290 313 steps_left: usize,
314 + carrying: Option<Item>,
291 315 ) -> String {
292 316 let activity = match (job.map(|j| j.task), steps_left) {
293 317 (None, 0) => "idle".to_string(),
@@ -297,14 +321,24 @@ pub fn describe_colonist(
297 321 (Some(Task::Chop { progress, .. }), _) => {
298 322 format!("chopping a tree, {}%", progress * 100 / CHOP_TICKS)
299 323 }
324 + (
325 + Some(Task::Haul {
326 + pickup: Some(_), ..
327 + }),
328 + _,
329 + ) => "fetching items to haul".to_string(),
330 + (Some(Task::Haul { pickup: None, .. }), _) => "hauling to the stockpile".to_string(),
300 331 };
332 + let load = carrying
333 + .map(|item| format!(", carrying {} {:?}", item.count, item.kind))
334 + .unwrap_or_default();
301 335 let ordered = if job.is_some_and(|j| j.ordered) {
302 336 " (ordered)"
303 337 } else {
304 338 ""
305 339 };
306 340 format!(
307 - "{} at ({}, {}), {activity}{ordered}",
341 + "{} at ({}, {}), {activity}{ordered}{load}",
308 342 colonist.name, tile.x, tile.y
309 343 )
310 344 }
@@ -465,7 +499,7 @@ mod tests {
465 499 let ada = Colonist { name: "Ada".into() };
466 500 let tile = UVec2::new(1, 2);
467 501 assert_eq!(
468 - describe_colonist(&ada, tile, None, 0),
502 + describe_colonist(&ada, tile, None, 0, None),
469 503 "Ada at (1, 2), idle"
470 504 );
471 505 let ordered_move = Job {
@@ -473,7 +507,7 @@ mod tests {
473 507 ordered: true,
474 508 };
475 509 assert_eq!(
476 - describe_colonist(&ada, tile, Some(ordered_move), 3),
510 + describe_colonist(&ada, tile, Some(ordered_move), 3, None),
477 511 "Ada at (1, 2), moving, 3 steps left (ordered)"
478 512 );
479 513 let chop = Job {
@@ -484,8 +518,23 @@ mod tests {
484 518 ordered: false,
485 519 };
486 520 assert_eq!(
487 - describe_colonist(&ada, tile, Some(chop), 0),
521 + describe_colonist(&ada, tile, Some(chop), 0, None),
488 522 "Ada at (1, 2), chopping a tree, 50%"
489 523 );
524 + let haul = Job {
525 + task: Task::Haul {
526 + pickup: None,
527 + to: UVec2::ZERO,
528 + },
529 + ordered: false,
530 + };
531 + let wood = Item {
532 + kind: ItemKind::Wood,
533 + count: 10,
534 + };
535 + assert_eq!(
536 + describe_colonist(&ada, tile, Some(haul), 4, Some(wood)),
537 + "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood"
538 + );
490 539 }
491 540 }

src/tilemap.rs +60 -7

@@ -8,6 +8,7 @@ use bevy::{
8 8 use crate::{
9 9 world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, TileChanged, WorldMap, hash2},
10 10 worldgen::WorldGenerated,
11 + zones::Zones,
11 12 };
12 13
13 14 pub const CHUNK_SIZE: u32 = 64;
@@ -26,6 +27,7 @@ impl Plugin for TilemapPlugin {
26 27 (
27 28 refresh_chunks.run_if(on_message::<WorldGenerated>),
28 29 refresh_changed_chunks,
30 + refresh_zone_chunks.run_if(resource_changed::<Zones>),
29 31 ),
30 32 );
31 33 }
@@ -34,6 +36,7 @@ impl Plugin for TilemapPlugin {
34 36 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
35 37 enum Layer {
36 38 Terrain,
39 + Zones,
37 40 Plants,
38 41 }
39 42
@@ -41,13 +44,15 @@ impl Layer {
41 44 fn z(self) -> f32 {
42 45 match self {
43 46 Layer::Terrain => 0.0,
47 + Layer::Zones => 0.3,
44 48 Layer::Plants => 0.5,
45 49 }
46 50 }
47 51
48 - fn tiles(self, map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
52 + fn tiles(self, map: &WorldMap, zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> {
49 53 match self {
50 54 Layer::Terrain => chunk_tiles(map, origin),
55 + Layer::Zones => zone_tiles(zones, origin),
51 56 Layer::Plants => plant_tiles(map, origin),
52 57 }
53 58 }
@@ -59,15 +64,25 @@ struct Chunk {
59 64 layer: Layer,
60 65 }
61 66
62 -fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<Assets<Image>>) {
67 +fn spawn_chunks(
68 + mut commands: Commands,
69 + map: Res<WorldMap>,
70 + zones: Res<Zones>,
71 + mut images: ResMut<Assets<Image>>,
72 +) {
63 73 let terrain = images.add(stack_tileset(
64 74 Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(),
65 75 ));
66 76 let plants = images.add(stack_tileset(
67 77 Plant::ALL.iter().map(|&p| plant_texture(p)).collect(),
68 78 ));
79 + let zone = images.add(stack_tileset(vec![zone_texture()]));
69 80 let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE;
70 - for (layer, tileset) in [(Layer::Terrain, terrain), (Layer::Plants, plants)] {
81 + for (layer, tileset) in [
82 + (Layer::Terrain, terrain),
83 + (Layer::Zones, zone),
84 + (Layer::Plants, plants),
85 + ] {
71 86 for cy in 0..chunks.y {
72 87 for cx in 0..chunks.x {
73 88 let origin = UVec2::new(cx, cy) * CHUNK_SIZE;
@@ -81,7 +96,7 @@ fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<A
81 96 tileset: tileset.clone(),
82 97 alpha_mode: AlphaMode2d::Blend,
83 98 },
84 - TilemapChunkTileData(layer.tiles(&map, origin)),
99 + TilemapChunkTileData(layer.tiles(&map, &zones, origin)),
85 100 Transform::from_translation(center.extend(layer.z())),
86 101 ));
87 102 }
@@ -89,14 +104,31 @@ fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<A
89 104 }
90 105 }
91 106
92 -fn refresh_chunks(map: Res<WorldMap>, mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>) {
107 +fn refresh_chunks(
108 + map: Res<WorldMap>,
109 + zones: Res<Zones>,
110 + mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
111 +) {
93 112 for (chunk, mut data) in &mut chunks {
94 - data.0 = chunk.layer.tiles(&map, chunk.origin);
113 + data.0 = chunk.layer.tiles(&map, &zones, chunk.origin);
114 + }
115 +}
116 +
117 +fn refresh_zone_chunks(
118 + map: Res<WorldMap>,
119 + zones: Res<Zones>,
120 + mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
121 +) {
122 + for (chunk, mut data) in &mut chunks {
123 + if chunk.layer == Layer::Zones {
124 + data.0 = chunk.layer.tiles(&map, &zones, chunk.origin);
125 + }
95 126 }
96 127 }
97 128
98 129 fn refresh_changed_chunks(
99 130 map: Res<WorldMap>,
131 + zones: Res<Zones>,
100 132 mut changes: MessageReader<TileChanged>,
101 133 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
102 134 ) {
@@ -106,7 +138,7 @@ fn refresh_changed_chunks(
106 138 .collect();
107 139 for (chunk, mut data) in &mut chunks {
108 140 if origins.contains(&chunk.origin) {
109 - data.0 = chunk.layer.tiles(&map, chunk.origin);
141 + data.0 = chunk.layer.tiles(&map, &zones, chunk.origin);
110 142 }
111 143 }
112 144 }
@@ -131,6 +163,16 @@ pub fn chunk_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
131 163 .collect()
132 164 }
133 165
166 +pub fn zone_tiles(zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> {
167 + chunk_positions(origin)
168 + .map(|pos| {
169 + zones
170 + .is_stockpile(pos)
171 + .then(|| TileData::from_tileset_index(0))
172 + })
173 + .collect()
174 +}
175 +
134 176 pub fn plant_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
135 177 chunk_positions(origin)
136 178 .map(|pos| {
@@ -188,6 +230,16 @@ fn terrain_texture(terrain: Terrain) -> Texture {
188 230 .collect()
189 231 }
190 232
233 +fn zone_texture() -> Texture {
234 + (0..TEXTURE_SIZE * TEXTURE_SIZE)
235 + .map(|i| {
236 + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
237 + let edge = x == 0 || y == 0 || x == TEXTURE_SIZE - 1 || y == TEXTURE_SIZE - 1;
238 + [90, 160, 230, if edge { 150 } else { 55 }]
239 + })
240 + .collect()
241 +}
242 +
191 243 fn plant_texture(plant: Plant) -> Texture {
192 244 match plant {
193 245 Plant::Tree => tree_texture(),
@@ -270,6 +322,7 @@ mod tests {
270 322 fn regeneration_rewrites_tile_data_of_existing_chunks() {
271 323 let mut app = App::new();
272 324 app.insert_resource(WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass))
325 + .init_resource::<Zones>()
273 326 .add_message::<WorldGenerated>()
274 327 .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>));
275 328 let map = app.world().resource::<WorldMap>().clone();

src/zones.rs +240 -0

@@ -0,0 +1,240 @@
1 +use std::collections::HashSet;
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + selection::{Cursor, update_cursor},
7 + world::{TILE_SIZE, WorldMap},
8 + worldgen::{WorldGenerated, spawn_tiles},
9 +};
10 +
11 +pub struct ZonesPlugin;
12 +
13 +impl Plugin for ZonesPlugin {
14 + fn build(&self, app: &mut App) {
15 + app.init_resource::<Zones>()
16 + .init_resource::<ZoneTool>()
17 + .add_systems(
18 + Update,
19 + place_starting_stockpile.run_if(on_message::<WorldGenerated>),
20 + )
21 + .add_systems(
22 + PostUpdate,
23 + (zone_tool, draw_zone_preview).chain().after(update_cursor),
24 + );
25 + }
26 +}
27 +
28 +const STARTING_STOCKPILE_OFFSET: IVec2 = IVec2::new(2, -2);
29 +const STARTING_STOCKPILE_SIZE: IVec2 = IVec2::new(4, 4);
30 +const PREVIEW_ADD_COLOR: Color = Color::srgba(0.4, 0.7, 1.0, 0.9);
31 +const PREVIEW_REMOVE_COLOR: Color = Color::srgba(1.0, 0.4, 0.4, 0.9);
32 +
33 +#[derive(Resource, Default, Debug, Clone, PartialEq)]
34 +pub struct Zones {
35 + stockpile: HashSet<UVec2>,
36 +}
37 +
38 +impl Zones {
39 + pub fn is_stockpile(&self, tile: UVec2) -> bool {
40 + self.stockpile.contains(&tile)
41 + }
42 +
43 + #[cfg(test)]
44 + pub fn stockpile_tiles(&self) -> impl Iterator<Item = UVec2> + '_ {
45 + self.stockpile.iter().copied()
46 + }
47 +
48 + pub fn add_stockpile(&mut self, map: &WorldMap, a: IVec2, b: IVec2) {
49 + for tile in rect_tiles(a, b) {
50 + if can_zone(map, tile) {
51 + self.stockpile.insert(tile.as_uvec2());
52 + }
53 + }
54 + }
55 +
56 + pub fn remove_stockpile(&mut self, a: IVec2, b: IVec2) {
57 + for tile in rect_tiles(a, b) {
58 + if tile.x >= 0 && tile.y >= 0 {
59 + self.stockpile.remove(&tile.as_uvec2());
60 + }
61 + }
62 + }
63 +}
64 +
65 +pub fn can_zone(map: &WorldMap, tile: IVec2) -> bool {
66 + map.move_cost(tile).is_some() && !map.get(tile).is_some_and(|t| t.is_water())
67 +}
68 +
69 +fn rect_tiles(a: IVec2, b: IVec2) -> impl Iterator<Item = IVec2> {
70 + let (min, max) = (a.min(b), a.max(b));
71 + (min.y..=max.y).flat_map(move |y| (min.x..=max.x).map(move |x| IVec2::new(x, y)))
72 +}
73 +
74 +#[derive(Resource, Default, Debug)]
75 +pub struct ZoneTool {
76 + pub active: bool,
77 + drag_start: Option<UVec2>,
78 +}
79 +
80 +fn place_starting_stockpile(map: Res<WorldMap>, mut zones: ResMut<Zones>) {
81 + *zones = Zones::default();
82 + if let Some(&spawn) = spawn_tiles(&map, 1).first() {
83 + let corner = spawn.as_ivec2() + STARTING_STOCKPILE_OFFSET;
84 + zones.add_stockpile(&map, corner, corner + STARTING_STOCKPILE_SIZE - 1);
85 + }
86 +}
87 +
88 +fn removing(keys: &ButtonInput<KeyCode>) -> bool {
89 + keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight])
90 +}
91 +
92 +fn zone_tool(
93 + keys: Res<ButtonInput<KeyCode>>,
94 + buttons: Res<ButtonInput<MouseButton>>,
95 + cursor: Res<Cursor>,
96 + map: Res<WorldMap>,
97 + mut tool: ResMut<ZoneTool>,
98 + mut zones: ResMut<Zones>,
99 +) {
100 + if keys.just_pressed(KeyCode::KeyZ) {
101 + tool.active = !tool.active;
102 + tool.drag_start = None;
103 + return;
104 + }
105 + if keys.just_pressed(KeyCode::Escape) {
106 + tool.active = false;
107 + tool.drag_start = None;
108 + }
109 + if !tool.active {
110 + return;
111 + }
112 + if buttons.just_pressed(MouseButton::Left) {
113 + tool.drag_start = cursor.tile;
114 + }
115 + if !buttons.just_released(MouseButton::Left) {
116 + return;
117 + }
118 + if let (Some(start), Some(end)) = (tool.drag_start.take(), cursor.tile) {
119 + if removing(&keys) {
120 + zones.remove_stockpile(start.as_ivec2(), end.as_ivec2());
121 + } else {
122 + zones.add_stockpile(&map, start.as_ivec2(), end.as_ivec2());
123 + }
124 + }
125 +}
126 +
127 +fn draw_zone_preview(
128 + keys: Res<ButtonInput<KeyCode>>,
129 + cursor: Res<Cursor>,
130 + tool: Res<ZoneTool>,
131 + mut gizmos: Gizmos,
132 +) {
133 + let (Some(start), Some(end)) = (tool.drag_start, cursor.tile) else {
134 + return;
135 + };
136 + let (min, max) = (start.min(end), start.max(end));
137 + let center = (WorldMap::tile_center(min) + WorldMap::tile_center(max)) / 2.0;
138 + let size = (max - min + 1).as_vec2() * TILE_SIZE;
139 + let color = if removing(&keys) {
140 + PREVIEW_REMOVE_COLOR
141 + } else {
142 + PREVIEW_ADD_COLOR
143 + };
144 + gizmos.rect_2d(center, size, color);
145 +}
146 +
147 +#[cfg(test)]
148 +mod tests {
149 + use super::*;
150 + use crate::world::Terrain;
151 +
152 + #[test]
153 + fn stockpiles_only_cover_walkable_dry_land() {
154 + let mut map = WorldMap::filled(UVec2::new(4, 1), Terrain::Grass);
155 + map.set(UVec2::new(1, 0), Terrain::Stone);
156 + map.set(UVec2::new(2, 0), Terrain::ShallowWater);
157 + let mut zones = Zones::default();
158 + zones.add_stockpile(&map, IVec2::new(3, 0), IVec2::new(-1, 0));
159 + let mut tiles: Vec<_> = zones.stockpile_tiles().collect();
160 + tiles.sort_by_key(|t| t.x);
161 + assert_eq!(tiles, vec![UVec2::new(0, 0), UVec2::new(3, 0)]);
162 + }
163 +
164 + #[test]
165 + fn removing_clears_a_rectangle() {
166 + let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Grass);
167 + let mut zones = Zones::default();
168 + zones.add_stockpile(&map, IVec2::ZERO, IVec2::splat(3));
169 + zones.remove_stockpile(IVec2::new(1, 1), IVec2::new(2, 2));
170 + assert_eq!(zones.stockpile_tiles().count(), 12);
171 + assert!(!zones.is_stockpile(UVec2::new(1, 2)));
172 + assert!(zones.is_stockpile(UVec2::new(0, 2)));
173 + }
174 +
175 + fn tool_app() -> App {
176 + let mut app = App::new();
177 + app.insert_resource(WorldMap::filled(UVec2::splat(8), Terrain::Grass))
178 + .init_resource::<Zones>()
179 + .init_resource::<ButtonInput<KeyCode>>()
180 + .init_resource::<ButtonInput<MouseButton>>()
181 + .insert_resource(ZoneTool {
182 + active: true,
183 + drag_start: None,
184 + })
185 + .init_resource::<Cursor>()
186 + .add_systems(Update, zone_tool);
187 + app
188 + }
189 +
190 + fn drag(app: &mut App, from: UVec2, to: UVec2) {
191 + app.world_mut().resource_mut::<Cursor>().tile = Some(from);
192 + app.world_mut()
193 + .resource_mut::<ButtonInput<MouseButton>>()
194 + .press(MouseButton::Left);
195 + app.update();
196 + app.world_mut().resource_mut::<Cursor>().tile = Some(to);
197 + let mut buttons = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
198 + buttons.clear();
199 + buttons.release(MouseButton::Left);
200 + app.update();
201 + app.world_mut()
202 + .resource_mut::<ButtonInput<MouseButton>>()
203 + .clear();
204 + }
205 +
206 + #[test]
207 + fn dragging_adds_and_shift_dragging_removes() {
208 + let mut app = tool_app();
209 + drag(&mut app, UVec2::new(1, 1), UVec2::new(3, 2));
210 + assert_eq!(app.world().resource::<Zones>().stockpile_tiles().count(), 6);
211 + app.world_mut()
212 + .resource_mut::<ButtonInput<KeyCode>>()
213 + .press(KeyCode::ShiftLeft);
214 + drag(&mut app, UVec2::new(3, 2), UVec2::new(3, 1));
215 + assert_eq!(app.world().resource::<Zones>().stockpile_tiles().count(), 4);
216 + }
217 +
218 + #[test]
219 + fn dragging_does_nothing_when_the_tool_is_off() {
220 + let mut app = tool_app();
221 + app.world_mut().resource_mut::<ZoneTool>().active = false;
222 + drag(&mut app, UVec2::new(1, 1), UVec2::new(3, 2));
223 + assert_eq!(app.world().resource::<Zones>().stockpile_tiles().count(), 0);
224 + }
225 +
226 + #[test]
227 + fn new_worlds_start_with_a_stockpile() {
228 + let mut app = App::new();
229 + app.insert_resource(crate::worldgen::generate(UVec2::new(96, 64), 1))
230 + .init_resource::<Zones>()
231 + .add_message::<WorldGenerated>()
232 + .add_systems(
233 + Update,
234 + place_starting_stockpile.run_if(on_message::<WorldGenerated>),
235 + );
236 + app.world_mut().write_message(WorldGenerated);
237 + app.update();
238 + assert!(app.world().resource::<Zones>().stockpile_tiles().count() > 8);
239 + }
240 +}

tasks.md +4 -2

@@ -14,12 +14,14 @@
14 14 - [x] Rivers (flow field), iron/gold ore veins, trees
15 15 - [x] Job system: autonomous job choice from colony demand, reservations, manual orders override
16 16 - [x] Woodcutting (auto when wood < goal, or right-click a tree), wood item piles
17 +- [x] Stockpile zones (Z tool, starting stockpile) and hauling (auto, or right-click loose items)
17 18
18 19 ## Planned
19 20 - [ ] Speed/pause buttons in UI
20 21 - [ ] Ignore map clicks over UI
21 -- [ ] Jobs: mining (stone/ore), building, hauling (wood to stockpile)
22 -- [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities
22 +- [ ] Jobs: mining (stone/ore), building
23 +- [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
24 +- [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities (job order is fixed: deliver > chop > haul > wander)
23 25 - [ ] UI to edit colony goals (e.g. wood target)
24 26 - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky)
25 27 - [ ] Basic UI (tile info, colonist panel)