Skip to main content

flatland_client_lib/
navigation.rs

1//! Client-side pathfinding and auto-navigation toward a map target.
2
3use 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}