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