1use std::collections::VecDeque;
3#[cfg(feature = "stats")]
4use std::time::Instant;
5
6use bevy::{log, platform::collections::HashMap, prelude::*};
7
8use crate::{pathfind::PathfindArgs, prelude::*, WithoutPathingFailures};
9
10#[derive(Default, Debug, Copy, Clone)]
12pub struct PathfindSettings {
13 pub default_mode: PathfindMode,
16}
17
18#[derive(Resource, Debug, Copy, Clone)]
20pub struct NorthstarPluginSettings {
21 pub max_pathfinding_agents_per_frame: usize,
25 pub max_collision_avoidance_agents_per_frame: usize,
27 pub pathfind_settings: PathfindSettings,
29}
30
31impl Default for NorthstarPluginSettings {
32 fn default() -> Self {
33 Self {
34 max_pathfinding_agents_per_frame: 128,
35 max_collision_avoidance_agents_per_frame: 128,
36 pathfind_settings: PathfindSettings::default(),
37 }
38 }
39}
40
41#[derive(Default)]
44pub struct NorthstarPlugin<N: Neighborhood> {
45 _neighborhood: std::marker::PhantomData<N>,
46}
47
48#[derive(Default, Debug)]
50pub struct PathfindingStats {
51 pub average_time: f64,
53 pub average_length: f64,
55 pub pathfind_time: Vec<f64>,
57 pub pathfind_length: Vec<f64>,
59}
60
61#[derive(Default, Debug)]
63pub struct CollisionStats {
64 pub average_time: f64,
66 pub average_length: f64,
68 pub avoidance_time: Vec<f64>,
70 pub avoidance_length: Vec<f64>,
72}
73
74#[derive(Resource, Default, Debug)]
76pub struct Stats {
77 pub pathfinding: PathfindingStats,
79 pub collision: CollisionStats,
81}
82
83impl Stats {
84 pub fn add_pathfinding(&mut self, time: f64, length: f64) {
87 self.pathfinding.pathfind_time.push(time);
88 self.pathfinding.pathfind_length.push(length);
89
90 self.pathfinding.average_time = self.pathfinding.pathfind_time.iter().sum::<f64>()
91 / self.pathfinding.pathfind_time.len() as f64;
92 self.pathfinding.average_length = self.pathfinding.pathfind_length.iter().sum::<f64>()
93 / self.pathfinding.pathfind_length.len() as f64;
94 }
95
96 pub fn reset_pathfinding(&mut self) {
98 self.pathfinding.average_time = 0.0;
99 self.pathfinding.average_length = 0.0;
100 self.pathfinding.pathfind_time.clear();
101 self.pathfinding.pathfind_length.clear();
102 }
103
104 pub fn add_collision(&mut self, time: f64, length: f64) {
107 self.collision.avoidance_time.push(time);
108 self.collision.avoidance_length.push(length);
109
110 self.collision.average_time = self.collision.avoidance_time.iter().sum::<f64>()
111 / self.collision.avoidance_time.len() as f64;
112 self.collision.average_length = self.collision.avoidance_length.iter().sum::<f64>()
113 / self.collision.avoidance_length.len() as f64;
114 }
115
116 pub fn reset_collision(&mut self) {
118 self.collision.average_time = 0.0;
119 self.collision.average_length = 0.0;
120 self.collision.avoidance_time.clear();
121 self.collision.avoidance_length.clear();
122 }
123}
124
125impl<N: 'static + Neighborhood> Plugin for NorthstarPlugin<N> {
126 fn build(&self, app: &mut App) {
127 app.add_systems(
128 Update,
129 (
130 tag_pathfinding_requests,
131 update_blocking_map,
132 pathfind::<N>,
133 next_position::<N>,
134 reroute_path::<N>,
135 )
136 .chain()
137 .in_set(PathingSet),
138 )
139 .insert_resource(NorthstarPluginSettings::default())
140 .insert_resource(BlockingMap::default())
141 .insert_resource(Stats::default())
142 .insert_resource(DirectionMap::default())
143 .register_type::<Path>()
144 .register_type::<Pathfind>()
145 .register_type::<PathfindMode>()
146 .register_type::<NextPos>()
147 .register_type::<AgentOfGrid>()
148 .register_type::<GridAgents>();
149 }
150}
151
152#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
155pub struct PathingSet;
156
157#[derive(Resource, Default)]
160pub struct BlockingMap(pub HashMap<UVec3, Entity>);
161
162#[derive(Resource, Default)]
165pub struct DirectionMap(pub HashMap<Entity, Vec3>);
166
167#[derive(Component)]
168#[component(storage = "SparseSet")]
169pub(crate) struct NeedsPathfinding;
170
171fn tag_pathfinding_requests(mut commands: Commands, query: Query<Entity, Changed<Pathfind>>) {
173 for entity in query.iter() {
174 commands.entity(entity).try_insert(NeedsPathfinding);
175 }
176}
177
178fn pathfind<N: Neighborhood + 'static>(
181 grid: Single<&Grid<N>>,
182 mut commands: Commands,
183 mut query: Query<
184 (Entity, &AgentPos, &Pathfind, Option<&mut AgentMask>),
185 With<NeedsPathfinding>,
186 >,
187 blocking: Res<BlockingMap>,
188 settings: Res<NorthstarPluginSettings>,
189 #[cfg(feature = "stats")] mut stats: ResMut<Stats>,
191) {
192 let grid = grid.into_inner();
193
194 let mut count = 0;
196
197 for (entity, start, pathfind, mut agent_mask) in &mut query {
198 if count >= settings.max_pathfinding_agents_per_frame {
199 return;
200 }
201
202 if start.0 == pathfind.goal {
203 commands.entity(entity).remove::<Pathfind>();
204 commands.entity(entity).remove::<NeedsPathfinding>();
205 continue;
206 }
207
208 #[cfg(feature = "stats")]
209 let start_time = Instant::now();
210
211 let algorithm = pathfind
212 .mode
213 .unwrap_or(settings.pathfind_settings.default_mode);
214
215 let mask = if let Some(agent_mask) = agent_mask.as_mut() {
216 Some(&mut agent_mask.0)
217 } else {
218 None
219 };
220
221 let path = grid.pathfind(&mut PathfindArgs {
222 start: start.0,
223 goal: pathfind.goal,
224 blocking: Some(&blocking.0),
225 mask,
226 mode: algorithm,
227 limits: pathfind.limits,
228 });
229
230 #[cfg(feature = "stats")]
249 let elapsed_time = start_time.elapsed().as_secs_f64();
250
251 if let Some(path) = path {
252 #[cfg(feature = "stats")]
253 stats.add_pathfinding(elapsed_time, path.cost() as f64);
254
255 commands
256 .entity(entity)
257 .try_insert(path)
258 .try_remove::<PathfindingFailed>()
259 .try_remove::<NeedsPathfinding>();
260 } else {
262 #[cfg(feature = "stats")]
263 stats.add_pathfinding(elapsed_time, 0.0);
264
265 commands
266 .entity(entity)
267 .try_insert(PathfindingFailed)
268 .try_remove::<NeedsPathfinding>()
269 .try_remove::<NextPos>(); }
271
272 count += 1;
273 }
274}
275
276#[allow(clippy::too_many_arguments)]
279#[allow(clippy::type_complexity)]
280fn next_position<N: Neighborhood + 'static>(
281 mut query: Query<
282 (
283 Entity,
284 &mut Path,
285 &AgentPos,
286 &Pathfind,
287 Option<&mut AgentMask>,
288 ),
289 (WithoutPathingFailures, Without<NextPos>),
290 >,
291 grid: Single<&Grid<N>>,
292 mut blocking_map: ResMut<BlockingMap>,
293 mut direction: ResMut<DirectionMap>,
294 mut commands: Commands,
295 settings: Res<NorthstarPluginSettings>,
296 mut queue: Local<VecDeque<Entity>>,
297 #[cfg(feature = "stats")] mut stats: ResMut<Stats>,
298) {
299 let grid = grid.into_inner();
300
301 if queue.is_empty() {
303 for (entity, ..) in query.iter() {
304 queue.push_back(entity);
305 }
306 }
307
308 let mut processed = 0;
309
310 for _ in 0..queue.len() {
311 if processed >= settings.max_collision_avoidance_agents_per_frame {
312 break;
313 }
314
315 let entity = queue.pop_front().unwrap();
316
317 if let Ok((entity, mut path, position, pathfind, agent_mask)) = query.get_mut(entity) {
319 if position.0 == pathfind.goal {
320 commands.entity(entity).try_remove::<Path>();
321 commands.entity(entity).try_remove::<Pathfind>();
322 continue;
323 }
324
325 #[cfg(test)]
326 log::info!(
327 "next_position processing entity, collision enabled: {}, path length: {}",
328 grid.collision(),
329 path.len()
330 );
331
332 let next = if grid.collision() {
333 #[cfg(feature = "stats")]
334 let start = Instant::now();
335
336 let default_mask = NavMask::default();
337 let mask = agent_mask.as_ref().map(|m| &m.0).unwrap_or(&default_mask);
338 let success = avoidance(
339 grid,
340 entity,
341 &mut path,
342 position.0,
343 &blocking_map.0,
344 mask,
345 &direction.0,
346 grid.avoidance_distance() as usize,
347 );
348
349 if !success {
350 commands.entity(entity).try_insert(AvoidanceFailed);
351 continue;
352 }
353
354 #[cfg(feature = "stats")]
355 let elapsed = start.elapsed().as_secs_f64();
356 #[cfg(feature = "stats")]
357 stats.add_collision(elapsed, path.cost() as f64);
358
359 processed += 1;
360 path.pop()
361 } else {
362 path.pop()
363 };
364
365 if let Some(next) = next {
366 direction
367 .0
368 .insert(entity, next.as_vec3() - position.0.as_vec3());
369
370 if blocking_map.0.contains_key(&next) && grid.collision() {
371 queue.push_back(entity);
373 continue;
374 }
375
376 if grid.collision() {
377 blocking_map.0.remove(&position.0);
379 blocking_map.0.insert(next, entity);
380 }
381 commands.entity(entity).try_insert(NextPos(next));
384
385 queue.push_back(entity);
387 }
388 }
389 }
390}
391
392#[allow(clippy::too_many_arguments)]
395fn avoidance<N: Neighborhood + 'static>(
396 grid: &Grid<N>,
397 entity: Entity,
398 path: &mut Path,
399 position: UVec3,
400 blocking_map: &HashMap<UVec3, Entity>,
401 mask: &NavMask,
402 direction: &HashMap<Entity, Vec3>,
403 avoidance_distance: usize,
404) -> bool {
405 if path.is_empty() {
406 log::error!("Path is empty for entity: {:?}", entity);
407 return false;
408 }
409
410 let count = if path.path().len() > avoidance_distance {
412 avoidance_distance
413 } else {
414 path.path().len()
415 };
416
417 let next_position = path.path.front().unwrap();
419
420 if path.path.len() == 1 && blocking_map.contains_key(next_position) {
421 return false;
422 }
423
424 let difference = next_position.as_vec3() - position.as_vec3();
425
426 let unblocked_pos: Vec<UVec3> = path
427 .path
428 .iter()
429 .take(count)
430 .filter(|pos| {
431 if let Some(blocking_entity) = blocking_map.get(*pos) {
432 if *blocking_entity == entity {
434 return true;
435 }
436
437 if *pos == next_position {
439 return false;
440 }
441
442 if let Some(blocking_dir) = direction.get(blocking_entity) {
443 let dot = difference.dot(*blocking_dir);
444 if dot <= 0.0 {
445 return false;
446 }
447 } else {
448 return false;
450 }
451 }
452 true
453 })
454 .cloned()
455 .collect();
456
457 if unblocked_pos.len() < count {
459 let avoidance_goal = {
460 let _ = info_span!("avoidance_goal", name = "avoidance_goal").entered();
461
462 path.path
464 .iter()
465 .skip(count)
466 .find(|pos| !blocking_map.contains_key(&**pos))
467 };
468
469 if let Some(avoidance_goal) = avoidance_goal {
471 let radius = position.as_vec3().distance(avoidance_goal.as_vec3()) as u32
473 + grid.avoidance_distance();
474
475 let new_path = grid.pathfind_astar_radius(
477 position,
478 *avoidance_goal,
479 radius,
480 blocking_map,
481 Some(mask),
482 );
483
484 if let Some(new_path) = new_path {
486 let old_path = path
488 .path
489 .iter()
490 .skip_while(|pos| *pos != avoidance_goal)
491 .cloned()
492 .collect::<Vec<UVec3>>();
493
494 let mut combined_path = new_path.path().to_vec();
496 combined_path.extend(&old_path[1..]);
497
498 if combined_path.is_empty() {
499 log::error!("Combined path is empty for entity: {:?}", entity);
500 return false;
501 }
502
503 let graph_path = path.graph_path.clone();
504
505 *path = Path::from_slice(&combined_path, new_path.cost());
507 path.graph_path = graph_path;
508 } else {
509 return false;
510 }
511 } else {
512 return false;
514 }
515 }
516
517 true
536}
537
538#[allow(clippy::type_complexity)]
543fn reroute_path<N: Neighborhood + 'static>(
544 mut query: Query<
545 (Entity, &AgentPos, &Pathfind, &Path, Option<&mut AgentMask>),
546 With<AvoidanceFailed>,
547 >,
548 grid: Single<&Grid<N>>,
549 blocking_map: Res<BlockingMap>,
550 mut commands: Commands,
551 settings: Res<NorthstarPluginSettings>,
552 #[cfg(feature = "stats")] mut stats: ResMut<Stats>,
553) {
554 let grid = grid.into_inner();
555
556 for (count, (entity, position, pathfind, path, mut agent_mask)) in query.iter_mut().enumerate()
557 {
558 if count >= settings.max_pathfinding_agents_per_frame {
561 return;
562 }
563
564 #[cfg(feature = "stats")]
565 let start = Instant::now();
566
567 let mut pathfind = pathfind.clone();
568
569 pathfind.mode = Some(
570 pathfind
571 .mode
572 .unwrap_or(settings.pathfind_settings.default_mode),
573 );
574
575 let new_path = grid.reroute_path(
576 path,
577 position.0,
578 &pathfind,
579 &blocking_map.0,
580 agent_mask.as_mut().map(|m| &mut m.0),
581 );
582
583 if let Some(new_path) = new_path {
584 if new_path.path().last().unwrap() != &pathfind.goal {
586 log::error!("WE HAVE A PARTIAL ROUTE ISSUE: {:?}", entity);
587 }
588
589 #[cfg(feature = "stats")]
590 let elapsed = start.elapsed().as_secs_f64();
591 #[cfg(feature = "stats")]
592 stats.add_collision(elapsed, new_path.cost() as f64);
593
594 commands.entity(entity).try_insert(new_path);
595 commands.entity(entity).try_remove::<AvoidanceFailed>();
596 } else {
597 commands.entity(entity).try_insert(RerouteFailed);
598 commands.entity(entity).try_remove::<AvoidanceFailed>(); #[cfg(feature = "stats")]
601 let elapsed = start.elapsed().as_secs_f64();
602 #[cfg(feature = "stats")]
603 stats.add_collision(elapsed, 0.0);
604 }
605 }
606}
607
608fn update_blocking_map(
609 mut blocking_map: ResMut<BlockingMap>,
610 query: Query<(Entity, &AgentPos), With<Blocking>>,
611) {
612 blocking_map.0.clear();
613
614 for (entity, position) in query.iter() {
615 blocking_map.0.insert(position.0, entity);
616 }
617}