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 { matches!(map.get(tile), Some(Terrain::Paved | Terrain::Bridge)) || map.wear(tile) >= BUSY_WEAR } pub const BRIDGE_WEAR: f32 = PATH_WEAR; pub const BRIDGE_BATCH: usize = 6; pub fn bridging_candidates(map: &WorldMap, blueprints: &Blueprints) -> Vec { let pending = blueprints .0 .values() .filter(|k| **k == BuildKind::Bridge) .count(); let reachable_from_land = |p: UVec2| { [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] .iter() .any(|d| map.move_cost(p.as_ivec2() + *d).is_some()) }; let mut busiest: Vec = map .worn_tiles() .into_iter() .filter(|&p| { map.get(p.as_ivec2()).is_some_and(|t| t.is_water()) && map.wear(p.as_ivec2()) >= BRIDGE_WEAR && blueprints.get(p).is_none() && reachable_from_land(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(BRIDGE_BATCH.saturating_sub(pending)); busiest } 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); } for tile in bridging_candidates(&map, &blueprints) { blueprints.0.insert(tile, BuildKind::Bridge); } } #[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))); } fn lake_map() -> WorldMap { let mut map = WorldMap::filled(UVec2::new(40, 30), Terrain::Grass); for y in 0..25 { for x in 18..22 { map.set(UVec2::new(x, y), Terrain::DeepWater); } } map } // A lake forces a long walk around; colonists notice the much shorter route a bridge would // give and put demand on its water tiles, then bridge them from the shore outwards. #[test] fn detours_around_water_lead_to_bridges() { let mut map = lake_map(); let (from, to) = (UVec2::new(10, 5), UVec2::new(30, 5)); let route = crate::pathfinding::find_path(&map, from, to).unwrap(); let bridged = crate::pathfinding::find_bridged_path(&map, from, to, 4000).unwrap(); assert!(bridged.len() * 2 < route.len()); for _ in 0..BRIDGE_WEAR as usize { crate::jobs::note_detour(&mut map, from, to, Some(&route)); } let blueprints = Blueprints::default(); let plan = bridging_candidates(&map, &blueprints); assert!(!plan.is_empty()); assert!(plan.iter().all(|t| t.x == 18 || t.x == 21), "{plan:?}"); assert!(plan.iter().all(|t| (3..=7).contains(&t.y)), "{plan:?}"); } #[test] fn short_or_straight_walks_put_no_demand_on_water() { let mut map = lake_map(); let route = crate::pathfinding::find_path(&map, UVec2::new(10, 28), UVec2::new(30, 28)); crate::jobs::note_detour( &mut map, UVec2::new(10, 28), UVec2::new(30, 28), route.as_deref(), ); assert!(map.worn_tiles().is_empty()); } }