1use crate::game::{GameState, CONTAINER_RANGE_M};
4
5const INTERACTION_RADIUS_M: f32 = 1.5;
6const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
7const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
8const CHEST_PICKUP_RANGE_M: f32 = 2.0;
9const HARVEST_RANGE_M: f32 = 1.5;
10pub const USE_WORLD_NEARBY_SCAN_M: f32 = 5.0;
12
13fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14 (ax - bx).hypot(ay - by)
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum UseWorldKind {
20 Npc,
21 QuestBoard,
22 ExitDoor,
23 EnterDoor,
24 Well,
25 Water,
26 Loot,
27 ChestPickup,
28 Harvest,
29}
30
31impl UseWorldKind {
32 pub fn verb(self) -> &'static str {
33 match self {
34 Self::Npc => "Talk/Trade",
35 Self::QuestBoard => "Read board",
36 Self::ExitDoor => "Exit",
37 Self::EnterDoor => "Enter",
38 Self::Well => "Use well",
39 Self::Water => "Fill water",
40 Self::Loot => "Pick up",
41 Self::ChestPickup => "Pick up chest",
42 Self::Harvest => "Harvest",
43 }
44 }
45
46 pub fn interact_priority(self) -> u8 {
48 match self {
49 Self::Npc => 0,
50 Self::QuestBoard => 1,
51 Self::ExitDoor => 2,
52 Self::EnterDoor => 3,
53 Self::Well => 4,
54 Self::Water => 5,
55 Self::Loot => 10,
56 Self::ChestPickup => 11,
57 Self::Harvest => 12,
58 }
59 }
60
61 pub fn cascade_stage(self) -> u8 {
63 match self {
64 Self::Npc
65 | Self::QuestBoard
66 | Self::ExitDoor
67 | Self::EnterDoor
68 | Self::Well
69 | Self::Water => 0,
70 Self::Loot => 1,
71 Self::ChestPickup => 2,
72 Self::Harvest => 3,
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq)]
78pub struct UseWorldCandidate {
79 pub id: String,
80 pub kind: UseWorldKind,
81 pub label: String,
82 pub x: f32,
83 pub y: f32,
84 pub distance_m: f32,
85 pub range_m: f32,
86 pub in_range: bool,
88}
89
90impl UseWorldCandidate {
91 pub fn hint_line(&self) -> String {
92 format!(
93 "f → {} {} ({:.1}m)",
94 self.kind.verb(),
95 self.label,
96 self.distance_m
97 )
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Default)]
102pub struct UseWorldProbe {
103 pub primary: Option<UseWorldCandidate>,
105 pub candidates: Vec<UseWorldCandidate>,
107}
108
109impl UseWorldProbe {
110 pub fn hint_line(&self) -> String {
111 match &self.primary {
112 Some(c) => c.hint_line(),
113 None => "f → nothing in range".into(),
114 }
115 }
116}
117
118impl GameState {
119 pub fn probe_use_world(&self) -> UseWorldProbe {
121 let (px, py) = self.player_position();
122 let inside = self.effective_inside_building();
123 let mut candidates: Vec<UseWorldCandidate> = Vec::new();
124
125 let push = |list: &mut Vec<UseWorldCandidate>,
126 id: String,
127 kind: UseWorldKind,
128 label: String,
129 x: f32,
130 y: f32,
131 range_m: f32| {
132 let distance_m = distance(px, py, x, y);
133 if distance_m > USE_WORLD_NEARBY_SCAN_M {
134 return;
135 }
136 list.push(UseWorldCandidate {
137 id,
138 kind,
139 label,
140 x,
141 y,
142 distance_m,
143 range_m,
144 in_range: distance_m <= range_m,
145 });
146 };
147
148 for npc in &self.npcs {
149 push(
150 &mut candidates,
151 npc.id.clone(),
152 UseWorldKind::Npc,
153 npc.label.clone(),
154 npc.x,
155 npc.y,
156 INTERACTION_RADIUS_M,
157 );
158 }
159
160 for door in &self.doors {
161 if let Some(ref bid) = inside {
162 if door.building_id != *bid {
163 continue;
164 }
165 let is_exit = door.portal.is_some();
166 let (kind, range) = if is_exit {
167 (UseWorldKind::ExitDoor, INTERACTION_RADIUS_M)
168 } else {
169 (UseWorldKind::EnterDoor, DOOR_INTERACTION_RADIUS_M)
170 };
171 push(
172 &mut candidates,
173 door.id.clone(),
174 kind,
175 if is_exit {
176 "Exit".into()
177 } else {
178 format!("Door ({})", door.building_id)
179 },
180 door.x,
181 door.y,
182 range,
183 );
184 continue;
185 }
186 push(
187 &mut candidates,
188 door.id.clone(),
189 UseWorldKind::EnterDoor,
190 format!("Door ({})", door.building_id),
191 door.x,
192 door.y,
193 DOOR_INTERACTION_RADIUS_M,
194 );
195 }
196
197 if inside.is_none() {
198 for inter in &self.interactables {
199 if inter.kind == "quest_board" {
200 push(
201 &mut candidates,
202 inter.id.clone(),
203 UseWorldKind::QuestBoard,
204 inter.label.clone(),
205 inter.x,
206 inter.y,
207 QUEST_BOARD_INTERACTION_RADIUS_M,
208 );
209 }
210 }
211 for building in &self.buildings {
212 if !building.tags.iter().any(|t| t == "well") {
213 continue;
214 }
215 push(
216 &mut candidates,
217 building.id.clone(),
218 UseWorldKind::Well,
219 building.label.clone(),
220 building.x,
221 building.y,
222 INTERACTION_RADIUS_M,
223 );
224 }
225 if self.in_shallow_water() {
226 push(
227 &mut candidates,
228 "water_source".into(),
229 UseWorldKind::Water,
230 "Shallow water".into(),
231 px,
232 py,
233 INTERACTION_RADIUS_M,
234 );
235 }
236 }
237
238 for drop in &self.ground_drops {
239 let label = if drop.quantity > 1 {
240 format!("{} ×{}", drop.template_id, drop.quantity)
241 } else {
242 drop.template_id.clone()
243 };
244 push(
245 &mut candidates,
246 drop.id.clone(),
247 UseWorldKind::Loot,
248 label,
249 drop.x,
250 drop.y,
251 INTERACTION_RADIUS_M,
252 );
253 }
254
255 for chest in &self.placed_containers {
256 let _browse = CONTAINER_RANGE_M;
257 push(
258 &mut candidates,
259 chest.id.clone(),
260 UseWorldKind::ChestPickup,
261 chest.display_name.clone(),
262 chest.x,
263 chest.y,
264 CHEST_PICKUP_RANGE_M,
265 );
266 }
267
268 for node in &self.resource_nodes {
269 if !matches!(
270 node.state,
271 flatland_protocol::ResourceNodeState::Available
272 | flatland_protocol::ResourceNodeState::Harvesting
273 ) {
274 continue;
275 }
276 push(
277 &mut candidates,
278 node.id.clone(),
279 UseWorldKind::Harvest,
280 node.label.clone(),
281 node.x,
282 node.y,
283 HARVEST_RANGE_M,
284 );
285 }
286
287 let primary = pick_primary(&candidates);
288
289 UseWorldProbe {
290 primary,
291 candidates,
292 }
293 }
294}
295
296fn pick_primary(candidates: &[UseWorldCandidate]) -> Option<UseWorldCandidate> {
297 let in_range: Vec<&UseWorldCandidate> = candidates.iter().filter(|c| c.in_range).collect();
298 if in_range.is_empty() {
299 return None;
300 }
301
302 let has_interact = in_range.iter().any(|c| c.kind.cascade_stage() == 0);
303 let stage = if has_interact {
304 0
305 } else if in_range.iter().any(|c| c.kind == UseWorldKind::Loot) {
306 1
307 } else if in_range.iter().any(|c| c.kind == UseWorldKind::ChestPickup) {
308 2
309 } else {
310 3
311 };
312
313 let mut best: Option<&UseWorldCandidate> = None;
314 for c in in_range.into_iter().filter(|c| c.kind.cascade_stage() == stage) {
315 let replace = match best {
316 None => true,
317 Some(b) if c.distance_m < b.distance_m - 0.05 => true,
318 Some(b) if (c.distance_m - b.distance_m).abs() <= 0.05 => {
319 c.kind.interact_priority() < b.kind.interact_priority()
320 }
321 _ => false,
322 };
323 if replace {
324 best = Some(c);
325 }
326 }
327 best.cloned()
328}