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>
222 lines
6.5 KiB
Rust
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());
|
|
}
|
|
}
|