//! Tile-based A* pathfinding (#237). //! //! Computes paths over the WalkabilityMap using the `pathfinding` crate. //! NPCs request paths via PathRequest component; the compute_paths system //! resolves them into ComputedPath (success) or PathBlocked (no route). use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use crate::simulation::movement::{TilePosition, WalkabilityMap}; /// Component requesting a path from current position to a goal. /// Consumed by the compute_paths system each tick. #[derive(Component, Debug, Clone)] pub struct PathRequest { pub goal: TilePosition, } /// Component holding a computed path. /// Steps run from start (exclusive) to goal (inclusive). #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct ComputedPath { pub steps: Vec, pub current_index: usize, } impl ComputedPath { /// Get the next step in the path, or None if finished. pub fn next_step(&self) -> Option<&TilePosition> { self.steps.get(self.current_index) } /// Advance to the next step. Returns true if there are more steps. pub fn advance(&mut self) -> bool { if self.current_index < self.steps.len() { self.current_index += 1; } self.current_index < self.steps.len() } /// Whether the path has been fully traversed. pub fn is_complete(&self) -> bool { self.current_index >= self.steps.len() } /// Remaining steps count. pub fn remaining(&self) -> usize { self.steps.len().saturating_sub(self.current_index) } } /// Marker component: pathfinding failed, no route exists. #[derive(Component, Debug, Clone)] pub struct PathBlocked; /// System: compute paths for entities with PathRequest components. /// Uses A* over the WalkabilityMap with cardinal movement (4 neighbors). /// Cardinal-only is a deliberate v0.1 simplification: diagonal movement /// would require √2 cost handling and diagonal wall-clipping checks. /// Removes PathRequest and inserts ComputedPath or PathBlocked. #[tracing::instrument(level = "debug", skip_all)] pub fn compute_paths( mut commands: Commands, walkability: Option>, queries: Query<(Entity, &TilePosition, &PathRequest)>, ) { let Some(walkability) = walkability else { // No map loaded — consume requests and mark blocked for (entity, _, _) in queries.iter() { commands.entity(entity).remove::(); commands.entity(entity).insert(PathBlocked); } return; }; for (entity, current_pos, request) in queries.iter() { commands.entity(entity).remove::(); if *current_pos == request.goal { commands.entity(entity).insert(ComputedPath { steps: Vec::new(), current_index: 0, }); continue; } let goal = request.goal; let result = pathfinding::directed::astar::astar( current_pos, |pos| { pos.cardinal_neighbors() .into_iter() .filter(|neighbor| walkability.can_move_to(neighbor)) .map(|neighbor| (neighbor, 1u32)) }, // manhattan_distance returns None for cross-z-level pairs; // u32::MAX makes A* deprioritize those nodes (v0.1: single z-level) |pos| pos.manhattan_distance(&goal).unwrap_or(u32::MAX), |pos| *pos == goal, ); match result { Some((path, _cost)) => { // path includes start position; skip it let steps: Vec = path.into_iter().skip(1).collect(); tracing::trace!( "Entity {:?}: path to {:?}, {} steps", entity, goal, steps.len() ); commands.entity(entity).insert(ComputedPath { steps, current_index: 0, }); } None => { tracing::trace!("Entity {:?}: no path to {:?}", entity, goal); commands.entity(entity).insert(PathBlocked); } } } } #[cfg(test)] mod tests { use super::*; #[test] fn path_to_adjacent_tile() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(WalkabilityMap::new(10, 10, 1)); let entity = world .spawn(( TilePosition::new(5, 5, 0), PathRequest { goal: TilePosition::new(5, 4, 0), }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_paths); schedule.run(&mut world); assert!(world.get::(entity).is_none()); let path = world.get::(entity).unwrap(); assert_eq!(path.steps, vec![TilePosition::new(5, 4, 0)]); assert_eq!(path.current_index, 0); } #[test] fn path_around_wall() { let mut world = bevy_ecs::world::World::new(); let mut map = WalkabilityMap::new(10, 10, 1); // Wall at (5,4) blocks direct north map.set_walkable(&TilePosition::new(5, 4, 0), false); world.insert_resource(map); let entity = world .spawn(( TilePosition::new(5, 5, 0), PathRequest { goal: TilePosition::new(5, 3, 0), }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_paths); schedule.run(&mut world); let path = world.get::(entity).unwrap(); assert!(!path.steps.is_empty()); // Path should end at goal assert_eq!(*path.steps.last().unwrap(), TilePosition::new(5, 3, 0)); // Path should not go through the wall assert!(!path.steps.contains(&TilePosition::new(5, 4, 0))); } #[test] fn path_to_same_position() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(WalkabilityMap::new(10, 10, 1)); let entity = world .spawn(( TilePosition::new(5, 5, 0), PathRequest { goal: TilePosition::new(5, 5, 0), }, )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_paths); schedule.run(&mut world); let path = world.get::(entity).unwrap(); assert!(path.steps.is_empty()); assert!(path.is_complete()); } #[test] fn path_blocked_no_route() { let mut world = bevy_ecs::world::World::new(); let mut map = WalkabilityMap::new(10, 10, 1); // Surround goal with walls let goal = TilePosition::new(5, 3, 0); for neighbor in goal.cardinal_neighbors() { map.set_walkable(&neighbor, false); } world.insert_resource(map); let entity = world .spawn((TilePosition::new(5, 5, 0), PathRequest { goal })) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_paths); schedule.run(&mut world); assert!(world.get::(entity).is_none()); assert!(world.get::(entity).is_none()); assert!(world.get::(entity).is_some()); } #[test] fn computed_path_navigation() { let mut path = ComputedPath { steps: vec![ TilePosition::new(1, 0, 0), TilePosition::new(2, 0, 0), TilePosition::new(3, 0, 0), ], current_index: 0, }; assert_eq!(path.remaining(), 3); assert!(!path.is_complete()); assert_eq!(*path.next_step().unwrap(), TilePosition::new(1, 0, 0)); assert!(path.advance()); // -> index 1 assert_eq!(*path.next_step().unwrap(), TilePosition::new(2, 0, 0)); assert!(path.advance()); // -> index 2 assert_eq!(*path.next_step().unwrap(), TilePosition::new(3, 0, 0)); assert!(!path.advance()); // -> index 3, no more steps assert!(path.is_complete()); assert!(path.next_step().is_none()); assert_eq!(path.remaining(), 0); } #[test] fn path_deterministic() { let map = { let mut m = WalkabilityMap::new(20, 20, 1); // Add some walls to make routing interesting for y in 3..8 { m.set_walkable(&TilePosition::new(5, y, 0), false); } m }; // Run pathfinding twice with same setup let mut results = Vec::new(); for _ in 0..2 { let mut world = bevy_ecs::world::World::new(); world.insert_resource(map.clone()); world.spawn(( TilePosition::new(4, 5, 0), PathRequest { goal: TilePosition::new(6, 5, 0), }, )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_paths); schedule.run(&mut world); let mut paths: Vec<_> = world .query::<&ComputedPath>() .iter(&world) .map(|p| p.steps.clone()) .collect(); results.push(paths.pop().unwrap()); } assert_eq!(results[0], results[1], "pathfinding must be deterministic"); } }