use core::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum SheetColor {
#[default]
Art,
Mask,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum SpriteAlign {
#[default]
TopLeft,
Top,
TopRight,
Left,
Center,
Right,
BottomLeft,
Bottom,
BottomRight,
}
impl SpriteAlign {
#[must_use]
pub const fn offset(self, sprite_w: u32, sprite_h: u32, box_w: u32, box_h: u32) -> (i16, i16) {
let slack_x = box_w.saturating_sub(sprite_w);
let slack_y = box_h.saturating_sub(sprite_h);
let (x, y) = match self {
Self::TopLeft => (0, 0),
Self::Top => (slack_x / 2, 0),
Self::TopRight => (slack_x, 0),
Self::Left => (0, slack_y / 2),
Self::Center => (slack_x / 2, slack_y / 2),
Self::Right => (slack_x, slack_y / 2),
Self::BottomLeft => (0, slack_y),
Self::Bottom => (slack_x / 2, slack_y),
Self::BottomRight => (slack_x, slack_y),
};
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
(
(if x > i16::MAX as u32 {
i16::MAX as u32
} else {
x
}) as i16,
(if y > i16::MAX as u32 {
i16::MAX as u32
} else {
y
}) as i16,
)
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum TilesetError {
PngDecode(String),
InvalidDimensions(u32, u32, u16, u16),
EmptyCodepage,
UnsupportedPixelFormat(String),
ZeroTileSize,
}
impl fmt::Display for TilesetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PngDecode(e) => write!(f, "png decode failed: {e}"),
Self::InvalidDimensions(iw, ih, tw, th) => {
write!(f, "image {iw}x{ih} is not divisible by tile size {tw}x{th}")
}
Self::EmptyCodepage => write!(f, "codepage mapping has no entries"),
Self::UnsupportedPixelFormat(fmt_name) => {
write!(
f,
"unsupported pixel format: {fmt_name}; expected RGBA8 or RGB8"
)
}
Self::ZeroTileSize => {
write!(f, "tile_width and tile_height must be non-zero")
}
}
}
}
impl std::error::Error for TilesetError {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Codepage {
Cp437,
Unicode {
start: char,
},
Identity,
Custom(Vec<char>),
}
impl Codepage {
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn codepoint(&self, i: usize) -> Option<char> {
match self {
Self::Cp437 => CP437_TO_UNICODE.get(i).copied(),
Self::Unicode { start } => {
let scalar = (*start as u32).checked_add(i as u32)?;
char::from_u32(scalar)
}
Self::Identity => char::from_u32(i as u32),
Self::Custom(table) => table.get(i).copied(),
}
}
#[must_use]
pub const fn len(&self) -> Option<usize> {
match self {
Self::Cp437 => Some(256),
Self::Unicode { .. } | Self::Identity => None,
Self::Custom(t) => Some(t.len()),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == Some(0)
}
}
pub const CP437_TO_UNICODE: [char; 256] = [
'\u{0000}', '\u{263A}', '\u{263B}', '\u{2665}', '\u{2666}', '\u{2663}', '\u{2660}', '\u{2022}',
'\u{25D8}', '\u{25CB}', '\u{25D9}', '\u{2642}', '\u{2640}', '\u{266A}', '\u{266B}', '\u{263C}',
'\u{25BA}', '\u{25C4}', '\u{2195}', '\u{203C}', '\u{00B6}', '\u{00A7}', '\u{25AC}', '\u{21A8}',
'\u{2191}', '\u{2193}', '\u{2192}', '\u{2190}', '\u{221F}', '\u{2194}', '\u{25B2}', '\u{25BC}',
' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2',
'3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E',
'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~',
'\u{2302}', '\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}',
'\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}', '\u{00EE}', '\u{00EC}', '\u{00C4}',
'\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}',
'\u{00F9}', '\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}',
'\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}', '\u{00F1}', '\u{00D1}', '\u{00AA}',
'\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}',
'\u{00BB}', '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}',
'\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}', '\u{255D}', '\u{255C}', '\u{255B}',
'\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}',
'\u{255F}', '\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}',
'\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}', '\u{2558}', '\u{2552}', '\u{2553}',
'\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}',
'\u{2580}', '\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}',
'\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}', '\u{221E}', '\u{03C6}', '\u{03B5}',
'\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}',
'\u{2248}', '\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}',
'\u{00A0}',
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TilesetOptions {
pub bytes: Vec<u8>,
pub tile_width: u16,
pub tile_height: u16,
pub columns: Option<u16>,
pub codepage: Codepage,
pub align: SpriteAlign,
pub color: SheetColor,
pub transparent_color: Option<(u8, u8, u8)>,
}
impl TilesetOptions {
#[must_use]
pub const fn from_bytes(bytes: Vec<u8>) -> TilesetBuilder {
TilesetBuilder {
bytes,
tile_width: 0,
tile_height: 0,
columns: None,
codepage: Codepage::Cp437,
align: SpriteAlign::TopLeft,
color: SheetColor::Art,
transparent_color: None,
}
}
}
pub struct TilesetBuilder {
bytes: Vec<u8>,
tile_width: u16,
tile_height: u16,
columns: Option<u16>,
codepage: Codepage,
align: SpriteAlign,
color: SheetColor,
transparent_color: Option<(u8, u8, u8)>,
}
impl TilesetBuilder {
#[must_use]
pub const fn tile_size(mut self, width: u16, height: u16) -> Self {
self.tile_width = width;
self.tile_height = height;
self
}
#[must_use]
pub const fn columns(mut self, cols: u16) -> Self {
self.columns = Some(cols);
self
}
#[must_use]
pub fn codepage(mut self, codepage: Codepage) -> Self {
self.codepage = codepage;
self
}
#[must_use]
pub fn start_codepoint(mut self, start: char) -> Self {
self.codepage = Codepage::Unicode { start };
self
}
#[must_use]
pub const fn align(mut self, align: SpriteAlign) -> Self {
self.align = align;
self
}
#[must_use]
pub const fn color(mut self, color: SheetColor) -> Self {
self.color = color;
self
}
#[must_use]
pub const fn mask(self) -> Self {
self.color(SheetColor::Mask)
}
#[must_use]
pub const fn transparent_color(mut self, r: u8, g: u8, b: u8) -> Self {
self.transparent_color = Some((r, g, b));
self
}
pub fn build(self) -> Result<TilesetOptions, TilesetError> {
if self.tile_width == 0 || self.tile_height == 0 {
return Err(TilesetError::ZeroTileSize);
}
if let Codepage::Custom(ref t) = self.codepage
&& t.is_empty()
{
return Err(TilesetError::EmptyCodepage);
}
Ok(TilesetOptions {
bytes: self.bytes,
tile_width: self.tile_width,
tile_height: self.tile_height,
columns: self.columns,
codepage: self.codepage,
align: self.align,
color: self.color,
transparent_color: self.transparent_color,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tileset_builder_rejects_zero_tile_size() {
let opts = TilesetOptions::from_bytes(vec![]).tile_size(0, 16).build();
assert!(matches!(opts, Err(TilesetError::ZeroTileSize)));
}
#[test]
fn tileset_builder_rejects_empty_custom_codepage() {
let opts = TilesetOptions::from_bytes(vec![])
.tile_size(16, 16)
.codepage(Codepage::Custom(vec![]))
.build();
assert!(matches!(opts, Err(TilesetError::EmptyCodepage)));
}
#[test]
fn tileset_builder_valid() {
let opts = TilesetOptions::from_bytes(vec![0u8; 64])
.tile_size(16, 16)
.start_codepoint('\u{E000}')
.align(SpriteAlign::Center)
.build()
.unwrap();
assert_eq!(opts.tile_width, 16);
assert_eq!(opts.align, SpriteAlign::Center);
assert!(matches!(
opts.codepage,
Codepage::Unicode { start: '\u{E000}' }
));
}
#[test]
fn tileset_builder_defaults_to_top_left_alignment() {
let opts = TilesetOptions::from_bytes(vec![0u8; 64])
.tile_size(16, 16)
.build()
.unwrap();
assert_eq!(opts.align, SpriteAlign::TopLeft);
}
#[test]
fn sprite_align_positions_art_within_a_larger_box() {
let at = |align: SpriteAlign| align.offset(16, 16, 32, 32);
assert_eq!(at(SpriteAlign::TopLeft), (0, 0));
assert_eq!(at(SpriteAlign::Top), (8, 0));
assert_eq!(at(SpriteAlign::TopRight), (16, 0));
assert_eq!(at(SpriteAlign::Left), (0, 8));
assert_eq!(at(SpriteAlign::Center), (8, 8));
assert_eq!(at(SpriteAlign::Right), (16, 8));
assert_eq!(at(SpriteAlign::BottomLeft), (0, 16));
assert_eq!(at(SpriteAlign::Bottom), (8, 16));
assert_eq!(at(SpriteAlign::BottomRight), (16, 16));
}
#[test]
fn sprite_align_centring_rounds_down() {
assert_eq!(SpriteAlign::Center.offset(7, 7, 16, 16), (4, 4));
assert_eq!(SpriteAlign::Center.offset(8, 8, 15, 15), (3, 3));
}
#[test]
fn sprite_align_is_a_no_op_when_the_art_fills_the_box() {
for align in [
SpriteAlign::TopLeft,
SpriteAlign::Top,
SpriteAlign::TopRight,
SpriteAlign::Left,
SpriteAlign::Center,
SpriteAlign::Right,
SpriteAlign::BottomLeft,
SpriteAlign::Bottom,
SpriteAlign::BottomRight,
] {
assert_eq!(align.offset(16, 16, 16, 16), (0, 0), "{align:?}");
}
}
#[test]
fn sprite_align_saturates_when_the_art_exceeds_the_box() {
assert_eq!(SpriteAlign::Center.offset(32, 32, 16, 16), (0, 0));
assert_eq!(SpriteAlign::BottomRight.offset(32, 32, 16, 16), (0, 0));
}
#[test]
fn cp437_codepage_spot_checks() {
assert_eq!(Codepage::Cp437.codepoint(32), Some(' '));
assert_eq!(Codepage::Cp437.codepoint(64), Some('@'));
assert_eq!(Codepage::Cp437.codepoint(176), Some('\u{2591}'));
assert_eq!(Codepage::Cp437.codepoint(256), None);
}
#[test]
fn identity_codepage_positional() {
assert_eq!(Codepage::Identity.codepoint(0), Some('\0'));
assert_eq!(Codepage::Identity.codepoint(65), Some('A'));
assert_eq!(Codepage::Identity.codepoint(0xD800), None);
assert_eq!(Codepage::Identity.codepoint(0xDFFF), None);
assert_eq!(Codepage::Identity.codepoint(0xE000), Some('\u{E000}'));
}
#[test]
fn unicode_codepage_offset() {
let cp = Codepage::Unicode { start: '\u{E000}' };
assert_eq!(cp.codepoint(0), Some('\u{E000}'));
assert_eq!(cp.codepoint(5), Some('\u{E005}'));
}
#[test]
fn custom_codepage_bounds() {
let cp = Codepage::Custom(vec!['A', 'B', 'C']);
assert_eq!(cp.codepoint(0), Some('A'));
assert_eq!(cp.codepoint(2), Some('C'));
assert_eq!(cp.codepoint(3), None);
}
}