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 paperdoll_ref: None,
118 presentation_state: None,
119 sprite_mode: None,
120 progression_xp: None,
121 }),
122 resource_nodes: vec![],
123 ground_drops: vec![],
124 placed_containers: vec![],
125 buildings: vec![],
126 doors: vec![],
127 interior_map: None,
128 npcs: vec![],
129 blueprints: vec![],
130 world_width_m: 64.0,
131 world_height_m: 64.0,
132 terrain_zones: vec![],
133 z_platforms: vec![],
134 z_transitions: vec![],
135 world_clock: Default::default(),
136 inventory: Default::default(),
137 inventory_hints: Default::default(),
138 logs: Default::default(),
139 intents_sent: 0,
140 ticks_received: 0,
141 connected: true,
142 disconnect_reason: None,
143 show_stats: false,
144 hud_log_hidden: false,
145 show_equip_menu: false,
146 equip_menu_index: 0,
147 show_craft_menu: false,
148 craft_menu_index: 0,
149 craft_batch_quantity: 1,
150 show_shop_menu: false,
151 shop_catalog: None,
152 bank_panel: None,
153 bank_menu_index: 0,
154 bank_ui_mode: crate::game::BankUiMode::Menu,
155 storage_panel: None,
156 storage_menu_index: 0,
157 storage_ui_mode: crate::game::StorageUiMode::Menu,
158 shop_tab: crate::game::ShopTab::default(),
159 shop_menu_index: 0,
160 shop_quantity: 1,
161 shop_trade_log: std::collections::VecDeque::new(),
162 show_npc_verb_menu: false,
163 npc_verb_target: None,
164 npc_verb_index: 0,
165 player_verbs: Default::default(),
166 social_chat: Default::default(),
167 trade_ui: Default::default(),
168 whisper_pouch_ui: Default::default(),
169 show_npc_chat: false,
170 npc_chat: None,
171 show_inventory_menu: false,
172 inventory_menu_index: 0,
173 inventory_tab: crate::game::InventoryTab::OnPerson,
174 inventory_filter: String::new(),
175 inventory_filter_focused: false,
176 show_move_picker: false,
177 show_rename_prompt: false,
178 show_worker_rename: false,
179 rename_buffer: String::new(),
180 move_picker_index: 0,
181 move_picker: None,
182 show_grant_picker: false,
183 grant_picker_index: 0,
184 grant_picker: None,
185 show_destroy_picker: false,
186 destroy_confirm_pending: false,
187 destroy_picker: None,
188 combat_target: None,
189 combat_target_label: None,
190 combat_fx: Vec::new(),
191 in_combat: false,
192 auto_attack: false,
193 combat_has_los: false,
194 attack_cd_ticks: 0,
195 gcd_ticks: 0,
196 weapon_ability_id: String::new(),
197 mainhand_template_id: None,
198 mainhand_label: None,
199 offhand_template_id: None,
200 offhand_label: None,
201 mainhand_hand_slots: 1,
202 defense: None,
203 worn: std::collections::BTreeMap::new(),
204 carry_mass: 0.0,
205 carry_mass_max: 0.0,
206 encumbrance: flatland_protocol::EncumbranceState::Light,
207 inventory_stacks: Vec::new(),
208 keychain_stacks: Vec::new(),
209 whisper_pouch_stacks: Vec::new(),
210 combat_target_detail: None,
211 statuses: Vec::new(),
212 cast_progress: None,
213 ability_cooldowns: vec![],
214 blocking_active: false,
215 max_target_slots: 1,
216 combat_slots: vec![],
217 rotation_presets: vec![],
218 known_abilities: Vec::new(),
219 hotbar: vec![None; 9],
220 max_abilities_per_rotation: 0,
221 show_loadout_menu: false,
222 show_keychain_menu: false,
223 keychain_menu_index: 0,
224 show_rotation_editor: false,
225 loadout_menu_index: 0,
226 loadout_hotbar_slot: 1,
227 loadout_ability_index: 0,
228 loadout_focus_presets: false,
229 rotation_editor: Default::default(),
230 harvest_in_progress: false,
231 harvest_started_at: None,
232 pending_craft_ack: None,
233 pending_worker_job_ack: None,
234 quest_log: Vec::new(),
235 interactables: Vec::new(),
236 ledger: None,
237 career: None,
238 character_sheet_tab: crate::CharacterSheetTab::Character,
239 ledger_period: crate::LedgerPeriod::Day,
240 show_quest_offer: false,
241 pending_quest_offer: None,
242 show_quest_menu: false,
243 quest_menu_index: 0,
244 quest_withdraw_confirm: false,
245 hired_workers: Vec::new(),
246 show_workers_menu: false,
247 workers_menu_index: 0,
248 workers_menu_compact: false,
249 worker_step_display: std::collections::BTreeMap::new(),
250 worker_error_display: std::collections::BTreeMap::new(),
251 show_worker_give_picker: false,
252 worker_give_picker_index: 0,
253 worker_give_picker: None,
254 show_worker_give_target_picker: false,
255 worker_give_target_picker_index: 0,
256 worker_give_target_picker: None,
257 show_worker_take_picker: false,
258 worker_take_picker_index: 0,
259 worker_take_picker: None,
260 show_worker_teach_picker: false,
261 worker_teach_picker_index: 0,
262 worker_teach_picker: None,
263 worker_route_editor: None,
264 progression_curve: None,
265 }
266 }
267
268 #[test]
269 fn path_on_open_field() {
270 let state = empty_state();
271 let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
272 assert!(!path.is_empty());
273 let last = *path.last().unwrap();
274 assert!((last.0 - 20.5).abs() < 1.0);
275 assert!((last.1 - 15.5).abs() < 1.0);
276 }
277
278 #[test]
279 fn path_routes_around_blocking_tree() {
280 let mut state = empty_state();
281 state
282 .resource_nodes
283 .push(flatland_protocol::ResourceNodeView {
284 id: "oak".into(),
285 label: "Oak".into(),
286 x: 15.0,
287 y: 12.0,
288 z: 0.0,
289 item_template: "oak_log".into(),
290 state: ResourceNodeState::Available,
291 blocking: true,
292 blocking_radius_m: 0.8,
293 tile_id: None,
294 paperdoll_ref: None,
295 yaw: 0.0,
296 pitch: 0.0,
297 roll: 0.0,
298 draw_scale: 1.0,
299 sprite_mode: None,
300 presentation_state: None,
301 });
302 let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
303 for (x, y) in &path {
304 let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
305 assert!(!near_tree, "path should not cut through tree at ({x},{y})");
306 }
307 }
308
309 #[test]
310 fn path_routes_around_building_with_clearance() {
311 let mut state = empty_state();
312 state.buildings.push(flatland_protocol::BuildingView {
313 id: "hut".into(),
314 label: "Hut".into(),
315 x: 20.0,
316 y: 20.0,
317 width_m: 6.0,
318 depth_m: 6.0,
319 interior_blueprint: None,
320 tags: vec![],
321 });
322 let path =
323 find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
324 assert!(!path.is_empty());
325 let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
326 let x0 = 20.0 - 3.0 - pad;
327 let x1 = 20.0 + 3.0 + pad;
328 let y0 = 20.0 - 3.0 - pad;
329 let y1 = 20.0 + 3.0 + pad;
330 for (x, y) in &path {
331 let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
332 assert!(
333 !inside,
334 "path waypoint ({x},{y}) intersects inflated building footprint"
335 );
336 }
337 let last = *path.last().unwrap();
338 assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
339 }
340
341 #[test]
342 fn auto_navigator_finishes_near_goal() {
343 let state = empty_state();
344 let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
345 let (fx, fy, fz) = state.player_position_with_z();
346 let steer = nav.steer(fx, fy, fz, &state).expect("steer");
347 assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
348 }
349}