use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use dotzuki_engine::overworld::actor::OverworldCollision;
use dotzuki_engine::render::{FrameBuffer, MapRenderState, Rgba};
use dotzuki_engine_tiled::{clean_gid, parse_tmx, tmx_to_map_state};
use serde::Deserialize;
use crate::tileset::PngTileset;
use crate::vfs::{join_path, DiskFiles, ProjectFiles};
pub const OBJECTS_SIDECAR: &str = "objects.json";
pub const LEGACY_SIDECAR: &str = "map.json";
#[derive(Debug, Clone, Deserialize)]
pub struct NpcDef {
pub id: u32,
#[serde(default)]
pub name: String,
pub x: i32,
pub y: i32,
#[serde(default = "default_facing")]
pub facing: String,
#[serde(default)]
pub sprite: String,
#[serde(default)]
pub talk: String,
}
fn default_facing() -> String {
"down".to_string()
}
#[derive(Debug, Clone, Deserialize)]
pub struct WarpDef {
pub x: i32,
pub y: i32,
#[serde(default)]
pub dest_map: String,
#[serde(default)]
pub dest_x: i32,
#[serde(default)]
pub dest_y: i32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SignDef {
pub x: i32,
pub y: i32,
#[serde(default)]
pub text: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EncounterTableEntry {
pub id: String,
#[serde(default = "default_weight")]
pub weight: u32,
}
fn default_weight() -> u32 {
1
}
#[derive(Debug, Clone, Deserialize)]
pub struct EncounterZone {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
#[serde(default)]
pub table: Vec<EncounterTableEntry>,
}
impl EncounterZone {
#[inline]
pub fn contains(&self, x: i32, y: i32) -> bool {
x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EncounterConfig {
pub rate: u8,
#[serde(default)]
pub zones: Vec<EncounterZone>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MapObjects {
#[serde(default)]
pub npcs: Vec<NpcDef>,
#[serde(default)]
pub warps: Vec<WarpDef>,
#[serde(default)]
pub signs: Vec<SignDef>,
#[serde(default)]
pub encounters: Option<EncounterConfig>,
}
impl MapObjects {
pub fn load(map_dir: &Path) -> Result<Self> {
Self::load_with_files(&DiskFiles::new(map_dir), "")
}
pub fn load_with_files(files: &dyn ProjectFiles, map_dir_rel: &str) -> Result<Self> {
for name in [OBJECTS_SIDECAR, LEGACY_SIDECAR] {
let rel = join_path(map_dir_rel, name);
match files.read(&rel) {
Ok(bytes) => {
let text =
String::from_utf8(bytes).with_context(|| format!("{rel} is not UTF-8"))?;
return serde_json::from_str(&text)
.with_context(|| format!("failed to parse {rel}"));
}
Err(_) => continue,
}
}
Ok(Self::default())
}
}
pub struct RuntimeMap {
id: String,
width: u16,
height: u16,
tile_w: u32,
tile_h: u32,
state: MapRenderState,
collision_levels: Vec<Vec<bool>>,
stairs: Option<Vec<u32>>,
tileset: PngTileset,
objects: MapObjects,
}
fn collision_layer_level(name: &str) -> Option<usize> {
let suffix = name.strip_prefix("collision")?;
if suffix.is_empty() {
return Some(0);
}
suffix.parse::<usize>().ok().filter(|&n| n >= 1)
}
impl RuntimeMap {
pub fn load(maps_dir: &Path, map_id: &str) -> Result<Self> {
Self::load_with_files(&DiskFiles::new(maps_dir), "", map_id)
}
pub fn load_with_files(files: &dyn ProjectFiles, maps_dir_rel: &str, map_id: &str) -> Result<Self> {
let map_dir = join_path(maps_dir_rel, map_id);
let tmx_rel = join_path(&map_dir, "map.tmx.json");
let bytes = files
.read(&tmx_rel)
.with_context(|| format!("failed to read {tmx_rel}"))?;
let json = String::from_utf8(bytes).with_context(|| format!("{tmx_rel} is not UTF-8"))?;
let tmx = parse_tmx(&json).map_err(|e| anyhow::anyhow!("parse {tmx_rel}: {e}"))?;
let width = tmx.width.max(1) as u16;
let height = tmx.height.max(1) as u16;
let cells = width as usize * height as usize;
let mut grids: Vec<(usize, Vec<bool>)> = Vec::new();
let mut stairs: Option<Vec<u32>> = None;
for layer in &tmx.layers {
if let Some(level) = collision_layer_level(&layer.name) {
let mut grid = vec![false; cells];
for (i, &gid) in layer.data.iter().enumerate().take(cells) {
grid[i] = gid != 0;
}
grids.push((level, grid));
} else if layer.name == "stairs" {
let mut grid = vec![0u32; cells];
for (i, &gid) in layer.data.iter().enumerate().take(cells) {
grid[i] = clean_gid(gid);
}
stairs = Some(grid);
}
}
let max_level = grids.iter().map(|(l, _)| *l).max().unwrap_or(0);
let mut collision_levels = vec![vec![false; cells]; max_level + 1];
for grid in collision_levels.iter_mut().skip(1) {
grid.fill(true);
}
for (level, grid) in grids {
collision_levels[level] = grid;
}
let mut visual = tmx.clone();
visual
.layers
.retain(|l| collision_layer_level(&l.name).is_none() && l.name != "stairs");
let state = tmx_to_map_state(&visual);
let tileset_rel = join_path(&map_dir, "tileset.png");
let png = files
.read(&tileset_rel)
.with_context(|| format!("failed to read tileset {tileset_rel}"))?;
let tileset = PngTileset::from_png_bytes(&png, tmx.tile_width, tmx.tile_height)
.with_context(|| format!("invalid tileset {tileset_rel}"))?;
let objects = MapObjects::load_with_files(files, &map_dir)?;
Ok(Self {
id: map_id.to_string(),
width,
height,
tile_w: tmx.tile_width,
tile_h: tmx.tile_height,
state,
collision_levels,
stairs,
tileset,
objects,
})
}
#[inline]
pub fn id(&self) -> &str {
&self.id
}
#[inline]
pub fn width(&self) -> u16 {
self.width
}
#[inline]
pub fn height(&self) -> u16 {
self.height
}
#[inline]
pub fn tile_size(&self) -> (u32, u32) {
(self.tile_w, self.tile_h)
}
#[inline]
pub fn pixel_width(&self) -> i32 {
self.width as i32 * self.tile_w as i32
}
#[inline]
pub fn pixel_height(&self) -> i32 {
self.height as i32 * self.tile_h as i32
}
#[inline]
pub fn render_state(&self) -> &MapRenderState {
&self.state
}
#[inline]
pub fn tileset(&self) -> &PngTileset {
&self.tileset
}
#[inline]
pub fn objects(&self) -> &MapObjects {
&self.objects
}
#[inline]
pub fn is_blocked(&self, x: i32, y: i32) -> bool {
self.is_blocked_at(0, x, y)
}
#[inline]
pub fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
return true;
}
let idx = y as usize * self.width as usize + x as usize;
self.collision_levels
.get(level as usize)
.and_then(|grid| grid.get(idx))
.copied()
.unwrap_or(true)
}
#[inline]
pub fn level_count(&self) -> usize {
self.collision_levels.len()
}
#[inline]
pub fn stair_at(&self, x: i32, y: i32) -> Option<u32> {
if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
return None;
}
let idx = y as usize * self.width as usize + x as usize;
self.stairs
.as_ref()
.and_then(|grid| grid.get(idx))
.copied()
.filter(|&gid| gid != 0)
}
#[inline]
pub fn gid_pixel(&self, gid: u16, px: u8, py: u8) -> Rgba {
self.tileset.gid_pixel(gid, px, py)
}
pub fn render(
&self,
fb: &mut FrameBuffer,
camera_x: i32,
camera_y: i32,
width: u32,
height: u32,
) -> Result<()> {
self.check_square_tiles()?;
dotzuki_renderer::layer_renderer::render_layers_sized(
fb,
&self.state.layers,
camera_x,
camera_y,
width,
height,
self.tile_w,
|gid, _pal, px, py| self.gid_pixel(gid, px, py),
);
Ok(())
}
pub fn render_below(
&self,
fb: &mut FrameBuffer,
camera_x: i32,
camera_y: i32,
width: u32,
height: u32,
player_level: i32,
) -> Result<()> {
self.render_filtered(fb, camera_x, camera_y, width, height, |level| {
level <= player_level
})
}
pub fn render_above(
&self,
fb: &mut FrameBuffer,
camera_x: i32,
camera_y: i32,
width: u32,
height: u32,
player_level: i32,
) -> Result<()> {
self.render_filtered(fb, camera_x, camera_y, width, height, |level| {
level > player_level
})
}
fn render_filtered(
&self,
fb: &mut FrameBuffer,
camera_x: i32,
camera_y: i32,
width: u32,
height: u32,
keep: impl Fn(i32) -> bool,
) -> Result<()> {
self.check_square_tiles()?;
let layers: Vec<_> = self
.state
.layers
.iter()
.filter(|l| keep(l.level))
.cloned()
.collect();
if layers.is_empty() {
return Ok(());
}
let mut temp = FrameBuffer::new(
dotzuki_engine::render_config::RenderConfig::new(width, height),
Rgba::TRANSPARENT,
);
dotzuki_renderer::layer_renderer::render_layers_sized(
&mut temp,
&layers,
camera_x,
camera_y,
width,
height,
self.tile_w,
|gid, _pal, px, py| self.gid_pixel(gid, px, py),
);
for (dst, src) in fb.data.chunks_exact_mut(4).zip(temp.data.chunks_exact(4)) {
if src[3] != 0 {
dst.copy_from_slice(src);
}
}
Ok(())
}
fn check_square_tiles(&self) -> Result<()> {
if self.tile_w != self.tile_h {
bail!(
"map '{}': render_layers_sized needs square tiles, got {}x{}",
self.id,
self.tile_w,
self.tile_h
);
}
Ok(())
}
}
impl OverworldCollision for RuntimeMap {
fn is_blocked(&self, x: i32, y: i32) -> bool {
self.is_blocked(x, y)
}
fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
self.is_blocked_at(level, x, y)
}
}
pub fn map_dir(maps_dir: &Path, map_id: &str) -> PathBuf {
maps_dir.join(map_id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::MemoryFiles;
use std::collections::HashMap;
fn tileset_png() -> Vec<u8> {
let tile = 16u32;
let mut img = image::RgbaImage::new(tile * 4, tile);
for px in img.pixels_mut() {
*px = image::Rgba([0xFF, 0x00, 0x00, 0xFF]);
}
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(img)
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
}
fn load_mem_map(tmx: &str) -> RuntimeMap {
let files = MemoryFiles::new(HashMap::from([
(
"maps/Town/map.tmx.json".to_string(),
tmx.as_bytes().to_vec(),
),
("maps/Town/tileset.png".to_string(), tileset_png()),
]));
RuntimeMap::load_with_files(&files, "maps", "Town").expect("load map")
}
const TWO_LEVEL_TMX: &str = r#"{
"width": 3, "height": 2, "tilewidth": 16, "tileheight": 16,
"layers": [
{ "name": "ground", "width": 3, "height": 2, "data": [1,1,1,1,1,1] },
{ "name": "walltop", "width": 3, "height": 2, "data": [0,2,0,0,2,0],
"properties": [{ "name": "level", "type": "int", "value": 1 }] },
{ "name": "collision", "width": 3, "height": 2, "data": [1,0,1,0,0,1] },
{ "name": "collision1", "width": 3, "height": 2, "data": [0,0,0,1,0,0] },
{ "name": "stairs", "width": 3, "height": 2, "data": [0,2147483649,0,0,0,2] }
],
"tilesets": [
{ "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
]
}"#;
#[test]
fn multi_level_collision_parsed_per_level() {
let map = load_mem_map(TWO_LEVEL_TMX);
assert_eq!(map.level_count(), 2);
assert!(map.is_blocked(0, 0));
assert!(!map.is_blocked(1, 0));
assert!(map.is_blocked(2, 1));
assert!(!map.is_blocked_at(1, 0, 0));
assert!(map.is_blocked_at(1, 0, 1));
assert!(!map.is_blocked_at(1, 2, 1));
assert!(map.is_blocked_at(2, 1, 0));
assert!(map.is_blocked_at(1, -1, 0));
assert!(map.is_blocked_at(1, 3, 0));
let collision: &dyn OverworldCollision = ↦
assert!(collision.is_blocked(0, 0));
assert!(collision.is_blocked_at(1, 0, 1));
}
#[test]
fn stairs_parsed_and_excluded_from_render_layers() {
let map = load_mem_map(TWO_LEVEL_TMX);
assert_eq!(map.stair_at(1, 0), Some(1), "flip flag stripped, ascend");
assert_eq!(map.stair_at(2, 1), Some(2), "descend");
assert_eq!(map.stair_at(0, 0), None, "empty stair cell");
assert_eq!(map.stair_at(-1, 0), None, "out-of-bounds");
let layers = &map.render_state().layers;
assert_eq!(layers.len(), 2, "collision*/stairs are not rendered");
assert_eq!(layers[0].level, 0);
assert_eq!(layers[1].level, 1);
}
#[test]
fn missing_intermediate_level_is_all_solid() {
let tmx = r#"{
"width": 2, "height": 1, "tilewidth": 16, "tileheight": 16,
"layers": [
{ "name": "ground", "width": 2, "height": 1, "data": [1,1] },
{ "name": "collision", "width": 2, "height": 1, "data": [0,0] },
{ "name": "collision2", "width": 2, "height": 1, "data": [0,1] }
],
"tilesets": [
{ "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
]
}"#;
let map = load_mem_map(tmx);
assert_eq!(map.level_count(), 3);
assert!(map.is_blocked_at(1, 0, 0), "gap level 1 is all-solid");
assert!(map.is_blocked_at(1, 1, 0));
assert!(!map.is_blocked_at(2, 0, 0));
assert!(map.is_blocked_at(2, 1, 0));
}
#[test]
fn split_render_preserves_lower_content() {
use dotzuki_engine::render_config::RenderConfig;
let map = load_mem_map(TWO_LEVEL_TMX);
let mut fb = FrameBuffer::new(RenderConfig::new(48, 32), Rgba::TRANSPARENT);
map.render_below(&mut fb, 0, 0, 48, 32, 0).expect("below");
assert_eq!(fb.get_pixel(0, 0), Some(Rgba::new(0xFF, 0, 0, 0xFF)));
let sprite = Rgba::new(0, 0, 0xFF, 0xFF);
fb.fill_rect(0, 0, 1, 1, sprite);
map.render_above(&mut fb, 0, 0, 48, 32, 0).expect("above");
assert_eq!(fb.get_pixel(0, 0), Some(sprite), "holes reveal the sprite");
assert_eq!(
fb.get_pixel(16, 0),
Some(Rgba::new(0xFF, 0, 0, 0xFF)),
"opaque above-layer tile draws over"
);
map.render_above(&mut fb, 0, 0, 48, 32, 1).expect("above");
assert_eq!(fb.get_pixel(0, 0), Some(sprite), "empty group leaves fb alone");
}
#[test]
fn single_level_map_behaves_as_before() {
let tmx = r#"{
"width": 2, "height": 1, "tilewidth": 16, "tileheight": 16,
"layers": [
{ "name": "ground", "width": 2, "height": 1, "data": [1,1] },
{ "name": "collision", "width": 2, "height": 1, "data": [0,1] }
],
"tilesets": [
{ "firstgid": 1, "name": "ts", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
]
}"#;
let map = load_mem_map(tmx);
assert_eq!(map.level_count(), 1);
assert!(!map.is_blocked(0, 0));
assert!(map.is_blocked(1, 0));
assert_eq!(map.stair_at(0, 0), None);
assert_eq!(map.render_state().layers.len(), 1);
}
}