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 let (approach_x, approach_y) = path.last().copied().unwrap_or((goal_x, goal_y));
71 Some(Self {
72 waypoints: path,
73 waypoint_index: 0,
74 goal_x: approach_x,
75 goal_y: approach_y,
76 goal_z,
77 last_progress_x: from_x,
78 last_progress_y: from_y,
79 stuck_ticks: 0,
80 replan_attempts: 0,
81 })
82 }
83
84 pub fn active(&self) -> bool {
85 self.waypoint_index < self.waypoints.len()
86 }
87
88 pub fn waypoints(&self) -> &[(f32, f32)] {
89 &self.waypoints
90 }
91
92 pub fn replan(&mut self, world: &NavWorld, px: f32, py: f32, pz: f32) -> bool {
93 if let Some(path) =
94 find_path_with_goal_z(world, px, py, pz, self.goal_x, self.goal_y, self.goal_z)
95 {
96 if !path.is_empty() {
97 let changed = path != self.waypoints;
98 self.waypoints = path;
99 self.waypoint_index = 0;
100 self.stuck_ticks = 0;
101 self.last_progress_x = px;
102 self.last_progress_y = py;
103 if changed {
104 self.replan_attempts = 0;
105 } else {
106 self.replan_attempts = self.replan_attempts.saturating_add(1);
107 }
108 return self.replan_attempts < MAX_REPLAN_ATTEMPTS;
109 }
110 }
111 self.replan_attempts = self.replan_attempts.saturating_add(1);
112 self.replan_attempts < MAX_REPLAN_ATTEMPTS
113 }
114
115 pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
116 if (px - self.last_progress_x).hypot(py - self.last_progress_y) > 0.25 {
117 self.last_progress_x = px;
118 self.last_progress_y = py;
119 self.stuck_ticks = 0;
120 self.replan_attempts = 0;
121 return false;
122 }
123 self.stuck_ticks = self.stuck_ticks.saturating_add(1);
124 self.stuck_ticks >= STUCK_TICKS_BEFORE_REPLAN
125 }
126
127 pub fn steer(&mut self, px: f32, py: f32, pz: f32, world: &NavWorld) -> Option<PathSteer> {
128 let vertical_climb = transition_vertical(world, px, py, pz, self.goal_z);
129 if vertical_climb.abs() > f32::EPSILON {
130 return Some(PathSteer {
131 forward: 0.0,
132 strafe: 0.0,
133 vertical: vertical_climb,
134 sprint: false,
135 });
136 }
137 while self.waypoint_index < self.waypoints.len() {
138 let (wx, wy) = self.waypoints[self.waypoint_index];
139 let dx = wx - px;
140 let dy = wy - py;
141 let dist = (dx * dx + dy * dy).sqrt();
142 if dist < WAYPOINT_RADIUS_M {
143 self.waypoint_index += 1;
144 continue;
145 }
146 let forward = (dy / dist).clamp(-1.0, 1.0);
147 let strafe = (dx / dist).clamp(-1.0, 1.0);
148 let sprint = dist > SPRINT_LEG_M;
149 return Some(PathSteer {
150 forward,
151 strafe,
152 vertical: 0.0,
153 sprint,
154 });
155 }
156 let goal_dist = (self.goal_x - px).hypot(self.goal_y - py);
157 if goal_dist > ARRIVE_RADIUS_M || (pz - self.goal_z).abs() > Z_LEVEL_TOLERANCE {
158 if goal_dist > ARRIVE_RADIUS_M {
159 let forward = ((self.goal_y - py) / goal_dist).clamp(-1.0, 1.0);
160 let strafe = ((self.goal_x - px) / goal_dist).clamp(-1.0, 1.0);
161 return Some(PathSteer {
162 forward,
163 strafe,
164 vertical: 0.0,
165 sprint: goal_dist > SPRINT_LEG_M,
166 });
167 }
168 let vz = transition_vertical(world, px, py, pz, self.goal_z);
169 if vz.abs() > f32::EPSILON {
170 return Some(PathSteer {
171 forward: 0.0,
172 strafe: 0.0,
173 vertical: vz,
174 sprint: false,
175 });
176 }
177 }
178 None
179 }
180
181 pub fn remaining_distance_m(&self, px: f32, py: f32) -> f32 {
183 let mut total = 0.0;
184 let mut last = (px, py);
185 if self.waypoint_index < self.waypoints.len() {
186 for &(wx, wy) in &self.waypoints[self.waypoint_index..] {
187 total += (wx - last.0).hypot(wy - last.1);
188 last = (wx, wy);
189 }
190 }
191 total + (self.goal_x - last.0).hypot(self.goal_y - last.1)
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::grid::NavWorld;
199
200 fn open_world() -> NavWorld {
201 NavWorld {
202 world_width_m: 64.0,
203 world_height_m: 64.0,
204 terrain_zones: vec![],
205 z_platforms: vec![],
206 z_transitions: vec![],
207 buildings: vec![],
208 doors: vec![],
209 circles: vec![],
210 }
211 }
212
213 #[test]
214 fn replan_gives_up_when_goal_becomes_unreachable() {
215 let world = open_world();
216 let mut session =
217 PathSession::plan_with_goal_z(&world, 10.0, 10.0, 0.0, 20.0, 10.0, 0.0).expect("path");
218 let mut blocked = open_world();
220 blocked.terrain_zones.push(flatland_protocol::TerrainZoneView {
221 id: "wall".into(),
222 x0: 14.0,
223 y0: 0.0,
224 x1: 18.0,
225 y1: 64.0,
226 kind: flatland_protocol::TerrainKindView::DeepWater,
227 elevation: 0.0,
228 glyph: None,
229 color: None,
230 tile_id: None,
231 z_order: 0,
232 channel_start_tick: None,
233 channel_end_tick: None,
234 });
235 let mut gave_up = false;
236 for _ in 0..MAX_REPLAN_ATTEMPTS + 2 {
237 if !session.replan(&blocked, 10.0, 10.0, 0.0) {
238 gave_up = true;
239 break;
240 }
241 }
242 assert!(gave_up, "replan must eventually return false for unreachable goals");
243 }
244}