292 lines
9.5 KiB
Rust
292 lines
9.5 KiB
Rust
//! 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<TilePosition>,
|
|
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<Res<WalkabilityMap>>,
|
|
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::<PathRequest>();
|
|
commands.entity(entity).insert(PathBlocked);
|
|
}
|
|
return;
|
|
};
|
|
|
|
for (entity, current_pos, request) in queries.iter() {
|
|
commands.entity(entity).remove::<PathRequest>();
|
|
|
|
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<TilePosition> = 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::<PathRequest>(entity).is_none());
|
|
let path = world.get::<ComputedPath>(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::<ComputedPath>(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::<ComputedPath>(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::<PathRequest>(entity).is_none());
|
|
assert!(world.get::<ComputedPath>(entity).is_none());
|
|
assert!(world.get::<PathBlocked>(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");
|
|
}
|
|
}
|