1use 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
30pub const OBJECTS_SIDECAR: &str = "objects.json";
32pub const LEGACY_SIDECAR: &str = "map.json";
34
35#[derive(Debug, Clone, Deserialize)]
37pub struct NpcDef {
38 pub id: u32,
40 #[serde(default)]
42 pub name: String,
43 pub x: i32,
45 pub y: i32,
47 #[serde(default = "default_facing")]
49 pub facing: String,
50 #[serde(default)]
52 pub sprite: String,
53 #[serde(default)]
55 pub talk: String,
56}
57
58fn default_facing() -> String {
59 "down".to_string()
60}
61
62#[derive(Debug, Clone, Deserialize)]
67pub struct WarpDef {
68 pub x: i32,
70 pub y: i32,
72 #[serde(default)]
74 pub dest_map: String,
75 #[serde(default)]
77 pub dest_x: i32,
78 #[serde(default)]
80 pub dest_y: i32,
81}
82
83#[derive(Debug, Clone, Deserialize)]
85pub struct SignDef {
86 pub x: i32,
88 pub y: i32,
90 #[serde(default)]
92 pub text: String,
93}
94
95#[derive(Debug, Clone, Deserialize)]
100pub struct EncounterTableEntry {
101 pub id: String,
103 #[serde(default = "default_weight")]
105 pub weight: u32,
106}
107
108fn default_weight() -> u32 {
109 1
110}
111
112#[derive(Debug, Clone, Deserialize)]
115pub struct EncounterZone {
116 pub x: i32,
118 pub y: i32,
120 pub w: i32,
122 pub h: i32,
124 #[serde(default)]
126 pub table: Vec<EncounterTableEntry>,
127}
128
129impl EncounterZone {
130 #[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#[derive(Debug, Clone, Deserialize)]
141pub struct EncounterConfig {
142 pub rate: u8,
144 #[serde(default)]
146 pub zones: Vec<EncounterZone>,
147}
148
149#[derive(Debug, Clone, Default, Deserialize)]
154pub struct MapObjects {
155 #[serde(default)]
157 pub npcs: Vec<NpcDef>,
158 #[serde(default)]
160 pub warps: Vec<WarpDef>,
161 #[serde(default)]
163 pub signs: Vec<SignDef>,
164 #[serde(default)]
167 pub encounters: Option<EncounterConfig>,
168}
169
170impl MapObjects {
171 pub fn load(map_dir: &Path) -> Result<Self> {
179 Self::load_with_files(&DiskFiles::new(map_dir), "")
180 }
181
182 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
205pub struct RuntimeMap {
208 id: String,
209 width: u16,
211 height: u16,
212 tile_w: u32,
214 tile_h: u32,
215 state: MapRenderState,
217 collision_levels: Vec<Vec<bool>>,
221 stairs: Option<Vec<u32>>,
224 tileset: PngTileset,
225 objects: MapObjects,
226}
227
228fn 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 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 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 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 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 #[inline]
338 pub fn id(&self) -> &str {
339 &self.id
340 }
341
342 #[inline]
344 pub fn width(&self) -> u16 {
345 self.width
346 }
347
348 #[inline]
350 pub fn height(&self) -> u16 {
351 self.height
352 }
353
354 #[inline]
356 pub fn tile_size(&self) -> (u32, u32) {
357 (self.tile_w, self.tile_h)
358 }
359
360 #[inline]
362 pub fn pixel_width(&self) -> i32 {
363 self.width as i32 * self.tile_w as i32
364 }
365
366 #[inline]
368 pub fn pixel_height(&self) -> i32 {
369 self.height as i32 * self.tile_h as i32
370 }
371
372 #[inline]
375 pub fn render_state(&self) -> &MapRenderState {
376 &self.state
377 }
378
379 #[inline]
381 pub fn tileset(&self) -> &PngTileset {
382 &self.tileset
383 }
384
385 #[inline]
387 pub fn objects(&self) -> &MapObjects {
388 &self.objects
389 }
390
391 #[inline]
394 pub fn is_blocked(&self, x: i32, y: i32) -> bool {
395 self.is_blocked_at(0, x, y)
396 }
397
398 #[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 #[inline]
415 pub fn level_count(&self) -> usize {
416 self.collision_levels.len()
417 }
418
419 #[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 #[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 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 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 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 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 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
581pub 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 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 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 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 assert!(map.is_blocked(0, 0));
644 assert!(!map.is_blocked(1, 0));
645 assert!(map.is_blocked(2, 1));
646
647 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 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 let collision: &dyn OverworldCollision = ↦
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 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 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 #[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 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 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 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 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 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}