reskit/src/reskit/level/converter.rs

496 lines
25 KiB
Rust

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<TiledTileset>,
pub metatiles: Vec<Metatile>,
pub layers: Vec<Layer>,
pub objects: Vec<Object>,
pub collision: Vec<Rect<u16>>,
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<u32>
}
#[derive(Debug)]
pub struct Object {
pub id: String,
pub attributes: LinkedHashMap<String, String>
}
#[derive(Debug)]
pub struct SpriteMetadata {
pub id: String,
pub width: u8,
pub height: u8,
pub palette: u16,
pub anim_interval: Option<u16>
}
#[derive(Debug)]
pub struct TiledTileset {
pub source: String,
pub image: DynamicImage,
pub palettes: Vec<Option<u8>>,
pub tile_order: TileOrder,
pub sprite_metadata: Option<SpriteMetadata>
}
#[derive(Debug)]
pub enum SystemPlane {
MdPlaneA,
MdPlaneB
}
#[derive(Debug)]
pub enum Layer {
Metatile {
system_plane: SystemPlane,
tiles: Vec<u32>
}
}
fn get_layer( layer: Node, map_width: usize, map_height: usize ) -> Result<Option<Layer>, Box<dyn Error>> {
let layer_id = layer.attribute( "id" ).ok_or( "invalid file: on layer: no id" )?;
let layer_name = layer.attribute( "name" ).unwrap_or( "<no name>" );
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<Node> = 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<u32> = data.into_iter().map( | string | Ok( string.trim().parse()? ) ).collect::< Result< Vec<u32>, Box<dyn Error> > >()?;
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<String>, working_directory: &str ) -> Result<Option<TiledTileset>, Box<dyn Error>> {
// 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) <tileset>
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<Option<u8>> = 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<Option<Metatile>, Box<dyn Error>> {
// 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 <map> 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) <tileset>
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<u32> = values.into_iter().map( | string | {
let val = string.trim().parse::<u32>().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<Vec<Object>, Box<dyn Error>> {
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<TiledTilemap, Box<dyn Error>> {
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<TiledTileset> = vec![];
let mut metatiles: Vec<Metatile> = vec![];
let mut seen_sources: HashSet<String> = 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<Layer> = map.descendants()
.filter( | node | node.tag_name() == "layer".into() )
.map( | node | get_layer( node, width, height ) )
.collect::< Result< Vec<Option<Layer>>, Box<dyn Error> > >()?
.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" )?
}
}