feat(simulation): add A* pathfinding and NPC path following
Implements tickets #237 and #238 for Sprint 3: - Add pathfinding crate dependency for A* algorithm - PathRequest component triggers compute_paths system which uses cardinal-neighbor A* with manhattan distance heuristic - ComputedPath component with step navigation (next_step, advance, is_complete) and PathBlocked marker for no-route cases - MovementSpeed component throttles NPC movement (ticks_per_step) - follow_paths system advances NPCs along computed paths, creating MoveIntent per step; cleanup_path_blocked removes markers after one tick - System ordering: input → compute_paths → follow_paths → validate_movement → cleanup_path_blocked → advance_tick Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
//! 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).
|
||||
/// Removes PathRequest and inserts ComputedPath or PathBlocked.
|
||||
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))
|
||||
},
|
||||
|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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user