Let forests grow back slowly main

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 19:58 UTC
commit
00eb022bc57951e0fa2de89243d0c2b9ed6360b5
parent
a2ad790e5c
tree
browse at this commit

6 files changed +173 -8

CLAUDE.md +1 -1

@@ -35,7 +35,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
35 35 - Refuel: `BuildKind::Refuel(kind)` (cost = kind.cost(), 60 work) reuses the Build task; `find_refuel` (before find_lighting) picks the lowest-fuel fire within 2×HOME_RADIUS of home below 1/REFUEL_BELOW_SHARE of full; completion resets Fuel to full.
36 36 - Camera zoom buttons: Action::ZoomIn/ZoomOut (=/-) → `button_zoom` using `CameraController::zoomed`.
37 37 - `src/food.rs` spoilage: `spoil_food` (every 60 ticks) adds `spoilage(kind, roofed, ticks)` to food stacks and carried food (shelf life meat 2 d, berries 3, meals 4, vegetables 5; ×0.5 under a roof); spoil ≥ 1 removes it. `Hunger(f32)` on colonists (1 = fed): `update_hunger` drains 1/24 per game hour (half while sleeping). HUNGRY 0.35 → Eat job; STARVING 0.05 interrupts autonomous work like exhaustion, but only when unreserved food exists (`food_to_fetch`), else colonists thrashed their job every tick. `find_food` prefers Meal stacks, then raw food; `portion(kind, fed)` = units to fill up. Eat: walk to pickup, take portion into `Carrying`, eat EAT_TICKS, add nutrition.
38 -- `src/plants.rs` `grow_plants` every GROWTH_INTERVAL_TICKS: plants with `ripening_ticks` (BerryBush 2 days, Crop 1.5) gain growth × `fertility(terrain)` (Soil 1, Grass .8, Sand .4, Gravel .3; wild bushes ≥ .8); writes TileChanged when `PlantLook` stage changes.
38 +- `src/plants.rs` `grow_plants` every GROWTH_INTERVAL_TICKS: plants with `ripening_ticks` (BerryBush 2 days, Crop 1.5) gain growth × `fertility(terrain)` (Soil 1, Grass .8, Sand .4, Gravel .3; wild bushes ≥ .8); writes TileChanged when `PlantLook` stage changes. Forest regrowth: `WorldMap::forest` mask (worldgen forest-noise tiles, regenerated on load) ; `spread_trees` hourly samples SPREAD_SAMPLES random tiles, `seed_chance` = SEED_CHANCE × adjacent trees (`Plant::is_tree`: Tree/Sapling) on free forest Grass/Soil (no plant/wall/roof/wear/zone/blueprint, > CLEARING_RADIUS 12 from colony home, < FOREST_DENSITY 9 trees in the 5×5) → `Plant::Sapling` (appended last for save compat; ripens 8 days, not choppable, `PlantLook::Sapling`) which `grow` turns into Tree (growth 0). On the 50-colonist save: +~1200 trees over 60 days, then levels off.
39 39 - `src/animals.rs` `Animal{kind: AnimalKind::{Deer, Rabbit}}` entities (Path/TilePos/SimPos/MoveSpeed, moved by colonist `follow_path`; ellipse-body visuals, z=0.9). `populate` on WorldGenerated (population per kind, ≥25 tiles from colony), `migrate` refills one per kind every 2 game hours, `wander` idles 2–8 s then walks ≤10 tiles; an animal reserved as `Target::Animal` (being hunted) stands still. meat 30/8, hunt_work 360/160.
40 40 - Hunting/animals (2nd pass): wild animals flee (`flee_goal`, FLEE_RANGE 4, run FLEE_DISTANCE) from colonists, abandoning paths that end near a threat; hunters shoot from ≤ HUNT_RANGE 6 with `line_of_sight` (repath when the path end drifts > HUNT_RANGE/2 from prey); kills drop `ItemKind::Carcass` (meat count, RawFood stock group, spoils like meat, not edible); `find_butchering` (before food gathering) → `Task::Butcher{site}` converts the carcass stack into Meat in place after BUTCHER_WORK. `Tame` animals: `Task::Tame` (adjacent, kind.tame_work; target stands still while being tamed) via `find_taming` when meat isn't wanted and < MAX_TAME 6; tame animals don't flee, wander within PEN_RADIUS of home, `breed` daily (≥2 of a kind, 50%), excluded from migration counts. `find_hunt` (`Wildlife` = (entity, tile, kind, tame)) takes spare tame animals (herd > 2) before wild ones.
41 41 - `src/farming.rs` `plan_farm` (every 600 ticks, once a stockpile exists and no farm): `FarmContext::plan` picks the FARM_SIZE² square near home (BFS corners) maximising soil − distance where every tile is farmable, plant-free and ≥ FARM_CLEARANCE from stockpile, blueprints, structures and items.

