use flatland_pathfinding::{circles_from_views, NavWorld, PathSession, PathSteer};
#[cfg(test)]
use flatland_pathfinding::{find_path_with_goal_z, PATH_CLEARANCE_M, PLAYER_RADIUS_M};
use crate::game::GameState;
fn nav_world_from_state(state: &GameState) -> NavWorld {
NavWorld {
world_width_m: state.world_width_m,
world_height_m: state.world_height_m,
terrain_zones: state.terrain_zones.clone(),
z_platforms: state.z_platforms.clone(),
z_transitions: state.z_transitions.clone(),
buildings: state.buildings.clone(),
doors: state.doors.clone(),
circles: circles_from_views(&state.resource_nodes, &state.npcs),
}
}
#[derive(Debug, Clone)]
pub struct AutoNavigator {
inner: PathSession,
}
impl AutoNavigator {
pub fn plan(state: &GameState, goal_x: f32, goal_y: f32) -> Option<Self> {
let world = nav_world_from_state(state);
let (px, py, pz) = state.player_position_with_z();
PathSession::plan(&world, px, py, pz, goal_x, goal_y).map(|inner| Self { inner })
}
pub fn active(&self) -> bool {
self.inner.active()
}
pub fn replan(&mut self, state: &GameState) -> bool {
let world = nav_world_from_state(state);
let (px, py, pz) = state.player_position_with_z();
self.inner.replan(&world, px, py, pz)
}
pub fn note_progress(&mut self, px: f32, py: f32) -> bool {
self.inner.note_progress(px, py)
}
pub fn steer(
&mut self,
px: f32,
py: f32,
pz: f32,
state: &GameState,
) -> Option<(f32, f32, f32, bool)> {
let world = nav_world_from_state(state);
self.inner.steer(px, py, pz, &world).map(|s: PathSteer| {
(s.forward, s.strafe, s.vertical, s.sprint)
})
}
pub fn goal_x(&self) -> f32 {
self.inner.goal_x
}
pub fn goal_y(&self) -> f32 {
self.inner.goal_y
}
pub fn goal_z(&self) -> f32 {
self.inner.goal_z
}
}
#[cfg(test)]
fn find_path(
state: &GameState,
from_x: f32,
from_y: f32,
from_z: f32,
to_x: f32,
to_y: f32,
to_z: f32,
) -> Option<Vec<(f32, f32)>> {
let world = nav_world_from_state(state);
find_path_with_goal_z(&world, from_x, from_y, from_z, to_x, to_y, to_z)
}
#[cfg(test)]
mod tests {
use super::*;
use flatland_protocol::{EntityState, ResourceNodeState, Transform, Velocity2D, WorldCoord};
fn empty_state() -> GameState {
GameState {
session_id: Default::default(),
entity_id: 1,
character_id: None,
tick: 0,
chunk_rev: 0,
content_rev: 0,
publish_rev: 0,
entities: vec![],
player: Some(EntityState {
id: 1,
transform: Transform {
position: WorldCoord::surface(10.0, 10.0),
yaw: 0.0,
velocity: Velocity2D { vx: 0.0, vy: 0.0 },
},
label: "p".into(),
vitals: None,
attributes: None,
skills: None,
inside_building: None,
tile_id: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
}),
resource_nodes: vec![],
ground_drops: vec![],
placed_containers: vec![],
buildings: vec![],
doors: vec![],
interior_map: None,
npcs: vec![],
blueprints: vec![],
world_width_m: 64.0,
world_height_m: 64.0,
terrain_zones: vec![],
z_platforms: vec![],
z_transitions: vec![],
world_clock: Default::default(),
inventory: Default::default(),
inventory_hints: Default::default(),
logs: Default::default(),
intents_sent: 0,
ticks_received: 0,
connected: true,
disconnect_reason: None,
show_stats: false,
show_craft_menu: false,
craft_menu_index: 0,
craft_batch_quantity: 1,
show_shop_menu: false,
shop_catalog: None,
shop_tab: crate::game::ShopTab::default(),
shop_menu_index: 0,
shop_quantity: 1,
shop_trade_log: std::collections::VecDeque::new(),
show_npc_verb_menu: false,
npc_verb_target: None,
npc_verb_index: 0,
show_npc_chat: false,
npc_chat: None,
show_inventory_menu: false,
inventory_menu_index: 0,
show_move_picker: false,
show_rename_prompt: false,
show_worker_rename: false,
rename_buffer: String::new(),
move_picker_index: 0,
move_picker: None,
show_destroy_picker: false,
destroy_confirm_pending: false,
destroy_picker: None,
combat_target: None,
combat_target_label: None,
in_combat: false,
auto_attack: false,
combat_has_los: false,
attack_cd_ticks: 0,
gcd_ticks: 0,
weapon_ability_id: String::new(),
mainhand_template_id: None,
mainhand_label: None,
worn: std::collections::BTreeMap::new(),
carry_mass: 0.0,
carry_mass_max: 0.0,
encumbrance: flatland_protocol::EncumbranceState::Light,
inventory_stacks: Vec::new(),
keychain_stacks: Vec::new(),
combat_target_detail: None,
cast_progress: None,
ability_cooldowns: vec![],
blocking_active: false,
max_target_slots: 1,
combat_slots: vec![],
rotation_presets: vec![],
show_loadout_menu: false,
show_keychain_menu: false,
keychain_menu_index: 0,
show_rotation_editor: false,
loadout_menu_index: 0,
rotation_editor: Default::default(),
harvest_in_progress: false,
harvest_started_at: None,
pending_craft_ack: None,
pending_worker_job_ack: None,
quest_log: Vec::new(),
interactables: Vec::new(),
show_quest_offer: false,
pending_quest_offer: None,
show_quest_menu: false,
quest_menu_index: 0,
quest_withdraw_confirm: false,
hired_workers: Vec::new(),
show_workers_menu: false,
workers_menu_index: 0,
workers_menu_compact: false,
worker_step_display: std::collections::BTreeMap::new(),
show_worker_give_picker: false,
worker_give_picker_index: 0,
worker_give_picker: None,
show_worker_give_target_picker: false,
worker_give_target_picker_index: 0,
worker_give_target_picker: None,
show_worker_take_picker: false,
worker_take_picker_index: 0,
worker_take_picker: None,
show_worker_teach_picker: false,
worker_teach_picker_index: 0,
worker_teach_picker: None,
worker_route_editor: None,
progression_curve: None,
}
}
#[test]
fn path_on_open_field() {
let state = empty_state();
let path = find_path(&state, 10.0, 10.0, 0.0, 20.0, 15.0, 0.0).expect("path");
assert!(!path.is_empty());
let last = *path.last().unwrap();
assert!((last.0 - 20.5).abs() < 1.0);
assert!((last.1 - 15.5).abs() < 1.0);
}
#[test]
fn path_routes_around_blocking_tree() {
let mut state = empty_state();
state
.resource_nodes
.push(flatland_protocol::ResourceNodeView {
id: "oak".into(),
label: "Oak".into(),
x: 15.0,
y: 12.0,
z: 0.0,
item_template: "oak_log".into(),
state: ResourceNodeState::Available,
blocking: true,
blocking_radius_m: 0.8,
tile_id: None,
sprite_mode: None,
presentation_state: None,
});
let path = find_path(&state, 10.0, 12.0, 0.0, 20.0, 12.0, 0.0).expect("path around tree");
for (x, y) in &path {
let near_tree = (*x - 15.0).abs() < 1.0 && (*y - 12.0).abs() < 1.0;
assert!(!near_tree, "path should not cut through tree at ({x},{y})");
}
}
#[test]
fn path_routes_around_building_with_clearance() {
let mut state = empty_state();
state.buildings.push(flatland_protocol::BuildingView {
id: "hut".into(),
label: "Hut".into(),
x: 20.0,
y: 20.0,
width_m: 6.0,
depth_m: 6.0,
interior_blueprint: None,
tags: vec![],
});
let path =
find_path(&state, 10.0, 20.0, 0.0, 30.0, 20.0, 0.0).expect("path around building");
assert!(!path.is_empty());
let pad = PLAYER_RADIUS_M + PATH_CLEARANCE_M;
let x0 = 20.0 - 3.0 - pad;
let x1 = 20.0 + 3.0 + pad;
let y0 = 20.0 - 3.0 - pad;
let y1 = 20.0 + 3.0 + pad;
for (x, y) in &path {
let inside = *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1;
assert!(
!inside,
"path waypoint ({x},{y}) intersects inflated building footprint"
);
}
let last = *path.last().unwrap();
assert!((last.0 - 30.0).abs() < 2.0, "should reach far side");
}
#[test]
fn auto_navigator_finishes_near_goal() {
let state = empty_state();
let mut nav = AutoNavigator::plan(&state, 14.0, 12.0).expect("plan");
let (fx, fy, fz) = state.player_position_with_z();
let steer = nav.steer(fx, fy, fz, &state).expect("steer");
assert!(steer.0.abs() + steer.1.abs() + steer.2.abs() > 0.0);
}
}