Skip to content
Snippets Groups Projects
tileset.rs 11.4 KiB
Newer Older
alexdevteam's avatar
alexdevteam committed
use std::collections::HashMap;
use std::io::Read;
alexdevteam's avatar
alexdevteam committed
use std::path::{Path, PathBuf};
alexdevteam's avatar
alexdevteam committed

use xml::attribute::OwnedAttribute;
use xml::reader::XmlEvent;
use xml::EventReader;

use crate::error::TiledError;
use crate::image::Image;
alexdevteam's avatar
alexdevteam committed
use crate::properties::{parse_properties, Properties};
Alejandro Perea's avatar
Alejandro Perea committed
use crate::tile::TileData;
use crate::{util::*, Gid, Tile};
alexdevteam's avatar
alexdevteam committed

/// A collection of tiles for usage in maps and template objects.
/// 
/// Also see the [TMX docs](https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#tileset).
alexdevteam's avatar
alexdevteam committed
#[derive(Debug, PartialEq, Clone)]
pub struct Tileset {
    /// The name of the tileset, set by the user.
alexdevteam's avatar
alexdevteam committed
    pub name: String,
    /// The (maximum) width in pixels of the tiles in this tileset. Irrelevant for [image collection]
    /// tilesets.
    /// 
    /// [image collection]: Self::image
alexdevteam's avatar
alexdevteam committed
    pub tile_width: u32,
    /// The (maximum) height in pixels of the tiles in this tileset. Irrelevant for [image collection]
    /// tilesets.
    /// 
    /// [image collection]: Self::image
alexdevteam's avatar
alexdevteam committed
    pub tile_height: u32,
    /// The spacing in pixels between the tiles in this tileset (applies to the tileset image).
    /// Irrelevant for image collection tilesets.
alexdevteam's avatar
alexdevteam committed
    pub spacing: u32,
    /// The margin around the tiles in this tileset (applies to the tileset image).
    /// Irrelevant for image collection tilesets.
alexdevteam's avatar
alexdevteam committed
    pub margin: u32,
    /// The number of tiles in this tileset. Note that tile IDs don't always have a connection with
    /// the tile count, and as such there may be tiles with an ID bigger than the tile count.
    pub tilecount: u32,
    /// The number of tile columns in the tileset. Editable for image collection tilesets, otherwise
    /// calculated using [image](Self::image) width, [tile width](Self::tile_width),
    /// [spacing](Self::spacing) and [margin](Self::margin).
alexdevteam's avatar
alexdevteam committed

    /// A tileset can either:
    /// * have a single spritesheet `image` in `tileset` ("regular" tileset);
    /// * have zero images in `tileset` and one `image` per `tile` ("image collection" tileset).
    ///
alexdevteam's avatar
alexdevteam committed
    /// --------
    /// - Source: [tiled issue #2117](https://github.com/mapeditor/tiled/issues/2117)
    /// - Source: [`columns` documentation](https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#tileset)
    pub image: Option<Image>,

    /// All the tiles present in this tileset, indexed by their local IDs.
Alejandro Perea's avatar
Alejandro Perea committed
    tiles: HashMap<u32, TileData>,

    /// The custom properties of the tileset.
alexdevteam's avatar
alexdevteam committed
    pub properties: Properties,
}

pub(crate) enum EmbeddedParseResultType {
    ExternalReference { tileset_path: PathBuf },
    Embedded { tileset: Tileset },
}

pub(crate) struct EmbeddedParseResult {
    pub first_gid: Gid,
    pub result_type: EmbeddedParseResultType,
}

