Skip to main content

dotzuki_renderer/
layer_renderer.rs

1//! Multi-layer map compositing.
2//!
3//! Provides [`render_layers`], which takes a sorted stack of [`MapLayer`]s,
4//! renders each one's tilemap into a temporary framebuffer via a caller-supplied
5//! tile-to-colour callback, then composites all layers bottom-to-top onto a
6//! single output framebuffer.
7
8use crate::{FbSurface, FrameBuffer, DirtyRegion, TILE_SIZE};
9use dotzuki_engine::render::Rgba;
10use dotzuki_engine::render::{BlendMode, MapLayer};
11use dotzuki_engine::tilemap::TilemapEntry;
12use crate::tile::RgbaTile;
13use std::collections::HashMap;
14
15const TILE_PIXELS: u32 = TILE_SIZE;
16
17/// A cache of pre-rendered RGBA tiles for `no_animation` layers.
18///
19/// Maps `(tile_id, palette_group)` → fully rendered RGBA tile, avoiding
20/// per-pixel calls to the `tile_color` closure on every frame.
21pub struct LayerTileCache {
22    entries: HashMap<(u16, u8), RgbaTile>,
23}
24
25impl LayerTileCache {
26    pub fn new() -> Self {
27        Self { entries: HashMap::new() }
28    }
29
30    /// Invalidate all cached entries (call when tileset or palette changes).
31    pub fn invalidate(&mut self) {
32        self.entries.clear();
33    }
34
35    /// Get a cached tile, or populate it by calling `tile_color` for all 64 pixels.
36    pub fn get_or_insert<F>(&mut self, tile_id: u16, pal_group: u8, tile_color: &F) -> &RgbaTile
37    where
38        F: Fn(u16, u8, u8, u8) -> Rgba,
39    {
40        let key = (tile_id, pal_group);
41        self.entries.entry(key).or_insert_with(|| {
42            let mut pixels = [[Rgba::TRANSPARENT; TILE_PIXELS as usize]; TILE_PIXELS as usize];
43            for py in 0..(TILE_PIXELS as u8) {
44                for px in 0..(TILE_PIXELS as u8) {
45                    pixels[py as usize][px as usize] = tile_color(tile_id, pal_group, px, py);
46                }
47            }
48            RgbaTile { pixels }
49        })
50    }
51}
52
53impl Default for LayerTileCache {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59/// Render a stack of map layers and composite them onto `fb`.
60///
61/// Always performs a **full redraw** across the whole viewport. With zero or
62/// two-plus visible layers, `fb` is cleared to transparent first and the layers
63/// are composited into a self-contained image. With exactly one visible layer
64/// (the fast path), the layer is composited directly onto `fb` *without*
65/// clearing, so fully-transparent tile pixels (alpha 0) reveal whatever the
66/// caller drew beneath — a background fill, a parallax pass, etc. — instead of
67/// stamping transparent-black over it.
68///
69/// An earlier version gated rendering on `fb.dirty_region` (skipping the frame
70/// when nothing was marked dirty and clearing the dirty flag afterwards). That
71/// incremental path was half-implemented: the only caller reuses one persistent
72/// framebuffer and never re-marks it dirty, so after the first frame the gate
73/// blanked the map entirely. Incremental layer rendering should only be
74/// reintroduced as a complete implementation (caller-managed dirty lifecycle +
75/// per-tile invalidation), not as a partial optimisation.
76///
77/// When a layer has `no_animation: true` and `cache` is provided, pre-rendered
78/// RGBA tiles are used instead of calling `tile_color` for every pixel.
79pub fn render_layers<F>(
80    fb: &mut impl FbSurface,
81    layers: &[MapLayer],
82    camera_x: i32,
83    camera_y: i32,
84    width: u32,
85    height: u32,
86    tile_color: F,
87) where
88    F: Fn(u16, u8, u8, u8) -> Rgba,
89{
90    render_layers_sized(fb, layers, camera_x, camera_y, width, height, TILE_PIXELS, tile_color)
91}
92
93/// Like [`render_layers`] but with a caller-specified `tile_size` (pixels per
94/// tile side). [`render_layers`] is this with `tile_size == TILE_SIZE` (8).
95/// Full-colour games whose tiles are not 8×8 (e.g. wuxia's 16×16 tiles) call
96/// this so the grid step and flip maths match their tile size.
97pub fn render_layers_sized<F>(
98    fb: &mut impl FbSurface,
99    layers: &[MapLayer],
100    camera_x: i32,
101    camera_y: i32,
102    width: u32,
103    height: u32,
104    tile_size: u32,
105    tile_color: F,
106) where
107    F: Fn(u16, u8, u8, u8) -> Rgba,
108{
109    render_layers_with_cache_sized(
110        fb, layers, camera_x, camera_y, width, height, tile_size, tile_color, None,
111    )
112}
113
114/// Like [`render_layers`] but accepts an optional [`LayerTileCache`] for
115/// accelerating `no_animation` layers.
116pub fn render_layers_with_cache<F>(
117    fb: &mut impl FbSurface,
118    layers: &[MapLayer],
119    camera_x: i32,
120    camera_y: i32,
121    width: u32,
122    height: u32,
123    tile_color: F,
124    cache: Option<&mut LayerTileCache>,
125) where
126    F: Fn(u16, u8, u8, u8) -> Rgba,
127{
128    render_layers_with_cache_sized(
129        fb, layers, camera_x, camera_y, width, height, TILE_PIXELS, tile_color, cache,
130    )
131}
132
133/// Like [`render_layers_with_cache`] but with a caller-specified `tile_size`
134/// (pixels per tile side).
135///
136/// Note: [`LayerTileCache`] stores fixed 8×8 [`RgbaTile`]s, so caching is
137/// bypassed whenever `tile_size != TILE_SIZE` — the per-pixel `tile_color`
138/// path is used instead. (wuxia's static maps render fine without the cache;
139/// the full-colour `tile_color` closure is a cheap array index.)
140pub fn render_layers_with_cache_sized<F>(
141    fb: &mut impl FbSurface,
142    layers: &[MapLayer],
143    camera_x: i32,
144    camera_y: i32,
145    width: u32,
146    height: u32,
147    tile_size: u32,
148    tile_color: F,
149    mut cache: Option<&mut LayerTileCache>,
150) where
151    F: Fn(u16, u8, u8, u8) -> Rgba,
152{
153    // Collect and sort visible layers.
154    let mut visible: Vec<&MapLayer> = layers.iter().filter(|l| l.visible).collect();
155    visible.sort_by_key(|l| l.z_index);
156
157    if visible.is_empty() {
158        // No layers to draw — blank the framebuffer (full-redraw contract).
159        fb.clear(Rgba::TRANSPARENT);
160        return;
161    }
162
163    // Fast path: exactly one visible layer — composite it directly onto `fb`,
164    // PRESERVING whatever the caller already drew. `render_single_layer` skips
165    // fully-transparent (alpha == 0) tile pixels, so holes in the layer reveal
166    // the caller's existing framebuffer (a background fill, a parallax pass…)
167    // instead of stamping transparent-black over it. (Deliberately no
168    // `fb.clear()` here — that is what makes single-layer transparency show
169    // through; the >=2-layer path below clears + composites self-contained.)
170    if visible.len() == 1 {
171        render_single_layer(fb, visible[0], camera_x, camera_y, width, height, tile_size, &tile_color, &mut cache);
172        return;
173    }
174
175    // General path: clear, render each layer to a temp buffer, then composite.
176    fb.clear(Rgba::TRANSPARENT);
177    let mut temp = FrameBuffer {
178        data: vec![0; (width * height * 4) as usize],
179        width,
180        height,
181        dirty_region: DirtyRegion::full(width, height),
182    };
183
184    for layer in &visible {
185        temp.clear(Rgba::TRANSPARENT);
186        render_single_layer(&mut temp, layer, camera_x, camera_y, width, height, tile_size, &tile_color, &mut cache);
187        composite_onto(fb, &temp, layer.opacity, layer.blend_mode);
188    }
189}
190
191/// Render a single layer across the whole viewport.
192///
193/// When `cache` is provided and `layer.no_animation` is true, pre-rendered
194/// RGBA tiles are used instead of calling `tile_color` per pixel.
195fn render_single_layer<F>(
196    fb: &mut impl FbSurface,
197    layer: &MapLayer,
198    camera_x: i32,
199    camera_y: i32,
200    width: u32,
201    height: u32,
202    tile_size: u32,
203    tile_color: &F,
204    cache: &mut Option<&mut LayerTileCache>,
205) where
206    F: Fn(u16, u8, u8, u8) -> Rgba,
207{
208    let scroll_x = (camera_x as f32 * layer.scroll_factor.0) as i32;
209    let scroll_y = (camera_y as f32 * layer.scroll_factor.1) as i32;
210
211    // The cache stores fixed 8×8 tiles, so it's only valid at the default size.
212    let use_cache = layer.no_animation && cache.is_some() && tile_size == TILE_PIXELS;
213
214    for screen_y in 0..height {
215        let world_y = scroll_y + screen_y as i32;
216        if world_y < 0 {
217            continue;
218        }
219        let tile_y = (world_y as u32) / tile_size;
220        let pixel_y = (world_y as u32 % tile_size) as u8;
221
222        for screen_x in 0..width {
223            let world_x = scroll_x + screen_x as i32;
224            if world_x < 0 {
225                continue;
226            }
227            let tile_x = (world_x as u32) / tile_size;
228            let pixel_x = (world_x as u32 % tile_size) as u8;
229
230            if let Some(entry) = layer.tilemap.get(tile_x as u16, tile_y as u16) {
231                let color = if use_cache {
232                    let c = cache.as_mut().unwrap();
233                    let rgba_tile = c.get_or_insert(entry.tile_id, entry.palette_group, tile_color);
234                    let (eff_px, eff_py) = effective_pixel(pixel_x, pixel_y, entry, tile_size);
235                    rgba_tile.pixels[eff_py as usize][eff_px as usize]
236                } else {
237                    let (eff_px, eff_py) = effective_pixel(pixel_x, pixel_y, entry, tile_size);
238                    tile_color(entry.tile_id, entry.palette_group, eff_px, eff_py)
239                };
240                // Skip fully-transparent tile pixels so they don't overwrite
241                // what's beneath. In the single-layer fast path this lets the
242                // caller's background show through holes; in the multi-layer
243                // path the temp buffer is already transparent here, so this is
244                // a no-op that matches the prior behaviour.
245                if color.a == 0 {
246                    continue;
247                }
248                fb.set_pixel(screen_x, screen_y, color);
249            }
250        }
251    }
252}
253
254/// Compute the effective intra-tile pixel coordinate after applying
255/// horizontal and vertical flip flags, for a tile of side `tile_size` pixels.
256#[inline]
257fn effective_pixel(px: u8, py: u8, entry: &TilemapEntry, tile_size: u32) -> (u8, u8) {
258    let last = (tile_size - 1) as u8;
259    let eff_px = if entry.flip_h { last - px } else { px };
260    let eff_py = if entry.flip_v { last - py } else { py };
261    (eff_px, eff_py)
262}
263
264/// Composite `src` onto `dst` in-place using the given blend mode and
265/// overall layer opacity.
266///
267/// `dst` may be any [`FbSurface`] (the RGBA engine buffer or the indexed
268/// facade); source pixels blend against the destination's current color and
269/// are written back through the surface. The intermediate temp buffer stays
270/// an RGBA [`FrameBuffer`].
271///
272/// Invariant: on an indexed destination, `get_pixel` reads through the
273/// *display* palette while `set_pixel` quantizes through the *base* palette,
274/// so compositing must only run while the display palette is the base
275/// palette — a fade/flash palette active here would shift indices on
276/// round-trip. Not reachable today; documented as a constraint.
277fn composite_onto(dst: &mut impl FbSurface, src: &FrameBuffer, opacity: f32, blend_mode: BlendMode) {
278    let (w, h) = (dst.width(), dst.height());
279    assert_eq!(w, src.width);
280    assert_eq!(h, src.height);
281
282    for y in 0..h {
283        for x in 0..w {
284            let off = ((y * w + x) * 4) as usize;
285            let sr = src.data[off] as f32;
286            let sg = src.data[off + 1] as f32;
287            let sb = src.data[off + 2] as f32;
288            let sa = src.data[off + 3] as f32;
289
290            let d = dst.get_pixel(x, y).unwrap_or(Rgba::TRANSPARENT);
291            let dr = d.r as f32;
292            let dg = d.g as f32;
293            let db = d.b as f32;
294            let da = d.a as f32;
295
296            // Effective source alpha = pixel alpha * layer opacity.
297            let src_alpha = (sa / 255.0) * opacity;
298
299            let (out_r, out_g, out_b, out_a) = match blend_mode {
300                BlendMode::Normal => {
301                    let a = src_alpha;
302                    let inv_a = 1.0 - a;
303                    let r = sr * a + dr * inv_a;
304                    let g = sg * a + dg * inv_a;
305                    let b = sb * a + db * inv_a;
306                    let a_out = sa * opacity + da * (1.0 - src_alpha);
307                    (r, g, b, a_out)
308                }
309                BlendMode::Additive => {
310                    let r = (dr + sr * src_alpha).min(255.0);
311                    let g = (dg + sg * src_alpha).min(255.0);
312                    let b = (db + sb * src_alpha).min(255.0);
313                    (r, g, b, 255.0)
314                }
315                BlendMode::Multiply => {
316                    let inv_opacity = 1.0 - opacity;
317                    // lerp between original dst and multiplied result based on opacity.
318                    let r_mul = sr * dr / 255.0;
319                    let g_mul = sg * dg / 255.0;
320                    let b_mul = sb * db / 255.0;
321                    let r = dr * inv_opacity + r_mul * opacity;
322                    let g = dg * inv_opacity + g_mul * opacity;
323                    let b = db * inv_opacity + b_mul * opacity;
324                    (r, g, b, 255.0)
325                }
326            };
327
328            dst.set_pixel(
329                x,
330                y,
331                Rgba::new(
332                    out_r.clamp(0.0, 255.0) as u8,
333                    out_g.clamp(0.0, 255.0) as u8,
334                    out_b.clamp(0.0, 255.0) as u8,
335                    out_a.clamp(0.0, 255.0) as u8,
336                ),
337            );
338        }
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::DirtyRegion;
346    use dotzuki_engine::render::{BlendMode, MapLayer};
347    use dotzuki_engine::tilemap::{Tilemap, TilemapEntry};
348
349    // ------------------------------------------------------------------
350    // Helpers
351    // ------------------------------------------------------------------
352
353    /// A tile-colour callback that maps tile_id to a unique solid colour.
354    /// tile_id 0 → transparent, 1 → red, 2 → green, 3 → blue, else → white.
355    fn test_tile_color(tile_id: u16, _pal: u8, _px: u8, _py: u8) -> Rgba {
356        match tile_id {
357            0 => Rgba::TRANSPARENT,
358            1 => Rgba::rgb(255, 0, 0),
359            2 => Rgba::rgb(0, 255, 0),
360            3 => Rgba::rgb(0, 0, 255),
361            _ => Rgba::rgb(255, 255, 255),
362        }
363    }
364
365    /// Build a small tilemap where every entry has the given tile_id.
366    fn uniform_tilemap(w: u16, h: u16, tile_id: u16) -> Tilemap {
367        let mut tm = Tilemap::new(w, h);
368        let entry = TilemapEntry {
369            tile_id,
370            ..Default::default()
371        };
372        tm.fill_rect(0, 0, w, h, entry);
373        tm
374    }
375
376    // ------------------------------------------------------------------
377    // effective_pixel
378    // ------------------------------------------------------------------
379
380    #[test]
381    fn effective_pixel_no_flip() {
382        let e = TilemapEntry::default();
383        assert_eq!(effective_pixel(3, 5, &e, 8), (3, 5));
384    }
385
386    #[test]
387    fn effective_pixel_flip_h() {
388        let e = TilemapEntry {
389            flip_h: true,
390            ..Default::default()
391        };
392        assert_eq!(effective_pixel(0, 0, &e, 8), (7, 0));
393        assert_eq!(effective_pixel(7, 0, &e, 8), (0, 0));
394    }
395
396    #[test]
397    fn effective_pixel_flip_v() {
398        let e = TilemapEntry {
399            flip_v: true,
400            ..Default::default()
401        };
402        assert_eq!(effective_pixel(0, 0, &e, 8), (0, 7));
403        assert_eq!(effective_pixel(0, 7, &e, 8), (0, 0));
404    }
405
406    #[test]
407    fn effective_pixel_flip_both() {
408        let e = TilemapEntry {
409            flip_h: true,
410            flip_v: true,
411            ..Default::default()
412        };
413        assert_eq!(effective_pixel(0, 0, &e, 8), (7, 7));
414    }
415
416    #[test]
417    fn effective_pixel_flip_16px() {
418        // At a 16px tile size, flips mirror around 15 (not 7).
419        let e = TilemapEntry {
420            flip_h: true,
421            flip_v: true,
422            ..Default::default()
423        };
424        assert_eq!(effective_pixel(0, 0, &e, 16), (15, 15));
425        assert_eq!(effective_pixel(15, 3, &e, 16), (0, 12));
426    }
427
428    #[test]
429    fn render_layers_sized_16px_tiles() {
430        // A 2×1 map of 16px tiles: tile (0,0)=green(2), tile (1,0)=blue(3).
431        // With a 16px grid, screen pixel x=20 lands in tile (1,0) → blue;
432        // x=4 lands in tile (0,0) → green. (At the default 8px grid, x=20
433        // would be tile 2 — out of range — proving the size is honoured.)
434        let mut tm = Tilemap::new(2, 1);
435        tm.set(0, 0, TilemapEntry { tile_id: 2, ..Default::default() });
436        tm.set(1, 0, TilemapEntry { tile_id: 3, ..Default::default() });
437        let layer = MapLayer::new(tm, 0);
438
439        let mut fb = make_fb(32, 16, Rgba::WHITE);
440        render_layers_sized(&mut fb, &[layer], 0, 0, 32, 16, 16, test_tile_color);
441
442        assert_eq!(fb.get_pixel(4, 8), Some(Rgba::rgb(0, 255, 0)), "left half = green tile 0");
443        assert_eq!(fb.get_pixel(20, 8), Some(Rgba::rgb(0, 0, 255)), "right half = blue tile 1");
444    }
445
446    // ------------------------------------------------------------------
447    // composite_onto
448    // ------------------------------------------------------------------
449
450    #[test]
451    fn composite_normal_fully_opaque() {
452        let mut dst = make_fb(2, 2, Rgba::rgb(0, 0, 0));
453        let src = make_fb(2, 2, Rgba::rgb(255, 0, 0));
454        composite_onto(&mut dst, &src, 1.0, BlendMode::Normal);
455        for y in 0..2 {
456            for x in 0..2 {
457                assert_eq!(dst.get_pixel(x, y), Some(Rgba::rgb(255, 0, 0)));
458            }
459        }
460    }
461
462    #[test]
463    fn composite_normal_half_opaque() {
464        let mut dst = make_fb(1, 1, Rgba::rgb(0, 0, 0));
465        let src = make_fb(1, 1, Rgba::rgb(200, 0, 0));
466        composite_onto(&mut dst, &src, 0.5, BlendMode::Normal);
467        // 200*0.5 + 0*0.5 = 100
468        let p = dst.get_pixel(0, 0).unwrap();
469        assert!((p.r as i32 - 100).abs() <= 1);
470        assert_eq!(p.g, 0);
471        assert_eq!(p.b, 0);
472    }
473
474    #[test]
475    fn composite_normal_transparent_src_does_not_overwrite() {
476        let mut dst = make_fb(1, 1, Rgba::rgb(100, 100, 100));
477        let src = make_fb(1, 1, Rgba::TRANSPARENT);
478        composite_onto(&mut dst, &src, 1.0, BlendMode::Normal);
479        assert_eq!(dst.get_pixel(0, 0), Some(Rgba::rgb(100, 100, 100)));
480    }
481
482    #[test]
483    fn composite_additive() {
484        let mut dst = make_fb(1, 1, Rgba::rgb(50, 30, 10));
485        let src = make_fb(1, 1, Rgba::rgb(100, 80, 60));
486        composite_onto(&mut dst, &src, 1.0, BlendMode::Additive);
487        let p = dst.get_pixel(0, 0).unwrap();
488        // 50+100=150, 30+80=110, 10+60=70
489        assert!(p.r >= 145);
490        assert!(p.g >= 105);
491        assert!(p.b >= 65);
492    }
493
494    #[test]
495    fn composite_additive_clamps_at_255() {
496        let mut dst = make_fb(1, 1, Rgba::rgb(200, 200, 200));
497        let src = make_fb(1, 1, Rgba::rgb(200, 200, 200));
498        composite_onto(&mut dst, &src, 1.0, BlendMode::Additive);
499        let p = dst.get_pixel(0, 0).unwrap();
500        // 200+200=400, clamped to 255.
501        assert_eq!(p.r, 255);
502        assert_eq!(p.g, 255);
503        assert_eq!(p.b, 255);
504    }
505
506    #[test]
507    fn composite_multiply() {
508        let mut dst = make_fb(1, 1, Rgba::rgb(255, 128, 64));
509        let src = make_fb(1, 1, Rgba::rgb(128, 255, 192));
510        composite_onto(&mut dst, &src, 1.0, BlendMode::Multiply);
511        let p = dst.get_pixel(0, 0).unwrap();
512        // 255*128/255=128, 128*255/255=128, 64*192/255≈48
513        assert!((p.r as i32 - 128).abs() <= 1);
514        assert!((p.g as i32 - 128).abs() <= 1);
515        assert!((p.b as i32 - 48).abs() <= 2);
516    }
517
518    // ------------------------------------------------------------------
519    // render_layers – single layer
520    // ------------------------------------------------------------------
521
522    #[test]
523    fn single_layer_renders_correctly() {
524        // 8×8 tilemap, all tiles = tile_id 1 (red).
525        let tm = uniform_tilemap(1, 1, 1);
526        let layer = MapLayer::new(tm, 0);
527        let mut fb = FrameBuffer {
528            data: vec![0; (8 * 8 * 4) as usize],
529            width: 8,
530            height: 8,
531            dirty_region: DirtyRegion::full(8, 8),
532        };
533
534        render_layers(&mut fb, &[layer], 0, 0, 8, 8, test_tile_color);
535
536        for y in 0..8 {
537            for x in 0..8 {
538                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(255, 0, 0)));
539            }
540        }
541    }
542
543    #[test]
544    fn single_layer_respects_camera_offset() {
545        // 2×1 tilemap: tile (0,0) = id 2 (green), tile (1,0) = id 3 (blue).
546        let mut tm = Tilemap::new(2, 1);
547        tm.set(0, 0, TilemapEntry { tile_id: 2, ..Default::default() });
548        tm.set(1, 0, TilemapEntry { tile_id: 3, ..Default::default() });
549
550        let layer = MapLayer::new(tm, 0);
551        // 8×8 viewport, camera scrolled right by 8 pixels.
552        let mut fb = FrameBuffer {
553            data: vec![0; (8 * 8 * 4) as usize],
554            width: 8,
555            height: 8,
556            dirty_region: DirtyRegion::full(8, 8),
557        };
558
559        render_layers(&mut fb, &[layer], 8, 0, 8, 8, test_tile_color);
560
561        // Should see tile (1,0) = blue
562        for y in 0..8 {
563            for x in 0..8 {
564                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(0, 0, 255)));
565            }
566        }
567    }
568
569    #[test]
570    fn scroll_factor_half_speed() {
571        // 2×1 tilemap: (0,0)=red(1), (1,0)=green(2). Camera moves 16px right
572        // but scroll_factor=0.5 → effective scroll=8px → tile (1,0) visible.
573        let mut tm = Tilemap::new(2, 1);
574        tm.set(0, 0, TilemapEntry { tile_id: 1, ..Default::default() });
575        tm.set(1, 0, TilemapEntry { tile_id: 2, ..Default::default() });
576
577        let mut layer = MapLayer::new(tm, 0);
578        layer.scroll_factor = (0.5, 0.5);
579
580        let mut fb = FrameBuffer {
581            data: vec![0; (8 * 8 * 4) as usize],
582            width: 8,
583            height: 8,
584            dirty_region: DirtyRegion::full(8, 8),
585        };
586
587        render_layers(&mut fb, &[layer], 16, 0, 8, 8, test_tile_color);
588
589        // Camera at 16, factor 0.5 → effective scroll 8 → tile (1,0) = green
590        for y in 0..8 {
591            for x in 0..8 {
592                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(0, 255, 0)));
593            }
594        }
595    }
596
597    #[test]
598    fn single_layer_transparent_tile_reveals_background() {
599        // Regression: a SINGLE visible layer with a transparent tile must let
600        // the caller's pre-existing framebuffer show through, rather than the
601        // fast path clearing fb and stamping transparent-black over it.
602        //
603        // 2×1 map at 8px tiles: tile (0,0) = transparent (id 0), (1,0) = red (1).
604        // The caller pre-fills the framebuffer blue; the left tile must keep the
605        // blue background and the right (opaque) tile must overwrite it.
606        let mut tm = Tilemap::new(2, 1);
607        tm.set(0, 0, TilemapEntry { tile_id: 0, ..Default::default() }); // transparent
608        tm.set(1, 0, TilemapEntry { tile_id: 1, ..Default::default() }); // red, opaque
609        let layer = MapLayer::new(tm, 0);
610
611        let mut fb = make_fb(16, 8, Rgba::rgb(0, 0, 255)); // blue background
612        render_layers(&mut fb, &[layer], 0, 0, 16, 8, test_tile_color);
613
614        // Left half (transparent tile) → blue background shows through.
615        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::rgb(0, 0, 255)), "transparent tile keeps background");
616        assert_eq!(fb.get_pixel(7, 7), Some(Rgba::rgb(0, 0, 255)), "transparent tile keeps background");
617        // Right half (opaque red tile) → covers background.
618        assert_eq!(fb.get_pixel(8, 0), Some(Rgba::rgb(255, 0, 0)), "opaque tile overwrites background");
619        assert_eq!(fb.get_pixel(15, 7), Some(Rgba::rgb(255, 0, 0)), "opaque tile overwrites background");
620    }
621
622    // ------------------------------------------------------------------
623    // render_layers – multi-layer compositing
624    // ------------------------------------------------------------------
625
626    #[test]
627    fn two_layers_composite_transparent_top() {
628        // Bottom: all green (2). Top: all transparent (0).
629        let bottom_tm = uniform_tilemap(1, 1, 2);
630        let top_tm = uniform_tilemap(1, 1, 0);
631
632        let bottom = MapLayer::new(bottom_tm, 0);
633        let top = MapLayer::new(top_tm, 1);
634
635        let mut fb = FrameBuffer {
636            data: vec![0; (8 * 8 * 4) as usize],
637            width: 8,
638            height: 8,
639            dirty_region: DirtyRegion::full(8, 8),
640        };
641
642        render_layers(&mut fb, &[bottom, top], 0, 0, 8, 8, test_tile_color);
643
644        // Bottom green should show through transparent top.
645        for y in 0..8 {
646            for x in 0..8 {
647                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(0, 255, 0)));
648            }
649        }
650    }
651
652    #[test]
653    fn two_layers_opaque_top_covers_bottom() {
654        // Bottom: green (2). Top: red (1), opaque.
655        let bottom_tm = uniform_tilemap(1, 1, 2);
656        let top_tm = uniform_tilemap(1, 1, 1);
657
658        let bottom = MapLayer::new(bottom_tm, 0);
659        let top = MapLayer::new(top_tm, 1);
660
661        let mut fb = FrameBuffer {
662            data: vec![0; (8 * 8 * 4) as usize],
663            width: 8,
664            height: 8,
665            dirty_region: DirtyRegion::full(8, 8),
666        };
667
668        render_layers(&mut fb, &[bottom, top], 0, 0, 8, 8, test_tile_color);
669
670        // Top red should cover bottom green.
671        for y in 0..8 {
672            for x in 0..8 {
673                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(255, 0, 0)));
674            }
675        }
676    }
677
678    #[test]
679    fn opacity_affects_visibility() {
680        // Bottom: red (1). Top: green (2), opacity 0.5.
681        let bottom_tm = uniform_tilemap(1, 1, 1);
682        let top_tm = uniform_tilemap(1, 1, 2);
683
684        let bottom = MapLayer::new(bottom_tm, 0);
685        let mut top = MapLayer::new(top_tm, 1);
686        top.opacity = 0.5;
687
688        let mut fb = FrameBuffer {
689            data: vec![0; (1 * 1 * 4) as usize],
690            width: 1,
691            height: 1,
692            dirty_region: DirtyRegion::full(1, 1),
693        };
694
695        render_layers(&mut fb, &[bottom, top], 0, 0, 1, 1, test_tile_color);
696
697        // Blend: src=green(0,255,0) at 0.5 over dst=red(255,0,0)
698        // Normal: g = 255 * 0.5 + 0 * 0.5 = 127.5, r = 0 * 0.5 + 255 * 0.5 = 127.5
699        let p = fb.get_pixel(0, 0).unwrap();
700        let r = p.r as i32;
701        let g = p.g as i32;
702        // Red should be ~128 (half original)
703        assert!(r >= 125 && r <= 130, "r={r}");
704        // Green should be ~128 (half top)
705        assert!(g >= 125 && g <= 130, "g={g}");
706    }
707
708    #[test]
709    fn invisible_layer_skipped() {
710        // Bottom: green (2). Top: red (1), invisible.
711        let bottom_tm = uniform_tilemap(1, 1, 2);
712        let top_tm = uniform_tilemap(1, 1, 1);
713
714        let bottom = MapLayer::new(bottom_tm, 0);
715        let mut top = MapLayer::new(top_tm, 1);
716        top.visible = false;
717
718        let mut fb = FrameBuffer {
719            data: vec![0; (8 * 8 * 4) as usize],
720            width: 8,
721            height: 8,
722            dirty_region: DirtyRegion::full(8, 8),
723        };
724
725        render_layers(&mut fb, &[bottom, top], 0, 0, 8, 8, test_tile_color);
726
727        // Only green should be visible.
728        for y in 0..8 {
729            for x in 0..8 {
730                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(0, 255, 0)));
731            }
732        }
733    }
734
735    #[test]
736    fn empty_layers_produces_blank() {
737        let mut fb = FrameBuffer {
738            data: vec![0xFF; (8 * 8 * 4) as usize],
739            width: 8,
740            height: 8,
741            dirty_region: DirtyRegion::full(8, 8),
742        };
743
744        render_layers(&mut fb, &[], 0, 0, 8, 8, test_tile_color);
745
746        // Should be cleared to transparent black.
747        for y in 0..8 {
748            for x in 0..8 {
749                assert_eq!(fb.get_pixel(x, y), Some(Rgba::TRANSPARENT));
750            }
751        }
752    }
753
754    #[test]
755    fn z_index_sorting() {
756        // Layer A: z=5 (red). Layer B: z=1 (green). B should render first (behind).
757        let tm_a = uniform_tilemap(1, 1, 1);
758        let tm_b = uniform_tilemap(1, 1, 2);
759
760        let layer_a = MapLayer::new(tm_a, 5);
761        let layer_b = MapLayer::new(tm_b, 1);
762
763        // Pass in reverse z-order — render_layers must sort.
764        let mut fb = FrameBuffer {
765            data: vec![0; (8 * 8 * 4) as usize],
766            width: 8,
767            height: 8,
768            dirty_region: DirtyRegion::full(8, 8),
769        };
770
771        render_layers(&mut fb, &[layer_a, layer_b], 0, 0, 8, 8, test_tile_color);
772
773        // Layer B (green, z=1) rendered first, then A (red, z=5) on top.
774        for y in 0..8 {
775            for x in 0..8 {
776                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(255, 0, 0)));
777            }
778        }
779    }
780
781    #[test]
782    fn scroll_factor_vertical() {
783        // 1×2 tilemap: (0,0)=red(1), (0,1)=green(2). Camera at y=8.
784        let mut tm = Tilemap::new(1, 2);
785        tm.set(0, 0, TilemapEntry { tile_id: 1, ..Default::default() });
786        tm.set(0, 1, TilemapEntry { tile_id: 2, ..Default::default() });
787
788        let layer = MapLayer::new(tm, 0);
789
790        let mut fb = FrameBuffer {
791            data: vec![0; (8 * 8 * 4) as usize],
792            width: 8,
793            height: 8,
794            dirty_region: DirtyRegion::full(8, 8),
795        };
796
797        render_layers(&mut fb, &[layer], 0, 8, 8, 8, test_tile_color);
798
799        // Should see green (tile at y=1).
800        for y in 0..8 {
801            for x in 0..8 {
802                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(0, 255, 0)));
803            }
804        }
805    }
806
807    #[test]
808    fn single_layer_optimization_bypasses_temp_buffer() {
809        // Verify that the single-layer fast path renders correctly.
810        // (The behaviour is identical to multi-layer with one layer,
811        // but the internal path is different.)
812        let tm = uniform_tilemap(2, 2, 3);
813        let layer = MapLayer::new(tm, 0);
814
815        let mut fb = FrameBuffer {
816            data: vec![0xFF; (16 * 16 * 4) as usize],
817            width: 16,
818            height: 16,
819            dirty_region: DirtyRegion::full(16, 16),
820        };
821
822        render_layers(&mut fb, &[layer], 0, 0, 16, 16, test_tile_color);
823
824        for y in 0..16 {
825            for x in 0..16 {
826                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(0, 0, 255)));
827            }
828        }
829    }
830
831    // ------------------------------------------------------------------
832    // DirtyRegion tests
833    // ------------------------------------------------------------------
834
835    #[test]
836    fn dirty_region_empty_is_not_present() {
837        let d = DirtyRegion::empty();
838        assert!(!d.present);
839        assert!(!d.contains_pixel(0, 0));
840        assert!(!d.contains_pixel(100, 50));
841    }
842
843    #[test]
844    fn dirty_region_full_contains_all() {
845        let d = DirtyRegion::full(160, 144);
846        assert!(d.present);
847        assert!(d.contains_pixel(0, 0));
848        assert!(d.contains_pixel(159, 143));
849        assert!(!d.contains_pixel(160, 0));
850        assert!(!d.contains_pixel(0, 144));
851    }
852
853    #[test]
854    fn dirty_region_union_combines() {
855        let a = DirtyRegion::new(0, 0, 10, 10);
856        let b = DirtyRegion::new(5, 5, 20, 20);
857        let u = a.union(&b);
858        assert!(u.present);
859        assert_eq!(u.x, 0);
860        assert_eq!(u.y, 0);
861        assert_eq!(u.width, 25);
862        assert_eq!(u.height, 25);
863    }
864
865    #[test]
866    fn dirty_region_union_with_empty() {
867        let a = DirtyRegion::new(0, 0, 10, 10);
868        let empty = DirtyRegion::empty();
869        assert_eq!(a.union(&empty), a);
870        assert_eq!(empty.union(&a), a);
871    }
872
873    #[test]
874    fn frame_buffer_dirty_mark_and_check() {
875        let mut fb = make_fb(20, 10, Rgba::WHITE);
876        fb.clear_dirty();
877        // Nothing dirty → no pixel should be dirty
878        assert!(!fb.is_dirty_pixel(0, 0));
879        assert!(!fb.is_dirty_pixel(10, 5));
880
881        // Mark a region
882        fb.mark_dirty_rect(5, 3, 10, 4);
883        assert!(fb.is_dirty_pixel(5, 3));
884        assert!(fb.is_dirty_pixel(14, 6));
885        assert!(!fb.is_dirty_pixel(4, 3));
886        assert!(!fb.is_dirty_pixel(0, 0));
887
888        // Mark all dirty
889        fb.mark_all_dirty();
890        assert!(fb.is_dirty_pixel(0, 0));
891        assert!(fb.is_dirty_pixel(19, 9));
892    }
893
894    #[test]
895    fn frame_buffer_mark_dirty_tile() {
896        let mut fb = make_fb(32, 24, Rgba::WHITE);
897        fb.clear_dirty();
898        fb.mark_dirty_tile(2, 3);
899        // Tile (2,3) starts at pixel (16, 24)
900        assert!(fb.is_dirty_pixel(16, 24));
901        assert!(fb.is_dirty_pixel(23, 31));
902        assert!(!fb.is_dirty_pixel(15, 24));
903        assert!(!fb.is_dirty_pixel(0, 0));
904    }
905
906    // ------------------------------------------------------------------
907    // LayerTileCache tests
908    // ------------------------------------------------------------------
909
910    #[test]
911    fn cached_tiles_render_same_as_non_cached() {
912        // Create a 4x4 tilemap with varying tile_ids.
913        let mut tm = Tilemap::new(4, 4);
914        let tiles = [1u16, 2, 3, 1, 2, 1, 3, 2, 3, 1, 2, 1, 1, 3, 2, 3];
915        for y in 0..4u16 {
916            for x in 0..4u16 {
917                tm.set(x, y, TilemapEntry { tile_id: tiles[(y * 4 + x) as usize], ..Default::default() });
918            }
919        }
920
921        let mut layer = MapLayer::new(tm, 0);
922        layer.no_animation = true;
923
924        // Render without cache
925        let mut fb_uncached = make_fb(32, 32, Rgba::WHITE);
926        render_layers(&mut fb_uncached, &[layer.clone()], 0, 0, 32, 32, test_tile_color);
927
928        // Render with cache
929        let mut fb_cached = make_fb(32, 32, Rgba::WHITE);
930        let mut cache = LayerTileCache::new();
931        render_layers_with_cache(&mut fb_cached, &[layer], 0, 0, 32, 32, test_tile_color, Some(&mut cache));
932
933        // Outputs must be identical
934        for y in 0..32 {
935            for x in 0..32 {
936                assert_eq!(fb_uncached.get_pixel(x, y), fb_cached.get_pixel(x, y),
937                    "pixel ({},{}) differs", x, y);
938            }
939        }
940    }
941
942    #[test]
943    fn cache_invalidates_and_rebuilds() {
944        let tm = uniform_tilemap(1, 1, 1);
945        let mut layer = MapLayer::new(tm, 0);
946        layer.no_animation = true;
947
948        let mut cache = LayerTileCache::new();
949        let mut fb = make_fb(8, 8, Rgba::WHITE);
950        render_layers_with_cache(&mut fb, &[layer.clone()], 0, 0, 8, 8, test_tile_color, Some(&mut cache));
951
952        // Cache should have entries now
953        assert!(!cache.entries.is_empty());
954
955        // Invalidate
956        cache.invalidate();
957        assert!(cache.entries.is_empty());
958
959        // Can still render (cache rebuilds)
960        let mut fb2 = make_fb(8, 8, Rgba::WHITE);
961        render_layers_with_cache(&mut fb2, &[layer], 0, 0, 8, 8, test_tile_color, Some(&mut cache));
962        assert!(!cache.entries.is_empty());
963    }
964
965    #[test]
966    fn render_layers_ignores_dirty_region_and_always_redraws() {
967        // Even with an empty dirty region, render_layers must perform a full
968        // redraw — this is the regression guard for the blank-overworld bug.
969        let tm = uniform_tilemap(2, 2, 1);
970        let layer = MapLayer::new(tm, 0);
971
972        let mut fb = make_fb(16, 16, Rgba::WHITE);
973        fb.clear_dirty(); // nothing marked dirty
974
975        render_layers(&mut fb, &[layer], 0, 0, 16, 16, test_tile_color);
976
977        // The whole framebuffer must be redrawn red regardless of dirty state.
978        for y in 0..16 {
979            for x in 0..16 {
980                assert_eq!(fb.get_pixel(x, y), Some(Rgba::rgb(255, 0, 0)),
981                    "pixel ({},{}) should be red after full redraw", x, y);
982            }
983        }
984    }
985
986    #[test]
987    fn layer_without_no_animation_does_not_use_cache() {
988        let tm = uniform_tilemap(1, 1, 1);
989        let mut layer = MapLayer::new(tm, 0);
990        layer.no_animation = false;
991
992        let mut cache = LayerTileCache::new();
993        let mut fb = make_fb(8, 8, Rgba::WHITE);
994        render_layers_with_cache(&mut fb, &[layer], 0, 0, 8, 8, test_tile_color, Some(&mut cache));
995
996        // Cache should be empty because no_animation was false
997        assert!(cache.entries.is_empty());
998    }
999
1000    // ------------------------------------------------------------------
1001    // Helper
1002    // ------------------------------------------------------------------
1003
1004    fn make_fb(w: u32, h: u32, color: Rgba) -> FrameBuffer {
1005        let mut fb = FrameBuffer {
1006            data: vec![0; (w * h * 4) as usize],
1007            width: w,
1008            height: h,
1009            dirty_region: DirtyRegion::full(w, h),
1010        };
1011        fb.clear(color);
1012        fb
1013    }
1014}