Skip to main content

shuyi_core/
path.rs

1//! Paths over a square grid: where a walker can go on a budget and the
2//! cheapest path there ([`reachable`]), and how far every tile is from a
3//! set of goals ([`flow_field`]). The game says what a tile costs with a
4//! cost function; the rules of who may stand where stay the game's.
5//!
6//! # Rules
7//!
8//! - **Steps.** Only the four grid directions ([`Dir`]) are steps. A step
9//!   pays the cost of the tile it enters: the cost function's answer for
10//!   that tile, or no step at all when it answers `None`. The start costs
11//!   nothing. The cost function is only asked about tiles inside the grid.
12//! - **Budget.** [`reachable`] keeps the tiles whose cheapest cost is at
13//!   most the budget.
14//! - **Determinism.** Both searches are one Dijkstra. Its queue is ordered
15//!   by `(cost, y, x)`, neighbours are visited in [`Dir::ALL`] order, and a
16//!   tile's recorded predecessor only changes for a strictly cheaper cost.
17//!   So among equally cheap paths to a tile, the one through the neighbour
18//!   expanded first wins, every time. When every step costs at least 1,
19//!   tiles are expanded in `(cost, y, x)` order, so that neighbour is the
20//!   one with the smallest `(cost, y, x)` among those on a cheapest path.
21//!   Steps that cost 0 can reach a tile at the cost being expanded but with
22//!   a smaller `(y, x)` than tiles already expanded at that cost, so then
23//!   the order is only cheapest first.
24//! - A cost that would pass `u32::MAX` is no step.
25
26use alloc::collections::BinaryHeap;
27use alloc::vec;
28use alloc::vec::Vec;
29use core::cmp::Reverse;
30
31use crate::grid::{Dir, Grid, Pos, TileSet};
32
33/// Where a walker can go from one tile on a budget, with the cheapest path
34/// to each tile. Built by [`reachable`].
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Reach {
37    origin: Pos,
38    budget: u32,
39    cost: Grid<Option<u32>>,
40    prev: Grid<Option<Pos>>,
41}
42
43impl Reach {
44    /// The tile the walk starts on.
45    pub fn origin(&self) -> Pos {
46        self.origin
47    }
48
49    /// The budget the walk had.
50    pub fn budget(&self) -> u32 {
51        self.budget
52    }
53
54    /// The cheapest cost to reach `pos`, or `None` if it is out of reach.
55    pub fn cost(&self, pos: Pos) -> Option<u32> {
56        self.cost.get(pos).copied().flatten()
57    }
58
59    /// Whether `pos` is within reach.
60    pub fn contains(&self, pos: Pos) -> bool {
61        self.cost(pos).is_some()
62    }
63
64    /// Every tile within reach, the origin included (unless it lies outside
65    /// the grid).
66    pub fn tiles(&self) -> TileSet {
67        let mut set = TileSet::new(self.cost.width(), self.cost.height());
68        for (pos, cost) in self.cost.positions().zip(self.cost.cells()) {
69            if cost.is_some() {
70                set.insert(pos);
71            }
72        }
73        set
74    }
75
76    /// The cheapest path from the origin to `dest`, both included, or
77    /// `None` if `dest` is out of reach. Deterministic (see the module
78    /// docs).
79    pub fn path_to(&self, dest: Pos) -> Option<Vec<Pos>> {
80        self.cost(dest)?;
81        let mut path = vec![dest];
82        let mut cur = dest;
83        while let Some(&Some(p)) = self.prev.get(cur) {
84            path.push(p);
85            cur = p;
86        }
87        path.reverse();
88        Some(path)
89    }
90}
91
92/// Where a walker on `start` can go with `budget` to spend on a
93/// `width × height` grid, where entering a tile costs `cost(tile)` (`None`:
94/// it can't be entered). A start outside the grid reaches nothing.
95pub fn reachable(
96    width: u16,
97    height: u16,
98    start: Pos,
99    budget: u32,
100    cost: impl Fn(Pos) -> Option<u32>,
101) -> Reach {
102    let found = search(width, height, [start], budget, |_| Some(0), cost);
103    Reach {
104        origin: start,
105        budget,
106        cost: found.cost,
107        prev: found.prev,
108    }
109}
110
111/// How far every tile of a `width × height` grid is from the nearest of
112/// `goals`: the cost of the cheapest walk from the tile onto a goal, where
113/// entering a tile costs `cost(tile)`, as in [`reachable`], and there is no
114/// budget. A goal is 0 from itself. A tile that can't be entered has no
115/// distance (unless it is a goal), and neither has a tile with no walk to a
116/// goal: a goal that can't be entered is never walked onto. Goals outside
117/// the grid are left out.
118pub fn flow_field(
119    width: u16,
120    height: u16,
121    goals: impl IntoIterator<Item = Pos>,
122    cost: impl Fn(Pos) -> Option<u32>,
123) -> Grid<Option<u32>> {
124    // Searched from the goals outwards, so each step is a walk's step
125    // backwards: from a tile to the neighbour a walker comes from. It pays
126    // for the tile walked onto, and the walker must be able to stand on the
127    // neighbour.
128    let found = search(width, height, goals, u32::MAX, &cost, |pos| {
129        cost(pos).map(|_| 0)
130    });
131    found.cost
132}
133
134/// What [`search`] found: each tile's cheapest cost and the tile it was
135/// reached from.
136struct Search {
137    cost: Grid<Option<u32>>,
138    prev: Grid<Option<Pos>>,
139}
140
141/// The one Dijkstra (see the module docs): from every tile of `sources` at
142/// cost 0, a step from a tile `a` to its neighbour `b` costs
143/// `leave(a) + enter(b)` (no step if either is `None`), and only costs
144/// within `budget` are kept.
145fn search(
146    width: u16,
147    height: u16,
148    sources: impl IntoIterator<Item = Pos>,
149    budget: u32,
150    leave: impl Fn(Pos) -> Option<u32>,
151    enter: impl Fn(Pos) -> Option<u32>,
152) -> Search {
153    let mut cost = Grid::filled(width, height, None);
154    let mut prev = Grid::filled(width, height, None);
155    let mut queue: BinaryHeap<Reverse<(u32, i32, i32)>> = BinaryHeap::new();
156    for source in sources {
157        if let Some(c) = cost.get_mut(source) {
158            *c = Some(0);
159            queue.push(Reverse((0, source.y, source.x)));
160        }
161    }
162    // Tiles already expanded: their cost and predecessor are final.
163    let mut done = TileSet::new(width, height);
164    while let Some(Reverse((c, y, x))) = queue.pop() {
165        let pos = Pos::new(x, y);
166        if !done.insert(pos) {
167            continue; // A stale entry: `pos` was expanded at a lower cost.
168        }
169        let Some(out) = leave(pos) else {
170            continue;
171        };
172        for next in Dir::ALL.map(|dir| pos.step(dir)) {
173            if !cost.in_bounds(next) || done.contains(next) {
174                continue;
175            }
176            let step = enter(next).and_then(|step| step.checked_add(out));
177            let Some(next_cost) = step
178                .and_then(|step| c.checked_add(step))
179                .filter(|&total| total <= budget)
180            else {
181                continue;
182            };
183            let Some(slot) = cost.get_mut(next) else {
184                continue;
185            };
186            if slot.is_none_or(|old| next_cost < old) {
187                *slot = Some(next_cost);
188                if let Some(p) = prev.get_mut(next) {
189                    *p = Some(pos);
190                }
191                queue.push(Reverse((next_cost, next.y, next.x)));
192            }
193        }
194    }
195    Search { cost, prev }
196}
197
198#[cfg(test)]
199mod tests;