Skip to main content

dotzuki_renderer/
tile.rs

1//! Tile decoding and tileset management.
2//!
3//! Game Boy tiles are 8×8 pixels, stored in 2bpp (2 bits per pixel) format.
4//! Each row is 2 bytes: the low bit-plane and the high bit-plane.
5//! Pixel colors are indices 0–3 into a palette.
6
7use crate::palette::{GbColor, Palette};
8use dotzuki_engine::render::Rgba;
9
10/// Number of bytes per tile row in 2bpp format (low byte + high byte).
11pub const BYTES_PER_TILE_ROW: usize = 2;
12/// Total bytes per 8×8 tile in 2bpp format.
13pub const BYTES_PER_TILE: usize = 16;
14/// Number of pixels per tile side.
15pub const TILE_PIXELS: usize = 8;
16
17/// Tile data format.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TileFormat {
20    /// Game Boy 2bpp: 2 bits per pixel, 4 colors (0–3), 16 bytes per tile.
21    Gb2bpp,
22    /// GBA-style 4bpp: 4 bits per pixel, 16 colors (0–15), 32 bytes per tile.
23    /// Uses 2 bitplanes × 16 bytes each.
24    Gba4bpp,
25    /// Direct RGBA: each pixel stored as 4 bytes (R, G, B, A), 256 bytes per tile.
26    FullColor,
27}
28
29/// A decoded 8×8 tile. Each element is a color index (0–3).
30#[derive(Debug, Clone)]
31pub struct Tile {
32    /// 8 rows × 8 columns of palette indices (0–3).
33    /// Indexed as `pixels[row][col]`.
34    pub pixels: [[u8; TILE_PIXELS]; TILE_PIXELS],
35}
36
37impl Tile {
38    /// Decode a tile from 16 bytes of 2bpp data.
39    ///
40    /// Each row is 2 bytes: `low_byte` then `high_byte`.
41    /// Bit 7 = leftmost pixel. The color index for pixel `x` is:
42    ///   `((high_byte >> (7-x)) & 1) << 1 | ((low_byte >> (7-x)) & 1)`
43    pub fn from_2bpp(data: &[u8]) -> Self {
44        assert!(
45            data.len() >= BYTES_PER_TILE,
46            "Need {} bytes for a tile, got {}",
47            BYTES_PER_TILE,
48            data.len()
49        );
50        let mut pixels = [[0u8; TILE_PIXELS]; TILE_PIXELS];
51        for row in 0..TILE_PIXELS {
52            let lo = data[row * 2];
53            let hi = data[row * 2 + 1];
54            for col in 0..TILE_PIXELS {
55                let bit = 7 - col;
56                let color_index = ((hi >> bit) & 1) << 1 | ((lo >> bit) & 1);
57                pixels[row][col] = color_index;
58            }
59        }
60        Self { pixels }
61    }
62
63    /// Create a blank (all color 0) tile.
64    pub fn blank() -> Self {
65        Self {
66            pixels: [[0; TILE_PIXELS]; TILE_PIXELS],
67        }
68    }
69
70    /// Get the color index at (row, col).
71    #[inline]
72    pub fn get(&self, row: usize, col: usize) -> u8 {
73        self.pixels[row][col]
74    }
75
76    /// Render this tile's row into RGBA pixels using a palette.
77    /// Returns 8 RGBA values for the given tile row.
78    pub fn render_row(&self, row: usize, palette: &Palette) -> [Rgba; TILE_PIXELS] {
79        let mut out = [Rgba::TRANSPARENT; TILE_PIXELS];
80        for col in 0..TILE_PIXELS {
81            let color_idx = GbColor::from_u8(self.pixels[row][col]);
82            out[col] = palette.color(color_idx);
83        }
84        out
85    }
86
87    /// Check if this tile is vertically flipped.
88    pub fn flip_y(&self) -> Tile {
89        let mut pixels = [[0u8; TILE_PIXELS]; TILE_PIXELS];
90        for row in 0..TILE_PIXELS {
91            pixels[row] = self.pixels[TILE_PIXELS - 1 - row];
92        }
93        Tile { pixels }
94    }
95
96    /// Check if this tile is horizontally flipped.
97    pub fn flip_x(&self) -> Tile {
98        let mut pixels = [[0u8; TILE_PIXELS]; TILE_PIXELS];
99        for row in 0..TILE_PIXELS {
100            for col in 0..TILE_PIXELS {
101                pixels[row][col] = self.pixels[row][TILE_PIXELS - 1 - col];
102            }
103        }
104        Tile { pixels }
105    }
106}
107
108/// A set of decoded tiles, indexed by tile number.
109#[derive(Debug, Clone)]
110pub struct TileSet {
111    tiles: Vec<Tile>,
112    format: TileFormat,
113    rgba_tiles: Vec<RgbaTile>,
114}
115
116impl TileSet {
117    /// Create a tileset by decoding 2bpp tile data.
118    /// The data length must be a multiple of 16 (bytes per tile).
119    pub fn from_2bpp(data: &[u8]) -> Self {
120        assert!(
121            data.len() % BYTES_PER_TILE == 0,
122            "Tile data length {} is not a multiple of {}",
123            data.len(),
124            BYTES_PER_TILE,
125        );
126        let count = data.len() / BYTES_PER_TILE;
127        let mut tiles = Vec::with_capacity(count);
128        for i in 0..count {
129            let start = i * BYTES_PER_TILE;
130            tiles.push(Tile::from_2bpp(&data[start..start + BYTES_PER_TILE]));
131        }
132        Self {
133            tiles,
134            format: TileFormat::Gb2bpp,
135            rgba_tiles: Vec::new(),
136        }
137    }
138
139    /// Create an empty tileset with `count` blank tiles.
140    pub fn blank(count: usize) -> Self {
141        Self {
142            tiles: vec![Tile::blank(); count],
143            format: TileFormat::Gb2bpp,
144            rgba_tiles: Vec::new(),
145        }
146    }
147
148    /// Get a tile by index. Returns blank tile if out of bounds.
149    pub fn get(&self, index: usize) -> &Tile {
150        if index < self.tiles.len() {
151            &self.tiles[index]
152        } else {
153            // Return a static blank tile for out-of-bounds access
154            // This matches GB behavior where VRAM reads wrap
155            static BLANK: Tile = Tile {
156                pixels: [[0; TILE_PIXELS]; TILE_PIXELS],
157            };
158            &BLANK
159        }
160    }
161
162    /// Number of tiles in this set.
163    pub fn len(&self) -> usize {
164        self.tiles.len()
165    }
166
167    /// Whether this tileset is empty.
168    pub fn is_empty(&self) -> bool {
169        self.tiles.is_empty()
170    }
171
172    /// Replace a tile at the given index.
173    pub fn set(&mut self, index: usize, tile: Tile) {
174        if index < self.tiles.len() {
175            self.tiles[index] = tile;
176        }
177    }
178
179    /// Load raw 2bpp data into the tileset starting at tile index `start_tile`.
180    /// Overwrites existing tiles.
181    pub fn load_2bpp_at(&mut self, start_tile: usize, data: &[u8]) {
182        let count = data.len() / BYTES_PER_TILE;
183        for i in 0..count {
184            let tile_idx = start_tile + i;
185            if tile_idx >= self.tiles.len() {
186                break;
187            }
188            let start = i * BYTES_PER_TILE;
189            self.tiles[tile_idx] = Tile::from_2bpp(&data[start..start + BYTES_PER_TILE]);
190        }
191    }
192
193    /// Decode 1bpp tile data (8 bytes per tile, 1 bit per pixel).
194    /// Color 0 → palette index 0, color 1 → palette index 3 (black).
195    pub fn from_1bpp(data: &[u8]) -> Self {
196        let bytes_per_tile_1bpp = 8;
197        assert!(
198            data.len() % bytes_per_tile_1bpp == 0,
199            "1bpp tile data length {} is not a multiple of {}",
200            data.len(),
201            bytes_per_tile_1bpp,
202        );
203        let count = data.len() / bytes_per_tile_1bpp;
204        let mut tiles = Vec::with_capacity(count);
205        for i in 0..count {
206            let mut pixels = [[0u8; TILE_PIXELS]; TILE_PIXELS];
207            for row in 0..TILE_PIXELS {
208                let byte = data[i * bytes_per_tile_1bpp + row];
209                for col in 0..TILE_PIXELS {
210                    let bit = 7 - col;
211                    // 1bpp: bit=1 → color 3 (black), bit=0 → color 0 (white)
212                    pixels[row][col] = if (byte >> bit) & 1 == 1 { 3 } else { 0 };
213                }
214            }
215            tiles.push(Tile { pixels });
216        }
217        Self {
218            tiles,
219            format: TileFormat::Gb2bpp,
220            rgba_tiles: Vec::new(),
221        }
222    }
223
224    /// Decode GBA-style 4bpp tile data (32 bytes per tile, 2 bitplanes × 16 bytes each).
225    ///
226    /// Each tile requires 32 bytes. The first 16 bytes are bitplane 0, the
227    /// next 16 bytes are bitplane 1. Each bitplane uses standard 2bpp
228    /// encoding (2 bytes per row, low byte then high byte), contributing 2
229    /// bits per pixel. Combined they give 4 bits per pixel (0–15), stored
230    /// in [`Tile::pixels`] as u8.
231    pub fn from_4bpp(data: &[u8]) -> Self {
232        const BYTES_PER_TILE_4BPP: usize = 32;
233        assert!(
234            data.len() % BYTES_PER_TILE_4BPP == 0,
235            "4bpp tile data length {} is not a multiple of {}",
236            data.len(),
237            BYTES_PER_TILE_4BPP,
238        );
239        let count = data.len() / BYTES_PER_TILE_4BPP;
240        let mut tiles = Vec::with_capacity(count);
241        for ti in 0..count {
242            let base = ti * BYTES_PER_TILE_4BPP;
243            let mut pixels = [[0u8; TILE_PIXELS]; TILE_PIXELS];
244            for row in 0..TILE_PIXELS {
245                let p0_lo = data[base + row * 2];
246                let p0_hi = data[base + row * 2 + 1];
247                let p1_lo = data[base + 16 + row * 2];
248                let p1_hi = data[base + 16 + row * 2 + 1];
249                for col in 0..TILE_PIXELS {
250                    let bit = 7 - col;
251                    // Plane 0 contributes 2 bits (standard 2bpp)
252                    let p0_color = ((p0_hi >> bit) & 1) << 1 | ((p0_lo >> bit) & 1);
253                    // Plane 1 contributes 2 bits (standard 2bpp)
254                    let p1_color = ((p1_hi >> bit) & 1) << 1 | ((p1_lo >> bit) & 1);
255                    // Combined: 4-bit color index (0–15)
256                    pixels[row][col] = (p1_color << 2) | p0_color;
257                }
258            }
259            tiles.push(Tile { pixels });
260        }
261        Self {
262            tiles,
263            format: TileFormat::Gba4bpp,
264            rgba_tiles: Vec::new(),
265        }
266    }
267
268    /// Create a tileset from flat RGBA pixel data.
269    ///
270    /// `pixels` should contain `tile_count * 64` RGBA values (8×8 pixels per tile).
271    /// Tiles are laid out sequentially: pixels[0..64] = tile 0, pixels[64..128] = tile 1, etc.
272    /// Each tile is stored as an [`RgbaTile`] in the internal `rgba_tiles` buffer for
273    /// direct rendering. The `tiles` field contains blank dummy tiles for backward
274    /// compatibility.
275    pub fn from_rgba(pixels: &[Rgba], tile_count: usize) -> Self {
276        assert!(
277            pixels.len() >= tile_count * TILE_PIXELS * TILE_PIXELS,
278            "RGBA pixel data length {} is insufficient for {} tiles (need {})",
279            pixels.len(),
280            tile_count,
281            tile_count * TILE_PIXELS * TILE_PIXELS,
282        );
283        let mut rgba_tiles = Vec::with_capacity(tile_count);
284        for ti in 0..tile_count {
285            let base = ti * TILE_PIXELS * TILE_PIXELS;
286            let mut rgba_pixels = [[Rgba::TRANSPARENT; TILE_PIXELS]; TILE_PIXELS];
287            for row in 0..TILE_PIXELS {
288                for col in 0..TILE_PIXELS {
289                    rgba_pixels[row][col] = pixels[base + row * TILE_PIXELS + col];
290                }
291            }
292            rgba_tiles.push(RgbaTile { pixels: rgba_pixels });
293        }
294        Self {
295            tiles: vec![Tile::blank(); tile_count],
296            format: TileFormat::FullColor,
297            rgba_tiles,
298        }
299    }
300
301    /// Return the tile format of this tileset.
302    pub fn tile_format(&self) -> TileFormat {
303        self.format
304    }
305
306    /// Get an RGBA tile by index. Returns `None` if the tileset is not in
307    /// [`TileFormat::FullColor`] mode or the index is out of bounds.
308    pub fn get_rgba(&self, index: usize) -> Option<&RgbaTile> {
309        if self.format != TileFormat::FullColor {
310            return None;
311        }
312        self.rgba_tiles.get(index)
313    }
314}
315
316/// Decode a single 2bpp tile row (2 bytes) into 8 color indices.
317pub fn decode_2bpp_row(lo: u8, hi: u8) -> [u8; TILE_PIXELS] {
318    let mut out = [0u8; TILE_PIXELS];
319    for col in 0..TILE_PIXELS {
320        let bit = 7 - col;
321        out[col] = ((hi >> bit) & 1) << 1 | ((lo >> bit) & 1);
322    }
323    out
324}
325
326// ---------------------------------------------------------------------------
327// RGBA tiles (no palette remapping)
328// ---------------------------------------------------------------------------
329
330/// An 8×8 tile with direct RGBA pixel data (no palette remapping).
331///
332/// Each pixel is stored as 4 bytes (R, G, B, A), for a total of 256 bytes per tile.
333/// These tiles are rendered without looking up palette indices — the RGBA values
334/// are used directly.
335#[derive(Debug, Clone)]
336pub struct RgbaTile {
337    /// 8 rows × 8 columns of RGBA pixels.
338    /// Indexed as `pixels[row][col]`.
339    pub pixels: [[Rgba; TILE_PIXELS]; TILE_PIXELS],
340}
341
342impl RgbaTile {
343    /// Create a blank (transparent) RGBA tile.
344    pub fn blank() -> Self {
345        Self {
346            pixels: [[Rgba::TRANSPARENT; TILE_PIXELS]; TILE_PIXELS],
347        }
348    }
349
350    /// Get the RGBA pixel value at (row, col).
351    #[inline]
352    pub fn get(&self, row: usize, col: usize) -> Rgba {
353        self.pixels[row][col]
354    }
355
356    /// Return the RGBA values for the given row directly (no palette lookup).
357    #[inline]
358    pub fn render_row(&self, row: usize) -> [Rgba; TILE_PIXELS] {
359        self.pixels[row]
360    }
361}
362
363/// A set of RGBA tiles indexed by tile number.
364///
365/// Unlike [`TileSet`], these tiles store direct RGBA pixel data and are
366/// rendered without any palette remapping.
367#[derive(Debug, Clone)]
368pub struct RgbaTileSet {
369    tiles: Vec<RgbaTile>,
370}
371
372/// Blank RGBA tile for out-of-bounds access.
373static BLANK_RGBA_TILE: RgbaTile = RgbaTile {
374    pixels: [[Rgba::TRANSPARENT; TILE_PIXELS]; TILE_PIXELS],
375};
376
377impl RgbaTileSet {
378    /// Load an RGBA tileset from PNG data.
379    ///
380    /// The PNG is cut into 8×8 tiles in row-major order.
381    /// Each tile stores its pixels as RGBA data directly (no 2bpp bitplanes,
382    /// no palette remapping).
383    ///
384    /// The PNG dimensions must be multiples of 8.
385    #[cfg(feature = "gpu")]
386    pub fn from_rgba_png(png_data: &[u8]) -> Result<Self, String> {
387        use image::GenericImageView;
388
389        let img = image::load_from_memory(png_data)
390            .map_err(|e| format!("Failed to decode PNG: {}", e))?;
391        let (w, h) = img.dimensions();
392        if w % TILE_PIXELS as u32 != 0 || h % TILE_PIXELS as u32 != 0 {
393            return Err(format!(
394                "PNG dimensions {}×{} are not multiples of {}",
395                w, h, TILE_PIXELS
396            ));
397        }
398
399        let rgba = img.to_rgba8();
400        let tiles_x = (w / TILE_PIXELS as u32) as usize;
401        let tiles_y = (h / TILE_PIXELS as u32) as usize;
402        let mut tiles = Vec::with_capacity(tiles_x * tiles_y);
403
404        for ty in 0..tiles_y {
405            for tx in 0..tiles_x {
406                let mut pixels = [[Rgba::TRANSPARENT; TILE_PIXELS]; TILE_PIXELS];
407                let base_x = (tx * TILE_PIXELS) as u32;
408                let base_y = (ty * TILE_PIXELS) as u32;
409                for row in 0..TILE_PIXELS {
410                    for col in 0..TILE_PIXELS {
411                        let px = rgba.get_pixel(base_x + col as u32, base_y + row as u32);
412                        pixels[row][col] = Rgba::from([px[0], px[1], px[2], px[3]]);
413                    }
414                }
415                tiles.push(RgbaTile { pixels });
416            }
417        }
418
419        Ok(Self { tiles })
420    }
421
422    /// Get a tile by index. Returns a transparent blank tile if out of bounds.
423    pub fn get(&self, index: usize) -> &RgbaTile {
424        if index < self.tiles.len() {
425            &self.tiles[index]
426        } else {
427            &BLANK_RGBA_TILE
428        }
429    }
430
431    /// Number of tiles in this set.
432    pub fn len(&self) -> usize {
433        self.tiles.len()
434    }
435
436    /// Whether this tileset is empty.
437    pub fn is_empty(&self) -> bool {
438        self.tiles.is_empty()
439    }
440}