1use crate::tileset::{SheetColor, SpriteAlign, TilesetError, TilesetOptions};
7#[cfg(test)]
11use alpha_blend::rgba::U8x4Rgba;
12use retroglyph_core::dev_only;
13use retroglyph_core::{Color, Tint};
14use std::collections::{BTreeMap, BTreeSet};
15
16#[derive(Debug, Clone)]
18#[non_exhaustive]
19pub struct Sprite {
20 pub pixels: Vec<u8>,
22 pub pixel_width: u32,
24 pub pixel_height: u32,
26 pub align: SpriteAlign,
28 pub color: SheetColor,
35}
36
37impl Sprite {
38 #[must_use]
50 pub const fn align_offset(
51 &self,
52 span_w: u16,
53 span_h: u16,
54 glyph_w: u8,
55 glyph_h: u8,
56 ) -> (i16, i16) {
57 let box_w = span_w as u32 * if glyph_w == 0 { 1 } else { glyph_w as u32 };
60 let box_h = span_h as u32 * if glyph_h == 0 { 1 } else { glyph_h as u32 };
61 self.align
62 .offset(self.pixel_width, self.pixel_height, box_w, box_h)
63 }
64}
65
66#[derive(Debug)]
81pub struct SpriteCache {
82 sprites: BTreeMap<char, Sprite>,
83}
84
85impl SpriteCache {
86 #[must_use]
88 pub const fn new() -> Self {
89 Self {
90 sprites: BTreeMap::new(),
91 }
92 }
93
94 #[must_use]
96 pub fn get(&self, ch: char) -> Option<&Sprite> {
97 self.sprites.get(&ch)
98 }
99
100 #[must_use]
105 pub fn iter(&self) -> impl ExactSizeIterator<Item = (char, &Sprite)> {
106 self.sprites.iter().map(|(&ch, sprite)| (ch, sprite))
107 }
108
109 #[must_use]
111 pub fn is_empty(&self) -> bool {
112 self.sprites.is_empty()
113 }
114
115 #[allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
127 pub fn load(&mut self, opts: &TilesetOptions) -> Result<(), TilesetError> {
128 let img = image::load_from_memory(&opts.bytes)
129 .map_err(|e| TilesetError::PngDecode(e.to_string()))?
130 .into_rgba8();
131
132 let img_w = img.width();
133 let img_h = img.height();
134 let tile_w = u32::from(opts.tile_width);
135 let tile_h = u32::from(opts.tile_height);
136
137 if tile_w == 0 || tile_h == 0 {
138 return Err(TilesetError::ZeroTileSize);
139 }
140 if img_w % tile_w != 0 || img_h % tile_h != 0 {
141 return Err(TilesetError::InvalidDimensions(
142 img_w,
143 img_h,
144 opts.tile_width,
145 opts.tile_height,
146 ));
147 }
148
149 let columns = opts.columns.map_or(img_w / tile_w, u32::from);
150 let rows = img_h / tile_h;
151 let total_tiles = (columns * rows) as usize;
152
153 let raw = img.as_raw();
154
155 for tile_idx in 0..total_tiles {
156 let Some(codepoint) = opts.codepage.codepoint(tile_idx) else {
157 break;
158 };
159
160 let tile_col = (tile_idx as u32) % columns;
161 let tile_row = (tile_idx as u32) / columns;
162
163 let px_x = tile_col * tile_w;
165 let px_y = tile_row * tile_h;
166 let mut pixels = vec![0u8; (tile_w * tile_h * 4) as usize];
167
168 for row in 0..tile_h {
169 let src_start = ((px_y + row) * img_w + px_x) as usize * 4;
170 let dst_start = (row * tile_w) as usize * 4;
171 pixels[dst_start..dst_start + (tile_w as usize * 4)]
172 .copy_from_slice(&raw[src_start..src_start + (tile_w as usize * 4)]);
173 }
174
175 if let Some((kr, kg, kb)) = opts.transparent_color {
177 for px in pixels.chunks_exact_mut(4) {
178 if px[0] == kr && px[1] == kg && px[2] == kb {
179 px[3] = 0;
180 }
181 }
182 }
183
184 let sprite = Sprite {
185 pixels,
186 pixel_width: tile_w,
187 pixel_height: tile_h,
188 align: opts.align,
189 color: opts.color,
190 };
191
192 if self.sprites.insert(codepoint, sprite).is_some() {
193 #[allow(clippy::cast_lossless)]
194 let cp = codepoint as u32;
195 log::warn!("tileset codepoint collision: U+{cp:04X} '{codepoint}' overwritten");
196 }
197 }
198 Ok(())
199 }
200}
201
202impl Default for SpriteCache {
203 fn default() -> Self {
204 Self::new()
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
220pub struct SpriteTint {
221 pub mask: Tint,
226 pub tint: Tint,
228}
229
230impl SpriteTint {
231 #[must_use]
242 pub const fn resolve(
243 sheet: SheetColor,
244 fg: Color,
245 tint: Tint,
246 default_fg: (u8, u8, u8),
247 ) -> Self {
248 let mask = match sheet {
249 SheetColor::Art => Tint::None,
250 SheetColor::Mask => {
251 let (r, g, b) = fg.resolve_rgb(default_fg);
252 Tint::multiply(r, g, b)
253 }
254 };
255 Self { mask, tint }
256 }
257
258 #[must_use]
261 pub const fn is_identity(&self) -> bool {
262 self.mask.is_identity() && self.tint.is_identity()
263 }
264
265 #[must_use]
267 pub const fn apply(&self, rgb: (u8, u8, u8)) -> (u8, u8, u8) {
268 self.tint.apply(self.mask.apply(rgb))
269 }
270}
271
272pub fn warn_sprite_needs_span(
290 seen: &mut BTreeSet<char>,
291 glyph: char,
292 sprite: (u32, u32),
293 cell: (u32, u32),
294) -> bool {
295 dev_only!({
296 let ((w, h), (cell_w, cell_h)) = (sprite, cell);
297 if w <= cell_w && h <= cell_h {
298 return false;
299 }
300 if !seen.insert(glyph) {
301 return false;
302 }
303 log::warn!(
304 "sprite for {glyph:?} is {w}x{h}px, larger than the {cell_w}x{cell_h}px cell, but was \
305 drawn without a span: neighbouring cells will paint over it. Reserve the cells it \
306 covers with `Surface::put_span`."
307 );
308 return true;
309 });
310 false
311}
312
313pub fn warn_tint_needs_sprite(seen: &mut BTreeSet<char>, glyph: char, tint: Tint) -> bool {
332 dev_only!({
333 if tint.is_identity() {
334 return false;
335 }
336 if !seen.insert(glyph) {
337 return false;
338 }
339 log::warn!(
340 "cell for {glyph:?} has a tint but no sprite is registered for it, so it renders as \
341 the bitmap font glyph and the tint has no effect. Register a sprite for that \
342 codepoint, or clear the tint."
343 );
344 return true;
345 });
346 false
347}
348
349#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::tileset::{Codepage, SpriteAlign, TilesetOptions};
355 use image::ImageEncoder;
356
357 #[allow(clippy::cast_possible_truncation)]
362 fn make_test_png(tile_w: u32, tile_h: u32, cols: u32, rows: u32) -> Vec<u8> {
363 let img_w = tile_w * cols;
364 let img_h = tile_h * rows;
365 let mut pixels = vec![0u8; (img_w * img_h * 4) as usize];
366
367 for row in 0..rows {
368 for col in 0..cols {
369 let r = ((col * 20) % 256) as u8;
370 let g = ((row * 20) % 256) as u8;
371 for py in 0..tile_h {
372 for px in 0..tile_w {
373 let idx = ((row * tile_h + py) * img_w + col * tile_w + px) as usize * 4;
374 pixels[idx] = r;
375 pixels[idx + 1] = g;
376 pixels[idx + 2] = 0;
377 pixels[idx + 3] = 255;
378 }
379 }
380 }
381 }
382
383 let mut out = std::io::Cursor::new(Vec::new());
384 let encoder = image::codecs::png::PngEncoder::new(&mut out);
385 encoder
386 .write_image(&pixels, img_w, img_h, image::ExtendedColorType::Rgba8)
387 .unwrap();
388 out.into_inner()
389 }
390
391 #[test]
392 fn sprite_cache_load_cp437_sheet() {
393 let png = make_test_png(16, 16, 16, 16); let opts = TilesetOptions::from_bytes(png)
395 .tile_size(16, 16)
396 .codepage(Codepage::Cp437)
397 .build()
398 .unwrap();
399 let mut cache = SpriteCache::new();
400 cache.load(&opts).unwrap();
401 let sprite = cache.get('@').expect("'@' must be in CP437 cache");
402 assert_eq!(sprite.pixel_width, 16);
403 assert_eq!(sprite.pixel_height, 16);
404 assert_eq!(sprite.pixels.len(), 16 * 16 * 4);
405 }
406
407 #[test]
408 fn sprite_cache_rejects_bad_dimensions() {
409 let png = make_test_png(17, 16, 1, 1);
410 let opts = TilesetOptions::from_bytes(png)
411 .tile_size(16, 16)
412 .build()
413 .unwrap();
414 let mut cache = SpriteCache::new();
415 let err = cache.load(&opts).unwrap_err();
416 assert!(matches!(
417 err,
418 TilesetError::InvalidDimensions(17, 16, 16, 16)
419 ));
420 }
421
422 #[test]
423 fn sprite_cache_load_empty_bytes_errors() {
424 let opts = TilesetOptions::from_bytes(vec![])
425 .tile_size(16, 16)
426 .build()
427 .unwrap();
428 let mut cache = SpriteCache::new();
429 assert!(matches!(cache.load(&opts), Err(TilesetError::PngDecode(_))));
430 }
431
432 #[test]
433 fn sprite_cache_last_registration_wins_on_collision() {
434 let png1 = make_test_png(16, 16, 1, 1);
435 let png2 = make_test_png(8, 8, 1, 1);
436 let opts1 = TilesetOptions::from_bytes(png1)
437 .tile_size(16, 16)
438 .start_codepoint('A')
439 .build()
440 .unwrap();
441 let opts2 = TilesetOptions::from_bytes(png2)
442 .tile_size(8, 8)
443 .start_codepoint('A')
444 .build()
445 .unwrap();
446 let mut cache = SpriteCache::new();
447 cache.load(&opts1).unwrap();
448 cache.load(&opts2).unwrap();
449 let sprite = cache.get('A').unwrap();
450 assert_eq!(sprite.pixel_width, 8); }
452
453 #[test]
454 fn sprite_cache_load_identity_codepage() {
455 let png = make_test_png(16, 16, 4, 1); let opts = TilesetOptions::from_bytes(png)
457 .tile_size(16, 16)
458 .codepage(Codepage::Identity)
459 .build()
460 .unwrap();
461 let mut cache = SpriteCache::new();
462 cache.load(&opts).unwrap();
463 assert!(cache.get('\0').is_some());
465 assert!(cache.get('\x01').is_some());
466 assert!(cache.get('\x03').is_some());
467 assert!(cache.get('\x04').is_none()); }
469
470 #[test]
471 fn sprite_cache_custom_codepage_stops_at_table_end() {
472 let png = make_test_png(16, 16, 4, 1); let opts = TilesetOptions::from_bytes(png)
474 .tile_size(16, 16)
475 .codepage(Codepage::Custom(vec!['A', 'B'])) .build()
477 .unwrap();
478 let mut cache = SpriteCache::new();
479 cache.load(&opts).unwrap();
480 assert!(cache.get('A').is_some());
481 assert!(cache.get('B').is_some());
482 assert!(cache.get('C').is_none()); }
484
485 fn one_sprite(tile_w: u32, tile_h: u32, align: SpriteAlign) -> Sprite {
489 let png = make_test_png(tile_w, tile_h, 1, 1);
490 #[allow(clippy::cast_possible_truncation)]
491 let opts = TilesetOptions::from_bytes(png)
492 .tile_size(tile_w as u16, tile_h as u16)
493 .codepage(Codepage::Custom(vec!['A']))
494 .align(align)
495 .build()
496 .unwrap();
497 let mut cache = SpriteCache::new();
498 cache.load(&opts).unwrap();
499 cache.get('A').unwrap().clone()
500 }
501
502 #[test]
503 fn sprite_align_offset_centres_art_in_a_multi_cell_box() {
504 let sprite = one_sprite(8, 16, SpriteAlign::Center);
506 assert_eq!(sprite.align_offset(2, 1, 8, 16), (4, 0));
507 assert_eq!(sprite.align_offset(2, 2, 8, 16), (4, 8));
508 }
509
510 #[test]
511 fn sprite_align_offset_is_zero_when_the_art_fills_its_span() {
512 let sprite = one_sprite(16, 32, SpriteAlign::Center);
514 assert_eq!(sprite.align_offset(2, 2, 8, 16), (0, 0));
515 }
516
517 #[test]
518 fn sprite_align_offset_defaults_to_top_left() {
519 let sprite = one_sprite(8, 16, SpriteAlign::TopLeft);
520 assert_eq!(sprite.align_offset(4, 4, 8, 16), (0, 0));
521 }
522
523 #[test]
524 fn sprite_align_offset_tolerates_a_zero_cell_size() {
525 let sprite = one_sprite(8, 16, SpriteAlign::Center);
527 assert_eq!(sprite.align_offset(2, 2, 0, 0), (0, 0));
528 }
529
530 #[test]
533 fn source_over_opaque_overwrites_destination() {
534 let src = U8x4Rgba::new(0, 255, 0, 255); let dst = U8x4Rgba::new(255, 0, 0, 255); let result = src.source_over(dst);
537 assert_eq!(result, src);
538 }
539
540 #[test]
541 fn source_over_transparent_preserves_destination() {
542 let src = U8x4Rgba::TRANSPARENT;
543 let dst = U8x4Rgba::new(255, 0, 0, 255);
544 let result = src.source_over(dst);
545 assert_eq!(result, dst);
546 }
547
548 #[test]
549 fn source_over_half_alpha_blends() {
550 let src = U8x4Rgba::new(0, 255, 0, 128);
552 let dst = U8x4Rgba::new(255, 0, 0, 255);
553 let result = src.source_over(dst);
554 assert_eq!(result, U8x4Rgba::new(127, 128, 0, 255));
565 }
566
567 fn sprite_with(color: SheetColor) -> Sprite {
570 Sprite {
571 pixels: vec![255, 255, 255, 255],
572 pixel_width: 1,
573 pixel_height: 1,
574 align: SpriteAlign::TopLeft,
575 color,
576 }
577 }
578
579 const DEFAULT_FG: (u8, u8, u8) = (0xD4, 0xD4, 0xD4);
580
581 #[test]
582 fn art_sheet_ignores_fg_entirely() {
583 let art = sprite_with(SheetColor::Art);
584 let resolved = SpriteTint::resolve(art.color, Color::RED, Tint::None, DEFAULT_FG);
585
586 assert_eq!(resolved.mask, Tint::None);
587 assert!(resolved.is_identity());
588 assert_eq!(resolved.apply((10, 200, 30)), (10, 200, 30));
590 }
591
592 #[test]
593 fn mask_sheet_takes_its_colour_from_fg() {
594 let mask = sprite_with(SheetColor::Mask);
595 let (r, g, b) = Color::RED.resolve_rgb(DEFAULT_FG);
596 let resolved = SpriteTint::resolve(mask.color, Color::RED, Tint::None, DEFAULT_FG);
597
598 assert_eq!(resolved.mask, Tint::multiply(r, g, b));
599 assert_eq!(resolved.apply((255, 255, 255)), (r, g, b));
601 }
602
603 #[test]
604 fn mask_sheet_shades_a_grey_pixel_proportionally() {
605 let mask = sprite_with(SheetColor::Mask);
606 let resolved = SpriteTint::resolve(
607 mask.color,
608 Color::Rgb {
609 r: 200,
610 g: 100,
611 b: 50,
612 },
613 Tint::None,
614 DEFAULT_FG,
615 );
616
617 let (r, _, _) = resolved.apply((128, 128, 128));
620 assert!(r > 0 && r < 200, "expected a shade of the fg, got {r}");
621 }
622
623 #[test]
624 fn mask_sheet_resolves_default_fg_through_the_palette() {
625 let mask = sprite_with(SheetColor::Mask);
626 let resolved = SpriteTint::resolve(mask.color, Color::Default, Tint::None, DEFAULT_FG);
627
628 assert_eq!(resolved.mask, Tint::multiply(0xD4, 0xD4, 0xD4));
631 }
632
633 #[test]
634 fn the_cell_tint_applies_on_top_of_an_art_sheet() {
635 let art = sprite_with(SheetColor::Art);
636 let resolved = SpriteTint::resolve(
637 art.color,
638 Color::RED,
639 Tint::multiply(128, 128, 128),
640 DEFAULT_FG,
641 );
642
643 assert!(!resolved.is_identity());
644 assert_eq!(resolved.apply((200, 180, 60)), (100, 90, 30));
645 }
646
647 #[test]
648 fn both_stages_apply_in_order_on_a_mask_sheet() {
649 let mask = sprite_with(SheetColor::Mask);
650 let flash = Tint::mix(255, 255, 255, 255);
651 let resolved = SpriteTint::resolve(
652 mask.color,
653 Color::Rgb { r: 255, g: 0, b: 0 },
654 flash,
655 DEFAULT_FG,
656 );
657
658 assert_eq!(resolved.apply((255, 255, 255)), (255, 255, 255));
661 }
662
663 #[test]
664 fn an_untouched_art_cell_is_identity_so_renderers_can_skip_the_work() {
665 let art = sprite_with(SheetColor::Art);
666 assert!(
667 SpriteTint::resolve(art.color, Color::Default, Tint::None, DEFAULT_FG).is_identity()
668 );
669 let mask = sprite_with(SheetColor::Mask);
671 assert!(
672 !SpriteTint::resolve(mask.color, Color::Default, Tint::None, DEFAULT_FG).is_identity()
673 );
674 }
675
676 #[test]
681 fn warn_sprite_needs_span_reports_an_oversized_sprite_once() {
682 let mut seen = BTreeSet::new();
683 assert_eq!(
684 warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16)),
685 retroglyph_core::DEV
686 );
687 assert!(!warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16)));
689 }
690
691 #[test]
692 fn warn_sprite_needs_span_is_silent_for_a_sprite_that_fits() {
693 let mut seen = BTreeSet::new();
694 assert!(!warn_sprite_needs_span(&mut seen, '@', (16, 16), (16, 16)));
695 assert!(seen.is_empty());
696 }
697
698 #[test]
699 fn warn_sprite_needs_span_touches_no_state_outside_a_reporting_build() {
700 let mut seen = BTreeSet::new();
701 warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16));
702 assert_eq!(seen.is_empty(), !retroglyph_core::DEV);
704 }
705
706 #[test]
711 fn warn_tint_needs_sprite_reports_a_dropped_tint_once() {
712 let mut seen = BTreeSet::new();
713 let tint = Tint::multiply(128, 128, 128);
714 assert_eq!(
715 warn_tint_needs_sprite(&mut seen, '@', tint),
716 retroglyph_core::DEV
717 );
718 assert!(!warn_tint_needs_sprite(&mut seen, '@', tint));
720 }
721
722 #[test]
723 fn warn_tint_needs_sprite_is_silent_for_tint_none() {
724 let mut seen = BTreeSet::new();
725 assert!(!warn_tint_needs_sprite(&mut seen, '@', Tint::None));
726 assert!(seen.is_empty());
727 }
728
729 #[test]
730 fn warn_tint_needs_sprite_is_silent_for_an_identity_tint() {
731 let mut seen = BTreeSet::new();
732 assert!(!warn_tint_needs_sprite(
733 &mut seen,
734 '@',
735 Tint::multiply(255, 255, 255)
736 ));
737 assert!(seen.is_empty());
738 }
739
740 #[test]
741 fn warn_tint_needs_sprite_touches_no_state_outside_a_reporting_build() {
742 let mut seen = BTreeSet::new();
743 warn_tint_needs_sprite(&mut seen, '@', Tint::multiply(128, 128, 128));
744 assert_eq!(seen.is_empty(), !retroglyph_core::DEV);
746 }
747}