Skip to main content

dotzuki_engine/
metatile.rs

1//! # metatile
2//!
3//! Defines `MetatileDef` and `MetatileRegistry` — the JRPG engine generalization
4//! of the Game Boy "block" concept.  A metatile is a logical unit comprised of
5//! N×N tiles (2×2, 3×3, 4×4, …) that carries collision, trigger, animation and
6//! z-offset metadata.  The module also provides a converter from the original
7//! Game Boy `.bst` block format (16-byte blocks, 4×4 tiles).
8
9use crate::tile_meta::CollisionType;
10
11// ---------------------------------------------------------------------------
12// MetatileCell — a single tile placed inside a metatile
13// ---------------------------------------------------------------------------
14
15/// One tile cell within a metatile.  Beyond the basic tile index, this
16/// carries optional flip flags and a palette bank selector that are commonly
17/// needed by tilemap renderers.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct MetatileCell {
20    /// Index of the tile in the tileset's tile table.
21    pub tile_id: u8,
22    /// Flip horizontally.
23    pub flip_x: bool,
24    /// Flip vertically.
25    pub flip_y: bool,
26    /// Palette bank index (0–7 for GBC-style, 0 for DMG).
27    pub palette_bank: u8,
28}
29
30impl Default for MetatileCell {
31    fn default() -> Self {
32        Self {
33            tile_id: 0,
34            flip_x: false,
35            flip_y: false,
36            palette_bank: 0,
37        }
38    }
39}
40
41impl MetatileCell {
42    /// Create an entry that references only a tile ID with no flips.
43    pub fn from_tile_id(tile_id: u8) -> Self {
44        Self {
45            tile_id,
46            ..Default::default()
47        }
48    }
49}
50
51// ---------------------------------------------------------------------------
52// CollisionCell
53// ---------------------------------------------------------------------------
54
55/// Per-cell collision descriptor.  May differ from the metatile's visual
56/// bounds when, for example, only the bottom row of the metatile is solid.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct CollisionCell {
59    pub collision: CollisionType,
60}
61
62impl CollisionCell {
63    /// Convenience constructor.
64    pub fn new(collision: CollisionType) -> Self {
65        Self { collision }
66    }
67
68    /// Returns a passable cell.
69    pub fn passable() -> Self {
70        Self {
71            collision: CollisionType::Passable,
72        }
73    }
74
75    /// Returns an impassable cell.
76    pub fn impassable() -> Self {
77        Self {
78            collision: CollisionType::Impassable,
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Trigger
85// ---------------------------------------------------------------------------
86
87/// When a trigger associated with a metatile fires.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum TriggerType {
90    /// Fires every frame the player (or NPC) stands on the tile.
91    OnStep,
92    /// Fires once when the entity enters the metatile's area.
93    OnEnter,
94    /// Fires when the player presses the A button facing the metatile.
95    OnInteract,
96}
97
98/// A trigger definition bound to a metatile.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct TriggerDef {
101    /// Name of the script (or event handler) to execute.
102    pub script_name: String,
103    /// When the trigger fires.
104    pub trigger_type: TriggerType,
105}
106
107// ---------------------------------------------------------------------------
108// MetatileDef
109// ---------------------------------------------------------------------------
110
111/// A metatile — a logical unit comprised of N×N tiles.
112///
113/// This is the JRPG engine generalization of Game Boy's "block" concept
114/// (4×4 tiles = 16 bytes).  Variable-size support enables use with other
115/// tile-based games (2×2, 3×3, …).
116#[derive(Debug, Clone)]
117pub struct MetatileDef {
118    /// Width and height in tiles, e.g. `(4, 4)` for GB blocks.
119    pub size: (u8, u8),
120
121    /// The tiles that make up this metatile, stored in **row-major** order.
122    /// Must contain exactly `size.0 * size.1` entries.
123    pub tiles: Vec<MetatileCell>,
124
125    /// Per-cell collision, same dimensions as `size`.  If `collision.len()`
126    /// differs from `tile_count()`, the engine should treat the whole
127    /// metatile uniformly (e.g. all cells inherit the first collision entry).
128    pub collision: Vec<CollisionCell>,
129
130    /// Optional trigger script that fires when the player interacts with
131    /// or steps on this metatile.
132    pub trigger: Option<TriggerDef>,
133
134    /// Optional animation group index — tiles inside the metatile animate
135    /// in lockstep if they share the same group.
136    pub animation_group: Option<u8>,
137
138    /// Vertical render offset (pixels).  Positive values render the
139    /// metatile lower on screen, useful for layered maps or floating
140    /// platforms.  Default is `0`.
141    pub z_offset: i8,
142}
143
144impl MetatileDef {
145    /// Creates an empty metatile of the given size.
146    ///
147    /// All tiles default to tile ID 0 with no flips; all collision cells
148    /// are `Passable`.  `trigger`, `animation_group` and `z_offset` are
149    /// initialised to their defaults.
150    ///
151    /// # Panics
152    ///
153    /// Panics if either dimension is 0.
154    pub fn new(size: (u8, u8)) -> Self {
155        assert!(size.0 > 0, "metatile width must be > 0");
156        assert!(size.1 > 0, "metatile height must be > 0");
157
158        let count = (size.0 as usize) * (size.1 as usize);
159        MetatileDef {
160            size,
161            tiles: vec![MetatileCell::default(); count],
162            collision: vec![CollisionCell::passable(); count],
163            trigger: None,
164            animation_group: None,
165            z_offset: 0,
166        }
167    }
168
169    /// Returns the total number of tiles in this metatile (`size.0 * size.1`).
170    pub fn tile_count(&self) -> usize {
171        (self.size.0 as usize) * (self.size.1 as usize)
172    }
173
174    /// Converts a 16-byte Game Boy `.bst` block into a 4×4 `MetatileDef`.
175    ///
176    /// Each byte in `block_data` is a raw tile ID.  Entries are placed in
177    /// **row-major** order (top-left to bottom-right).  All collision cells
178    /// default to `Passable`.
179    ///
180    /// # Panics
181    ///
182    /// Panics if `block_data` does not contain exactly 16 bytes.
183    pub fn from_gb_block(block_data: &[u8]) -> Self {
184        assert_eq!(
185            block_data.len(),
186            16,
187            "GB block data must be exactly 16 bytes, got {}",
188            block_data.len()
189        );
190
191        let tiles: Vec<MetatileCell> = block_data
192            .iter()
193            .map(|&id| MetatileCell::from_tile_id(id))
194            .collect();
195
196        MetatileDef {
197            size: (4, 4),
198            tiles,
199            collision: vec![CollisionCell::passable(); 16],
200            trigger: None,
201            animation_group: None,
202            z_offset: 0,
203        }
204    }
205}
206
207// ---------------------------------------------------------------------------
208// MetatileRegistry
209// ---------------------------------------------------------------------------
210
211/// Registry of all metatile definitions for a tileset.
212///
213/// A registry holds every `MetatileDef` used by a map or tileset.  The map's
214/// block data references metatiles by their index in this registry (the
215/// index is returned by [`add_def`](MetatileRegistry::add_def)).
216#[derive(Debug, Clone)]
217pub struct MetatileRegistry {
218    pub defs: Vec<MetatileDef>,
219}
220
221impl MetatileRegistry {
222    /// Creates an empty registry.
223    pub fn new() -> Self {
224        MetatileRegistry { defs: Vec::new() }
225    }
226
227    /// Adds a metatile definition and returns its index in the registry.
228    pub fn add_def(&mut self, def: MetatileDef) -> usize {
229        let index = self.defs.len();
230        self.defs.push(def);
231        index
232    }
233
234    /// Looks up a metatile definition by index.  Returns `None` if the
235    /// index is out of bounds.
236    pub fn get(&self, index: usize) -> Option<&MetatileDef> {
237        self.defs.get(index)
238    }
239
240    /// Parses a complete `.bst` byte slice into a `MetatileRegistry`.
241    ///
242    /// The slice is expected to be a multiple of **16 bytes** (the standard
243    /// Game Boy block size).  Each 16-byte chunk is fed to
244    /// [`MetatileDef::from_gb_block`].
245    ///
246    /// # Panics
247    ///
248    /// Panics if `bst_data.len()` is not a multiple of 16.
249    pub fn from_gb_blocksets(bst_data: &[u8]) -> Self {
250        assert!(
251            bst_data.len() % 16 == 0,
252            "GB blockset data length must be a multiple of 16, got {}",
253            bst_data.len()
254        );
255
256        let mut registry = MetatileRegistry::new();
257        for chunk in bst_data.chunks_exact(16) {
258            registry.add_def(MetatileDef::from_gb_block(chunk));
259        }
260        registry
261    }
262}
263
264impl Default for MetatileRegistry {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270// ===========================================================================
271// Unit tests
272// ===========================================================================
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    // ------------------------------------------------------------------
279    // MetatileDef::new
280    // ------------------------------------------------------------------
281
282    #[test]
283    fn test_new_creates_empty_metatile() {
284        let m = MetatileDef::new((2, 3));
285        assert_eq!(m.size, (2, 3));
286        assert_eq!(m.tile_count(), 6);
287        assert_eq!(m.tiles.len(), 6);
288        assert_eq!(m.collision.len(), 6);
289        assert!(m.trigger.is_none());
290        assert!(m.animation_group.is_none());
291        assert_eq!(m.z_offset, 0);
292
293        // All tiles should be zero with no flips.
294        for tile in &m.tiles {
295            assert_eq!(tile.tile_id, 0);
296            assert!(!tile.flip_x);
297            assert!(!tile.flip_y);
298            assert_eq!(tile.palette_bank, 0);
299        }
300
301        // All collision cells should be passable.
302        for cell in &m.collision {
303            assert_eq!(cell.collision, CollisionType::Passable);
304        }
305    }
306
307    #[test]
308    fn test_new_2x2() {
309        let m = MetatileDef::new((2, 2));
310        assert_eq!(m.size, (2, 2));
311        assert_eq!(m.tile_count(), 4);
312    }
313
314    #[test]
315    fn test_new_4x4() {
316        let m = MetatileDef::new((4, 4));
317        assert_eq!(m.size, (4, 4));
318        assert_eq!(m.tile_count(), 16);
319    }
320
321    #[test]
322    #[should_panic(expected = "metatile width must be > 0")]
323    fn test_new_zero_width_panics() {
324        MetatileDef::new((0, 4));
325    }
326
327    #[test]
328    #[should_panic(expected = "metatile height must be > 0")]
329    fn test_new_zero_height_panics() {
330        MetatileDef::new((4, 0));
331    }
332
333    // ------------------------------------------------------------------
334    // MetatileDef::from_gb_block
335    // ------------------------------------------------------------------
336
337    #[test]
338    fn test_from_gb_block_full_16_bytes() {
339        let data: [u8; 16] = [
340            0x00, 0x01, 0x02, 0x03, // row 0
341            0x10, 0x11, 0x12, 0x13, // row 1
342            0x20, 0x21, 0x22, 0x23, // row 2
343            0x30, 0x31, 0x32, 0x33, // row 3
344        ];
345
346        let m = MetatileDef::from_gb_block(&data);
347
348        assert_eq!(m.size, (4, 4));
349        assert_eq!(m.tile_count(), 16);
350        assert_eq!(m.z_offset, 0);
351        assert!(m.trigger.is_none());
352
353        // Verify row-major ordering.
354        assert_eq!(m.tiles[0].tile_id, 0x00);
355        assert_eq!(m.tiles[1].tile_id, 0x01);
356        assert_eq!(m.tiles[2].tile_id, 0x02);
357        assert_eq!(m.tiles[3].tile_id, 0x03);
358        assert_eq!(m.tiles[4].tile_id, 0x10);
359        assert_eq!(m.tiles[15].tile_id, 0x33);
360
361        // All entries should have default flip/palette.
362        for tile in &m.tiles {
363            assert!(!tile.flip_x);
364            assert!(!tile.flip_y);
365            assert_eq!(tile.palette_bank, 0);
366        }
367    }
368
369    #[test]
370    fn test_from_gb_block_all_zeros() {
371        let data = [0u8; 16];
372        let m = MetatileDef::from_gb_block(&data);
373
374        assert_eq!(m.size, (4, 4));
375        for tile in &m.tiles {
376            assert_eq!(tile.tile_id, 0);
377        }
378    }
379
380    #[test]
381    fn test_from_gb_block_with_high_tile_ids() {
382        let mut data = [0u8; 16];
383        // Fill with tiles 0xA0 .. 0xAF
384        for (i, b) in data.iter_mut().enumerate() {
385            *b = 0xA0 + i as u8;
386        }
387
388        let m = MetatileDef::from_gb_block(&data);
389        assert_eq!(m.tiles[0].tile_id, 0xA0);
390        assert_eq!(m.tiles[15].tile_id, 0xAF);
391    }
392
393    #[test]
394    #[should_panic(expected = "GB block data must be exactly 16 bytes")]
395    fn test_from_gb_block_too_short_panics() {
396        MetatileDef::from_gb_block(&[0u8; 8]);
397    }
398
399    #[test]
400    #[should_panic(expected = "GB block data must be exactly 16 bytes")]
401    fn test_from_gb_block_too_long_panics() {
402        MetatileDef::from_gb_block(&[0u8; 32]);
403    }
404
405    #[test]
406    #[should_panic(expected = "GB block data must be exactly 16 bytes")]
407    fn test_from_gb_block_empty_panics() {
408        MetatileDef::from_gb_block(&[]);
409    }
410
411    // ------------------------------------------------------------------
412    // MetatileDef::tile_count
413    // ------------------------------------------------------------------
414
415    #[test]
416    fn test_tile_count_various_sizes() {
417        assert_eq!(MetatileDef::new((1, 1)).tile_count(), 1);
418        assert_eq!(MetatileDef::new((2, 2)).tile_count(), 4);
419        assert_eq!(MetatileDef::new((3, 3)).tile_count(), 9);
420        assert_eq!(MetatileDef::new((4, 4)).tile_count(), 16);
421        assert_eq!(MetatileDef::new((2, 5)).tile_count(), 10);
422    }
423
424    // ------------------------------------------------------------------
425    // MetatileDef — Trigger / animation / z_offset
426    // ------------------------------------------------------------------
427
428    #[test]
429    fn test_metatile_with_trigger() {
430        let mut m = MetatileDef::new((2, 2));
431        m.trigger = Some(TriggerDef {
432            script_name: "heal_party".into(),
433            trigger_type: TriggerType::OnStep,
434        });
435
436        let t = m.trigger.as_ref().unwrap();
437        assert_eq!(t.script_name, "heal_party");
438        assert_eq!(t.trigger_type, TriggerType::OnStep);
439    }
440
441    #[test]
442    fn test_metatile_with_animation_group() {
443        let mut m = MetatileDef::new((2, 2));
444        m.animation_group = Some(7);
445        assert_eq!(m.animation_group, Some(7));
446    }
447
448    #[test]
449    fn test_metatile_z_offset() {
450        let mut m = MetatileDef::new((2, 2));
451        m.z_offset = -4;
452        assert_eq!(m.z_offset, -4);
453
454        m.z_offset = 8;
455        assert_eq!(m.z_offset, 8);
456    }
457
458    // ------------------------------------------------------------------
459    // MetatileRegistry
460    // ------------------------------------------------------------------
461
462    #[test]
463    fn test_registry_new_is_empty() {
464        let r = MetatileRegistry::new();
465        assert!(r.defs.is_empty());
466        assert!(r.get(0).is_none());
467    }
468
469    #[test]
470    fn test_registry_add_and_get() {
471        let mut r = MetatileRegistry::new();
472
473        let m1 = MetatileDef::new((2, 2));
474        let m2 = MetatileDef::new((3, 3));
475
476        let idx1 = r.add_def(m1);
477        let idx2 = r.add_def(m2);
478
479        assert_eq!(idx1, 0);
480        assert_eq!(idx2, 1);
481
482        assert!(r.get(0).is_some());
483        assert!(r.get(1).is_some());
484        assert!(r.get(2).is_none());
485
486        assert_eq!(r.get(0).unwrap().size, (2, 2));
487        assert_eq!(r.get(1).unwrap().size, (3, 3));
488    }
489
490    #[test]
491    fn test_registry_get_out_of_bounds() {
492        let r = MetatileRegistry::new();
493        assert!(r.get(0).is_none());
494        assert!(r.get(100).is_none());
495    }
496
497    #[test]
498    fn test_registry_default() {
499        let r = MetatileRegistry::default();
500        assert!(r.defs.is_empty());
501    }
502
503    // ------------------------------------------------------------------
504    // MetatileRegistry::from_gb_blocksets
505    // ------------------------------------------------------------------
506
507    #[test]
508    fn test_from_gb_blocksets_single_block() {
509        let data: Vec<u8> = (0..16).collect(); // 0x00..0x0F
510        let registry = MetatileRegistry::from_gb_blocksets(&data);
511
512        assert_eq!(registry.defs.len(), 1);
513        let m = registry.get(0).unwrap();
514        assert_eq!(m.size, (4, 4));
515        assert_eq!(m.tiles[0].tile_id, 0x00);
516        assert_eq!(m.tiles[15].tile_id, 0x0F);
517    }
518
519    #[test]
520    fn test_from_gb_blocksets_multiple_blocks() {
521        // Two blocks: first is 0x00–0x0F, second is 0x10–0x1F
522        let data: Vec<u8> = (0..32).collect();
523        let registry = MetatileRegistry::from_gb_blocksets(&data);
524
525        assert_eq!(registry.defs.len(), 2);
526
527        let m0 = registry.get(0).unwrap();
528        assert_eq!(m0.tiles[0].tile_id, 0x00);
529        assert_eq!(m0.tiles[15].tile_id, 0x0F);
530
531        let m1 = registry.get(1).unwrap();
532        assert_eq!(m1.tiles[0].tile_id, 0x10);
533        assert_eq!(m1.tiles[15].tile_id, 0x1F);
534    }
535
536    #[test]
537    fn test_from_gb_blocksets_many_blocks() {
538        // 256 blocks (4096 bytes) — a real tileset.
539        let data = vec![0u8; 256 * 16];
540        let registry = MetatileRegistry::from_gb_blocksets(&data);
541        assert_eq!(registry.defs.len(), 256);
542
543        for def in &registry.defs {
544            assert_eq!(def.size, (4, 4));
545            assert_eq!(def.tile_count(), 16);
546        }
547    }
548
549    #[test]
550    fn test_from_gb_blocksets_empty() {
551        let registry = MetatileRegistry::from_gb_blocksets(&[]);
552        assert!(registry.defs.is_empty());
553    }
554
555    #[test]
556    #[should_panic(expected = "GB blockset data length must be a multiple of 16")]
557    fn test_from_gb_blocksets_unaligned_panics() {
558        MetatileRegistry::from_gb_blocksets(&[0u8; 10]);
559    }
560
561    #[test]
562    #[should_panic(expected = "GB blockset data length must be a multiple of 16")]
563    fn test_from_gb_blocksets_odd_length_panics() {
564        MetatileRegistry::from_gb_blocksets(&[0u8; 17]);
565    }
566
567    // ------------------------------------------------------------------
568    // CollisionCell
569    // ------------------------------------------------------------------
570
571    #[test]
572    fn test_collision_cell_passable() {
573        let c = CollisionCell::passable();
574        assert_eq!(c.collision, CollisionType::Passable);
575    }
576
577    #[test]
578    fn test_collision_cell_impassable() {
579        let c = CollisionCell::impassable();
580        assert_eq!(c.collision, CollisionType::Impassable);
581    }
582
583    #[test]
584    fn test_collision_cell_custom() {
585        let c = CollisionCell::new(CollisionType::Water);
586        assert_eq!(c.collision, CollisionType::Water);
587    }
588
589    // ------------------------------------------------------------------
590    // MetatileCell
591    // ------------------------------------------------------------------
592
593    #[test]
594    fn test_tilemap_entry_default() {
595        let t = MetatileCell::default();
596        assert_eq!(t.tile_id, 0);
597        assert!(!t.flip_x);
598        assert!(!t.flip_y);
599        assert_eq!(t.palette_bank, 0);
600    }
601
602    #[test]
603    fn test_tilemap_entry_from_tile_id() {
604        let t = MetatileCell::from_tile_id(42);
605        assert_eq!(t.tile_id, 42);
606        assert!(!t.flip_x);
607        assert!(!t.flip_y);
608    }
609
610    #[test]
611    fn test_tilemap_entry_with_flips() {
612        let t = MetatileCell {
613            tile_id: 0x80,
614            flip_x: true,
615            flip_y: false,
616            palette_bank: 3,
617        };
618        assert!(t.flip_x);
619        assert!(!t.flip_y);
620        assert_eq!(t.palette_bank, 3);
621    }
622
623    // ------------------------------------------------------------------
624    // TriggerDef / TriggerType
625    // ------------------------------------------------------------------
626
627    #[test]
628    fn test_trigger_type_equality() {
629        assert_eq!(TriggerType::OnStep, TriggerType::OnStep);
630        assert_ne!(TriggerType::OnStep, TriggerType::OnInteract);
631    }
632
633    #[test]
634    fn test_trigger_def() {
635        let td = TriggerDef {
636            script_name: "warp_to_start_town".into(),
637            trigger_type: TriggerType::OnEnter,
638        };
639        assert_eq!(td.script_name, "warp_to_start_town");
640        assert_eq!(td.trigger_type, TriggerType::OnEnter);
641    }
642}