Add hunger, eating, berry bushes and foraging
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 11:06 UTC
- commit
- 3db77ac6b40b4c0834ff15ff51e6d273cbd35052
- parent
- 5a322f3fb7
- tree
- browse at this commit
11 files changed +687 -76
| src/colonist.rs | +2 -1 | |
| src/food.rs | added | +115 -0 |
| src/items.rs | +29 -1 | |
| src/jobs/mod.rs | +115 -14 | |
| src/jobs/tests.rs | +65 -0 | |
| src/main.rs | +6 -0 | |
| src/plants.rs | added | +100 -0 |
| src/selection.rs | +99 -44 | |
| src/tilemap.rs | +94 -6 | |
| src/world.rs | +43 -9 | |
| src/worldgen.rs | +19 -1 |
src/colonist.rs +2 -1
| @@ -3,6 +3,7 @@ use std::collections::VecDeque; | ||
| 3 | 3 | use bevy::prelude::*; |
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | + food::Hunger, | |
| 6 | 7 | items::{Carrying, ItemAssets}, |
| 7 | 8 | jobs::{CurrentJob, IdleTimer}, |
| 8 | 9 | needs::{Rest, sleep_marker}, |
| @@ -31,7 +32,7 @@ const STARTING_COLONISTS: [(&str, Color); 3] = [ | ||
| 31 | 32 | |
| 32 | 33 | #[derive(Component, Debug)] |
| 33 | 34 | #[require( |
| 34 | - Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying, Rest | |
| 35 | + Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying, Rest, Hunger | |
| 35 | 36 | )] |
| 36 | 37 | pub struct Colonist { |
| 37 | 38 | pub name: String, |
src/food.rs +115 -0
| @@ -0,0 +1,115 @@ | ||
| 1 | +use bevy::prelude::*; | |
| 2 | + | |
| 3 | +use crate::{ | |
| 4 | + colonist::{Colonist, Path}, | |
| 5 | + items::{Item, ItemKind}, | |
| 6 | + jobs::{CurrentJob, Reservations, Target}, | |
| 7 | + needs::is_sleeping, | |
| 8 | + pathfinding::nearest_tile, | |
| 9 | + sim::{SimSystems, TICKS_PER_HOUR}, | |
| 10 | + world::{TilePos, WorldMap}, | |
| 11 | +}; | |
| 12 | + | |
| 13 | +pub struct FoodPlugin; | |
| 14 | + | |
| 15 | +impl Plugin for FoodPlugin { | |
| 16 | + fn build(&self, app: &mut App) { | |
| 17 | + app.add_systems(FixedUpdate, update_hunger.in_set(SimSystems::Act)); | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +pub const HUNGER_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32); | |
| 22 | +pub const SLEEPING_HUNGER_FACTOR: f32 = 0.5; | |
| 23 | +pub const HUNGRY: f32 = 0.35; | |
| 24 | +pub const STARVING: f32 = 0.05; | |
| 25 | +pub const EAT_TICKS: u32 = 90; | |
| 26 | +const FOOD_SEARCH_TILES: usize = 15000; | |
| 27 | + | |
| 28 | +#[derive(Component, Debug, Clone, Copy, PartialEq)] | |
| 29 | +pub struct Hunger(pub f32); | |
| 30 | + | |
| 31 | +impl Default for Hunger { | |
| 32 | + fn default() -> Self { | |
| 33 | + Self(1.0) | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +pub fn wants_food(fed: f32) -> bool { | |
| 38 | + fed < HUNGRY | |
| 39 | +} | |
| 40 | + | |
| 41 | +pub fn portion(kind: ItemKind, fed: f32) -> u32 { | |
| 42 | + ((1.0 - fed) / kind.nutrition()).ceil().max(1.0) as u32 | |
| 43 | +} | |
| 44 | + | |
| 45 | +pub fn find_food( | |
| 46 | + map: &WorldMap, | |
| 47 | + reservations: &Reservations, | |
| 48 | + items: &[(Item, TilePos)], | |
| 49 | + from: UVec2, | |
| 50 | +) -> Option<UVec2> { | |
| 51 | + let has = |p: UVec2, accept: fn(ItemKind) -> bool| { | |
| 52 | + reservations.is_free(Target::Pickup(p)) | |
| 53 | + && items | |
| 54 | + .iter() | |
| 55 | + .any(|(item, pos)| pos.0 == p && accept(item.kind)) | |
| 56 | + }; | |
| 57 | + nearest_tile(map, from, FOOD_SEARCH_TILES, |p| { | |
| 58 | + has(p, |k| k == ItemKind::Meal) | |
| 59 | + }) | |
| 60 | + .or_else(|| { | |
| 61 | + nearest_tile(map, from, FOOD_SEARCH_TILES, |p| { | |
| 62 | + has(p, ItemKind::is_raw_food) | |
| 63 | + }) | |
| 64 | + }) | |
| 65 | +} | |
| 66 | + | |
| 67 | +pub fn update_hunger( | |
| 68 | + mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path), With<Colonist>>, | |
| 69 | +) { | |
| 70 | + for (mut hunger, job, tile, path) in &mut colonists { | |
| 71 | + let rate = if is_sleeping(job, tile, path) { | |
| 72 | + HUNGER_PER_TICK * SLEEPING_HUNGER_FACTOR | |
| 73 | + } else { | |
| 74 | + HUNGER_PER_TICK | |
| 75 | + }; | |
| 76 | + hunger.0 = (hunger.0 - rate).max(0.0); | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +#[cfg(test)] | |
| 81 | +mod tests { | |
| 82 | + use super::*; | |
| 83 | + use crate::world::Terrain; | |
| 84 | + | |
| 85 | + #[test] | |
| 86 | + fn portions_fill_a_colonist_up() { | |
| 87 | + assert_eq!(portion(ItemKind::Meal, 0.2), 1); | |
| 88 | + assert_eq!(portion(ItemKind::Berries, 0.3), 7); | |
| 89 | + assert_eq!(portion(ItemKind::Berries, 0.99), 1); | |
| 90 | + } | |
| 91 | + | |
| 92 | + #[test] | |
| 93 | + fn colonists_prefer_meals_over_raw_food() { | |
| 94 | + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); | |
| 95 | + let reservations = Reservations::default(); | |
| 96 | + let food = |kind, x| (Item { kind, count: 5 }, TilePos(UVec2::new(x, 0))); | |
| 97 | + let items = [ | |
| 98 | + food(ItemKind::Berries, 1), | |
| 99 | + food(ItemKind::Meal, 15), | |
| 100 | + food(ItemKind::Wood, 0), | |
| 101 | + ]; | |
| 102 | + assert_eq!( | |
| 103 | + find_food(&map, &reservations, &items, UVec2::ZERO), | |
| 104 | + Some(UVec2::new(15, 0)) | |
| 105 | + ); | |
| 106 | + assert_eq!( | |
| 107 | + find_food(&map, &reservations, &items[..1], UVec2::ZERO), | |
| 108 | + Some(UVec2::new(1, 0)) | |
| 109 | + ); | |
| 110 | + assert_eq!( | |
| 111 | + find_food(&map, &reservations, &items[2..], UVec2::ZERO), | |
| 112 | + None | |
| 113 | + ); | |
| 114 | + } | |
| 115 | +} | |
src/items.rs +29 -1
| @@ -25,22 +25,50 @@ pub enum ItemKind { | ||
| 25 | 25 | Stone, |
| 26 | 26 | Iron, |
| 27 | 27 | Gold, |
| 28 | + Berries, | |
| 29 | + Meat, | |
| 30 | + Vegetables, | |
| 31 | + Meal, | |
| 28 | 32 | } |
| 29 | 33 | |
| 30 | 34 | impl ItemKind { |
| 31 | - pub const ALL: [ItemKind; 4] = [ | |
| 35 | + pub const ALL: [ItemKind; 8] = [ | |
| 32 | 36 | ItemKind::Wood, |
| 33 | 37 | ItemKind::Stone, |
| 34 | 38 | ItemKind::Iron, |
| 35 | 39 | ItemKind::Gold, |
| 40 | + ItemKind::Berries, | |
| 41 | + ItemKind::Meat, | |
| 42 | + ItemKind::Vegetables, | |
| 43 | + ItemKind::Meal, | |
| 36 | 44 | ]; |
| 37 | 45 | |
| 46 | + pub fn nutrition(self) -> f32 { | |
| 47 | + match self { | |
| 48 | + ItemKind::Berries | ItemKind::Meat | ItemKind::Vegetables => 0.1, | |
| 49 | + ItemKind::Meal => 0.9, | |
| 50 | + _ => 0.0, | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + pub fn is_food(self) -> bool { | |
| 55 | + self.nutrition() > 0.0 | |
| 56 | + } | |
| 57 | + | |
| 58 | + pub fn is_raw_food(self) -> bool { | |
| 59 | + self.is_food() && self != ItemKind::Meal | |
| 60 | + } | |
| 61 | + | |
| 38 | 62 | fn color(self) -> Color { |
| 39 | 63 | match self { |
| 40 | 64 | ItemKind::Wood => Color::srgb_u8(150, 104, 62), |
| 41 | 65 | ItemKind::Stone => Color::srgb_u8(170, 170, 176), |
| 42 | 66 | ItemKind::Iron => Color::srgb_u8(178, 104, 72), |
| 43 | 67 | ItemKind::Gold => Color::srgb_u8(236, 196, 60), |
| 68 | + ItemKind::Berries => Color::srgb_u8(170, 40, 90), | |
| 69 | + ItemKind::Meat => Color::srgb_u8(200, 80, 70), | |
| 70 | + ItemKind::Vegetables => Color::srgb_u8(120, 170, 60), | |
| 71 | + ItemKind::Meal => Color::srgb_u8(240, 210, 150), | |
| 44 | 72 | } |
| 45 | 73 | } |
| 46 | 74 | } |
src/jobs/mod.rs +115 -14
| @@ -9,6 +9,7 @@ use bevy::prelude::*; | ||
| 9 | 9 | use crate::{ |
| 10 | 10 | building::{Blueprints, BuildKind}, |
| 11 | 11 | colonist::{Colonist, Path, repath}, |
| 12 | + food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food}, | |
| 12 | 13 | items::{ |
| 13 | 14 | CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for, |
| 14 | 15 | stock, take_items, |
| @@ -103,6 +104,10 @@ pub enum Task { | ||
| 103 | 104 | Sleep { |
| 104 | 105 | spot: UVec2, |
| 105 | 106 | }, |
| 107 | + Eat { | |
| 108 | + pickup: Option<UVec2>, | |
| 109 | + progress: u32, | |
| 110 | + }, | |
| 106 | 111 | } |
| 107 | 112 | |
| 108 | 113 | pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 { |
| @@ -117,6 +122,7 @@ pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 { | ||
| 117 | 122 | pub enum WorkKind { |
| 118 | 123 | Chop, |
| 119 | 124 | Mine, |
| 125 | + Harvest, | |
| 120 | 126 | } |
| 121 | 127 | |
| 122 | 128 | impl WorkKind { |
| @@ -124,6 +130,7 @@ impl WorkKind { | ||
| 124 | 130 | match self { |
| 125 | 131 | WorkKind::Chop => 480, |
| 126 | 132 | WorkKind::Mine => 720, |
| 133 | + WorkKind::Harvest => 240, | |
| 127 | 134 | } |
| 128 | 135 | } |
| 129 | 136 | |
| @@ -131,6 +138,12 @@ impl WorkKind { | ||
| 131 | 138 | match self { |
| 132 | 139 | WorkKind::Chop => map.set_plant(site, None), |
| 133 | 140 | WorkKind::Mine => map.set(site, Terrain::Gravel), |
| 141 | + WorkKind::Harvest => { | |
| 142 | + if map.plant(site.as_ivec2()) == Some(Plant::Crop) { | |
| 143 | + map.set_plant(site, None); | |
| 144 | + } | |
| 145 | + map.set_growth(site, 0.0); | |
| 146 | + } | |
| 134 | 147 | } |
| 135 | 148 | } |
| 136 | 149 | } |
| @@ -143,12 +156,26 @@ pub struct SiteWork { | ||
| 143 | 156 | |
| 144 | 157 | pub fn site_work(map: &WorldMap, site: UVec2) -> Option<SiteWork> { |
| 145 | 158 | let p = site.as_ivec2(); |
| 146 | - if map.plant(p) == Some(Plant::Tree) { | |
| 147 | - return Some(SiteWork { | |
| 148 | - kind: WorkKind::Chop, | |
| 149 | - item: ItemKind::Wood, | |
| 150 | - count: 10, | |
| 151 | - }); | |
| 159 | + let harvest = |item, count| SiteWork { | |
| 160 | + kind: WorkKind::Harvest, | |
| 161 | + item, | |
| 162 | + count, | |
| 163 | + }; | |
| 164 | + match map.plant(p) { | |
| 165 | + Some(Plant::Tree) => { | |
| 166 | + return Some(SiteWork { | |
| 167 | + kind: WorkKind::Chop, | |
| 168 | + item: ItemKind::Wood, | |
| 169 | + count: 10, | |
| 170 | + }); | |
| 171 | + } | |
| 172 | + Some(Plant::BerryBush) if map.ripe_plant(p).is_some() => { | |
| 173 | + return Some(harvest(ItemKind::Berries, 8)); | |
| 174 | + } | |
| 175 | + Some(Plant::Crop) if map.ripe_plant(p).is_some() => { | |
| 176 | + return Some(harvest(ItemKind::Vegetables, 10)); | |
| 177 | + } | |
| 178 | + _ => {} | |
| 152 | 179 | } |
| 153 | 180 | let (item, count) = match map.get(p)? { |
| 154 | 181 | Terrain::Stone => (ItemKind::Stone, 10), |
| @@ -223,6 +250,10 @@ impl Default for ColonyGoals { | ||
| 223 | 250 | (ItemKind::Stone, 40), |
| 224 | 251 | (ItemKind::Iron, 20), |
| 225 | 252 | (ItemKind::Gold, 10), |
| 253 | + (ItemKind::Berries, 30), | |
| 254 | + (ItemKind::Meat, 30), | |
| 255 | + (ItemKind::Vegetables, 30), | |
| 256 | + (ItemKind::Meal, 10), | |
| 226 | 257 | ])) |
| 227 | 258 | } |
| 228 | 259 | } |
| @@ -309,6 +340,11 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) { | ||
| 309 | 340 | } |
| 310 | 341 | Task::ExpandStockpile { .. } => reservations.reserve(Target::StockpileExpansion, by), |
| 311 | 342 | Task::Sleep { spot } => reservations.reserve(Target::SleepSpot(spot), by), |
| 343 | + Task::Eat { pickup, .. } => { | |
| 344 | + if let Some(pickup) = pickup { | |
| 345 | + reservations.reserve(Target::Pickup(pickup), by); | |
| 346 | + } | |
| 347 | + } | |
| 312 | 348 | Task::Build { site, pickup, .. } => { |
| 313 | 349 | reservations.reserve(Target::Site(site), by); |
| 314 | 350 | if let Some(pickup) = pickup { |
| @@ -613,6 +649,7 @@ fn choose_jobs( | ||
| 613 | 649 | &mut IdleTimer, |
| 614 | 650 | &mut Carrying, |
| 615 | 651 | &Rest, |
| 652 | + &Hunger, | |
| 616 | 653 | ), |
| 617 | 654 | With<Colonist>, |
| 618 | 655 | >, |
| @@ -644,19 +681,21 @@ fn choose_jobs( | ||
| 644 | 681 | .collect(); |
| 645 | 682 | let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0)); |
| 646 | 683 | let hour = hour_of_day(*tick); |
| 647 | - for (entity, tile, mut path, mut job, mut idle, mut carrying, rest) in &mut colonists { | |
| 684 | + for (entity, tile, mut path, mut job, mut idle, mut carrying, rest, hunger) in &mut colonists { | |
| 648 | 685 | let exhausted = rest.0 < EXHAUSTED_REST; |
| 649 | - if exhausted | |
| 650 | - && job | |
| 651 | - .0 | |
| 652 | - .is_some_and(|j| !j.ordered && !matches!(j.task, Task::Sleep { .. })) | |
| 686 | + let starving = hunger.0 < STARVING; | |
| 687 | + let urgent = exhausted || starving; | |
| 688 | + if urgent | |
| 689 | + && job.0.is_some_and(|j| { | |
| 690 | + !j.ordered && !matches!(j.task, Task::Sleep { .. } | Task::Eat { .. }) | |
| 691 | + }) | |
| 653 | 692 | { |
| 654 | 693 | reservations.release_all(entity); |
| 655 | 694 | job.0 = None; |
| 656 | 695 | path.0.truncate(1); |
| 657 | 696 | } |
| 658 | 697 | let rested = idle.0.tick(time.delta()).is_finished(); |
| 659 | - if job.0.is_some() || !path.0.is_empty() || !(rested || exhausted) { | |
| 698 | + if job.0.is_some() || !path.0.is_empty() || !(rested || urgent) { | |
| 660 | 699 | continue; |
| 661 | 700 | } |
| 662 | 701 | let snapshot: Vec<(Item, TilePos)> = |
| @@ -677,7 +716,29 @@ fn choose_jobs( | ||
| 677 | 716 | structures: &structures_list, |
| 678 | 717 | blueprints: &blueprints, |
| 679 | 718 | }; |
| 680 | - let task = if wants_sleep(rest.0, hour) { | |
| 719 | + let food = wants_food(hunger.0) | |
| 720 | + .then(|| find_food(&map, &reservations, &snapshot, tile.0)) | |
| 721 | + .flatten(); | |
| 722 | + let task = if exhausted { | |
| 723 | + Some(Task::Sleep { | |
| 724 | + spot: sleep.spot(tile.0), | |
| 725 | + }) | |
| 726 | + } else if let Some(pickup) = food { | |
| 727 | + if let Some(load) = carrying.0.take() { | |
| 728 | + drop_items( | |
| 729 | + &mut commands, | |
| 730 | + &mut items, | |
| 731 | + &item_assets, | |
| 732 | + tile.0, | |
| 733 | + load.kind, | |
| 734 | + load.count, | |
| 735 | + ); | |
| 736 | + } | |
| 737 | + Some(Task::Eat { | |
| 738 | + pickup: Some(pickup), | |
| 739 | + progress: 0, | |
| 740 | + }) | |
| 741 | + } else if wants_sleep(rest.0, hour) { | |
| 681 | 742 | Some(Task::Sleep { |
| 682 | 743 | spot: sleep.spot(tile.0), |
| 683 | 744 | }) |
| @@ -786,13 +847,16 @@ fn run_jobs( | ||
| 786 | 847 | &mut IdleTimer, |
| 787 | 848 | &mut Carrying, |
| 788 | 849 | &Rest, |
| 850 | + &mut Hunger, | |
| 789 | 851 | ), |
| 790 | 852 | With<Colonist>, |
| 791 | 853 | >, |
| 792 | 854 | ) { |
| 793 | 855 | let hour = hour_of_day(*tick); |
| 794 | 856 | let occupied: Vec<UVec2> = colonists.iter().map(|c| c.1.0).collect(); |
| 795 | - for (entity, tile, mut path, mut current, mut idle, mut carrying, rest) in &mut colonists { | |
| 857 | + for (entity, tile, mut path, mut current, mut idle, mut carrying, rest, mut hunger) in | |
| 858 | + &mut colonists | |
| 859 | + { | |
| 796 | 860 | let Some(mut job) = current.0 else { |
| 797 | 861 | continue; |
| 798 | 862 | }; |
| @@ -801,6 +865,43 @@ fn run_jobs( | ||
| 801 | 865 | } |
| 802 | 866 | let step = match &mut job.task { |
| 803 | 867 | Task::MoveTo(_) => Step::Done, |
| 868 | + Task::Eat { pickup, progress } => { | |
| 869 | + if let Some(from) = *pickup { | |
| 870 | + if reservations.holder(Target::Pickup(from)) != Some(entity) { | |
| 871 | + Step::Done | |
| 872 | + } else if tile.0 != from { | |
| 873 | + walk_to(&map, tile.0, from, &mut path) | |
| 874 | + } else { | |
| 875 | + let kind = items | |
| 876 | + .iter() | |
| 877 | + .find(|(_, item, pos)| pos.0 == from && item.kind.is_food()) | |
| 878 | + .map(|(_, item, _)| item.kind); | |
| 879 | + let meal = kind.and_then(|kind| { | |
| 880 | + take_items(&mut commands, &mut items, from, portion(kind, hunger.0)) | |
| 881 | + }); | |
| 882 | + reservations.release(Target::Pickup(from)); | |
| 883 | + match meal.filter(|m| m.kind.is_food()) { | |
| 884 | + Some(meal) => { | |
| 885 | + carrying.0 = Some(meal); | |
| 886 | + *pickup = None; | |
| 887 | + Step::Continue | |
| 888 | + } | |
| 889 | + None => Step::Done, | |
| 890 | + } | |
| 891 | + } | |
| 892 | + } else if let Some(meal) = carrying.0.filter(|m| m.kind.is_food()) { | |
| 893 | + *progress += 1; | |
| 894 | + if *progress < EAT_TICKS { | |
| 895 | + Step::Continue | |
| 896 | + } else { | |
| 897 | + hunger.0 = (hunger.0 + meal.count as f32 * meal.kind.nutrition()).min(1.0); | |
| 898 | + carrying.0 = None; | |
| 899 | + Step::Done | |
| 900 | + } | |
| 901 | + } else { | |
| 902 | + Step::Done | |
| 903 | + } | |
| 904 | + } | |
| 804 | 905 | Task::Sleep { spot } => { |
| 805 | 906 | if reservations.holder(Target::SleepSpot(*spot)) != Some(entity) { |
| 806 | 907 | Step::Done |
src/jobs/tests.rs +65 -0
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | use super::*; |
| 2 | 2 | use crate::{ |
| 3 | 3 | colonist::follow_path, |
| 4 | + food::Hunger, | |
| 4 | 5 | items::STACK_LIMIT, |
| 5 | 6 | sim::{SimPos, sim_pos}, |
| 6 | 7 | world::Terrain, |
| @@ -775,3 +776,67 @@ fn colonists_chop_from_a_neighbouring_tile() { | ||
| 775 | 776 | WorldMap::tile_center(tile) |
| 776 | 777 | ); |
| 777 | 778 | } |
| 779 | + | |
| 780 | +fn set_fed(app: &mut App, colonist: Entity, fed: f32) { | |
| 781 | + app.world_mut().get_mut::<Hunger>(colonist).unwrap().0 = fed; | |
| 782 | +} | |
| 783 | + | |
| 784 | +fn fed(app: &App, colonist: Entity) -> f32 { | |
| 785 | + app.world().get::<Hunger>(colonist).unwrap().0 | |
| 786 | +} | |
| 787 | + | |
| 788 | +#[test] | |
| 789 | +fn hungry_colonists_eat_until_full() { | |
| 790 | + let mut app = test_app(map_with_trees(&[])); | |
| 791 | + set_goals(&mut app, &[]); | |
| 792 | + put_items(&mut app, UVec2::new(8, 5), ItemKind::Berries, 20); | |
| 793 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 794 | + set_fed(&mut app, colonist, 0.3); | |
| 795 | + run_ticks(&mut app, 60 * 6); | |
| 796 | + assert!(fed(&app, colonist) > 0.95); | |
| 797 | + assert_eq!(items_of(&mut app, ItemKind::Berries), 13); | |
| 798 | +} | |
| 799 | + | |
| 800 | +#[test] | |
| 801 | +fn colonists_are_not_peckish_when_fed() { | |
| 802 | + let mut app = test_app(map_with_trees(&[])); | |
| 803 | + set_goals(&mut app, &[]); | |
| 804 | + put_items(&mut app, UVec2::new(8, 5), ItemKind::Berries, 20); | |
| 805 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 806 | + set_fed(&mut app, colonist, 0.8); | |
| 807 | + run_ticks(&mut app, 60 * 6); | |
| 808 | + assert_eq!(items_of(&mut app, ItemKind::Berries), 20); | |
| 809 | +} | |
| 810 | + | |
| 811 | +#[test] | |
| 812 | +fn starving_colonists_drop_their_work_to_eat() { | |
| 813 | + let tree = UVec2::new(15, 5); | |
| 814 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 815 | + put_items(&mut app, UVec2::new(2, 8), ItemKind::Meat, 20); | |
| 816 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 817 | + run_ticks(&mut app, 61); | |
| 818 | + assert!(task(&app, colonist).is_some_and(|t| !matches!(t, Task::Eat { .. }))); | |
| 819 | + set_fed(&mut app, colonist, STARVING / 2.0); | |
| 820 | + run_ticks(&mut app, 30); | |
| 821 | + assert!(matches!(task(&app, colonist), Some(Task::Eat { .. }))); | |
| 822 | +} | |
| 823 | + | |
| 824 | +#[test] | |
| 825 | +fn colonists_forage_ripe_berries_when_the_colony_wants_them() { | |
| 826 | + let mut map = map_with_trees(&[]); | |
| 827 | + let ripe = UVec2::new(8, 5); | |
| 828 | + let unripe = UVec2::new(4, 5); | |
| 829 | + for (bush, growth) in [(ripe, 1.0), (unripe, 0.5)] { | |
| 830 | + map.set_plant(bush, Some(Plant::BerryBush)); | |
| 831 | + map.set_growth(bush, growth); | |
| 832 | + } | |
| 833 | + let mut app = test_app(map); | |
| 834 | + set_goals(&mut app, &[(ItemKind::Berries, 30)]); | |
| 835 | + spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 836 | + run_ticks(&mut app, 60 * 10); | |
| 837 | + assert_eq!(items_of(&mut app, ItemKind::Berries), 8); | |
| 838 | + let map = app.world().resource::<WorldMap>(); | |
| 839 | + assert_eq!(map.plant(ripe.as_ivec2()), Some(Plant::BerryBush)); | |
| 840 | + assert_eq!(map.growth(ripe.as_ivec2()), 0.0); | |
| 841 | + assert_eq!(map.growth(unripe.as_ivec2()), 0.5); | |
| 842 | +} | |
src/main.rs +6 -0
| @@ -2,12 +2,14 @@ mod actions; | ||
| 2 | 2 | mod building; |
| 3 | 3 | mod camera; |
| 4 | 4 | mod colonist; |
| 5 | +mod food; | |
| 5 | 6 | mod items; |
| 6 | 7 | mod jobs; |
| 7 | 8 | mod light; |
| 8 | 9 | mod needs; |
| 9 | 10 | mod noise; |
| 10 | 11 | mod pathfinding; |
| 12 | +mod plants; | |
| 11 | 13 | mod rng; |
| 12 | 14 | mod selection; |
| 13 | 15 | mod sim; |
| @@ -49,6 +51,10 @@ fn main() { | ||
| 49 | 51 | structures::StructuresPlugin, |
| 50 | 52 | building::BuildingPlugin, |
| 51 | 53 | needs::NeedsPlugin, |
| 54 | + )) | |
| 55 | + .add_plugins(( | |
| 56 | + food::FoodPlugin, | |
| 57 | + plants::PlantsPlugin, | |
| 52 | 58 | selection::SelectionPlugin, |
| 53 | 59 | zones::ZonesPlugin, |
| 54 | 60 | ui::HudPlugin, |
src/plants.rs +100 -0
| @@ -0,0 +1,100 @@ | ||
| 1 | +use bevy::prelude::*; | |
| 2 | + | |
| 3 | +use crate::{ | |
| 4 | + sim::{SimSystems, SimTick}, | |
| 5 | + tilemap::PlantLook, | |
| 6 | + world::{Terrain, TileChanged, WorldMap}, | |
| 7 | +}; | |
| 8 | + | |
| 9 | +pub struct PlantsPlugin; | |
| 10 | + | |
| 11 | +impl Plugin for PlantsPlugin { | |
| 12 | + fn build(&self, app: &mut App) { | |
| 13 | + app.add_systems(FixedUpdate, grow_plants.in_set(SimSystems::Act)); | |
| 14 | + } | |
| 15 | +} | |
| 16 | + | |
| 17 | +pub const GROWTH_INTERVAL_TICKS: u64 = 60; | |
| 18 | +const WILD_MIN_FERTILITY: f32 = 0.8; | |
| 19 | + | |
| 20 | +pub fn fertility(terrain: Terrain) -> f32 { | |
| 21 | + match terrain { | |
| 22 | + Terrain::Soil => 1.0, | |
| 23 | + Terrain::Grass => 0.8, | |
| 24 | + Terrain::Sand => 0.4, | |
| 25 | + Terrain::Gravel => 0.3, | |
| 26 | + _ => 0.0, | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +pub fn grow(map: &mut WorldMap, elapsed_ticks: f32, mut on_change: impl FnMut(UVec2)) { | |
| 31 | + for pos in map.positions() { | |
| 32 | + let p = pos.as_ivec2(); | |
| 33 | + let Some(plant) = map.plant(p) else { | |
| 34 | + continue; | |
| 35 | + }; | |
| 36 | + let Some(ripening) = plant.ripening_ticks() else { | |
| 37 | + continue; | |
| 38 | + }; | |
| 39 | + let growth = map.growth(p); | |
| 40 | + if growth >= 1.0 { | |
| 41 | + continue; | |
| 42 | + } | |
| 43 | + let soil = fertility(map.get(p).unwrap_or(Terrain::Grass)); | |
| 44 | + let soil = match plant { | |
| 45 | + crate::world::Plant::Crop => soil, | |
| 46 | + _ => soil.max(WILD_MIN_FERTILITY), | |
| 47 | + }; | |
| 48 | + let grown = (growth + elapsed_ticks / ripening * soil).min(1.0); | |
| 49 | + map.set_growth(pos, grown); | |
| 50 | + if PlantLook::of(plant, growth) != PlantLook::of(plant, grown) { | |
| 51 | + on_change(pos); | |
| 52 | + } | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +fn grow_plants( | |
| 57 | + tick: Res<SimTick>, | |
| 58 | + mut map: ResMut<WorldMap>, | |
| 59 | + mut changed: MessageWriter<TileChanged>, | |
| 60 | +) { | |
| 61 | + if !tick.0.is_multiple_of(GROWTH_INTERVAL_TICKS) { | |
| 62 | + return; | |
| 63 | + } | |
| 64 | + grow(&mut map, GROWTH_INTERVAL_TICKS as f32, |pos| { | |
| 65 | + changed.write(TileChanged(pos)); | |
| 66 | + }); | |
| 67 | +} | |
| 68 | + | |
| 69 | +#[cfg(test)] | |
| 70 | +mod tests { | |
| 71 | + use super::*; | |
| 72 | + use crate::world::Plant; | |
| 73 | + | |
| 74 | + fn growth_after(terrain: Terrain, plant: Plant, ticks: f32) -> f32 { | |
| 75 | + let mut map = WorldMap::filled(UVec2::splat(2), terrain); | |
| 76 | + map.set_plant(UVec2::ZERO, Some(plant)); | |
| 77 | + grow(&mut map, ticks, |_| {}); | |
| 78 | + map.growth(IVec2::ZERO) | |
| 79 | + } | |
| 80 | + | |
| 81 | + #[test] | |
| 82 | + fn crops_ripen_faster_on_fertile_soil() { | |
| 83 | + let day = 24.0 * crate::sim::TICKS_PER_HOUR as f32; | |
| 84 | + assert!( | |
| 85 | + growth_after(Terrain::Soil, Plant::Crop, day) | |
| 86 | + > growth_after(Terrain::Sand, Plant::Crop, day) | |
| 87 | + ); | |
| 88 | + assert_eq!(growth_after(Terrain::Soil, Plant::Crop, 2.0 * day), 1.0); | |
| 89 | + } | |
| 90 | + | |
| 91 | + #[test] | |
| 92 | + fn trees_do_not_grow_and_stage_changes_are_reported() { | |
| 93 | + assert_eq!(growth_after(Terrain::Soil, Plant::Tree, 1e9), 0.0); | |
| 94 | + let mut map = WorldMap::filled(UVec2::splat(2), Terrain::Soil); | |
| 95 | + map.set_plant(UVec2::ZERO, Some(Plant::Crop)); | |
| 96 | + let mut changes = 0; | |
| 97 | + grow(&mut map, 1e9, |_| changes += 1); | |
| 98 | + assert_eq!(changes, 1); | |
| 99 | + } | |
| 100 | +} | |
src/selection.rs +99 -44
| @@ -5,6 +5,7 @@ use crate::{ | ||
| 5 | 5 | building::Blueprints, |
| 6 | 6 | camera::CameraController, |
| 7 | 7 | colonist::{Colonist, Path}, |
| 8 | + food::Hunger, | |
| 8 | 9 | items::{Carrying, Item, ItemKind, stock}, |
| 9 | 10 | jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind}, |
| 10 | 11 | light::daylight, |
| @@ -236,7 +237,15 @@ fn update_tile_info( | ||
| 236 | 237 | map: Res<WorldMap>, |
| 237 | 238 | cursor: Res<Cursor>, |
| 238 | 239 | selection: Res<Selection>, |
| 239 | - colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying, &Rest)>, | |
| 240 | + colonists: Query<( | |
| 241 | + &Colonist, | |
| 242 | + &TilePos, | |
| 243 | + &Path, | |
| 244 | + &CurrentJob, | |
| 245 | + &Carrying, | |
| 246 | + &Rest, | |
| 247 | + &Hunger, | |
| 248 | + )>, | |
| 240 | 249 | items: Query<(&Item, &TilePos)>, |
| 241 | 250 | structures: Query<(&Structure, &TilePos, Option<&Fuel>)>, |
| 242 | 251 | blueprints: Res<Blueprints>, |
| @@ -254,8 +263,16 @@ fn update_tile_info( | ||
| 254 | 263 | Selection::Tile(tile) => describe_tile(&map, Some(tile)), |
| 255 | 264 | Selection::Colonist(entity) => colonists |
| 256 | 265 | .get(entity) |
| 257 | - .map(|(colonist, tile, path, job, carrying, rest)| { | |
| 258 | - describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0, rest.0) | |
| 266 | + .map(|(colonist, tile, path, job, carrying, rest, hunger)| { | |
| 267 | + describe_colonist(&ColonistView { | |
| 268 | + name: &colonist.name, | |
| 269 | + tile: tile.0, | |
| 270 | + job: job.0, | |
| 271 | + steps_left: path.0.len(), | |
| 272 | + carrying: carrying.0, | |
| 273 | + rest: rest.0, | |
| 274 | + fed: hunger.0, | |
| 275 | + }) | |
| 259 | 276 | }) |
| 260 | 277 | .unwrap_or_else(|_| "-".into()), |
| 261 | 278 | }; |
| @@ -266,17 +283,21 @@ fn update_tile_info( | ||
| 266 | 283 | ); |
| 267 | 284 | let speed = describe_speed(*speed, time.is_paused()); |
| 268 | 285 | let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect(); |
| 269 | - let materials = ItemKind::ALL | |
| 270 | - .iter() | |
| 271 | - .map(|&kind| { | |
| 272 | - let have = stock( | |
| 273 | - items.iter().map(|(item, _)| item).chain(carried.iter()), | |
| 274 | - kind, | |
| 275 | - ); | |
| 276 | - format!("{kind:?} {have}/{}", goals.goal(kind)) | |
| 277 | - }) | |
| 278 | - .collect::<Vec<_>>() | |
| 279 | - .join(" "); | |
| 286 | + let stock_line = |food: bool| { | |
| 287 | + ItemKind::ALL | |
| 288 | + .iter() | |
| 289 | + .filter(|kind| kind.is_food() == food) | |
| 290 | + .map(|&kind| { | |
| 291 | + let have = stock( | |
| 292 | + items.iter().map(|(item, _)| item).chain(carried.iter()), | |
| 293 | + kind, | |
| 294 | + ); | |
| 295 | + format!("{kind:?} {have}/{}", goals.goal(kind)) | |
| 296 | + }) | |
| 297 | + .collect::<Vec<_>>() | |
| 298 | + .join(" ") | |
| 299 | + }; | |
| 300 | + let materials = format!("{}\n{}", stock_line(false), stock_line(true)); | |
| 280 | 301 | let hovered_zone = match cursor.tile { |
| 281 | 302 | Some(tile) if zones.is_stockpile(tile) => ", Stockpile", |
| 282 | 303 | _ => "", |
| @@ -321,7 +342,11 @@ pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String { | ||
| 321 | 342 | let roof = if map.is_roofed(p) { ", Roofed" } else { "" }; |
| 322 | 343 | let plant = map |
| 323 | 344 | .plant(p) |
| 324 | - .map(|plant| format!(", {plant:?}")) | |
| 345 | + .map(|plant| match plant.ripening_ticks() { | |
| 346 | + None => format!(", {plant:?}"), | |
| 347 | + Some(_) if plant.is_ripe(map.growth(p)) => format!(", {plant:?} (ripe)"), | |
| 348 | + Some(_) => format!(", {plant:?} ({:.0}% grown)", map.growth(p) * 100.0), | |
| 349 | + }) | |
| 325 | 350 | .unwrap_or_default(); |
| 326 | 351 | let height = map.height(p).unwrap_or(0.0); |
| 327 | 352 | format!("({}, {}) {terrain:?}{plant}{roof}, {height:.0} m", t.x, t.y) |
| @@ -367,15 +392,19 @@ fn percent(task: Task) -> u32 { | ||
| 367 | 392 | (progress_fraction(task).unwrap_or(0.0) * 100.0).round() as u32 |
| 368 | 393 | } |
| 369 | 394 | |
| 370 | -pub fn describe_colonist( | |
| 371 | - colonist: &Colonist, | |
| 372 | - tile: UVec2, | |
| 373 | - job: Option<Job>, | |
| 374 | - steps_left: usize, | |
| 375 | - carrying: Option<Item>, | |
| 376 | - rest: f32, | |
| 377 | -) -> String { | |
| 378 | - let activity = match (job.map(|j| j.task), steps_left) { | |
| 395 | +pub struct ColonistView<'a> { | |
| 396 | + pub name: &'a str, | |
| 397 | + pub tile: UVec2, | |
| 398 | + pub job: Option<Job>, | |
| 399 | + pub steps_left: usize, | |
| 400 | + pub carrying: Option<Item>, | |
| 401 | + pub rest: f32, | |
| 402 | + pub fed: f32, | |
| 403 | +} | |
| 404 | + | |
| 405 | +pub fn describe_colonist(view: &ColonistView) -> String { | |
| 406 | + let (tile, job, carrying) = (view.tile, view.job, view.carrying); | |
| 407 | + let activity = match (job.map(|j| j.task), view.steps_left) { | |
| 379 | 408 | (None, 0) => "idle".to_string(), |
| 380 | 409 | (None, _) => "wandering".to_string(), |
| 381 | 410 | (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"), |
| @@ -393,10 +422,18 @@ pub fn describe_colonist( | ||
| 393 | 422 | }), |
| 394 | 423 | n, |
| 395 | 424 | ) if n > 0 => "walking to rock".to_string(), |
| 425 | + ( | |
| 426 | + Some(Task::Work { | |
| 427 | + kind: WorkKind::Harvest, | |
| 428 | + .. | |
| 429 | + }), | |
| 430 | + n, | |
| 431 | + ) if n > 0 => "walking to a plant".to_string(), | |
| 396 | 432 | (Some(task @ Task::Work { kind, .. }), _) => { |
| 397 | 433 | let verb = match kind { |
| 398 | 434 | WorkKind::Chop => "chopping a tree", |
| 399 | 435 | WorkKind::Mine => "mining", |
| 436 | + WorkKind::Harvest => "harvesting", | |
| 400 | 437 | }; |
| 401 | 438 | format!("{verb}, {}%", percent(task)) |
| 402 | 439 | } |
| @@ -426,6 +463,13 @@ pub fn describe_colonist( | ||
| 426 | 463 | } |
| 427 | 464 | (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(), |
| 428 | 465 | (Some(Task::Sleep { .. }), _) => "sleeping".to_string(), |
| 466 | + ( | |
| 467 | + Some(Task::Eat { | |
| 468 | + pickup: Some(_), .. | |
| 469 | + }), | |
| 470 | + _, | |
| 471 | + ) => "fetching food".to_string(), | |
| 472 | + (Some(Task::Eat { .. }), _) => "eating".to_string(), | |
| 429 | 473 | (Some(task @ Task::Build { kind, .. }), _) => { |
| 430 | 474 | format!("building a {}, {}%", kind.name(), percent(task)) |
| 431 | 475 | } |
| @@ -439,11 +483,12 @@ pub fn describe_colonist( | ||
| 439 | 483 | "" |
| 440 | 484 | }; |
| 441 | 485 | format!( |
| 442 | - "{} at ({}, {}), {activity}{ordered}{load}, rest {:.0}%", | |
| 443 | - colonist.name, | |
| 486 | + "{} at ({}, {}), {activity}{ordered}{load}, rest {:.0}%, food {:.0}%", | |
| 487 | + view.name, | |
| 444 | 488 | tile.x, |
| 445 | 489 | tile.y, |
| 446 | - rest * 100.0 | |
| 490 | + view.rest * 100.0, | |
| 491 | + view.fed * 100.0 | |
| 447 | 492 | ) |
| 448 | 493 | } |
| 449 | 494 | |
| @@ -620,21 +665,31 @@ mod tests { | ||
| 620 | 665 | assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED"); |
| 621 | 666 | } |
| 622 | 667 | |
| 668 | + fn view(job: Option<Job>, steps_left: usize, carrying: Option<Item>) -> ColonistView<'static> { | |
| 669 | + ColonistView { | |
| 670 | + name: "Ada", | |
| 671 | + tile: UVec2::new(1, 2), | |
| 672 | + job, | |
| 673 | + steps_left, | |
| 674 | + carrying, | |
| 675 | + rest: 1.0, | |
| 676 | + fed: 0.5, | |
| 677 | + } | |
| 678 | + } | |
| 679 | + | |
| 623 | 680 | #[test] |
| 624 | - fn describe_colonist_shows_activity() { | |
| 625 | - let ada = Colonist { name: "Ada".into() }; | |
| 626 | - let tile = UVec2::new(1, 2); | |
| 681 | + fn describe_colonist_shows_activity_and_needs() { | |
| 627 | 682 | assert_eq!( |
| 628 | - describe_colonist(&ada, tile, None, 0, None, 1.0), | |
| 629 | - "Ada at (1, 2), idle, rest 100%" | |
| 683 | + describe_colonist(&view(None, 0, None)), | |
| 684 | + "Ada at (1, 2), idle, rest 100%, food 50%" | |
| 630 | 685 | ); |
| 631 | 686 | let ordered_move = Job { |
| 632 | 687 | task: Task::MoveTo(UVec2::ZERO), |
| 633 | 688 | ordered: true, |
| 634 | 689 | }; |
| 635 | 690 | assert_eq!( |
| 636 | - describe_colonist(&ada, tile, Some(ordered_move), 3, None, 1.0), | |
| 637 | - "Ada at (1, 2), moving, 3 steps left (ordered), rest 100%" | |
| 691 | + describe_colonist(&view(Some(ordered_move), 3, None)), | |
| 692 | + "Ada at (1, 2), moving, 3 steps left (ordered), rest 100%, food 50%" | |
| 638 | 693 | ); |
| 639 | 694 | let chop = Job { |
| 640 | 695 | task: Task::Work { |
| @@ -645,23 +700,23 @@ mod tests { | ||
| 645 | 700 | ordered: false, |
| 646 | 701 | }; |
| 647 | 702 | assert_eq!( |
| 648 | - describe_colonist(&ada, tile, Some(chop), 0, None, 0.5), | |
| 649 | - "Ada at (1, 2), chopping a tree, 50%, rest 50%" | |
| 703 | + describe_colonist(&view(Some(chop), 0, None)), | |
| 704 | + "Ada at (1, 2), chopping a tree, 50%, rest 100%, food 50%" | |
| 650 | 705 | ); |
| 651 | - let haul = Job { | |
| 652 | - task: Task::Haul { | |
| 706 | + let eat = Job { | |
| 707 | + task: Task::Eat { | |
| 653 | 708 | pickup: None, |
| 654 | - to: UVec2::ZERO, | |
| 709 | + progress: 0, | |
| 655 | 710 | }, |
| 656 | 711 | ordered: false, |
| 657 | 712 | }; |
| 658 | - let wood = Item { | |
| 659 | - kind: ItemKind::Wood, | |
| 660 | - count: 10, | |
| 713 | + let meal = Item { | |
| 714 | + kind: ItemKind::Meal, | |
| 715 | + count: 1, | |
| 661 | 716 | }; |
| 662 | 717 | assert_eq!( |
| 663 | - describe_colonist(&ada, tile, Some(haul), 4, Some(wood), 0.64), | |
| 664 | - "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood, rest 64%" | |
| 718 | + describe_colonist(&view(Some(eat), 0, Some(meal))), | |
| 719 | + "Ada at (1, 2), eating, carrying 1 Meal, rest 100%, food 50%" | |
| 665 | 720 | ); |
| 666 | 721 | } |
| 667 | 722 | } |
src/tilemap.rs +94 -6
| @@ -85,7 +85,10 @@ fn spawn_chunks( | ||
| 85 | 85 | Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(), |
| 86 | 86 | )); |
| 87 | 87 | let plants = images.add(stack_tileset( |
| 88 | - Plant::ALL.iter().map(|&p| plant_texture(p)).collect(), | |
| 88 | + PlantLook::ALL | |
| 89 | + .iter() | |
| 90 | + .map(|&look| plant_texture(look)) | |
| 91 | + .collect(), | |
| 89 | 92 | )); |
| 90 | 93 | let zone = images.add(stack_tileset(vec![zone_texture()])); |
| 91 | 94 | let roof = images.add(stack_tileset(vec![roof_texture()])); |
| @@ -209,7 +212,7 @@ pub fn plant_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Optio | ||
| 209 | 212 | chunk_positions(origin) |
| 210 | 213 | .map(|pos| { |
| 211 | 214 | map.plant(pos.as_ivec2()).map(|plant| TileData { |
| 212 | - tileset_index: plant.index(), | |
| 215 | + tileset_index: PlantLook::of(plant, map.growth(pos.as_ivec2())) as u16, | |
| 213 | 216 | color: tint(jitter(pos) * hillshade(map, pos), light.color(pos)), |
| 214 | 217 | ..default() |
| 215 | 218 | }) |
| @@ -282,12 +285,88 @@ fn roof_texture() -> Texture { | ||
| 282 | 285 | .collect() |
| 283 | 286 | } |
| 284 | 287 | |
| 285 | -fn plant_texture(plant: Plant) -> Texture { | |
| 286 | - match plant { | |
| 287 | - Plant::Tree => tree_texture(), | |
| 288 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 289 | +pub enum PlantLook { | |
| 290 | + Tree, | |
| 291 | + Bush, | |
| 292 | + RipeBush, | |
| 293 | + Sprout, | |
| 294 | + YoungCrop, | |
| 295 | + RipeCrop, | |
| 296 | +} | |
| 297 | + | |
| 298 | +impl PlantLook { | |
| 299 | + const ALL: [PlantLook; 6] = [ | |
| 300 | + PlantLook::Tree, | |
| 301 | + PlantLook::Bush, | |
| 302 | + PlantLook::RipeBush, | |
| 303 | + PlantLook::Sprout, | |
| 304 | + PlantLook::YoungCrop, | |
| 305 | + PlantLook::RipeCrop, | |
| 306 | + ]; | |
| 307 | + | |
| 308 | + pub fn of(plant: Plant, growth: f32) -> Self { | |
| 309 | + match plant { | |
| 310 | + Plant::Tree => PlantLook::Tree, | |
| 311 | + Plant::BerryBush if plant.is_ripe(growth) => PlantLook::RipeBush, | |
| 312 | + Plant::BerryBush => PlantLook::Bush, | |
| 313 | + Plant::Crop if plant.is_ripe(growth) => PlantLook::RipeCrop, | |
| 314 | + Plant::Crop if growth >= SPROUT_UNTIL => PlantLook::YoungCrop, | |
| 315 | + Plant::Crop => PlantLook::Sprout, | |
| 316 | + } | |
| 317 | + } | |
| 318 | +} | |
| 319 | + | |
| 320 | +pub const SPROUT_UNTIL: f32 = 0.4; | |
| 321 | + | |
| 322 | +fn plant_texture(look: PlantLook) -> Texture { | |
| 323 | + let leaves = Srgba::rgb_u8(52, 110, 50); | |
| 324 | + let berry = Srgba::rgb_u8(190, 40, 90); | |
| 325 | + let stalk = Srgba::rgb_u8(110, 160, 60); | |
| 326 | + let grain = Srgba::rgb_u8(220, 190, 80); | |
| 327 | + match look { | |
| 328 | + PlantLook::Tree => tree_texture(), | |
| 329 | + PlantLook::Bush => blob_texture(5.0, leaves, None), | |
| 330 | + PlantLook::RipeBush => blob_texture(5.0, leaves, Some(berry)), | |
| 331 | + PlantLook::Sprout => rows_texture(3, stalk, None), | |
| 332 | + PlantLook::YoungCrop => rows_texture(7, stalk, None), | |
| 333 | + PlantLook::RipeCrop => rows_texture(10, stalk, Some(grain)), | |
| 288 | 334 | } |
| 289 | 335 | } |
| 290 | 336 | |
| 337 | +fn blob_texture(radius: f32, color: Srgba, dots: Option<Srgba>) -> Texture { | |
| 338 | + (0..TEXTURE_SIZE * TEXTURE_SIZE) | |
| 339 | + .map(|i| { | |
| 340 | + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); | |
| 341 | + let d = Vec2::new(x as f32, y as f32).distance(Vec2::new(7.5, 8.5)); | |
| 342 | + if d > radius { | |
| 343 | + return [0, 0, 0, 0]; | |
| 344 | + } | |
| 345 | + match dots { | |
| 346 | + Some(dot) if hash2(x, y) % 5 == 0 => dot.to_u8_array(), | |
| 347 | + _ => color.to_u8_array(), | |
| 348 | + } | |
| 349 | + }) | |
| 350 | + .collect() | |
| 351 | +} | |
| 352 | + | |
| 353 | +fn rows_texture(height: u32, stalk: Srgba, head: Option<Srgba>) -> Texture { | |
| 354 | + (0..TEXTURE_SIZE * TEXTURE_SIZE) | |
| 355 | + .map(|i| { | |
| 356 | + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); | |
| 357 | + let in_row = x % 4 == 1 || x % 4 == 2; | |
| 358 | + let top = TEXTURE_SIZE - 2 - height; | |
| 359 | + if !in_row || y < top || y >= TEXTURE_SIZE - 2 { | |
| 360 | + return [0, 0, 0, 0]; | |
| 361 | + } | |
| 362 | + match head { | |
| 363 | + Some(head) if y < top + 3 => head.to_u8_array(), | |
| 364 | + _ => stalk.to_u8_array(), | |
| 365 | + } | |
| 366 | + }) | |
| 367 | + .collect() | |
| 368 | +} | |
| 369 | + | |
| 291 | 370 | fn tree_texture() -> Texture { |
| 292 | 371 | let leaves = Srgba::rgb_u8(38, 92, 42); |
| 293 | 372 | let highlight = Srgba::rgb_u8(66, 126, 58); |
| @@ -435,12 +514,21 @@ mod tests { | ||
| 435 | 514 | } |
| 436 | 515 | |
| 437 | 516 | #[test] |
| 517 | + fn plants_look_different_as_they_grow() { | |
| 518 | + assert_eq!(PlantLook::of(Plant::Crop, 0.1), PlantLook::Sprout); | |
| 519 | + assert_eq!(PlantLook::of(Plant::Crop, 0.7), PlantLook::YoungCrop); | |
| 520 | + assert_eq!(PlantLook::of(Plant::Crop, 1.0), PlantLook::RipeCrop); | |
| 521 | + assert_eq!(PlantLook::of(Plant::BerryBush, 0.5), PlantLook::Bush); | |
| 522 | + assert_eq!(PlantLook::of(Plant::BerryBush, 1.0), PlantLook::RipeBush); | |
| 523 | + } | |
| 524 | + | |
| 525 | + #[test] | |
| 438 | 526 | fn plant_layer_only_has_tiles_where_plants_grow() { |
| 439 | 527 | let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass); |
| 440 | 528 | map.set_plant(UVec2::new(1, 0), Some(Plant::Tree)); |
| 441 | 529 | let tiles = plant_tiles(&map, &LightMap::default(), UVec2::ZERO); |
| 442 | 530 | assert!(tiles[0].is_none()); |
| 443 | - assert_eq!(tiles[1].unwrap().tileset_index, Plant::Tree.index()); | |
| 531 | + assert_eq!(tiles[1].unwrap().tileset_index, PlantLook::Tree as u16); | |
| 444 | 532 | } |
| 445 | 533 | |
| 446 | 534 | // Light comes from the north-west (LIGHT has -x, +y), the convention for hillshaded maps. |
src/world.rs +43 -9
| @@ -75,20 +75,31 @@ impl Terrain { | ||
| 75 | 75 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 76 | 76 | pub enum Plant { |
| 77 | 77 | Tree, |
| 78 | + BerryBush, | |
| 79 | + Crop, | |
| 78 | 80 | } |
| 79 | 81 | |
| 80 | 82 | impl Plant { |
| 81 | - pub const ALL: [Plant; 1] = [Plant::Tree]; | |
| 82 | - | |
| 83 | - pub fn index(self) -> u16 { | |
| 84 | - self as u16 | |
| 85 | - } | |
| 86 | - | |
| 87 | 83 | pub fn extra_move_cost(self) -> u32 { |
| 88 | 84 | match self { |
| 89 | 85 | Plant::Tree => 100, |
| 86 | + Plant::BerryBush => 60, | |
| 87 | + Plant::Crop => 30, | |
| 90 | 88 | } |
| 91 | 89 | } |
| 90 | + | |
| 91 | + pub fn ripening_ticks(self) -> Option<f32> { | |
| 92 | + let days = match self { | |
| 93 | + Plant::Tree => return None, | |
| 94 | + Plant::BerryBush => 2.0, | |
| 95 | + Plant::Crop => 1.5, | |
| 96 | + }; | |
| 97 | + Some(days * 24.0 * crate::sim::TICKS_PER_HOUR as f32) | |
| 98 | + } | |
| 99 | + | |
| 100 | + pub fn is_ripe(self, growth: f32) -> bool { | |
| 101 | + self.ripening_ticks().is_some() && growth >= 1.0 | |
| 102 | + } | |
| 92 | 103 | } |
| 93 | 104 | |
| 94 | 105 | #[derive(Component, Default, Debug, Clone, Copy, PartialEq)] |
| @@ -105,6 +116,7 @@ pub struct WorldMap { | ||
| 105 | 116 | heights: Vec<f32>, |
| 106 | 117 | walls: Vec<bool>, |
| 107 | 118 | roofs: Vec<bool>, |
| 119 | + growth: Vec<f32>, | |
| 108 | 120 | } |
| 109 | 121 | |
| 110 | 122 | impl WorldMap { |
| @@ -117,6 +129,7 @@ impl WorldMap { | ||
| 117 | 129 | heights: vec![0.0; n], |
| 118 | 130 | walls: vec![false; n], |
| 119 | 131 | roofs: vec![false; n], |
| 132 | + growth: vec![0.0; n], | |
| 120 | 133 | } |
| 121 | 134 | } |
| 122 | 135 | |
| @@ -168,6 +181,23 @@ impl WorldMap { | ||
| 168 | 181 | self.walls[i] = wall; |
| 169 | 182 | } |
| 170 | 183 | |
| 184 | + pub fn growth(&self, pos: IVec2) -> f32 { | |
| 185 | + if self.in_bounds(pos) { | |
| 186 | + self.growth[self.index(pos.as_uvec2())] | |
| 187 | + } else { | |
| 188 | + 0.0 | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + pub fn set_growth(&mut self, pos: UVec2, growth: f32) { | |
| 193 | + let i = self.index(pos); | |
| 194 | + self.growth[i] = growth.clamp(0.0, 1.0); | |
| 195 | + } | |
| 196 | + | |
| 197 | + pub fn ripe_plant(&self, pos: IVec2) -> Option<Plant> { | |
| 198 | + self.plant(pos).filter(|p| p.is_ripe(self.growth(pos))) | |
| 199 | + } | |
| 200 | + | |
| 171 | 201 | pub fn is_roofed(&self, pos: IVec2) -> bool { |
| 172 | 202 | self.in_bounds(pos) && self.roofs[self.index(pos.as_uvec2())] |
| 173 | 203 | } |
| @@ -262,6 +292,13 @@ mod tests { | ||
| 262 | 292 | } |
| 263 | 293 | |
| 264 | 294 | #[test] |
| 295 | + fn bushes_and_crops_ripen_but_trees_do_not() { | |
| 296 | + assert!(Plant::BerryBush.is_ripe(1.0)); | |
| 297 | + assert!(!Plant::Crop.is_ripe(0.9)); | |
| 298 | + assert!(!Plant::Tree.is_ripe(1.0)); | |
| 299 | + } | |
| 300 | + | |
| 301 | + #[test] | |
| 265 | 302 | fn walls_block_movement() { |
| 266 | 303 | let mut map = WorldMap::filled(UVec2::new(2, 1), Terrain::Grass); |
| 267 | 304 | map.set_wall(UVec2::ZERO, true); |
| @@ -291,8 +328,5 @@ mod tests { | ||
| 291 | 328 | for (i, t) in Terrain::ALL.iter().enumerate() { |
| 292 | 329 | assert_eq!(t.index() as usize, i); |
| 293 | 330 | } |
| 294 | - for (i, p) in Plant::ALL.iter().enumerate() { | |
| 295 | - assert_eq!(p.index() as usize, i); | |
| 296 | - } | |
| 297 | 331 | } |
| 298 | 332 | } |
src/worldgen.rs +19 -1
| @@ -29,6 +29,7 @@ const FOREST_SCALE: f32 = 1.0 / 32.0; | ||
| 29 | 29 | const FOREST_SHARE: f32 = 0.35; |
| 30 | 30 | const FOREST_TREE_PERCENT: u32 = 45; |
| 31 | 31 | const LONE_TREE_PERCENT: u32 = 3; |
| 32 | +const BERRY_BUSH_PERCENT: u32 = 2; | |
| 32 | 33 | const RIVER_COUNT: usize = 4; |
| 33 | 34 | const RIVER_CANDIDATES: usize = 40; |
| 34 | 35 | const RIVER_SOURCE_SPACING: f32 = 40.0; |
| @@ -142,8 +143,12 @@ pub fn generate(size: UVec2, seed: u32) -> WorldMap { | ||
| 142 | 143 | } else { |
| 143 | 144 | LONE_TREE_PERCENT |
| 144 | 145 | }; |
| 145 | - if fertile && hash2(pos.x ^ seed, pos.y) % 100 < percent { | |
| 146 | + let roll = hash2(pos.x ^ seed, pos.y) % 100; | |
| 147 | + if fertile && roll < percent { | |
| 146 | 148 | map.set_plant(pos, Some(Plant::Tree)); |
| 149 | + } else if fertile && roll >= 100 - BERRY_BUSH_PERCENT { | |
| 150 | + map.set_plant(pos, Some(Plant::BerryBush)); | |
| 151 | + map.set_growth(pos, (hash2(pos.y ^ seed, pos.x) % 140) as f32 / 100.0); | |
| 147 | 152 | } |
| 148 | 153 | } |
| 149 | 154 | map |
| @@ -402,6 +407,19 @@ mod tests { | ||
| 402 | 407 | } |
| 403 | 408 | |
| 404 | 409 | #[test] |
| 410 | + fn worlds_have_berry_bushes_some_of_them_ripe() { | |
| 411 | + let map = generate(SIZE, 2); | |
| 412 | + let bushes: Vec<_> = map | |
| 413 | + .positions() | |
| 414 | + .map(|p| p.as_ivec2()) | |
| 415 | + .filter(|&p| map.plant(p) == Some(Plant::BerryBush)) | |
| 416 | + .collect(); | |
| 417 | + assert!(bushes.len() > 20); | |
| 418 | + assert!(bushes.iter().any(|&p| map.ripe_plant(p).is_some())); | |
| 419 | + assert!(bushes.iter().any(|&p| map.ripe_plant(p).is_none())); | |
| 420 | + } | |
| 421 | + | |
| 422 | + #[test] | |
| 405 | 423 | fn trees_only_grow_on_grass_and_soil() { |
| 406 | 424 | let map = generate(SIZE, 2); |
| 407 | 425 | let trees: Vec<_> = map |