Pathfinding and re-pickup of the project
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
//! Grid A* over the client's chunk cache. The client only *plans* here — every step
|
||||
//! still goes to the server as a cardinal action, and the server stays authoritative.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
|
||||
/// Hard cap on expanded nodes, so a click on an unreachable tile can't stall the frame.
|
||||
const MAX_EXPANSIONS: usize = 4096;
|
||||
|
||||
/// Longest route we will plan. Keeps the reply small and bounds replan cost.
|
||||
const MAX_ROUTE_LEN: usize = 64;
|
||||
|
||||
/// 4-connected A* from `start` to `goal` (manhattan heuristic — admissible on a
|
||||
/// cardinal grid). Returns the tiles to walk, excluding `start`, ending on `goal`,
|
||||
/// or `None` if the goal is unreachable within the search budget.
|
||||
pub fn find_path(
|
||||
start: (i32, i32),
|
||||
goal: (i32, i32),
|
||||
blocked: impl Fn(i32, i32) -> bool,
|
||||
) -> Option<Vec<(i32, i32)>> {
|
||||
if start == goal || blocked(goal.0, goal.1) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let h = |p: (i32, i32)| (p.0 - goal.0).abs() + (p.1 - goal.1).abs();
|
||||
|
||||
// (f, tile) min-heap; g and parent per visited tile.
|
||||
let mut open = BinaryHeap::new();
|
||||
let mut best: HashMap<(i32, i32), (i32, (i32, i32))> = HashMap::new();
|
||||
open.push(Reverse((h(start), start)));
|
||||
best.insert(start, (0, start));
|
||||
|
||||
let mut expanded = 0;
|
||||
while let Some(Reverse((_, cur))) = open.pop() {
|
||||
if cur == goal {
|
||||
let mut route = Vec::new();
|
||||
let mut p = goal;
|
||||
while p != start {
|
||||
route.push(p);
|
||||
p = best[&p].1;
|
||||
}
|
||||
if route.len() > MAX_ROUTE_LEN {
|
||||
return None;
|
||||
}
|
||||
route.reverse();
|
||||
return Some(route);
|
||||
}
|
||||
|
||||
expanded += 1;
|
||||
if expanded > MAX_EXPANSIONS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let g = best[&cur].0;
|
||||
for (dx, dy) in [(0, -1), (0, 1), (-1, 0), (1, 0)] {
|
||||
let next = (cur.0 + dx, cur.1 + dy);
|
||||
if blocked(next.0, next.1) {
|
||||
continue;
|
||||
}
|
||||
let ng = g + 1;
|
||||
if best.get(&next).is_none_or(|&(og, _)| ng < og) {
|
||||
best.insert(next, (ng, cur));
|
||||
open.push(Reverse((ng + h(next), next)));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::find_path;
|
||||
|
||||
#[test]
|
||||
fn straight_line() {
|
||||
let path = find_path((0, 0), (3, 0), |_, _| false).unwrap();
|
||||
assert_eq!(path, vec![(1, 0), (2, 0), (3, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detours_around_wall() {
|
||||
// Vertical wall at x=2 with a gap at y=5.
|
||||
let blocked = |x: i32, y: i32| x == 2 && y != 5;
|
||||
let path = find_path((0, 0), (4, 0), blocked).unwrap();
|
||||
assert_eq!(path.last(), Some(&(4, 0)));
|
||||
assert!(path.iter().all(|&(x, y)| !blocked(x, y)));
|
||||
assert!(path.windows(2).all(|w| {
|
||||
(w[1].0 - w[0].0).abs() + (w[1].1 - w[0].1).abs() == 1
|
||||
}));
|
||||
assert!(path.contains(&(2, 5)));
|
||||
assert_eq!(path.len(), 14); // 4 across + 2·5 detour
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_is_none() {
|
||||
// Goal sealed in by a ring.
|
||||
let blocked = |x: i32, y: i32| (x - 10).abs().max((y - 10).abs()) == 1;
|
||||
assert_eq!(find_path((0, 0), (10, 10), blocked), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_cases() {
|
||||
assert_eq!(find_path((5, 5), (5, 5), |_, _| false), None);
|
||||
assert_eq!(find_path((0, 0), (1, 0), |x, y| (x, y) == (1, 0)), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user