Skip to main content

dotzuki_engine/
tilemap.rs

1//! Game Boy Advance-style tilemap with 16-bit metadata per tile.
2//!
3//! This module provides [`TilemapEntry`] and [`Tilemap`] as an upgrade
4//! over the classic GB format (1 byte per tile, index only). Each entry
5//! carries flip flags, palette bank selection, layer priority, and
6//! optional collision/animation data — sufficient to drive modern tile
7//! renderers without external lookup tables.
8
9use crate::tile_meta::CollisionType;
10
11/// A single entry in a tilemap, supporting 16-bit-style metadata.
12///
13/// Inspired by GBA's screen entry format but not bound to GBA hardware.
14/// Each entry describes which tile to draw, how it should be transformed,
15/// and optional gameplay metadata (collision, animation frame group).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct TilemapEntry {
18    /// Tile index — supports >256 tiles (unlike the 1-byte GB limit).
19    pub tile_id: u16,
20    /// Flip the tile horizontally before rendering.
21    pub flip_h: bool,
22    /// Flip the tile vertically before rendering.
23    pub flip_v: bool,
24    /// Palette bank selection (which sub-palette to use).
25    pub palette_group: u8,
26    /// Layer priority (0 = lowest, rendered first / behind).
27    pub priority: u8,
28    /// Per-tile collision override. When `Some`, this overrides the
29    /// collision data that would normally come from tile-metadata tables.
30    pub collision_override: Option<CollisionType>,
31    /// Animation frame group ID. When `Some`, the tile belongs to an
32    /// animated sequence (e.g. water, flowers). The renderer uses this
33    /// group index together with a global frame counter to select the
34    /// correct tile.
35    pub animation_group: Option<u8>,
36}
37
38impl Default for TilemapEntry {
39    fn default() -> Self {
40        Self {
41            tile_id: 0,
42            flip_h: false,
43            flip_v: false,
44            palette_group: 0,
45            priority: 0,
46            collision_override: None,
47            animation_group: None,
48        }
49    }
50}
51
52/// A two-dimensional tilemap using [`TilemapEntry`] instead of raw byte
53/// indices.
54///
55/// Unlike the classic GB `TileMap` (a flat `Vec<u8>` of tile indices),
56/// this tilemap carries per-tile metadata and supports arbitrary widths
57/// and heights.
58#[derive(Debug, Clone)]
59pub struct Tilemap {
60    /// Number of tiles horizontally.
61    pub width: u16,
62    /// Number of tiles vertically.
63    pub height: u16,
64    /// Row-major tile entries. `entries[y * width as usize + x]`.
65    pub entries: Vec<TilemapEntry>,
66}
67
68impl Tilemap {
69    /// Creates a new tilemap filled with default entries (tile_id = 0,
70    /// no flips, no collision/animation overrides).
71    ///
72    /// # Panics
73    ///
74    /// Panics if `width` or `height` is 0.
75    pub fn new(width: u16, height: u16) -> Self {
76        assert!(width > 0, "Tilemap width must be > 0");
77        assert!(height > 0, "Tilemap height must be > 0");
78
79        let len = width as usize * height as usize;
80        Self {
81            width,
82            height,
83            entries: vec![TilemapEntry::default(); len],
84        }
85    }
86
87    /// Converts a classic Game Boy tilemap (flat `&[u8]` of tile indices)
88    /// into a [`Tilemap`] of [`TilemapEntry`]s.
89    ///
90    /// Each byte in `data` becomes a `TilemapEntry` with that byte as
91    /// `tile_id` and all other fields set to their defaults.
92    ///
93    /// # Panics
94    ///
95    /// Panics if `data.len()` is less than `width * height`.
96    pub fn from_gb_tilemap(data: &[u8], width: u16, height: u16) -> Self {
97        assert!(width > 0, "Tilemap width must be > 0");
98        assert!(height > 0, "Tilemap height must be > 0");
99
100        let expected = width as usize * height as usize;
101        assert!(
102            data.len() >= expected,
103            "GB tilemap data too short: expected at least {} bytes, got {}",
104            expected,
105            data.len(),
106        );
107
108        let entries: Vec<TilemapEntry> = data[..expected]
109            .iter()
110            .map(|&b| TilemapEntry {
111                tile_id: b as u16,
112                ..Default::default()
113            })
114            .collect();
115
116        Self {
117            width,
118            height,
119            entries,
120        }
121    }
122
123    #[inline]
124    fn index(&self, x: u16, y: u16) -> usize {
125        y as usize * self.width as usize + x as usize
126    }
127
128    /// Returns `true` if (x, y) is within bounds.
129    #[inline]
130    pub fn in_bounds(&self, x: u16, y: u16) -> bool {
131        x < self.width && y < self.height
132    }
133
134    /// Returns a reference to the tile entry at (x, y), or `None` if
135    /// out of bounds.
136    #[inline]
137    pub fn get(&self, x: u16, y: u16) -> Option<&TilemapEntry> {
138        if self.in_bounds(x, y) {
139            Some(&self.entries[self.index(x, y)])
140        } else {
141            None
142        }
143    }
144
145    /// Returns a mutable reference to the tile entry at (x, y), or
146    /// `None` if out of bounds.
147    #[inline]
148    pub fn get_mut(&mut self, x: u16, y: u16) -> Option<&mut TilemapEntry> {
149        if self.in_bounds(x, y) {
150            let idx = self.index(x, y);
151            Some(&mut self.entries[idx])
152        } else {
153            None
154        }
155    }
156
157    /// Sets the tile entry at (x, y).  Silently does nothing if out of
158    /// bounds.
159    #[inline]
160    pub fn set(&mut self, x: u16, y: u16, entry: TilemapEntry) {
161        if self.in_bounds(x, y) {
162            let idx = self.index(x, y);
163            self.entries[idx] = entry;
164        }
165    }
166
167    /// Fills a rectangular region with copies of `entry`.
168    ///
169    /// Coordinates are clamped to the tilemap bounds; no-op if the
170    /// region lies entirely outside.
171    pub fn fill_rect(&mut self, x: u16, y: u16, w: u16, h: u16, entry: TilemapEntry) {
172        let x_end = (x + w).min(self.width);
173        let y_end = (y + h).min(self.height);
174        for row in y..y_end {
175            for col in x..x_end {
176                self.set(col, row, entry);
177            }
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    // ----------------------------------------------------------------
187    // TilemapEntry
188    // ----------------------------------------------------------------
189
190    #[test]
191    fn entry_default_is_zero_tile() {
192        let e = TilemapEntry::default();
193        assert_eq!(e.tile_id, 0);
194        assert!(!e.flip_h);
195        assert!(!e.flip_v);
196        assert_eq!(e.palette_group, 0);
197        assert_eq!(e.priority, 0);
198        assert_eq!(e.collision_override, None);
199        assert_eq!(e.animation_group, None);
200    }
201
202    #[test]
203    fn entry_with_collision_override() {
204        let e = TilemapEntry {
205            collision_override: Some(CollisionType::Water),
206            ..Default::default()
207        };
208        assert_eq!(e.collision_override, Some(CollisionType::Water));
209    }
210
211    #[test]
212    fn entry_with_animation() {
213        let e = TilemapEntry {
214            animation_group: Some(3),
215            ..Default::default()
216        };
217        assert_eq!(e.animation_group, Some(3));
218    }
219
220    // ----------------------------------------------------------------
221    // Tilemap::new
222    // ----------------------------------------------------------------
223
224    #[test]
225    fn new_creates_correct_dimensions() {
226        let tm = Tilemap::new(32, 32);
227        assert_eq!(tm.width, 32);
228        assert_eq!(tm.height, 32);
229        assert_eq!(tm.entries.len(), 1024);
230    }
231
232    #[test]
233    fn new_fills_with_default_entries() {
234        let tm = Tilemap::new(4, 3);
235        for entry in &tm.entries {
236            assert_eq!(*entry, TilemapEntry::default());
237        }
238    }
239
240    #[test]
241    #[should_panic]
242    fn new_panics_on_zero_width() {
243        Tilemap::new(0, 10);
244    }
245
246    #[test]
247    #[should_panic]
248    fn new_panics_on_zero_height() {
249        Tilemap::new(10, 0);
250    }
251
252    // ----------------------------------------------------------------
253    // Tilemap::from_gb_tilemap
254    // ----------------------------------------------------------------
255
256    #[test]
257    fn from_gb_tilemap_32x32() {
258        let data = vec![42u8; 1024];
259        let tm = Tilemap::from_gb_tilemap(&data, 32, 32);
260        assert_eq!(tm.width, 32);
261        assert_eq!(tm.height, 32);
262        for e in &tm.entries {
263            assert_eq!(e.tile_id, 42);
264            assert!(!e.flip_h);
265            assert!(!e.flip_v);
266        }
267    }
268
269    #[test]
270    fn from_gb_tilemap_preserves_values() {
271        let data: Vec<u8> = (0..9).collect();
272        let tm = Tilemap::from_gb_tilemap(&data, 3, 3);
273        for (i, e) in tm.entries.iter().enumerate() {
274            assert_eq!(e.tile_id, i as u16);
275        }
276    }
277
278    #[test]
279    fn from_gb_tilemap_ignores_extra_bytes() {
280        let mut data = vec![7u8; 9];
281        data.push(99);
282        let tm = Tilemap::from_gb_tilemap(&data, 3, 3);
283        assert_eq!(tm.entries.len(), 9);
284        for e in &tm.entries {
285            assert_eq!(e.tile_id, 7);
286        }
287    }
288
289    #[test]
290    #[should_panic]
291    fn from_gb_tilemap_panics_on_short_data() {
292        Tilemap::from_gb_tilemap(&[1, 2, 3], 4, 4);
293    }
294
295    // ----------------------------------------------------------------
296    // in_bounds / get / get_mut / set
297    // ----------------------------------------------------------------
298
299    #[test]
300    fn in_bounds() {
301        let tm = Tilemap::new(10, 8);
302        assert!(tm.in_bounds(0, 0));
303        assert!(tm.in_bounds(9, 7));
304        assert!(!tm.in_bounds(10, 0));
305        assert!(!tm.in_bounds(0, 8));
306    }
307
308    #[test]
309    fn get_returns_entry() {
310        let mut tm = Tilemap::new(5, 5);
311        let e = TilemapEntry {
312            tile_id: 99,
313            ..Default::default()
314        };
315        tm.set(2, 3, e);
316        assert_eq!(tm.get(2, 3), Some(&e));
317    }
318
319    #[test]
320    fn get_returns_none_oob() {
321        let tm = Tilemap::new(5, 5);
322        assert_eq!(tm.get(5, 0), None);
323        assert_eq!(tm.get(0, 5), None);
324    }
325
326    #[test]
327    fn get_mut_modifies_entry() {
328        let mut tm = Tilemap::new(4, 4);
329        {
330            let e = tm.get_mut(1, 2).unwrap();
331            e.flip_h = true;
332        }
333        assert!(tm.get(1, 2).unwrap().flip_h);
334    }
335
336    #[test]
337    fn get_mut_returns_none_oob() {
338        let mut tm = Tilemap::new(3, 3);
339        assert!(tm.get_mut(3, 0).is_none());
340    }
341
342    #[test]
343    fn set_and_get_roundtrip() {
344        let mut tm = Tilemap::new(16, 16);
345        let e = TilemapEntry {
346            tile_id: 255,
347            flip_h: true,
348            flip_v: true,
349            palette_group: 3,
350            priority: 1,
351            collision_override: Some(CollisionType::Impassable),
352            animation_group: Some(2),
353        };
354        tm.set(10, 5, e);
355        assert_eq!(tm.get(10, 5), Some(&e));
356    }
357
358    #[test]
359    fn set_oob_is_noop() {
360        let mut tm = Tilemap::new(4, 4);
361        let e = TilemapEntry {
362            tile_id: 99,
363            ..Default::default()
364        };
365        tm.set(4, 0, e);
366
367        for i in 0..4 {
368            for j in 0..4 {
369                assert_eq!(tm.get(i, j), Some(&TilemapEntry::default()));
370            }
371        }
372    }
373
374    // ----------------------------------------------------------------
375    // fill_rect
376    // ----------------------------------------------------------------
377
378    #[test]
379    fn fill_rect_partial() {
380        let mut tm = Tilemap::new(8, 8);
381        let e = TilemapEntry {
382            tile_id: 42,
383            ..Default::default()
384        };
385        tm.fill_rect(2, 3, 4, 2, e);
386        for y in 3..5 {
387            for x in 2..6 {
388                assert_eq!(tm.get(x, y).unwrap().tile_id, 42);
389            }
390        }
391
392        assert_eq!(tm.get(0, 0).unwrap().tile_id, 0);
393        assert_eq!(tm.get(7, 7).unwrap().tile_id, 0);
394    }
395
396    #[test]
397    fn fill_rect_clamped_to_bounds() {
398        let mut tm = Tilemap::new(4, 4);
399        let e = TilemapEntry {
400            tile_id: 99,
401            ..Default::default()
402        };
403        tm.fill_rect(2, 2, 10, 10, e);
404
405        for y in 0..4 {
406            for x in 0..4 {
407                if x >= 2 && y >= 2 {
408                    assert_eq!(tm.get(x, y).unwrap().tile_id, 99);
409                } else {
410                    assert_eq!(tm.get(x, y).unwrap().tile_id, 0);
411                }
412            }
413        }
414    }
415
416    // ----------------------------------------------------------------
417    // Large tilemap (supports > 256 tiles)
418    // ----------------------------------------------------------------
419
420    #[test]
421    fn supports_tile_ids_above_255() {
422        let e = TilemapEntry {
423            tile_id: 1023,
424            ..Default::default()
425        };
426        let mut tm = Tilemap::new(1, 1);
427        tm.set(0, 0, e);
428        assert_eq!(tm.get(0, 0).unwrap().tile_id, 1023);
429    }
430
431    #[test]
432    fn non_square_tilemap() {
433        let tm = Tilemap::new(64, 16);
434        assert_eq!(tm.entries.len(), 1024);
435        assert!(tm.get(63, 15).is_some());
436        assert!(tm.get(64, 0).is_none());
437    }
438}