1use std::cmp::Ordering;
4use std::collections::{BinaryHeap, HashMap};
5
6use flatland_protocol::{
7 BuildingView, DoorView, LifeState, ResourceNodeState, TerrainKindView, TerrainZoneView,
8 ZTransitionView,
9};
10
11use crate::game::GameState;
12
13#[derive(Debug, Clone, Copy)]
14struct BlockingCircle {
15 x: f32,
16 y: f32,
17 radius_m: f32,
18}
19
20#[derive(Debug, Clone)]
21struct NavObstacles {
22 circles: Vec<BlockingCircle>,
23 buildings: Vec<BuildingView>,
24}
25
26impl NavObstacles {
27 fn from_state(state: &GameState) -> Self {
28 let mut circles = Vec::new();
29 for node in &state.resource_nodes {
30 if node.blocking
31 && matches!(
32 node.state,
33 ResourceNodeState::Available | ResourceNodeState::Harvesting
34 )
35 {
36 let radius = if node.blocking_radius_m > 0.0 {
37 node.blocking_radius_m
38 } else {
39 0.8
40 };
41 circles.push(BlockingCircle {
42 x: node.x,
43 y: node.y,
44 radius_m: radius,
45 });
46 }
47 }
48 for npc in &state.npcs {
49 let alive = npc.life_state != Some(LifeState::Dead)
50 && npc.hp_pct.is_none_or(|h| h > 0.0);
51 if alive {
52 circles.push(BlockingCircle {
53 x: npc.x,
54 y: npc.y,
55 radius_m: 0.55,
56 });
57 }
58 }
59 Self {
60 circles,
61 buildings: state.buildings.clone(),
62 }
63 }
64}
65
66fn collides_player_at(x: f32, y: f32, obstacles: &NavObstacles) -> bool {
67 if obstacles.circles.iter().any(|o| circle_overlap(x, y, PLAYER_RADIUS_M, o.x, o.y, o.radius_m)) {
68 return true;
69 }
70 let pad = PLAYER_RADIUS_M;
71 obstacles.buildings.iter().any(|b| {
72 let hw = b.width_m / 2.0 + pad;
73 let hd = b.depth_m / 2.0 + pad;
74 x >= b.x - hw && x <= b.x + hw && y >= b.y - hd && y <= b.y + hd
75 })
76}
77
78fn circle_overlap(ax: f32, ay: f32, ar: f32, bx: f32, by: f32, br: f32) -> bool {
79 let dx = ax - bx;
80 let dy = ay - by;
81 let min_dist = ar + br;
82 dx * dx + dy * dy < min_dist * min_dist
83}
84
85fn segment_clear_world(
86 grid: &NavGrid,
87 obstacles: &NavObstacles,
88 from: (f32, f32),
89 to: (f32, f32),
90) -> bool {
91 let (fx, fy) = from;
92 let (tx, ty) = to;
93 let dist = (tx - fx).hypot(ty - fy);
94 let steps = (dist / SEGMENT_SAMPLE_M).ceil() as u32 + 1;
95 for step in 0..=steps {
96 let t = step as f32 / steps as f32;
97 let x = fx + (tx - fx) * t;
98 let y = fy + (ty - fy) * t;
99 if collides_player_at(x, y, obstacles) {
100 return false;
101 }
102 }
103 let (cx0, cy0) = world_to_cell(fx, fy);
104 let (cx1, cy1) = world_to_cell(tx, ty);
105 line_clear(grid, (cx0, cy0), (cx1, cy1))
106}
107
108const PLAYER_RADIUS_M: f32 = 0.45;
109const PATH_CLEARANCE_M: f32 = 0.35;
111const WAYPOINT_RADIUS_M: f32 = 0.85;
112const ARRIVE_RADIUS_M: f32 = 0.75;
113const SPRINT_LEG_M: f32 = 4.0;
114const SEGMENT_SAMPLE_M: f32 = 0.3;
116const STUCK_TICKS_BEFORE_REPLAN: u32 = 6;
118const MAX_REPLAN_ATTEMPTS: u32 = 5;
120const Z_LEVEL_TOLERANCE: f32 = 0.35;
121
122fn nearest_level(levels: &[f32], current: f32) -> f32 {
123 levels
124 .iter()
125 .min_by(|a, b| {
126 (*a - current)
127 .abs()
128 .partial_cmp(&(*b - current).abs())
129 .unwrap_or(std::cmp::Ordering::Equal)
130 })
131 .copied()
132 .unwrap_or(current)
133}
134
135fn walkable_levels(state: &GameState, x: f32, y: f32) -> Vec<f32> {
136 state.walkable_levels_at(x, y)
137}
138
139fn goal_z_for(state: &GameState, goal_x: f32, goal_y: f32, player_z: f32) -> f32 {
140 nearest_level(&walkable_levels(state, goal_x, goal_y), player_z)
141}
142
143fn transition_at<'a>(state: &'a GameState, x: f32, y: f32) -> Option<&'a ZTransitionView> {
144 state
145 .z_transitions
146 .iter()
147 .find(|t| x >= t.x0 && x <= t.x1 && y >= t.y0 && y <= t.y1)
148}
149
150fn transition_vertical(state: &GameState, px: f32, py: f32, pz: f32, goal_z: f32) -> f32 {
151 let Some(tr) = transition_at(state, px, py) else {
152 return 0.0;
153 };
154 if (pz - goal_z).abs() <= Z_LEVEL_TOLERANCE {
155 return 0.0;
156 }
157 if goal_z > pz + Z_LEVEL_TOLERANCE
158 && (pz - tr.z_from).abs() <= Z_LEVEL_TOLERANCE
159 && tr.z_to > tr.z_from
160 {
161 return 1.0;
162 }
163 if goal_z < pz - Z_LEVEL_TOLERANCE
164 && (pz - tr.z_to).abs() <= Z_LEVEL_TOLERANCE
165 && tr.z_to > tr.z_from
166 {
167 return -1.0;
168 }
169 if goal_z > pz + Z_LEVEL_TOLERANCE && pz <= tr.z_min() + Z_LEVEL_TOLERANCE {
170 return 1.0;
171 }
172 if goal_z < pz - Z_LEVEL_TOLERANCE && pz >= tr.z_max() - Z_LEVEL_TOLERANCE {
173 return -1.0;
174 }
175 0.0
176}
177
178trait ZTransitionExt {
179 fn z_min(&self) -> f32;
180 fn z_max(&self) -> f32;
181}
182
183impl ZTransitionExt for ZTransitionView {
184 fn z_min(&self) -> f32 {
185 self.z_from.min(self.z_to)
186 }
187
188 fn z_max(&self) -> f32 {
189 self.z_from.max(self.z_to)
190 }
191}
192
193#[derive(Debug, Clone)]
194pub struct AutoNavigator {
195 waypoints: Vec<(f32, f32)>,
196 waypoint_index: usize,
197 pub goal_x: f32,
198 pub goal_y: f32,
199 pub goal_z: f32,
200 last_progress_x: f32,
201 last_progress_y: f32,
202 stuck_ticks: u32,
203 replan_attempts: u32,
204}
205
206impl AutoNavigator {
207 pub fn plan(state: &GameState, goal_x: f32, goal_y: f32) -> Option<Self> {
208 let (px, py, pz) = state.player_position_with_z();
209 let goal_z = goal_z_for(state, goal_x, goal_y, pz);
210 let path = find_path(state, px, py, pz, goal_x, goal_y, goal_z)?;
211 if path.is_empty() {
212 return None;
213 }
214 Some(Self {
215 waypoints: path,
216 waypoint_index: 0,
217 goal_x,
218 goal_y,
219 goal_z,
220 last_progress_x: px,
221 last_progress_y: py,
222 stuck_ticks: 0,
223 replan_attempts: 0,
224 })
225 }
226
227 pub fn active(&self) -> bool {
228 self.waypoint_index < self.waypoints.len()
229 }
230
231 pub fn replan(&mut self, state: &GameState) -> bool {
234 let (px, py, pz) = state.player_position_with_z();
235 if let Some(path) = find_path(state, px, py, pz, self.goal_x, self.goal_y, self.goal_z) {
236 if !path.is_empty() {
237 let changed = path != self.waypoints;
238 self.waypoints = path;
239 self.waypoint_index = 0;
240 self.stuck_ticks = 0;
241 self.last_progress_x = px;
242 self.last_progress_y = py;
243 if changed {
244 self.replan_attempts = 0;
245 } else {
246 self.replan_attempts = self.replan_attempts.saturating_add(1);
247 }
248 return self.replan_attempts < MAX_REPLAN_ATTEMPTS;
249 }
250 }
251 self.replan_attempts = self.replan_attempts.saturating_add(1);
252 self.replan_attempts < MAX_REPLAN_ATTEMPTS
253 }
254
255 pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
257 if (px - self.last_progress_x).hypot(py - self.last_progress_y) > 0.25 {
258 self.last_progress_x = px;
259 self.last_progress_y = py;
260 self.stuck_ticks = 0;
261 self.replan_attempts = 0;
262 return false;
263 }
264 self.stuck_ticks = self.stuck_ticks.saturating_add(1);
265 self.stuck_ticks >= STUCK_TICKS_BEFORE_REPLAN
266 }
267
268 pub fn steer(
270 &mut self,
271 px: f32,
272 py: f32,
273 pz: f32,
274 state: &GameState,
275 ) -> Option<(f32, f32, f32, bool)> {
276 let vertical_climb = transition_vertical(state, px, py, pz, self.goal_z);
277 if vertical_climb.abs() > f32::EPSILON {
278 return Some((0.0, 0.0, vertical_climb, false));
279 }
280 while self.waypoint_index < self.waypoints.len() {
281 let (wx, wy) = self.waypoints[self.waypoint_index];
282 let dx = wx - px;
283 let dy = wy - py;
284 let dist = (dx * dx + dy * dy).sqrt();
285 if dist < WAYPOINT_RADIUS_M {
286 self.waypoint_index += 1;
287 continue;
288 }
289 let forward = (dy / dist).clamp(-1.0, 1.0);
290 let strafe = (dx / dist).clamp(-1.0, 1.0);
291 let sprint = dist > SPRINT_LEG_M;
292 return Some((forward, strafe, 0.0, sprint));
293 }
294 let goal_dist = (self.goal_x - px).hypot(self.goal_y - py);
295 if goal_dist > ARRIVE_RADIUS_M || (pz - self.goal_z).abs() > Z_LEVEL_TOLERANCE {
296 if goal_dist > ARRIVE_RADIUS_M {
297 let forward = ((self.goal_y - py) / goal_dist).clamp(-1.0, 1.0);
298 let strafe = ((self.goal_x - px) / goal_dist).clamp(-1.0, 1.0);
299 return Some((forward, strafe, 0.0, goal_dist > SPRINT_LEG_M));
300 }
301 let vz = transition_vertical(state, px, py, pz, self.goal_z);
302 if vz.abs() > f32::EPSILON {
303 return Some((0.0, 0.0, vz, false));
304 }
305 }
306 None
307 }
308}
309
310#[derive(Clone, Copy, Eq, PartialEq)]
311struct OpenNode {
312 f: u32,
313 g: u32,
314 x: i16,
315 y: i16,
316}
317
318impl Ord for OpenNode {
319 fn cmp(&self, other: &Self) -> Ordering {
320 other.f.cmp(&self.f).then_with(|| other.g.cmp(&self.g))
321 }
322}
323
324impl PartialOrd for OpenNode {
325 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
326 Some(self.cmp(other))
327 }
328}
329
330struct NavGrid {
331 width: i16,
332 height: i16,
333 blocked: Vec<bool>,
334 cost: Vec<u16>,
335}
336
337impl NavGrid {
338 fn idx(&self, x: i16, y: i16) -> usize {
339 (y as usize) * (self.width as usize) + (x as usize)
340 }
341
342 fn in_bounds(&self, x: i16, y: i16) -> bool {
343 x >= 0 && y >= 0 && x < self.width && y < self.height
344 }
345
346 fn is_walkable(&self, x: i16, y: i16) -> bool {
347 self.in_bounds(x, y) && !self.blocked[self.idx(x, y)]
348 }
349
350 fn move_cost(&self, x: i16, y: i16) -> u32 {
351 self.cost[self.idx(x, y)] as u32
352 }
353
354 fn set_blocked(&mut self, x: i16, y: i16, blocked: bool) {
355 if self.in_bounds(x, y) {
356 let idx = self.idx(x, y);
357 self.blocked[idx] = blocked;
358 }
359 }
360}
361
362fn terrain_at(zones: &[TerrainZoneView], x: f32, y: f32) -> TerrainKindView {
363 for zone in zones {
364 if x >= zone.x0 && x < zone.x1 && y >= zone.y0 && y < zone.y1 {
365 return zone.kind;
366 }
367 }
368 TerrainKindView::Grass
369}
370
371fn terrain_cost(kind: TerrainKindView) -> u16 {
372 match kind {
373 TerrainKindView::Grass => 10,
374 TerrainKindView::Hill => 14,
375 TerrainKindView::Bog => 25,
376 TerrainKindView::ShallowWater => 40,
377 TerrainKindView::DeepWater => u16::MAX,
378 TerrainKindView::Trail => 8,
379 TerrainKindView::Rock => u16::MAX,
380 }
381}
382
383fn block_circle(grid: &mut NavGrid, cx: f32, cy: f32, radius_m: f32) {
384 let block_r = radius_m + PLAYER_RADIUS_M + PATH_CLEARANCE_M;
385 let r = block_r.ceil() as i16;
386 let ix = cx.floor() as i16;
387 let iy = cy.floor() as i16;
388 for dy in -r..=r {
389 for dx in -r..=r {
390 let x = ix + dx;
391 let y = iy + dy;
392 if !grid.in_bounds(x, y) {
393 continue;
394 }
395 let cell_cx = x as f32 + 0.5;
396 let cell_cy = y as f32 + 0.5;
397 if (cell_cx - cx).hypot(cell_cy - cy) <= block_r {
398 grid.set_blocked(x, y, true);
399 }
400 }
401 }
402}
403
404fn mark_building_footprint(grid: &mut NavGrid, building: &BuildingView) {
406 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
407 let hw = building.width_m / 2.0;
408 let hd = building.depth_m / 2.0;
409 let x0 = (building.x - hw - pad).floor() as i16;
410 let y0 = (building.y - hd - pad).floor() as i16;
411 let x1 = (building.x + hw + pad).ceil() as i16 - 1;
412 let y1 = (building.y + hd + pad).ceil() as i16 - 1;
413 if x1 < x0 || y1 < y0 {
414 return;
415 }
416 for x in x0..=x1 {
417 for y in y0..=y1 {
418 grid.set_blocked(x, y, true);
419 }
420 }
421}
422
423fn clear_door_cells(grid: &mut NavGrid, doors: &[DoorView]) {
424 for door in doors {
427 if door.open {
428 let (x, y) = world_to_cell(door.x, door.y);
429 for dx in -1i16..=1 {
430 for dy in -1i16..=1 {
431 if dx.abs() + dy.abs() <= 1 {
432 grid.set_blocked(x + dx, y + dy, false);
433 }
434 }
435 }
436 }
437 }
438}
439
440fn build_grid(state: &GameState, obstacles: &NavObstacles, player_z: f32, goal_z: f32) -> NavGrid {
441 let width = state.world_width_m.max(1.0).ceil() as i16;
442 let height = state.world_height_m.max(1.0).ceil() as i16;
443 let len = (width as usize) * (height as usize);
444 let mut blocked = vec![false; len];
445 let mut cost = vec![10u16; len];
446
447 for y in 0..height {
448 for x in 0..width {
449 let cx = x as f32 + 0.5;
450 let cy = y as f32 + 0.5;
451 let kind = terrain_at(&state.terrain_zones, cx, cy);
452 let tc = terrain_cost(kind);
453 let idx = (y as usize) * (width as usize) + (x as usize);
454 cost[idx] = tc;
455 if tc == u16::MAX {
456 blocked[idx] = true;
457 }
458 }
459 }
460
461 let mut grid = NavGrid {
462 width,
463 height,
464 blocked,
465 cost,
466 };
467
468 for circle in &obstacles.circles {
469 block_circle(&mut grid, circle.x, circle.y, circle.radius_m);
470 }
471
472 for building in &state.buildings {
473 mark_building_footprint(&mut grid, building);
474 }
475
476 clear_door_cells(&mut grid, &state.doors);
477
478 for y in 0..height {
479 for x in 0..width {
480 let cx = x as f32 + 0.5;
481 let cy = y as f32 + 0.5;
482 if !cell_walkable_for_path(state, cx, cy, player_z, goal_z) {
483 grid.set_blocked(x, y, true);
484 }
485 }
486 }
487
488 grid
489}
490
491fn cell_walkable_for_path(
492 state: &GameState,
493 x: f32,
494 y: f32,
495 player_z: f32,
496 goal_z: f32,
497) -> bool {
498 if state.is_walkable_at_z(x, y, player_z) {
499 return true;
500 }
501 if let Some(tr) = transition_at(state, x, y) {
502 let on_from = (player_z - tr.z_from).abs() <= Z_LEVEL_TOLERANCE;
503 let on_to = (player_z - tr.z_to).abs() <= Z_LEVEL_TOLERANCE;
504 let goal_on_from = (goal_z - tr.z_from).abs() <= Z_LEVEL_TOLERANCE;
505 let goal_on_to = (goal_z - tr.z_to).abs() <= Z_LEVEL_TOLERANCE;
506 if (on_from && goal_on_to) || (on_to && goal_on_from) {
507 return true;
508 }
509 }
510 false
511}
512
513fn world_to_cell(x: f32, y: f32) -> (i16, i16) {
514 (x.floor() as i16, y.floor() as i16)
515}
516
517fn cell_center(x: i16, y: i16) -> (f32, f32) {
518 (x as f32 + 0.5, y as f32 + 0.5)
519}
520
521fn heuristic(ax: i16, ay: i16, bx: i16, by: i16) -> u32 {
522 let dx = (ax - bx).unsigned_abs() as u32;
523 let dy = (ay - by).unsigned_abs() as u32;
524 let diag = dx.min(dy);
525 let straight = dx.max(dy) - diag;
526 diag * 14 + straight * 10
527}
528
529fn line_clear(grid: &NavGrid, from: (i16, i16), to: (i16, i16)) -> bool {
530 let (mut x0, mut y0) = from;
531 let (x1, y1) = to;
532 let dx = (x1 - x0).abs();
533 let dy = (y1 - y0).abs();
534 let sx = if x0 < x1 { 1 } else { -1 };
535 let sy = if y0 < y1 { 1 } else { -1 };
536 let mut err = dx - dy;
537 loop {
538 if !grid.is_walkable(x0, y0) {
539 return false;
540 }
541 if x0 == x1 && y0 == y1 {
542 break;
543 }
544 let e2 = err * 2;
545 if e2 > -dy {
546 err -= dy;
547 x0 += sx;
548 }
549 if e2 < dx {
550 err += dx;
551 y0 += sy;
552 }
553 }
554 true
555}
556
557fn find_path(
558 state: &GameState,
559 from_x: f32,
560 from_y: f32,
561 from_z: f32,
562 to_x: f32,
563 to_y: f32,
564 to_z: f32,
565) -> Option<Vec<(f32, f32)>> {
566 let obstacles = NavObstacles::from_state(state);
567 let grid = build_grid(state, &obstacles, from_z, to_z);
568 let (sx, sy) = world_to_cell(from_x, from_y);
569 let (gx, gy) = world_to_cell(to_x, to_y);
570
571 if !grid.in_bounds(sx, sy) || !grid.in_bounds(gx, gy) {
572 return None;
573 }
574
575 let mut goal_x = gx;
576 let mut goal_y = gy;
577 if !grid.is_walkable(goal_x, goal_y) {
578 let mut found = None;
579 'search: for radius in 1..=16i16 {
580 for dy in -radius..=radius {
581 for dx in -radius..=radius {
582 if dx.abs() != radius && dy.abs() != radius {
583 continue;
584 }
585 let x = gx + dx;
586 let y = gy + dy;
587 if grid.is_walkable(x, y) {
588 found = Some((x, y));
589 break 'search;
590 }
591 }
592 }
593 }
594 let (x, y) = found?;
595 goal_x = x;
596 goal_y = y;
597 }
598
599 let goal_key = (goal_x, goal_y);
600
601 let mut start_x = sx;
603 let mut start_y = sy;
604 if !grid.is_walkable(start_x, start_y) {
605 let mut found = None;
606 'start: for radius in 1..=16i16 {
607 for dy in -radius..=radius {
608 for dx in -radius..=radius {
609 if dx.abs() != radius && dy.abs() != radius {
610 continue;
611 }
612 let x = sx + dx;
613 let y = sy + dy;
614 if grid.is_walkable(x, y) {
615 found = Some((x, y));
616 break 'start;
617 }
618 }
619 }
620 }
621 let (x, y) = found?;
622 start_x = x;
623 start_y = y;
624 }
625 let start_key = (start_x, start_y);
626
627 if start_key == goal_key {
628 return Some(vec![cell_center(goal_x, goal_y)]);
629 }
630
631 let mut open = BinaryHeap::new();
632 let mut g_score: HashMap<(i16, i16), u32> = HashMap::new();
633 let mut came_from: HashMap<(i16, i16), (i16, i16)> = HashMap::new();
634
635 g_score.insert(start_key, 0);
636 open.push(OpenNode {
637 f: heuristic(start_x, start_y, goal_x, goal_y),
638 g: 0,
639 x: start_x,
640 y: start_y,
641 });
642
643 const NEIGHBORS: [(i16, i16, u32); 8] = [
644 (1, 0, 10),
645 (-1, 0, 10),
646 (0, 1, 10),
647 (0, -1, 10),
648 (1, 1, 14),
649 (1, -1, 14),
650 (-1, 1, 14),
651 (-1, -1, 14),
652 ];
653
654 while let Some(current) = open.pop() {
655 if (current.x, current.y) == goal_key {
656 return Some(simplify_path(
657 &grid,
658 &obstacles,
659 &came_from,
660 start_key,
661 goal_key,
662 cell_center(goal_x, goal_y),
663 ));
664 }
665 let Some(&best_g) = g_score.get(&(current.x, current.y)) else {
666 continue;
667 };
668 if current.g > best_g {
669 continue;
670 }
671
672 for (dx, dy, step_base) in NEIGHBORS {
673 let nx = current.x + dx;
674 let ny = current.y + dy;
675 if !grid.is_walkable(nx, ny) {
676 continue;
677 }
678 if dx != 0 && dy != 0 {
679 if !grid.is_walkable(current.x + dx, current.y)
680 || !grid.is_walkable(current.x, current.y + dy)
681 {
682 continue;
683 }
684 }
685 let from = cell_center(current.x, current.y);
686 let to = cell_center(nx, ny);
687 if !segment_clear_world(&grid, &obstacles, from, to) {
688 continue;
689 }
690 let step = step_base * grid.move_cost(nx, ny) / 10;
691 let tentative = best_g + step;
692 let key = (nx, ny);
693 if tentative >= *g_score.get(&key).unwrap_or(&u32::MAX) {
694 continue;
695 }
696 came_from.insert(key, (current.x, current.y));
697 g_score.insert(key, tentative);
698 open.push(OpenNode {
699 f: tentative + heuristic(nx, ny, goal_x, goal_y),
700 g: tentative,
701 x: nx,
702 y: ny,
703 });
704 }
705 }
706
707 None
708}
709
710fn simplify_path(
711 grid: &NavGrid,
712 obstacles: &NavObstacles,
713 came_from: &HashMap<(i16, i16), (i16, i16)>,
714 start: (i16, i16),
715 goal: (i16, i16),
716 goal_center: (f32, f32),
717) -> Vec<(f32, f32)> {
718 let mut cells = vec![goal];
719 let mut current = goal;
720 while current != start {
721 let Some(&prev) = came_from.get(¤t) else {
722 break;
723 };
724 cells.push(prev);
725 current = prev;
726 }
727 cells.reverse();
728
729 if cells.is_empty() {
730 return vec![goal_center];
731 }
732
733 let mut waypoints: Vec<(i16, i16)> = Vec::new();
734 let mut anchor = 0usize;
735 waypoints.push(cells[0]);
736 for i in 1..cells.len() {
737 if i + 1 < cells.len() {
738 let from = cell_center(cells[anchor].0, cells[anchor].1);
739 let to = if cells[i + 1] == goal {
740 goal_center
741 } else {
742 cell_center(cells[i + 1].0, cells[i + 1].1)
743 };
744 if line_clear(grid, cells[anchor], cells[i + 1])
745 && segment_clear_world(grid, obstacles, from, to)
746 {
747 continue;
748 }
749 }
750 waypoints.push(cells[i]);
751 anchor = i;
752 }
753
754 let mut out: Vec<(f32, f32)> = waypoints
755 .iter()
756 .map(|&(x, y)| cell_center(x, y))
757 .collect();
758 if let Some(last) = out.last_mut() {
759 *last = goal_center;
760 }
761
762 if path_segments_clear(&grid, &obstacles, &out) {
763 return out;
764 }
765
766 let mut fallback: Vec<(f32, f32)> = cells
768 .iter()
769 .map(|&(x, y)| cell_center(x, y))
770 .collect();
771 if let Some(last) = fallback.last_mut() {
772 *last = goal_center;
773 }
774 fallback
775}
776
777fn path_segments_clear(grid: &NavGrid, obstacles: &NavObstacles, path: &[(f32, f32)]) -> bool {
778 path.windows(2).all(|w| segment_clear_world(grid, obstacles, w[0], w[1]))
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use flatland_protocol::{EntityState, Transform, Velocity2D, WorldCoord};
785
786 fn empty_state() -> GameState {
787 GameState {
788 session_id: Default::default(),
789 entity_id: 1,
790 character_id: None,
791 tick: 0,
792 chunk_rev: 0,
793 content_rev: 0,
794 entities: vec![],
795 player: Some(EntityState {
796 id: 1,
797 transform: Transform {
798 position: WorldCoord::surface(10.0, 10.0),
799 yaw: 0.0,
800 velocity: Velocity2D { vx: 0.0, vy: 0.0 },
801 },
802 label: "p".into(),
803 vitals: None,
804 attributes: None,
805 skills: None,
806 inside_building: None,
807 tile_id: None,
808 presentation_state: None,
809 sprite_mode: None,
810 }),
811 resource_nodes: vec![],
812 ground_drops: vec![],
813 placed_containers: vec![],
814 buildings: vec![],
815 doors: vec![],
816 interior_map: None,
817 npcs: vec![],
818 blueprints: vec![],
819 world_width_m: 64.0,
820 world_height_m: 64.0,
821 terrain_zones: vec![],
822 z_platforms: vec![],
823 z_transitions: vec![],
824 world_clock: Default::default(),
825 inventory: Default::default(),
826 inventory_hints: Default::default(),
827 logs: Default::default(),
828 intents_sent: 0,
829 ticks_received: 0,
830 connected: true,
831 disconnect_reason: None,
832 show_stats: false,
833 show_craft_menu: false,
834 craft_menu_index: 0,
835 craft_batch_quantity: 1,
836 show_shop_menu: false,
837 shop_catalog: None,
838 shop_tab: crate::game::ShopTab::default(),
839 shop_menu_index: 0,
840 shop_quantity: 1,
841 shop_trade_log: std::collections::VecDeque::new(),
842 show_npc_verb_menu: false,
843 npc_verb_target: None,
844 npc_verb_index: 0,
845 show_npc_chat: false,
846 npc_chat: None,
847 show_inventory_menu: false,
848 inventory_menu_index: 0,
849 show_move_picker: false,
850 show_rename_prompt: false,
851 rename_buffer: String::new(),
852 move_picker_index: 0,
853 move_picker: None,
854 show_destroy_picker: false,
855 destroy_confirm_pending: false,
856 destroy_picker: None,
857 combat_target: None,
858 combat_target_label: None,
859 in_combat: false,
860 auto_attack: false,
861 combat_has_los: false,
862 attack_cd_ticks: 0,
863 gcd_ticks: 0,
864 weapon_ability_id: String::new(),
865 mainhand_template_id: None,
866 mainhand_label: None,
867 worn: std::collections::BTreeMap::new(),
868 carry_mass: 0.0,
869 carry_mass_max: 0.0,
870 encumbrance: flatland_protocol::EncumbranceState::Light,
871 inventory_stacks: Vec::new(),
872 keychain_stacks: Vec::new(),
873 combat_target_detail: None,
874 cast_progress: None,
875 ability_cooldowns: vec![],
876 blocking_active: false,
877 max_target_slots: 1,
878 combat_slots: vec![],
879 rotation_presets: vec![],
880 show_loadout_menu: false,
881 show_keychain_menu: false,
882 keychain_menu_index: 0,
883 show_rotation_editor: false,
884 loadout_menu_index: 0,
885 rotation_editor: Default::default(),
886 harvest_in_progress: false,
887 harvest_started_at: None,
888 pending_craft_ack: None,
889 quest_log: Vec::new(),
890 interactables: Vec::new(),
891 show_quest_offer: false,
892 pending_quest_offer: None,
893 show_quest_menu: false,
894 quest_menu_index: 0,
895 quest_withdraw_confirm: false,
896 }
897 }
898
899 #[test]
900 fn path_on_open_field() {
901 let state = empty_state();
902 let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
903 assert!(!path.is_empty());
904 let last = *path.last().unwrap();
905 assert!((last.0 - 20.5).abs() < 1.0);
906 assert!((last.1 - 15.5).abs() < 1.0);
907 }
908
909 #[test]
910 fn path_routes_around_blocking_tree() {
911 let mut state = empty_state();
912 state.resource_nodes.push(flatland_protocol::ResourceNodeView {
913 id: "oak".into(),
914 label: "Oak".into(),
915 x: 15.0,
916 y: 12.0,
917 z: 0.0,
918 item_template: "oak_log".into(),
919 state: ResourceNodeState::Available,
920 blocking: true,
921 blocking_radius_m: 0.8,
922 tile_id: None,
923 sprite_mode: None,
924 presentation_state: None,
925 });
926 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
927 for (x, y) in &path {
928 let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
929 assert!(!near_tree, "path should not cut through tree at ({x},{y})");
930 }
931 }
932
933 #[test]
934 fn path_segments_avoid_tree_corner_cut() {
935 let mut state = empty_state();
936 state.resource_nodes.push(flatland_protocol::ResourceNodeView {
937 id: "oak".into(),
938 label: "Oak".into(),
939 x: 15.0,
940 y: 12.0,
941 z: 0.0,
942 item_template: "oak_log".into(),
943 state: ResourceNodeState::Available,
944 blocking: true,
945 blocking_radius_m: 0.8,
946 tile_id: None,
947 sprite_mode: None,
948 presentation_state: None,
949 });
950 let path = find_path(&state, 10.0, 11.0, 0.0, 20.0, 13.0, 0.0).expect("diagonal path");
951 let obstacles = NavObstacles::from_state(&state);
952 let grid = build_grid(&state, &obstacles, 0.0, 0.0);
953 assert!(
954 path_segments_clear(&grid, &obstacles, &path),
955 "every leg must clear tree collision: {path:?}"
956 );
957 }
958
959 #[test]
960 fn path_routes_around_building_with_clearance() {
961 let mut state = empty_state();
962 state.buildings.push(flatland_protocol::BuildingView {
964 id: "hut".into(),
965 label: "Hut".into(),
966 x: 20.0,
967 y: 20.0,
968 width_m: 6.0,
969 depth_m: 6.0,
970 interior_blueprint: None,
971 tags: vec![],
972 });
973 let path = find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
974 assert!(!path.is_empty());
975 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
976 let x0 = 20.0 - 3.0 - pad;
977 let x1 = 20.0 + 3.0 + pad;
978 let y0 = 20.0 - 3.0 - pad;
979 let y1 = 20.0 + 3.0 + pad;
980 for (x, y) in &path {
981 let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
982 assert!(
983 !inside,
984 "path waypoint ({x},{y}) intersects inflated building footprint"
985 );
986 }
987 let last = *path.last().unwrap();
988 assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
989 }
990
991 #[test]
992 fn auto_navigator_finishes_near_goal() {
993 let state = empty_state();
994 let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
995 let (fx, fy, fz) = state.player_position_with_z();
996 let steer = nav.steer(fx, fy, fz, &state).expect("steer");
997 assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
998 }
999}