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(
259        files: &dyn ProjectFiles,
260        maps_dir_rel: &str,
261        map_id: &str,
262    ) -> Result<Self> {
263        let map_dir = join_path(maps_dir_rel, map_id);
264        let tmx_rel = join_path(&map_dir, "map.tmx.json");
265        let bytes = files
266            .read(&tmx_rel)
267            .with_context(|| format!("failed to read {tmx_rel}"))?;
268        let json = String::from_utf8(bytes).with_context(|| format!("{tmx_rel} is not UTF-8"))?;
269        let tmx = parse_tmx(&json).map_err(|e| anyhow::anyhow!("parse {tmx_rel}: {e}"))?;
270
271        let width = tmx.width.max(1) as u16;
272        let height = tmx.height.max(1) as u16;
273
274        // Collision grids per elevation level: `collision` is level 0,
275        // `collisionN` level N (a non-zero GID ⇒ solid at that level).
276        // Missing intermediate levels (e.g. `collision` + `collision2`
277        // without `collision1`) are filled all-SOLID — an undefined level
278        // must never be walkable, or the player could climb into a void.
279        let cells = width as usize * height as usize;
280        let mut grids: Vec<(usize, Vec<bool>)> = Vec::new();
281        let mut stairs: Option<Vec<u32>> = None;
282        for layer in &tmx.layers {
283            if let Some(level) = collision_layer_level(&layer.name) {
284                let mut grid = vec![false; cells];
285                for (i, &gid) in layer.data.iter().enumerate().take(cells) {
286                    grid[i] = gid != 0;
287                }
288                grids.push((level, grid));
289            } else if layer.name == "stairs" {
290                let mut grid = vec![0u32; cells];
291                for (i, &gid) in layer.data.iter().enumerate().take(cells) {
292                    grid[i] = clean_gid(gid);
293                }
294                stairs = Some(grid);
295            }
296        }
297        let max_level = grids.iter().map(|(l, _)| *l).max().unwrap_or(0);
298        let mut collision_levels = vec![vec![false; cells]; max_level + 1];
299        for grid in collision_levels.iter_mut().skip(1) {
300            grid.fill(true);
301        }
302        for (level, grid) in grids {
303            collision_levels[level] = grid;
304        }
305
306        // Visual layers = everything except the collision*/stairs data layers.
307        let mut visual = tmx.clone();
308        visual
309            .layers
310            .retain(|l| collision_layer_level(&l.name).is_none() && l.name != "stairs");
311        let state = tmx_to_map_state(&visual);
312
313        let tileset_rel = join_path(&map_dir, "tileset.png");
314        let png = files
315            .read(&tileset_rel)
316            .with_context(|| format!("failed to read tileset {tileset_rel}"))?;
317        let tileset = PngTileset::from_png_bytes(&png, tmx.tile_width, tmx.tile_height)
318            .with_context(|| format!("invalid tileset {tileset_rel}"))?;
319
320        let objects = MapObjects::load_with_files(files, &map_dir)?;
321
322        Ok(Self {
323            id: map_id.to_string(),
324            width,
325            height,
326            tile_w: tmx.tile_width,
327            tile_h: tmx.tile_height,
328            state,
329            collision_levels,
330            stairs,
331            tileset,
332            objects,
333        })
334    }
335
336    /// Map id (the directory name under `maps/`).
337    #[inline]
338    pub fn id(&self) -> &str {
339        &self.id
340    }
341
342    /// Map width in tiles.
343    #[inline]
344    pub fn width(&self) -> u16 {
345        self.width
346    }
347
348    /// Map height in tiles.
349    #[inline]
350    pub fn height(&self) -> u16 {
351        self.height
352    }
353
354    /// Tile size in pixels `(width, height)`.
355    #[inline]
356    pub fn tile_size(&self) -> (u32, u32) {
357        (self.tile_w, self.tile_h)
358    }
359
360    /// Map size in pixels (for camera bounds).
361    #[inline]
362    pub fn pixel_width(&self) -> i32 {
363        self.width as i32 * self.tile_w as i32
364    }
365
366    /// Map size in pixels (for camera bounds).
367    #[inline]
368    pub fn pixel_height(&self) -> i32 {
369        self.height as i32 * self.tile_h as i32
370    }
371
372    /// Visual layers (collision/stairs data layers excluded) with the map
373    /// background.
374    #[inline]
375    pub fn render_state(&self) -> &MapRenderState {
376        &self.state
377    }
378
379    /// The sliced tileset.
380    #[inline]
381    pub fn tileset(&self) -> &PngTileset {
382        &self.tileset
383    }
384
385    /// The entity sidecar (NPCs, warps, signs).
386    #[inline]
387    pub fn objects(&self) -> &MapObjects {
388        &self.objects
389    }
390
391    /// `true` if walking onto tile `(x, y)` is blocked at ground level.
392    /// Out-of-bounds is solid (enclosed world); never panics.
393    #[inline]
394    pub fn is_blocked(&self, x: i32, y: i32) -> bool {
395        self.is_blocked_at(0, x, y)
396    }
397
398    /// `true` if walking onto tile `(x, y)` is blocked at elevation `level`.
399    /// Out-of-bounds and levels the map doesn't define are solid; never panics.
400    #[inline]
401    pub fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
402        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
403            return true;
404        }
405        let idx = y as usize * self.width as usize + x as usize;
406        self.collision_levels
407            .get(level as usize)
408            .and_then(|grid| grid.get(idx))
409            .copied()
410            .unwrap_or(true)
411    }
412
413    /// The number of elevation levels (1 for a single-level map).
414    #[inline]
415    pub fn level_count(&self) -> usize {
416        self.collision_levels.len()
417    }
418
419    /// The stair GID on tile `(x, y)` (1 = ascend, 2 = descend), `None`
420    /// when the map has no `stairs` layer, the tile is out-of-bounds, or
421    /// the cell is empty.
422    #[inline]
423    pub fn stair_at(&self, x: i32, y: i32) -> Option<u32> {
424        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
425            return None;
426        }
427        let idx = y as usize * self.width as usize + x as usize;
428        self.stairs
429            .as_ref()
430            .and_then(|grid| grid.get(idx))
431            .copied()
432            .filter(|&gid| gid != 0)
433    }
434
435    /// RGBA pixel of the tile with 1-based Tiled `gid` at intra-tile
436    /// `(px, py)` — matches the `tile_color` callback shape of
437    /// `dotzuki_renderer::layer_renderer::render_layers_sized` (the palette-group
438    /// argument is unused: PNG tiles carry their own colours).
439    #[inline]
440    pub fn gid_pixel(&self, gid: u16, px: u8, py: u8) -> Rgba {
441        self.tileset.gid_pixel(gid, px, py)
442    }
443
444    /// Render the map's visual layers into `fb` at camera offset
445    /// `(camera_x, camera_y)` (world pixels at the framebuffer's top-left).
446    ///
447    /// Requires square tiles (the renderer's grid step is a single
448    /// `tile_size`); fails for maps whose `tilewidth != tileheight`.
449    pub fn render(
450        &self,
451        fb: &mut FrameBuffer,
452        camera_x: i32,
453        camera_y: i32,
454        width: u32,
455        height: u32,
456    ) -> Result<()> {
457        self.check_square_tiles()?;
458        dotzuki_renderer::layer_renderer::render_layers_sized(
459            fb,
460            &self.state.layers,
461            camera_x,
462            camera_y,
463            width,
464            height,
465            self.tile_w,
466            |gid, _pal, px, py| self.gid_pixel(gid, px, py),
467        );
468        Ok(())
469    }
470
471    /// Render only the layers at or below `player_level` (`level <=
472    /// player_level`) — the half of the stack drawn *under* the sprites on
473    /// multi-level maps. Same square-tiles requirement as [`render`](Self::render).
474    pub fn render_below(
475        &self,
476        fb: &mut FrameBuffer,
477        camera_x: i32,
478        camera_y: i32,
479        width: u32,
480        height: u32,
481        player_level: i32,
482    ) -> Result<()> {
483        self.render_filtered(fb, camera_x, camera_y, width, height, |level| {
484            level <= player_level
485        })
486    }
487
488    /// Render only the layers above `player_level` — the half of the stack
489    /// drawn *over* the sprites on multi-level maps. Same square-tiles
490    /// requirement as [`render`](Self::render).
491    pub fn render_above(
492        &self,
493        fb: &mut FrameBuffer,
494        camera_x: i32,
495        camera_y: i32,
496        width: u32,
497        height: u32,
498        player_level: i32,
499    ) -> Result<()> {
500        self.render_filtered(fb, camera_x, camera_y, width, height, |level| {
501            level > player_level
502        })
503    }
504
505    /// Render the layers whose elevation `level` passes `keep`, preserving
506    /// what is already in `fb`. Each kept group composites in `z_index`
507    /// order (the renderer sorts the slice it is given), so the two halves
508    /// of a split draw each preserve the original stack order.
509    ///
510    /// `render_layers_sized` has a full-redraw contract (it clears the
511    /// framebuffer), so a partial stack renders into a temp buffer first
512    /// and is then stamped over the existing frame — transparent pixels
513    /// reveal what was drawn before (e.g. the sprites under an above-group).
514    fn render_filtered(
515        &self,
516        fb: &mut FrameBuffer,
517        camera_x: i32,
518        camera_y: i32,
519        width: u32,
520        height: u32,
521        keep: impl Fn(i32) -> bool,
522    ) -> Result<()> {
523        self.check_square_tiles()?;
524        let layers: Vec<_> = self
525            .state
526            .layers
527            .iter()
528            .filter(|l| keep(l.level))
529            .cloned()
530            .collect();
531        if layers.is_empty() {
532            return Ok(());
533        }
534        let mut temp = FrameBuffer::new(
535            dotzuki_engine::render_config::RenderConfig::new(width, height),
536            Rgba::TRANSPARENT,
537        );
538        dotzuki_renderer::layer_renderer::render_layers_sized(
539            &mut temp,
540            &layers,
541            camera_x,
542            camera_y,
543            width,
544            height,
545            self.tile_w,
546            |gid, _pal, px, py| self.gid_pixel(gid, px, py),
547        );
548        for (dst, src) in fb.data.chunks_exact_mut(4).zip(temp.data.chunks_exact(4)) {
549            if src[3] != 0 {
550                dst.copy_from_slice(src);
551            }
552        }
553        Ok(())
554    }
555
556    /// Guard shared by the render methods: the layer renderer's grid step is
557    /// a single `tile_size`, so tiles must be square.
558    fn check_square_tiles(&self) -> Result<()> {
559        if self.tile_w != self.tile_h {
560            bail!(
561                "map '{}': render_layers_sized needs square tiles, got {}x{}",
562                self.id,
563                self.tile_w,
564                self.tile_h
565            );
566        }
567        Ok(())
568    }
569}
570
571impl OverworldCollision for RuntimeMap {
572    fn is_blocked(&self, x: i32, y: i32) -> bool {
573        self.is_blocked(x, y)
574    }
575
576    fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
577        self.is_blocked_at(level, x, y)
578    }
579}
580
581/// Convenience: the directory of one map under a project's maps dir.
582pub fn map_dir(maps_dir: &Path, map_id: &str) -> PathBuf {
583    maps_dir.join(map_id)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::vfs::MemoryFiles;
590    use std::collections::HashMap;
591
592    /// A 64×16 `tileset.png` (four 16×16 flat-colour tiles, row-major).
593    fn tileset_png() -> Vec<u8> {
594        let tile = 16u32;
595        let mut img = image::RgbaImage::new(tile * 4, tile);
596        for px in img.pixels_mut() {
597            *px = image::Rgba([0xFF, 0x00, 0x00, 0xFF]);
598        }
599        let mut out = std::io::Cursor::new(Vec::new());
600        image::DynamicImage::ImageRgba8(img)
601            .write_to(&mut out, image::ImageFormat::Png)
602            .unwrap();
603        out.into_inner()
604    }
605
606    /// Load a map from an in-memory project holding just the given TMX JSON
607    /// plus a generated tileset.
608    fn load_mem_map(tmx: &str) -> RuntimeMap {
609        let files = MemoryFiles::new(HashMap::from([
610            (
611                "maps/Town/map.tmx.json".to_string(),
612                tmx.as_bytes().to_vec(),
613            ),
614            ("maps/Town/tileset.png".to_string(), tileset_png()),
615        ]));
616        RuntimeMap::load_with_files(&files, "maps", "Town").expect("load map")
617    }
618
619    /// A 3×2 two-level map: ground + wall-top visual layers, `collision`
620    /// (level 0), `collision1`, and a `stairs` layer (ascend at (1, 0) with
621    /// a flipped GID, descend at (2, 1)).
622    const TWO_LEVEL_TMX: &str = r#"{
623  "width": 3, "height": 2, "tilewidth": 16, "tileheight": 16,
624  "layers": [
625    { "name": "ground", "width": 3, "height": 2, "data": [1,1,1,1,1,1] },
626    { "name": "walltop", "width": 3, "height": 2, "data": [0,2,0,0,2,0],
627      "properties": [{ "name": "level", "type": "int", "value": 1 }] },
628    { "name": "collision", "width": 3, "height": 2, "data": [1,0,1,0,0,1] },
629    { "name": "collision1", "width": 3, "height": 2, "data": [0,0,0,1,0,0] },
630    { "name": "stairs", "width": 3, "height": 2, "data": [0,2147483649,0,0,0,2] }
631  ],
632  "tilesets": [
633    { "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
634  ]
635}"#;
636
637    #[test]
638    fn multi_level_collision_parsed_per_level() {
639        let map = load_mem_map(TWO_LEVEL_TMX);
640        assert_eq!(map.level_count(), 2);
641
642        // Level 0 (the `collision` layer); `is_blocked` stays level 0.
643        assert!(map.is_blocked(0, 0));
644        assert!(!map.is_blocked(1, 0));
645        assert!(map.is_blocked(2, 1));
646
647        // Level 1 has a different grid.
648        assert!(!map.is_blocked_at(1, 0, 0));
649        assert!(map.is_blocked_at(1, 0, 1));
650        assert!(!map.is_blocked_at(1, 2, 1));
651
652        // Undefined levels and out-of-bounds are solid.
653        assert!(map.is_blocked_at(2, 1, 0));
654        assert!(map.is_blocked_at(1, -1, 0));
655        assert!(map.is_blocked_at(1, 3, 0));
656
657        // The trait impl agrees with the inherent methods.
658        let collision: &dyn OverworldCollision = &map;
659        assert!(collision.is_blocked(0, 0));
660        assert!(collision.is_blocked_at(1, 0, 1));
661    }
662
663    #[test]
664    fn stairs_parsed_and_excluded_from_render_layers() {
665        let map = load_mem_map(TWO_LEVEL_TMX);
666        assert_eq!(map.stair_at(1, 0), Some(1), "flip flag stripped, ascend");
667        assert_eq!(map.stair_at(2, 1), Some(2), "descend");
668        assert_eq!(map.stair_at(0, 0), None, "empty stair cell");
669        assert_eq!(map.stair_at(-1, 0), None, "out-of-bounds");
670
671        // Only the two visual layers render; `level` rides along.
672        let layers = &map.render_state().layers;
673        assert_eq!(layers.len(), 2, "collision*/stairs are not rendered");
674        assert_eq!(layers[0].level, 0);
675        assert_eq!(layers[1].level, 1);
676    }
677
678    #[test]
679    fn missing_intermediate_level_is_all_solid() {
680        // `collision` + `collision2` without `collision1`: the gap level is
681        // filled all-solid so an undefined level is never walkable.
682        let tmx = r#"{
683  "width": 2, "height": 1, "tilewidth": 16, "tileheight": 16,
684  "layers": [
685    { "name": "ground", "width": 2, "height": 1, "data": [1,1] },
686    { "name": "collision", "width": 2, "height": 1, "data": [0,0] },
687    { "name": "collision2", "width": 2, "height": 1, "data": [0,1] }
688  ],
689  "tilesets": [
690    { "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
691  ]
692}"#;
693        let map = load_mem_map(tmx);
694        assert_eq!(map.level_count(), 3);
695        assert!(map.is_blocked_at(1, 0, 0), "gap level 1 is all-solid");
696        assert!(map.is_blocked_at(1, 1, 0));
697        assert!(!map.is_blocked_at(2, 0, 0));
698        assert!(map.is_blocked_at(2, 1, 0));
699    }
700
701    /// A split draw preserves what is already in the framebuffer: the above
702    /// group stamps only its opaque pixels over the "sprites" beneath.
703    #[test]
704    fn split_render_preserves_lower_content() {
705        use dotzuki_engine::render_config::RenderConfig;
706
707        let map = load_mem_map(TWO_LEVEL_TMX);
708        let mut fb = FrameBuffer::new(RenderConfig::new(48, 32), Rgba::TRANSPARENT);
709
710        // Below group (level 0): the ground layer fills the view.
711        map.render_below(&mut fb, 0, 0, 48, 32, 0).expect("below");
712        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::new(0xFF, 0, 0, 0xFF)));
713
714        // A "sprite" pixel where the wall-top layer is transparent…
715        let sprite = Rgba::new(0, 0, 0xFF, 0xFF);
716        fb.fill_rect(0, 0, 1, 1, sprite);
717        map.render_above(&mut fb, 0, 0, 48, 32, 0).expect("above");
718        assert_eq!(fb.get_pixel(0, 0), Some(sprite), "holes reveal the sprite");
719        // …and an opaque wall-top tile (tile (1,0)) stamps over the ground.
720        assert_eq!(
721            fb.get_pixel(16, 0),
722            Some(Rgba::new(0xFF, 0, 0, 0xFF)),
723            "opaque above-layer tile draws over"
724        );
725
726        // An empty above group (player at level 1 ⇒ nothing is higher) is a
727        // no-op, not a clear.
728        map.render_above(&mut fb, 0, 0, 48, 32, 1).expect("above");
729        assert_eq!(
730            fb.get_pixel(0, 0),
731            Some(sprite),
732            "empty group leaves fb alone"
733        );
734    }
735
736    #[test]
737    fn single_level_map_behaves_as_before() {
738        // Legacy shape: only a `collision` layer, no stairs.
739        let tmx = r#"{
740  "width": 2, "height": 1, "tilewidth": 16, "tileheight": 16,
741  "layers": [
742    { "name": "ground", "width": 2, "height": 1, "data": [1,1] },
743    { "name": "collision", "width": 2, "height": 1, "data": [0,1] }
744  ],
745  "tilesets": [
746    { "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
747  ]
748}"#;
749        let map = load_mem_map(tmx);
750        assert_eq!(map.level_count(), 1);
751        assert!(!map.is_blocked(0, 0));
752        assert!(map.is_blocked(1, 0));
753        assert_eq!(map.stair_at(0, 0), None);
754        assert_eq!(map.render_state().layers.len(), 1);
755    }
756}