use crate::tileset::{TilesetError, TilesetOptions};
use alpha_blend::rgba::U8x4Rgba;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct Sprite {
pub pixels: Vec<u8>,
pub pixel_width: u32,
pub pixel_height: u32,
pub spacing_cells_x: u16,
pub spacing_cells_y: u16,
}
#[derive(Debug)]
pub struct SpriteCache {
sprites: BTreeMap<char, Sprite>,
}
impl SpriteCache {
#[must_use]
pub const fn new() -> Self {
Self {
sprites: BTreeMap::new(),
}
}
#[must_use]
pub fn get(&self, ch: char) -> Option<&Sprite> {
self.sprites.get(&ch)
}
#[must_use]
pub fn iter(&self) -> impl ExactSizeIterator<Item = (char, &Sprite)> {
self.sprites.iter().map(|(&ch, sprite)| (ch, sprite))
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.sprites.is_empty()
}
#[allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
pub fn load(&mut self, opts: &TilesetOptions) -> Result<(), TilesetError> {
let img = image::load_from_memory(&opts.bytes)
.map_err(|e| TilesetError::PngDecode(e.to_string()))?
.into_rgba8();
let img_w = img.width();
let img_h = img.height();
let tile_w = u32::from(opts.tile_width);
let tile_h = u32::from(opts.tile_height);
if tile_w == 0 || tile_h == 0 {
return Err(TilesetError::ZeroTileSize);
}
if img_w % tile_w != 0 || img_h % tile_h != 0 {
return Err(TilesetError::InvalidDimensions(
img_w,
img_h,
opts.tile_width,
opts.tile_height,
));
}
let columns = opts.columns.map_or(img_w / tile_w, u32::from);
let rows = img_h / tile_h;
let total_tiles = (columns * rows) as usize;
let raw = img.as_raw();
for tile_idx in 0..total_tiles {
let Some(codepoint) = opts.codepage.codepoint(tile_idx) else {
break;
};
let tile_col = (tile_idx as u32) % columns;
let tile_row = (tile_idx as u32) / columns;
let px_x = tile_col * tile_w;
let px_y = tile_row * tile_h;
let mut pixels = vec![0u8; (tile_w * tile_h * 4) as usize];
for row in 0..tile_h {
let src_start = ((px_y + row) * img_w + px_x) as usize * 4;
let dst_start = (row * tile_w) as usize * 4;
pixels[dst_start..dst_start + (tile_w as usize * 4)]
.copy_from_slice(&raw[src_start..src_start + (tile_w as usize * 4)]);
}
if let Some((kr, kg, kb)) = opts.transparent_color {
for px in pixels.chunks_exact_mut(4) {
if px[0] == kr && px[1] == kg && px[2] == kb {
px[3] = 0;
}
}
}
let sprite = Sprite {
pixels,
pixel_width: tile_w,
pixel_height: tile_h,
spacing_cells_x: opts.spacing_cells_x,
spacing_cells_y: opts.spacing_cells_y,
};
if self.sprites.insert(codepoint, sprite).is_some() {
#[allow(clippy::cast_lossless)]
let cp = codepoint as u32;
log::warn!("tileset codepoint collision: U+{cp:04X} '{codepoint}' overwritten");
}
}
Ok(())
}
}
impl Default for SpriteCache {
fn default() -> Self {
Self::new()
}
}
#[inline]
#[must_use]
pub fn source_over(src: U8x4Rgba, dst: U8x4Rgba) -> U8x4Rgba {
use alpha_blend::rgba::F32x4Rgba;
use alpha_blend::{BlendMode, RgbaBlend};
BlendMode::SourceOver
.apply(F32x4Rgba::from(src), F32x4Rgba::from(dst))
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tileset::{Codepage, TilesetOptions};
use image::ImageEncoder;
#[allow(clippy::cast_possible_truncation)]
fn make_test_png(tile_w: u32, tile_h: u32, cols: u32, rows: u32) -> Vec<u8> {
let img_w = tile_w * cols;
let img_h = tile_h * rows;
let mut pixels = vec![0u8; (img_w * img_h * 4) as usize];
for row in 0..rows {
for col in 0..cols {
let r = ((col * 20) % 256) as u8;
let g = ((row * 20) % 256) as u8;
for py in 0..tile_h {
for px in 0..tile_w {
let idx = ((row * tile_h + py) * img_w + col * tile_w + px) as usize * 4;
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = 0;
pixels[idx + 3] = 255;
}
}
}
}
let mut out = std::io::Cursor::new(Vec::new());
let encoder = image::codecs::png::PngEncoder::new(&mut out);
encoder
.write_image(&pixels, img_w, img_h, image::ExtendedColorType::Rgba8)
.unwrap();
out.into_inner()
}
#[test]
fn sprite_cache_load_cp437_sheet() {
let png = make_test_png(16, 16, 16, 16); let opts = TilesetOptions::from_bytes(png)
.tile_size(16, 16)
.codepage(Codepage::Cp437)
.build()
.unwrap();
let mut cache = SpriteCache::new();
cache.load(&opts).unwrap();
let sprite = cache.get('@').expect("'@' must be in CP437 cache");
assert_eq!(sprite.pixel_width, 16);
assert_eq!(sprite.pixel_height, 16);
assert_eq!(sprite.pixels.len(), 16 * 16 * 4);
}
#[test]
fn sprite_cache_rejects_bad_dimensions() {
let png = make_test_png(17, 16, 1, 1);
let opts = TilesetOptions::from_bytes(png)
.tile_size(16, 16)
.build()
.unwrap();
let mut cache = SpriteCache::new();
let err = cache.load(&opts).unwrap_err();
assert!(matches!(
err,
TilesetError::InvalidDimensions(17, 16, 16, 16)
));
}
#[test]
fn sprite_cache_load_empty_bytes_errors() {
let opts = TilesetOptions::from_bytes(vec![])
.tile_size(16, 16)
.build()
.unwrap();
let mut cache = SpriteCache::new();
assert!(matches!(cache.load(&opts), Err(TilesetError::PngDecode(_))));
}
#[test]
fn sprite_cache_last_registration_wins_on_collision() {
let png1 = make_test_png(16, 16, 1, 1);
let png2 = make_test_png(8, 8, 1, 1);
let opts1 = TilesetOptions::from_bytes(png1)
.tile_size(16, 16)
.start_codepoint('A')
.build()
.unwrap();
let opts2 = TilesetOptions::from_bytes(png2)
.tile_size(8, 8)
.start_codepoint('A')
.build()
.unwrap();
let mut cache = SpriteCache::new();
cache.load(&opts1).unwrap();
cache.load(&opts2).unwrap();
let sprite = cache.get('A').unwrap();
assert_eq!(sprite.pixel_width, 8); }
#[test]
fn sprite_cache_load_identity_codepage() {
let png = make_test_png(16, 16, 4, 1); let opts = TilesetOptions::from_bytes(png)
.tile_size(16, 16)
.codepage(Codepage::Identity)
.build()
.unwrap();
let mut cache = SpriteCache::new();
cache.load(&opts).unwrap();
assert!(cache.get('\0').is_some());
assert!(cache.get('\x01').is_some());
assert!(cache.get('\x03').is_some());
assert!(cache.get('\x04').is_none()); }
#[test]
fn sprite_cache_custom_codepage_stops_at_table_end() {
let png = make_test_png(16, 16, 4, 1); let opts = TilesetOptions::from_bytes(png)
.tile_size(16, 16)
.codepage(Codepage::Custom(vec!['A', 'B'])) .build()
.unwrap();
let mut cache = SpriteCache::new();
cache.load(&opts).unwrap();
assert!(cache.get('A').is_some());
assert!(cache.get('B').is_some());
assert!(cache.get('C').is_none()); }
#[test]
fn source_over_opaque_overwrites_destination() {
let src = U8x4Rgba::new(0, 255, 0, 255); let dst = U8x4Rgba::new(255, 0, 0, 255); let result = source_over(src, dst);
assert_eq!(result, src);
}
#[test]
fn source_over_transparent_preserves_destination() {
let src = U8x4Rgba::TRANSPARENT;
let dst = U8x4Rgba::new(255, 0, 0, 255);
let result = source_over(src, dst);
assert_eq!(result, dst);
}
#[test]
fn source_over_half_alpha_blends() {
let src = U8x4Rgba::new(0, 255, 0, 128);
let dst = U8x4Rgba::new(255, 0, 0, 255);
let result = source_over(src, dst);
assert_eq!(result.r, 127);
assert_eq!(result.g, 128);
assert_eq!(result.b, 0);
assert_eq!(result.a, 191);
}
}