Add farm zones, sowing and crop harvesting

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 11:13 UTC
commit
85be05d307a761cc30de965eab50b8c1b5468639
parent
28a0cff806
tree
browse at this commit

9 files changed +406 -87

src/actions.rs +4 -1

@@ -16,17 +16,19 @@ pub enum Action {
16 16 TogglePause,
17 17 SetSpeed(SimSpeed),
18 18 ToggleStockpileTool,
19 + ToggleFarmTool,
19 20 Cancel,
20 21 NewWorld,
21 22 ToggleUi,
22 23 }
23 24
24 -pub const KEYBINDS: [(KeyCode, Action); 8] = [
25 +pub const KEYBINDS: [(KeyCode, Action); 9] = [
25 26 (KeyCode::Space, Action::TogglePause),
26 27 (KeyCode::Digit1, Action::SetSpeed(SimSpeed::Normal)),
27 28 (KeyCode::Digit2, Action::SetSpeed(SimSpeed::Fast)),
28 29 (KeyCode::Digit3, Action::SetSpeed(SimSpeed::Fastest)),
29 30 (KeyCode::KeyZ, Action::ToggleStockpileTool),
31 + (KeyCode::KeyF, Action::ToggleFarmTool),
30 32 (KeyCode::Escape, Action::Cancel),
31 33 (KeyCode::KeyR, Action::NewWorld),
32 34 (KeyCode::KeyH, Action::ToggleUi),
@@ -40,6 +42,7 @@ impl Action {
40 42 Action::SetSpeed(SimSpeed::Fast) => "3x",
41 43 Action::SetSpeed(SimSpeed::Fastest) => "6x",
42 44 Action::ToggleStockpileTool => "Stockpile",
45 + Action::ToggleFarmTool => "Farm",
43 46 Action::Cancel => "Cancel",
44 47 Action::NewWorld => "New world",
45 48 Action::ToggleUi => "Hide UI",

src/farming.rs +147 -0

@@ -0,0 +1,147 @@
1 +use bevy::prelude::*;
2 +
3 +use crate::{
4 + building::Blueprints,
5 + colonist::Colonist,
6 + items::Item,
7 + pathfinding::nearest_tile,
8 + plants::fertility,
9 + sim::{SimSystems, SimTick},
10 + structures::Structure,
11 + world::{Terrain, TilePos, WorldMap},
12 + zones::{Zones, can_farm},
13 +};
14 +
15 +pub struct FarmingPlugin;
16 +
17 +impl Plugin for FarmingPlugin {
18 + fn build(&self, app: &mut App) {
19 + app.add_systems(FixedUpdate, plan_farm.in_set(SimSystems::Decide));
20 + }
21 +}
22 +
23 +pub const FARM_SIZE: u32 = 5;
24 +pub const FARM_CLEARANCE: i32 = 2;
25 +const PLAN_INTERVAL_TICKS: u64 = 600;
26 +const FARM_SEARCH_TILES: usize = 3000;
27 +const SOIL_WEIGHT: f32 = 4.0;
28 +
29 +pub struct FarmContext<'a> {
30 + pub map: &'a WorldMap,
31 + pub zones: &'a Zones,
32 + pub blueprints: &'a Blueprints,
33 + pub occupied: &'a [UVec2],
34 +}
35 +
36 +impl FarmContext<'_> {
37 + fn square(corner: UVec2) -> impl Iterator<Item = UVec2> {
38 + (0..FARM_SIZE).flat_map(move |y| (0..FARM_SIZE).map(move |x| corner + UVec2::new(x, y)))
39 + }
40 +
41 + fn clear(&self, tile: UVec2) -> bool {
42 + let near = |other: UVec2| {
43 + (other.as_ivec2() - tile.as_ivec2()).abs().max_element() <= FARM_CLEARANCE
44 + };
45 + can_farm(self.map, tile.as_ivec2())
46 + && self.map.plant(tile.as_ivec2()).is_none()
47 + && !self.zones.stockpile_tiles().any(near)
48 + && !self.blueprints.0.keys().copied().any(near)
49 + && !self.occupied.iter().copied().any(near)
50 + }
51 +
52 + fn score(&self, corner: UVec2, home: UVec2) -> Option<f32> {
53 + let mut soil = 0.0;
54 + for tile in Self::square(corner) {
55 + if !self.clear(tile) {
56 + return None;
57 + }
58 + soil += fertility(self.map.get(tile.as_ivec2()).unwrap_or(Terrain::Sand));
59 + }
60 + Some(soil * SOIL_WEIGHT - corner.as_vec2().distance(home.as_vec2()))
61 + }
62 +
63 + pub fn plan(&self, home: UVec2) -> Option<Vec<UVec2>> {
64 + let mut best: Option<(f32, UVec2)> = None;
65 + nearest_tile(self.map, home, FARM_SEARCH_TILES, |corner| {
66 + if let Some(score) = self.score(corner, home)
67 + && best.is_none_or(|(b, _)| score > b)
68 + {
69 + best = Some((score, corner));
70 + }
71 + false
72 + });
73 + best.map(|(_, corner)| Self::square(corner).collect())
74 + }
75 +}
76 +
77 +fn plan_farm(
78 + tick: Res<SimTick>,
79 + map: Res<WorldMap>,
80 + mut zones: ResMut<Zones>,
81 + blueprints: Res<Blueprints>,
82 + structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>,
83 + items: Query<&TilePos, With<Item>>,
84 +) {
85 + if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) || zones.has_farm() || !zones.has_stockpile() {
86 + return;
87 + }
88 + let occupied: Vec<UVec2> = structures.iter().chain(items.iter()).map(|p| p.0).collect();
89 + let home = zones.home(map.size / 2);
90 + let ctx = FarmContext {
91 + map: &map,
92 + zones: &zones,
93 + blueprints: &blueprints,
94 + occupied: &occupied,
95 + };
96 + if let Some(tiles) = ctx.plan(home) {
97 + zones.add_farm_tiles(&map, tiles);
98 + }
99 +}
100 +
101 +#[cfg(test)]
102 +mod tests {
103 + use super::*;
104 +
105 + fn plan(map: &WorldMap, zones: &Zones, occupied: &[UVec2]) -> Vec<UVec2> {
106 + FarmContext {
107 + map,
108 + zones,
109 + blueprints: &Blueprints::default(),
110 + occupied,
111 + }
112 + .plan(UVec2::new(20, 20))
113 + .unwrap()
114 + }
115 +
116 + #[test]
117 + fn farms_go_on_the_most_fertile_ground_nearby() {
118 + let mut map = WorldMap::filled(UVec2::splat(40), Terrain::Sand);
119 + for y in 26..34 {
120 + for x in 10..18 {
121 + map.set(UVec2::new(x, y), Terrain::Soil);
122 + }
123 + }
124 + let farm = plan(&map, &Zones::default(), &[]);
125 + assert_eq!(farm.len(), (FARM_SIZE * FARM_SIZE) as usize);
126 + assert!(
127 + farm.iter()
128 + .all(|t| map.get(t.as_ivec2()) == Some(Terrain::Soil))
129 + );
130 + }
131 +
132 + // Beds, fires and the stockpile need room around them for walls and walkways, and a farm
133 + // packed against them would be dug into by every later shelter plan.
134 + #[test]
135 + fn farms_keep_their_distance_from_the_colony() {
136 + let map = WorldMap::filled(UVec2::splat(40), Terrain::Soil);
137 + let mut zones = Zones::default();
138 + zones.add_tiles(&map, [UVec2::new(20, 20)]);
139 + let bed = UVec2::new(24, 20);
140 + let farm = plan(&map, &zones, &[bed]);
141 + for tile in farm {
142 + for other in [UVec2::new(20, 20), bed] {
143 + assert!((tile.as_ivec2() - other.as_ivec2()).abs().max_element() > FARM_CLEARANCE);
144 + }
145 + }
146 + }
147 +}

