Skip to main content

wde_terrain_navigation/navigation/
pathfinding.rs

1//! A* pathfinding over the colony.
2//!
3//! The search graph mixes two resolutions:
4//! - Outdoor grid tiles, walkable when empty or covered by a path piece.
5//! - Walk cells inside buildings (see [`WalkMap`]), baked from their geometry, so that colonists
6//!   go around walls and furniture.
7//!
8//! Moves between two buildings follow tunnel links, and moves between pressurized and outdoor
9//! places go through airlocks, where colonists stop for the airlock cycle. Each step costs its
10//! length divided by the walking speed factor of its terrain (see [`WalkSpeeds`]), so colonists
11//! prefer paths and tunnels over bare ground.
12
13use std::{
14    cmp::Ordering,
15    collections::{BinaryHeap, HashMap}
16};
17
18use bevy::prelude::*;
19use wde_terrain::prelude::Terrain as TerrainMap;
20use wde_terrain_grid::{core::grid::TILE_SIZE, prelude::*};
21
22use crate::{
23    navgrid::NavGrid,
24    navigation::{
25        WalkSpeeds,
26        walkmap::{CELL_SIZE, WalkMap, is_airlock, is_path_piece, is_pressurized}
27    }
28};
29
30/// Placement role of the buildings linking the outdoors to the tunnel network.
31pub const AIRLOCK_ROLE: &str = "airlock";
32
33/// Margin around the start and goal within which outdoor tiles are searched.
34const SEARCH_MARGIN: f32 = 40.0;
35/// Node expansions after which the search gives up.
36const MAX_EXPANSIONS: usize = 80_000;
37/// How far an unreachable start or goal is moved to the nearest walkable place.
38const SNAP_DISTANCE: f32 = 3.0;
39/// Step between the samples checking that a straight line stays walkable.
40const LINE_STEP: f32 = CELL_SIZE * 0.5;
41/// Half width kept walkable on both sides of a straight line, so that bodies do not brush walls.
42const LINE_HALF_WIDTH: f32 = 0.08;
43/// Rings of candidate spots searched around a group destination.
44const MAX_FORMATION_RINGS: usize = 24;
45/// Steepest outdoor ground walked on (height change per unit of distance, about 42°).
46const MAX_WALK_SLOPE: f32 = 0.9;
47/// Distance over which the ground slope is measured.
48const SLOPE_PROBE: f32 = 0.5;
49
50#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
51enum Node {
52    /// Outdoor tile (empty or path piece), by tile index.
53    Tile(IVec2),
54    /// Walk cell inside a building, by cell index.
55    Cell(IVec2)
56}
57
58/// Kind of ground a node stands on.
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60pub(crate) enum Terrain {
61    /// Bare outdoor ground, or an outdoor building.
62    Ground,
63    /// Outdoor path.
64    Path,
65    /// Pressurized building or tunnel.
66    Indoor,
67    /// Airlock, between outside and inside.
68    Airlock
69}
70impl Terrain {
71    fn speed(self, speeds: &WalkSpeeds) -> f32 {
72        match self {
73            Terrain::Ground => speeds.ground,
74            Terrain::Path => speeds.path,
75            Terrain::Indoor | Terrain::Airlock => speeds.indoor
76        }
77    }
78
79    pub fn pressurized(self) -> bool {
80        matches!(self, Terrain::Indoor | Terrain::Airlock)
81    }
82}
83
84/// Speed factor of the step between two nodes: the slower of both ends.
85fn step_speed(a: Terrain, b: Terrain, speeds: &WalkSpeeds) -> f32 {
86    a.speed(speeds).min(b.speed(speeds)).max(0.01)
87}
88
89/// Point of a path: where to go, how fast to get there, and what to do on arrival.
90#[derive(Clone, Copy, Debug)]
91pub(crate) struct Waypoint {
92    pub position: Vec3,
93    /// Walking speed factor of the segment leading to this waypoint.
94    pub speed: f32,
95    /// Whether the segment leading to this waypoint is pressurized (helmet off).
96    pub indoor: bool,
97    /// Time to stand still once reached (airlock cycle), in seconds.
98    pub wait: f32
99}
100
101/// Walkable place.
102#[derive(Clone, Copy, PartialEq, Debug)]
103pub(crate) struct Location {
104    /// Building walked in, `None` outdoors (path pieces included).
105    pub building: Option<Entity>,
106    pub terrain: Terrain,
107    /// Floor height.
108    pub height: f32
109}
110
111/// Placed buildings, grid occupancy and walk cells used to build the search graph.
112pub(crate) struct NavWorld<'a> {
113    pub grid: &'a Grid,
114    pub buildings: HashMap<Entity, &'a GridEntity>,
115    pub walk: &'a WalkMap,
116    pub network: &'a NavGrid,
117    /// Outdoor ground heights; flat at 0 without terrain.
118    pub terrain: Option<&'a TerrainMap>
119}
120
121impl NavWorld<'_> {
122    fn tile_key(pos: Vec2) -> IVec2 {
123        (pos / TILE_SIZE).floor().as_ivec2()
124    }
125
126    fn tile_center(key: IVec2) -> Vec2 {
127        (key.as_vec2() + 0.5) * TILE_SIZE
128    }
129
130    fn occupant(&self, pos: Vec2) -> Option<(Entity, &GridEntity)> {
131        let (chunk, local) = Grid::get_nearest_tile(pos);
132        let entity = self.grid.get_entity(chunk, local)?;
133        self.buildings.get(&entity).map(|b| (entity, *b))
134    }
135
136    /// Whether the tile at `pos` is walked as a whole (empty or path piece), rather than through
137    /// the walk cells of a building.
138    fn is_open_tile(&self, pos: Vec2) -> bool {
139        self.occupant(pos).is_none_or(|(_, b)| is_path_piece(b))
140    }
141
142    /// Outdoor ground height at `pos`, `None` beyond the terrain or where too steep to walk.
143    fn ground(&self, pos: Vec2) -> Option<f32> {
144        let Some(terrain) = self.terrain else {
145            return Some(0.0);
146        };
147        let height = terrain.height_at(pos)?;
148        let steep = [Vec2::X, Vec2::Y].into_iter().any(|axis| {
149            let probe = axis * SLOPE_PROBE;
150            match (
151                terrain.height_at(pos + probe),
152                terrain.height_at(pos - probe)
153            ) {
154                (Some(a), Some(b)) => (a - b).abs() > MAX_WALK_SLOPE * 2.0 * SLOPE_PROBE,
155                _ => true
156            }
157        });
158        (!steep).then_some(height)
159    }
160
161    /// Walkable place at `pos`, `None` if blocked.
162    pub fn locate(&self, pos: Vec2) -> Option<Location> {
163        match self.occupant(pos) {
164            None => Some(Location {
165                building: None,
166                terrain: Terrain::Ground,
167                height: self.ground(pos)?
168            }),
169            Some((_, b)) if is_path_piece(b) => Some(Location {
170                building: None,
171                terrain: Terrain::Path,
172                height: self.ground(pos)?
173            }),
174            Some((entity, b)) => {
175                let cell = self.walk.cells.get(&WalkMap::cell_key(pos))?;
176                (cell.building == entity).then(|| Location {
177                    building: Some(entity),
178                    terrain: match (is_airlock(b), is_pressurized(b)) {
179                        (true, _) => Terrain::Airlock,
180                        (false, true) => Terrain::Indoor,
181                        (false, false) => Terrain::Ground
182                    },
183                    height: cell.floor
184                })
185            }
186        }
187    }
188
189    /// Whether one can step from `a` to the neighboring place `b`: within a building or the
190    /// outdoors, between buildings linked by a tunnel, or through an airlock door.
191    pub fn can_cross(&self, a: &Location, b: &Location) -> bool {
192        if a.building == b.building {
193            return true;
194        }
195        match (a.terrain.pressurized(), b.terrain.pressurized()) {
196            (false, false) => true,
197            (true, true) => match (a.building, b.building) {
198                (Some(x), Some(y)) => self.network.buildings.get(&x).is_some_and(|building| {
199                    building
200                        .neighbors
201                        .iter()
202                        .any(|n| n.entity == y && n.kind == LinkKind::Tunnel)
203                }),
204                _ => false
205            },
206            _ => a.terrain == Terrain::Airlock || b.terrain == Terrain::Airlock
207        }
208    }
209
210    /// Whether the straight line from `a` to `b` can be walked, with some width, on the same
211    /// terrain all along.
212    fn line_walkable(&self, a: Vec2, b: Vec2) -> bool {
213        let side = (b - a).perp().normalize_or_zero() * LINE_HALF_WIDTH;
214        [Vec2::ZERO, side, -side]
215            .into_iter()
216            .all(|offset| self.strip_walkable(a + offset, b + offset))
217    }
218
219    fn strip_walkable(&self, a: Vec2, b: Vec2) -> bool {
220        let Some(mut previous) = self.locate(a) else {
221            return false;
222        };
223        let steps = (a.distance(b) / LINE_STEP).ceil().max(1.0) as usize;
224        for i in 1..=steps {
225            let Some(here) = self.locate(a.lerp(b, i as f32 / steps as f32)) else {
226                return false;
227            };
228            if here.terrain != previous.terrain || !self.can_cross(&previous, &here) {
229                return false;
230            }
231            previous = here;
232        }
233        true
234    }
235
236    /// Nearest walkable place to `pos` within `max_distance`, accepted by `filter`.
237    fn nearest_walkable(
238        &self,
239        pos: Vec2,
240        max_distance: f32,
241        filter: impl Fn(&Location) -> bool
242    ) -> Option<(Vec2, Location)> {
243        if let Some(here) = self.locate(pos).filter(&filter) {
244            return Some((pos, here));
245        }
246        let rings = (max_distance / CELL_SIZE).ceil() as i32;
247        let origin = WalkMap::cell_key(pos);
248        let mut best: Option<(f32, Vec2, Location)> = None;
249        for ring in 1..=rings {
250            for dy in -ring..=ring {
251                for dx in -ring..=ring {
252                    if dx.abs() != ring && dy.abs() != ring {
253                        continue;
254                    }
255                    let p = WalkMap::cell_center(origin + IVec2::new(dx, dy));
256                    let distance = p.distance(pos);
257                    if distance > max_distance || best.is_some_and(|b| b.0 <= distance) {
258                        continue;
259                    }
260                    if let Some(here) = self.locate(p).filter(&filter) {
261                        best = Some((distance, p, here));
262                    }
263                }
264            }
265            // Later rings are at least `ring` cells away
266            if best.is_some_and(|b| b.0 <= ring as f32 * CELL_SIZE) {
267                break;
268            }
269        }
270        best.map(|(_, p, l)| (p, l))
271    }
272
273    /// Search node at a walkable position.
274    fn node_at(&self, pos: Vec2) -> Node {
275        match self.is_open_tile(pos) {
276            true => Node::Tile(Self::tile_key(pos)),
277            false => Node::Cell(WalkMap::cell_key(pos))
278        }
279    }
280
281    fn node_position(node: Node) -> Vec2 {
282        match node {
283            Node::Tile(key) => Self::tile_center(key),
284            Node::Cell(key) => WalkMap::cell_center(key)
285        }
286    }
287
288    /// Walkable neighbors of a node, with their location.
289    fn neighbors(&self, node: Node, from: &Location, out: &mut Vec<(Node, Location)>) {
290        let push = |pos: Vec2, out: &mut Vec<(Node, Location)>| {
291            if let Some(here) = self.locate(pos)
292                && self.can_cross(from, &here)
293            {
294                let next = self.node_at(pos);
295                if next != node && !out.iter().any(|(n, _)| *n == next) {
296                    out.push((next, here));
297                }
298            }
299        };
300        match node {
301            Node::Tile(key) => {
302                let center = Self::tile_center(key);
303                for d in DIRECTIONS {
304                    let next = Self::tile_center(key + d);
305                    let diagonal = d.x != 0 && d.y != 0;
306                    if self.is_open_tile(next) {
307                        // No corner cutting past a building
308                        if !diagonal
309                            || (self.is_open_tile(Self::tile_center(key + IVec2::new(d.x, 0)))
310                                && self.is_open_tile(Self::tile_center(key + IVec2::new(0, d.y))))
311                        {
312                            push(next, out);
313                        }
314                    } else if !diagonal {
315                        // Cells of the building along the shared side
316                        let side = d.as_vec2() * (TILE_SIZE + CELL_SIZE) / 2.0;
317                        let along = Vec2::new(d.y as f32, d.x as f32).abs();
318                        for i in 0..8 {
319                            let offset = (i as f32 - 3.5) * CELL_SIZE;
320                            push(center + side + along * offset, out);
321                        }
322                    }
323                }
324            }
325            Node::Cell(key) => {
326                for d in DIRECTIONS {
327                    // No corner cutting past an obstacle
328                    let diagonal = d.x != 0 && d.y != 0;
329                    if diagonal
330                        && (self
331                            .locate(WalkMap::cell_center(key + IVec2::new(d.x, 0)))
332                            .is_none()
333                            || self
334                                .locate(WalkMap::cell_center(key + IVec2::new(0, d.y)))
335                                .is_none())
336                    {
337                        continue;
338                    }
339                    push(WalkMap::cell_center(key + d), out);
340                }
341            }
342        }
343    }
344}
345
346const DIRECTIONS: [IVec2; 8] = [
347    IVec2::new(1, 0),
348    IVec2::new(-1, 0),
349    IVec2::new(0, 1),
350    IVec2::new(0, -1),
351    IVec2::new(1, 1),
352    IVec2::new(1, -1),
353    IVec2::new(-1, 1),
354    IVec2::new(-1, -1)
355];
356
357#[derive(PartialEq)]
358struct Open {
359    f: f32,
360    node: Node
361}
362impl Eq for Open {}
363impl PartialOrd for Open {
364    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
365        Some(self.cmp(other))
366    }
367}
368impl Ord for Open {
369    // Min-heap on f
370    fn cmp(&self, other: &Self) -> Ordering {
371        other.f.total_cmp(&self.f)
372    }
373}
374
375/// Point of a path before it becomes waypoints.
376#[derive(Clone, Copy)]
377struct PathPoint {
378    position: Vec3,
379    terrain: Terrain,
380    /// Whether the colonist is on the pressurized side here (airlocks are both).
381    pressurized: bool,
382    /// Airlock cycle stop: kept by the smoothing.
383    wait: f32
384}
385
386/// Path from `start` to `goal`, through walkable places only (`outdoors_only`: never through
387/// pressurized places). An unreachable goal is moved to the nearest walkable place.
388pub(crate) fn find_path(
389    world: &NavWorld,
390    speeds: &WalkSpeeds,
391    start: Vec3,
392    goal: Vec3,
393    outdoors_only: bool
394) -> Option<Vec<Waypoint>> {
395    let allowed = |l: &Location| !outdoors_only || !l.terrain.pressurized();
396    let (start, start_location) = world.nearest_walkable(start.xz(), SNAP_DISTANCE, allowed)?;
397    let (goal, goal_location) = world.nearest_walkable(goal.xz(), SNAP_DISTANCE, allowed)?;
398    let (start_node, goal_node) = (world.node_at(start), world.node_at(goal));
399    let bounds = Rect::from_corners(start, goal).inflate(SEARCH_MARGIN);
400
401    // Admissible heuristic: straight line at the fastest speed
402    let fastest = speeds.indoor.max(speeds.path).max(speeds.ground).max(0.01);
403    let goal_position = NavWorld::node_position(goal_node);
404    let heuristic = |pos: Vec2| pos.distance(goal_position) / fastest;
405
406    // A*
407    let mut open = BinaryHeap::from([Open {
408        f: heuristic(start),
409        node: start_node
410    }]);
411    let mut came_from: HashMap<Node, Node> = HashMap::new();
412    let mut cost: HashMap<Node, f32> = HashMap::from([(start_node, 0.0)]);
413    let mut locations: HashMap<Node, Location> =
414        HashMap::from([(start_node, start_location), (goal_node, goal_location)]);
415    let mut expansions = 0;
416    let mut neighbors = Vec::with_capacity(16);
417    while let Some(Open { node, .. }) = open.pop() {
418        if node == goal_node {
419            break;
420        }
421        expansions += 1;
422        if expansions > MAX_EXPANSIONS {
423            return None;
424        }
425        let location = locations[&node];
426        let position = NavWorld::node_position(node);
427        neighbors.clear();
428        world.neighbors(node, &location, &mut neighbors);
429        neighbors.retain(|(_, l)| allowed(l));
430        let node_cost = cost[&node];
431        for &(next, next_location) in &neighbors {
432            let next_position = NavWorld::node_position(next);
433            if !bounds.contains(next_position) {
434                continue;
435            }
436            let next_cost = node_cost
437                + position.distance(next_position)
438                    / step_speed(location.terrain, next_location.terrain, speeds);
439            if cost.get(&next).is_some_and(|c| *c <= next_cost) {
440                continue;
441            }
442            cost.insert(next, next_cost);
443            came_from.insert(next, node);
444            locations.insert(next, next_location);
445            open.push(Open {
446                f: next_cost + heuristic(next_position),
447                node: next
448            });
449        }
450    }
451    if !came_from.contains_key(&goal_node) && start_node != goal_node {
452        return None;
453    }
454
455    // Nodes from start to goal, with the exact start and goal positions at both ends
456    let mut nodes = vec![goal_node];
457    while let Some(previous) = came_from.get(nodes.last().unwrap()) {
458        nodes.push(*previous);
459    }
460    nodes.reverse();
461    let mut points: Vec<(Vec2, Location)> = nodes
462        .iter()
463        .map(|node| (NavWorld::node_position(*node), locations[node]))
464        .collect();
465    points[0] = (start, start_location);
466    if points.len() == 1 {
467        points.push((goal, goal_location));
468    }
469    let last = points.len() - 1;
470    points[last] = (goal, goal_location);
471
472    let points = airlock_stops(world, &points, speeds);
473    let points = smooth(world, &points);
474
475    // Waypoints: speed and environment of the segment leading to each
476    Some(
477        points
478            .windows(2)
479            .map(|pair| {
480                let (from, to) = (pair[0], pair[1]);
481                Waypoint {
482                    position: to.position,
483                    speed: step_speed(from.terrain, to.terrain, speeds),
484                    indoor: from.pressurized && to.pressurized,
485                    wait: to.wait
486                }
487            })
488            .collect()
489    )
490}
491
492/// Turns located points into path points, adding a stop at the center of each airlock crossed
493/// from one side to the other.
494fn airlock_stops(
495    world: &NavWorld,
496    points: &[(Vec2, Location)],
497    speeds: &WalkSpeeds
498) -> Vec<PathPoint> {
499    let point = |(p, l): (Vec2, Location), pressurized: bool, wait: f32| PathPoint {
500        position: Vec3::new(p.x, l.height, p.y),
501        terrain: l.terrain,
502        pressurized,
503        wait
504    };
505    let mut result = Vec::with_capacity(points.len() + 2);
506    let mut i = 0;
507    while i < points.len() {
508        if points[i].1.terrain != Terrain::Airlock {
509            result.push(point(points[i], points[i].1.terrain.pressurized(), 0.0));
510            i += 1;
511            continue;
512        }
513        // Run of airlock points, and the sides it joins
514        let end = (i..points.len())
515            .find(|&j| points[j].1.terrain != Terrain::Airlock)
516            .unwrap_or(points.len());
517        let before = i.checked_sub(1).map(|j| points[j].1.terrain.pressurized());
518        let after = points.get(end).map(|p| p.1.terrain.pressurized());
519        let side_in = before.or(after).unwrap_or(true);
520        let side_out = after.or(before).unwrap_or(true);
521        let center = (side_in != side_out)
522            .then(|| {
523                let airlock = points[i].1.building?;
524                let center = world.buildings.get(&airlock)?.center();
525                world.nearest_walkable(center, TILE_SIZE, |l| l.building == Some(airlock))
526            })
527            .flatten();
528        match center {
529            Some(center) => {
530                result.push(point(points[i], side_in, 0.0));
531                result.push(point(center, side_in, speeds.airlock_cycle));
532                result.push(point(points[end - 1], side_out, 0.0));
533            }
534            None => result.extend(points[i..end].iter().map(|p| point(*p, side_in, 0.0)))
535        }
536        i = end;
537    }
538    result
539}
540
541/// Skips the points that a straight walkable line on the same terrain can cut, keeping the
542/// airlock stops.
543fn smooth(world: &NavWorld, points: &[PathPoint]) -> Vec<PathPoint> {
544    let mut result = vec![points[0]];
545    let mut from = 0;
546    while from + 1 < points.len() {
547        let mut to = from + 1;
548        while to + 1 < points.len()
549            && points[to].wait <= 0.0
550            && points[to + 1].pressurized == points[from].pressurized
551            && world.line_walkable(points[from].position.xz(), points[to + 1].position.xz())
552        {
553            to += 1;
554        }
555        result.push(points[to]);
556        from = to;
557    }
558    result
559}
560
561/// Up to `count` standing spots around `center`, `spacing` apart on a hexagonal pattern, nearest
562/// first. Spots stay walkable and in the place of `center` (its building, or outdoors).
563pub(crate) fn formation_spots(
564    world: &NavWorld,
565    center: Vec3,
566    count: usize,
567    spacing: f32
568) -> Vec<Vec3> {
569    let Some((origin, place)) = world.nearest_walkable(center.xz(), SNAP_DISTANCE, |_| true) else {
570        return Vec::new();
571    };
572    let axes: Vec<Vec2> = (0..6)
573        .map(|i| Vec2::from_angle(i as f32 * std::f32::consts::TAU / 6.0) * spacing)
574        .collect();
575    let mut spots = Vec::with_capacity(count);
576    for ring in 0..=MAX_FORMATION_RINGS {
577        let candidates: Vec<Vec2> = match ring {
578            0 => vec![origin],
579            _ => (0..6)
580                .flat_map(|side| {
581                    let corner = origin + axes[side] * ring as f32;
582                    let step = axes[(side + 2) % 6];
583                    (0..ring).map(move |i| corner + step * i as f32)
584                })
585                .collect()
586        };
587        for p in candidates {
588            if let Some(here) = world.locate(p)
589                && here.building == place.building
590                && here.terrain.pressurized() == place.terrain.pressurized()
591            {
592                spots.push(Vec3::new(p.x, here.height, p.y));
593                if spots.len() == count {
594                    return spots;
595                }
596            }
597        }
598    }
599    spots
600}