//! Tiled (`.tmx`) map support. //! //! Conventions understood by the game: //! - Tile layers are rendered in order. A tile is solid when its tile (in the tileset) //! or its layer has the boolean custom property `solid = true`. //! - Object layers spawn gameplay entities based on the object *class*: //! - `spawn`: player spawn point. //! - `building`: building with the custom property `proto` (a building id). //! - `item`: ground item with the property `proto` (an item id) and optional `count`. //! //! State overrides (`state.*`, `machine.*`) and legacy integer script variables //! are decoded by the server for both new worlds and imports. use std::{ collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, }; use crate::components::GridPos; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TilesetInfo { /// Image path relative to the assets directory. pub image: String, pub image_width: u32, pub image_height: u32, pub tile_width: u32, pub tile_height: u32, pub columns: u32, pub tile_count: u32, pub spacing: u32, pub margin: u32, pub solid_tiles: BTreeSet, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TileCell { pub tileset: usize, pub id: u32, pub flip_h: bool, pub flip_v: bool, pub flip_d: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TileLayerData { pub name: String, pub solid: bool, /// Row-major cells, `width * height` entries. pub cells: Vec>, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct MapObject { pub id: u32, pub class: String, pub name: String, pub pos: GridPos, pub properties: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct MapLayout { pub width: u32, pub height: u32, pub tile_width: u32, pub tile_height: u32, pub tilesets: Vec, pub layers: Vec, pub objects: Vec, solid: Vec, } #[derive(Debug, thiserror::Error)] pub enum MapError { #[error("failed to load map `{0}`: {1}")] Tiled(PathBuf, tiled::Error), #[error("map `{0}`: {1}")] Unsupported(PathBuf, String), } impl MapLayout { /// Loads an asset-relative or absolute map path. pub fn load(assets_dir: &Path, map_path: &str) -> Result { let full = assets_dir.join(map_path); let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg); let map = tiled::Loader::new() .load_tmx_map(&full) .map_err(|e| MapError::Tiled(full.clone(), e))?; if map.orientation != tiled::Orientation::Orthogonal { return Err(unsupported("only orthogonal maps are supported".into())); } let assets_canon = assets_dir .canonicalize() .unwrap_or_else(|_| assets_dir.into()); let mut tilesets = Vec::new(); for ts in map.tilesets() { let image = ts.image.as_ref().ok_or_else(|| { unsupported(format!( "tileset `{}` must be based on a single image (image collections are not supported)", ts.name )) })?; let image_path = image.source.canonicalize().unwrap_or(image.source.clone()); let rel = image_path.strip_prefix(&assets_canon).map_err(|_| { unsupported(format!( "tileset image `{}` must be inside the assets directory", image.source.display() )) })?; tilesets.push(TilesetInfo { image: rel.to_string_lossy().replace('\\', "/"), image_width: image.width as u32, image_height: image.height as u32, tile_width: ts.tile_width, tile_height: ts.tile_height, columns: ts.columns, tile_count: ts.tilecount, spacing: ts.spacing, margin: ts.margin, solid_tiles: ts .tiles() .filter(|(_, tile)| bool_prop(&tile.properties, "solid")) .map(|(id, _)| id) .collect(), }); } let (width, height) = (map.width, map.height); if width == 0 || height == 0 || map.tile_width == 0 || map.tile_height == 0 { return Err(unsupported( "map and tile dimensions must be nonzero".into(), )); } if width > i32::MAX as u32 || height > i32::MAX as u32 || u64::from(width) * u64::from(height) > 1_000_000 { return Err(unsupported( "maps may contain at most 1,000,000 cells".into(), )); } let mut layers = Vec::new(); let mut solid = vec![false; (width * height) as usize]; let mut objects = Vec::new(); let mut stack: Vec = map.layers().collect(); stack.reverse(); while let Some(layer) = stack.pop() { let layer_solid = bool_prop(&layer.properties, "solid"); match layer.layer_type() { tiled::LayerType::Tiles(tiles) => { if matches!(tiles, tiled::TileLayer::Infinite(_)) { return Err(unsupported("infinite maps are not supported".into())); } let mut cells = Vec::with_capacity(solid.len()); for y in 0..height as i32 { for x in 0..width as i32 { let cell = tiles.get_tile(x, y).map(|t| { let tile_solid = t .get_tile() .is_some_and(|d| bool_prop(&d.properties, "solid")); if tile_solid || layer_solid { solid[(y as u32 * width + x as u32) as usize] = true; } TileCell { tileset: t.tileset_index(), id: t.id(), flip_h: t.flip_h, flip_v: t.flip_v, flip_d: t.flip_d, } }); cells.push(cell); } } layers.push(TileLayerData { name: layer.name.clone(), solid: layer_solid, cells, }); } tiled::LayerType::Objects(group) => { for obj in group.objects() { let tw = map.tile_width as f32; let th = map.tile_height as f32; // Tile objects are anchored at their bottom-left corner. let y = if obj.get_tile().is_some() { obj.y - 1.0 } else { obj.y }; objects.push(MapObject { id: obj.id(), class: obj.user_type.clone(), name: obj.name.clone(), pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32), properties: obj .properties .iter() .map(|(k, v)| (k.clone(), prop_to_string(v))) .collect(), }); } } tiled::LayerType::Group(group) => { let children: Vec<_> = group.layers().collect(); stack.extend(children.into_iter().rev()); } tiled::LayerType::Image(_) => {} } } Ok(Self { width, height, tile_width: map.tile_width, tile_height: map.tile_height, tilesets, layers, objects, solid, }) } pub fn in_bounds(&self, pos: GridPos) -> bool { pos.x >= 0 && pos.y >= 0 && (pos.x as u32) < self.width && (pos.y as u32) < self.height } /// Tiles outside of the map are always solid. pub fn is_solid(&self, pos: GridPos) -> bool { !self.in_bounds(pos) || self.solid[(pos.y as u32 * self.width + pos.x as u32) as usize] } pub fn objects_of_class<'a>(&'a self, class: &'a str) -> impl Iterator { self.objects.iter().filter(move |o| o.class == class) } } fn bool_prop(props: &tiled::Properties, key: &str) -> bool { matches!(props.get(key), Some(tiled::PropertyValue::BoolValue(true))) } fn prop_to_string(value: &tiled::PropertyValue) -> String { use tiled::PropertyValue::*; match value { BoolValue(v) => v.to_string(), FloatValue(v) => v.to_string(), IntValue(v) => v.to_string(), StringValue(v) | FileValue(v) => v.clone(), ObjectValue(v) => v.to_string(), other => format!("{other:?}"), } } #[cfg(test)] mod tests { use super::*; #[test] fn loads_shipped_map() { let map = MapLayout::load(&crate::assets_dir(), crate::DEFAULT_MAP).unwrap(); assert!(map.width > 0 && map.height > 0); assert!(!map.layers.is_empty()); assert!(map.objects_of_class("spawn").next().is_some()); assert!(map.is_solid(GridPos::new(-1, 0))); assert!( map.is_solid(GridPos::new(0, 0)), "map border should be walls" ); let spawn = map.objects_of_class("spawn").next().unwrap().pos; assert!(!map.is_solid(spawn)); } }