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_with_ground, 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/// Default walk cost per cell (grass). Zones with this cost cannot change the grid.
16const DEFAULT_COST: u16 = 10;
17
18#[derive(Clone, Copy, Eq, PartialEq)]
19struct OpenNode {
20    f: u32,
21    g: u32,
22    x: i16,
23    y: i16,
24}
25
26impl Ord for OpenNode {
27    fn cmp(&self, other: &Self) -> Ordering {
28        other.f.cmp(&self.f).then_with(|| other.g.cmp(&self.g))
29    }
30}
31
32impl PartialOrd for OpenNode {
33    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
34        Some(self.cmp(other))
35    }
36}
37
38struct NavGrid {
39    width: i16,
40    height: i16,
41    blocked: Vec<bool>,
42    cost: Vec<u16>,
43}
44
45impl NavGrid {
46    fn idx(&self, x: i16, y: i16) -> usize {
47        (y as usize) * (self.width as usize) + (x as usize)
48    }
49
50    fn in_bounds(&self, x: i16, y: i16) -> bool {
51        x >= 0 && y >= 0 && x < self.width && y < self.height
52    }
53
54    fn is_walkable(&self, x: i16, y: i16) -> bool {
55        self.in_bounds(x, y) && !self.blocked[self.idx(x, y)]
56    }
57
58    fn move_cost(&self, x: i16, y: i16) -> u32 {
59        self.cost[self.idx(x, y)] as u32
60    }
61
62    fn set_blocked(&mut self, x: i16, y: i16, blocked: bool) {
63        if self.in_bounds(x, y) {
64            let idx = self.idx(x, y);
65            self.blocked[idx] = blocked;
66        }
67    }
68}
69
70fn build_grid(world: &NavWorld, player_z: f32, goal_z: f32) -> NavGrid {
71    let width = world.world_width_m.max(1.0).ceil() as i16;
72    let height = world.world_height_m.max(1.0).ceil() as i16;
73    let len = (width as usize) * (height as usize);
74    let mut blocked = vec![false; len];
75    // Default grass cost; paint zone rects instead of O(cells × zones) probes.
76    let mut cost = vec![10u16; len];
77    // Ground elevation painted once so the z-band pass is O(cells × platforms),
78    // not O(cells × terrain_zones) via per-cell elevation_at scans.
79    let mut elev = vec![0.0f32; len];
80
81    // Paint later zones first so earlier list entries win (matches terrain_kind_at).
82    // Skip zones that change neither cost nor elevation — pure no-ops that cost
83    // O(zone cells). Interior floor/overlay zones are normally default-cost.
84    for zone in world.terrain_zones.iter().rev() {
85        let tc = terrain_cost(zone.kind);
86        let paint_cost = tc != DEFAULT_COST;
87        let paint_elev = zone.elevation != 0.0;
88        if !paint_cost && !paint_elev {
89            continue;
90        }
91        let x0 = zone.x0.floor().max(0.0) as i16;
92        let y0 = zone.y0.floor().max(0.0) as i16;
93        let x1 = zone.x1.ceil().min(width as f32) as i16;
94        let y1 = zone.y1.ceil().min(height as f32) as i16;
95        for y in y0..y1 {
96            if y < 0 || y >= height {
97                continue;
98            }
99            for x in x0..x1 {
100                if x < 0 || x >= width {
101                    continue;
102                }
103                let cx = x as f32 + 0.5;
104                let cy = y as f32 + 0.5;
105                if cx < zone.x0 || cx >= zone.x1 || cy < zone.y0 || cy >= zone.y1 {
106                    continue;
107                }
108                let idx = (y as usize) * (width as usize) + (x as usize);
109                if paint_cost {
110                    cost[idx] = tc;
111                    blocked[idx] = tc == u16::MAX;
112                }
113                if paint_elev {
114                    elev[idx] = zone.elevation;
115                }
116            }
117        }
118    }
119
120    let mut grid = NavGrid {
121        width,
122        height,
123        blocked,
124        cost,
125    };
126
127    for circle in &world.circles {
128        block_circle(
129            &mut grid.blocked,
130            grid.width,
131            grid.height,
132            circle.x,
133            circle.y,
134            circle.radius_m,
135        );
136    }
137
138    for building in &world.buildings {
139        mark_building_footprint(&mut grid.blocked, grid.width, grid.height, building);
140    }
141
142    clear_door_cells(&mut grid.blocked, grid.width, grid.height, &world.doors);
143
144    // Z-band walkability is O(width*height × platforms); skip when no layers.
145    // Must use painted `elev` — calling elevation_at per cell re-scans all zones
146    // and stalls ~1s on large outdoor maps once any interior z_platform is set.
147    if !world.z_platforms.is_empty() || !world.z_transitions.is_empty() {
148        for y in 0..height {
149            for x in 0..width {
150                let cx = x as f32 + 0.5;
151                let cy = y as f32 + 0.5;
152                let idx = (y as usize) * (width as usize) + (x as usize);
153                if !cell_walkable_for_path_with_ground(
154                    world,
155                    cx,
156                    cy,
157                    player_z,
158                    goal_z,
159                    elev[idx],
160                ) {
161                    grid.set_blocked(x, y, true);
162                }
163            }
164        }
165    }
166
167    grid
168}
169
170fn heuristic(ax: i16, ay: i16, bx: i16, by: i16) -> u32 {
171    let dx = (ax - bx).unsigned_abs() as u32;
172    let dy = (ay - by).unsigned_abs() as u32;
173    let diag = dx.min(dy);
174    let straight = dx.max(dy) - diag;
175    diag * 14 + straight * 10
176}
177
178fn line_clear(grid: &NavGrid, from: (i16, i16), to: (i16, i16)) -> bool {
179    let (mut x0, mut y0) = from;
180    let (x1, y1) = to;
181    let dx = (x1 - x0).abs();
182    let dy = (y1 - y0).abs();
183    let sx = if x0 < x1 { 1 } else { -1 };
184    let sy = if y0 < y1 { 1 } else { -1 };
185    let mut err = dx - dy;
186    loop {
187        if !grid.is_walkable(x0, y0) {
188            return false;
189        }
190        if x0 == x1 && y0 == y1 {
191            break;
192        }
193        let e2 = err * 2;
194        if e2 > -dy {
195            err -= dy;
196            x0 += sx;
197        }
198        if e2 < dx {
199            err += dx;
200            y0 += sy;
201        }
202    }
203    true
204}
205
206fn simplify_path(
207    grid: &NavGrid,
208    world: &NavWorld,
209    came_from: &HashMap<(i16, i16), (i16, i16)>,
210    start: (i16, i16),
211    goal: (i16, i16),
212    goal_center: (f32, f32),
213) -> Vec<(f32, f32)> {
214    let mut cells = vec![goal];
215    let mut current = goal;
216    while current != start {
217        let Some(&prev) = came_from.get(&current) else {
218            break;
219        };
220        cells.push(prev);
221        current = prev;
222    }
223    cells.reverse();
224
225    if cells.is_empty() {
226        return vec![goal_center];
227    }
228
229    let mut waypoints: Vec<(i16, i16)> = Vec::new();
230    let mut anchor = 0usize;
231    waypoints.push(cells[0]);
232    for i in 1..cells.len() {
233        if i + 1 < cells.len() {
234            let from = cell_center(cells[anchor].0, cells[anchor].1);
235            let to = if cells[i + 1] == goal {
236                goal_center
237            } else {
238                cell_center(cells[i + 1].0, cells[i + 1].1)
239            };
240            if line_clear(grid, cells[anchor], cells[i + 1])
241                && segment_clear_world(grid, world, from, to)
242            {
243                continue;
244            }
245        }
246        waypoints.push(cells[i]);
247        anchor = i;
248    }
249
250    let mut out: Vec<(f32, f32)> = waypoints.iter().map(|&(x, y)| cell_center(x, y)).collect();
251    if let Some(last) = out.last_mut() {
252        *last = goal_center;
253    }
254
255    if path_segments_clear(grid, world, &out) {
256        return out;
257    }
258
259    let mut fallback: Vec<(f32, f32)> = cells.iter().map(|&(x, y)| cell_center(x, y)).collect();
260    if let Some(last) = fallback.last_mut() {
261        *last = goal_center;
262    }
263    fallback
264}
265
266fn path_segments_clear(grid: &NavGrid, world: &NavWorld, path: &[(f32, f32)]) -> bool {
267    path.windows(2)
268        .all(|w| segment_clear_world(grid, world, w[0], w[1]))
269}
270
271fn segment_clear_world(grid: &NavGrid, world: &NavWorld, from: (f32, f32), to: (f32, f32)) -> bool {
272    let (fx, fy) = from;
273    let (tx, ty) = to;
274    let dist = (tx - fx).hypot(ty - fy);
275    let steps = (dist / SEGMENT_SAMPLE_M).ceil() as u32 + 1;
276    for step in 0..=steps {
277        let t = step as f32 / steps as f32;
278        let x = fx + (tx - fx) * t;
279        let y = fy + (ty - fy) * t;
280        if collides_player_at(x, y, world) {
281            return false;
282        }
283    }
284    let (cx0, cy0) = world_to_cell(fx, fy);
285    let (cx1, cy1) = world_to_cell(tx, ty);
286    line_clear(grid, (cx0, cy0), (cx1, cy1))
287}
288
289/// Plan a path from `(from_x, from_y, from_z)` to `(to_x, to_y)` using goal z inferred from `from_z`.
290pub fn find_path(
291    world: &NavWorld,
292    from_x: f32,
293    from_y: f32,
294    from_z: f32,
295    to_x: f32,
296    to_y: f32,
297) -> Option<Vec<(f32, f32)>> {
298    let to_z = goal_z_for(world, to_x, to_y, from_z);
299    find_path_with_goal_z(world, from_x, from_y, from_z, to_x, to_y, to_z)
300}
301
302pub fn find_path_with_goal_z(
303    world: &NavWorld,
304    from_x: f32,
305    from_y: f32,
306    from_z: f32,
307    to_x: f32,
308    to_y: f32,
309    to_z: f32,
310) -> Option<Vec<(f32, f32)>> {
311    let t_grid = std::time::Instant::now();
312    let grid = build_grid(world, from_z, to_z);
313    let grid_ms = t_grid.elapsed().as_secs_f32() * 1000.0;
314    if grid_ms > 30.0 {
315        let mut non_default = 0usize;
316        let mut max_area: u64 = 0;
317        let mut max_span_kind = String::new();
318        for z in &world.terrain_zones {
319            if terrain_cost(z.kind) == DEFAULT_COST {
320                continue;
321            }
322            non_default += 1;
323            let w = (z.x1 - z.x0).max(0.0) as u64;
324            let h = (z.y1 - z.y0).max(0.0) as u64;
325            let a = w.saturating_mul(h);
326            if a > max_area {
327                max_area = a;
328                max_span_kind = format!("{:?}", z.kind);
329            }
330        }
331        eprintln!(
332            "[diag] build_grid {grid_ms:.1}ms zones={} circles={} buildings={} non_default_zones={} max_zone_area={} max_spans={} (world {:.0}x{:.0})",
333            world.terrain_zones.len(), world.circles.len(), world.buildings.len(), non_default, max_area, max_span_kind, world.world_width_m, world.world_height_m,
334        );
335    }
336    let (sx, sy) = world_to_cell(from_x, from_y);
337    let (gx, gy) = world_to_cell(to_x, to_y);
338
339    if !grid.in_bounds(sx, sy) || !grid.in_bounds(gx, gy) {
340        return None;
341    }
342
343    let mut goal_x = gx;
344    let mut goal_y = gy;
345    if !grid.is_walkable(goal_x, goal_y) {
346        let mut found = None;
347        'search: for radius in 1..=16i16 {
348            for dy in -radius..=radius {
349                for dx in -radius..=radius {
350                    if dx.abs() != radius && dy.abs() != radius {
351                        continue;
352                    }
353                    let x = gx + dx;
354                    let y = gy + dy;
355                    if grid.is_walkable(x, y) {
356                        found = Some((x, y));
357                        break 'search;
358                    }
359                }
360            }
361        }
362        let (x, y) = found?;
363        goal_x = x;
364        goal_y = y;
365    }
366
367    let goal_key = (goal_x, goal_y);
368
369    let mut start_x = sx;
370    let mut start_y = sy;
371    if !grid.is_walkable(start_x, start_y) {
372        let mut found = None;
373        'start: for radius in 1..=16i16 {
374            for dy in -radius..=radius {
375                for dx in -radius..=radius {
376                    if dx.abs() != radius && dy.abs() != radius {
377                        continue;
378                    }
379                    let x = sx + dx;
380                    let y = sy + dy;
381                    if grid.is_walkable(x, y) {
382                        found = Some((x, y));
383                        break 'start;
384                    }
385                }
386            }
387        }
388        let (x, y) = found?;
389        start_x = x;
390        start_y = y;
391    }
392    let start_key = (start_x, start_y);
393
394    if start_key == goal_key {
395        return Some(vec![cell_center(goal_x, goal_y)]);
396    }
397
398    let mut open = BinaryHeap::new();
399    let mut g_score: HashMap<(i16, i16), u32> = HashMap::new();
400    let mut came_from: HashMap<(i16, i16), (i16, i16)> = HashMap::new();
401
402    g_score.insert(start_key, 0);
403    open.push(OpenNode {
404        f: heuristic(start_x, start_y, goal_x, goal_y),
405        g: 0,
406        x: start_x,
407        y: start_y,
408    });
409
410    const NEIGHBORS: [(i16, i16, u32); 8] = [
411        (1, 0, 10),
412        (-1, 0, 10),
413        (0, 1, 10),
414        (0, -1, 10),
415        (1, 1, 14),
416        (1, -1, 14),
417        (-1, 1, 14),
418        (-1, -1, 14),
419    ];
420
421    let mut expansions = 0u32;
422    while let Some(current) = open.pop() {
423        if (current.x, current.y) == goal_key {
424            return Some(simplify_path(
425                &grid,
426                world,
427                &came_from,
428                start_key,
429                goal_key,
430                cell_center(goal_x, goal_y),
431            ));
432        }
433        let Some(&best_g) = g_score.get(&(current.x, current.y)) else {
434            continue;
435        };
436        if current.g > best_g {
437            continue;
438        }
439        expansions = expansions.saturating_add(1);
440        if expansions > MAX_ASTAR_EXPANSIONS {
441            return None;
442        }
443
444        for (dx, dy, step_base) in NEIGHBORS {
445            let nx = current.x + dx;
446            let ny = current.y + dy;
447            if !grid.is_walkable(nx, ny) {
448                continue;
449            }
450            if dx != 0 && dy != 0 {
451                if !grid.is_walkable(current.x + dx, current.y)
452                    || !grid.is_walkable(current.x, current.y + dy)
453                {
454                    continue;
455                }
456            }
457            // Obstacles are already stamped into `blocked` (circles + buildings).
458            // Per-edge world sampling here used to dominate failed long-range searches.
459            let step = step_base * grid.move_cost(nx, ny) / 10;
460            let tentative = best_g + step;
461            let key = (nx, ny);
462            if tentative >= *g_score.get(&key).unwrap_or(&u32::MAX) {
463                continue;
464            }
465            came_from.insert(key, (current.x, current.y));
466            g_score.insert(key, tentative);
467            open.push(OpenNode {
468                f: tentative + heuristic(nx, ny, goal_x, goal_y),
469                g: tentative,
470                x: nx,
471                y: ny,
472            });
473        }
474    }
475
476    None
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::grid::collides_player_at;
483
484    fn open_world() -> NavWorld {
485        NavWorld {
486            world_width_m: 64.0,
487            world_height_m: 64.0,
488            terrain_zones: vec![],
489            z_platforms: vec![],
490            z_transitions: vec![],
491            buildings: vec![],
492            doors: vec![],
493            circles: vec![],
494        }
495    }
496
497    #[test]
498    fn path_on_open_field() {
499        let world = open_world();
500        let path = find_path(&world, 10.0, 10.0, 0.0, 20.0, 15.0).expect("path");
501        assert!(!path.is_empty());
502        let last = *path.last().unwrap();
503        assert!((last.0 - 20.5).abs() < 1.0);
504        assert!((last.1 - 15.5).abs() < 1.0);
505    }
506
507    #[test]
508    fn path_routes_around_building_footprint() {
509        let mut world = open_world();
510        world.buildings.push(flatland_protocol::BuildingView {
511            id: "storage".into(),
512            label: "Storage".into(),
513            x: 15.0,
514            y: 12.0,
515            width_m: 6.0,
516            depth_m: 4.0,
517            interior_blueprint: None,
518            tags: vec![],
519            market_boundary_zone_ids: vec![],
520            market_max_volume: None,
521            wall_set: None,
522            roof_set: None,
523        });
524        let path = find_path(&world, 10.0, 12.0, 0.0, 20.0, 12.0).expect("path around building");
525        for (x, y) in &path {
526            assert!(
527                !collides_player_at(*x, *y, &world),
528                "path must not cut through building at ({x},{y})"
529            );
530        }
531    }
532
533    #[test]
534    fn path_routes_around_blocking_tree() {
535        let mut world = open_world();
536        world.circles.push(crate::grid::NavBlockingCircle {
537            x: 15.0,
538            y: 12.0,
539            radius_m: 0.8,
540        });
541        let path = find_path(&world, 10.0, 12.0, 0.0, 20.0, 12.0).expect("path around tree");
542        for (x, y) in &path {
543            let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
544            assert!(!near_tree, "path should not cut through tree at ({x},{y})");
545        }
546    }
547
548    #[test]
549    fn unreachable_goal_fails_fast_on_large_map() {
550        use std::time::Instant;
551        let mut world = NavWorld {
552            world_width_m: 256.0,
553            world_height_m: 256.0,
554            terrain_zones: vec![],
555            z_platforms: vec![],
556            z_transitions: vec![],
557            buildings: vec![],
558            doors: vec![],
559            circles: vec![],
560        };
561        // Full-height deep-water barrier splits the map so A* would flood otherwise.
562        world.terrain_zones.push(flatland_protocol::TerrainZoneView {
563            id: "moat".into(),
564            x0: 120.0,
565            y0: 0.0,
566            x1: 136.0,
567            y1: 256.0,
568            kind: flatland_protocol::TerrainKindView::DeepWater,
569            elevation: 0.0,
570            glyph: None,
571            color: None,
572            tile_id: None,
573            z_order: 0,
574            channel_start_tick: None,
575            channel_end_tick: None,
576        });
577        let start = Instant::now();
578        let path = find_path(&world, 40.0, 40.0, 0.0, 200.0, 200.0);
579        let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
580        assert!(path.is_none(), "moat should make goal unreachable");
581        assert!(
582            elapsed_ms < 150.0,
583            "unreachable search must stay under 150ms (debug), took {elapsed_ms:.1}ms"
584        );
585    }
586
587    #[test]
588    fn default_cost_overlay_flood_stays_fast() {
589        use std::time::Instant;
590        let mut world = NavWorld {
591            world_width_m: 256.0,
592            world_height_m: 256.0,
593            terrain_zones: vec![],
594            z_platforms: vec![],
595            z_transitions: vec![],
596            buildings: vec![],
597            doors: vec![],
598            circles: vec![],
599        };
600        // Mimic an interior entry flooding `terrain_zones` with cheap (Grass-cost)
601        // overlay rects. Painting these is a no-op for nav, so build_grid must skip
602        // them instead of costing O(zone cells) per zone (~1s stall).
603        for i in 0..600i16 {
604            world.terrain_zones.push(flatland_protocol::TerrainZoneView {
605                id: format!("rt:{i}").into(),
606                x0: 0.0,
607                y0: 0.0,
608                x1: 256.0,
609                y1: 256.0,
610                kind: flatland_protocol::TerrainKindView::Grass,
611                elevation: 0.0,
612                glyph: None,
613                color: None,
614                tile_id: None,
615                z_order: 0,
616                channel_start_tick: None,
617                channel_end_tick: None,
618            });
619        }
620        let start = Instant::now();
621        let path = find_path(&world, 40.0, 40.0, 0.0, 200.0, 200.0);
622        let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
623        assert!(path.is_some(), "open field must remain reachable");
624        assert!(
625            elapsed_ms < 150.0,
626            "default-cost overlay flood must stay under 150ms (debug), took {elapsed_ms:.1}ms"
627        );
628    }
629
630    #[test]
631    fn z_platform_with_many_zones_stays_fast() {
632        use std::time::Instant;
633        // Reproduce the building-enter stall: outdoor-sized map + many terrain zones +
634        // a single interior z_platform. Per-cell elevation_at would be O(cells × zones)
635        // (~1s); painted elev must keep this fast.
636        let mut world = NavWorld {
637            world_width_m: 512.0,
638            world_height_m: 256.0,
639            terrain_zones: vec![],
640            z_platforms: vec![flatland_protocol::ZPlatformView {
641                id: "floor_0".into(),
642                z: 0.0,
643                x0: 0.0,
644                y0: 0.0,
645                x1: 16.0,
646                y1: 16.0,
647            }],
648            z_transitions: vec![],
649            buildings: vec![],
650            doors: vec![],
651            circles: vec![],
652        };
653        for i in 0..400i16 {
654            world.terrain_zones.push(flatland_protocol::TerrainZoneView {
655                id: format!("zone:{i}").into(),
656                x0: (i % 32) as f32 * 8.0,
657                y0: (i / 32) as f32 * 8.0,
658                x1: (i % 32) as f32 * 8.0 + 8.0,
659                y1: (i / 32) as f32 * 8.0 + 8.0,
660                kind: flatland_protocol::TerrainKindView::Dirt,
661                elevation: 0.0,
662                glyph: None,
663                color: None,
664                tile_id: None,
665                z_order: 0,
666                channel_start_tick: None,
667                channel_end_tick: None,
668            });
669        }
670        let start = Instant::now();
671        let path = find_path(&world, 4.0, 4.0, 0.0, 12.0, 12.0);
672        let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
673        assert!(path.is_some(), "interior cells must remain reachable");
674        assert!(
675            elapsed_ms < 200.0,
676            "z_platform + many zones must stay under 200ms (debug), took {elapsed_ms:.1}ms"
677        );
678    }
679}