spottedcat 2.0.0

Rusty SpottedCat simple game engine
Documentation
use crate::{Context, Image, Pt, Texture};
use std::collections::HashMap;

/// Stable name used to look up a replaceable image asset.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ImageAssetKey(String);

impl ImageAssetKey {
    pub fn new(name: impl Into<String>) -> Self {
        Self(name.into())
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl From<&str> for ImageAssetKey {
    fn from(value: &str) -> Self {
        Self::new(value)
    }
}

impl From<String> for ImageAssetKey {
    fn from(value: String) -> Self {
        Self::new(value)
    }
}

/// A replaceable image and its monotonically increasing content version.
#[derive(Debug, Clone, Copy)]
pub struct ImageAsset {
    texture: Texture,
    version: u64,
}

impl ImageAsset {
    pub fn image(self) -> Image {
        self.texture.view()
    }

    pub fn texture(self) -> Texture {
        self.texture
    }

    pub fn version(self) -> u64 {
        self.version
    }
}

/// Owns named standalone textures that can be replaced at runtime.
///
/// Callers should keep resource names rather than copying the returned [`Image`]
/// into long-lived game state. Looking the image up at draw time makes a replacement
/// visible to every consumer on the next frame.
#[derive(Debug, Default)]
pub struct ImageAssetStore {
    assets: HashMap<ImageAssetKey, ImageAsset>,
    next_version: u64,
}

impl ImageAssetStore {
    pub fn new() -> Self {
        Self::default()
    }

    /// Registers or replaces an asset from RGBA8 pixels.
    ///
    /// Same-sized replacements update the existing texture in place. A size change
    /// creates a new standalone texture and destroys the previous one.
    pub fn replace_rgba8(
        &mut self,
        ctx: &mut Context,
        key: impl Into<ImageAssetKey>,
        width: u32,
        height: u32,
        rgba: Vec<u8>,
    ) -> anyhow::Result<u64> {
        if width == 0 || height == 0 {
            anyhow::bail!("image asset dimensions must be non-zero, got {width}x{height}");
        }
        let expected_len = (width as usize)
            .checked_mul(height as usize)
            .and_then(|pixels| pixels.checked_mul(4))
            .ok_or_else(|| anyhow::anyhow!("image asset dimensions overflow: {width}x{height}"))?;
        if rgba.len() != expected_len {
            anyhow::bail!(
                "image asset RGBA length mismatch: expected {expected_len}, got {}",
                rgba.len()
            );
        }

        let key = key.into();
        if let Some(asset) = self.assets.get_mut(&key)
            && asset.texture.width().0 == width as f32
            && asset.texture.height().0 == height as f32
        {
            asset.texture.update_rgba8(ctx, rgba)?;
            self.next_version = self.next_version.saturating_add(1).max(1);
            asset.version = self.next_version;
            return Ok(asset.version);
        }

        let texture = Texture::new(ctx, Pt::from(width as f32), Pt::from(height as f32), &rgba)?;
        self.next_version = self.next_version.saturating_add(1).max(1);
        let new_asset = ImageAsset {
            texture,
            version: self.next_version,
        };
        let old_asset = self.assets.insert(key, new_asset);
        if let Some(old_asset) = old_asset {
            old_asset.texture.destroy(ctx);
        }
        Ok(self.next_version)
    }

    pub fn get(&self, key: &str) -> Option<ImageAsset> {
        self.assets.get(&ImageAssetKey::from(key)).copied()
    }

    pub fn remove(&mut self, ctx: &mut Context, key: &str) -> bool {
        let Some(asset) = self.assets.remove(&ImageAssetKey::from(key)) else {
            return false;
        };
        asset.texture.destroy(ctx)
    }

    pub fn clear(&mut self, ctx: &mut Context) {
        let assets = std::mem::take(&mut self.assets);
        for asset in assets.into_values() {
            asset.texture.destroy(ctx);
        }
    }

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

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

#[cfg(test)]
mod tests {
    use super::ImageAssetStore;
    use crate::{Context, Pt};

    #[test]
    fn replaces_same_sized_image_without_changing_texture_handle() {
        let mut ctx = Context::new();
        let mut store = ImageAssetStore::new();

        let first_version = store
            .replace_rgba8(&mut ctx, "player", 2, 2, vec![255; 2 * 2 * 4])
            .expect("initial image should register");
        assert_eq!(first_version, 1);
        let first_texture = store
            .get("player")
            .expect("asset should be registered")
            .texture();
        assert_eq!(first_texture.width(), Pt::from(2.0));

        let second_version = store
            .replace_rgba8(&mut ctx, "player", 2, 2, vec![0; 2 * 2 * 4])
            .expect("same-sized image should update");
        assert_eq!(second_version, 2);
        let second_texture = store
            .get("player")
            .expect("asset should remain registered")
            .texture();
        assert_eq!(first_texture.id(), second_texture.id());
    }

    #[test]
    fn replacing_image_with_new_size_replaces_texture() {
        let mut ctx = Context::new();
        let mut store = ImageAssetStore::new();

        store
            .replace_rgba8(&mut ctx, "player", 1, 1, vec![255; 4])
            .expect("initial image should register");
        let first_texture = store.get("player").expect("asset should exist").texture();

        store
            .replace_rgba8(&mut ctx, "player", 2, 1, vec![0; 8])
            .expect("resized image should register");
        let second_texture = store.get("player").expect("asset should exist").texture();

        assert_ne!(first_texture.id(), second_texture.id());
        assert!(!first_texture.is_ready(&ctx));
    }

    #[test]
    fn rejects_invalid_image_data_before_registering_asset() {
        let mut ctx = Context::new();
        let mut store = ImageAssetStore::new();

        let error = store
            .replace_rgba8(&mut ctx, "player", 2, 2, vec![0; 3])
            .expect_err("invalid RGBA data should be rejected");

        assert!(error.to_string().contains("RGBA length mismatch"));
        assert!(store.is_empty());
    }
}