Add hunger, eating, berry bushes and foraging

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

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

@@ -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