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:
Generated
+42
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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