Let forests grow back slowly main
- 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 | |
| src/plants.rs | +122 -4 | |
| src/tilemap.rs | +23 -1 | |
| src/world.rs | +25 -2 | |
| src/worldgen.rs | +1 -0 | |
| tasks.md | +1 -0 |
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 |