src/plants.rs +122 -4

@@ -1,22 +1,32 @@
1 1 use bevy::prelude::*;
2 2
3 3 use crate::{
4 + building::Blueprints,
4 5 climate::{FROST_TEMP, growth_factor, outdoor_temperature},
5 - sim::{SimSystems, SimTick},
6 + rng::SimRng,
7 + sim::{SimSystems, SimTick, TICKS_PER_HOUR},
6 8 tilemap::PlantLook,
7 - world::{Terrain, TileChanged, WorldMap},
9 + world::{Plant, Terrain, TileChanged, WorldMap},
10 + zones::Zones,
8 11 };
9 12
10 13 pub struct PlantsPlugin;
11 14
12 15 impl Plugin for PlantsPlugin {
13 16 fn build(&self, app: &mut App) {
14 - app.add_systems(FixedUpdate, grow_plants.in_set(SimSystems::Act));
17 + app.add_systems(
18 + FixedUpdate,
19 + (grow_plants, spread_trees).in_set(SimSystems::Act),
20 + );
15 21 }
16 22 }
17 23
18 24 pub const GROWTH_INTERVAL_TICKS: u64 = 60;
19 25 const WILD_MIN_FERTILITY: f32 = 0.8;
26 +pub const SPREAD_SAMPLES: usize = 2000;
27 +pub const SEED_CHANCE: f32 = 0.03;
28 +pub const FOREST_DENSITY: usize = 9;
29 +pub const CLEARING_RADIUS: i32 = 12;
20 30
21 31 pub fn fertility(terrain: Terrain) -> f32 {
22 32 match terrain {
@@ -58,6 +68,12 @@ pub fn grow(
58 68 _ => soil.max(WILD_MIN_FERTILITY),
59 69 };
60 70 let grown = (growth + elapsed_ticks / ripening * soil * warmth).min(1.0);
71 + if plant == Plant::Sapling && grown >= 1.0 {
72 + map.set_plant(pos, Some(Plant::Tree));
73 + map.set_growth(pos, 0.0);
74 + on_change(pos);
75 + continue;
76 + }
61 77 map.set_growth(pos, grown);
62 78 if PlantLook::of(plant, growth) != PlantLook::of(plant, grown) {
63 79 on_change(pos);
@@ -79,10 +95,68 @@ fn grow_plants(
79 95 });
80 96 }
81 97
98 +pub fn seed_chance(map: &WorldMap, zones: &Zones, blueprints: &Blueprints, pos: UVec2) -> f32 {
99 + let p = pos.as_ivec2();
100 + let open = map.is_forest(pos)
101 + && matches!(map.get(p), Some(Terrain::Grass | Terrain::Soil))
102 + && map.plant(p).is_none()
103 + && !map.is_wall(p)
104 + && !map.is_roofed(p)
105 + && map.wear(p) == 0.0
106 + && !zones.is_stockpile(pos)
107 + && !zones.is_farm(pos)
108 + && blueprints.get(pos).is_none()
109 + && (p - zones.home(map.size / 2).as_ivec2())
110 + .abs()
111 + .max_element()
112 + > CLEARING_RADIUS;
113 + if !open {
114 + return 0.0;
115 + }
116 + let tree = |d: IVec2| map.plant(p + d).is_some_and(|t| t.is_tree());
117 + let mut near = 0;
118 + let mut around = 0;
119 + for y in -2..=2 {
120 + for x in -2..=2 {
121 + let d = IVec2::new(x, y);
122 + if tree(d) {
123 + around += 1;
124 + near += (d.abs().max_element() == 1) as usize;
125 + }
126 + }
127 + }
128 + if around >= FOREST_DENSITY {
129 + return 0.0;
130 + }
131 + SEED_CHANCE * near as f32
132 +}
133 +
134 +fn spread_trees(
135 + tick: Res<SimTick>,
136 + zones: Res<Zones>,
137 + blueprints: Res<Blueprints>,
138 + mut map: ResMut<WorldMap>,
139 + mut rng: ResMut<SimRng>,
140 + mut changed: MessageWriter<TileChanged>,
141 +) {
142 + if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
143 + return;
144 + }
145 + let size = map.size.as_ivec2();
146 + for _ in 0..SPREAD_SAMPLES {
147 + let pos = IVec2::new(rng.range_i32(0..size.x), rng.range_i32(0..size.y)).as_uvec2();
148 + let chance = seed_chance(&map, &zones, &blueprints, pos);
149 + if chance > 0.0 && rng.range_f32(0.0..1.0) < chance {
150 + map.set_plant(pos, Some(Plant::Sapling));
151 + map.set_growth(pos, 0.0);
152 + changed.write(TileChanged(pos));
153 + }
154 + }
155 +}
156 +
82 157 #[cfg(test)]
83 158 mod tests {
84 159 use super::*;
85 - use crate::world::Plant;
86 160
87 161 const SUMMER: f32 = 20.0;
88 162
@@ -129,4 +203,48 @@ mod tests {
129 203 assert_eq!(map.plant(IVec2::new(1, 0)), Some(Plant::Crop));
130 204 assert_eq!(map.plant(IVec2::new(2, 0)), Some(Plant::BerryBush));
131 205 }
206 +
207 + // Cut forest grows back from neighbouring trees: saplings appear on free forest tiles next
208 + // to trees and mature into trees after days. Paths, zones, buildings and the colony core stay
209 + // clear, open grassland never turns into forest, and forests don't grow denser than worldgen
210 + // made them.
211 + #[test]
212 + fn forests_reseed_cleared_tiles_and_saplings_mature() {
213 + let mut map = WorldMap::filled(UVec2::splat(40), Terrain::Grass);
214 + let mut zones = Zones::default();
215 + zones.add_zone(
216 + crate::zones::ZoneKind::Stockpile,
217 + &map,
218 + IVec2::new(1, 1),
219 + IVec2::new(2, 2),
220 + );
221 + for x in 25..35 {
222 + for y in 25..35 {
223 + map.set_forest(UVec2::new(x, y), true);
224 + }
225 + }
226 + map.set_plant(UVec2::new(30, 30), Some(Plant::Tree));
227 + let none = Blueprints::default();
228 + assert!(seed_chance(&map, &zones, &none, UVec2::new(31, 30)) > 0.0);
229 + assert_eq!(seed_chance(&map, &zones, &none, UVec2::new(33, 30)), 0.0);
230 + map.set_plant(UVec2::new(24, 30), Some(Plant::Tree));
231 + assert_eq!(seed_chance(&map, &zones, &none, UVec2::new(23, 30)), 0.0);
232 + map.set_wear(UVec2::new(29, 30), 1.0);
233 + assert_eq!(seed_chance(&map, &zones, &none, UVec2::new(29, 30)), 0.0);
234 + for x in 26..33 {
235 + for y in 28..33 {
236 + if (x + y) % 2 == 0 {
237 + map.set_plant(UVec2::new(x, y), Some(Plant::Tree));
238 + }
239 + }
240 + }
241 + assert_eq!(seed_chance(&map, &zones, &none, UVec2::new(29, 29)), 0.0);
242 +
243 + map.set_plant(UVec2::new(31, 34), Some(Plant::Sapling));
244 + let day = 24.0 * crate::sim::TICKS_PER_HOUR as f32;
245 + grow(&mut map, 7.0 * day, SUMMER, |_| {});
246 + assert_eq!(map.plant(IVec2::new(31, 34)), Some(Plant::Sapling));
247 + grow(&mut map, 3.0 * day, SUMMER, |_| {});
248 + assert_eq!(map.plant(IVec2::new(31, 34)), Some(Plant::Tree));
249 + }
132 250 }

