Skip to main content

dotzuki_renderer/layout_engine/elements/
tile.rs

1//! Tile element — renders a single Game Boy tile, with optional repeat
2//! to fill a rectangle, flip support, and template variable resolution
3//! for the tile id.
4//!
5//! ## Features
6//! - `tile_id` can be a literal number, a string, or a `{template}` variable
7//! - `repeat` fills the element rect with the tile
8//! - `flip_x` / `flip_y` for horizontal / vertical mirroring (requires
9//!   [`render_tile_with_tiles`] for pixel-level flip; the Painter-based
10//!   [`render_tile`] passes the flags through but flip fidelity depends
11//!   on the backend)
12
13use dotzuki_engine::render::painter::Painter;
14use dotzuki_engine::render::{Rgba, TilePos};
15
16use crate::layout_engine::types::{DataContext, LayoutElement, RenderContext, RenderError, TileParams};
17
18// ── Public API ──────────────────────────────────────────────────────────────
19
20/// Render a tile element via the [`Painter`] backend.
21///
22/// Handles template resolution, repeat-to-fill, and colour mapping.
23/// Flip flags are passed through to [`Painter::draw_gb_tile`]; pixel-level
24/// flip fidelity depends on the backend implementation.
25///
26/// # Arguments
27/// * `element` — The layout element (position from `rect`).
28/// * `params` — Deserialised [`TileParams`].
29/// * `ctx` — Data context for resolving `{template}` tile ids.
30/// * `_render_ctx` — Shared rendering state.
31/// * `painter` — Drawing backend.
32pub fn render_tile(
33    element: &LayoutElement,
34    params: &TileParams,
35    ctx: &DataContext,
36    _render_ctx: &RenderContext,
37    painter: &mut dyn Painter,
38) -> Result<(), RenderError> {
39    let tile_id = resolve_tile_id(params, ctx)?;
40    let color = params
41        .palette
42        .as_deref()
43        .map(parse_color)
44        .unwrap_or(Rgba::INK_BLACK);
45    let fallback = format!("[{}]", tile_id);
46
47    let rect = &element.rect;
48
49    let fill = params.repeat.is_some();
50    let tw = rect.tw.unwrap_or(1);
51    let th = rect.th.unwrap_or(1);
52
53    let base_tx = rect.tx.resolve(ctx);
54    let base_ty = rect.ty.resolve(ctx);
55
56    let cols = if fill { tw } else { 1 };
57    let rows = if fill { th } else { 1 };
58
59    for row in 0..rows {
60        for col in 0..cols {
61            painter.draw_gb_tile(
62                TilePos::new(base_tx + col, base_ty + row),
63                tile_id,
64                &fallback,
65                color,
66            );
67        }
68    }
69
70    Ok(())
71}
72
73/// Render a tile with full pixel-level flip support.
74///
75/// Requires direct access to the framebuffer, tileset, and palette.
76/// Use this when `flip_x` or `flip_y` is needed and the Painter backend
77/// does not support flipping.
78///
79/// # Arguments
80/// * `element` — The layout element.
81/// * `params` — Deserialised [`TileParams`].
82/// * `ctx` — Data context for tile id resolution.
83/// * `tileset` — The tile data source.
84/// * `palette` — Palette for mapping colour indices to RGBA.
85/// * `fb` — The framebuffer to draw into.
86pub fn render_tile_with_tiles(
87    element: &LayoutElement,
88    params: &TileParams,
89    ctx: &DataContext,
90    tileset: &crate::tile::TileSet,
91    palette: &crate::palette::Palette,
92    fb: &mut dotzuki_engine::render::FrameBuffer,
93) -> Result<(), RenderError> {
94    use crate::tile::TILE_PIXELS;
95
96    let tile_id = resolve_tile_id(params, ctx)?;
97    let tile = tileset.get(tile_id as usize);
98
99    let mut tile_data = tile.clone();
100    if params.flip_x {
101        tile_data = tile_data.flip_x();
102    }
103    if params.flip_y {
104        tile_data = tile_data.flip_y();
105    }
106
107    let rect = &element.rect;
108    let fill = params.repeat.is_some();
109    let tw = rect.tw.unwrap_or(1);
110    let th = rect.th.unwrap_or(1);
111
112    let base_tx = rect.tx.resolve(ctx);
113    let base_ty = rect.ty.resolve(ctx);
114
115    let cols = if fill { tw } else { 1 };
116    let rows = if fill { th } else { 1 };
117
118    for row in 0..rows {
119        for col in 0..cols {
120            let px = (base_tx + col) * TILE_PIXELS as u32;
121            let py = (base_ty + row) * TILE_PIXELS as u32;
122
123            for ty in 0..TILE_PIXELS {
124                let rgba_row = tile_data.render_row(ty, palette);
125                for tx in 0..TILE_PIXELS {
126                    let rgba = rgba_row[tx];
127                    if rgba != dotzuki_engine::render::Rgba::TRANSPARENT {
128                        fb.set_pixel(px + tx as u32, py + ty as u32, rgba);
129                    }
130                }
131            }
132        }
133    }
134
135    Ok(())
136}
137
138// ── Helpers ────────────────────────────────────────────────────────────────
139
140/// Resolve `tile_id` from params — may be a literal number, a string, or
141/// a `{template}` variable that expands via [`DataContext::resolve`].
142pub fn resolve_tile_id(params: &TileParams, ctx: &DataContext) -> Result<u8, RenderError> {
143    match &params.tile_id {
144        serde_json::Value::Number(n) => n
145            .as_u64()
146            .and_then(|v| u8::try_from(v).ok())
147            .ok_or(RenderError::MissingVariable),
148        serde_json::Value::String(s) => {
149            let resolved = ctx.resolve(s);
150            resolved
151                .trim()
152                .parse::<u8>()
153                .map_err(|_| RenderError::MissingVariable)
154        }
155        _ => Err(RenderError::MissingVariable),
156    }
157}
158
159pub use crate::layout_engine::elements::text::parse_color;
160
161// ── Tests ──────────────────────────────────────────────────────────────────
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::layout_engine::types::{Coord, ElementParams, ElementRect};
167    use dotzuki_engine::render::Rgba as EngineRgba;
168
169    // ── Recording painter ────────────────────────────────────────────
170
171    #[derive(Debug, Default)]
172    struct RecordingPainter {
173        tile_calls: Vec<(TilePos, u8, String, EngineRgba)>,
174    }
175
176    impl RecordingPainter {
177        fn new() -> Self {
178            Self::default()
179        }
180    }
181
182    impl Painter for RecordingPainter {
183        fn clear(&mut self, _color: EngineRgba) {}
184
185        fn draw_text_box(
186            &mut self,
187            _rect: dotzuki_engine::render::TileRect,
188            _color: EngineRgba,
189        ) {
190        }
191
192        fn draw_text(&mut self, _pos: TilePos, _text: &str, _color: EngineRgba) {}
193
194        fn draw_glyph(&mut self, _pos: TilePos, _glyph: char, _color: EngineRgba) {}
195
196        fn draw_pixel_rect(
197            &mut self,
198            _px: u32,
199            _py: u32,
200            _pw: u32,
201            _ph: u32,
202            _color: EngineRgba,
203        ) {
204        }
205
206        fn draw_gb_tile(
207            &mut self,
208            pos: TilePos,
209            tile_id: u8,
210            fallback: &str,
211            color: EngineRgba,
212        ) {
213            self.tile_calls
214                .push((pos, tile_id, fallback.to_string(), color));
215        }
216    }
217
218    // ── Helpers ──────────────────────────────────────────────────────
219
220    fn make_element(tile_id: serde_json::Value, tx: u32, ty: u32) -> LayoutElement {
221        LayoutElement {
222            id: String::new(),
223            element_type: "tile".to_string(),
224            rect: ElementRect {
225                tx: Coord::Literal(tx),
226                ty: Coord::Literal(ty),
227                tw: Some(1),
228                th: Some(1),
229            },
230            visible: crate::layout_engine::types::Visibility::Static(true),
231            z_index: 0,
232            params: ElementParams::Tile(TileParams {
233                tile_id,
234                flip_x: false,
235                flip_y: false,
236                palette: None,
237                repeat: None,
238            }),
239        }
240    }
241
242    fn make_theme() -> crate::layout_engine::types::Theme {
243        Default::default()
244    }
245
246    // ── Tests: resolve_tile_id ───────────────────────────────────────
247
248    #[test]
249    fn resolve_from_json_number() {
250        let params = TileParams {
251            tile_id: serde_json::Value::Number(serde_json::Number::from(42)),
252            flip_x: false,
253            flip_y: false,
254            palette: None,
255            repeat: None,
256        };
257        assert_eq!(resolve_tile_id(&params, &DataContext::new()).unwrap(), 42);
258    }
259
260    #[test]
261    fn resolve_from_json_string() {
262        let params = TileParams {
263            tile_id: serde_json::Value::String("99".to_string()),
264            flip_x: false,
265            flip_y: false,
266            palette: None,
267            repeat: None,
268        };
269        assert_eq!(resolve_tile_id(&params, &DataContext::new()).unwrap(), 99);
270    }
271
272    #[test]
273    fn resolve_from_template() {
274        let params = TileParams {
275            tile_id: serde_json::Value::String("{t}".to_string()),
276            flip_x: false,
277            flip_y: false,
278            palette: None,
279            repeat: None,
280        };
281        let mut ctx = DataContext::new();
282        ctx.set("t", 77i64);
283        assert_eq!(resolve_tile_id(&params, &ctx).unwrap(), 77);
284    }
285
286    #[test]
287    fn resolve_invalid_number_errors() {
288        let params = TileParams {
289            tile_id: serde_json::Value::Number(serde_json::Number::from(99999)),
290            flip_x: false,
291            flip_y: false,
292            palette: None,
293            repeat: None,
294        };
295        assert!(resolve_tile_id(&params, &DataContext::new()).is_err());
296    }
297
298    #[test]
299    fn resolve_invalid_string_errors() {
300        let params = TileParams {
301            tile_id: serde_json::Value::String("abc".to_string()),
302            flip_x: false,
303            flip_y: false,
304            palette: None,
305            repeat: None,
306        };
307        assert!(resolve_tile_id(&params, &DataContext::new()).is_err());
308    }
309
310    #[test]
311    fn resolve_missing_template_errors() {
312        let params = TileParams {
313            tile_id: serde_json::Value::String("{missing}".to_string()),
314            flip_x: false,
315            flip_y: false,
316            palette: None,
317            repeat: None,
318        };
319        let ctx = DataContext::new();
320        assert!(resolve_tile_id(&params, &ctx).is_err());
321    }
322
323    // ── Tests: parse_color ───────────────────────────────────────────
324
325    #[test]
326    fn parse_black() {
327        assert_eq!(parse_color("black"), Rgba::INK_BLACK);
328    }
329
330    #[test]
331    fn parse_darkgray() {
332        assert_eq!(parse_color("darkgray"), Rgba::INK_DARK_GRAY);
333    }
334
335    #[test]
336    fn parse_unknown_returns_black() {
337        assert_eq!(parse_color("green"), Rgba::INK_BLACK);
338    }
339
340    // ── Tests: render_tile ───────────────────────────────────────────
341
342    #[test]
343    fn render_single_tile() {
344        let elem = make_element(serde_json::Value::Number(serde_json::Number::from(10)), 3, 5);
345        let params = match &elem.params {
346            ElementParams::Tile(p) => p,
347            _ => unreachable!(),
348        };
349        let ctx = DataContext::new();
350        let theme = make_theme();
351        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
352        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
353        let rc = RenderContext {
354            screen: "test",
355            theme: &theme,
356            fonts: &fonts,
357            tilesets: &tilesets,
358            images: crate::layout_engine::types::empty_image_registry(),
359        };
360        let mut p = RecordingPainter::new();
361
362        render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
363
364        assert_eq!(p.tile_calls.len(), 1);
365        assert_eq!(p.tile_calls[0].0, TilePos::new(3, 5));
366        assert_eq!(p.tile_calls[0].1, 10);
367    }
368
369    #[test]
370    fn render_repeat_fills_rect() {
371        let mut elem = make_element(serde_json::Value::Number(serde_json::Number::from(5)), 0, 0);
372        elem.rect.tw = Some(3);
373        elem.rect.th = Some(2);
374        if let ElementParams::Tile(ref mut tp) = elem.params {
375            tp.repeat = Some(1);
376        }
377        let params = match &elem.params {
378            ElementParams::Tile(p) => p,
379            _ => unreachable!(),
380        };
381        let ctx = DataContext::new();
382        let theme = make_theme();
383        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
384        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
385        let rc = RenderContext {
386            screen: "test",
387            theme: &theme,
388            fonts: &fonts,
389            tilesets: &tilesets,
390            images: crate::layout_engine::types::empty_image_registry(),
391        };
392        let mut p = RecordingPainter::new();
393
394        render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
395
396        assert_eq!(p.tile_calls.len(), 6); // 3×2
397        for call in &p.tile_calls {
398            assert_eq!(call.1, 5);
399        }
400        assert_eq!(p.tile_calls[0].0, TilePos::new(0, 0));
401        assert_eq!(p.tile_calls[5].0, TilePos::new(2, 1));
402    }
403
404    #[test]
405    fn render_repeat_none_draws_once() {
406        let elem = make_element(serde_json::Value::Number(serde_json::Number::from(7)), 0, 0);
407        let params = match &elem.params {
408            ElementParams::Tile(p) => p,
409            _ => unreachable!(),
410        };
411        let ctx = DataContext::new();
412        let theme = make_theme();
413        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
414        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
415        let rc = RenderContext {
416            screen: "test",
417            theme: &theme,
418            fonts: &fonts,
419            tilesets: &tilesets,
420            images: crate::layout_engine::types::empty_image_registry(),
421        };
422        let mut p = RecordingPainter::new();
423
424        render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
425
426        assert_eq!(p.tile_calls.len(), 1);
427    }
428
429    #[test]
430    fn render_from_template() {
431        let elem = make_element(
432            serde_json::Value::String("{id}".to_string()),
433            0,
434            0,
435        );
436        let params = match &elem.params {
437            ElementParams::Tile(p) => p,
438            _ => unreachable!(),
439        };
440        let mut ctx = DataContext::new();
441        ctx.set("id", 77i64);
442        let theme = make_theme();
443        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
444        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
445        let rc = RenderContext {
446            screen: "test",
447            theme: &theme,
448            fonts: &fonts,
449            tilesets: &tilesets,
450            images: crate::layout_engine::types::empty_image_registry(),
451        };
452        let mut p = RecordingPainter::new();
453
454        render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
455
456        assert_eq!(p.tile_calls[0].1, 77);
457    }
458
459    #[test]
460    fn render_with_palette() {
461        let mut elem = make_element(serde_json::Value::Number(serde_json::Number::from(1)), 0, 0);
462        if let ElementParams::Tile(ref mut tp) = elem.params {
463            tp.palette = Some("darkgray".to_string());
464        }
465        let params = match &elem.params {
466            ElementParams::Tile(p) => p,
467            _ => unreachable!(),
468        };
469        let ctx = DataContext::new();
470        let theme = make_theme();
471        let fonts: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
472        let tilesets: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
473        let rc = RenderContext {
474            screen: "test",
475            theme: &theme,
476            fonts: &fonts,
477            tilesets: &tilesets,
478            images: crate::layout_engine::types::empty_image_registry(),
479        };
480        let mut p = RecordingPainter::new();
481
482        render_tile(&elem, params, &ctx, &rc, &mut p).unwrap();
483
484        assert_eq!(p.tile_calls[0].3, Rgba::INK_DARK_GRAY);
485    }
486
487    // ── Tests: render_tile_with_tiles ────────────────────────────────
488
489    #[test]
490    fn render_tile_with_tiles_no_flip() {
491        let elem = make_element(serde_json::Value::Number(serde_json::Number::from(0)), 0, 0);
492        let params = match &elem.params {
493            ElementParams::Tile(p) => p,
494            _ => unreachable!(),
495        };
496        let ctx = DataContext::new();
497
498        let data = [0u8; 16]; // blank tile
499        let tileset = crate::tile::TileSet::from_2bpp(&data);
500        let palette = crate::palette::GRAYSCALE_PALETTE;
501        let mut fb = dotzuki_engine::render::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), dotzuki_engine::render::Rgba::WHITE);
502
503        let result = render_tile_with_tiles(&elem, params, &ctx, &tileset, &palette, &mut fb);
504        assert!(result.is_ok());
505    }
506
507    #[test]
508    fn render_tile_with_tiles_flip_x() {
509        let elem = make_element(serde_json::Value::Number(serde_json::Number::from(0)), 0, 0);
510        let params = TileParams {
511            tile_id: serde_json::Value::Number(serde_json::Number::from(0)),
512            flip_x: true,
513            flip_y: false,
514            palette: None,
515            repeat: None,
516        };
517        let ctx = DataContext::new();
518
519        let data = [0xFFu8; 16]; // all color 3
520        let tileset = crate::tile::TileSet::from_2bpp(&data);
521        let palette = crate::palette::GRAYSCALE_PALETTE;
522        let mut fb = dotzuki_engine::render::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), dotzuki_engine::render::Rgba::WHITE);
523
524        let result = render_tile_with_tiles(&elem, &params, &ctx, &tileset, &palette, &mut fb);
525        assert!(result.is_ok());
526    }
527
528    #[test]
529    fn render_tile_with_tiles_repeat() {
530        let mut elem = make_element(serde_json::Value::Number(serde_json::Number::from(0)), 2, 3);
531        elem.rect.tw = Some(2);
532        elem.rect.th = Some(2);
533        let params = TileParams {
534            tile_id: serde_json::Value::Number(serde_json::Number::from(0)),
535            flip_x: false,
536            flip_y: false,
537            palette: None,
538            repeat: Some(1),
539        };
540        let ctx = DataContext::new();
541
542        let data = [0xFFu8; 16];
543        let tileset = crate::tile::TileSet::from_2bpp(&data);
544        let palette = crate::palette::GRAYSCALE_PALETTE;
545        let mut fb = dotzuki_engine::render::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), dotzuki_engine::render::Rgba::WHITE);
546
547        let result = render_tile_with_tiles(&elem, &params, &ctx, &tileset, &palette, &mut fb);
548        assert!(result.is_ok());
549
550        let tile_pixel_count = 8 * 8;
551        let filled_pixels: usize = (0..tile_pixel_count * 4)
552            .filter(|&i| {
553                let _x = (i % 4) as u32;
554                fb.data.get(i).map_or(false, |&b| b != 0)
555            })
556            .count();
557        assert!(filled_pixels > 0, "should have drawn some pixels");
558    }
559
560    #[test]
561    fn render_tile_with_tiles_out_of_bounds_tile_does_not_panic() {
562        let elem = make_element(
563            serde_json::Value::Number(serde_json::Number::from(255)),
564            0,
565            0,
566        );
567        let params = match &elem.params {
568            ElementParams::Tile(p) => p,
569            _ => unreachable!(),
570        };
571        let ctx = DataContext::new();
572
573        let data = [0u8; 16]; // only 1 tile
574        let tileset = crate::tile::TileSet::from_2bpp(&data);
575        let palette = crate::palette::GRAYSCALE_PALETTE;
576        let mut fb = dotzuki_engine::render::FrameBuffer::new(dotzuki_engine::render_config::RenderConfig::new(160, 144), dotzuki_engine::render::Rgba::WHITE);
577
578        let result = render_tile_with_tiles(&elem, params, &ctx, &tileset, &palette, &mut fb);
579        assert!(result.is_ok());
580    }
581}