1use flatland_pathfinding::{circles_from_views, NavWorld, PathSession, PathSteer};
4
5#[cfg(test)]
6use flatland_pathfinding::{find_path_with_goal_z, PATH_CLEARANCE_M, PLAYER_RADIUS_M};
7
8use crate::game::GameState;
9
10fn nav_world_from_state(state: &GameState) -> NavWorld {
11 NavWorld {
12 world_width_m: state.world_width_m,
13 world_height_m: state.world_height_m,
14 terrain_zones: state.terrain_zones.clone(),
15 z_platforms: state.z_platforms.clone(),
16 z_transitions: state.z_transitions.clone(),
17 buildings: state.buildings.clone(),
18 doors: state.doors.clone(),
19 circles: circles_from_views(&state.resource_nodes, &state.npcs),
20 }
21}
22
23#[derive(Debug, Clone)]
24pub struct AutoNavigator {
25 inner: PathSession,
26}
27
28impl AutoNavigator {
29 pub fn plan(state: &GameState, goal_x: f32, goal_y: f32) -> Option<Self> {
30 let world = nav_world_from_state(state);
31 let (px, py, pz) = state.player_position_with_z();
32 PathSession::plan(&world, px, py, pz, goal_x, goal_y).map(|inner| Self { inner })
33 }
34
35 pub fn active(&self) -> bool {
36 self.inner.active()
37 }
38
39 pub fn replan(&mut self, state: &GameState) -> bool {
40 let world = nav_world_from_state(state);
41 let (px, py, pz) = state.player_position_with_z();
42 self.inner.replan(&world, px, py, pz)
43 }
44
45 pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
46 self.inner.note_progress(px, py)
47 }
48
49 pub fn steer(
50 &mut self,
51 px: f32,
52 py: f32,
53 pz: f32,
54 state: &GameState,
55 ) -> Option<(f32, f32, f32, bool)> {
56 let world = nav_world_from_state(state);
57 self.inner.steer(px, py, pz, &world).map(|s: PathSteer| {
58 (s.forward, s.strafe, s.vertical, s.sprint)
59 })
60 }
61
62 pub fn goal_x(&self) -> f32 {
63 self.inner.goal_x
64 }
65
66 pub fn goal_y(&self) -> f32 {
67 self.inner.goal_y
68 }
69
70 pub fn goal_z(&self) -> f32 {
71 self.inner.goal_z
72 }
73}
74
75#[cfg(test)]
76fn find_path(
77 state: &GameState,
78 from_x: f32,
79 from_y: f32,
80 from_z: f32,
81 to_x: f32,
82 to_y: f32,
83 to_z: f32,
84) -> Option<Vec<(f32, f32)>> {
85 let world = nav_world_from_state(state);
86 find_path_with_goal_z(&world, from_x, from_y, from_z, to_x, to_y, to_z)
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use flatland_protocol::{EntityState, ResourceNodeState, Transform, Velocity2D, WorldCoord};
93
94 fn empty_state() -> GameState {
95 GameState {
96 session_id: Default::default(),
97 entity_id: 1,
98 character_id: None,
99 tick: 0,
100 chunk_rev: 0,
101 content_rev: 0,
102 publish_rev: 0,
103 entities: vec![],
104 player: Some(EntityState {
105 id: 1,
106 transform: Transform {
107 position: WorldCoord::surface(10.0, 10.0),
108 yaw: 0.0,
109 velocity: Velocity2D { vx: 0.0, vy: 0.0 },
110 },
111 label: "p".into(),
112 vitals: None,
113 attributes: None,
114 skills: None,
115 inside_building: None,
116 tile_id: None,
117 presentation_state: None,
118 sprite_mode: None,
119 progression_xp: None,
120 }),
121 resource_nodes: vec![],
122 ground_drops: vec![],
123 placed_containers: vec![],
124 buildings: vec![],
125 doors: vec![],
126 interior_map: None,
127 npcs: vec![],
128 blueprints: vec![],
129 world_width_m: 64.0,
130 world_height_m: 64.0,
131 terrain_zones: vec![],
132 z_platforms: vec![],
133 z_transitions: vec![],
134 world_clock: Default::default(),
135 inventory: Default::default(),
136 inventory_hints: Default::default(),
137 logs: Default::default(),
138 intents_sent: 0,
139 ticks_received: 0,
140 connected: true,
141 disconnect_reason: None,
142 show_stats: false,
143 show_equip_menu: false,
144 equip_menu_index: 0,
145 show_craft_menu: false,
146 craft_menu_index: 0,
147 craft_batch_quantity: 1,
148 show_shop_menu: false,
149 shop_catalog: None,
150 shop_tab: crate::game::ShopTab::default(),
151 shop_menu_index: 0,
152 shop_quantity: 1,
153 shop_trade_log: std::collections::VecDeque::new(),
154 show_npc_verb_menu: false,
155 npc_verb_target: None,
156 npc_verb_index: 0,
157 show_npc_chat: false,
158 npc_chat: None,
159 show_inventory_menu: false,
160 inventory_menu_index: 0,
161 inventory_tab: crate::game::InventoryTab::OnPerson,
162 inventory_filter: String::new(),
163 inventory_filter_focused: false,
164 show_move_picker: false,
165 show_rename_prompt: false,
166 show_worker_rename: false,
167 rename_buffer: String::new(),
168 move_picker_index: 0,
169 move_picker: None,
170 show_grant_picker: false,
171 grant_picker_index: 0,
172 grant_picker: None,
173 show_destroy_picker: false,
174 destroy_confirm_pending: false,
175 destroy_picker: None,
176 combat_target: None,
177 combat_target_label: None,
178 in_combat: false,
179 auto_attack: false,
180 combat_has_los: false,
181 attack_cd_ticks: 0,
182 gcd_ticks: 0,
183 weapon_ability_id: String::new(),
184 mainhand_template_id: None,
185 mainhand_label: None,
186 offhand_template_id: None,
187 offhand_label: None,
188 mainhand_hand_slots: 1,
189 defense: None,
190 worn: std::collections::BTreeMap::new(),
191 carry_mass: 0.0,
192 carry_mass_max: 0.0,
193 encumbrance: flatland_protocol::EncumbranceState::Light,
194 inventory_stacks: Vec::new(),
195 keychain_stacks: Vec::new(),
196 combat_target_detail: None,
197 statuses: Vec::new(),
198 cast_progress: None,
199 ability_cooldowns: vec![],
200 blocking_active: false,
201 max_target_slots: 1,
202 combat_slots: vec![],
203 rotation_presets: vec![],
204 show_loadout_menu: false,
205 show_keychain_menu: false,
206 keychain_menu_index: 0,
207 show_rotation_editor: false,
208 loadout_menu_index: 0,
209 rotation_editor: Default::default(),
210 harvest_in_progress: false,
211 harvest_started_at: None,
212 pending_craft_ack: None,
213 pending_worker_job_ack: None,
214 quest_log: Vec::new(),
215 interactables: Vec::new(),
216 ledger: None,
217 career: None,
218 character_sheet_tab: crate::CharacterSheetTab::Character,
219 ledger_period: crate::LedgerPeriod::Day,
220 show_quest_offer: false,
221 pending_quest_offer: None,
222 show_quest_menu: false,
223 quest_menu_index: 0,
224 quest_withdraw_confirm: false,
225 hired_workers: Vec::new(),
226 show_workers_menu: false,
227 workers_menu_index: 0,
228 workers_menu_compact: false,
229 worker_step_display: std::collections::BTreeMap::new(),
230 show_worker_give_picker: false,
231 worker_give_picker_index: 0,
232 worker_give_picker: None,
233 show_worker_give_target_picker: false,
234 worker_give_target_picker_index: 0,
235 worker_give_target_picker: None,
236 show_worker_take_picker: false,
237 worker_take_picker_index: 0,
238 worker_take_picker: None,
239 show_worker_teach_picker: false,
240 worker_teach_picker_index: 0,
241 worker_teach_picker: None,
242 worker_route_editor: None,
243 progression_curve: None,
244 }
245 }
246
247 #[test]
248 fn path_on_open_field() {
249 let state = empty_state();
250 let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
251 assert!(!path.is_empty());
252 let last = *path.last().unwrap();
253 assert!((last.0 - 20.5).abs() < 1.0);
254 assert!((last.1 - 15.5).abs() < 1.0);
255 }
256
257 #[test]
258 fn path_routes_around_blocking_tree() {
259 let mut state = empty_state();
260 state
261 .resource_nodes
262 .push(flatland_protocol::ResourceNodeView {
263 id: "oak".into(),
264 label: "Oak".into(),
265 x: 15.0,
266 y: 12.0,
267 z: 0.0,
268 item_template: "oak_log".into(),
269 state: ResourceNodeState::Available,
270 blocking: true,
271 blocking_radius_m: 0.8,
272 tile_id: None,
273 sprite_mode: None,
274 presentation_state: None,
275 });
276 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
277 for (x, y) in &path {
278 let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
279 assert!(!near_tree, "path should not cut through tree at ({x},{y})");
280 }
281 }
282
283 #[test]
284 fn path_routes_around_building_with_clearance() {
285 let mut state = empty_state();
286 state.buildings.push(flatland_protocol::BuildingView {
287 id: "hut".into(),
288 label: "Hut".into(),
289 x: 20.0,
290 y: 20.0,
291 width_m: 6.0,
292 depth_m: 6.0,
293 interior_blueprint: None,
294 tags: vec![],
295 });
296 let path =
297 find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
298 assert!(!path.is_empty());
299 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
300 let x0 = 20.0 - 3.0 - pad;
301 let x1 = 20.0 + 3.0 + pad;
302 let y0 = 20.0 - 3.0 - pad;
303 let y1 = 20.0 + 3.0 + pad;
304 for (x, y) in &path {
305 let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
306 assert!(
307 !inside,
308 "path waypoint ({x},{y}) intersects inflated building footprint"
309 );
310 }
311 let last = *path.last().unwrap();
312 assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
313 }
314
315 #[test]
316 fn auto_navigator_finishes_near_goal() {
317 let state = empty_state();
318 let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
319 let (fx, fy, fz) = state.player_position_with_z();
320 let steer = nav.steer(fx, fy, fz, &state).expect("steer");
321 assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
322 }
323}