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