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