use crate::tileset::{SheetColor, SpriteAlign, TilesetError, TilesetOptions};
#[cfg(test)]
use alpha_blend::rgba::U8x4Rgba;
use retroglyph_core::color::{Color, Tint};
use retroglyph_core::dev_only;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Sprite {
pub pixels: Vec<u8>,
pub pixel_width: u32,
pub pixel_height: u32,
pub align: SpriteAlign,
pub color: SheetColor,
}
impl Sprite {
#[must_use]
pub const fn align_offset(
&self,
span_w: u16,
span_h: u16,
glyph_w: u8,
glyph_h: u8,
) -> (i16, i16) {
self.align.offset_in_span(
self.pixel_width,
self.pixel_height,
span_w,
span_h,
glyph_w,
glyph_h,
)
}
}
#[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()
}
pub fn from_tilesets(opts: &[TilesetOptions]) -> Result<Self, TilesetError> {
let mut cache = Self::new();
for tileset in opts {
cache.load(tileset)?;
}
Ok(cache)
}
#[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::ImageDecode(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 natural_columns = img_w / tile_w;
let columns = opts.columns.map_or(natural_columns, u32::from);
if columns > natural_columns {
return Err(TilesetError::TooManyColumns(
opts.columns.unwrap_or(0),
natural_columns,
));
}
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 {
continue;
};
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,
align: opts.align,
color: opts.color,
};
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()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SpriteTint {
pub mask: Tint,
pub tint: Tint,
}
impl SpriteTint {
#[must_use]
pub const fn resolve(
sheet: SheetColor,
fg: Color,
tint: Tint,
default_fg: (u8, u8, u8),
) -> Self {
let mask = match sheet {
SheetColor::Art => Tint::None,
SheetColor::Mask => {
let (r, g, b) = fg.resolve_rgb(default_fg);
Tint::multiply(r, g, b)
}
};
Self { mask, tint }
}
#[must_use]
pub const fn is_identity(&self) -> bool {
self.mask.is_identity() && self.tint.is_identity()
}
#[must_use]
pub const fn apply(&self, rgb: (u8, u8, u8)) -> (u8, u8, u8) {
self.tint.apply(self.mask.apply(rgb))
}
}
pub fn warn_sprite_needs_span(
seen: &mut BTreeSet<char>,
glyph: char,
sprite: (u32, u32),
cell: (u32, u32),
) -> bool {
dev_only!({
let ((w, h), (cell_w, cell_h)) = (sprite, cell);
if w <= cell_w && h <= cell_h {
return false;
}
if !seen.insert(glyph) {
return false;
}
log::warn!(
"sprite for {glyph:?} is {w}x{h}px, larger than the {cell_w}x{cell_h}px cell, but was \
drawn without a span: neighbouring cells will paint over it. Reserve the cells it \
covers with `Surface::put_span`."
);
return true;
});
false
}
pub fn warn_tint_needs_sprite(seen: &mut BTreeSet<char>, glyph: char, tint: Tint) -> bool {
dev_only!({
if tint.is_identity() {
return false;
}
if !seen.insert(glyph) {
return false;
}
log::warn!(
"cell for {glyph:?} has a tint but no sprite is registered for it, so it renders as \
the bitmap font glyph and the tint has no effect. Register a sprite for that \
codepoint, or clear the tint."
);
return true;
});
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tileset::{Codepage, SpriteAlign, 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::builder(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::builder(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_rejects_columns_wider_than_the_image() {
let png = make_test_png(8, 16, 4, 1);
let opts = TilesetOptions::builder(png)
.tile_size(8, 16)
.columns(8)
.build()
.unwrap();
let mut cache = SpriteCache::new();
let err = cache.load(&opts).unwrap_err();
assert!(matches!(err, TilesetError::TooManyColumns(8, 4)));
}
#[test]
fn sprite_cache_load_empty_bytes_errors() {
let opts = TilesetOptions::builder(vec![])
.tile_size(16, 16)
.build()
.unwrap();
let mut cache = SpriteCache::new();
assert!(matches!(
cache.load(&opts),
Err(TilesetError::ImageDecode(_))
));
}
#[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::builder(png1)
.tile_size(16, 16)
.start_codepoint('A')
.build()
.unwrap();
let opts2 = TilesetOptions::builder(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::builder(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_surrogate_tile_index_stops_load_instead_of_skipping() {
let png = make_test_png(1, 1, 2050, 1);
let opts = TilesetOptions::builder(png)
.tile_size(1, 1)
.columns(2050)
.start_codepoint('\u{D7FF}')
.build()
.unwrap();
let mut cache = SpriteCache::new();
cache.load(&opts).unwrap();
assert!(cache.get('\u{D7FF}').is_some());
assert!(cache.get('\u{E000}').is_some());
}
#[test]
fn sprite_cache_custom_codepage_stops_at_table_end() {
let png = make_test_png(16, 16, 4, 1); let opts = TilesetOptions::builder(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()); }
fn one_sprite(tile_w: u32, tile_h: u32, align: SpriteAlign) -> Sprite {
let png = make_test_png(tile_w, tile_h, 1, 1);
#[allow(clippy::cast_possible_truncation)]
let opts = TilesetOptions::builder(png)
.tile_size(tile_w as u16, tile_h as u16)
.codepage(Codepage::Custom(vec!['A']))
.align(align)
.build()
.unwrap();
let mut cache = SpriteCache::new();
cache.load(&opts).unwrap();
cache.get('A').unwrap().clone()
}
#[test]
fn sprite_align_offset_centres_art_in_a_multi_cell_box() {
let sprite = one_sprite(8, 16, SpriteAlign::Center);
assert_eq!(sprite.align_offset(2, 1, 8, 16), (4, 0));
assert_eq!(sprite.align_offset(2, 2, 8, 16), (4, 8));
}
#[test]
fn sprite_align_offset_is_zero_when_the_art_fills_its_span() {
let sprite = one_sprite(16, 32, SpriteAlign::Center);
assert_eq!(sprite.align_offset(2, 2, 8, 16), (0, 0));
}
#[test]
fn sprite_align_offset_defaults_to_top_left() {
let sprite = one_sprite(8, 16, SpriteAlign::TopLeft);
assert_eq!(sprite.align_offset(4, 4, 8, 16), (0, 0));
}
#[test]
fn sprite_align_offset_tolerates_a_zero_cell_size() {
let sprite = one_sprite(8, 16, SpriteAlign::Center);
assert_eq!(sprite.align_offset(2, 2, 0, 0), (0, 0));
}
#[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 = src.source_over(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 = src.source_over(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 = src.source_over(dst);
assert_eq!(result, U8x4Rgba::new(127, 128, 0, 255));
}
fn sprite_with(color: SheetColor) -> Sprite {
Sprite {
pixels: vec![255, 255, 255, 255],
pixel_width: 1,
pixel_height: 1,
align: SpriteAlign::TopLeft,
color,
}
}
const DEFAULT_FG: (u8, u8, u8) = (0xD4, 0xD4, 0xD4);
#[test]
fn art_sheet_ignores_fg_entirely() {
let art = sprite_with(SheetColor::Art);
let resolved = SpriteTint::resolve(art.color, Color::RED, Tint::None, DEFAULT_FG);
assert_eq!(resolved.mask, Tint::None);
assert!(resolved.is_identity());
assert_eq!(resolved.apply((10, 200, 30)), (10, 200, 30));
}
#[test]
fn mask_sheet_takes_its_colour_from_fg() {
let mask = sprite_with(SheetColor::Mask);
let (r, g, b) = Color::RED.resolve_rgb(DEFAULT_FG);
let resolved = SpriteTint::resolve(mask.color, Color::RED, Tint::None, DEFAULT_FG);
assert_eq!(resolved.mask, Tint::multiply(r, g, b));
assert_eq!(resolved.apply((255, 255, 255)), (r, g, b));
}
#[test]
fn mask_sheet_shades_a_grey_pixel_proportionally() {
let mask = sprite_with(SheetColor::Mask);
let resolved = SpriteTint::resolve(
mask.color,
Color::Rgb {
r: 200,
g: 100,
b: 50,
},
Tint::None,
DEFAULT_FG,
);
let (r, _, _) = resolved.apply((128, 128, 128));
assert!(r > 0 && r < 200, "expected a shade of the fg, got {r}");
}
#[test]
fn mask_sheet_resolves_default_fg_through_the_palette() {
let mask = sprite_with(SheetColor::Mask);
let resolved = SpriteTint::resolve(mask.color, Color::Default, Tint::None, DEFAULT_FG);
assert_eq!(resolved.mask, Tint::multiply(0xD4, 0xD4, 0xD4));
}
#[test]
fn the_cell_tint_applies_on_top_of_an_art_sheet() {
let art = sprite_with(SheetColor::Art);
let resolved = SpriteTint::resolve(
art.color,
Color::RED,
Tint::multiply(128, 128, 128),
DEFAULT_FG,
);
assert!(!resolved.is_identity());
assert_eq!(resolved.apply((200, 180, 60)), (100, 90, 30));
}
#[test]
fn both_stages_apply_in_order_on_a_mask_sheet() {
let mask = sprite_with(SheetColor::Mask);
let flash = Tint::mix(255, 255, 255, 255);
let resolved = SpriteTint::resolve(
mask.color,
Color::Rgb { r: 255, g: 0, b: 0 },
flash,
DEFAULT_FG,
);
assert_eq!(resolved.apply((255, 255, 255)), (255, 255, 255));
}
#[test]
fn an_untouched_art_cell_is_identity_so_renderers_can_skip_the_work() {
let art = sprite_with(SheetColor::Art);
assert!(
SpriteTint::resolve(art.color, Color::Default, Tint::None, DEFAULT_FG).is_identity()
);
let mask = sprite_with(SheetColor::Mask);
assert!(
!SpriteTint::resolve(mask.color, Color::Default, Tint::None, DEFAULT_FG).is_identity()
);
}
#[test]
fn warn_sprite_needs_span_reports_an_oversized_sprite_once() {
let mut seen = BTreeSet::new();
assert_eq!(
warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16)),
retroglyph_core::dev::DEV
);
assert!(!warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16)));
}
#[test]
fn warn_sprite_needs_span_is_silent_for_a_sprite_that_fits() {
let mut seen = BTreeSet::new();
assert!(!warn_sprite_needs_span(&mut seen, '@', (16, 16), (16, 16)));
assert!(seen.is_empty());
}
#[test]
fn warn_sprite_needs_span_touches_no_state_outside_a_reporting_build() {
let mut seen = BTreeSet::new();
warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16));
assert_eq!(seen.is_empty(), !retroglyph_core::dev::DEV);
}
#[test]
fn warn_tint_needs_sprite_reports_a_dropped_tint_once() {
let mut seen = BTreeSet::new();
let tint = Tint::multiply(128, 128, 128);
assert_eq!(
warn_tint_needs_sprite(&mut seen, '@', tint),
retroglyph_core::dev::DEV
);
assert!(!warn_tint_needs_sprite(&mut seen, '@', tint));
}
#[test]
fn warn_tint_needs_sprite_is_silent_for_tint_none() {
let mut seen = BTreeSet::new();
assert!(!warn_tint_needs_sprite(&mut seen, '@', Tint::None));
assert!(seen.is_empty());
}
#[test]
fn warn_tint_needs_sprite_is_silent_for_an_identity_tint() {
let mut seen = BTreeSet::new();
assert!(!warn_tint_needs_sprite(
&mut seen,
'@',
Tint::multiply(255, 255, 255)
));
assert!(seen.is_empty());
}
#[test]
fn warn_tint_needs_sprite_touches_no_state_outside_a_reporting_build() {
let mut seen = BTreeSet::new();
warn_tint_needs_sprite(&mut seen, '@', Tint::multiply(128, 128, 128));
assert_eq!(seen.is_empty(), !retroglyph_core::dev::DEV);
}
}