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