Skip to main content

bevy_northstar/
plugin.rs

1//! Northstar Plugin. This plugin handles the pathfinding and collision avoidance systems.
2use 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/// Sets default settings for the Pathfind component.
11#[derive(Default, Debug, Copy, Clone)]
12pub struct PathfindSettings {
13    /// Sets the default pathfinding mode.
14    /// Defaults to PathfindMode::Refined
15    pub default_mode: PathfindMode,
16}
17
18/// General settings for the Northstar plugin.
19#[derive(Resource, Debug, Copy, Clone)]
20pub struct NorthstarPluginSettings {
21    /// The maximum number of agents that can be processed per frame.
22    /// This is useful to stagger the pathfinding and collision avoidance systems
23    /// to prevent stutters when too many agents are pathfinding at once.
24    pub max_pathfinding_agents_per_frame: usize,
25    /// The maximum number of agents that can be processed for collision avoidance per frame.
26    pub max_collision_avoidance_agents_per_frame: usize,
27    /// Pathfind defaults
28    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/// NorthstarPlugin is the main plugin for the Northstar pathfinding and collision avoidance systems.
42///
43#[derive(Default)]
44pub struct NorthstarPlugin<N: Neighborhood> {
45    _neighborhood: std::marker::PhantomData<N>,
46}
47
48/// Tracks the average time the pathfinding algorithm takes.
49#[derive(Default, Debug)]
50pub struct PathfindingStats {
51    /// The average time taken for pathfinding in seconds.
52    pub average_time: f64,
53    /// The average length of the path found.
54    pub average_length: f64,
55    /// A `Vec` list of all the pathfinding times.
56    pub pathfind_time: Vec<f64>,
57    /// A `Vec` list of all the pathfinding lengths.
58    pub pathfind_length: Vec<f64>,
59}
60
61/// Tracks the average time the collision avoidance algorithms take.
62#[derive(Default, Debug)]
63pub struct CollisionStats {
64    /// The average time taken for collision avoidance in seconds.
65    pub average_time: f64,
66    /// The average length of the path found after collision avoidance.
67    pub average_length: f64,
68    /// A `Vec` list of all the collision avoidance times.
69    pub avoidance_time: Vec<f64>,
70    /// A `Vec` list of all the collision avoidance lengths.
71    pub avoidance_length: Vec<f64>,
72}
73
74/// The `Stats` `Resource` holds the pathfinding and collision avoidance statistics.
75#[derive(Resource, Default, Debug)]
76pub struct Stats {
77    /// Pathfinding frame time statistics.
78    pub pathfinding: PathfindingStats,
79    /// Collision avoidance frame time statistics.
80    pub collision: CollisionStats,
81}
82
83impl Stats {
84    /// If you're manually pathfinding and want to keep track of the pathfinding statistics,
85    /// you can use this method to add the time and length of the path found.
86    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    /// Resets the pathfinding statistics.
97    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    /// If you're manually pathfinding and want to keep track of the collision avoidance statistics,
105    /// you can use this method to add the time and length of the path found after collision avoidance.
106    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    /// Resets the collision statistics.
117    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/// The `PathingSet` is a system set that is used to group the pathfinding systems together.
153/// You can use this set to schedule systems before or after the pathfinding systems.
154#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
155pub struct PathingSet;
156
157/// The `BlockingMap` `Resource` contains a map of positions of entities holding the `Blocking` component.
158/// The map is rebuilt every frame at the beginning of the `PathingSet`.
159#[derive(Resource, Default)]
160pub struct BlockingMap(pub HashMap<UVec3, Entity>);
161
162/// The `DirectionMap` `Resource` contains a map of every pathfinding entity's last moved direction.
163/// This is mainly used for collision avoidance but could be used for other purposes.
164#[derive(Resource, Default)]
165pub struct DirectionMap(pub HashMap<Entity, Vec3>);
166
167#[derive(Component)]
168#[component(storage = "SparseSet")]
169pub(crate) struct NeedsPathfinding;
170
171// Flags all the entities with a changed `Pathfind` component to request pathfinding.
172fn 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
178// The main pathfinding system. Queries for entities with the a changed `Pathfind` component.
179// It will pathfind to the goal position and insert a `Path` component with the path found.
180fn 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    //mut queue: Local<VecDeque<Entity>>,
190    #[cfg(feature = "stats")] mut stats: ResMut<Stats>,
191) {
192    let grid = grid.into_inner();
193
194    // Limit the number of agents processed per frame to prevent stutters
195    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        /*let path = match algorithm {
231            PathfindMode::Refined => {
232                grid.pathfind(start.0, pathfind.goal, &blocking.0, mask, pathfind.partial)
233            }
234            PathfindMode::Coarse => {
235                grid.pathfind_coarse(start.0, pathfind.goal, &blocking.0, mask, pathfind.partial)
236            }
237            PathfindMode::AStar => {
238                grid.pathfind_astar(start.0, pathfind.goal, &blocking.0, mask.as_deref(), pathfind.partial)
239            }
240            PathfindMode::Waypoints => {
241                grid.pathfind_waypoints(start.0, pathfind.goal, &blocking.0, mask, pathfind.partial)
242            }
243            PathfindMode::ThetaStar => {
244                grid.pathfind_thetastar(start.0, pathfind.goal, &blocking.0, mask.as_deref(), pathfind.partial)
245            }
246        };*/
247
248        #[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            // We remove PathfindingFailed even if it's not there.
261        } 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>(); // Just to be safe
270        }
271
272        count += 1;
273    }
274}
275
276// The `next_position` system is responsible for popping the front of the path into a `NextPos` component.
277// If collision is enabled it will check for nearyby blocked paths and reroute the path if necessary.
278#[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    // Initialize the queue with all candidates once
302    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 the entity still exists and is valid
318        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                    // Someone beat us to it - requeue without inserting NextPos
372                    queue.push_back(entity);
373                    continue;
374                }
375
376                if grid.collision() {
377                    // If we have a blocking mask, insert the next position as impassable
378                    blocking_map.0.remove(&position.0);
379                    blocking_map.0.insert(next, entity);
380                }
381                // Get mutable reference to the blocking map
382
383                commands.entity(entity).try_insert(NextPos(next));
384
385                // Re-queue for next frame
386                queue.push_back(entity);
387            }
388        }
389    }
390}
391
392// The `avoidance` function does a lookahead on the path, if any blocking entities are found
393// that are moving in the opposite relateive direciton it will attempt a short astar reroute.
394#[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    // Check if the next few positions are blocked
411    let count = if path.path().len() > avoidance_distance {
412        avoidance_distance
413    } else {
414        path.path().len()
415    };
416
417    // Precompute the dot product for the current position
418    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 the blocking entity is the same as the current entity, skip it
433                if *blocking_entity == entity {
434                    return true;
435                }
436
437                // Too risky to move to the next position regardless of the direction
438                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                    // They have no direction
449                    return false;
450                }
451            }
452            true
453        })
454        .cloned()
455        .collect();
456
457    // If we have a blocked position in the path, repath
458    if unblocked_pos.len() < count {
459        let avoidance_goal = {
460            let _ = info_span!("avoidance_goal", name = "avoidance_goal").entered();
461
462            // Get the first unlocked position AFTER skipping the count, we don't care about the direction
463            path.path
464                .iter()
465                .skip(count)
466                .find(|pos| !blocking_map.contains_key(&**pos))
467        };
468
469        // If we have an avoidance goal, astar path to that
470        if let Some(avoidance_goal) = avoidance_goal {
471            // Calculate a good radius from the current position to the avoidance goal
472            let radius = position.as_vec3().distance(avoidance_goal.as_vec3()) as u32
473                + grid.avoidance_distance();
474
475            //let new_path = grid.pathfind_astar(position, *avoidance_goal, blocking, false);
476            let new_path = grid.pathfind_astar_radius(
477                position,
478                *avoidance_goal,
479                radius,
480                blocking_map,
481                Some(mask),
482            );
483
484            // Replace the first few positions of path until the avoidance goal
485            if let Some(new_path) = new_path {
486                // Get every position AFTER the avoidance goal in the old path
487                let old_path = path
488                    .path
489                    .iter()
490                    .skip_while(|pos| *pos != avoidance_goal)
491                    .cloned()
492                    .collect::<Vec<UVec3>>();
493
494                // Combine the new path with the old path
495                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                // Replace the path with the combined path
506                *path = Path::from_slice(&combined_path, new_path.cost());
507                path.graph_path = graph_path;
508            } else {
509                return false;
510            }
511        } else {
512            // No easy avoidance astar path to the next entrance
513            return false;
514        }
515    }
516
517    // DELETE THIS
518    // We must have gotten to this point because of partial paths
519    /*if path.path.is_empty() {
520        // if goal is in blocking
521        if blocking_map.contains_key(&pathfind.goal) {
522            return false;
523        }
524
525        let new_path = grid.pathfind(position, pathfind.goal, blocking_map, false);
526
527        if let Some(new_path) = new_path {
528            *path = new_path;
529            return true;
530        } else {
531            return false;
532        }
533    }*/
534
535    true
536}
537
538// The `reroute_path` system is responsible for rerouting the path if the `AvoidanceFailed` component is present.
539// It will attempt to find a new full path to the goal position and insert it into the entity.
540// If the reroute fails, it will insert a `RerouteFailed` component to the entity.
541// Once an entity has a reroute failure, no pathfinding will be attempted until the user handles reinserts the `Pathfind` component.
542#[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        // TODO: This doesn't really tie in with the main pathfinding agent counts. This will stil help limit how many are rereouting for now.
559        // There's no point bridging it for the moment since this really needs to be reworked into an async system to really prevent stutters.
560        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 the last position in the path is not the goal...
585            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>(); // Try again next frame
599
600            #[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}