Files
settled-reach/server/src/simulation/pathfinding.rs
T

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");
}
}