use std::collections::HashMap;
use std::path::{Path, PathBuf};
use image::GenericImageView;
use thiserror::Error;
use crate::tile::{RgbaTileSet, TileSet, TILE_PIXELS};
use dotzuki_engine::render::Rgba;
#[derive(Debug, Error)]
pub enum ResourceError {
#[error("asset root directory not found: {0}")]
AssetRootNotFound(PathBuf),
#[error("PNG file not found: {0}")]
PngNotFound(PathBuf),
#[error("failed to load PNG: {0}")]
ImageError(#[from] image::ImageError),
#[error("PNG dimensions {width}×{height} are not a multiple of 8 pixels")]
InvalidDimensions { width: u32, height: u32 },
#[error("unexpected grayscale value {value} at ({x}, {y}); expected 0, 85, 170, or 255")]
InvalidGrayscale { value: u8, x: u32, y: u32 },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, ResourceError>;
#[inline]
pub fn grayscale_to_color_index(value: u8) -> u8 {
match value {
213..=255 => 0,
128..=212 => 1,
43..=127 => 2,
0..=42 => 3,
}
}
#[inline]
pub fn grayscale_to_color_index_strict(value: u8) -> Option<u8> {
match value {
255 => Some(0),
170 => Some(1),
85 => Some(2),
0 => Some(3),
_ => None,
}
}
#[inline]
pub fn bw_to_color_index(value: u8) -> u8 {
if value >= 128 {
0
} else {
3
}
}
pub fn png_to_2bpp(img: &image::DynamicImage) -> Result<Vec<u8>> {
let (w, h) = img.dimensions();
if w % 8 != 0 || h % 8 != 0 {
return Err(ResourceError::InvalidDimensions {
width: w,
height: h,
});
}
let gray = img.to_luma8();
let tiles_x = (w / 8) as usize;
let tiles_y = (h / 8) as usize;
let total_tiles = tiles_x * tiles_y;
let mut data = Vec::with_capacity(total_tiles * 16);
for ty in 0..tiles_y {
for tx in 0..tiles_x {
for row in 0..TILE_PIXELS {
let mut lo: u8 = 0;
let mut hi: u8 = 0;
for col in 0..TILE_PIXELS {
let px = gray.get_pixel((tx * 8 + col) as u32, (ty * 8 + row) as u32)[0];
let color = grayscale_to_color_index(px);
let bit = 7 - col;
lo |= (color & 1) << bit;
hi |= ((color >> 1) & 1) << bit;
}
data.push(lo);
data.push(hi);
}
}
}
Ok(data)
}
pub fn png_to_1bpp(img: &image::DynamicImage) -> Result<Vec<u8>> {
let (w, h) = img.dimensions();
if w % 8 != 0 || h % 8 != 0 {
return Err(ResourceError::InvalidDimensions {
width: w,
height: h,
});
}
let gray = img.to_luma8();
let tiles_x = (w / 8) as usize;
let tiles_y = (h / 8) as usize;
let total_tiles = tiles_x * tiles_y;
let mut data = Vec::with_capacity(total_tiles * 8);
for ty in 0..tiles_y {
for tx in 0..tiles_x {
for row in 0..TILE_PIXELS {
let mut byte: u8 = 0;
for col in 0..TILE_PIXELS {
let px = gray.get_pixel((tx * 8 + col) as u32, (ty * 8 + row) as u32)[0];
if px < 128 {
byte |= 1 << (7 - col);
}
}
data.push(byte);
}
}
}
Ok(data)
}
pub fn png_to_tileset_2bpp(img: &image::DynamicImage) -> Result<TileSet> {
let data = png_to_2bpp(img)?;
Ok(TileSet::from_2bpp(&data))
}
pub fn png_to_tileset_1bpp(img: &image::DynamicImage) -> Result<TileSet> {
let data = png_to_1bpp(img)?;
Ok(TileSet::from_1bpp(&data))
}
#[inline]
pub fn grayscale_to_16_levels(value: u8) -> u8 {
let level = ((value as u16) * 16) / 256;
level.min(15) as u8
}
pub fn png_to_4bpp(img: &image::DynamicImage) -> Result<Vec<u8>> {
let (w, h) = img.dimensions();
if w % 8 != 0 || h % 8 != 0 {
return Err(ResourceError::InvalidDimensions {
width: w,
height: h,
});
}
let gray = img.to_luma8();
let tiles_x = (w / 8) as usize;
let tiles_y = (h / 8) as usize;
let total_tiles = tiles_x * tiles_y;
let mut data = Vec::with_capacity(total_tiles * 32);
for ty in 0..tiles_y {
for tx in 0..tiles_x {
for row in 0..TILE_PIXELS {
let mut lo: u8 = 0;
let mut hi: u8 = 0;
for col in 0..TILE_PIXELS {
let px = gray.get_pixel((tx * 8 + col) as u32, (ty * 8 + row) as u32)[0];
let color = grayscale_to_16_levels(px);
let bit = 7 - col;
lo |= (color & 1) << bit;
hi |= ((color >> 1) & 1) << bit;
}
data.push(lo);
data.push(hi);
}
for row in 0..TILE_PIXELS {
let mut lo: u8 = 0;
let mut hi: u8 = 0;
for col in 0..TILE_PIXELS {
let px = gray.get_pixel((tx * 8 + col) as u32, (ty * 8 + row) as u32)[0];
let color = grayscale_to_16_levels(px);
let bit = 7 - col;
lo |= ((color >> 2) & 1) << bit;
hi |= ((color >> 3) & 1) << bit;
}
data.push(lo);
data.push(hi);
}
}
}
Ok(data)
}
pub fn png_to_rgba(img: &image::DynamicImage) -> Result<Vec<Rgba>> {
let rgba = img.to_rgba8();
let (w, h) = img.dimensions();
let mut pixels = Vec::with_capacity((w * h) as usize);
for y in 0..h {
for x in 0..w {
let px = rgba.get_pixel(x, y);
pixels.push(Rgba::from([px[0], px[1], px[2], px[3]]));
}
}
Ok(pixels)
}
pub fn png_to_tileset_4bpp(img: &image::DynamicImage) -> Result<TileSet> {
let data = png_to_4bpp(img)?;
Ok(TileSet::from_4bpp(&data))
}
pub fn png_to_tileset_rgba(img: &image::DynamicImage) -> Result<TileSet> {
let (w, h) = img.dimensions();
let tile_count = (w / 8 * h / 8) as usize;
let pixels = png_to_rgba(img)?;
Ok(TileSet::from_rgba(&pixels, tile_count))
}
pub trait AssetKind: Copy + Eq + std::hash::Hash {
fn subdir(self) -> &'static str;
fn is_1bpp(self) -> bool {
false
}
}
#[derive(Debug, Clone)]
pub struct AssetRoot {
gfx_dir: PathBuf,
}
impl AssetRoot {
pub fn new(gfx_dir: impl Into<PathBuf>) -> Result<Self> {
let gfx_dir = gfx_dir.into();
if !gfx_dir.is_dir() {
return Err(ResourceError::AssetRootNotFound(gfx_dir));
}
Ok(Self { gfx_dir })
}
pub fn new_wasm() -> Self {
Self {
gfx_dir: PathBuf::from("gfx"),
}
}
pub fn from_parent(parent: impl AsRef<Path>) -> Result<Self> {
let gfx_dir = parent.as_ref().join("gfx");
if !gfx_dir.is_dir() {
return Err(ResourceError::AssetRootNotFound(gfx_dir));
}
Ok(Self { gfx_dir })
}
pub fn auto_detect() -> Result<Self> {
if let Ok(dir) = std::env::var("DOTZUKI_GFX_DIR") {
let gfx = PathBuf::from(&dir);
if gfx.is_dir() {
return Ok(Self { gfx_dir: gfx });
}
log::warn!(
"DOTZUKI_GFX_DIR={dir:?} is not a directory; falling back to auto-detection"
);
}
if let Ok(cwd) = std::env::current_dir() {
let gfx = cwd.join("gfx");
if gfx.is_dir() {
return Ok(Self { gfx_dir: gfx });
}
let mut dir = cwd.as_path().to_path_buf();
for _ in 0..5 {
if let Some(parent) = dir.parent() {
let gfx = parent.join("gfx");
if gfx.is_dir() {
return Ok(Self { gfx_dir: gfx });
}
dir = parent.to_path_buf();
} else {
break;
}
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(exe_dir) = exe.parent() {
let gfx = exe_dir.join("gfx");
if gfx.is_dir() {
return Ok(Self { gfx_dir: gfx });
}
}
}
Err(ResourceError::AssetRootNotFound(PathBuf::from("gfx")))
}
pub fn gfx_dir(&self) -> &Path {
&self.gfx_dir
}
pub fn resolve<K: AssetKind>(&self, category: K, filename: &str) -> PathBuf {
self.gfx_dir.join(category.subdir()).join(filename)
}
pub fn resolve_checked<K: AssetKind>(&self, category: K, filename: &str) -> Result<PathBuf> {
let path = self.resolve(category, filename);
if !path.is_file() {
return Err(ResourceError::PngNotFound(path));
}
Ok(path)
}
pub fn list_pngs<K: AssetKind>(&self, category: K) -> Result<Vec<PathBuf>> {
let dir = self.gfx_dir.join(category.subdir());
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "png") {
files.push(path);
}
}
files.sort();
Ok(files)
}
}
#[derive(Debug, Clone)]
pub struct CachedTileSet {
pub tileset: TileSet,
pub source_size: (u32, u32),
pub tile_count: usize,
}
#[derive(Debug)]
pub struct LoadedPng {
pub image: image::DynamicImage,
pub dimensions: (u32, u32),
}
impl LoadedPng {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
#[cfg(not(target_arch = "wasm32"))]
{
if !path.is_file() {
return Err(ResourceError::PngNotFound(path.to_path_buf()));
}
let image = image::open(path)?;
let dimensions = image.dimensions();
Ok(Self { image, dimensions })
}
#[cfg(target_arch = "wasm32")]
{
Err(ResourceError::PngNotFound(path.to_path_buf()))
}
}
pub fn load_from_bytes(data: &[u8]) -> Result<Self> {
use std::io::Cursor;
let image = image::load(Cursor::new(data), image::ImageFormat::Png)?;
let dimensions = image.dimensions();
Ok(Self { image, dimensions })
}
pub fn to_2bpp(&self) -> Result<Vec<u8>> {
png_to_2bpp(&self.image)
}
pub fn to_1bpp(&self) -> Result<Vec<u8>> {
png_to_1bpp(&self.image)
}
pub fn to_tileset(&self, is_1bpp: bool) -> Result<TileSet> {
if is_1bpp {
png_to_tileset_1bpp(&self.image)
} else {
png_to_tileset_2bpp(&self.image)
}
}
pub fn tiles_x(&self) -> u32 {
self.dimensions.0 / 8
}
pub fn tiles_y(&self) -> u32 {
self.dimensions.1 / 8
}
}
pub type EmbeddedAssetLoader = fn(&str) -> Option<&'static [u8]>;
pub struct ResourceManager<K: AssetKind> {
root: AssetRoot,
cache: HashMap<(K, String), CachedTileSet>,
embedded_loader: Option<EmbeddedAssetLoader>,
}
impl<K: AssetKind> ResourceManager<K> {
pub fn new(root: AssetRoot) -> Self {
Self {
root,
cache: HashMap::new(),
embedded_loader: None,
}
}
pub fn set_embedded_loader(&mut self, loader: EmbeddedAssetLoader) {
self.embedded_loader = Some(loader);
}
pub fn root(&self) -> &AssetRoot {
&self.root
}
pub fn cache_size(&self) -> usize {
self.cache.len()
}
pub fn clear_cache(&mut self) {
self.cache.clear();
}
pub fn evict(&mut self, category: K, filename: &str) -> bool {
self.cache
.remove(&(category, filename.to_string()))
.is_some()
}
pub fn load_asset(&mut self, category: K, filename: &str) -> Result<&CachedTileSet> {
let key = (category, filename.to_string());
if !self.cache.contains_key(&key) {
let entry = self.load_raw(category, filename, category.is_1bpp())?;
self.cache.insert(key.clone(), entry);
}
Ok(self.cache.get(&key).unwrap())
}
pub fn load_asset_2bpp(&mut self, category: K, filename: &str) -> Result<&CachedTileSet> {
let cache_key = (category, format!("{}:2bpp", filename));
if !self.cache.contains_key(&cache_key) {
let entry = self.load_raw(category, filename, false)?;
self.cache.insert(cache_key.clone(), entry);
}
Ok(self.cache.get(&cache_key).unwrap())
}
pub fn load_asset_1bpp(&mut self, category: K, filename: &str) -> Result<&CachedTileSet> {
let cache_key = (category, format!("{}:1bpp", filename));
if !self.cache.contains_key(&cache_key) {
let entry = self.load_raw(category, filename, true)?;
self.cache.insert(cache_key.clone(), entry);
}
Ok(self.cache.get(&cache_key).unwrap())
}
fn load_png(&self, category: K, filename: &str) -> Result<LoadedPng> {
if let Some(loader) = self.embedded_loader {
let relative_path = format!("{}/{}", category.subdir(), filename);
let bytes = loader(&relative_path)
.ok_or_else(|| ResourceError::PngNotFound(PathBuf::from(relative_path)))?;
LoadedPng::load_from_bytes(bytes)
} else {
LoadedPng::load(&self.root.resolve_checked(category, filename)?)
}
}
fn load_raw(&self, category: K, filename: &str, is_1bpp: bool) -> Result<CachedTileSet> {
let loaded = self.load_png(category, filename)?;
let tileset = loaded.to_tileset(is_1bpp)?;
Ok(CachedTileSet {
tile_count: tileset.len(),
source_size: loaded.dimensions,
tileset,
})
}
pub fn load_tileset_4bpp(
&mut self,
category: K,
name: &str,
) -> std::result::Result<&TileSet, String> {
let filename = ensure_png_ext(name);
let cache_key = (category, format!("{}:4bpp", filename));
if !self.cache.contains_key(&cache_key) {
let entry = self
.load_tileset_4bpp_raw(category, &filename)
.map_err(|e| e.to_string())?;
self.cache.insert(cache_key.clone(), entry);
}
Ok(&self.cache.get(&cache_key).unwrap().tileset)
}
pub fn load_tileset_rgba_tileset(
&mut self,
category: K,
name: &str,
) -> std::result::Result<&TileSet, String> {
let filename = ensure_png_ext(name);
let cache_key = (category, format!("{}:rgba", filename));
if !self.cache.contains_key(&cache_key) {
let entry = self
.load_tileset_rgba_raw(category, &filename)
.map_err(|e| e.to_string())?;
self.cache.insert(cache_key.clone(), entry);
}
Ok(&self.cache.get(&cache_key).unwrap().tileset)
}
fn load_tileset_4bpp_raw(&self, category: K, filename: &str) -> Result<CachedTileSet> {
let loaded = self.load_png(category, filename)?;
let data = png_to_4bpp(&loaded.image)?;
let tileset = TileSet::from_4bpp(&data);
Ok(CachedTileSet {
tile_count: tileset.len(),
source_size: loaded.dimensions,
tileset,
})
}
fn load_tileset_rgba_raw(&self, category: K, filename: &str) -> Result<CachedTileSet> {
let loaded = self.load_png(category, filename)?;
let pixels = png_to_rgba(&loaded.image)?;
let tile_count = (loaded.dimensions.0 / 8 * loaded.dimensions.1 / 8) as usize;
let tileset = TileSet::from_rgba(&pixels, tile_count);
Ok(CachedTileSet {
tile_count: tileset.len(),
source_size: loaded.dimensions,
tileset,
})
}
pub fn load_tileset_rgba(
&self,
category: K,
name: &str,
) -> std::result::Result<RgbaTileSet, String> {
let filename = ensure_png_ext(name);
if let Some(loader) = self.embedded_loader {
let relative = format!("{}/{}", category.subdir(), filename);
let data =
loader(&relative).ok_or_else(|| format!("Missing embedded asset: {relative}"))?;
return RgbaTileSet::from_rgba_png(data);
}
let path = self.root.gfx_dir().join(category.subdir()).join(&filename);
let data = std::fs::read(&path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
RgbaTileSet::from_rgba_png(&data)
}
pub fn load(&mut self, category: K, name: &str) -> Result<&CachedTileSet> {
let filename = ensure_png_ext(name);
self.load_asset(category, &filename)
}
pub fn is_cached(&self, category: K, filename: &str) -> bool {
self.cache.contains_key(&(category, filename.to_string()))
}
pub fn preload_category(&mut self, category: K) -> Result<usize> {
let files = self.root.list_pngs(category)?;
let mut count = 0;
for path in &files {
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
if self.load_asset(category, filename).is_ok() {
count += 1;
}
}
}
Ok(count)
}
}
fn ensure_png_ext(name: &str) -> String {
if name.ends_with(".png") {
name.to_string()
} else {
format!("{}.png", name)
}
}
pub fn load_tileset_from_png(path: impl AsRef<Path>) -> Result<TileSet> {
let loaded = LoadedPng::load(path)?;
png_to_tileset_2bpp(&loaded.image)
}
pub fn load_tileset_from_png_1bpp(path: impl AsRef<Path>) -> Result<TileSet> {
let loaded = LoadedPng::load(path)?;
png_to_tileset_1bpp(&loaded.image)
}
pub fn load_2bpp_from_png(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let loaded = LoadedPng::load(path)?;
png_to_2bpp(&loaded.image)
}
pub fn load_1bpp_from_png(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let loaded = LoadedPng::load(path)?;
png_to_1bpp(&loaded.image)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grayscale_white_maps_to_color_0() {
assert_eq!(grayscale_to_color_index(255), 0);
}
#[test]
fn grayscale_light_gray_maps_to_color_1() {
assert_eq!(grayscale_to_color_index(170), 1);
}
#[test]
fn grayscale_dark_gray_maps_to_color_2() {
assert_eq!(grayscale_to_color_index(85), 2);
}
#[test]
fn grayscale_black_maps_to_color_3() {
assert_eq!(grayscale_to_color_index(0), 3);
}
#[test]
fn grayscale_snapping_near_white() {
assert_eq!(grayscale_to_color_index(213), 0);
assert_eq!(grayscale_to_color_index(240), 0);
}
#[test]
fn grayscale_snapping_near_light_gray() {
assert_eq!(grayscale_to_color_index(128), 1);
assert_eq!(grayscale_to_color_index(212), 1);
}
#[test]
fn grayscale_snapping_near_dark_gray() {
assert_eq!(grayscale_to_color_index(43), 2);
assert_eq!(grayscale_to_color_index(127), 2);
}
#[test]
fn grayscale_snapping_near_black() {
assert_eq!(grayscale_to_color_index(42), 3);
assert_eq!(grayscale_to_color_index(1), 3);
}
#[test]
fn strict_grayscale_exact_values() {
assert_eq!(grayscale_to_color_index_strict(255), Some(0));
assert_eq!(grayscale_to_color_index_strict(170), Some(1));
assert_eq!(grayscale_to_color_index_strict(85), Some(2));
assert_eq!(grayscale_to_color_index_strict(0), Some(3));
}
#[test]
fn strict_grayscale_rejects_non_standard() {
assert_eq!(grayscale_to_color_index_strict(128), None);
assert_eq!(grayscale_to_color_index_strict(200), None);
assert_eq!(grayscale_to_color_index_strict(50), None);
assert_eq!(grayscale_to_color_index_strict(1), None);
}
#[test]
fn bw_white_maps_to_color_0() {
assert_eq!(bw_to_color_index(255), 0);
assert_eq!(bw_to_color_index(128), 0);
}
#[test]
fn bw_black_maps_to_color_3() {
assert_eq!(bw_to_color_index(0), 3);
assert_eq!(bw_to_color_index(127), 3);
}
#[test]
fn png_to_2bpp_single_white_tile() {
let img =
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(8, 8, image::Luma([255])));
let data = png_to_2bpp(&img).unwrap();
assert_eq!(data.len(), 16); for byte in &data {
assert_eq!(*byte, 0x00);
}
}
#[test]
fn png_to_2bpp_single_black_tile() {
let img =
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(8, 8, image::Luma([0])));
let data = png_to_2bpp(&img).unwrap();
assert_eq!(data.len(), 16);
for byte in &data {
assert_eq!(*byte, 0xFF);
}
}
#[test]
fn png_to_2bpp_alternating_colors() {
let mut img = image::GrayImage::from_pixel(8, 8, image::Luma([255]));
for col in (1..8).step_by(2) {
img.put_pixel(col, 0, image::Luma([0]));
}
let dyn_img = image::DynamicImage::ImageLuma8(img);
let data = png_to_2bpp(&dyn_img).unwrap();
assert_eq!(data[0], 0x55); assert_eq!(data[1], 0x55); }
#[test]
fn png_to_2bpp_light_gray_tile() {
let img =
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(8, 8, image::Luma([170])));
let data = png_to_2bpp(&img).unwrap();
for row in 0..8 {
assert_eq!(data[row * 2], 0xFF); assert_eq!(data[row * 2 + 1], 0x00); }
}
#[test]
fn png_to_2bpp_dark_gray_tile() {
let img =
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(8, 8, image::Luma([85])));
let data = png_to_2bpp(&img).unwrap();
for row in 0..8 {
assert_eq!(data[row * 2], 0x00); assert_eq!(data[row * 2 + 1], 0xFF); }
}
#[test]
fn png_to_2bpp_multi_tile() {
let mut img = image::GrayImage::from_pixel(16, 8, image::Luma([255])); for y in 0..8 {
for x in 8..16 {
img.put_pixel(x, y, image::Luma([0]));
}
}
let dyn_img = image::DynamicImage::ImageLuma8(img);
let data = png_to_2bpp(&dyn_img).unwrap();
assert_eq!(data.len(), 32);
for i in 0..16 {
assert_eq!(data[i], 0x00);
}
for i in 16..32 {
assert_eq!(data[i], 0xFF);
}
}
#[test]
fn png_to_2bpp_2x2_tiles() {
let mut img = image::GrayImage::from_pixel(16, 16, image::Luma([255])); for y in 0..8 {
for x in 8..16 {
img.put_pixel(x, y, image::Luma([0]));
}
}
let dyn_img = image::DynamicImage::ImageLuma8(img);
let data = png_to_2bpp(&dyn_img).unwrap();
assert_eq!(data.len(), 64);
for i in 0..16 {
assert_eq!(data[i], 0x00);
}
for i in 16..32 {
assert_eq!(data[i], 0xFF);
}
for i in 32..48 {
assert_eq!(data[i], 0x00);
}
for i in 48..64 {
assert_eq!(data[i], 0x00);
}
}
#[test]
fn png_to_2bpp_rejects_non_multiple_of_8() {
let img = image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(
10,
8,
image::Luma([255]),
));
let result = png_to_2bpp(&img);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ResourceError::InvalidDimensions {
width: 10,
height: 8
}
));
}
#[test]
fn png_to_1bpp_all_white() {
let img =
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(8, 8, image::Luma([255])));
let data = png_to_1bpp(&img).unwrap();
assert_eq!(data.len(), 8); for byte in &data {
assert_eq!(*byte, 0x00); }
}
#[test]
fn png_to_1bpp_all_black() {
let img =
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(8, 8, image::Luma([0])));
let data = png_to_1bpp(&img).unwrap();
assert_eq!(data.len(), 8);
for byte in &data {
assert_eq!(*byte, 0xFF); }
}
#[test]
fn png_to_1bpp_checkerboard() {
let mut img = image::GrayImage::from_pixel(8, 8, image::Luma([255]));
for col in (0..8).step_by(2) {
img.put_pixel(col, 0, image::Luma([0]));
}
let dyn_img = image::DynamicImage::ImageLuma8(img);
let data = png_to_1bpp(&dyn_img).unwrap();
assert_eq!(data[0], 0xAA);
}
#[test]
fn png_to_1bpp_rejects_non_multiple_of_8() {
let img =
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(7, 8, image::Luma([255])));
assert!(png_to_1bpp(&img).is_err());
}
#[test]
fn png_to_tileset_2bpp_produces_correct_tile_count() {
let img = image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(
16,
16,
image::Luma([255]),
));
let ts = png_to_tileset_2bpp(&img).unwrap();
assert_eq!(ts.len(), 4);
}
#[test]
fn png_to_tileset_1bpp_produces_correct_tile_count() {
let img = image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(
128,
64,
image::Luma([255]),
));
let ts = png_to_tileset_1bpp(&img).unwrap();
assert_eq!(ts.len(), 128);
}
#[test]
fn png_to_2bpp_roundtrip_with_tileset() {
let mut img = image::GrayImage::new(8, 8);
let colors = [255u8, 170, 85, 0, 255, 170, 85, 0];
for (col, &val) in colors.iter().enumerate() {
img.put_pixel(col as u32, 0, image::Luma([val]));
}
for y in 1..8 {
for x in 0..8 {
img.put_pixel(x, y, image::Luma([255]));
}
}
let dyn_img = image::DynamicImage::ImageLuma8(img);
let data_2bpp = png_to_2bpp(&dyn_img).unwrap();
let ts = crate::tile::TileSet::from_2bpp(&data_2bpp);
assert_eq!(ts.len(), 1);
let tile = ts.get(0);
assert_eq!(tile.pixels[0], [0, 1, 2, 3, 0, 1, 2, 3]);
for row in 1..8 {
assert_eq!(tile.pixels[row], [0; 8]);
}
}
#[test]
fn png_to_1bpp_roundtrip_with_tileset() {
let mut img = image::GrayImage::new(8, 8);
for col in 0..8 {
let val = if col % 2 == 0 { 0u8 } else { 255 };
img.put_pixel(col, 0, image::Luma([val]));
}
for y in 1..8 {
for x in 0..8 {
img.put_pixel(x, y, image::Luma([255]));
}
}
let dyn_img = image::DynamicImage::ImageLuma8(img);
let data = png_to_1bpp(&dyn_img).unwrap();
let ts = crate::tile::TileSet::from_1bpp(&data);
assert_eq!(ts.len(), 1);
let tile = ts.get(0);
assert_eq!(tile.pixels[0], [3, 0, 3, 0, 3, 0, 3, 0]);
for row in 1..8 {
assert_eq!(tile.pixels[row], [0; 8]);
}
}
#[test]
fn loaded_png_missing_file() {
let result = LoadedPng::load("/nonexistent/path/to/file.png");
assert!(result.is_err());
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum TestKind {
Tiles,
Font,
}
impl AssetKind for TestKind {
fn subdir(self) -> &'static str {
match self {
Self::Tiles => "tiles",
Self::Font => "font",
}
}
fn is_1bpp(self) -> bool {
matches!(self, Self::Font)
}
}
fn temp_asset_root() -> AssetRoot {
let unique = format!(
"dotzuki-resource-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let gfx = std::env::temp_dir().join(unique);
let tiles = gfx.join("tiles");
let font = gfx.join("font");
std::fs::create_dir_all(&tiles).unwrap();
std::fs::create_dir_all(&font).unwrap();
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(16, 8, image::Luma([170])))
.save(tiles.join("a.png"))
.unwrap();
image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(8, 8, image::Luma([0])))
.save(font.join("f.png"))
.unwrap();
AssetRoot::new(&gfx).unwrap()
}
#[test]
fn asset_root_resolve_and_list() {
let root = temp_asset_root();
let path = root.resolve(TestKind::Tiles, "a.png");
assert!(path.to_str().unwrap().contains("tiles/a.png"));
assert!(root.resolve_checked(TestKind::Tiles, "a.png").is_ok());
assert!(root
.resolve_checked(TestKind::Tiles, "missing.png")
.is_err());
let pngs = root.list_pngs(TestKind::Tiles).unwrap();
assert_eq!(pngs.len(), 1);
}
#[test]
fn resource_manager_caches_by_category_and_filename() {
let root = temp_asset_root();
let mut mgr = ResourceManager::<TestKind>::new(root);
assert_eq!(mgr.cache_size(), 0);
assert!(!mgr.is_cached(TestKind::Tiles, "a.png"));
let cached = mgr.load_asset(TestKind::Tiles, "a.png").unwrap();
assert_eq!(cached.source_size, (16, 8));
assert_eq!(cached.tile_count, 2);
assert_eq!(mgr.cache_size(), 1);
assert!(mgr.is_cached(TestKind::Tiles, "a.png"));
let _ = mgr.load_asset(TestKind::Tiles, "a.png").unwrap();
assert_eq!(mgr.cache_size(), 1);
let _ = mgr.load_asset_2bpp(TestKind::Tiles, "a.png").unwrap();
assert_eq!(mgr.cache_size(), 2);
assert!(mgr.evict(TestKind::Tiles, "a.png"));
assert_eq!(mgr.cache_size(), 1);
assert!(!mgr.is_cached(TestKind::Tiles, "a.png"));
mgr.clear_cache();
assert_eq!(mgr.cache_size(), 0);
}
#[test]
fn resource_manager_category_default_encoding() {
let root = temp_asset_root();
let mut mgr = ResourceManager::<TestKind>::new(root);
let cached = mgr.load(TestKind::Font, "f").unwrap();
assert_eq!(cached.tile_count, 1);
assert!(mgr.is_cached(TestKind::Font, "f.png"));
}
}