Skip to main content

flatland_pathfinding/
path.rs

1//! A* grid pathfinding.
2
3use std::cmp::Ordering;
4use std::collections::{BinaryHeap, HashMap};
5
6use crate::grid::{
7    block_circle, cell_center, clear_door_cells, collides_player_at, mark_building_footprint,
8    terrain_cost, world_to_cell, NavWorld, SEGMENT_SAMPLE_M,
9};
10use crate::z_nav::{cell_walkable_for_path, goal_z_for};
11
12#[derive(Clone, Copy, Eq, PartialEq)]
13struct OpenNode {
14    f: u32,
15    g: u32,
16    x: i16,
17    y: i16,
18}
19
20impl Ord for OpenNode {
21    fn cmp(&self, other: &Self) -> Ordering {
22        other.f.cmp(&self.f).then_with(|| other.g.cmp(&self.g))
23    }
24}
25
26impl PartialOrd for OpenNode {
27    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
28        Some(self.cmp(other))
29    }
30}
31
32struct NavGrid {
33    width: i16,
34    height: i16,
35    blocked: Vec<bool>,
36    cost: Vec<u16>,
37}
38
39impl NavGrid {
40    fn idx(&self, x: i16, y: i16) -> usize {
41        (y as usize) * (self.width as usize) + (x as usize)
42    }
43
44    fn in_bounds(&self, x: i16, y: i16) -> bool {
45        x >= 0 && y >= 0 && x < self.width && y < self.height
46    }
47
48    fn is_walkable(&self, x: i16, y: i16) -> bool {
49        self.in_bounds(x, y) && !self.blocked[self.idx(x, y)]
50    }
51
52    fn move_cost(&self, x: i16, y: i16) -> u32 {
53        self.cost[self.idx(x, y)] as u32
54    }
55
56    fn set_blocked(&mut self, x: i16, y: i16, blocked: bool) {
57        if self.in_bounds(x, y) {
58            let idx = self.idx(x, y);
59            self.blocked[idx] = blocked;
60        }
61    }
62}
63
64fn build_grid(world: &NavWorld, player_z: f32, goal_z: f32) -> NavGrid {
65    let width = world.world_width_m.max(1.0).ceil() as i16;
66    let height = world.world_height_m.max(1.0).ceil() as i16;
67    let len = (width as usize) * (height as usize);
68    let mut blocked = vec![false; len];
69    let mut cost = vec![10u16; len];
70
71    for y in 0..height {
72        for x in 0..width {
73            let cx = x as f32 + 0.5;
74            let cy = y as f32 + 0.5;
75            let kind = world.terrain_kind_at(cx, cy);
76            let tc = terrain_cost(kind);
77            let idx = (y as usize) * (width as usize) + (x as usize);
78            cost[idx] = tc;
79            if tc == u16::MAX {
80                blocked[idx] = true;
81            }
82        }
83    }
84
85    let mut grid = NavGrid {
86        width,
87        height,
88        blocked,
89        cost,
90    };
91
92    for circle in &world.circles {
93        block_circle(
94            &mut grid.blocked,
95            grid.width,
96            grid.height,
97            circle.x,
98            circle.y,
99            circle.radius_m,
100        );
101    }
102
103    for building in &world.buildings {
104        mark_building_footprint(&mut grid.blocked, grid.width, grid.height, building);
105    }
106
107    clear_door_cells(&mut grid.blocked, grid.width, grid.height, &world.doors);
108
109    // Z-band walkability is O(width*height); skip when the segment has no layers.
110    if !world.z_platforms.is_empty() || !world.z_transitions.is_empty() {
111        for y in 0..height {
112            for x in 0..width {
113                let cx = x as f32 + 0.5;
114                let cy = y as f32 + 0.5;
115                if !cell_walkable_for_path(world, cx, cy, player_z, goal_z) {
116                    grid.set_blocked(x, y, true);
117                }
118            }
119        }
120    }
121
122    grid
123}
124
125fn heuristic(ax: i16, ay: i16, bx: i16, by: i16) -> u32 {
126    let dx = (ax - bx).unsigned_abs() as u32;
127    let dy = (ay - by).unsigned_abs() as u32;
128    let diag = dx.min(dy);
129    let straight = dx.max(dy) - diag;
130    diag * 14 + straight * 10
131}
132
133fn line_clear(grid: &NavGrid, from: (i16, i16), to: (i16, i16)) -> bool {
134    let (mut x0, mut y0) = from;
135    let (x1, y1) = to;
136    let dx = (x1 - x0).abs();
137    let dy = (y1 - y0).abs();
138    let sx = if x0 < x1 { 1 } else { -1 };
139    let sy = if y0 < y1 { 1 } else { -1 };
140    let mut err = dx - dy;
141    loop {
142        if !grid.is_walkable(x0, y0) {
143            return false;
144        }
145        if x0 == x1 && y0 == y1 {
146            break;
147        }
148        let e2 = err * 2;
149        if e2 > -dy {
150            err -= dy;
151            x0 += sx;
152        }
153        if e2 < dx {
154            err += dx;
155            y0 += sy;
156        }
157    }
158    true
159}
160
161fn simplify_path(
162    grid: &NavGrid,
163    world: &NavWorld,
164    came_from: &HashMap<(i16, i16), (i16, i16)>,
165    start: (i16, i16),
166    goal: (i16, i16),
167    goal_center: (f32, f32),
168) -> Vec<(f32, f32)> {
169    let mut cells = vec![goal];
170    let mut current = goal;
171    while current != start {
172        let Some(&prev) = came_from.get(&current) else {
173            break;
174        };
175        cells.push(prev);
176        current = prev;
177    }
178    cells.reverse();
179
180    if cells.is_empty() {
181        return vec![goal_center];
182    }
183
184    let mut waypoints: Vec<(i16, i16)> = Vec::new();
185    let mut anchor = 0usize;
186    waypoints.push(cells[0]);
187    for i in 1..cells.len() {
188        if i + 1 < cells.len() {
189            let from = cell_center(cells[anchor].0, cells[anchor].1);
190            let to = if cells[i + 1] == goal {
191                goal_center
192            } else {
193                cell_center(cells[i + 1].0, cells[i + 1].1)
194            };
195            if line_clear(grid, cells[anchor], cells[i + 1])
196                && segment_clear_world(grid, world, from, to)
197            {
198                continue;
199            }
200        }
201        waypoints.push(cells[i]);
202        anchor = i;
203    }
204
205    let mut out: Vec<(f32, f32)> = waypoints.iter().map(|&(x, y)| cell_center(x, y)).collect();
206    if let Some(last) = out.last_mut() {
207        *last = goal_center;
208    }
209
210    if path_segments_clear(grid, world, &out) {
211        return out;
212    }
213
214    let mut fallback: Vec<(f32, f32)> = cells.iter().map(|&(x, y)| cell_center(x, y)).collect();
215    if let Some(last) = fallback.last_mut() {
216        *last = goal_center;
217    }
218    fallback
219}
220
221fn path_segments_clear(grid: &NavGrid, world: &NavWorld, path: &[(f32, f32)]) -> bool {
222    path.windows(2)
223        .all(|w| segment_clear_world(grid, world, w[0], w[1]))
224}
225
226fn segment_clear_world(grid: &NavGrid, world: &NavWorld, from: (f32, f32), to: (f32, f32)) -> bool {
227    let (fx, fy) = from;
228    let (tx, ty) = to;
229    let dist = (tx - fx).hypot(ty - fy);
230    let steps = (dist / SEGMENT_SAMPLE_M).ceil() as u32 + 1;
231    for step in 0..=steps {
232        let t = step as f32 / steps as f32;
233        let x = fx + (tx - fx) * t;
234        let y = fy + (ty - fy) * t;
235        if collides_player_at(x, y, world) {
236            return false;
237        }
238    }
239    let (cx0, cy0) = world_to_cell(fx, fy);
240    let (cx1, cy1) = world_to_cell(tx, ty);
241    line_clear(grid, (cx0, cy0), (cx1, cy1))
242}
243
244/// Plan a path from `(from_x, from_y, from_z)` to `(to_x, to_y)` using goal z inferred from `from_z`.
245pub fn find_path(
246    world: &NavWorld,
247    from_x: f32,
248    from_y: f32,
249    from_z: f32,
250    to_x: f32,
251    to_y: f32,
252) -> Option<Vec<(f32, f32)>> {
253    let to_z = goal_z_for(world, to_x, to_y, from_z);
254    find_path_with_goal_z(world, from_x, from_y, from_z, to_x, to_y, to_z)
255}
256
257pub fn find_path_with_goal_z(
258    world: &NavWorld,
259    from_x: f32,
260    from_y: f32,
261    from_z: f32,
262    to_x: f32,
263    to_y: f32,
264    to_z: f32,
265) -> Option<Vec<(f32, f32)>> {
266    let grid = build_grid(world, from_z, to_z);
267    let (sx, sy) = world_to_cell(from_x, from_y);
268    let (gx, gy) = world_to_cell(to_x, to_y);
269
270    if !grid.in_bounds(sx, sy) || !grid.in_bounds(gx, gy) {
271        return None;
272    }
273
274    let mut goal_x = gx;
275    let mut goal_y = gy;
276    if !grid.is_walkable(goal_x, goal_y) {
277        let mut found = None;
278        'search: for radius in 1..=16i16 {
279            for dy in -radius..=radius {
280                for dx in -radius..=radius {
281                    if dx.abs() != radius && dy.abs() != radius {
282                        continue;
283                    }
284                    let x = gx + dx;
285                    let y = gy + dy;
286                    if grid.is_walkable(x, y) {
287                        found = Some((x, y));
288                        break 'search;
289                    }
290                }
291            }
292        }
293        let (x, y) = found?;
294        goal_x = x;
295        goal_y = y;
296    }
297
298    let goal_key = (goal_x, goal_y);
299
300    let mut start_x = sx;
301    let mut start_y = sy;
302    if !grid.is_walkable(start_x, start_y) {
303        let mut found = None;
304        'start: for radius in 1..=16i16 {
305            for dy in -radius..=radius {
306                for dx in -radius..=radius {
307                    if dx.abs() != radius && dy.abs() != radius {
308                        continue;
309                    }
310                    let x = sx + dx;
311                    let y = sy + dy;
312                    if grid.is_walkable(x, y) {
313                        found = Some((x, y));
314                        break 'start;
315                    }
316                }
317            }
318        }
319        let (x, y) = found?;
320        start_x = x;
321        start_y = y;
322    }
323    let start_key = (start_x, start_y);
324
325    if start_key == goal_key {
326        return Some(vec![cell_center(goal_x, goal_y)]);
327    }
328
329    let mut open = BinaryHeap::new();
330    let mut g_score: HashMap<(i16, i16), u32> = HashMap::new();
331    let mut came_from: HashMap<(i16, i16), (i16, i16)> = HashMap::new();
332
333    g_score.insert(start_key, 0);
334    open.push(OpenNode {
335        f: heuristic(start_x, start_y, goal_x, goal_y),
336        g: 0,
337        x: start_x,
338        y: start_y,
339    });
340
341    const NEIGHBORS: [(i16, i16, u32); 8] = [
342        (1, 0, 10),
343        (-1, 0, 10),
344        (0, 1, 10),
345        (0, -1, 10),
346        (1, 1, 14),
347        (1, -1, 14),
348        (-1, 1, 14),
349        (-1, -1, 14),
350    ];
351
352    while let Some(current) = open.pop() {
353        if (current.x, current.y) == goal_key {
354            return Some(simplify_path(
355                &grid,
356                world,
357                &came_from,
358                start_key,
359                goal_key,
360                cell_center(goal_x, goal_y),
361            ));
362        }
363        let Some(&best_g) = g_score.get(&(current.x, current.y)) else {
364            continue;
365        };
366        if current.g > best_g {
367            continue;
368        }
369
370        for (dx, dy, step_base) in NEIGHBORS {
371            let nx = current.x + dx;
372            let ny = current.y + dy;
373            if !grid.is_walkable(nx, ny) {
374                continue;
375            }
376            if dx != 0 && dy != 0 {
377                if !grid.is_walkable(current.x + dx, current.y)
378                    || !grid.is_walkable(current.x, current.y + dy)
379                {
380                    continue;
381                }
382            }
383            let from = cell_center(current.x, current.y);
384            let to = cell_center(nx, ny);
385            if !segment_clear_world(&grid, world, from, to) {
386                continue;
387            }
388            let step = step_base * grid.move_cost(nx, ny) / 10;
389            let tentative = best_g + step;
390            let key = (nx, ny);
391            if tentative >= *g_score.get(&key).unwrap_or(&u32::MAX) {
392                continue;
393            }
394            came_from.insert(key, (current.x, current.y));
395            g_score.insert(key, tentative);
396            open.push(OpenNode {
397                f: tentative + heuristic(nx, ny, goal_x, goal_y),
398                g: tentative,
399                x: nx,
400                y: ny,
401            });
402        }
403    }
404
405    None
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn open_world() -> NavWorld {
413        NavWorld {
414            world_width_m: 64.0,
415            world_height_m: 64.0,
416            terrain_zones: vec![],
417            z_platforms: vec![],
418            z_transitions: vec![],
419            buildings: vec![],
420            doors: vec![],
421            circles: vec![],
422        }
423    }
424
425    #[test]
426    fn path_on_open_field() {
427        let world = open_world();
428        let path = find_path(&world, 10.0, 10.0, 0.0, 20.0, 15.0).expect("path");
429        assert!(!path.is_empty());
430        let last = *path.last().unwrap();
431        assert!((last.0 - 20.5).abs() < 1.0);
432        assert!((last.1 - 15.5).abs() < 1.0);
433    }
434
435    #[test]
436    fn path_routes_around_blocking_tree() {
437        let mut world = open_world();
438        world.circles.push(crate::grid::NavBlockingCircle {
439            x: 15.0,
440            y: 12.0,
441            radius_m: 0.8,
442        });
443        let path = find_path(&world, 10.0, 12.0, 0.0, 20.0, 12.0).expect("path around tree");
444        for (x, y) in &path {
445            let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
446            assert!(!near_tree, "path should not cut through tree at ({x},{y})");
447        }
448    }
449}