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(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 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 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 #[inline]
334 pub fn id(&self) -> &str {
335 &self.id
336 }
337
338 #[inline]
340 pub fn width(&self) -> u16 {
341 self.width
342 }
343
344 #[inline]
346 pub fn height(&self) -> u16 {
347 self.height
348 }
349
350 #[inline]
352 pub fn tile_size(&self) -> (u32, u32) {
353 (self.tile_w, self.tile_h)
354 }
355
356 #[inline]
358 pub fn pixel_width(&self) -> i32 {
359 self.width as i32 * self.tile_w as i32
360 }
361
362 #[inline]
364 pub fn pixel_height(&self) -> i32 {
365 self.height as i32 * self.tile_h as i32
366 }
367
368 #[inline]
371 pub fn render_state(&self) -> &MapRenderState {
372 &self.state
373 }
374
375 #[inline]
377 pub fn tileset(&self) -> &PngTileset {
378 &self.tileset
379 }
380
381 #[inline]
383 pub fn objects(&self) -> &MapObjects {
384 &self.objects
385 }
386
387 #[inline]
390 pub fn is_blocked(&self, x: i32, y: i32) -> bool {
391 self.is_blocked_at(0, x, y)
392 }
393
394 #[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 #[inline]
411 pub fn level_count(&self) -> usize {
412 self.collision_levels.len()
413 }
414
415 #[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 #[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 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 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 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 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 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
577pub 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 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 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 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 assert!(map.is_blocked(0, 0));
640 assert!(!map.is_blocked(1, 0));
641 assert!(map.is_blocked(2, 1));
642
643 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 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 let collision: &dyn OverworldCollision = ↦
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 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 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 #[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 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 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 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 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 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}