Add farm zones, sowing and crop harvesting
- 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 | |
| src/farming.rs | added | +147 -0 |
| src/jobs/mod.rs | +66 -31 | |
| src/jobs/tests.rs | +46 -0 | |
| src/main.rs | +2 -0 | |
| src/selection.rs | +9 -7 | |
| src/tilemap.rs | +16 -1 | |
| src/ui.rs | +19 -29 | |
| src/zones.rs | +97 -18 |
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 | } |