flatland-presentation 0.2.83

Gfx sprite mode resolution — maps game states to sprite sheet mode ids
Documentation
//! Load gfx sprite sheet mode lists from `assets/gfx/sprites/`.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use std::collections::BTreeMap;

fn default_resource_mode_defaults() -> BTreeMap<String, String> {
    BTreeMap::from([
        ("available".into(), "idle".into()),
        ("harvesting".into(), "harvest".into()),
        ("depleted".into(), "depleted".into()),
    ])
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpriteFallback {
    pub tile_id: String,
    pub mode_id: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourcePresentationDefaults {
    #[serde(default = "default_resource_mode_defaults")]
    pub modes: BTreeMap<String, String>,
    #[serde(default)]
    pub fallbacks: BTreeMap<String, SpriteFallback>,
}

impl Default for ResourcePresentationDefaults {
    fn default() -> Self {
        Self {
            modes: default_resource_mode_defaults(),
            fallbacks: BTreeMap::new(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpritePresentationDefaults {
    #[serde(default = "default_presentation_version")]
    pub version: u32,
    #[serde(default)]
    pub resource: ResourcePresentationDefaults,
}

fn default_presentation_version() -> u32 {
    1
}

impl Default for SpritePresentationDefaults {
    fn default() -> Self {
        Self {
            version: default_presentation_version(),
            resource: ResourcePresentationDefaults::default(),
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
struct SpriteManifest {
    #[allow(dead_code)]
    version: u32,
    sheets: Vec<SpriteManifestEntry>,
}

#[derive(Debug, Clone, Deserialize)]
struct SpriteManifestEntry {
    #[allow(dead_code)]
    id: String,
    file: String,
}

#[derive(Debug, Clone, Deserialize)]
struct SpriteSheetDef {
    id: String,
    modes: Vec<SpriteModeDef>,
}

#[derive(Debug, Clone, Deserialize)]
struct SpriteModeDef {
    id: String,
}

/// Mode ids for one sprite sheet.
#[derive(Debug, Clone, Default)]
pub struct SpriteSheetModes {
    pub sheet_id: String,
    pub mode_ids: Vec<String>,
}

/// Index of all sprite sheets and their mode ids.
#[derive(Debug, Clone, Default)]
pub struct SpriteCatalog {
    sheets: HashMap<String, SpriteSheetModes>,
    presentation_defaults: SpritePresentationDefaults,
}

impl SpriteCatalog {
    pub fn mode_ids(&self, sheet_id: &str) -> &[String] {
        self.sheets
            .get(sheet_id)
            .map(|s| s.mode_ids.as_slice())
            .unwrap_or(&[])
    }

    pub fn sheet_ids(&self) -> impl Iterator<Item = &String> {
        self.sheets.keys()
    }

    pub fn len(&self) -> usize {
        self.sheets.len()
    }

    pub fn is_empty(&self) -> bool {
        self.sheets.is_empty()
    }

    pub fn presentation_defaults(&self) -> &SpritePresentationDefaults {
        &self.presentation_defaults
    }

    pub fn set_presentation_defaults(&mut self, defaults: SpritePresentationDefaults) {
        self.presentation_defaults = defaults;
    }

    /// Register or replace mode ids for a sheet (e.g. gfx-baked `paperdoll.*` sheets).
    pub fn register_sheet_modes(&mut self, sheet_id: impl Into<String>, mode_ids: Vec<String>) {
        let sheet_id = sheet_id.into();
        if mode_ids.is_empty() {
            return;
        }
        self.sheets
            .insert(sheet_id.clone(), SpriteSheetModes { sheet_id, mode_ids });
    }
}

/// Load sprite mode index from `dir` (expects `manifest.yaml` + sheet YAML files).
pub fn load_sprite_catalog(dir: &Path) -> anyhow::Result<SpriteCatalog> {
    let manifest_path = dir.join("manifest.yaml");
    if !manifest_path.is_file() {
        return Ok(SpriteCatalog::default());
    }
    let text = std::fs::read_to_string(&manifest_path)?;
    let manifest: SpriteManifest = serde_yaml::from_str(&text)?;
    let presentation_defaults = load_sprite_presentation_defaults(dir)?;
    let mut sheets = HashMap::new();

    for entry in &manifest.sheets {
        let sheet_path = dir.join(&entry.file);
        let sheet_text = match std::fs::read_to_string(&sheet_path) {
            Ok(t) => t,
            Err(_) => continue,
        };
        let def: SpriteSheetDef = match serde_yaml::from_str(&sheet_text) {
            Ok(d) => d,
            Err(_) => continue,
        };
        let mode_ids: Vec<String> = def.modes.into_iter().map(|m| m.id).collect();
        if mode_ids.is_empty() {
            continue;
        }
        sheets.insert(
            def.id.clone(),
            SpriteSheetModes {
                sheet_id: def.id,
                mode_ids,
            },
        );
    }

    Ok(SpriteCatalog {
        sheets,
        presentation_defaults,
    })
}

pub fn load_sprite_presentation_defaults(dir: &Path) -> anyhow::Result<SpritePresentationDefaults> {
    let path = dir.join("presentation-defaults.yaml");
    if !path.is_file() {
        return Ok(SpritePresentationDefaults::default());
    }
    let text = std::fs::read_to_string(path)?;
    Ok(serde_yaml::from_str(&text)?)
}

/// Resolve sprites directory from env, repo checkout, or synced install cache.
pub fn default_sprites_dir() -> Option<PathBuf> {
    if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
        let path = PathBuf::from(p);
        if path.join("manifest.yaml").is_file() {
            return Some(path);
        }
    }
    // Prefer the live repo tree when developing / validating content so new sheets
    // (e.g. LoRA imports) are visible before the next client asset sync.
    let mut dir = std::env::current_dir().ok()?;
    for _ in 0..8 {
        let candidate = dir.join("assets/gfx/sprites");
        if candidate.join("manifest.yaml").is_file() {
            return Some(candidate);
        }
        if !dir.pop() {
            break;
        }
    }
    if let Ok(home) = std::env::var("HOME") {
        let current = PathBuf::from(home).join(".flatland3/assets/current");
        if current.join("manifest.yaml").is_file() {
            return Some(current);
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn loads_starter_sprite_catalog() {
        let dir = default_sprites_dir().expect("sprites dir");
        let cat = load_sprite_catalog(&dir).expect("load");
        assert!(!cat.is_empty());
        let modes = cat.mode_ids("resource.oak_log");
        assert!(modes.contains(&"idle".to_string()));
        assert_eq!(modes.len(), 1);
    }
}