src/tilemap.rs +23 -1

@@ -432,10 +432,11 @@ pub enum PlantLook {
432 432 RipeCrop,
433 433 Herb,
434 434 RipeHerb,
435 + Sapling,
435 436 }
436 437
437 438 impl PlantLook {
438 - const ALL: [PlantLook; 8] = [
439 + const ALL: [PlantLook; 9] = [
439 440 PlantLook::Tree,
440 441 PlantLook::Bush,
441 442 PlantLook::RipeBush,
@@ -444,6 +445,7 @@ impl PlantLook {
444 445 PlantLook::RipeCrop,
445 446 PlantLook::Herb,
446 447 PlantLook::RipeHerb,
448 + PlantLook::Sapling,
447 449 ];
448 450
449 451 pub fn of(plant: Plant, growth: f32) -> Self {
@@ -456,6 +458,7 @@ impl PlantLook {
456 458 Plant::Crop => PlantLook::Sprout,
457 459 Plant::Herb if plant.is_ripe(growth) => PlantLook::RipeHerb,
458 460 Plant::Herb => PlantLook::Herb,
461 + Plant::Sapling => PlantLook::Sapling,
459 462 }
460 463 }
461 464 }
@@ -493,6 +496,7 @@ fn plant_texture(look: PlantLook) -> Texture {
493 496 PlantLook::RipeCrop => rows_texture(10, stalk, Some(grain)),
494 497 PlantLook::Herb => blob_texture(3.0, herb, None),
495 498 PlantLook::RipeHerb => blob_texture(3.5, herb, Some(bloom)),
499 + PlantLook::Sapling => sapling_texture(),
496 500 }
497 501 }
498 502
@@ -529,6 +533,24 @@ fn rows_texture(height: u32, stalk: Srgba, head: Option<Srgba>) -> Texture {
529 533 .collect()
530 534 }
531 535
536 +fn sapling_texture() -> Texture {
537 + let leaves = Srgba::rgb_u8(66, 126, 58);
538 + let trunk = Srgba::rgb_u8(92, 64, 40);
539 + (0..TEXTURE_SIZE * TEXTURE_SIZE)
540 + .map(|i| {
541 + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
542 + let d = Vec2::new(x as f32, y as f32).distance(Vec2::new(7.5, 8.5));
543 + if d <= 3.0 {
544 + leaves.to_u8_array()
545 + } else if (7..=8).contains(&x) && (11..=13).contains(&y) {
546 + trunk.to_u8_array()
547 + } else {
548 + [0, 0, 0, 0]
549 + }
550 + })
551 + .collect()
552 +}
553 +
532 554 fn tree_texture() -> Texture {
533 555 let leaves = Srgba::rgb_u8(38, 92, 42);
534 556 let highlight = Srgba::rgb_u8(66, 126, 58);

src/world.rs +25 -2

@@ -98,17 +98,28 @@ pub enum Plant {
98 98 BerryBush,
99 99 Crop,
100 100 Herb,
101 + Sapling,
101 102 }
102 103
103 104 impl Plant {
104 - pub const ALL: [Plant; 4] = [Plant::Tree, Plant::BerryBush, Plant::Crop, Plant::Herb];
105 + pub const ALL: [Plant; 5] = [
106 + Plant::Tree,
107 + Plant::BerryBush,
108 + Plant::Crop,
109 + Plant::Herb,
110 + Plant::Sapling,
111 + ];
112 +
113 + pub fn is_tree(self) -> bool {
114 + matches!(self, Plant::Tree | Plant::Sapling)
115 + }
105 116
106 117 pub fn extra_move_cost(self) -> u32 {
107 118 match self {
108 119 Plant::Tree => 100,
109 120 Plant::BerryBush => 60,
110 121 Plant::Herb => 20,
111 - Plant::Crop => 30,
122 + Plant::Crop | Plant::Sapling => 30,
112 123 }
113 124 }
114 125
@@ -118,6 +129,7 @@ impl Plant {
118 129 Plant::BerryBush => 2.0,
119 130 Plant::Herb => 3.0,
120 131 Plant::Crop => 1.5,
132 + Plant::Sapling => 8.0,
121 133 };
122 134 Some(days * 24.0 * crate::sim::TICKS_PER_HOUR as f32)
123 135 }
@@ -167,6 +179,7 @@ pub struct WorldMap {
167 179 wear: Vec<f32>,
168 180 worn: HashSet<UVec2>,
169 181 growing: HashSet<UVec2>,
182 + forest: Vec<bool>,
170 183 shape_version: u64,
171 184 }
172 185
@@ -184,6 +197,7 @@ impl WorldMap {
184 197 wear: vec![0.0; n],
185 198 worn: HashSet::new(),
186 199 growing: HashSet::new(),
200 + forest: vec![false; n],
187 201 shape_version: 0,
188 202 }
189 203 }
@@ -225,6 +239,15 @@ impl WorldMap {
225 239 }
226 240 }
227 241
242 + pub fn is_forest(&self, pos: UVec2) -> bool {
243 + self.forest[self.index(pos)]
244 + }
245 +
246 + pub fn set_forest(&mut self, pos: UVec2, forest: bool) {
247 + let i = self.index(pos);
248 + self.forest[i] = forest;
249 + }
250 +
228 251 pub fn growing_plants(&self) -> Vec<UVec2> {
229 252 let mut tiles: Vec<UVec2> = self.growing.iter().copied().collect();
230 253 tiles.sort_by_key(|t| (t.y, t.x));

src/worldgen.rs +1 -0

@@ -139,6 +139,7 @@ pub fn generate(size: UVec2, seed: u32) -> WorldMap {
139 139 map.get(pos.as_ivec2()),
140 140 Some(Terrain::Grass | Terrain::Soil)
141 141 );
142 + map.set_forest(pos, forest[i] >= forest_min);
142 143 let percent = if forest[i] >= forest_min {
143 144 FOREST_TREE_PERCENT
144 145 } else {

tasks.md +1 -0

@@ -61,6 +61,7 @@
61 61 - [x] Raiders: periodic bands that steal from stores and fight colonists; colonists defend (ranged, Hunting skill), raiders flee when hurt or loaded, killed raiders drop loot
62 62 - [x] Social life: personalities (warmth), shared opinions from chats/arguments near each other, friendships/rivalries (with hysteresis) affect mood and loneliness, rivals never pair up, friends visit when idle and mourn the dead; saved
63 63 - [x] Trading: caravans visit every few days with random wares, colonists haul surplus (above 1.5× goal) to them for goods below goal (trader takes a 20% margin); stock targets steer what is bought and sold
64 +- [x] Forest regrowth: saplings sprout next to trees on free forest tiles (not on paths, zones or the colony core) and mature in ~8 days; capped below worldgen density
64 65 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
65 66
66 67 ## Planned