src/jobs/mod.rs +66 -31

@@ -129,6 +129,7 @@ pub enum WorkKind {
129 129 Chop,
130 130 Mine,
131 131 Harvest,
132 + Sow,
132 133 }
133 134
134 135 impl WorkKind {
@@ -137,6 +138,7 @@ impl WorkKind {
137 138 WorkKind::Chop => 480,
138 139 WorkKind::Mine => 720,
139 140 WorkKind::Harvest => 240,
141 + WorkKind::Sow => 160,
140 142 }
141 143 }
142 144
@@ -150,6 +152,10 @@ impl WorkKind {
150 152 }
151 153 map.set_growth(site, 0.0);
152 154 }
155 + WorkKind::Sow => {
156 + map.set_plant(site, Some(Plant::Crop));
157 + map.set_growth(site, 0.0);
158 + }
153 159 }
154 160 }
155 161 }
@@ -436,6 +442,29 @@ pub fn find_haul(
436 442 })
437 443 }
438 444
445 +pub fn sowable(map: &WorldMap, zones: &Zones, site: UVec2) -> bool {
446 + zones.is_farm(site) && map.plant(site.as_ivec2()).is_none()
447 +}
448 +
449 +pub fn find_sowing(
450 + map: &WorldMap,
451 + zones: &Zones,
452 + reservations: &Reservations,
453 + from: UVec2,
454 +) -> Option<Task> {
455 + if !zones.has_farm() {
456 + return None;
457 + }
458 + let site = nearest_tile(map, from, SEARCH_TILES, |p| {
459 + sowable(map, zones, p) && reservations.is_free(Target::Site(p))
460 + })?;
461 + Some(Task::Work {
462 + site,
463 + kind: WorkKind::Sow,
464 + progress: 0,
465 + })
466 +}
467 +
439 468 pub const HUNT_RADIUS: f32 = 60.0;
440 469
441 470 pub fn find_hunt(
@@ -515,6 +544,7 @@ impl BuildContext<'_> {
515 544 fn buildable(&self, tile: UVec2) -> bool {
516 545 can_zone(self.map, tile.as_ivec2())
517 546 && !self.zones.is_stockpile(tile)
547 + && !self.zones.is_farm(tile)
518 548 && self.map.plant(tile.as_ivec2()).is_none()
519 549 && !self.structures.iter().any(|(pos, ..)| *pos == tile)
520 550 && !self.planned.iter().any(|(pos, _)| *pos == tile)
@@ -572,9 +602,12 @@ impl BuildContext<'_> {
572 602 }
573 603
574 604 fn clear_of_stockpile(&self, tile: UVec2) -> bool {
575 - self.zones.stockpile_tiles().all(|s| {
576 - (s.as_ivec2() - tile.as_ivec2()).abs().max_element() >= BED_STOCKPILE_CLEARANCE
577 - })
605 + self.zones
606 + .stockpile_tiles()
607 + .chain(self.zones.farm_tiles())
608 + .all(|s| {
609 + (s.as_ivec2() - tile.as_ivec2()).abs().max_element() >= BED_STOCKPILE_CLEARANCE
610 + })
578 611 }
579 612
580 613 fn count(&self, kind: StructureKind) -> usize {
@@ -829,6 +862,7 @@ fn choose_jobs(
829 862 .then(|| find_hunt(&map, &reservations, &wildlife, tile.0))
830 863 .flatten()
831 864 })
865 + .or_else(|| find_sowing(&map, &zones, &reservations, tile.0))
832 866 .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0))
833 867 .or_else(|| find_bed_site(&build, colonist_count, tile.0))
834 868 .or_else(|| find_blueprint_work(&build, tile.0))
@@ -1025,37 +1059,38 @@ fn run_jobs(
1025 1059 } => {
1026 1060 let (site, kind) = (*site, *kind);
1027 1061 let work = site_work(&map, site).filter(|w| w.kind == kind);
1062 + let valid =
1063 + work.is_some() || (kind == WorkKind::Sow && sowable(&map, &zones, site));
1028 1064 let ours = reservations.holder(Target::Site(site)) == Some(entity);
1029 - match work {
1030 - Some(work) if ours => {
1031 - if !is_adjacent(tile.0.as_ivec2(), site.as_ivec2()) {
1032 - match find_path_adjacent(&map, tile.0, site) {
1033 - Some(p) if !p.is_empty() => {
1034 - path.0 = p.into();
1035 - Step::Continue
1036 - }
1037 - _ => Step::Done,
1038 - }
1039 - } else {
1040 - *progress += work_rate(&light, tile.0);
1041 - if *progress < kind.work() {
1042 - Step::Continue
1043 - } else {
1044 - kind.complete(&mut map, site);
1045 - changed.write(TileChanged(site));
1046 - drop_items(
1047 - &mut commands,
1048 - &mut items,
1049 - &item_assets,
1050 - site,
1051 - work.item,
1052 - work.count,
1053 - );
1054 - Step::Done
1055 - }
1065 + if !valid || !ours {
1066 + Step::Done
1067 + } else if !is_adjacent(tile.0.as_ivec2(), site.as_ivec2()) {
1068 + match find_path_adjacent(&map, tile.0, site) {
1069 + Some(p) if !p.is_empty() => {
1070 + path.0 = p.into();
1071 + Step::Continue
1072 + }
1073 + _ => Step::Done,
1074 + }
1075 + } else {
1076 + *progress += work_rate(&light, tile.0);
1077 + if *progress < kind.work() {
1078 + Step::Continue
1079 + } else {
1080 + kind.complete(&mut map, site);
1081 + changed.write(TileChanged(site));
1082 + if let Some(work) = work {
1083 + drop_items(
1084 + &mut commands,
1085 + &mut items,
1086 + &item_assets,
1087 + site,
1088 + work.item,
1089 + work.count,
1090 + );
1056 1091 }
1092 + Step::Done
1057 1093 }
1058 - _ => Step::Done,
1059 1094 }
1060 1095 }
1061 1096 Task::ExpandStockpile { site, progress } => {

src/jobs/tests.rs +46 -0

@@ -891,3 +891,49 @@ fn right_clicking_an_animal_orders_a_hunt() {
891 891 AnimalKind::Rabbit.meat()
892 892 );
893 893 }
894 +
895 +fn farm_app(tiles: &[UVec2]) -> App {
896 + let mut map = map_with_trees(&[]);
897 + for &t in tiles {
898 + map.set(t, Terrain::Soil);
899 + }
900 + let mut app = test_app(map);
901 + set_goals(&mut app, &[]);
902 + put_beds(&mut app, 1);
903 + let map = app.world().resource::<WorldMap>().clone();
904 + app.world_mut()
905 + .resource_mut::<Zones>()
906 + .add_farm_tiles(&map, tiles.iter().copied());
907 + app
908 +}
909 +
910 +#[test]
911 +fn colonists_sow_empty_farm_tiles() {
912 + let farm = [UVec2::new(8, 8), UVec2::new(9, 8)];
913 + let mut app = farm_app(&farm);
914 + spawn_colonist(&mut app, UVec2::new(2, 2));
915 + run_ticks(&mut app, 60 * 10);
916 + let map = app.world().resource::<WorldMap>();
917 + assert!(
918 + farm.iter()
919 + .all(|t| map.plant(t.as_ivec2()) == Some(Plant::Crop))
920 + );
921 +}
922 +
923 +#[test]
924 +fn ripe_crops_are_harvested_into_vegetables_and_resown() {
925 + let farm = [UVec2::new(8, 8)];
926 + let mut app = farm_app(&farm);
927 + set_goals(&mut app, &[(ItemKind::Vegetables, 30)]);
928 + {
929 + let mut map = app.world_mut().resource_mut::<WorldMap>();
930 + map.set_plant(farm[0], Some(Plant::Crop));
931 + map.set_growth(farm[0], 1.0);
932 + }
933 + spawn_colonist(&mut app, UVec2::new(2, 2));
934 + run_ticks(&mut app, 60 * 15);
935 + assert_eq!(items_of(&mut app, ItemKind::Vegetables), 10);
936 + let map = app.world().resource::<WorldMap>();
937 + assert_eq!(map.plant(farm[0].as_ivec2()), Some(Plant::Crop));
938 + assert!(map.growth(farm[0].as_ivec2()) < 0.1);
939 +}

src/main.rs +2 -0

@@ -3,6 +3,7 @@ mod animals;
3 3 mod building;
4 4 mod camera;
5 5 mod colonist;
6 +mod farming;
6 7 mod food;
7 8 mod items;
8 9 mod jobs;
@@ -56,6 +57,7 @@ fn main() {
56 57 .add_plugins((
57 58 food::FoodPlugin,
58 59 animals::AnimalsPlugin,
60 + farming::FarmingPlugin,
59 61 plants::PlantsPlugin,
60 62 selection::SelectionPlugin,
61 63 zones::ZonesPlugin,

src/selection.rs +9 -7

@@ -16,7 +16,7 @@ use crate::{
16 16 ui::{HideableUi, PointerOverUi},
17 17 world::{TILE_SIZE, TilePos, WorldMap},
18 18 worldgen::{WorldGenerated, WorldSeed},
19 - zones::{ZoneTool, Zones},
19 + zones::{ZoneKind, ZoneTool, Zones},
20 20 };
21 21
22 22 pub struct SelectionPlugin;
@@ -31,7 +31,7 @@ impl Plugin for SelectionPlugin {
31 31 PostUpdate,
32 32 (
33 33 update_cursor,
34 - update_selection.run_if(|tool: Res<ZoneTool>| !tool.active),
34 + update_selection.run_if(|tool: Res<ZoneTool>| !tool.active()),
35 35 issue_move_orders,
36 36 draw_highlights,
37 37 update_tile_info,
@@ -302,6 +302,7 @@ fn update_tile_info(
302 302 let materials = format!("{}\n{}", stock_line(false), stock_line(true));
303 303 let hovered_zone = match cursor.tile {
304 304 Some(tile) if zones.is_stockpile(tile) => ", Stockpile",
305 + Some(tile) if zones.is_farm(tile) => ", Farm",
305 306 _ => "",
306 307 };
307 308 let hovered_items: String = items
@@ -326,10 +327,10 @@ fn update_tile_info(
326 327 .map(|(animal, _)| format!(", {}", animal.kind.name())),
327 328 )
328 329 .collect();
329 - let tool_hint = if tool.active {
330 - "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
331 - } else {
332 - ""
330 + let tool_hint = match tool.mode {
331 + Some(ZoneKind::Stockpile) => "\nSTOCKPILE TOOL: drag to add, shift-drag to remove",
332 + Some(ZoneKind::Farm) => "\nFARM TOOL: drag to add, shift-drag to remove",
333 + None => "",
333 334 };
334 335 let info = format!(
335 336 "{clock} {speed}\nSeed: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\nSelected: {selected}",
@@ -433,7 +434,7 @@ pub fn describe_colonist(view: &ColonistView) -> String {
433 434 ) if n > 0 => "walking to rock".to_string(),
434 435 (
435 436 Some(Task::Work {
436 - kind: WorkKind::Harvest,
437 + kind: WorkKind::Harvest | WorkKind::Sow,
437 438 ..
438 439 }),
439 440 n,
@@ -443,6 +444,7 @@ pub fn describe_colonist(view: &ColonistView) -> String {
443 444 WorkKind::Chop => "chopping a tree",
444 445 WorkKind::Mine => "mining",
445 446 WorkKind::Harvest => "harvesting",
447 + WorkKind::Sow => "sowing",
446 448 };
447 449 format!("{verb}, {}%", percent(task))
448 450 }

src/tilemap.rs +16 -1

@@ -90,7 +90,7 @@ fn spawn_chunks(
90 90 .map(|&look| plant_texture(look))
91 91 .collect(),
92 92 ));
93 - let zone = images.add(stack_tileset(vec![zone_texture()]));
93 + let zone = images.add(stack_tileset(vec![zone_texture(), farm_texture()]));
94 94 let roof = images.add(stack_tileset(vec![roof_texture()]));
95 95 let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE;
96 96 for (layer, tileset) in [
@@ -319,6 +319,21 @@ impl PlantLook {
319 319
320 320 pub const SPROUT_UNTIL: f32 = 0.4;
321 321
322 +fn farm_texture() -> Texture {
323 + (0..TEXTURE_SIZE * TEXTURE_SIZE)
324 + .map(|i| {
325 + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
326 + let edge = x == 0 || y == 0 || x == TEXTURE_SIZE - 1 || y == TEXTURE_SIZE - 1;
327 + let furrow = x % 4 == 0;
328 + match (edge, furrow) {
329 + (true, _) => [150, 190, 90, 140],
330 + (_, true) => [80, 56, 34, 150],
331 + _ => [110, 78, 48, 110],
332 + }
333 + })
334 + .collect()
335 +}
336 +
322 337 fn plant_texture(look: PlantLook) -> Texture {
323 338 let leaves = Srgba::rgb_u8(52, 110, 50);
324 339 let berry = Srgba::rgb_u8(190, 40, 90);

src/ui.rs +19 -29

@@ -8,7 +8,11 @@ use bevy::{
8 8 ui_widgets::{Activate, ActivateOnPress, Button},
9 9 };
10 10
11 -use crate::{actions::Action, sim::SimSpeed, zones::ZoneTool};
11 +use crate::{
12 + actions::Action,
13 + sim::SimSpeed,
14 + zones::{ZoneKind, ZoneTool},
15 +};
12 16
13 17 pub struct HudPlugin;
14 18
@@ -30,12 +34,13 @@ impl Plugin for HudPlugin {
30 34 }
31 35 }
32 36
33 -const TOOLBAR: [Action; 8] = [
37 +const TOOLBAR: [Action; 9] = [
34 38 Action::TogglePause,
35 39 Action::SetSpeed(SimSpeed::Normal),
36 40 Action::SetSpeed(SimSpeed::Fast),
37 41 Action::SetSpeed(SimSpeed::Fastest),
38 42 Action::ToggleStockpileTool,
43 + Action::ToggleFarmTool,
39 44 Action::Cancel,
40 45 Action::NewWorld,
41 46 Action::ToggleUi,
@@ -180,11 +185,12 @@ fn apply_ui_visibility(
180 185 }
181 186 }
182 187
183 -pub fn is_active(action: Action, paused: bool, speed: SimSpeed, stockpile_tool: bool) -> bool {
188 +pub fn is_active(action: Action, paused: bool, speed: SimSpeed, tool: Option<ZoneKind>) -> bool {
184 189 match action {
185 190 Action::TogglePause => paused,
186 191 Action::SetSpeed(s) => !paused && s == speed,
187 - Action::ToggleStockpileTool => stockpile_tool,
192 + Action::ToggleStockpileTool => tool == Some(ZoneKind::Stockpile),
193 + Action::ToggleFarmTool => tool == Some(ZoneKind::Farm),
188 194 _ => false,
189 195 }
190 196 }
@@ -196,7 +202,7 @@ fn style_buttons(
196 202 mut buttons: Query<(&ActionButton, &Hovered, Has<Pressed>, &mut BackgroundColor)>,
197 203 ) {
198 204 for (button, hovered, pressed, mut color) in &mut buttons {
199 - let active = is_active(button.0, time.is_paused(), *speed, tool.active);
205 + let active = is_active(button.0, time.is_paused(), *speed, tool.mode);
200 206 let new = match (pressed && hovered.get(), active, hovered.get()) {
201 207 (true, ..) => BUTTON_PRESSED_COLOR,
202 208 (_, true, _) => BUTTON_ACTIVE_COLOR,
@@ -231,30 +237,14 @@ mod tests {
231 237
232 238 #[test]
233 239 fn active_buttons_reflect_simulation_state() {
234 - assert!(is_active(
235 - Action::TogglePause,
236 - true,
237 - SimSpeed::Normal,
238 - false
239 - ));
240 - assert!(is_active(
241 - Action::SetSpeed(SimSpeed::Fast),
242 - false,
243 - SimSpeed::Fast,
244 - false
245 - ));
246 - assert!(!is_active(
247 - Action::SetSpeed(SimSpeed::Fast),
248 - true,
249 - SimSpeed::Fast,
250 - false
251 - ));
252 - assert!(is_active(
253 - Action::ToggleStockpileTool,
254 - false,
255 - SimSpeed::Normal,
256 - true
257 - ));
240 + let normal = SimSpeed::Normal;
241 + assert!(is_active(Action::TogglePause, true, normal, None));
242 + let fast = Action::SetSpeed(SimSpeed::Fast);
243 + assert!(is_active(fast, false, SimSpeed::Fast, None));
244 + assert!(!is_active(fast, true, SimSpeed::Fast, None));
245 + let farm = Some(ZoneKind::Farm);
246 + assert!(is_active(Action::ToggleFarmTool, false, normal, farm));
247 + assert!(!is_active(Action::ToggleStockpileTool, false, normal, farm));
258 248 }
259 249
260 250 #[test]

src/zones.rs +97 -18

@@ -5,6 +5,7 @@ use bevy::prelude::*;
5 5 use crate::{
6 6 actions::Action,
7 7 pathfinding::nearest_tile,
8 + plants::fertility,
8 9 selection::{Cursor, update_cursor},
9 10 ui::PointerOverUi,
10 11 world::{TILE_SIZE, WorldMap},
@@ -30,12 +31,59 @@ const STOCKPILE_SITE_SEARCH_TILES: usize = 4000;
30 31 const PREVIEW_ADD_COLOR: Color = Color::srgba(0.4, 0.7, 1.0, 0.9);
31 32 const PREVIEW_REMOVE_COLOR: Color = Color::srgba(1.0, 0.4, 0.4, 0.9);
32 33
34 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35 +pub enum ZoneKind {
36 + Stockpile,
37 + Farm,
38 +}
39 +
33 40 #[derive(Resource, Default, Debug, Clone, PartialEq)]
34 41 pub struct Zones {
35 42 stockpile: HashSet<UVec2>,
43 + farm: HashSet<UVec2>,
36 44 }
37 45
38 46 impl Zones {
47 + pub fn is_farm(&self, tile: UVec2) -> bool {
48 + self.farm.contains(&tile)
49 + }
50 +
51 + pub fn farm_tiles(&self) -> impl Iterator<Item = UVec2> + '_ {
52 + self.farm.iter().copied()
53 + }
54 +
55 + pub fn has_farm(&self) -> bool {
56 + !self.farm.is_empty()
57 + }
58 +
59 + pub fn add_farm_tiles(&mut self, map: &WorldMap, tiles: impl IntoIterator<Item = UVec2>) {
60 + for tile in tiles {
61 + if can_farm(map, tile.as_ivec2()) && !self.is_stockpile(tile) {
62 + self.farm.insert(tile);
63 + }
64 + }
65 + }
66 +
67 + pub fn add_zone(&mut self, kind: ZoneKind, map: &WorldMap, a: IVec2, b: IVec2) {
68 + let tiles = rect_tiles(a, b).filter_map(|t| t.try_into().ok());
69 + match kind {
70 + ZoneKind::Stockpile => self.add_tiles(map, tiles),
71 + ZoneKind::Farm => self.add_farm_tiles(map, tiles),
72 + }
73 + }
74 +
75 + pub fn remove_zone(&mut self, kind: ZoneKind, a: IVec2, b: IVec2) {
76 + let set = match kind {
77 + ZoneKind::Stockpile => &mut self.stockpile,
78 + ZoneKind::Farm => &mut self.farm,
79 + };
80 + for tile in rect_tiles(a, b) {
81 + if tile.x >= 0 && tile.y >= 0 {
82 + set.remove(&tile.as_uvec2());
83 + }
84 + }
85 + }
86 +
39 87 pub fn is_stockpile(&self, tile: UVec2) -> bool {
40 88 self.stockpile.contains(&tile)
41 89 }
@@ -71,24 +119,22 @@ impl Zones {
71 119 self.stockpile.difference(occupied).count()
72 120 }
73 121
122 + #[cfg(test)]
74 123 pub fn add_stockpile(&mut self, map: &WorldMap, a: IVec2, b: IVec2) {
75 - self.add_tiles(map, rect_tiles(a, b).filter_map(|t| t.try_into().ok()));
124 + self.add_zone(ZoneKind::Stockpile, map, a, b);
76 125 }
77 126
78 127 pub fn add_tiles(&mut self, map: &WorldMap, tiles: impl IntoIterator<Item = UVec2>) {
79 128 for tile in tiles {
80 - if can_zone(map, tile.as_ivec2()) {
129 + if can_zone(map, tile.as_ivec2()) && !self.is_farm(tile) {
81 130 self.stockpile.insert(tile);
82 131 }
83 132 }
84 133 }
85 134
135 + #[cfg(test)]
86 136 pub fn remove_stockpile(&mut self, a: IVec2, b: IVec2) {
87 - for tile in rect_tiles(a, b) {
88 - if tile.x >= 0 && tile.y >= 0 {
89 - self.stockpile.remove(&tile.as_uvec2());
90 - }
91 - }
137 + self.remove_zone(ZoneKind::Stockpile, a, b);
92 138 }
93 139 }
94 140
@@ -113,8 +159,12 @@ pub fn plan_expansion(
113 159 };
114 160 let mut planned: Vec<UVec2> = Vec::new();
115 161 while planned.len() < count {
116 - let taken =
117 - |t: UVec2| zones.is_stockpile(t) || planned.contains(&t) || excluded.contains(&t);
162 + let taken = |t: UVec2| {
163 + zones.is_stockpile(t)
164 + || zones.is_farm(t)
165 + || planned.contains(&t)
166 + || excluded.contains(&t)
167 + };
118 168 let best = zones
119 169 .stockpile
120 170 .iter()
@@ -142,6 +192,10 @@ pub fn can_zone(map: &WorldMap, tile: IVec2) -> bool {
142 192 map.move_cost(tile).is_some() && !map.get(tile).is_some_and(|t| t.is_water())
143 193 }
144 194
195 +pub fn can_farm(map: &WorldMap, tile: IVec2) -> bool {
196 + can_zone(map, tile) && map.get(tile).is_some_and(|t| fertility(t) > 0.0)
197 +}
198 +
145 199 fn rect_tiles(a: IVec2, b: IVec2) -> impl Iterator<Item = IVec2> {
146 200 let (min, max) = (a.min(b), a.max(b));
147 201 (min.y..=max.y).flat_map(move |y| (min.x..=max.x).map(move |x| IVec2::new(x, y)))
@@ -149,10 +203,20 @@ fn rect_tiles(a: IVec2, b: IVec2) -> impl Iterator<Item = IVec2> {
149 203
150 204 #[derive(Resource, Default, Debug)]
151 205 pub struct ZoneTool {
152 - pub active: bool,
206 + pub mode: Option<ZoneKind>,
153 207 drag_start: Option<UVec2>,
154 208 }
155 209
210 +impl ZoneTool {
211 + pub fn active(&self) -> bool {
212 + self.mode.is_some()
213 + }
214 +
215 + fn toggle(&mut self, kind: ZoneKind) {
216 + self.mode = (self.mode != Some(kind)).then_some(kind);
217 + }
218 +}
219 +
156 220 fn clear_zones(mut zones: ResMut<Zones>) {
157 221 *zones = Zones::default();
158 222 }
@@ -194,16 +258,17 @@ fn zone_tool(
194 258 ) {
195 259 for action in actions.read() {
196 260 match action {
197 - Action::ToggleStockpileTool => tool.active = !tool.active,
198 - Action::Cancel => tool.active = false,
261 + Action::ToggleStockpileTool => tool.toggle(ZoneKind::Stockpile),
262 + Action::ToggleFarmTool => tool.toggle(ZoneKind::Farm),
263 + Action::Cancel => tool.mode = None,
199 264 _ => continue,
200 265 }
201 266 tool.drag_start = None;
202 267 return;
203 268 }
204 - if !tool.active {
269 + let Some(kind) = tool.mode else {
205 270 return;
206 - }
271 + };
207 272 if buttons.just_pressed(MouseButton::Left) && !over_ui.0 {
208 273 tool.drag_start = cursor.tile;
209 274 }
@@ -212,9 +277,9 @@ fn zone_tool(
212 277 }
213 278 if let (Some(start), Some(end)) = (tool.drag_start.take(), cursor.tile) {
214 279 if removing(&keys) {
215 - zones.remove_stockpile(start.as_ivec2(), end.as_ivec2());
280 + zones.remove_zone(kind, start.as_ivec2(), end.as_ivec2());
216 281 } else {
217 - zones.add_stockpile(&map, start.as_ivec2(), end.as_ivec2());
282 + zones.add_zone(kind, &map, start.as_ivec2(), end.as_ivec2());
218 283 }
219 284 }
220 285 }
@@ -294,6 +359,20 @@ mod tests {
294 359 }
295 360
296 361 #[test]
362 + fn farms_need_fertile_ground_and_never_overlap_stockpiles() {
363 + let mut map = WorldMap::filled(UVec2::new(4, 1), Terrain::Soil);
364 + map.set(UVec2::new(1, 0), Terrain::Stone);
365 + let mut zones = Zones::default();
366 + zones.add_stockpile(&map, IVec2::new(3, 0), IVec2::new(3, 0));
367 + zones.add_zone(ZoneKind::Farm, &map, IVec2::ZERO, IVec2::new(3, 0));
368 + let mut farm: Vec<_> = zones.farm_tiles().collect();
369 + farm.sort_by_key(|t| t.x);
370 + assert_eq!(farm, vec![UVec2::new(0, 0), UVec2::new(2, 0)]);
371 + zones.add_stockpile(&map, IVec2::ZERO, IVec2::ZERO);
372 + assert!(!zones.is_stockpile(UVec2::ZERO));
373 + }
374 +
375 + #[test]
297 376 fn removing_clears_a_rectangle() {
298 377 let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Grass);
299 378 let mut zones = Zones::default();
@@ -311,7 +390,7 @@ mod tests {
311 390 .init_resource::<ButtonInput<KeyCode>>()
312 391 .init_resource::<ButtonInput<MouseButton>>()
313 392 .insert_resource(ZoneTool {
314 - active: true,
393 + mode: Some(ZoneKind::Stockpile),
315 394 drag_start: None,
316 395 })
317 396 .init_resource::<Cursor>()
@@ -352,7 +431,7 @@ mod tests {
352 431 #[test]
353 432 fn dragging_does_nothing_when_the_tool_is_off() {
354 433 let mut app = tool_app();
355 - app.world_mut().resource_mut::<ZoneTool>().active = false;
434 + app.world_mut().resource_mut::<ZoneTool>().mode = None;
356 435 drag(&mut app, UVec2::new(1, 1), UVec2::new(3, 2));
357 436 assert_eq!(app.world().resource::<Zones>().stockpile_tiles().count(), 0);
358 437 }