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/// Hard cap on A* node expansions. Unreachable goals used to flood a full 256×256
13/// map (~65k cells × world collision samples) and stall the region tick for ~1s.
14const MAX_ASTAR_EXPANSIONS: u32 = 8_000;
15
16#[derive(Clone, Copy, Eq, PartialEq)]
17struct OpenNode {
18    f: u32,
19    g: u32,
20    x: i16,
21    y: i16,
22}
23
24impl Ord for OpenNode {
25    fn cmp(&self, other: &Self) -> Ordering {
26        other.f.cmp(&self.f).then_with(|| other.g.cmp(&self.g))
27    }
28}
29
30impl PartialOrd for OpenNode {
31    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
32        Some(self.cmp(other))
33    }
34}
35
36struct NavGrid {
37    width: i16,
38    height: i16,
39    blocked: Vec<bool>,
40    cost: Vec<u16>,
41}
42
43impl NavGrid {
44    fn idx(&self, x: i16, y: i16) -> usize {
45        (y as usize) * (self.width as usize) + (x as usize)
46    }
47
48    fn in_bounds(&self, x: i16, y: i16) -> bool {
49        x >= 0 && y >= 0 && x < self.width && y < self.height
50    }
51
52    fn is_walkable(&self, x: i16, y: i16) -> bool {
53        self.in_bounds(x, y) && !self.blocked[self.idx(x, y)]
54    }
55
56    fn move_cost(&self, x: i16, y: i16) -> u32 {
57        self.cost[self.idx(x, y)] as u32
58    }
59
60    fn set_blocked(&mut self, x: i16, y: i16, blocked: bool) {
61        if self.in_bounds(x, y) {
62            let idx = self.idx(x, y);
63            self.blocked[idx] = blocked;
64        }
65    }
66}
67
68fn build_grid(world: &NavWorld, player_z: f32, goal_z: f32) -> NavGrid {
69    let width = world.world_width_m.max(1.0).ceil() as i16;
70    let height = world.world_height_m.max(1.0).ceil() as i16;
71    let len = (width as usize) * (height as usize);
72    let mut blocked = vec![false; len];
73    // Default grass cost; paint zone rects instead of O(cells × zones) probes.
74    let mut cost = vec![10u16; len];
75
76    // Paint later zones first so earlier list entries win (matches terrain_kind_at).
77    for zone in world.terrain_zones.iter().rev() {
78        let tc = terrain_cost(zone.kind);
79        let x0 = zone.x0.floor().max(0.0) as i16;
80        let y0 = zone.y0.floor().max(0.0) as i16;
81        let x1 = zone.x1.ceil().min(width as f32) as i16;
82        let y1 = zone.y1.ceil().min(height as f32) as i16;
83        for y in y0..y1 {
84            if y < 0 || y >= height {
85                continue;
86            }
87            for x in x0..x1 {
88                if x < 0 || x >= width {
89                    continue;
90                }
91                let cx = x as f32 + 0.5;
92                let cy = y as f32 + 0.5;
93                if cx < zone.x0 || cx >= zone.x1 || cy < zone.y0 || cy >= zone.y1 {
94                    continue;
95                }
96                let idx = (y as usize) * (width as usize) + (x as usize);
97                cost[idx] = tc;
98                blocked[idx] = tc == u16::MAX;
99            }
100        }
101    }
102
103    let mut grid = NavGrid {
104        width,
105        height,
106        blocked,
107        cost,
108    };
109
110    for circle in &world.circles {
111        block_circle(
112            &mut grid.blocked,
113            grid.width,
114            grid.height,
115            circle.x,
116            circle.y,
117            circle.radius_m,
118        );
119    }
120
121    for building in &world.buildings {
122        mark_building_footprint(&mut grid.blocked, grid.width, grid.height, building);
123    }
124
125    clear_door_cells(&mut grid.blocked, grid.width, grid.height, &world.doors);
126
127    // Z-band walkability is O(width*height); skip when the segment has no layers.
128    if !world.z_platforms.is_empty() || !world.z_transitions.is_empty() {
129        for y in 0..height {
130            for x in 0..width {
131                let cx = x as f32 + 0.5;
132                let cy = y as f32 + 0.5;
133                if !cell_walkable_for_path(world, cx, cy, player_z, goal_z) {
134                    grid.set_blocked(x, y, true);
135                }
136            }
137        }
138    }
139
140    grid
141}
142
143fn heuristic(ax: i16, ay: i16, bx: i16, by: i16) -> u32 {
144    let dx = (ax - bx).unsigned_abs() as u32;
145    let dy = (ay - by).unsigned_abs() as u32;
146    let diag = dx.min(dy);
147    let straight = dx.max(dy) - diag;
148    diag * 14 + straight * 10
149}
150
151fn line_clear(grid: &NavGrid, from: (i16, i16), to: (i16, i16)) -> bool {
152    let (mut x0, mut y0) = from;
153    let (x1, y1) = to;
154    let dx = (x1 - x0).abs();
155    let dy = (y1 - y0).abs();
156    let sx = if x0 < x1 { 1 } else { -1 };
157    let sy = if y0 < y1 { 1 } else { -1 };
158    let mut err = dx - dy;
159    loop {
160        if !grid.is_walkable(x0, y0) {
161            return false;
162        }
163        if x0 == x1 && y0 == y1 {
164            break;
165        }
166        let e2 = err * 2;
167        if e2 > -dy {
168            err -= dy;
169            x0 += sx;
170        }
171        if e2 < dx {
172            err += dx;
173            y0 += sy;
174        }
175    }
176    true
177}
178
179fn simplify_path(
180    grid: &NavGrid,
181    world: &NavWorld,
182    came_from: &HashMap<(i16, i16), (i16, i16)>,
183    start: (i16, i16),
184    goal: (i16, i16),
185    goal_center: (f32, f32),
186) -> Vec<(f32, f32)> {
187    let mut cells = vec![goal];
188    let mut current = goal;
189    while current != start {
190        let Some(&prev) = came_from.get(&current) else {
191            break;
192        };
193        cells.push(prev);
194        current = prev;
195    }
196    cells.reverse();
197
198    if cells.is_empty() {
199        return vec![goal_center];
200    }
201
202    let mut waypoints: Vec<(i16, i16)> = Vec::new();
203    let mut anchor = 0usize;
204    waypoints.push(cells[0]);
205    for i in 1..cells.len() {
206        if i + 1 < cells.len() {
207            let from = cell_center(cells[anchor].0, cells[anchor].1);
208            let to = if cells[i + 1] == goal {
209                goal_center
210            } else {
211                cell_center(cells[i + 1].0, cells[i + 1].1)
212            };
213            if line_clear(grid, cells[anchor], cells[i + 1])
214                && segment_clear_world(grid, world, from, to)
215            {
216                continue;
217            }
218        }
219        waypoints.push(cells[i]);
220        anchor = i;
221    }
222
223    let mut out: Vec<(f32, f32)> = waypoints.iter().map(|&(x, y)| cell_center(x, y)).collect();
224    if let Some(last) = out.last_mut() {
225        *last = goal_center;
226    }
227
228    if path_segments_clear(grid, world, &out) {
229        return out;
230    }
231
232    let mut fallback: Vec<(f32, f32)> = cells.iter().map(|&(x, y)| cell_center(x, y)).collect();
233    if let Some(last) = fallback.last_mut() {
234        *last = goal_center;
235    }
236    fallback
237}
238
239fn path_segments_clear(grid: &NavGrid, world: &NavWorld, path: &[(f32, f32)]) -> bool {
240    path.windows(2)
241        .all(|w| segment_clear_world(grid, world, w[0], w[1]))
242}
243
244fn segment_clear_world(grid: &NavGrid, world: &NavWorld, from: (f32, f32), to: (f32, f32)) -> bool {
245    let (fx, fy) = from;
246    let (tx, ty) = to;
247    let dist = (tx - fx).hypot(ty - fy);
248    let steps = (dist / SEGMENT_SAMPLE_M).ceil() as u32 + 1;
249    for step in 0..=steps {
250        let t = step as f32 / steps as f32;
251        let x = fx + (tx - fx) * t;
252        let y = fy + (ty - fy) * t;
253        if collides_player_at(x, y, world) {
254            return false;
255        }
256    }
257    let (cx0, cy0) = world_to_cell(fx, fy);
258    let (cx1, cy1) = world_to_cell(tx, ty);
259    line_clear(grid, (cx0, cy0), (cx1, cy1))
260}
261
262/// Plan a path from `(from_x, from_y, from_z)` to `(to_x, to_y)` using goal z inferred from `from_z`.
263pub fn find_path(
264    world: &NavWorld,
265    from_x: f32,
266    from_y: f32,
267    from_z: f32,
268    to_x: f32,
269    to_y: f32,
270) -> Option<Vec<(f32, f32)>> {
271    let to_z = goal_z_for(world, to_x, to_y, from_z);
272    find_path_with_goal_z(world, from_x, from_y, from_z, to_x, to_y, to_z)
273}
274
275pub fn find_path_with_goal_z(
276    world: &NavWorld,
277    from_x: f32,
278    from_y: f32,
279    from_z: f32,
280    to_x: f32,
281    to_y: f32,
282    to_z: f32,
283) -> Option<Vec<(f32, f32)>> {
284    let grid = build_grid(world, from_z, to_z);
285    let (sx, sy) = world_to_cell(from_x, from_y);
286    let (gx, gy) = world_to_cell(to_x, to_y);
287
288    if !grid.in_bounds(sx, sy) || !grid.in_bounds(gx, gy) {
289        return None;
290    }
291
292    let mut goal_x = gx;
293    let mut goal_y = gy;
294    if !grid.is_walkable(goal_x, goal_y) {
295        let mut found = None;
296        'search: for radius in 1..=16i16 {
297            for dy in -radius..=radius {
298                for dx in -radius..=radius {
299                    if dx.abs() != radius && dy.abs() != radius {
300                        continue;
301                    }
302                    let x = gx + dx;
303                    let y = gy + dy;
304                    if grid.is_walkable(x, y) {
305                        found = Some((x, y));
306                        break 'search;
307                    }
308                }
309            }
310        }
311        let (x, y) = found?;
312        goal_x = x;
313        goal_y = y;
314    }
315
316    let goal_key = (goal_x, goal_y);
317
318    let mut start_x = sx;
319    let mut start_y = sy;
320    if !grid.is_walkable(start_x, start_y) {
321        let mut found = None;
322        'start: for radius in 1..=16i16 {
323            for dy in -radius..=radius {
324                for dx in -radius..=radius {
325                    if dx.abs() != radius && dy.abs() != radius {
326                        continue;
327                    }
328                    let x = sx + dx;
329                    let y = sy + dy;
330                    if grid.is_walkable(x, y) {
331                        found = Some((x, y));
332                        break 'start;
333                    }
334                }
335            }
336        }
337        let (x, y) = found?;
338        start_x = x;
339        start_y = y;
340    }
341    let start_key = (start_x, start_y);
342
343    if start_key == goal_key {
344        return Some(vec![cell_center(goal_x, goal_y)]);
345    }
346
347    let mut open = BinaryHeap::new();
348    let mut g_score: HashMap<(i16, i16), u32> = HashMap::new();
349    let mut came_from: HashMap<(i16, i16), (i16, i16)> = HashMap::new();
350
351    g_score.insert(start_key, 0);
352    open.push(OpenNode {
353        f: heuristic(start_x, start_y, goal_x, goal_y),
354        g: 0,
355        x: start_x,
356        y: start_y,
357    });
358
359    const NEIGHBORS: [(i16, i16, u32); 8] = [
360        (1, 0, 10),
361        (-1, 0, 10),
362        (0, 1, 10),
363        (0, -1, 10),
364        (1, 1, 14),
365        (1, -1, 14),
366        (-1, 1, 14),
367        (-1, -1, 14),
368    ];
369
370    let mut expansions = 0u32;
371    while let Some(current) = open.pop() {
372        if (current.x, current.y) == goal_key {
373            return Some(simplify_path(
374                &grid,
375                world,
376                &came_from,
377                start_key,
378                goal_key,
379                cell_center(goal_x, goal_y),
380            ));
381        }
382        let Some(&best_g) = g_score.get(&(current.x, current.y)) else {
383            continue;
384        };
385        if current.g > best_g {
386            continue;
387        }
388        expansions = expansions.saturating_add(1);
389        if expansions > MAX_ASTAR_EXPANSIONS {
390            return None;
391        }
392
393        for (dx, dy, step_base) in NEIGHBORS {
394            let nx = current.x + dx;
395            let ny = current.y + dy;
396            if !grid.is_walkable(nx, ny) {
397                continue;
398            }
399            if dx != 0 && dy != 0 {
400                if !grid.is_walkable(current.x + dx, current.y)
401                    || !grid.is_walkable(current.x, current.y + dy)
402                {
403                    continue;
404                }
405            }
406            // Obstacles are already stamped into `blocked` (circles + buildings).
407            // Per-edge world sampling here used to dominate failed long-range searches.
408            let step = step_base * grid.move_cost(nx, ny) / 10;
409            let tentative = best_g + step;
410            let key = (nx, ny);
411            if tentative >= *g_score.get(&key).unwrap_or(&u32::MAX) {
412                continue;
413            }
414            came_from.insert(key, (current.x, current.y));
415            g_score.insert(key, tentative);
416            open.push(OpenNode {
417                f: tentative + heuristic(nx, ny, goal_x, goal_y),
418                g: tentative,
419                x: nx,
420                y: ny,
421            });
422        }
423    }
424
425    None
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::grid::collides_player_at;
432
433    fn open_world() -> NavWorld {
434        NavWorld {
435            world_width_m: 64.0,
436            world_height_m: 64.0,
437            terrain_zones: vec![],
438            z_platforms: vec![],
439            z_transitions: vec![],
440            buildings: vec![],
441            doors: vec![],
442            circles: vec![],
443        }
444    }
445
446    #[test]
447    fn path_on_open_field() {
448        let world = open_world();
449        let path = find_path(&world, 10.0, 10.0, 0.0, 20.0, 15.0).expect("path");
450        assert!(!path.is_empty());
451        let last = *path.last().unwrap();
452        assert!((last.0 - 20.5).abs() < 1.0);
453        assert!((last.1 - 15.5).abs() < 1.0);
454    }
455
456    #[test]
457    fn path_routes_around_building_footprint() {
458        let mut world = open_world();
459        world.buildings.push(flatland_protocol::BuildingView {
460            id: "storage".into(),
461            label: "Storage".into(),
462            x: 15.0,
463            y: 12.0,
464            width_m: 6.0,
465            depth_m: 4.0,
466            interior_blueprint: None,
467            tags: vec![],
468            market_boundary_zone_ids: vec![],
469            market_max_volume: None,
470            wall_set: None,
471            roof_set: None,
472        });
473        let path = find_path(&world, 10.0, 12.0, 0.0, 20.0, 12.0).expect("path around building");
474        for (x, y) in &path {
475            assert!(
476                !collides_player_at(*x, *y, &world),
477                "path must not cut through building at ({x},{y})"
478            );
479        }
480    }
481
482    #[test]
483    fn path_routes_around_blocking_tree() {
484        let mut world = open_world();
485        world.circles.push(crate::grid::NavBlockingCircle {
486            x: 15.0,
487            y: 12.0,
488            radius_m: 0.8,
489        });
490        let path = find_path(&world, 10.0, 12.0, 0.0, 20.0, 12.0).expect("path around tree");
491        for (x, y) in &path {
492            let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
493            assert!(!near_tree, "path should not cut through tree at ({x},{y})");
494        }
495    }
496
497    #[test]
498    fn unreachable_goal_fails_fast_on_large_map() {
499        use std::time::Instant;
500        let mut world = NavWorld {
501            world_width_m: 256.0,
502            world_height_m: 256.0,
503            terrain_zones: vec![],
504            z_platforms: vec![],
505            z_transitions: vec![],
506            buildings: vec![],
507            doors: vec![],
508            circles: vec![],
509        };
510        // Full-height deep-water barrier splits the map so A* would flood otherwise.
511        world.terrain_zones.push(flatland_protocol::TerrainZoneView {
512            id: "moat".into(),
513            x0: 120.0,
514            y0: 0.0,
515            x1: 136.0,
516            y1: 256.0,
517            kind: flatland_protocol::TerrainKindView::DeepWater,
518            elevation: 0.0,
519            glyph: None,
520            color: None,
521            tile_id: None,
522            z_order: 0,
523            channel_start_tick: None,
524            channel_end_tick: None,
525        });
526        let start = Instant::now();
527        let path = find_path(&world, 40.0, 40.0, 0.0, 200.0, 200.0);
528        let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
529        assert!(path.is_none(), "moat should make goal unreachable");
530        assert!(
531            elapsed_ms < 150.0,
532            "unreachable search must stay under 150ms (debug), took {elapsed_ms:.1}ms"
533        );
534    }
535}