Skip to main content

dotzuki_runner/
map.rs

1//! Runtime map loading for zero-Rust game projects.
2//!
3//! A map lives in `<maps_dir>/<MapId>/` and consists of:
4//!
5//! - `map.tmx.json` — a Tiled JSON map. Visual layers render in stack order,
6//!   split at the player's elevation by their integer custom property
7//!   `level` (default 0): `level <= player elevation` draws below sprites,
8//!   above it draws over them. The data layers are never rendered:
9//!   `collision` is the level-0 collision grid, `collisionN` the level-N
10//!   grid (any non-zero GID marks a solid tile at that level), and `stairs`
11//!   marks elevation transitions (GID 1 = ascend on arrival, 2 = descend).
12//! - `tileset.png` — a row-major tile atlas sliced by [`crate::tileset::PngTileset`]
13//!   using the TMX `tilewidth`/`tileheight`.
14//! - an entity sidecar — the dotzuki-editor writes `objects.json`
15//!   (`{npcs, warps, …}`); older fixtures used `map.json`. [`MapObjects::load`]
16//!   tries `objects.json` first and falls back to `map.json`; neither being
17//!   present yields an empty sidecar, not an error.
18
19use std::path::{Path, PathBuf};
20
21use anyhow::{bail, Context, Result};
22use dotzuki_engine::overworld::actor::OverworldCollision;
23use dotzuki_engine::render::{FrameBuffer, MapRenderState, Rgba};
24use dotzuki_engine_tiled::{clean_gid, parse_tmx, tmx_to_map_state};
25use serde::Deserialize;
26
27use crate::tileset::PngTileset;
28use crate::vfs::{join_path, DiskFiles, ProjectFiles};
29
30/// Filename of the entity sidecar the dotzuki-editor writes today.
31pub const OBJECTS_SIDECAR: &str = "objects.json";
32/// Legacy sidecar filename, read as a fallback when `objects.json` is absent.
33pub const LEGACY_SIDECAR: &str = "map.json";
34
35/// A placed NPC in the per-map objects sidecar.
36#[derive(Debug, Clone, Deserialize)]
37pub struct NpcDef {
38    /// Editor-assigned numeric id.
39    pub id: u32,
40    /// Display name (may be empty).
41    #[serde(default)]
42    pub name: String,
43    /// Tile X.
44    pub x: i32,
45    /// Tile Y.
46    pub y: i32,
47    /// Facing direction (`"down"`, `"up"`, `"left"`, `"right"`).
48    #[serde(default = "default_facing")]
49    pub facing: String,
50    /// Sprite identifier/path (may be empty while art is pending).
51    #[serde(default)]
52    pub sprite: String,
53    /// Storyline/text the NPC speaks when talked to (may be empty).
54    #[serde(default)]
55    pub talk: String,
56}
57
58fn default_facing() -> String {
59    "down".to_string()
60}
61
62/// A warp tile in the objects sidecar.
63///
64/// `dest_map` is validated lazily at warp time, not at load — a map must
65/// load even while one of its warps points at a not-yet-created map.
66#[derive(Debug, Clone, Deserialize)]
67pub struct WarpDef {
68    /// Tile X of the warp source.
69    pub x: i32,
70    /// Tile Y of the warp source.
71    pub y: i32,
72    /// Destination map id (empty while the warp is unlinked).
73    #[serde(default)]
74    pub dest_map: String,
75    /// Destination tile X.
76    #[serde(default)]
77    pub dest_x: i32,
78    /// Destination tile Y.
79    #[serde(default)]
80    pub dest_y: i32,
81}
82
83/// A sign tile in the objects sidecar.
84#[derive(Debug, Clone, Deserialize)]
85pub struct SignDef {
86    /// Tile X.
87    pub x: i32,
88    /// Tile Y.
89    pub y: i32,
90    /// Text shown when the sign is read.
91    #[serde(default)]
92    pub text: String,
93}
94
95/// A random-encounter table entry: a species/encounter id and its relative
96/// weight. The `id` is resolved at battle start by
97/// [`crate::battle::BattleSetup::start_with`] — an encounter record first,
98/// then a single enemy record (trainer queues and wild singles both work).
99#[derive(Debug, Clone, Deserialize)]
100pub struct EncounterTableEntry {
101    /// Encounter or enemy record id.
102    pub id: String,
103    /// Relative draw weight within the zone's table.
104    #[serde(default = "default_weight")]
105    pub weight: u32,
106}
107
108fn default_weight() -> u32 {
109    1
110}
111
112/// One encounter zone: an inclusive map-tile rectangle plus the weighted
113/// table drawn from when a step lands inside it.
114#[derive(Debug, Clone, Deserialize)]
115pub struct EncounterZone {
116    /// Left edge (tile X, inclusive).
117    pub x: i32,
118    /// Top edge (tile Y, inclusive).
119    pub y: i32,
120    /// Width in tiles.
121    pub w: i32,
122    /// Height in tiles.
123    pub h: i32,
124    /// Weighted species/encounter table.
125    #[serde(default)]
126    pub table: Vec<EncounterTableEntry>,
127}
128
129impl EncounterZone {
130    /// `true` when tile `(x, y)` lies inside the rectangle (inclusive).
131    #[inline]
132    pub fn contains(&self, x: i32, y: i32) -> bool {
133        x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
134    }
135}
136
137/// Random-encounter configuration in the objects sidecar (same shape as
138/// pokered's `wild_data`: a per-step rate byte in /256 units — `rate: 25`
139/// ≈ a 9.8% trigger chance per step — plus grass-like zones).
140#[derive(Debug, Clone, Deserialize)]
141pub struct EncounterConfig {
142    /// Per-step trigger probability in /256 units.
143    pub rate: u8,
144    /// Encounter zones; a step rolls only when it lands inside one.
145    #[serde(default)]
146    pub zones: Vec<EncounterZone>,
147}
148
149/// The per-map entity sidecar: NPCs, warps, signs and random encounters.
150///
151/// Unknown keys in the JSON (e.g. a legacy `collision` grid, `music`,
152/// `tileset` in old `map.json` fixtures) are ignored.
153#[derive(Debug, Clone, Default, Deserialize)]
154pub struct MapObjects {
155    /// Placed NPCs.
156    #[serde(default)]
157    pub npcs: Vec<NpcDef>,
158    /// Warp tiles.
159    #[serde(default)]
160    pub warps: Vec<WarpDef>,
161    /// Sign tiles.
162    #[serde(default)]
163    pub signs: Vec<SignDef>,
164    /// Random-encounter configuration; `None` (or absent in the JSON) means
165    /// the map never triggers wild battles from walking.
166    #[serde(default)]
167    pub encounters: Option<EncounterConfig>,
168}
169
170impl MapObjects {
171    /// Load the sidecar for a map directory: `objects.json` first, falling
172    /// back to the legacy `map.json`. Returns an empty sidecar when neither
173    /// exists. Disk convenience for [`load_with_files`](Self::load_with_files).
174    ///
175    /// # Errors
176    ///
177    /// Fails only when a sidecar file exists but cannot be read or parsed.
178    pub fn load(map_dir: &Path) -> Result<Self> {
179        Self::load_with_files(&DiskFiles::new(map_dir), "")
180    }
181
182    /// VFS form of [`load`](Self::load): `map_dir_rel` is the map directory
183    /// as a project-relative POSIX path (`""` = the backend root).
184    ///
185    /// # Errors
186    ///
187    /// Fails only when a sidecar file exists but cannot be read or parsed.
188    pub fn load_with_files(files: &dyn ProjectFiles, map_dir_rel: &str) -> Result<Self> {
189        for name in [OBJECTS_SIDECAR, LEGACY_SIDECAR] {
190            let rel = join_path(map_dir_rel, name);
191            match files.read(&rel) {
192                Ok(bytes) => {
193                    let text =
194                        String::from_utf8(bytes).with_context(|| format!("{rel} is not UTF-8"))?;
195                    return serde_json::from_str(&text)
196                        .with_context(|| format!("failed to parse {rel}"));
197                }
198                Err(_) => continue,
199            }
200        }
201        Ok(Self::default())
202    }
203}
204
205/// A loaded runtime map: visual layers, per-level collision grids, the
206/// stairs grid, tileset pixels and the entity sidecar.
207pub struct RuntimeMap {
208    id: String,
209    /// Map size in tiles.
210    width: u16,
211    height: u16,
212    /// Tile size in pixels (from the TMX `tilewidth`/`tileheight`).
213    tile_w: u32,
214    tile_h: u32,
215    /// Visual render state (the `collision*`/`stairs` data layers are excluded).
216    state: MapRenderState,
217    /// Collision cells per elevation level (`collision_levels[level][cell]`,
218    /// `true` = solid); index 0 always exists (the `collision` layer, or an
219    /// all-passable grid when the map has none).
220    collision_levels: Vec<Vec<bool>>,
221    /// `width * height` stair GIDs (flip flags stripped; 0 = no stair) when
222    /// the map has a `stairs` layer.
223    stairs: Option<Vec<u32>>,
224    tileset: PngTileset,
225    objects: MapObjects,
226}
227
228/// The elevation level a collision layer name encodes: `collision` ⇒ 0,
229/// `collisionN` ⇒ N. `None` for any other layer name.
230fn collision_layer_level(name: &str) -> Option<usize> {
231    let suffix = name.strip_prefix("collision")?;
232    if suffix.is_empty() {
233        return Some(0);
234    }
235    suffix.parse::<usize>().ok().filter(|&n| n >= 1)
236}
237
238impl RuntimeMap {
239    /// Load `<maps_dir>/<map_id>/` (TMX + tileset + sidecar) from disk.
240    /// Convenience for [`load_with_files`](Self::load_with_files) over a
241    /// [`DiskFiles`] rooted at `maps_dir`.
242    ///
243    /// # Errors
244    ///
245    /// Fails when `map.tmx.json` or `tileset.png` is missing/unreadable, the
246    /// TMX does not parse, or the tileset is invalid. A missing sidecar is
247    /// not an error.
248    pub fn load(maps_dir: &Path, map_id: &str) -> Result<Self> {
249        Self::load_with_files(&DiskFiles::new(maps_dir), "", map_id)
250    }
251
252    /// VFS form of [`load`](Self::load): `maps_dir_rel` is the maps
253    /// directory as a project-relative POSIX path (`""` = the backend root).
254    ///
255    /// # Errors
256    ///
257    /// Same conditions as [`load`](Self::load).
258    pub fn load_with_files(files: &dyn ProjectFiles, maps_dir_rel: &str, map_id: &str) -> Result<Self> {
259        let map_dir = join_path(maps_dir_rel, map_id);
260        let tmx_rel = join_path(&map_dir, "map.tmx.json");
261        let bytes = files
262            .read(&tmx_rel)
263            .with_context(|| format!("failed to read {tmx_rel}"))?;
264        let json = String::from_utf8(bytes).with_context(|| format!("{tmx_rel} is not UTF-8"))?;
265        let tmx = parse_tmx(&json).map_err(|e| anyhow::anyhow!("parse {tmx_rel}: {e}"))?;
266
267        let width = tmx.width.max(1) as u16;
268        let height = tmx.height.max(1) as u16;
269
270        // Collision grids per elevation level: `collision` is level 0,
271        // `collisionN` level N (a non-zero GID ⇒ solid at that level).
272        // Missing intermediate levels (e.g. `collision` + `collision2`
273        // without `collision1`) are filled all-SOLID — an undefined level
274        // must never be walkable, or the player could climb into a void.
275        let cells = width as usize * height as usize;
276        let mut grids: Vec<(usize, Vec<bool>)> = Vec::new();
277        let mut stairs: Option<Vec<u32>> = None;
278        for layer in &tmx.layers {
279            if let Some(level) = collision_layer_level(&layer.name) {
280                let mut grid = vec![false; cells];
281                for (i, &gid) in layer.data.iter().enumerate().take(cells) {
282                    grid[i] = gid != 0;
283                }
284                grids.push((level, grid));
285            } else if layer.name == "stairs" {
286                let mut grid = vec![0u32; cells];
287                for (i, &gid) in layer.data.iter().enumerate().take(cells) {
288                    grid[i] = clean_gid(gid);
289                }
290                stairs = Some(grid);
291            }
292        }
293        let max_level = grids.iter().map(|(l, _)| *l).max().unwrap_or(0);
294        let mut collision_levels = vec![vec![false; cells]; max_level + 1];
295        for grid in collision_levels.iter_mut().skip(1) {
296            grid.fill(true);
297        }
298        for (level, grid) in grids {
299            collision_levels[level] = grid;
300        }
301
302        // Visual layers = everything except the collision*/stairs data layers.
303        let mut visual = tmx.clone();
304        visual
305            .layers
306            .retain(|l| collision_layer_level(&l.name).is_none() && l.name != "stairs");
307        let state = tmx_to_map_state(&visual);
308
309        let tileset_rel = join_path(&map_dir, "tileset.png");
310        let png = files
311            .read(&tileset_rel)
312            .with_context(|| format!("failed to read tileset {tileset_rel}"))?;
313        let tileset = PngTileset::from_png_bytes(&png, tmx.tile_width, tmx.tile_height)
314            .with_context(|| format!("invalid tileset {tileset_rel}"))?;
315
316        let objects = MapObjects::load_with_files(files, &map_dir)?;
317
318        Ok(Self {
319            id: map_id.to_string(),
320            width,
321            height,
322            tile_w: tmx.tile_width,
323            tile_h: tmx.tile_height,
324            state,
325            collision_levels,
326            stairs,
327            tileset,
328            objects,
329        })
330    }
331
332    /// Map id (the directory name under `maps/`).
333    #[inline]
334    pub fn id(&self) -> &str {
335        &self.id
336    }
337
338    /// Map width in tiles.
339    #[inline]
340    pub fn width(&self) -> u16 {
341        self.width
342    }
343
344    /// Map height in tiles.
345    #[inline]
346    pub fn height(&self) -> u16 {
347        self.height
348    }
349
350    /// Tile size in pixels `(width, height)`.
351    #[inline]
352    pub fn tile_size(&self) -> (u32, u32) {
353        (self.tile_w, self.tile_h)
354    }
355
356    /// Map size in pixels (for camera bounds).
357    #[inline]
358    pub fn pixel_width(&self) -> i32 {
359        self.width as i32 * self.tile_w as i32
360    }
361
362    /// Map size in pixels (for camera bounds).
363    #[inline]
364    pub fn pixel_height(&self) -> i32 {
365        self.height as i32 * self.tile_h as i32
366    }
367
368    /// Visual layers (collision/stairs data layers excluded) with the map
369    /// background.
370    #[inline]
371    pub fn render_state(&self) -> &MapRenderState {
372        &self.state
373    }
374
375    /// The sliced tileset.
376    #[inline]
377    pub fn tileset(&self) -> &PngTileset {
378        &self.tileset
379    }
380
381    /// The entity sidecar (NPCs, warps, signs).
382    #[inline]
383    pub fn objects(&self) -> &MapObjects {
384        &self.objects
385    }
386
387    /// `true` if walking onto tile `(x, y)` is blocked at ground level.
388    /// Out-of-bounds is solid (enclosed world); never panics.
389    #[inline]
390    pub fn is_blocked(&self, x: i32, y: i32) -> bool {
391        self.is_blocked_at(0, x, y)
392    }
393
394    /// `true` if walking onto tile `(x, y)` is blocked at elevation `level`.
395    /// Out-of-bounds and levels the map doesn't define are solid; never panics.
396    #[inline]
397    pub fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
398        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
399            return true;
400        }
401        let idx = y as usize * self.width as usize + x as usize;
402        self.collision_levels
403            .get(level as usize)
404            .and_then(|grid| grid.get(idx))
405            .copied()
406            .unwrap_or(true)
407    }
408
409    /// The number of elevation levels (1 for a single-level map).
410    #[inline]
411    pub fn level_count(&self) -> usize {
412        self.collision_levels.len()
413    }
414
415    /// The stair GID on tile `(x, y)` (1 = ascend, 2 = descend), `None`
416    /// when the map has no `stairs` layer, the tile is out-of-bounds, or
417    /// the cell is empty.
418    #[inline]
419    pub fn stair_at(&self, x: i32, y: i32) -> Option<u32> {
420        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
421            return None;
422        }
423        let idx = y as usize * self.width as usize + x as usize;
424        self.stairs
425            .as_ref()
426            .and_then(|grid| grid.get(idx))
427            .copied()
428            .filter(|&gid| gid != 0)
429    }
430
431    /// RGBA pixel of the tile with 1-based Tiled `gid` at intra-tile
432    /// `(px, py)` — matches the `tile_color` callback shape of
433    /// `dotzuki_renderer::layer_renderer::render_layers_sized` (the palette-group
434    /// argument is unused: PNG tiles carry their own colours).
435    #[inline]
436    pub fn gid_pixel(&self, gid: u16, px: u8, py: u8) -> Rgba {
437        self.tileset.gid_pixel(gid, px, py)
438    }
439
440    /// Render the map's visual layers into `fb` at camera offset
441    /// `(camera_x, camera_y)` (world pixels at the framebuffer's top-left).
442    ///
443    /// Requires square tiles (the renderer's grid step is a single
444    /// `tile_size`); fails for maps whose `tilewidth != tileheight`.
445    pub fn render(
446        &self,
447        fb: &mut FrameBuffer,
448        camera_x: i32,
449        camera_y: i32,
450        width: u32,
451        height: u32,
452    ) -> Result<()> {
453        self.check_square_tiles()?;
454        dotzuki_renderer::layer_renderer::render_layers_sized(
455            fb,
456            &self.state.layers,
457            camera_x,
458            camera_y,
459            width,
460            height,
461            self.tile_w,
462            |gid, _pal, px, py| self.gid_pixel(gid, px, py),
463        );
464        Ok(())
465    }
466
467    /// Render only the layers at or below `player_level` (`level <=
468    /// player_level`) — the half of the stack drawn *under* the sprites on
469    /// multi-level maps. Same square-tiles requirement as [`render`](Self::render).
470    pub fn render_below(
471        &self,
472        fb: &mut FrameBuffer,
473        camera_x: i32,
474        camera_y: i32,
475        width: u32,
476        height: u32,
477        player_level: i32,
478    ) -> Result<()> {
479        self.render_filtered(fb, camera_x, camera_y, width, height, |level| {
480            level <= player_level
481        })
482    }
483
484    /// Render only the layers above `player_level` — the half of the stack
485    /// drawn *over* the sprites on multi-level maps. Same square-tiles
486    /// requirement as [`render`](Self::render).
487    pub fn render_above(
488        &self,
489        fb: &mut FrameBuffer,
490        camera_x: i32,
491        camera_y: i32,
492        width: u32,
493        height: u32,
494        player_level: i32,
495    ) -> Result<()> {
496        self.render_filtered(fb, camera_x, camera_y, width, height, |level| {
497            level > player_level
498        })
499    }
500
501    /// Render the layers whose elevation `level` passes `keep`, preserving
502    /// what is already in `fb`. Each kept group composites in `z_index`
503    /// order (the renderer sorts the slice it is given), so the two halves
504    /// of a split draw each preserve the original stack order.
505    ///
506    /// `render_layers_sized` has a full-redraw contract (it clears the
507    /// framebuffer), so a partial stack renders into a temp buffer first
508    /// and is then stamped over the existing frame — transparent pixels
509    /// reveal what was drawn before (e.g. the sprites under an above-group).
510    fn render_filtered(
511        &self,
512        fb: &mut FrameBuffer,
513        camera_x: i32,
514        camera_y: i32,
515        width: u32,
516        height: u32,
517        keep: impl Fn(i32) -> bool,
518    ) -> Result<()> {
519        self.check_square_tiles()?;
520        let layers: Vec<_> = self
521            .state
522            .layers
523            .iter()
524            .filter(|l| keep(l.level))
525            .cloned()
526            .collect();
527        if layers.is_empty() {
528            return Ok(());
529        }
530        let mut temp = FrameBuffer::new(
531            dotzuki_engine::render_config::RenderConfig::new(width, height),
532            Rgba::TRANSPARENT,
533        );
534        dotzuki_renderer::layer_renderer::render_layers_sized(
535            &mut temp,
536            &layers,
537            camera_x,
538            camera_y,
539            width,
540            height,
541            self.tile_w,
542            |gid, _pal, px, py| self.gid_pixel(gid, px, py),
543        );
544        for (dst, src) in fb.data.chunks_exact_mut(4).zip(temp.data.chunks_exact(4)) {
545            if src[3] != 0 {
546                dst.copy_from_slice(src);
547            }
548        }
549        Ok(())
550    }
551
552    /// Guard shared by the render methods: the layer renderer's grid step is
553    /// a single `tile_size`, so tiles must be square.
554    fn check_square_tiles(&self) -> Result<()> {
555        if self.tile_w != self.tile_h {
556            bail!(
557                "map '{}': render_layers_sized needs square tiles, got {}x{}",
558                self.id,
559                self.tile_w,
560                self.tile_h
561            );
562        }
563        Ok(())
564    }
565}
566
567impl OverworldCollision for RuntimeMap {
568    fn is_blocked(&self, x: i32, y: i32) -> bool {
569        self.is_blocked(x, y)
570    }
571
572    fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
573        self.is_blocked_at(level, x, y)
574    }
575}
576
577/// Convenience: the directory of one map under a project's maps dir.
578pub fn map_dir(maps_dir: &Path, map_id: &str) -> PathBuf {
579    maps_dir.join(map_id)
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use crate::vfs::MemoryFiles;
586    use std::collections::HashMap;
587
588    /// A 64×16 `tileset.png` (four 16×16 flat-colour tiles, row-major).
589    fn tileset_png() -> Vec<u8> {
590        let tile = 16u32;
591        let mut img = image::RgbaImage::new(tile * 4, tile);
592        for px in img.pixels_mut() {
593            *px = image::Rgba([0xFF, 0x00, 0x00, 0xFF]);
594        }
595        let mut out = std::io::Cursor::new(Vec::new());
596        image::DynamicImage::ImageRgba8(img)
597            .write_to(&mut out, image::ImageFormat::Png)
598            .unwrap();
599        out.into_inner()
600    }
601
602    /// Load a map from an in-memory project holding just the given TMX JSON
603    /// plus a generated tileset.
604    fn load_mem_map(tmx: &str) -> RuntimeMap {
605        let files = MemoryFiles::new(HashMap::from([
606            (
607                "maps/Town/map.tmx.json".to_string(),
608                tmx.as_bytes().to_vec(),
609            ),
610            ("maps/Town/tileset.png".to_string(), tileset_png()),
611        ]));
612        RuntimeMap::load_with_files(&files, "maps", "Town").expect("load map")
613    }
614
615    /// A 3×2 two-level map: ground + wall-top visual layers, `collision`
616    /// (level 0), `collision1`, and a `stairs` layer (ascend at (1, 0) with
617    /// a flipped GID, descend at (2, 1)).
618    const TWO_LEVEL_TMX: &str = r#"{
619  "width": 3, "height": 2, "tilewidth": 16, "tileheight": 16,
620  "layers": [
621    { "name": "ground", "width": 3, "height": 2, "data": [1,1,1,1,1,1] },
622    { "name": "walltop", "width": 3, "height": 2, "data": [0,2,0,0,2,0],
623      "properties": [{ "name": "level", "type": "int", "value": 1 }] },
624    { "name": "collision", "width": 3, "height": 2, "data": [1,0,1,0,0,1] },
625    { "name": "collision1", "width": 3, "height": 2, "data": [0,0,0,1,0,0] },
626    { "name": "stairs", "width": 3, "height": 2, "data": [0,2147483649,0,0,0,2] }
627  ],
628  "tilesets": [
629    { "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
630  ]
631}"#;
632
633    #[test]
634    fn multi_level_collision_parsed_per_level() {
635        let map = load_mem_map(TWO_LEVEL_TMX);
636        assert_eq!(map.level_count(), 2);
637
638        // Level 0 (the `collision` layer); `is_blocked` stays level 0.
639        assert!(map.is_blocked(0, 0));
640        assert!(!map.is_blocked(1, 0));
641        assert!(map.is_blocked(2, 1));
642
643        // Level 1 has a different grid.
644        assert!(!map.is_blocked_at(1, 0, 0));
645        assert!(map.is_blocked_at(1, 0, 1));
646        assert!(!map.is_blocked_at(1, 2, 1));
647
648        // Undefined levels and out-of-bounds are solid.
649        assert!(map.is_blocked_at(2, 1, 0));
650        assert!(map.is_blocked_at(1, -1, 0));
651        assert!(map.is_blocked_at(1, 3, 0));
652
653        // The trait impl agrees with the inherent methods.
654        let collision: &dyn OverworldCollision = &map;
655        assert!(collision.is_blocked(0, 0));
656        assert!(collision.is_blocked_at(1, 0, 1));
657    }
658
659    #[test]
660    fn stairs_parsed_and_excluded_from_render_layers() {
661        let map = load_mem_map(TWO_LEVEL_TMX);
662        assert_eq!(map.stair_at(1, 0), Some(1), "flip flag stripped, ascend");
663        assert_eq!(map.stair_at(2, 1), Some(2), "descend");
664        assert_eq!(map.stair_at(0, 0), None, "empty stair cell");
665        assert_eq!(map.stair_at(-1, 0), None, "out-of-bounds");
666
667        // Only the two visual layers render; `level` rides along.
668        let layers = &map.render_state().layers;
669        assert_eq!(layers.len(), 2, "collision*/stairs are not rendered");
670        assert_eq!(layers[0].level, 0);
671        assert_eq!(layers[1].level, 1);
672    }
673
674    #[test]
675    fn missing_intermediate_level_is_all_solid() {
676        // `collision` + `collision2` without `collision1`: the gap level is
677        // filled all-solid so an undefined level is never walkable.
678        let tmx = r#"{
679  "width": 2, "height": 1, "tilewidth": 16, "tileheight": 16,
680  "layers": [
681    { "name": "ground", "width": 2, "height": 1, "data": [1,1] },
682    { "name": "collision", "width": 2, "height": 1, "data": [0,0] },
683    { "name": "collision2", "width": 2, "height": 1, "data": [0,1] }
684  ],
685  "tilesets": [
686    { "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
687  ]
688}"#;
689        let map = load_mem_map(tmx);
690        assert_eq!(map.level_count(), 3);
691        assert!(map.is_blocked_at(1, 0, 0), "gap level 1 is all-solid");
692        assert!(map.is_blocked_at(1, 1, 0));
693        assert!(!map.is_blocked_at(2, 0, 0));
694        assert!(map.is_blocked_at(2, 1, 0));
695    }
696
697    /// A split draw preserves what is already in the framebuffer: the above
698    /// group stamps only its opaque pixels over the "sprites" beneath.
699    #[test]
700    fn split_render_preserves_lower_content() {
701        use dotzuki_engine::render_config::RenderConfig;
702
703        let map = load_mem_map(TWO_LEVEL_TMX);
704        let mut fb = FrameBuffer::new(RenderConfig::new(48, 32), Rgba::TRANSPARENT);
705
706        // Below group (level 0): the ground layer fills the view.
707        map.render_below(&mut fb, 0, 0, 48, 32, 0).expect("below");
708        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::new(0xFF, 0, 0, 0xFF)));
709
710        // A "sprite" pixel where the wall-top layer is transparent…
711        let sprite = Rgba::new(0, 0, 0xFF, 0xFF);
712        fb.fill_rect(0, 0, 1, 1, sprite);
713        map.render_above(&mut fb, 0, 0, 48, 32, 0).expect("above");
714        assert_eq!(fb.get_pixel(0, 0), Some(sprite), "holes reveal the sprite");
715        // …and an opaque wall-top tile (tile (1,0)) stamps over the ground.
716        assert_eq!(
717            fb.get_pixel(16, 0),
718            Some(Rgba::new(0xFF, 0, 0, 0xFF)),
719            "opaque above-layer tile draws over"
720        );
721
722        // An empty above group (player at level 1 ⇒ nothing is higher) is a
723        // no-op, not a clear.
724        map.render_above(&mut fb, 0, 0, 48, 32, 1).expect("above");
725        assert_eq!(fb.get_pixel(0, 0), Some(sprite), "empty group leaves fb alone");
726    }
727
728    #[test]
729    fn single_level_map_behaves_as_before() {
730        // Legacy shape: only a `collision` layer, no stairs.
731        let tmx = r#"{
732  "width": 2, "height": 1, "tilewidth": 16, "tileheight": 16,
733  "layers": [
734    { "name": "ground", "width": 2, "height": 1, "data": [1,1] },
735    { "name": "collision", "width": 2, "height": 1, "data": [0,1] }
736  ],
737  "tilesets": [
738    { "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
739  ]
740}"#;
741        let map = load_mem_map(tmx);
742        assert_eq!(map.level_count(), 1);
743        assert!(!map.is_blocked(0, 0));
744        assert!(map.is_blocked(1, 0));
745        assert_eq!(map.stair_at(0, 0), None);
746        assert_eq!(map.render_state().layers.len(), 1);
747    }
748}