1use crate::grid::NavWorld;
4use crate::path::find_path_with_goal_z;
5use crate::z_nav::{goal_z_for, transition_vertical, Z_LEVEL_TOLERANCE};
6
7pub const WAYPOINT_RADIUS_M: f32 = 0.85;
8pub const ARRIVE_RADIUS_M: f32 = 0.75;
9const SPRINT_LEG_M: f32 = 4.0;
10const STUCK_TICKS_BEFORE_REPLAN: u32 = 6;
11const MAX_REPLAN_ATTEMPTS: u32 = 5;
12
13pub const DEX_MOVE_COEFF: f32 = 0.003;
15
16pub fn effective_move_speed_mps(base_speed: f32, dex_display: f32, encumbrance_mult: f32) -> f32 {
18 base_speed * encumbrance_mult * (1.0 + DEX_MOVE_COEFF * dex_display.max(0.0))
19}
20
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct PathSteer {
23 pub forward: f32,
24 pub strafe: f32,
25 pub vertical: f32,
26 pub sprint: bool,
27}
28
29#[derive(Debug, Clone)]
30pub struct PathSession {
31 waypoints: Vec<(f32, f32)>,
32 waypoint_index: usize,
33 pub goal_x: f32,
34 pub goal_y: f32,
35 pub goal_z: f32,
36 last_progress_x: f32,
37 last_progress_y: f32,
38 stuck_ticks: u32,
39 replan_attempts: u32,
40}
41
42impl PathSession {
43 pub fn plan(
44 world: &NavWorld,
45 from_x: f32,
46 from_y: f32,
47 from_z: f32,
48 goal_x: f32,
49 goal_y: f32,
50 ) -> Option<Self> {
51 let goal_z = goal_z_for(world, goal_x, goal_y, from_z);
52 Self::plan_with_goal_z(world, from_x, from_y, from_z, goal_x, goal_y, goal_z)
53 }
54
55 pub fn plan_with_goal_z(
56 world: &NavWorld,
57 from_x: f32,
58 from_y: f32,
59 from_z: f32,
60 goal_x: f32,
61 goal_y: f32,
62 goal_z: f32,
63 ) -> Option<Self> {
64 let path = find_path_with_goal_z(world, from_x, from_y, from_z, goal_x, goal_y, goal_z)?;
65 if path.is_empty() {
66 return None;
67 }
68 Some(Self {
69 waypoints: path,
70 waypoint_index: 0,
71 goal_x,
72 goal_y,
73 goal_z,
74 last_progress_x: from_x,
75 last_progress_y: from_y,
76 stuck_ticks: 0,
77 replan_attempts: 0,
78 })
79 }
80
81 pub fn active(&self) -> bool {
82 self.waypoint_index < self.waypoints.len()
83 }
84
85 pub fn waypoints(&self) -> &[(f32, f32)] {
86 &self.waypoints
87 }
88
89 pub fn replan(&mut self, world: &NavWorld, px: f32, py: f32, pz: f32) -> bool {
90 if let Some(path) =
91 find_path_with_goal_z(world, px, py, pz, self.goal_x, self.goal_y, self.goal_z)
92 {
93 if !path.is_empty() {
94 let changed = path != self.waypoints;
95 self.waypoints = path;
96 self.waypoint_index = 0;
97 self.stuck_ticks = 0;
98 self.last_progress_x = px;
99 self.last_progress_y = py;
100 if changed {
101 self.replan_attempts = 0;
102 } else {
103 self.replan_attempts = self.replan_attempts.saturating_add(1);
104 }
105 return self.replan_attempts < MAX_REPLAN_ATTEMPTS;
106 }
107 }
108 self.replan_attempts = self.replan_attempts.saturating_add(1);
109 self.replan_attempts < MAX_REPLAN_ATTEMPTS
110 }
111
112 pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
113 if (px - self.last_progress_x).hypot(py - self.last_progress_y) > 0.25 {
114 self.last_progress_x = px;
115 self.last_progress_y = py;
116 self.stuck_ticks = 0;
117 self.replan_attempts = 0;
118 return false;
119 }
120 self.stuck_ticks = self.stuck_ticks.saturating_add(1);
121 self.stuck_ticks >= STUCK_TICKS_BEFORE_REPLAN
122 }
123
124 pub fn steer(&mut self, px: f32, py: f32, pz: f32, world: &NavWorld) -> Option<PathSteer> {
125 let vertical_climb = transition_vertical(world, px, py, pz, self.goal_z);
126 if vertical_climb.abs() > f32::EPSILON {
127 return Some(PathSteer {
128 forward: 0.0,
129 strafe: 0.0,
130 vertical: vertical_climb,
131 sprint: false,
132 });
133 }
134 while self.waypoint_index < self.waypoints.len() {
135 let (wx, wy) = self.waypoints[self.waypoint_index];
136 let dx = wx - px;
137 let dy = wy - py;
138 let dist = (dx * dx + dy * dy).sqrt();
139 if dist < WAYPOINT_RADIUS_M {
140 self.waypoint_index += 1;
141 continue;
142 }
143 let forward = (dy / dist).clamp(-1.0, 1.0);
144 let strafe = (dx / dist).clamp(-1.0, 1.0);
145 let sprint = dist > SPRINT_LEG_M;
146 return Some(PathSteer {
147 forward,
148 strafe,
149 vertical: 0.0,
150 sprint,
151 });
152 }
153 let goal_dist = (self.goal_x - px).hypot(self.goal_y - py);
154 if goal_dist > ARRIVE_RADIUS_M || (pz - self.goal_z).abs() > Z_LEVEL_TOLERANCE {
155 if goal_dist > ARRIVE_RADIUS_M {
156 let forward = ((self.goal_y - py) / goal_dist).clamp(-1.0, 1.0);
157 let strafe = ((self.goal_x - px) / goal_dist).clamp(-1.0, 1.0);
158 return Some(PathSteer {
159 forward,
160 strafe,
161 vertical: 0.0,
162 sprint: goal_dist > SPRINT_LEG_M,
163 });
164 }
165 let vz = transition_vertical(world, px, py, pz, self.goal_z);
166 if vz.abs() > f32::EPSILON {
167 return Some(PathSteer {
168 forward: 0.0,
169 strafe: 0.0,
170 vertical: vz,
171 sprint: false,
172 });
173 }
174 }
175 None
176 }
177
178 pub fn remaining_distance_m(&self, px: f32, py: f32) -> f32 {
180 let mut total = 0.0;
181 let mut last = (px, py);
182 if self.waypoint_index < self.waypoints.len() {
183 for &(wx, wy) in &self.waypoints[self.waypoint_index..] {
184 total += (wx - last.0).hypot(wy - last.1);
185 last = (wx, wy);
186 }
187 }
188 total + (self.goal_x - last.0).hypot(self.goal_y - last.1)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::grid::NavWorld;
196
197 fn open_world() -> NavWorld {
198 NavWorld {
199 world_width_m: 64.0,
200 world_height_m: 64.0,
201 terrain_zones: vec![],
202 z_platforms: vec![],
203 z_transitions: vec![],
204 buildings: vec![],
205 doors: vec![],
206 circles: vec![],
207 }
208 }
209
210 #[test]
211 fn replan_gives_up_when_goal_becomes_unreachable() {
212 let world = open_world();
213 let mut session =
214 PathSession::plan_with_goal_z(&world, 10.0, 10.0, 0.0, 20.0, 10.0, 0.0).expect("path");
215 let mut blocked = open_world();
217 blocked.terrain_zones.push(flatland_protocol::TerrainZoneView {
218 id: "wall".into(),
219 x0: 14.0,
220 y0: 0.0,
221 x1: 18.0,
222 y1: 64.0,
223 kind: flatland_protocol::TerrainKindView::DeepWater,
224 elevation: 0.0,
225 glyph: None,
226 color: None,
227 tile_id: None,
228 z_order: 0,
229 });
230 let mut gave_up = false;
231 for _ in 0..MAX_REPLAN_ATTEMPTS + 2 {
232 if !session.replan(&blocked, 10.0, 10.0, 0.0) {
233 gave_up = true;
234 break;
235 }
236 }
237 assert!(gave_up, "replan must eventually return false for unreachable goals");
238 }
239}