1use crate::tileset::{TilesetError, TilesetOptions};
7use alpha_blend::rgba::U8x4Rgba;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone)]
12pub struct Sprite {
13 pub pixels: Vec<u8>,
15 pub pixel_width: u32,
17 pub pixel_height: u32,
19 pub spacing_cells_x: u16,
21 pub spacing_cells_y: u16,
23}
24
25#[derive(Debug)]
40pub struct SpriteCache {
41 sprites: BTreeMap<char, Sprite>,
42}
43
44impl SpriteCache {
45 #[must_use]
47 pub const fn new() -> Self {
48 Self {
49 sprites: BTreeMap::new(),
50 }
51 }
52
53 #[must_use]
55 pub fn get(&self, ch: char) -> Option<&Sprite> {
56 self.sprites.get(&ch)
57 }
58
59 #[must_use]
64 pub fn iter(&self) -> impl ExactSizeIterator<Item = (char, &Sprite)> {
65 self.sprites.iter().map(|(&ch, sprite)| (ch, sprite))
66 }
67
68 #[must_use]
70 pub fn is_empty(&self) -> bool {
71 self.sprites.is_empty()
72 }
73
74 #[allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
86 pub fn load(&mut self, opts: &TilesetOptions) -> Result<(), TilesetError> {
87 let img = image::load_from_memory(&opts.bytes)
88 .map_err(|e| TilesetError::PngDecode(e.to_string()))?
89 .into_rgba8();
90
91 let img_w = img.width();
92 let img_h = img.height();
93 let tile_w = u32::from(opts.tile_width);
94 let tile_h = u32::from(opts.tile_height);
95
96 if tile_w == 0 || tile_h == 0 {
97 return Err(TilesetError::ZeroTileSize);
98 }
99 if img_w % tile_w != 0 || img_h % tile_h != 0 {
100 return Err(TilesetError::InvalidDimensions(
101 img_w,
102 img_h,
103 opts.tile_width,
104 opts.tile_height,
105 ));
106 }
107
108 let columns = opts.columns.map_or(img_w / tile_w, u32::from);
109 let rows = img_h / tile_h;
110 let total_tiles = (columns * rows) as usize;
111
112 let raw = img.as_raw();
113
114 for tile_idx in 0..total_tiles {
115 let Some(codepoint) = opts.codepage.codepoint(tile_idx) else {
116 break;
117 };
118
119 let tile_col = (tile_idx as u32) % columns;
120 let tile_row = (tile_idx as u32) / columns;
121
122 let px_x = tile_col * tile_w;
124 let px_y = tile_row * tile_h;
125 let mut pixels = vec![0u8; (tile_w * tile_h * 4) as usize];
126
127 for row in 0..tile_h {
128 let src_start = ((px_y + row) * img_w + px_x) as usize * 4;
129 let dst_start = (row * tile_w) as usize * 4;
130 pixels[dst_start..dst_start + (tile_w as usize * 4)]
131 .copy_from_slice(&raw[src_start..src_start + (tile_w as usize * 4)]);
132 }
133
134 if let Some((kr, kg, kb)) = opts.transparent_color {
136 for px in pixels.chunks_exact_mut(4) {
137 if px[0] == kr && px[1] == kg && px[2] == kb {
138 px[3] = 0;
139 }
140 }
141 }
142
143 let sprite = Sprite {
144 pixels,
145 pixel_width: tile_w,
146 pixel_height: tile_h,
147 spacing_cells_x: opts.spacing_cells_x,
148 spacing_cells_y: opts.spacing_cells_y,
149 };
150
151 if self.sprites.insert(codepoint, sprite).is_some() {
152 #[allow(clippy::cast_lossless)]
153 let cp = codepoint as u32;
154 log::warn!("tileset codepoint collision: U+{cp:04X} '{codepoint}' overwritten");
155 }
156 }
157 Ok(())
158 }
159}
160
161impl Default for SpriteCache {
162 fn default() -> Self {
163 Self::new()
164 }
165}
166
167#[inline]
174#[must_use]
175pub fn source_over(src: U8x4Rgba, dst: U8x4Rgba) -> U8x4Rgba {
176 use alpha_blend::rgba::F32x4Rgba;
177 use alpha_blend::{BlendMode, RgbaBlend};
178 BlendMode::SourceOver
179 .apply(F32x4Rgba::from(src), F32x4Rgba::from(dst))
180 .into()
181}
182
183#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::tileset::{Codepage, TilesetOptions};
189 use image::ImageEncoder;
190
191 #[allow(clippy::cast_possible_truncation)]
196 fn make_test_png(tile_w: u32, tile_h: u32, cols: u32, rows: u32) -> Vec<u8> {
197 let img_w = tile_w * cols;
198 let img_h = tile_h * rows;
199 let mut pixels = vec![0u8; (img_w * img_h * 4) as usize];
200
201 for row in 0..rows {
202 for col in 0..cols {
203 let r = ((col * 20) % 256) as u8;
204 let g = ((row * 20) % 256) as u8;
205 for py in 0..tile_h {
206 for px in 0..tile_w {
207 let idx = ((row * tile_h + py) * img_w + col * tile_w + px) as usize * 4;
208 pixels[idx] = r;
209 pixels[idx + 1] = g;
210 pixels[idx + 2] = 0;
211 pixels[idx + 3] = 255;
212 }
213 }
214 }
215 }
216
217 let mut out = std::io::Cursor::new(Vec::new());
218 let encoder = image::codecs::png::PngEncoder::new(&mut out);
219 encoder
220 .write_image(&pixels, img_w, img_h, image::ExtendedColorType::Rgba8)
221 .unwrap();
222 out.into_inner()
223 }
224
225 #[test]
226 fn sprite_cache_load_cp437_sheet() {
227 let png = make_test_png(16, 16, 16, 16); let opts = TilesetOptions::from_bytes(png)
229 .tile_size(16, 16)
230 .codepage(Codepage::Cp437)
231 .build()
232 .unwrap();
233 let mut cache = SpriteCache::new();
234 cache.load(&opts).unwrap();
235 let sprite = cache.get('@').expect("'@' must be in CP437 cache");
236 assert_eq!(sprite.pixel_width, 16);
237 assert_eq!(sprite.pixel_height, 16);
238 assert_eq!(sprite.pixels.len(), 16 * 16 * 4);
239 }
240
241 #[test]
242 fn sprite_cache_rejects_bad_dimensions() {
243 let png = make_test_png(17, 16, 1, 1);
244 let opts = TilesetOptions::from_bytes(png)
245 .tile_size(16, 16)
246 .build()
247 .unwrap();
248 let mut cache = SpriteCache::new();
249 let err = cache.load(&opts).unwrap_err();
250 assert!(matches!(
251 err,
252 TilesetError::InvalidDimensions(17, 16, 16, 16)
253 ));
254 }
255
256 #[test]
257 fn sprite_cache_load_empty_bytes_errors() {
258 let opts = TilesetOptions::from_bytes(vec![])
259 .tile_size(16, 16)
260 .build()
261 .unwrap();
262 let mut cache = SpriteCache::new();
263 assert!(matches!(cache.load(&opts), Err(TilesetError::PngDecode(_))));
264 }
265
266 #[test]
267 fn sprite_cache_last_registration_wins_on_collision() {
268 let png1 = make_test_png(16, 16, 1, 1);
269 let png2 = make_test_png(8, 8, 1, 1);
270 let opts1 = TilesetOptions::from_bytes(png1)
271 .tile_size(16, 16)
272 .start_codepoint('A')
273 .build()
274 .unwrap();
275 let opts2 = TilesetOptions::from_bytes(png2)
276 .tile_size(8, 8)
277 .start_codepoint('A')
278 .build()
279 .unwrap();
280 let mut cache = SpriteCache::new();
281 cache.load(&opts1).unwrap();
282 cache.load(&opts2).unwrap();
283 let sprite = cache.get('A').unwrap();
284 assert_eq!(sprite.pixel_width, 8); }
286
287 #[test]
288 fn sprite_cache_load_identity_codepage() {
289 let png = make_test_png(16, 16, 4, 1); let opts = TilesetOptions::from_bytes(png)
291 .tile_size(16, 16)
292 .codepage(Codepage::Identity)
293 .build()
294 .unwrap();
295 let mut cache = SpriteCache::new();
296 cache.load(&opts).unwrap();
297 assert!(cache.get('\0').is_some());
299 assert!(cache.get('\x01').is_some());
300 assert!(cache.get('\x03').is_some());
301 assert!(cache.get('\x04').is_none()); }
303
304 #[test]
305 fn sprite_cache_custom_codepage_stops_at_table_end() {
306 let png = make_test_png(16, 16, 4, 1); let opts = TilesetOptions::from_bytes(png)
308 .tile_size(16, 16)
309 .codepage(Codepage::Custom(vec!['A', 'B'])) .build()
311 .unwrap();
312 let mut cache = SpriteCache::new();
313 cache.load(&opts).unwrap();
314 assert!(cache.get('A').is_some());
315 assert!(cache.get('B').is_some());
316 assert!(cache.get('C').is_none()); }
318
319 #[test]
322 fn source_over_opaque_overwrites_destination() {
323 let src = U8x4Rgba::new(0, 255, 0, 255); let dst = U8x4Rgba::new(255, 0, 0, 255); let result = source_over(src, dst);
326 assert_eq!(result, src);
327 }
328
329 #[test]
330 fn source_over_transparent_preserves_destination() {
331 let src = U8x4Rgba::TRANSPARENT;
332 let dst = U8x4Rgba::new(255, 0, 0, 255);
333 let result = source_over(src, dst);
334 assert_eq!(result, dst);
335 }
336
337 #[test]
338 fn source_over_half_alpha_blends() {
339 let src = U8x4Rgba::new(0, 255, 0, 128);
341 let dst = U8x4Rgba::new(255, 0, 0, 255);
342 let result = source_over(src, dst);
343 assert_eq!(result.r, 127);
354 assert_eq!(result.g, 128);
355 assert_eq!(result.b, 0);
356 assert_eq!(result.a, 191);
357 }
358}