1use flatland_protocol::ResourceNodeState;
4
5use crate::npc_states::{
6 resolve_placed_npc_presentation_key, resolve_wildlife_presentation_key,
7 PlacedNpcPresentationInput, WildlifeNpcPresentationInput,
8};
9use crate::player_states::{resolve_player_presentation_key, PlayerPresentationInput};
10use crate::validate::mode_exists_on_sheet;
11use crate::{SpriteCatalog, SpriteModeMap};
12
13pub const RESOURCE_PRESENTATION_KEYS: &[&str] = &["available", "harvesting", "depleted"];
15
16pub const RESOURCE_PRESENTATION_KEY_COOLDOWN_LEGACY: &str = "cooldown";
18
19pub fn resource_presentation_key(state: ResourceNodeState) -> &'static str {
20 match state {
21 ResourceNodeState::Available => "available",
22 ResourceNodeState::Harvesting => "harvesting",
23 ResourceNodeState::Cooldown => "depleted",
24 }
25}
26
27pub fn resolve_resource_sprite_mode(
29 state: ResourceNodeState,
30 sprite_modes: &SpriteModeMap,
31 sheet_modes: &[String],
32) -> Option<String> {
33 let key = resource_presentation_key(state);
34 pick_mode(key, sprite_modes, sheet_modes)
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ResourceSpriteResolution {
40 pub tile_id: String,
41 pub mode_id: String,
42}
43
44pub fn resolve_resource_sprite(
50 tile_id: Option<&str>,
51 state: ResourceNodeState,
52 sprite_modes: &SpriteModeMap,
53 catalog: &SpriteCatalog,
54) -> Option<ResourceSpriteResolution> {
55 let key = resource_presentation_key(state);
56 let defaults = &catalog.presentation_defaults().resource;
57
58 if let Some(source_tile_id) = tile_id.filter(|id| !id.is_empty()) {
59 let sheet_modes = catalog.mode_ids(source_tile_id);
60 for mode_id in [
61 sprite_modes.get(key).map(String::as_str),
62 defaults.modes.get(key).map(String::as_str),
63 ]
64 .into_iter()
65 .flatten()
66 {
67 if mode_exists_on_sheet(mode_id, sheet_modes) {
68 return Some(ResourceSpriteResolution {
69 tile_id: source_tile_id.to_string(),
70 mode_id: mode_id.to_string(),
71 });
72 }
73 }
74
75 let alias = match key {
76 "available" => ["idle", ""],
77 "harvesting" => ["harvest", "idle"],
78 "depleted" => ["depleted", "cooldown"],
79 _ => ["", ""],
80 };
81 for mode_id in alias.into_iter().filter(|mode| !mode.is_empty()) {
82 if mode_exists_on_sheet(mode_id, sheet_modes) {
83 return Some(ResourceSpriteResolution {
84 tile_id: source_tile_id.to_string(),
85 mode_id: mode_id.to_string(),
86 });
87 }
88 }
89
90 if key != "depleted" {
91 if let Some(mode_id) = sheet_modes.first().filter(|mode| !mode.contains('_')) {
92 return Some(ResourceSpriteResolution {
93 tile_id: source_tile_id.to_string(),
94 mode_id: mode_id.clone(),
95 });
96 }
97 }
98 }
99
100 let fallback = defaults.fallbacks.get(key)?;
101 if mode_exists_on_sheet(&fallback.mode_id, catalog.mode_ids(&fallback.tile_id)) {
102 Some(ResourceSpriteResolution {
103 tile_id: fallback.tile_id.clone(),
104 mode_id: fallback.mode_id.clone(),
105 })
106 } else {
107 None
108 }
109}
110
111pub struct NpcPresentationInput<'a> {
113 pub presentation_key: &'a str,
115 pub sprite_modes: &'a SpriteModeMap,
116 pub sheet_modes: &'a [String],
117}
118
119pub fn npc_presentation_key<'a>(input: &'a NpcPresentationInput<'a>) -> &'a str {
120 input.presentation_key
121}
122
123pub fn resolve_npc_sprite_mode(input: &NpcPresentationInput<'_>) -> Option<String> {
125 let key = npc_presentation_key(input);
126 pick_mode(key, input.sprite_modes, input.sheet_modes)
127}
128
129pub fn wildlife_npc_presentation_input<'a>(
131 ctx: WildlifeNpcPresentationInput<'a>,
132 sprite_modes: &'a SpriteModeMap,
133 sheet_modes: &'a [String],
134) -> NpcPresentationInput<'a> {
135 let key = resolve_wildlife_presentation_key(ctx);
136 NpcPresentationInput {
137 presentation_key: key,
138 sprite_modes,
139 sheet_modes,
140 }
141}
142
143pub fn placed_npc_presentation_input<'a>(
145 ctx: PlacedNpcPresentationInput<'a>,
146 sprite_modes: &'a SpriteModeMap,
147 sheet_modes: &'a [String],
148) -> NpcPresentationInput<'a> {
149 let key = resolve_placed_npc_presentation_key(ctx);
150 NpcPresentationInput {
151 presentation_key: key,
152 sprite_modes,
153 sheet_modes,
154 }
155}
156
157pub struct PlayerSpritePresentationInput<'a> {
159 pub presentation_key: &'a str,
160 pub sprite_modes: &'a SpriteModeMap,
161 pub sheet_modes: &'a [String],
162}
163
164pub fn player_presentation_input<'a>(
165 ctx: PlayerPresentationInput,
166 sprite_modes: &'a SpriteModeMap,
167 sheet_modes: &'a [String],
168) -> PlayerSpritePresentationInput<'a> {
169 let key = resolve_player_presentation_key(ctx);
170 PlayerSpritePresentationInput {
171 presentation_key: key,
172 sprite_modes,
173 sheet_modes,
174 }
175}
176
177pub fn presentation_sheet_mode_ids(
179 catalog: &crate::catalog::SpriteCatalog,
180 tile_id: &str,
181 paperdoll_ref: Option<&str>,
182) -> Vec<String> {
183 if let Some(skin_id) = paperdoll_ref.filter(|s| !s.is_empty()) {
184 let sheet = format!("paperdoll.{skin_id}");
185 let modes: Vec<String> = catalog.mode_ids(&sheet).iter().cloned().collect();
186 if !modes.is_empty() {
187 return modes;
188 }
189 }
190 catalog.mode_ids(tile_id).iter().cloned().collect()
191}
192
193pub fn resolve_player_sprite_mode(input: &PlayerSpritePresentationInput<'_>) -> Option<String> {
195 pick_mode(
196 input.presentation_key,
197 input.sprite_modes,
198 input.sheet_modes,
199 )
200}
201
202pub fn resolve_farm_growth_sprite_mode(
205 growth_mode: &str,
206 sheet_modes: &[String],
207) -> Option<String> {
208 const STAGES: &[&str] = &["grow_10", "grow_20", "grow_60", "grow_100"];
209 let Some(idx) = STAGES.iter().position(|s| *s == growth_mode) else {
210 return None;
211 };
212 for i in (0..=idx).rev() {
213 let stage = STAGES[i];
214 if mode_exists_on_sheet(stage, sheet_modes) {
215 return Some(stage.to_string());
216 }
217 }
218 for alt in ["idle", "harvest", "depleted"] {
219 if mode_exists_on_sheet(alt, sheet_modes) {
220 return Some(alt.to_string());
221 }
222 }
223 sheet_modes.first().cloned()
224}
225
226fn pick_mode(
227 presentation_key: &str,
228 sprite_modes: &SpriteModeMap,
229 sheet_modes: &[String],
230) -> Option<String> {
231 if let Some(mode) = sprite_modes.get(presentation_key) {
232 if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
233 return Some(mode.clone());
234 }
235 return Some(mode.clone());
238 }
239 if sheet_modes.iter().any(|m| m == presentation_key) {
240 return Some(presentation_key.to_string());
241 }
242 let resource_alias = match presentation_key {
244 "available" => sheet_modes
245 .iter()
246 .find(|m| **m == "idle" || m.starts_with("idle_"))
247 .map(|m| {
248 if m.starts_with("idle_") {
249 "idle".to_string()
250 } else {
251 m.clone()
252 }
253 }),
254 "harvesting" => sheet_modes.iter().find(|m| *m == "harvest").cloned(),
255 "depleted" | "cooldown" => sheet_modes
256 .iter()
257 .find(|m| *m == "depleted" || *m == "cooldown")
258 .cloned(),
259 _ => None,
260 };
261 if let Some(mode) = resource_alias {
262 return Some(mode);
263 }
264 if presentation_key == "cooldown" {
266 if let Some(mode) = sprite_modes.get("depleted") {
267 if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
268 return Some(mode.clone());
269 }
270 }
271 }
272 if let Some(sheet_id) = crate::validate::npc_sheet_alias(presentation_key, sheet_modes) {
274 return Some(sheet_id);
275 }
276 sprite_modes
277 .get(presentation_key)
278 .filter(|m| mode_exists_on_sheet(m, sheet_modes))
279 .cloned()
280 .or_else(|| sheet_modes.first().filter(|m| !m.contains('_')).cloned())
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn modes(ids: &[&str]) -> Vec<String> {
288 ids.iter().map(|s| s.to_string()).collect()
289 }
290
291 #[test]
292 fn resource_explicit_map() {
293 let mut map = SpriteModeMap::new();
294 map.insert("available".into(), "idle".into());
295 map.insert("harvesting".into(), "harvest".into());
296 map.insert("depleted".into(), "depleted".into());
297 let sheet = modes(&["idle", "harvest", "depleted"]);
298 assert_eq!(
299 resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
300 Some("harvest")
301 );
302 }
303
304 #[test]
305 fn resource_alias_fallback() {
306 let map = SpriteModeMap::new();
307 let sheet = modes(&["idle", "harvest"]);
308 assert_eq!(
309 resolve_resource_sprite_mode(ResourceNodeState::Available, &map, &sheet).as_deref(),
310 Some("idle")
311 );
312 assert_eq!(
313 resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
314 Some("harvest")
315 );
316 }
317
318 #[test]
319 fn resource_defaults_use_shared_depleted_fallback() {
320 let mut catalog = SpriteCatalog::default();
321 catalog.register_sheet_modes("resource.depleted", vec!["depleted".into()]);
322 let mut defaults = catalog.presentation_defaults().clone();
323 defaults.resource.fallbacks.insert(
324 "depleted".into(),
325 crate::SpriteFallback {
326 tile_id: "resource.depleted".into(),
327 mode_id: "depleted".into(),
328 },
329 );
330 catalog.set_presentation_defaults(defaults);
331
332 let result = resolve_resource_sprite(
333 Some("resource.oak_log"),
334 ResourceNodeState::Cooldown,
335 &SpriteModeMap::new(),
336 &catalog,
337 );
338 assert_eq!(
339 result,
340 Some(ResourceSpriteResolution {
341 tile_id: "resource.depleted".into(),
342 mode_id: "depleted".into(),
343 })
344 );
345 }
346
347 #[test]
348 fn resource_override_wins_when_mode_exists() {
349 let mut catalog = SpriteCatalog::default();
350 catalog.register_sheet_modes(
351 "resource.test",
352 vec![
353 "idle".into(),
354 "harvest_custom".into(),
355 "depleted_custom".into(),
356 ],
357 );
358 let mut map = SpriteModeMap::new();
359 map.insert("harvesting".into(), "harvest_custom".into());
360
361 let result = resolve_resource_sprite(
362 Some("resource.test"),
363 ResourceNodeState::Harvesting,
364 &map,
365 &catalog,
366 );
367 assert_eq!(
368 result,
369 Some(ResourceSpriteResolution {
370 tile_id: "resource.test".into(),
371 mode_id: "harvest_custom".into(),
372 })
373 );
374 }
375
376 #[test]
377 fn farm_growth_falls_back_to_lower_stage() {
378 let sheet = modes(&["grow_10", "grow_60", "idle"]);
379 assert_eq!(
380 resolve_farm_growth_sprite_mode("grow_100", &sheet).as_deref(),
381 Some("grow_60")
382 );
383 assert_eq!(
384 resolve_farm_growth_sprite_mode("grow_20", &sheet).as_deref(),
385 Some("grow_10")
386 );
387 }
388
389 #[test]
390 fn farm_growth_falls_back_to_idle() {
391 let sheet = modes(&["idle", "harvest"]);
392 assert_eq!(
393 resolve_farm_growth_sprite_mode("grow_60", &sheet).as_deref(),
394 Some("idle")
395 );
396 }
397
398 #[test]
399 fn npc_telegraph_presentation_key() {
400 let map = SpriteModeMap::new();
401 let sheet = modes(&["idle", "combat", "telegraph"]);
402 let input = NpcPresentationInput {
403 presentation_key: "telegraph",
404 sprite_modes: &map,
405 sheet_modes: &sheet,
406 };
407 assert_eq!(
408 resolve_npc_sprite_mode(&input).as_deref(),
409 Some("telegraph")
410 );
411 }
412
413 #[test]
414 fn npc_pursue_maps_to_chase_mode() {
415 let mut map = SpriteModeMap::new();
416 map.insert("pursue".into(), "chase".into());
417 let sheet = modes(&["idle", "chase", "combat"]);
418 let input = wildlife_npc_presentation_input(
419 WildlifeNpcPresentationInput {
420 fsm_state: "chase",
421 telegraph_active: false,
422 has_target: true,
423 in_attack_range: false,
424 is_moving: true,
425 fast_movement: true,
426 },
427 &map,
428 &sheet,
429 );
430 assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
431 assert_eq!(input.presentation_key, "pursue");
432 }
433
434 #[test]
435 fn npc_legacy_behavior_alias() {
436 let map = SpriteModeMap::new();
437 let sheet = modes(&["idle", "chase", "combat"]);
438 let input = NpcPresentationInput {
439 presentation_key: "chase",
440 sprite_modes: &map,
441 sheet_modes: &sheet,
442 };
443 assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
444 }
445
446 #[test]
447 fn player_directional_sheet_returns_base_modes() {
448 let mut map = SpriteModeMap::new();
449 map.insert("idle".into(), "idle".into());
450 map.insert("walking".into(), "walk".into());
451 map.insert("sprinting".into(), "run".into());
452 let sheet = modes(&[
453 "idle_north",
454 "idle_south",
455 "walk_north",
456 "walk_east",
457 "run_south",
458 "combat",
459 ]);
460
461 let idle = player_presentation_input(
462 PlayerPresentationInput {
463 harvesting: false,
464 crafting: false,
465 talking: false,
466 chatting: false,
467 in_combat: false,
468 pursue: false,
469 in_attack_range: false,
470 is_moving: false,
471 sprinting: false,
472 telegraph_active: false,
473 },
474 &map,
475 &sheet,
476 );
477 assert_eq!(resolve_player_sprite_mode(&idle).as_deref(), Some("idle"));
478
479 let walking = player_presentation_input(
480 PlayerPresentationInput {
481 harvesting: false,
482 crafting: false,
483 talking: false,
484 chatting: false,
485 in_combat: false,
486 pursue: false,
487 in_attack_range: false,
488 is_moving: true,
489 sprinting: false,
490 telegraph_active: false,
491 },
492 &map,
493 &sheet,
494 );
495 assert_eq!(
496 resolve_player_sprite_mode(&walking).as_deref(),
497 Some("walk")
498 );
499 }
500}