use std::{ cmp::Reverse, collections::{BinaryHeap, HashSet, VecDeque}, }; use bevy::prelude::*; use crate::world::{CHEAPEST_MOVE_COST, WorldMap}; const STRAIGHT: u32 = 10; const DIAGONAL: u32 = 14; const MIN_TERRAIN_COST: u32 = CHEAPEST_MOVE_COST; const NEIGHBORS: [IVec2; 8] = [ IVec2::new(1, 0), IVec2::new(-1, 0), IVec2::new(0, 1), IVec2::new(0, -1), IVec2::new(1, 1), IVec2::new(1, -1), IVec2::new(-1, 1), IVec2::new(-1, -1), ]; pub fn find_path(map: &WorldMap, start: UVec2, goal: UVec2) -> Option> { map.move_cost(goal.as_ivec2())?; let goal = goal.as_ivec2(); astar(map, start, |p| p == goal, |p| octile(p, goal)) } pub fn find_path_adjacent(map: &WorldMap, start: UVec2, target: UVec2) -> Option> { let target = target.as_ivec2(); astar( map, start, |p| is_adjacent(p, target), |p| octile(p, target).saturating_sub(DIAGONAL * MIN_TERRAIN_COST), ) } pub fn is_adjacent(a: IVec2, b: IVec2) -> bool { a != b && (a - b).abs().max_element() == 1 } pub fn nearest_tile( map: &WorldMap, start: UVec2, max_tiles: usize, mut is_target: impl FnMut(UVec2) -> bool, ) -> Option { let index = |p: UVec2| (p.y * map.size.x + p.x) as usize; let mut seen = vec![false; (map.size.x * map.size.y) as usize]; let mut seen_count = 1; if let Some(cell) = seen.get_mut(index(start)) { *cell = true; } let mut queue = VecDeque::from([start]); while let Some(pos) = queue.pop_front() { if is_target(pos) { return Some(pos); } if seen_count >= max_tiles { continue; } for step in NEIGHBORS { let next = pos.as_ivec2() + step; if map.move_cost(next).is_some() && !seen[index(next.as_uvec2())] { seen[index(next.as_uvec2())] = true; seen_count += 1; queue.push_back(next.as_uvec2()); } } } None } pub fn search_reach(max_tiles: usize) -> u32 { (max_tiles as f32).sqrt().ceil() as u32 } pub fn nearest_of( map: &WorldMap, start: UVec2, max_tiles: usize, targets: &HashSet, ) -> Option { let reach = search_reach(max_tiles) as i32; let in_reach = |p: &&UVec2| (p.as_ivec2() - start.as_ivec2()).abs().max_element() <= reach; if !targets.iter().any(|p| in_reach(&p)) { return None; } nearest_tile(map, start, max_tiles, |p| targets.contains(&p)) } fn astar( map: &WorldMap, start: UVec2, is_goal: impl Fn(IVec2) -> bool, heuristic: impl Fn(IVec2) -> u32, ) -> Option> { let start = start.as_ivec2(); if !map.in_bounds(start) { return None; } if is_goal(start) { return Some(vec![]); } let width = map.size.x as usize; let index = |p: IVec2| p.y as usize * width + p.x as usize; let tiles = map.size.element_product() as usize; let mut cost_so_far = vec![u32::MAX; tiles]; let mut came_from = vec![None::; tiles]; let mut open = BinaryHeap::new(); cost_so_far[index(start)] = 0; open.push(Reverse((heuristic(start), start.x, start.y))); while let Some(Reverse((_, x, y))) = open.pop() { let current = IVec2::new(x, y); if is_goal(current) { return Some(reconstruct(&came_from, index, start, current)); } let current_cost = cost_so_far[index(current)]; for step in NEIGHBORS { let next = current + step; let Some(terrain_cost) = map.step_cost(current, next) else { continue; }; let diagonal = step.x != 0 && step.y != 0; if diagonal && (map.move_cost(current + IVec2::new(step.x, 0)).is_none() || map.move_cost(current + IVec2::new(0, step.y)).is_none()) { continue; } let base = if diagonal { DIAGONAL } else { STRAIGHT }; let new_cost = current_cost + base * terrain_cost; if new_cost < cost_so_far[index(next)] { cost_so_far[index(next)] = new_cost; came_from[index(next)] = Some(current); open.push(Reverse((new_cost + heuristic(next), next.x, next.y))); } } } None } fn octile(a: IVec2, b: IVec2) -> u32 { let d = (a - b).abs(); let (min, max) = (d.x.min(d.y) as u32, d.x.max(d.y) as u32); (DIAGONAL * min + STRAIGHT * (max - min)) * MIN_TERRAIN_COST } fn reconstruct( came_from: &[Option], index: impl Fn(IVec2) -> usize, start: IVec2, goal: IVec2, ) -> Vec { let mut path = vec![goal.as_uvec2()]; let mut current = goal; while let Some(prev) = came_from[index(current)] { if prev == start { break; } path.push(prev.as_uvec2()); current = prev; } path.reverse(); path } #[cfg(test)] mod tests { use super::*; use crate::world::Terrain; // Idle colonists look for food and cooking input several times per game minute. A stack far // beyond what the flood fill can cover made every one of those searches visit the full tile // budget and fail, which dominated frame time at high speeds. #[test] fn target_searches_skip_targets_beyond_their_reach() { let map = WorldMap::filled(UVec2::splat(300), Terrain::Grass); let start = UVec2::splat(10); let max_tiles = 400; let near = start + UVec2::new(search_reach(max_tiles) / 4, 0); let far = start + UVec2::new(search_reach(max_tiles) + 1, 0); assert_eq!( nearest_of(&map, start, max_tiles, &HashSet::from([far])), None ); assert_eq!( nearest_of(&map, start, max_tiles, &HashSet::from([far, near])), Some(near) ); } fn map_from(rows: &[&str]) -> WorldMap { let size = UVec2::new(rows[0].len() as u32, rows.len() as u32); let mut map = WorldMap::filled(size, Terrain::Grass); for (row, line) in rows.iter().enumerate() { let y = size.y - 1 - row as u32; for (x, c) in line.chars().enumerate() { let terrain = match c { '#' => Terrain::Stone, '~' => Terrain::ShallowWater, _ => Terrain::Grass, }; map.set(UVec2::new(x as u32, y), terrain); } } map } fn is_connected(start: UVec2, path: &[UVec2]) -> bool { std::iter::once(start) .chain(path.iter().copied()) .collect::>() .windows(2) .all(|w| (w[0].as_ivec2() - w[1].as_ivec2()).abs().max_element() == 1) } #[test] fn path_excludes_start_and_ends_at_goal() { let map = map_from(&["....."]); let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap(); assert_eq!( path, vec![ UVec2::new(1, 0), UVec2::new(2, 0), UVec2::new(3, 0), UVec2::new(4, 0) ] ); } #[test] fn path_to_self_is_empty() { let map = map_from(&["..."]); assert_eq!(find_path(&map, UVec2::ZERO, UVec2::ZERO), Some(vec![])); } #[test] fn path_goes_around_walls() { let map = map_from(&[ ".....", // ".###.", ".#.#.", "...#.", ]); let start = UVec2::new(2, 1); let goal = UVec2::new(4, 0); let path = find_path(&map, start, goal).unwrap(); assert!(is_connected(start, &path)); assert_eq!(path.last(), Some(&goal)); assert!(path.iter().all(|p| map.move_cost(p.as_ivec2()).is_some())); } #[test] fn unreachable_or_impassable_goal_has_no_path() { let map = map_from(&[ "..#..", // "..#..", ]); assert_eq!(find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)), None); assert_eq!(find_path(&map, UVec2::new(0, 0), UVec2::new(2, 0)), None); } // Colonists move in straight lines between tile centers, so a diagonal step between two // blocked orthogonal neighbours would visually clip through the corners of walls. #[test] fn diagonal_steps_never_cut_wall_corners() { let map = map_from(&[ "#.", // ".#", ]); assert_eq!(find_path(&map, UVec2::new(0, 0), UVec2::new(1, 1)), None); let map = map_from(&[ "..", // "#.", ]); let path = find_path(&map, UVec2::new(0, 1), UVec2::new(1, 0)).unwrap(); assert_eq!(path, vec![UVec2::new(1, 1), UVec2::new(1, 0)]); } #[test] fn prefers_detour_over_slow_terrain() { let map = map_from(&[ ".....", // "..~..", ]); let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap(); assert!(!path.contains(&UVec2::new(2, 0))); } #[test] fn walks_around_a_hill_rather_than_over_it() { let mut map = map_from(&[ ".....", // ".....", ]); map.set_height(UVec2::new(2, 0), 10.0); let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap(); assert!(!path.contains(&UVec2::new(2, 0))); } #[test] fn detours_along_paths_when_they_are_faster() { let mut map = map_from(&[ "..........", // "..........", ]); for x in 0..10 { map.set(UVec2::new(x, 1), Terrain::Paved); } let path = find_path(&map, UVec2::new(0, 0), UVec2::new(9, 0)).unwrap(); assert!(path.iter().filter(|p| p.y == 1).count() >= 6); } // A wall can go up on a tile a colonist is standing on (or just about to enter); routes must // still start from that tile so nobody gets permanently stuck inside a wall. #[test] fn colonists_can_walk_out_of_a_blocked_tile() { let mut map = map_from(&["....."]); map.set_wall(UVec2::new(2, 0), true); let path = find_path(&map, UVec2::new(2, 0), UVec2::new(4, 0)).unwrap(); assert_eq!(path, vec![UVec2::new(3, 0), UVec2::new(4, 0)]); } #[test] fn crosses_slow_terrain_when_it_is_the_only_way() { let map = map_from(&["..~.."]); let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap(); assert!(path.contains(&UVec2::new(2, 0))); } #[test] fn path_adjacent_stops_next_to_target() { let map = map_from(&["......"]); let path = find_path_adjacent(&map, UVec2::new(0, 0), UVec2::new(5, 0)).unwrap(); assert_eq!(path.last(), Some(&UVec2::new(4, 0))); } #[test] fn path_adjacent_works_for_impassable_targets() { let map = map_from(&["...#"]); let path = find_path_adjacent(&map, UVec2::new(0, 0), UVec2::new(3, 0)).unwrap(); assert_eq!(path.last(), Some(&UVec2::new(2, 0))); } #[test] fn path_adjacent_from_neighbor_is_empty() { let map = map_from(&["..."]); let path = find_path_adjacent(&map, UVec2::new(1, 0), UVec2::new(2, 0)); assert_eq!(path, Some(vec![])); } #[test] fn path_adjacent_to_enclosed_target_is_none() { let walled = map_from(&[ "#####", // "#...#", "#####", ".....", ]); assert_eq!( find_path_adjacent(&walled, UVec2::new(0, 0), UVec2::new(2, 2)), None ); } #[test] fn nearest_tile_finds_closest_match_by_walking_distance() { let map = map_from(&[ ".#...", // ".#.#.", "...#.", ]); let found = nearest_tile(&map, UVec2::new(0, 0), 100, |p| p.x == 4); assert_eq!(found, Some(UVec2::new(4, 2))); assert_eq!( nearest_tile(&map, UVec2::new(0, 0), 100, |p| p.x == 9), None ); } #[test] fn finds_paths_across_a_generated_world() { let map = crate::worldgen::generate(crate::worldgen::WORLD_SIZE, 1); let region = crate::worldgen::largest_region(&map); let (start, goal) = (region[0], region[region.len() - 1]); let path = find_path(&map, start, goal).unwrap(); assert!(is_connected(start, &path)); assert_eq!(path.last(), Some(&goal)); } }