Skip to main content

flatland_presentation/
catalog.rs

1//! Load gfx sprite sheet mode lists from `assets/gfx/sprites/`.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use std::collections::BTreeMap;
9
10fn default_resource_mode_defaults() -> BTreeMap<String, String> {
11    BTreeMap::from([
12        ("available".into(), "idle".into()),
13        ("harvesting".into(), "harvest".into()),
14        ("depleted".into(), "depleted".into()),
15    ])
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SpriteFallback {
20    pub tile_id: String,
21    pub mode_id: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ResourcePresentationDefaults {
26    #[serde(default = "default_resource_mode_defaults")]
27    pub modes: BTreeMap<String, String>,
28    #[serde(default)]
29    pub fallbacks: BTreeMap<String, SpriteFallback>,
30    /// When the source `tile_id` ends with a key (e.g. `_tree`), use that
31    /// presentation-key → sheet fallback before the generic `fallbacks` map.
32    #[serde(default)]
33    pub sheet_suffix_fallbacks: BTreeMap<String, BTreeMap<String, SpriteFallback>>,
34}
35
36impl Default for ResourcePresentationDefaults {
37    fn default() -> Self {
38        Self {
39            modes: default_resource_mode_defaults(),
40            fallbacks: BTreeMap::new(),
41            sheet_suffix_fallbacks: BTreeMap::new(),
42        }
43    }
44}
45
46/// Shared farm crop growth / harvested stages when a crop sheet omits them.
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct FarmPresentationDefaults {
49    /// Sheet with `grow_10` / `grow_20` / `grow_60` / `harvested` (and similar).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub growth_fallback: Option<FarmGrowthFallback>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct FarmGrowthFallback {
56    pub tile_id: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SpritePresentationDefaults {
61    #[serde(default = "default_presentation_version")]
62    pub version: u32,
63    #[serde(default)]
64    pub resource: ResourcePresentationDefaults,
65    #[serde(default)]
66    pub farm: FarmPresentationDefaults,
67}
68
69fn default_presentation_version() -> u32 {
70    1
71}
72
73impl Default for SpritePresentationDefaults {
74    fn default() -> Self {
75        Self {
76            version: default_presentation_version(),
77            resource: ResourcePresentationDefaults::default(),
78            farm: FarmPresentationDefaults::default(),
79        }
80    }
81}
82
83#[derive(Debug, Clone, Deserialize)]
84struct SpriteManifest {
85    #[allow(dead_code)]
86    version: u32,
87    sheets: Vec<SpriteManifestEntry>,
88}
89
90#[derive(Debug, Clone, Deserialize)]
91struct SpriteManifestEntry {
92    #[allow(dead_code)]
93    id: String,
94    file: String,
95}
96
97#[derive(Debug, Clone, Deserialize)]
98struct SpriteSheetDef {
99    id: String,
100    modes: Vec<SpriteModeDef>,
101}
102
103#[derive(Debug, Clone, Deserialize)]
104struct SpriteModeDef {
105    id: String,
106}
107
108/// Mode ids for one sprite sheet.
109#[derive(Debug, Clone, Default)]
110pub struct SpriteSheetModes {
111    pub sheet_id: String,
112    pub mode_ids: Vec<String>,
113}
114
115/// Index of all sprite sheets and their mode ids.
116#[derive(Debug, Clone, Default)]
117pub struct SpriteCatalog {
118    sheets: HashMap<String, SpriteSheetModes>,
119    presentation_defaults: SpritePresentationDefaults,
120}
121
122impl SpriteCatalog {
123    pub fn mode_ids(&self, sheet_id: &str) -> &[String] {
124        self.sheets
125            .get(sheet_id)
126            .map(|s| s.mode_ids.as_slice())
127            .unwrap_or(&[])
128    }
129
130    pub fn sheet_ids(&self) -> impl Iterator<Item = &String> {
131        self.sheets.keys()
132    }
133
134    pub fn len(&self) -> usize {
135        self.sheets.len()
136    }
137
138    pub fn is_empty(&self) -> bool {
139        self.sheets.is_empty()
140    }
141
142    pub fn presentation_defaults(&self) -> &SpritePresentationDefaults {
143        &self.presentation_defaults
144    }
145
146    pub fn set_presentation_defaults(&mut self, defaults: SpritePresentationDefaults) {
147        self.presentation_defaults = defaults;
148    }
149
150    /// Register or replace mode ids for a sheet (e.g. gfx-baked `paperdoll.*` sheets).
151    pub fn register_sheet_modes(&mut self, sheet_id: impl Into<String>, mode_ids: Vec<String>) {
152        let sheet_id = sheet_id.into();
153        if mode_ids.is_empty() {
154            return;
155        }
156        self.sheets
157            .insert(sheet_id.clone(), SpriteSheetModes { sheet_id, mode_ids });
158    }
159}
160
161/// Load sprite mode index from `dir` (expects `manifest.yaml` + sheet YAML files).
162pub fn load_sprite_catalog(dir: &Path) -> anyhow::Result<SpriteCatalog> {
163    let manifest_path = dir.join("manifest.yaml");
164    if !manifest_path.is_file() {
165        return Ok(SpriteCatalog::default());
166    }
167    let text = std::fs::read_to_string(&manifest_path)?;
168    let manifest: SpriteManifest = serde_yaml::from_str(&text)?;
169    let presentation_defaults = load_sprite_presentation_defaults(dir)?;
170    let mut sheets = HashMap::new();
171
172    for entry in &manifest.sheets {
173        let sheet_path = dir.join(&entry.file);
174        let sheet_text = match std::fs::read_to_string(&sheet_path) {
175            Ok(t) => t,
176            Err(_) => continue,
177        };
178        let def: SpriteSheetDef = match serde_yaml::from_str(&sheet_text) {
179            Ok(d) => d,
180            Err(_) => continue,
181        };
182        let mode_ids: Vec<String> = def.modes.into_iter().map(|m| m.id).collect();
183        if mode_ids.is_empty() {
184            continue;
185        }
186        sheets.insert(
187            def.id.clone(),
188            SpriteSheetModes {
189                sheet_id: def.id,
190                mode_ids,
191            },
192        );
193    }
194
195    Ok(SpriteCatalog {
196        sheets,
197        presentation_defaults,
198    })
199}
200
201pub fn load_sprite_presentation_defaults(dir: &Path) -> anyhow::Result<SpritePresentationDefaults> {
202    let path = dir.join("presentation-defaults.yaml");
203    if !path.is_file() {
204        return Ok(SpritePresentationDefaults::default());
205    }
206    let text = std::fs::read_to_string(path)?;
207    Ok(serde_yaml::from_str(&text)?)
208}
209
210/// Resolve sprites directory from env, repo checkout, or synced install cache.
211pub fn default_sprites_dir() -> Option<PathBuf> {
212    if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
213        let path = PathBuf::from(p);
214        if path.join("manifest.yaml").is_file() {
215            return Some(path);
216        }
217    }
218    // Prefer the live repo tree when developing / validating content so new sheets
219    // (e.g. LoRA imports) are visible before the next client asset sync.
220    let mut dir = std::env::current_dir().ok()?;
221    for _ in 0..8 {
222        let candidate = dir.join("assets/gfx/sprites");
223        if candidate.join("manifest.yaml").is_file() {
224            return Some(candidate);
225        }
226        if !dir.pop() {
227            break;
228        }
229    }
230    if let Ok(home) = std::env::var("HOME") {
231        let current = PathBuf::from(home).join(".flatland3/assets/current");
232        if current.join("manifest.yaml").is_file() {
233            return Some(current);
234        }
235    }
236    None
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn loads_starter_sprite_catalog() {
245        let dir = default_sprites_dir().expect("sprites dir");
246        let cat = load_sprite_catalog(&dir).expect("load");
247        assert!(!cat.is_empty());
248        let modes = cat.mode_ids("resource.oak_log");
249        assert!(modes.contains(&"idle".to_string()));
250        assert_eq!(modes.len(), 1);
251    }
252}