alexdevteam's avatar
alexdevteam committed
/// Internal structure for holding mid-parse information.
struct TilesetProperties {
    spacing: Option<u32>,
    margin: Option<u32>,
    tilecount: u32,
alexdevteam's avatar
alexdevteam committed
    columns: Option<u32>,
    name: String,
    tile_width: u32,
    tile_height: u32,
    /// The root all non-absolute paths contained within the tileset are relative to.
    root_path: PathBuf,
alexdevteam's avatar
alexdevteam committed

impl Tileset {
    /// Parses a tileset out of a reader hopefully containing the contents of a Tiled tileset.
    /// Uses the `path` parameter as the root for any relative paths found in the tileset.
    ///
    /// ## Example
    /// ```
    /// use std::fs::File;
    /// use std::path::PathBuf;
    /// use std::io::BufReader;
    /// use tiled::Tileset;
    ///
    /// let path = "assets/tilesheet.tsx";
    /// let reader = BufReader::new(File::open(path).unwrap());
    /// let tileset = Tileset::parse_reader(reader, path).unwrap();
    ///
    /// assert_eq!(tileset.image.unwrap().source, PathBuf::from("assets/tilesheet.png"));
    /// ```
    pub fn parse_reader<R: Read>(reader: R, path: impl AsRef<Path>) -> Result<Self, TiledError> {
        let mut tileset_parser = EventReader::new(reader);
alexdevteam's avatar
alexdevteam committed
        loop {
            match tileset_parser
                .next()
                .map_err(TiledError::XmlDecodingError)?
            {
                XmlEvent::StartElement {
                    name, attributes, ..
                } if name.local_name == "tileset" => {
                    return Self::parse_external_tileset(
                        &mut tileset_parser.into_iter(),
                        &attributes,
alexdevteam's avatar
alexdevteam committed
                }
                XmlEvent::EndDocument => {
                    return Err(TiledError::PrematureEnd(
                        "Tileset Document ended before map was parsed".to_string(),
                    ))
                }
                _ => {}
            }
        }
    }

    /// Gets the tile with the specified ID from the tileset.
Alejandro Perea's avatar
Alejandro Perea committed
    pub fn get_tile(&self, id: u32) -> Option<Tile> {
        self.tiles.get(&id).map(|data| Tile::new(self, data))
    pub(crate) fn parse_xml_in_map(
        parser: &mut impl Iterator<Item = XmlEventResult>,
        attrs: Vec<OwnedAttribute>,
        map_path: &Path,
    ) -> Result<EmbeddedParseResult, TiledError> {
        Tileset::parse_xml_embedded(parser, &attrs, map_path).or_else(|err| {
            if matches!(err, TiledError::MalformedAttributes(_)) {
                Tileset::parse_xml_reference(&attrs, map_path)
            } else {
                Err(err)
            }
        })
    }

    fn parse_xml_embedded(
        parser: &mut impl Iterator<Item = XmlEventResult>,
alexdevteam's avatar
alexdevteam committed
        attrs: &Vec<OwnedAttribute>,
    ) -> Result<EmbeddedParseResult, TiledError> {
        let ((spacing, margin, columns, name), (tilecount, first_gid, tile_width, tile_height)) = get_attrs!(
alexdevteam's avatar
alexdevteam committed
           attrs,
           optionals: [
                ("spacing", spacing, |v:String| v.parse().ok()),
                ("margin", margin, |v:String| v.parse().ok()),
                ("columns", columns, |v:String| v.parse().ok()),
                ("name", name, |v| Some(v)),
alexdevteam's avatar
alexdevteam committed
            ],
           required: [
                ("tilecount", tilecount, |v:String| v.parse().ok()),
                ("firstgid", first_gid, |v:String| v.parse().ok().map(|n| Gid(n))),
alexdevteam's avatar
alexdevteam committed
                ("tilewidth", width, |v:String| v.parse().ok()),
                ("tileheight", height, |v:String| v.parse().ok()),
            ],
            TiledError::MalformedAttributes("tileset must have a firstgid, name tile width and height with correct types".to_string())
        );

        let root_path = map_path
            .parent()
            .ok_or(TiledError::PathIsNotFile)?
            .to_owned();

alexdevteam's avatar
alexdevteam committed
        Self::finish_parsing_xml(
            parser,
            TilesetProperties {
                spacing,
                margin,
                name: name.unwrap_or_default(),
alexdevteam's avatar
alexdevteam committed
                columns,
                tilecount,
                tile_height,
                tile_width,
        .map(|tileset| EmbeddedParseResult {
            first_gid,
            result_type: EmbeddedParseResultType::Embedded { tileset },
        })
alexdevteam's avatar
alexdevteam committed
    }

alexdevteam's avatar
alexdevteam committed
    fn parse_xml_reference(
alexdevteam's avatar
alexdevteam committed
        attrs: &Vec<OwnedAttribute>,
    ) -> Result<EmbeddedParseResult, TiledError> {
Alejandro Perea's avatar
Alejandro Perea committed
        let (first_gid, source) = get_attrs!(
alexdevteam's avatar
alexdevteam committed
            attrs,
            required: [
                ("firstgid", first_gid, |v:String| v.parse().ok().map(|n| Gid(n))),
alexdevteam's avatar
alexdevteam committed
                ("source", name, |v| Some(v)),
            ],
alexdevteam's avatar
alexdevteam committed
            TiledError::MalformedAttributes("Tileset reference must have a firstgid and source with correct types".to_string())
alexdevteam's avatar
alexdevteam committed
        );

        let tileset_path = map_path
            .parent()
            .ok_or(TiledError::PathIsNotFile)?
alexdevteam's avatar
alexdevteam committed
            .join(source);

        Ok(EmbeddedParseResult {
            first_gid,
            result_type: EmbeddedParseResultType::ExternalReference { tileset_path },
        })
alexdevteam's avatar
alexdevteam committed
    }

    fn parse_external_tileset(
        parser: &mut impl Iterator<Item = XmlEventResult>,
alexdevteam's avatar
alexdevteam committed
        attrs: &Vec<OwnedAttribute>,
alexdevteam's avatar
alexdevteam committed
    ) -> Result<Tileset, TiledError> {
        let ((spacing, margin, columns, name), (tilecount, tile_width, tile_height)) = get_attrs!(
alexdevteam's avatar
alexdevteam committed
            attrs,
            optionals: [
                ("spacing", spacing, |v:String| v.parse().ok()),
                ("margin", margin, |v:String| v.parse().ok()),
                ("columns", columns, |v:String| v.parse().ok()),
                ("name", name, |v| Some(v)),
alexdevteam's avatar
alexdevteam committed
            ],
            required: [
                ("tilecount", tilecount, |v:String| v.parse().ok()),
alexdevteam's avatar
alexdevteam committed
                ("tilewidth", width, |v:String| v.parse().ok()),
                ("tileheight", height, |v:String| v.parse().ok()),
            ],
            TiledError::MalformedAttributes("tileset must have a name, tile width and height with correct types".to_string())
alexdevteam's avatar
alexdevteam committed
        );

        let root_path = path.parent().ok_or(TiledError::PathIsNotFile)?.to_owned();
alexdevteam's avatar
alexdevteam committed

alexdevteam's avatar
alexdevteam committed
        Self::finish_parsing_xml(
            parser,
            TilesetProperties {
                spacing,
                margin,
                name: name.unwrap_or_default(),
alexdevteam's avatar
alexdevteam committed
                columns,
                tilecount,
                tile_height,
                tile_width,
            },
        )
    }

    fn finish_parsing_xml(
        parser: &mut impl Iterator<Item = XmlEventResult>,
alexdevteam's avatar
alexdevteam committed
        prop: TilesetProperties,
    ) -> Result<Tileset, TiledError> {
        let mut tiles = HashMap::with_capacity(prop.tilecount as usize);
alexdevteam's avatar
alexdevteam committed
        let mut properties = HashMap::new();
alexdevteam's avatar
alexdevteam committed
        parse_tag!(parser, "tileset", {
Alejandro Perea's avatar
Alejandro Perea committed
                    "image" => |attrs| {
                        image = Some(Image::new(parser, attrs, &prop.root_path)?);
                        Ok(())
                    },
                    "properties" => |_| {
                        properties = parse_properties(parser)?;
                        Ok(())
                    },
                    "tile" => |attrs| {
                        let (id, tile) = TileData::new(parser, attrs, &prop.root_path)?;
                        tiles.insert(id, tile);
                        Ok(())
                    },
                });
alexdevteam's avatar
alexdevteam committed

        // A tileset is considered an image collection tileset if there is no image attribute (because its tiles do).
        let is_image_collection_tileset = image.is_none();

        if !is_image_collection_tileset {
            for tile_id in 0..prop.tilecount {
                tiles.entry(tile_id).or_default();
            }
        }
        let margin = prop.margin.unwrap_or(0);
        let spacing = prop.spacing.unwrap_or(0);
alexdevteam's avatar
alexdevteam committed
        let columns = prop
            .columns
            .map(Ok)
            .unwrap_or_else(|| Self::calculate_columns(&image, prop.tile_width, margin, spacing))?;
alexdevteam's avatar
alexdevteam committed
        Ok(Tileset {
alexdevteam's avatar
alexdevteam committed
            name: prop.name,
            tile_width: prop.tile_width,
            tile_height: prop.tile_height,
            spacing,
            margin,
alexdevteam's avatar
alexdevteam committed
            tilecount: prop.tilecount,
alexdevteam's avatar
alexdevteam committed
            tiles,
alexdevteam's avatar
alexdevteam committed
            properties,
        })
    }
alexdevteam's avatar
alexdevteam committed

    fn calculate_columns(
        image: &Option<Image>,
        tile_width: u32,
        margin: u32,
        spacing: u32,
    ) -> Result<u32, TiledError> {
        image
            .as_ref()
            .ok_or(TiledError::MalformedAttributes(
                "No <image> nor columns attribute in <tileset>".to_string(),
            ))
            .and_then(|image| Ok((image.width as u32 - margin + spacing) / (tile_width + spacing)))
    }
alexdevteam's avatar
alexdevteam committed
}