1use 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
12const 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 let mut cost = vec![10u16; len];
75
76 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 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(¤t) 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
262pub 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 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 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}