use crate::game::{GameState, CONTAINER_RANGE_M};
const INTERACTION_RADIUS_M: f32 = 1.5;
const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
const CHEST_PICKUP_RANGE_M: f32 = 2.0;
const HARVEST_RANGE_M: f32 = 1.5;
pub const USE_WORLD_NEARBY_SCAN_M: f32 = 5.0;
fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
(ax - bx).hypot(ay - by)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UseWorldKind {
Npc,
QuestBoard,
ExitDoor,
EnterDoor,
Well,
Water,
Loot,
ChestPickup,
Harvest,
}
impl UseWorldKind {
pub fn verb(self) -> &'static str {
match self {
Self::Npc => "Talk/Trade",
Self::QuestBoard => "Read board",
Self::ExitDoor => "Exit",
Self::EnterDoor => "Enter",
Self::Well => "Use well",
Self::Water => "Fill water",
Self::Loot => "Pick up",
Self::ChestPickup => "Pick up chest",
Self::Harvest => "Harvest",
}
}
pub fn interact_priority(self) -> u8 {
match self {
Self::Npc => 0,
Self::QuestBoard => 1,
Self::ExitDoor => 2,
Self::EnterDoor => 3,
Self::Well => 4,
Self::Water => 5,
Self::Loot => 10,
Self::ChestPickup => 11,
Self::Harvest => 12,
}
}
pub fn cascade_stage(self) -> u8 {
match self {
Self::Npc
| Self::QuestBoard
| Self::ExitDoor
| Self::EnterDoor
| Self::Well
| Self::Water => 0,
Self::Loot => 1,
Self::ChestPickup => 2,
Self::Harvest => 3,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct UseWorldCandidate {
pub id: String,
pub kind: UseWorldKind,
pub label: String,
pub x: f32,
pub y: f32,
pub distance_m: f32,
pub range_m: f32,
pub in_range: bool,
}
impl UseWorldCandidate {
pub fn hint_line(&self) -> String {
format!(
"f → {} {} ({:.1}m)",
self.kind.verb(),
self.label,
self.distance_m
)
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct UseWorldProbe {
pub primary: Option<UseWorldCandidate>,
pub candidates: Vec<UseWorldCandidate>,
}
impl UseWorldProbe {
pub fn hint_line(&self) -> String {
match &self.primary {
Some(c) => c.hint_line(),
None => "f → nothing in range".into(),
}
}
}
impl GameState {
pub fn probe_use_world(&self) -> UseWorldProbe {
let (px, py) = self.player_position();
let inside = self.effective_inside_building();
let mut candidates: Vec<UseWorldCandidate> = Vec::new();
let push = |list: &mut Vec<UseWorldCandidate>,
id: String,
kind: UseWorldKind,
label: String,
x: f32,
y: f32,
range_m: f32| {
let distance_m = distance(px, py, x, y);
if distance_m > USE_WORLD_NEARBY_SCAN_M {
return;
}
list.push(UseWorldCandidate {
id,
kind,
label,
x,
y,
distance_m,
range_m,
in_range: distance_m <= range_m,
});
};
for npc in &self.npcs {
push(
&mut candidates,
npc.id.clone(),
UseWorldKind::Npc,
npc.label.clone(),
npc.x,
npc.y,
INTERACTION_RADIUS_M,
);
}
for door in &self.doors {
if let Some(ref bid) = inside {
if door.building_id != *bid {
continue;
}
let is_exit = door.portal.is_some();
let (kind, range) = if is_exit {
(UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
} else {
(UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
};
push(
&mut candidates,
door.id.clone(),
kind,
if is_exit {
"Exit".into()
} else {
format!("Door ({})", door.building_id)
},
door.x,
door.y,
range,
);
continue;
}
push(
&mut candidates,
door.id.clone(),
UseWorldKind::EnterDoor,
format!("Door ({})", door.building_id),
door.x,
door.y,
DOOR_INTERACTION_RADIUS_M,
);
}
if inside.is_none() {
for inter in &self.interactables {
if inter.kind == "quest_board" {
push(
&mut candidates,
inter.id.clone(),
UseWorldKind::QuestBoard,
inter.label.clone(),
inter.x,
inter.y,
QUEST_BOARD_INTERACTION_RADIUS_M,
);
}
}
for building in &self.buildings {
if !building.tags.iter().any(|t| t == "well") {
continue;
}
push(
&mut candidates,
building.id.clone(),
UseWorldKind::Well,
building.label.clone(),
building.x,
building.y,
INTERACTION_RADIUS_M,
);
}
if self.in_shallow_water() {
push(
&mut candidates,
"water_source".into(),
UseWorldKind::Water,
"Shallow water".into(),
px,
py,
INTERACTION_RADIUS_M,
);
}
}
for drop in &self.ground_drops {
let label = if drop.quantity > 1 {
format!("{} ×{}", drop.template_id, drop.quantity)
} else {
drop.template_id.clone()
};
push(
&mut candidates,
drop.id.clone(),
UseWorldKind::Loot,
label,
drop.x,
drop.y,
INTERACTION_RADIUS_M,
);
}
for chest in &self.placed_containers {
let _browse = CONTAINER_RANGE_M;
let mut label = chest.display_name.clone();
if let Some(who) = self.lodging_occupancy_label(&chest.id) {
label = format!("{label} ({who})");
}
push(
&mut candidates,
chest.id.clone(),
UseWorldKind::ChestPickup,
label,
chest.x,
chest.y,
CHEST_PICKUP_RANGE_M,
);
}
for node in &self.resource_nodes {
if !matches!(
node.state,
flatland_protocol::ResourceNodeState::Available
| flatland_protocol::ResourceNodeState::Harvesting
) {
continue;
}
push(
&mut candidates,
node.id.clone(),
UseWorldKind::Harvest,
node.label.clone(),
node.x,
node.y,
HARVEST_RANGE_M,
);
}
let primary = pick_primary(&candidates);
UseWorldProbe {
primary,
candidates,
}
}
}
fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
if in_range.is_empty() {
return None;
}
let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
let stage = if has_interact {
0
} else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
1
} else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
2
} else {
3
};
let mut best: Option<&UseWorldCandidate> = None;
for c in in_range
.into_iter()
.filter(|c| c.kind.cascade_stage() == stage)
{
let replace = match best {
None => true,
Some(b) if c.distance_m < b.distance_m - 0.05 => true,
Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
c.kind.interact_priority() < b.kind.interact_priority()
}
_ => false,
};
if replace {
best = Some(c);
}
}
best.cloned()
}