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_with_ground, goal_z_for};
11
12const MAX_ASTAR_EXPANSIONS: u32 = 8_000;
15const 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 let mut cost = vec![10u16; len];
77 let mut elev = vec![0.0f32; len];
80
81 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 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(¤t) 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
289pub 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 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 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 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 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}