Skip to main content

dotzuki_renderer/
mon_icon.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::palette::{GbColor, Palette};
5use crate::asset_provider::ResourceProvider;
6use crate::tile::{TileSet, TILE_PIXELS};
7use crate::FbSurface;
8
9pub use crate::icon::IconKind;
10
11/// Animation frame for the party-screen mon icon.
12///
13/// In the original game, the *selected* party mon's icon alternates between
14/// `Frame1` and `Frame2` every few VBlanks (faster the lower its HP).
15/// Non-selected icons stay on `Frame1`.
16#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
17pub enum IconFrame {
18    Frame1,
19    Frame2,
20}
21
22impl IconFrame {
23    /// Pick a frame from a free-running counter.  Frame swaps every
24    /// `period` ticks (e.g. 16 ≈ ~4 swaps/second at 60fps, matching the
25    /// original mid-HP animation speed).
26    pub fn from_counter(counter: u64, period: u64) -> Self {
27        if period == 0 || (counter / period) % 2 == 0 {
28            IconFrame::Frame1
29        } else {
30            IconFrame::Frame2
31        }
32    }
33}
34
35// Cache key includes both the icon kind and which frame, since the two
36// frames are different bitmaps.
37static CACHE: Mutex<Option<HashMap<(IconKind, IconFrame), &'static TileSet>>> = Mutex::new(None);
38
39struct IconAsset {
40    category: &'static str,
41    filename: &'static str,
42    start_tile: usize,
43    tile_count: usize,
44}
45
46fn asset_for(kind: IconKind, frame: IconFrame) -> IconAsset {
47    match (kind, frame) {
48        (IconKind::Mon, _) => IconAsset { category: "sprites", filename: "monster.png", start_tile: 12, tile_count: 4 },
49        (IconKind::Fairy, _) => IconAsset { category: "sprites", filename: "fairy.png", start_tile: 12, tile_count: 4 },
50        (IconKind::Bird, _) => IconAsset { category: "sprites", filename: "bird.png", start_tile: 12, tile_count: 4 },
51        (IconKind::Water, _) => IconAsset { category: "sprites", filename: "fish.png", start_tile: 0, tile_count: 4 },
52        (IconKind::Ball, _) => IconAsset { category: "sprites", filename: "ball.png", start_tile: 0, tile_count: 4 },
53        (IconKind::Helix, _) => IconAsset { category: "sprites", filename: "ball.png", start_tile: 0, tile_count: 4 },
54        (IconKind::Bug, IconFrame::Frame1) => IconAsset { category: "icons", filename: "bug.png", start_tile: 2, tile_count: 2 },
55        (IconKind::Bug, IconFrame::Frame2) => IconAsset { category: "icons", filename: "bug.png", start_tile: 4, tile_count: 2 },
56        (IconKind::Grass, IconFrame::Frame1) => IconAsset { category: "icons", filename: "plant.png", start_tile: 2, tile_count: 2 },
57        (IconKind::Grass, IconFrame::Frame2) => IconAsset { category: "icons", filename: "plant.png", start_tile: 4, tile_count: 2 },
58        (IconKind::Snake, IconFrame::Frame1) => IconAsset { category: "icons", filename: "snake.png", start_tile: 2, tile_count: 2 },
59        (IconKind::Snake, IconFrame::Frame2) => IconAsset { category: "icons", filename: "snake.png", start_tile: 4, tile_count: 2 },
60        (IconKind::Quadruped, IconFrame::Frame1) => IconAsset { category: "icons", filename: "quadruped.png", start_tile: 2, tile_count: 2 },
61        (IconKind::Quadruped, IconFrame::Frame2) => IconAsset { category: "icons", filename: "quadruped.png", start_tile: 4, tile_count: 2 },
62    }
63}
64
65fn extract_16wide(source: &TileSet, start: usize) -> TileSet {
66    let indices = [start, start + 2, start + 1, start + 3];
67    let mut ts = TileSet::blank(4);
68    for (i, &idx) in indices.iter().enumerate() {
69        ts.set(i, source.get(idx).clone());
70    }
71    ts
72}
73
74/// The 8-wide icons (bug, plant, snake, quadruped) are stored column-major in
75/// the Game Boy OAM with X-flip symmetry: the left 8 px form the icon's edge
76/// and the right 8 px are a mirrored copy.  We replicate that here so
77/// `draw_mon_icon` can always use the same 2×2 blit loop.  The two source
78/// tiles (top half, bottom half) are expanded to four by adding X-flipped
79/// copies for the right column.
80fn extract_8wide(source: &TileSet, start: usize) -> TileSet {
81    let top = source.get(start).clone();
82    let bot = source.get(start + 1).clone();
83    let top_flip = top.flip_x();
84    let bot_flip = bot.flip_x();
85    let mut ts = TileSet::blank(4);
86    ts.set(0, top);
87    ts.set(1, bot);
88    ts.set(2, top_flip);
89    ts.set(3, bot_flip);
90    ts
91}
92
93pub fn load_mon_icon_tiles(
94    provider: &mut dyn ResourceProvider,
95    kind: IconKind,
96    frame: IconFrame,
97) -> Result<&TileSet, String> {
98    let key = (kind, frame);
99    {
100        let guard = CACHE.lock().map_err(|e| format!("cache lock poisoned: {}", e))?;
101        if let Some(ref map) = *guard {
102            if let Some(tiles) = map.get(&key) {
103                return Ok(tiles);
104            }
105        }
106    }
107
108    let asset = asset_for(kind, frame);
109    let loaded = provider
110        .load_asset(asset.category, asset.filename)
111        .map_err(|e| format!("failed to load {}: {}", asset.filename, e))?;
112
113    let source = loaded;
114    if source.len() < asset.start_tile + asset.tile_count {
115        // For 8-wide icons whose frame2 slot doesn't exist in the asset,
116        // silently fall back to frame1 so we never crash the party screen.
117        if frame == IconFrame::Frame2 && asset.tile_count == 2 {
118            return load_mon_icon_tiles(provider, kind, IconFrame::Frame1);
119        }
120        return Err(format!(
121            "{} has only {} tiles, need at least {}",
122            asset.filename,
123            source.len(),
124            asset.start_tile + asset.tile_count
125        ));
126    }
127
128    let icon_tiles = if asset.tile_count == 2 {
129        extract_8wide(source, asset.start_tile)
130    } else {
131        extract_16wide(source, asset.start_tile)
132    };
133
134    let leaked: &'static TileSet = Box::leak(Box::new(icon_tiles));
135    let mut guard = CACHE.lock().map_err(|e| format!("cache lock poisoned: {}", e))?;
136    let map = guard.get_or_insert_with(HashMap::new);
137    map.insert(key, leaked);
138    Ok(leaked)
139}
140
141pub fn draw_mon_icon(
142    fb: &mut impl FbSurface,
143    tiles: &TileSet,
144    x: u32,
145    y: u32,
146    palette: &Palette,
147) {
148    let fb_h = fb.height();
149    let fb_w = fb.width();
150    let positions = [(0u32, 0u32), (0, 1), (1, 0), (1, 1)];
151    for (i, (col, row)) in positions.iter().enumerate() {
152        let tile = tiles.get(i);
153        let base_x = x + col * TILE_PIXELS as u32;
154        let base_y = y + row * TILE_PIXELS as u32;
155        for r in 0..TILE_PIXELS {
156            let screen_y = base_y + r as u32;
157            if screen_y >= fb_h {
158                continue;
159            }
160            for c in 0..TILE_PIXELS {
161                let screen_x = base_x + c as u32;
162                if screen_x >= fb_w {
163                    continue;
164                }
165                let color_idx = tile.get(r, c);
166                if color_idx == 0 {
167                    continue;
168                }
169                fb.set_pixel(screen_x, screen_y, palette.color(GbColor::from_u8(color_idx)));
170            }
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use dotzuki_engine::render::Rgba;
179
180    #[test]
181    fn frame_from_counter_alternates() {
182        assert_eq!(IconFrame::from_counter(0, 16), IconFrame::Frame1);
183        assert_eq!(IconFrame::from_counter(15, 16), IconFrame::Frame1);
184        assert_eq!(IconFrame::from_counter(16, 16), IconFrame::Frame2);
185        assert_eq!(IconFrame::from_counter(31, 16), IconFrame::Frame2);
186        assert_eq!(IconFrame::from_counter(32, 16), IconFrame::Frame1);
187    }
188
189    #[test]
190    fn draw_mon_icon_respects_transparent_color0() {
191        let mut fb = crate::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), Rgba::BLACK);
192        let ts = TileSet::blank(4);
193        draw_mon_icon(&mut fb, &ts, 0, 0, &crate::palette::GRAYSCALE_SPRITE_PALETTE);
194        // All blank tiles should be transparent (color 0), so framebuffer stays black
195        for dy in 0..16u32 {
196            for dx in 0..16u32 {
197                assert_eq!(fb.get_pixel(dx, dy), Some(Rgba::BLACK),
198                    "blank icon area should remain background color at ({},{})", dx, dy);
199            }
200        }
201    }
202}