Files
settled-reach/server/src/simulation/path_follow.rs
T
jpmschweitzerandClaude Opus 4.6 f899624103 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>
2026-02-12 17:51:27 +01:00

222 lines
6.5 KiB
Rust

//! 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());
}
}