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:
2026-02-12 17:51:27 +01:00
co-authored by Claude Opus 4.6
parent 3c04a0c568
commit f899624103
5 changed files with 555 additions and 2 deletions
+42
View File
@@ -378,6 +378,18 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "deprecate-until"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a3767f826efbbe5a5ae093920b58b43b01734202be697e1354914e862e8e704"
dependencies = [
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -595,6 +607,15 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "integer-sqrt"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770"
dependencies = [
"num-traits",
]
[[package]]
name = "js-sys"
version = "0.3.85"
@@ -701,6 +722,20 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pathfinding"
version = "4.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ac35caa284c08f3721fb33c2741b5f763decaf42d080c8a6a722154347017e"
dependencies = [
"deprecate-until",
"indexmap",
"integer-sqrt",
"num-traits",
"rustc-hash",
"thiserror",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -846,6 +881,12 @@ dependencies = [
"serde",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -904,6 +945,7 @@ dependencies = [
"bevy_app",
"bevy_ecs",
"bincode",
"pathfinding",
"rand",
"rand_chacha",
"rmp-serde",
+1
View File
@@ -11,6 +11,7 @@ rmp-serde = "1"
bincode = "1"
rand = "0.9"
rand_chacha = "0.9"
pathfinding = "4"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+7 -2
View File
@@ -6,6 +6,8 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod input;
pub mod movement;
pub mod path_follow;
pub mod pathfinding;
pub mod rng;
pub mod tier;
pub mod time;
@@ -24,8 +26,11 @@ impl Plugin for SimulationPlugin {
Update,
(
input::process_player_input,
movement::validate_movement.after(input::process_player_input),
time::advance_tick.after(movement::validate_movement),
pathfinding::compute_paths.after(input::process_player_input),
path_follow::follow_paths.after(pathfinding::compute_paths),
movement::validate_movement.after(path_follow::follow_paths),
path_follow::cleanup_path_blocked.after(movement::validate_movement),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
);
+221
View File
@@ -0,0 +1,221 @@
//! NPC path following system (#238).
//!
//! Per-tick NPC position updates along computed paths.
//! Separate from pathfinding — this is the movement execution system.
use bevy_ecs::prelude::*;
use crate::npc::Npc;
use crate::simulation::movement::MoveIntent;
use crate::simulation::pathfinding::{ComputedPath, PathBlocked};
/// Movement speed component. Controls ticks between path steps.
/// Default: 1 step per tick. Higher values = slower movement.
#[derive(Component, Debug, Clone)]
pub struct MovementSpeed {
pub ticks_per_step: u32,
ticks_since_last_step: u32,
}
impl Default for MovementSpeed {
fn default() -> Self {
Self {
ticks_per_step: 1,
ticks_since_last_step: 0,
}
}
}
impl MovementSpeed {
pub fn new(ticks_per_step: u32) -> Self {
Self {
ticks_per_step: ticks_per_step.max(1),
ticks_since_last_step: 0,
}
}
/// Returns true if entity should step this tick.
fn should_step(&mut self) -> bool {
self.ticks_since_last_step += 1;
if self.ticks_since_last_step >= self.ticks_per_step {
self.ticks_since_last_step = 0;
true
} else {
false
}
}
}
/// System: NPC entities with ComputedPath advance along their path.
/// Creates MoveIntent for the next step. Removes ComputedPath when complete.
pub fn follow_paths(
mut commands: Commands,
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), With<Npc>>,
) {
for (entity, mut path, speed_opt) in query.iter_mut() {
if let Some(mut speed) = speed_opt {
if !speed.should_step() {
continue;
}
}
if let Some(next_pos) = path.next_step() {
commands
.entity(entity)
.insert(MoveIntent { target: *next_pos });
path.advance();
}
if path.is_complete() {
commands.entity(entity).remove::<ComputedPath>();
tracing::trace!("Entity {:?}: path complete", entity);
}
}
}
/// System: clean up PathBlocked markers after one tick.
pub fn cleanup_path_blocked(mut commands: Commands, query: Query<Entity, With<PathBlocked>>) {
for entity in query.iter() {
commands.entity(entity).remove::<PathBlocked>();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::movement::TilePosition;
use crate::simulation::pathfinding::ComputedPath;
#[test]
fn npc_follows_path_one_step() {
let mut world = bevy_ecs::world::World::new();
let entity = world
.spawn((
Npc,
TilePosition::new(0, 0, 0),
ComputedPath {
steps: vec![
TilePosition::new(1, 0, 0),
TilePosition::new(2, 0, 0),
TilePosition::new(3, 0, 0),
],
current_index: 0,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(follow_paths);
schedule.run(&mut world);
// MoveIntent should target step 0
let intent = world.get::<MoveIntent>(entity).unwrap();
assert_eq!(intent.target, TilePosition::new(1, 0, 0));
// Path advanced to index 1
let path = world.get::<ComputedPath>(entity).unwrap();
assert_eq!(path.current_index, 1);
}
#[test]
fn npc_path_complete_removes_component() {
let mut world = bevy_ecs::world::World::new();
let entity = world
.spawn((
Npc,
TilePosition::new(2, 0, 0),
ComputedPath {
steps: vec![TilePosition::new(3, 0, 0)],
current_index: 0,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(follow_paths);
schedule.run(&mut world);
// After consuming the last step, ComputedPath should be removed
assert!(world.get::<ComputedPath>(entity).is_none());
// But MoveIntent was still created
assert!(world.get::<MoveIntent>(entity).is_some());
}
#[test]
fn movement_speed_throttles() {
let mut world = bevy_ecs::world::World::new();
let entity = world
.spawn((
Npc,
TilePosition::new(0, 0, 0),
ComputedPath {
steps: vec![
TilePosition::new(1, 0, 0),
TilePosition::new(2, 0, 0),
],
current_index: 0,
},
MovementSpeed::new(3),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(follow_paths);
// Tick 1: no step (1/3)
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(entity).is_none());
// Tick 2: no step (2/3)
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(entity).is_none());
// Tick 3: step! (3/3)
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(entity).is_some());
assert_eq!(
world.get::<MoveIntent>(entity).unwrap().target,
TilePosition::new(1, 0, 0)
);
}
#[test]
fn non_npc_entity_ignored() {
let mut world = bevy_ecs::world::World::new();
// Entity without Npc marker
let entity = world
.spawn((
TilePosition::new(0, 0, 0),
ComputedPath {
steps: vec![TilePosition::new(1, 0, 0)],
current_index: 0,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(follow_paths);
schedule.run(&mut world);
// Should NOT have MoveIntent since it's not an Npc
assert!(world.get::<MoveIntent>(entity).is_none());
// Path unchanged
assert_eq!(world.get::<ComputedPath>(entity).unwrap().current_index, 0);
}
#[test]
fn cleanup_path_blocked_removes_marker() {
let mut world = bevy_ecs::world::World::new();
let entity = world.spawn((Npc, PathBlocked)).id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(cleanup_path_blocked);
schedule.run(&mut world);
assert!(world.get::<PathBlocked>(entity).is_none());
}
}
+284
View File
@@ -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");
}
}