use std::{error::Error, fs::read_to_string, path::Path, borrow::Cow, collections::HashSet}; use euclid::default::Rect; use image::{DynamicImage, GenericImageView}; use linked_hash_map::LinkedHashMap; use roxmltree::Node; use crate::reskit::{utility::print_warning, cli::settings::TileOrder}; #[derive(Debug)] pub struct TiledTilemap { pub tileset: Vec, pub metatiles: Vec, pub layers: Vec, pub objects: Vec, pub collision: Vec>, pub width: usize, pub height: usize } #[derive(Debug, PartialEq)] pub struct Metatile { pub id: u32, pub source: String, pub width: u16, pub height: u16, pub tiles: Vec } #[derive(Debug)] pub struct Object { pub id: String, pub attributes: LinkedHashMap } #[derive(Debug)] pub struct SpriteMetadata { pub id: String, pub width: u8, pub height: u8, pub palette: u16, pub anim_interval: Option } #[derive(Debug)] pub struct TiledTileset { pub source: String, pub image: DynamicImage, pub palettes: Vec>, pub tile_order: TileOrder, pub sprite_metadata: Option } #[derive(Debug)] pub enum SystemPlane { MdPlaneA, MdPlaneB } #[derive(Debug)] pub enum Layer { Metatile { system_plane: SystemPlane, tiles: Vec } } fn get_layer( layer: Node, map_width: usize, map_height: usize ) -> Result, Box> { let layer_id = layer.attribute( "id" ).ok_or( "invalid file: on layer: no id" )?; let layer_name = layer.attribute( "name" ).unwrap_or( "" ); let layer_name = format!( "({}, ID: {})", layer_name, layer_id ); // Validate layer is same as total map size let ( layer_width, layer_height ): ( usize, usize ) = ( layer.attribute( "width" ).ok_or( format!( "invalid file: on layer {}: no width attribute", layer_name ) )?.parse()?, layer.attribute( "height" ).ok_or( format!( "invalid file: on layer {}: no height attribute", layer_name ) )?.parse()? ); if layer_width != map_width || layer_height != map_height { return Err( format!( "invalid file: on layer {}: layer width must match map width", layer_name ) )? } let properties = layer.descendants().find( | node | node.tag_name() == "properties".into() ); if let Some( properties ) = properties { let properties: Vec = properties.descendants().filter( | node | { if let Some( name ) = node.attribute( "name" ) { name == "reskit-layer" } else { false } } ).collect(); // Should be either one or none if let Some( layer_property ) = properties.first() { let layer_type = layer_property.attribute( "value" ).ok_or( format!( "invalid file: on layer {}: no value for property", layer_name ) )?; let data = layer.descendants() .find( | node | node.tag_name() == "data".into() ) .ok_or( format!( "invalid file: on layer {}: no data for layer", layer_name ) )?; let encoding = data.attribute( "encoding" ).ok_or( format!( "invalid file: on layer {}: no encoding attribute", layer_name ) )?; if encoding != "csv" { return Err( format!( "invalid file: on layer {}: only csv is supported for layer encoding", layer_name ) )? } let data: Vec<&str> = data.text().ok_or( format!( "invalid file: on layer {}: no layer data", layer_name ) )?.split( "," ).collect(); let tiles: Vec = data.into_iter().map( | string | Ok( string.trim().parse()? ) ).collect::< Result< Vec, Box > >()?; match layer_type.to_lowercase().as_str() { "a" => Ok( Some( Layer::Metatile { system_plane: SystemPlane::MdPlaneA, tiles } ) ), "b" => Ok( Some( Layer::Metatile { system_plane: SystemPlane::MdPlaneB, tiles } ) ), _ => { print_warning( &format!( "on layer {}: invalid reskit-layer value {}; ignoring this layer", layer_name, layer_type ) ); Ok( None ) } } } else { print_warning( &format!( "on layer {}: no reskit-layer property defining hardware layer or collision; ignoring this layer", layer_name ) ); Ok( None ) } } else { print_warning( &format!( "on layer {}: no properties defining hardware layer or collision; ignoring this layer", layer_name ) ); Ok( None ) } } fn get_tiles( tileset: Node, seen_sources: &mut HashSet, working_directory: &str ) -> Result, Box> { // Get the image for the tileset and its source let image = tileset.descendants().find( | node | node.tag_name() == "image".into() ).ok_or( "invalid file: no image object" )?; let source = image.attribute( "source" ).ok_or( "invalid file: no source attribute on image" )?.to_string(); // If the file path ends in .tmx, it's a metatile definition. We need to hop to the .tmx file in source, then redefine layer // to the single tileset in that metatile .tmx. let full_path = format!( "{}/{}", working_directory, source ); let formatted_path = Path::new( &full_path ); if let Some( extension ) = formatted_path.extension() { let extension = extension.to_string_lossy(); if extension == "tmx" { // Case where source is a nested tileset. Open the .tmx file let file = read_to_string( full_path.clone() )?; let tmx_document = roxmltree::Document::parse( &file )?; // Obtain the single (first) let tileset = tmx_document.descendants().find( | node | node.tag_name() == "tileset".into() ).ok_or( "invalid file: metatile has no tileset" )?; let tileset_source = tileset.attribute( "source" ).ok_or( "invalid file: metatile tileset has no source" )?.to_string(); // tileset_source should already point to the _actual_ source we need. Was this tileset source already seen? If so, bail if seen_sources.contains( &tileset_source ) { return Ok( None ) } else { seen_sources.insert( tileset_source.clone() ); } // Load nested tileset into roxmltree document let file = read_to_string( format!( "{}/{}", working_directory, tileset_source ) )?; let nested_tileset = roxmltree::Document::parse( &file )?; let tileset = nested_tileset.descendants().find( | node | node.tag_name() == "tileset".into() ).ok_or( "invalid file: metatile tileset has no tileset" )?; // Return nested tileset from tmx file let result = get_tiles( tileset, seen_sources, working_directory )?; if let Some( mut result ) = result { result.source = tileset_source.to_owned(); return Ok( Some( result ) ) } return Ok( result ) } } // Case where source is an actual image let image = image::open( full_path )?; // Image must be a multiple of 8 (--system md) if image.width() % 8 != 0 { return Err( "invalid file: tileset width not multiple of 8" )? } if image.height() % 8 != 0 { return Err( "invalid file: tileset height not multiple of 8" )? } let tile_count: usize = tileset.attribute( "tilecount" ).ok_or( "invalid file: no tilecount attribute on tileset" )?.parse()?; let mut palettes: Vec> = vec![ None; tile_count ]; let defined_tiles = tileset.descendants().filter( | node | node.tag_name() == "tile".into() ); for defined_tile in defined_tiles { let tile_id: usize = defined_tile.attribute( "id" ).ok_or( "invalid file: id attribute not defined on a tile" )?.parse()?; let properties = defined_tile.descendants().find( | node | node.tag_name() == "properties".into() ).ok_or( "invalid file: no properties descendant in tileset" )?; let property = properties.descendants().find( | node | node.tag_name() == "property".into() && node.attribute( "name" ).unwrap_or( "" ) == "reskit-palette" ); if let Some( property ) = property { let property_type = property.attribute( "type" ).unwrap_or( "string" ); if property_type == "int" { let palette_value: u8 = property.attribute( "value" ).ok_or( "invalid file: reskit-palette property has no value" )?.parse()?; // --system md if palette_value > 3 { print_warning( &format!( "reskit-palette property on tile {} is not valid palette (0 to 3) - leaving palette unset, this is probably not what you want...", tile_id ) ); } else { palettes[ tile_id ] = Some( palette_value ); } } else { print_warning( &format!( "reskit-palette property on tile {} is not int type - leaving palette unset, this is probably not what you want...", tile_id ) ) } } } let sprite_metadata; // Retrieve the tile order as specified by reskit-tile-order (if none is specified, fallback on Tile) let tile_order = if let Some( properties ) = tileset.descendants().find( | node | node.tag_name() == "properties".into() ) { if let Some( tile_order_property ) = properties.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-tile-order" ) ) { let tile_order_property = tile_order_property.attribute( "value" ).expect( "invalid file: no reskit-tile-order value" ); match tile_order_property.to_lowercase().as_str() { "sprite" => { // If sprite, reskit-sprite-height and reskit-sprite-width must be defined let reskit_sprite_height = properties.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-sprite-height" ) ).ok_or( "invalid file: for reskit-tile-order \"sprite\", reskit-sprite-height and reskit-sprite-width must be defined." )?; let reskit_sprite_width = properties.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-sprite-width" ) ).ok_or( "invalid file: for reskit-tile-order \"sprite\", reskit-sprite-height and reskit-sprite-width must be defined." )?; let reskit_sprite_id = properties.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-sprite-id" ) ).ok_or( "invalid file: for reskit-tile-order \"sprite\", reskit-sprite-id must be defined." )?; let reskit_palette = properties.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-palette" ) ).ok_or( "invalid file: for reskit-tile-order \"sprite\", reskit-palette must be defined." )?; let reskit_anim_interval = properties.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-anim-interval" ) ); let reskit_sprite_height = reskit_sprite_height.attribute( "value" ).ok_or( "invalid file: no reskit-sprite-height value" )?; let reskit_sprite_width = reskit_sprite_width.attribute( "value" ).ok_or( "invalid file: no reskit-sprite-width value" )?; let reskit_palette = reskit_palette.attribute( "value" ).ok_or( "invalid file: no reskit-palette value" )?; let id: String = reskit_sprite_id.attribute( "value" ).ok_or( "invalid file: no reskit-sprite-id value" )?.to_owned(); let width: u8 = reskit_sprite_width.parse()?; let height: u8 = reskit_sprite_height.parse()?; let palette: u16 = reskit_palette.parse()?; let anim_interval = if let Some( property ) = reskit_anim_interval { let value = property.attribute( "value" ).ok_or( "invalid file: no reskit-anim-interval value" )?; Some( value.parse()? ) } else { None }; sprite_metadata = Some( SpriteMetadata { id, width, height, palette, anim_interval } ); TileOrder::Sprite }, "tile" => { sprite_metadata = None; TileOrder::Tile }, invalid => { sprite_metadata = None; print_warning( &format!( "invalid setting for property reskit-tile-order: {}. falling back on \"tile\"", invalid ) ); TileOrder::Tile } } } else { sprite_metadata = None; TileOrder::Tile } } else { sprite_metadata = None; TileOrder::Tile }; Ok( Some( TiledTileset { source: format!( "" ), image, palettes, tile_order, sprite_metadata } ) ) } pub fn get_metatile( id: u32, tileset: Node, working_directory: &str ) -> Result, Box> { // Get the image for the tileset and its source let image = tileset.descendants().find( | node | node.tag_name() == "image".into() ).ok_or( "invalid file: no image object" )?; let source = image.attribute( "source" ).ok_or( "invalid file: no source attribute on image" )?.to_string(); let full_path = format!( "{}/{}", working_directory, source ); let formatted_path = Path::new( &full_path ); if let Some( extension ) = formatted_path.extension() { let extension = extension.to_string_lossy(); if extension == "tmx" { // Case where source is a nested tileset. Open the .tmx file let file = read_to_string( full_path.clone() )?; let tmx_document = roxmltree::Document::parse( &file )?; // Get width and height from item let map_item = tmx_document.descendants().find( | node | node.tag_name() == "map".into() ).ok_or( "invalid file: no map element in metatile" )?; let width: u16 = map_item.attribute( "width" ).ok_or( "invalid file: map of metatile has no width attribute" )?.parse()?; let height: u16 = map_item.attribute( "height" ).ok_or( "invalid file: map of metatile has no height attribute" )?.parse()?; // Obtain the single (first) let tileset_count = tmx_document.descendants().filter( | node | node.tag_name() == "tileset".into() ).count(); if tileset_count > 1 { return Err( "invalid file: only a single tileset is valid for a metatile file" )? } let tileset = tmx_document.descendants().find( | node | node.tag_name() == "tileset".into() ).ok_or( "invalid file: metatile has no tileset" )?; let source = tileset.attribute( "source" ).ok_or( "invalid file: metatile tileset has no source" )?.to_string(); // Create metatile object here let data = tmx_document.descendants().find( | node | node.tag_name() == "data".into() ).ok_or( "invalid file: metatile has no data" )?; if data.attribute( "encoding" ) != Some( "csv" ) { return Err( "invalid file: metatile data not in csv format" )? } let data = data.text().ok_or( "invalid file: data node has no text" )?; let values: Vec<&str> = data.split( "," ).collect(); // Subtract 1 to each value to account for firstgid. This is a sane assumption because a valid metatile file only contains a single tileset. let tiles: Vec = values.into_iter().map( | string | { let val = string.trim().parse::().expect( "fatal: non-u32 value in metatile" ); if val == 0 { val } else { val - 1 } } ).collect(); return Ok( Some( Metatile { id, source, width, height, tiles } ) ) } } Ok( None ) } pub fn get_objs( node: &Node, object_fields: &Vec<&str> ) -> Result, Box> { let mut result = Vec::new(); let objects = node.descendants().filter( | node | node.tag_name() == "object".into() ); for object in objects { let object_id = object.attribute( "id" ).ok_or( "invalid file: object in objectgroup has no id attribute" )?; if let Some( properties ) = object.descendants().find( | node | node.tag_name() == "properties".into() ) { if let Some( object_id_property ) = properties.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-object-id" ) ) { let id = object_id_property.attribute( "value" ).ok_or( "invalid file: property has no value attribute" )?.to_owned(); let mut attributes = LinkedHashMap::new(); // Get position x and y as beginning attributes let ( x, y ) = ( object.attribute( "x" ).ok_or( "invalid file: object has no x position" )?.to_owned(), object.attribute( "y" ).ok_or( "invalid file: object has no y position" )?.to_owned() ); attributes.insert( "x".trim().to_owned(), x ); attributes.insert( "y".trim().to_owned(), y ); // Custom object fields - push these in order into the LinkedHashMap for field in object_fields { let property = properties.descendants().find( | node | node.attribute( "name" ) == Some( &format!( "reskit-field[{}]", field ) ) ); if let Some( property ) = property { let value = property.attribute( "value" ).ok_or( "invalid file: property has no value attribute" )?.to_owned(); attributes.insert( field.to_string(), value ); } else { print_warning( &format!( "object {} does not define a value for struct field \"{}\", this field will be filled in with 0x00 at export", id, field ) ); attributes.insert( field.to_string(), "0".to_owned() ); } } result.push( Object { id, attributes } ); } else { print_warning( &format!( "object {} has no \"reskit-object-id\" property....ignoring this object. this is probably not what you want.", object_id ) ); } } else { print_warning( &format!( "object {} has no properties....ignoring this object. this is probably not what you want", object_id ) ); } } Ok( result ) } pub fn get_tiled_tilemap( path: &str, object_fields: &Vec<&str> ) -> Result> { let file = read_to_string( path )?; let document = roxmltree::Document::parse( &file )?; let working_directory = { let result = Path::new( path ).parent().unwrap_or( Path::new( "." ) ).to_string_lossy(); if result == "" { Cow::from( "." ) } else { result } }; let map = document.descendants().find( | node | node.tag_name() == "map".into() ); if let Some( map ) = map { // Validate version let version = map.attribute( "version" ).ok_or( "invalid file: no version attribute" )?; if version < "1.10" || !version.starts_with( "1." ) { return Err( "invalid file: unsupported version" )? } // Validate orientation and render order let orientation = map.attribute( "orientation" ).ok_or( "invalid file: no orientation attribute" )?; if orientation != "orthogonal" { return Err( "invalid file: only orthogonal orientation is supported" )? } let render_order = map.attribute( "renderorder" ).ok_or( "invalid file: no renderorder attribute" )?; if render_order != "left-down" { return Err( "invalid file: only left-down orientation is supported" )? } let ( width, height ): ( usize, usize ) = ( map.attribute( "width" ).ok_or( "invalid file: no width attribute" )?.parse()?, map.attribute( "height" ).ok_or( "invalid file: no height attribute" )?.parse()? ); let ( tile_width, tile_height ): ( usize, usize ) = ( map.attribute( "tilewidth" ).ok_or( "invalid file: no tilewidth attribute" )?.parse()?, map.attribute( "tileheight" ).ok_or( "invalid file: no tileheight attribute" )?.parse()? ); // --system md is 8x8 if tile_width != 8 { return Err( "invalid file: tile width is not 8 for --system md" )? } if tile_height != 8 { return Err( "invalid file: tile height is not 8 for --system md" )? } // Build tilesets (current version assumes one tileset per level) let mut tilesets: Vec = vec![]; let mut metatiles: Vec = vec![]; let mut seen_sources: HashSet = HashSet::new(); for tileset in map.descendants().filter( | node | node.tag_name() == "tileset".into() ) { let tileset_first_gid = tileset.attribute( "firstgid" ).ok_or( "invalid file: no tileset firstgid" )?; let tileset_source_path = tileset.attribute( "source" ).ok_or( "invalid file: no tileset source" )?; let tileset_file = read_to_string( format!( "{}/{}", working_directory, tileset_source_path ) )?; let tileset_document = roxmltree::Document::parse( &tileset_file )?; let tileset = tileset_document.descendants().find( | node | node.tag_name() == "tileset".into() ).ok_or( "invalid file: no tileset origin object" )?; // Tilesets may instead contain metatiles // First, load tilesets instead of metatiles // If tileset_source_path was seen, don't proceed if !seen_sources.contains( tileset_source_path ) { seen_sources.insert( tileset_source_path.to_owned() ); if let Some( mut tiles ) = get_tiles( tileset.clone(), &mut seen_sources, &working_directory )? { if tiles.source == "" { tiles.source = tileset_source_path.to_owned(); } tilesets.push( tiles ); } } // Then, check for metatiles if let Some( metatile ) = get_metatile( tileset_first_gid.parse()?, tileset, &working_directory )? { metatiles.push( metatile ); } } let tileset = tilesets; if tileset.is_empty() { return Err( "invalid file: at least one tileset must be present in file" )? } // Get the layers let layers: Vec = map.descendants() .filter( | node | node.tag_name() == "layer".into() ) .map( | node | get_layer( node, width, height ) ) .collect::< Result< Vec>, Box > >()? .into_iter() .filter_map( | option | option ) .collect(); // Get the entity-component system let object_group = map.descendants().find( | node | node.tag_name() == "objectgroup".into() && node.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-layer" ) && node.attribute( "value" ) == Some( "object" ) ).is_some() ); let objects = if let Some( object_group ) = object_group { get_objs( &object_group, object_fields )? } else { print_warning( "no object layer in this file, this is probably not what you want..." ); Vec::new() }; // Get collision let collision_group = map.descendants().find( | node | node.tag_name() == "objectgroup".into() && node.descendants().find( | node | node.attribute( "name" ) == Some( "reskit-layer" ) && node.attribute( "value" ) == Some( "collision" ) ).is_some() ); let collision = if let Some( collision_group ) = collision_group { let mut result = Vec::new(); let objects = collision_group.descendants().filter( | node | node.tag_name() == "object".into() ); for object in objects { let x: u16 = object.attribute( "x" ).ok_or( "invalid file: no \"x\" attribute in collision bounding box" )?.parse()?; let y: u16 = object.attribute( "y" ).ok_or( "invalid file: no \"y\" attribute in collision bounding box" )?.parse()?; let width: u16 = object.attribute( "width" ).ok_or( "invalid file: no \"width\" attribute in collision bounding box" )?.parse()?; let height: u16 = object.attribute( "height" ).ok_or( "invalid file: no \"height\" attribute in collision bounding box" )?.parse()?; result.push( euclid::rect( x, y, width, height ) ); } if result.is_empty() { print_warning( "collision layer present but no bounding boxes are defined, this is probably not what you want..." ); } result } else { print_warning( "no collision layer in this file, this is probably not what you want..." ); Vec::new() }; Ok( TiledTilemap { tileset, metatiles, layers, objects, collision, width, height } ) } else { Err( "invalid file: this does not appear to be valid Tiled .tmx file" )? } }