Skip to main content

flatland_presentation/
resolve.rs

1//! Map runtime game state → sprite sheet mode id.
2
3use 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::SpriteModeMap;
12
13/// Stable presentation keys for harvestable resource nodes (designer-facing).
14pub const RESOURCE_PRESENTATION_KEYS: &[&str] = &["available", "harvesting", "depleted"];
15
16/// Legacy key still accepted in item `sprite_modes` YAML.
17pub 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
27/// Resolve gfx mode for a resource node view.
28pub 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/// Inputs for NPC wildlife presentation (keeps this crate free of sim types).
38pub struct NpcPresentationInput<'a> {
39    /// Canonical presentation key (`combat`, `pursue`, `walking`, …).
40    pub presentation_key: &'a str,
41    pub sprite_modes: &'a SpriteModeMap,
42    pub sheet_modes: &'a [String],
43}
44
45pub fn npc_presentation_key<'a>(input: &'a NpcPresentationInput<'a>) -> &'a str {
46    input.presentation_key
47}
48
49/// Resolve gfx mode for an NPC view.
50pub fn resolve_npc_sprite_mode(input: &NpcPresentationInput<'_>) -> Option<String> {
51    let key = npc_presentation_key(input);
52    pick_mode(key, input.sprite_modes, input.sheet_modes)
53}
54
55/// Build presentation input for wildlife from sim context.
56pub fn wildlife_npc_presentation_input<'a>(
57    ctx: WildlifeNpcPresentationInput<'a>,
58    sprite_modes: &'a SpriteModeMap,
59    sheet_modes: &'a [String],
60) -> NpcPresentationInput<'a> {
61    let key = resolve_wildlife_presentation_key(ctx);
62    NpcPresentationInput {
63        presentation_key: key,
64        sprite_modes,
65        sheet_modes,
66    }
67}
68
69/// Build presentation input for placed (town/building) NPCs.
70pub fn placed_npc_presentation_input<'a>(
71    ctx: PlacedNpcPresentationInput<'a>,
72    sprite_modes: &'a SpriteModeMap,
73    sheet_modes: &'a [String],
74) -> NpcPresentationInput<'a> {
75    let key = resolve_placed_npc_presentation_key(ctx);
76    NpcPresentationInput {
77        presentation_key: key,
78        sprite_modes,
79        sheet_modes,
80    }
81}
82
83/// Inputs for player sprite resolution.
84pub struct PlayerSpritePresentationInput<'a> {
85    pub presentation_key: &'a str,
86    pub sprite_modes: &'a SpriteModeMap,
87    pub sheet_modes: &'a [String],
88}
89
90pub fn player_presentation_input<'a>(
91    ctx: PlayerPresentationInput,
92    sprite_modes: &'a SpriteModeMap,
93    sheet_modes: &'a [String],
94) -> PlayerSpritePresentationInput<'a> {
95    let key = resolve_player_presentation_key(ctx);
96    PlayerSpritePresentationInput {
97        presentation_key: key,
98        sprite_modes,
99        sheet_modes,
100    }
101}
102
103/// Resolve gfx mode for a player entity view.
104pub fn resolve_player_sprite_mode(input: &PlayerSpritePresentationInput<'_>) -> Option<String> {
105    pick_mode(
106        input.presentation_key,
107        input.sprite_modes,
108        input.sheet_modes,
109    )
110}
111
112fn pick_mode(
113    presentation_key: &str,
114    sprite_modes: &SpriteModeMap,
115    sheet_modes: &[String],
116) -> Option<String> {
117    if let Some(mode) = sprite_modes.get(presentation_key) {
118        if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
119            return Some(mode.clone());
120        }
121    }
122    if sheet_modes.iter().any(|m| m == presentation_key) {
123        return Some(presentation_key.to_string());
124    }
125    // Resource state aliases when sprite_modes omitted.
126    let resource_alias = match presentation_key {
127        "available" => sheet_modes
128            .iter()
129            .find(|m| **m == "idle" || m.starts_with("idle_"))
130            .map(|m| {
131                if m.starts_with("idle_") {
132                    "idle".to_string()
133                } else {
134                    m.clone()
135                }
136            }),
137        "harvesting" => sheet_modes.iter().find(|m| *m == "harvest").cloned(),
138        "depleted" | "cooldown" => sheet_modes
139            .iter()
140            .find(|m| *m == "depleted" || *m == "cooldown")
141            .cloned(),
142        _ => None,
143    };
144    if let Some(mode) = resource_alias {
145        return Some(mode);
146    }
147    // Legacy item YAML may still use `cooldown` as presentation key.
148    if presentation_key == "cooldown" {
149        if let Some(mode) = sprite_modes.get("depleted") {
150            if sheet_modes.is_empty() || mode_exists_on_sheet(mode, sheet_modes) {
151                return Some(mode.clone());
152            }
153        }
154    }
155    // Canonical NPC keys → common sheet mode ids when sprite_modes omitted.
156    if let Some(sheet_id) = npc_sheet_alias(presentation_key, sheet_modes) {
157        return Some(sheet_id);
158    }
159    sprite_modes
160        .get(presentation_key)
161        .filter(|m| mode_exists_on_sheet(m, sheet_modes))
162        .cloned()
163        .or_else(|| sheet_modes.first().filter(|m| !m.contains('_')).cloned())
164}
165
166fn npc_sheet_alias(presentation_key: &str, sheet_modes: &[String]) -> Option<String> {
167    let candidates: &[&str] = match presentation_key {
168        "pursue" => &["chase", "alert", "run"],
169        "running" => &["chase", "run"],
170        "walking" => &["walk", "idle"],
171        "combat" => &["combat", "attack"],
172        "rest" => &["idle", "sleep", "rest"],
173        "work" => &["work", "idle"],
174        "patrol" => &["walk", "patrol", "idle"],
175        "talking" => &["talk", "idle"],
176        "chatting" => &["chat", "talk"],
177        "idle" => &["idle"],
178        "telegraph" => &["telegraph"],
179        "harvesting" => &["harvest"],
180        "crafting" => &["craft", "work"],
181        "sprinting" => &["run", "sprint", "chase"],
182        _ => &[],
183    };
184    candidates
185        .iter()
186        .find(|c| mode_exists_on_sheet(c, sheet_modes))
187        .map(|c| c.to_string())
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::collections::HashMap;
194
195    fn modes(ids: &[&str]) -> Vec<String> {
196        ids.iter().map(|s| s.to_string()).collect()
197    }
198
199    #[test]
200    fn resource_explicit_map() {
201        let mut map = SpriteModeMap::new();
202        map.insert("available".into(), "idle".into());
203        map.insert("harvesting".into(), "harvest".into());
204        map.insert("depleted".into(), "depleted".into());
205        let sheet = modes(&["idle", "harvest", "depleted"]);
206        assert_eq!(
207            resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
208            Some("harvest")
209        );
210    }
211
212    #[test]
213    fn resource_alias_fallback() {
214        let map = SpriteModeMap::new();
215        let sheet = modes(&["idle", "harvest"]);
216        assert_eq!(
217            resolve_resource_sprite_mode(ResourceNodeState::Available, &map, &sheet).as_deref(),
218            Some("idle")
219        );
220        assert_eq!(
221            resolve_resource_sprite_mode(ResourceNodeState::Harvesting, &map, &sheet).as_deref(),
222            Some("harvest")
223        );
224    }
225
226    #[test]
227    fn npc_telegraph_presentation_key() {
228        let map = SpriteModeMap::new();
229        let sheet = modes(&["idle", "combat", "telegraph"]);
230        let input = NpcPresentationInput {
231            presentation_key: "telegraph",
232            sprite_modes: &map,
233            sheet_modes: &sheet,
234        };
235        assert_eq!(
236            resolve_npc_sprite_mode(&input).as_deref(),
237            Some("telegraph")
238        );
239    }
240
241    #[test]
242    fn npc_pursue_maps_to_chase_mode() {
243        let mut map = SpriteModeMap::new();
244        map.insert("pursue".into(), "chase".into());
245        let sheet = modes(&["idle", "chase", "combat"]);
246        let input = wildlife_npc_presentation_input(
247            WildlifeNpcPresentationInput {
248                fsm_state: "chase",
249                telegraph_active: false,
250                has_target: true,
251                in_attack_range: false,
252                is_moving: true,
253                fast_movement: true,
254            },
255            &map,
256            &sheet,
257        );
258        assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
259        assert_eq!(input.presentation_key, "pursue");
260    }
261
262    #[test]
263    fn npc_legacy_behavior_alias() {
264        let map = SpriteModeMap::new();
265        let sheet = modes(&["idle", "chase", "combat"]);
266        let input = NpcPresentationInput {
267            presentation_key: "chase",
268            sprite_modes: &map,
269            sheet_modes: &sheet,
270        };
271        assert_eq!(resolve_npc_sprite_mode(&input).as_deref(), Some("chase"));
272    }
273
274    #[test]
275    fn player_directional_sheet_returns_base_modes() {
276        let mut map = SpriteModeMap::new();
277        map.insert("idle".into(), "idle".into());
278        map.insert("walking".into(), "walk".into());
279        map.insert("sprinting".into(), "run".into());
280        let sheet = modes(&[
281            "idle_north",
282            "idle_south",
283            "walk_north",
284            "walk_east",
285            "run_south",
286            "combat",
287        ]);
288
289        let idle = player_presentation_input(
290            PlayerPresentationInput {
291                harvesting: false,
292                crafting: false,
293                talking: false,
294                chatting: false,
295                in_combat: false,
296                pursue: false,
297                in_attack_range: false,
298                is_moving: false,
299                sprinting: false,
300            },
301            &map,
302            &sheet,
303        );
304        assert_eq!(resolve_player_sprite_mode(&idle).as_deref(), Some("idle"));
305
306        let walking = player_presentation_input(
307            PlayerPresentationInput {
308                harvesting: false,
309                crafting: false,
310                talking: false,
311                chatting: false,
312                in_combat: false,
313                pursue: false,
314                in_attack_range: false,
315                is_moving: true,
316                sprinting: false,
317            },
318            &map,
319            &sheet,
320        );
321        assert_eq!(
322            resolve_player_sprite_mode(&walking).as_deref(),
323            Some("walk")
324        );
325    }
326}