use bevy::prelude::*; use crate::{ building::{Blueprints, BuildKind}, colonist::Colonist, sim::{SimSystems, SimTick, TICKS_PER_HOUR}, structures::Structure, world::{Terrain, TileChanged, TilePos, WorldMap}, zones::Zones, }; pub struct PathsPlugin; impl Plugin for PathsPlugin { fn build(&self, app: &mut App) { app.add_systems( FixedUpdate, ( plan_paving.in_set(SimSystems::Decide), update_paths.in_set(SimSystems::Act), ), ); } } pub const PATH_WEAR: f32 = 8.0; pub const PAVE_WEAR: f32 = 20.0; pub const BUSY_WEAR: f32 = PAVE_WEAR; pub fn busy_path(map: &WorldMap, tile: IVec2) -> bool { map.get(tile) == Some(Terrain::Paved) || map.wear(tile) >= BUSY_WEAR } pub const REGROW_WEAR: f32 = 1.0; pub const WEAR_KEPT_PER_HOUR: f32 = 0.98; pub const PAVE_BATCH: usize = 8; const UPDATE_INTERVAL_TICKS: u64 = TICKS_PER_HOUR; pub fn wears_into_path(terrain: Terrain) -> bool { matches!(terrain, Terrain::Grass | Terrain::Soil | Terrain::Sand) } pub fn evolve(map: &mut WorldMap, zones: &Zones, mut on_change: impl FnMut(UVec2)) { for pos in map.worn_tiles() { let p = pos.as_ivec2(); let wear = map.wear(p) * WEAR_KEPT_PER_HOUR; map.set_wear(pos, wear); let Some(terrain) = map.get(p) else { continue; }; let becomes = if wears_into_path(terrain) && wear >= PATH_WEAR && map.plant(p).is_none() && !zones.is_farm(pos) { Terrain::Path } else if terrain == Terrain::Path && wear < REGROW_WEAR { Terrain::Grass } else { continue; }; map.set(pos, becomes); on_change(pos); } } fn update_paths( tick: Res, zones: Res, mut map: ResMut, mut changed: MessageWriter, ) { if !tick.0.is_multiple_of(UPDATE_INTERVAL_TICKS) { return; } evolve(&mut map, &zones, |pos| { changed.write(TileChanged(pos)); }); } pub fn paving_candidates( map: &WorldMap, blueprints: &Blueprints, occupied: &[UVec2], ) -> Vec { let pending = blueprints .0 .values() .filter(|k| **k == BuildKind::Pave) .count(); let mut busiest: Vec = map .worn_tiles() .into_iter() .filter(|&p| { map.get(p.as_ivec2()) == Some(Terrain::Path) && map.wear(p.as_ivec2()) >= PAVE_WEAR && blueprints.get(p).is_none() && !occupied.contains(&p) }) .collect(); busiest.sort_by(|a, b| { map.wear(b.as_ivec2()) .total_cmp(&map.wear(a.as_ivec2())) .then((a.y, a.x).cmp(&(b.y, b.x))) }); busiest.truncate(PAVE_BATCH.saturating_sub(pending)); busiest } fn plan_paving( tick: Res, map: Res, structures: Query<&TilePos, (With, Without)>, mut blueprints: ResMut, ) { if !tick.0.is_multiple_of(UPDATE_INTERVAL_TICKS) { return; } let occupied: Vec = structures.iter().map(|p| p.0).collect(); for tile in paving_candidates(&map, &blueprints, &occupied) { blueprints.0.insert(tile, BuildKind::Pave); } } #[cfg(test)] mod tests { use super::*; fn worn(terrain: Terrain, wear: f32) -> WorldMap { let mut map = WorldMap::filled(UVec2::splat(3), terrain); map.set_wear(UVec2::ONE, wear); map } #[test] fn busy_ground_wears_into_a_path() { let mut map = worn(Terrain::Grass, PATH_WEAR * 2.0); let mut changes = 0; evolve(&mut map, &Zones::default(), |_| changes += 1); assert_eq!(map.get(IVec2::ONE), Some(Terrain::Path)); assert_eq!(map.get(IVec2::ZERO), Some(Terrain::Grass)); assert_eq!(changes, 1); } #[test] fn farms_plants_and_rock_floors_keep_their_ground() { let mut farm_map = worn(Terrain::Soil, PATH_WEAR * 2.0); let mut zones = Zones::default(); zones.add_farm_tiles(&farm_map, [UVec2::ONE]); evolve(&mut farm_map, &zones, |_| {}); assert_eq!(farm_map.get(IVec2::ONE), Some(Terrain::Soil)); let mut bush_map = worn(Terrain::Grass, PATH_WEAR * 2.0); bush_map.set_plant(UVec2::ONE, Some(crate::world::Plant::BerryBush)); evolve(&mut bush_map, &Zones::default(), |_| {}); assert_eq!(bush_map.get(IVec2::ONE), Some(Terrain::Grass)); let mut gravel = worn(Terrain::Gravel, PATH_WEAR * 2.0); evolve(&mut gravel, &Zones::default(), |_| {}); assert_eq!(gravel.get(IVec2::ONE), Some(Terrain::Gravel)); } #[test] fn unused_paths_grow_back() { let mut map = worn(Terrain::Path, 0.5); evolve(&mut map, &Zones::default(), |_| {}); assert_eq!(map.get(IVec2::ONE), Some(Terrain::Grass)); } // Only worn tiles are visited each hour (a full-map pass every game hour dominated frame time // at high speeds), so faded wear must drop out of the worn set and stray paths must still // regrow before that happens. #[test] fn faded_paths_regrow_and_leave_the_worn_set() { let mut map = worn(Terrain::Path, PATH_WEAR); for _ in 0..500 { evolve(&mut map, &Zones::default(), |_| {}); } assert_eq!(map.get(IVec2::ONE), Some(Terrain::Grass)); assert!(map.worn_tiles().is_empty()); } #[test] fn wear_fades_over_time() { let mut map = worn(Terrain::Grass, 10.0); evolve(&mut map, &Zones::default(), |_| {}); assert!(map.wear(IVec2::ONE) < 10.0); } #[test] fn the_busiest_paths_are_paved_first_in_batches() { let mut map = WorldMap::filled(UVec2::new(20, 1), Terrain::Path); for x in 0..20 { map.set_wear(UVec2::new(x, 0), PAVE_WEAR + x as f32); } let mut blueprints = Blueprints::default(); blueprints.0.insert(UVec2::new(19, 0), BuildKind::Pave); let picked = paving_candidates(&map, &blueprints, &[UVec2::new(18, 0)]); assert_eq!(picked.len(), PAVE_BATCH - 1); assert_eq!(picked[0], UVec2::new(17, 0)); assert!(!picked.contains(&UVec2::new(18, 0))); } }