From 115afb5e2297f64f3e45c59dbd0d968b68098ec4 Mon Sep 17 00:00:00 2001 From: Darren Eberly Date: Fri, 17 Dec 2021 01:09:40 -0500 Subject: [PATCH] More TMX work(it's mostly working I think) --- pytiled_parser/parsers/tmx/layer.py | 223 ++++++++++++++++- pytiled_parser/parsers/tmx/tiled_map.py | 59 ++++- pytiled_parser/parsers/tmx/tiled_object.py | 275 +++++++++++++++++++++ pytiled_parser/parsers/tmx/tileset.py | 5 + pytiled_parser/tiled_object.py | 5 +- 5 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 pytiled_parser/parsers/tmx/tiled_object.py diff --git a/pytiled_parser/parsers/tmx/layer.py b/pytiled_parser/parsers/tmx/layer.py index 21da21a..f825d0d 100644 --- a/pytiled_parser/parsers/tmx/layer.py +++ b/pytiled_parser/parsers/tmx/layer.py @@ -1,14 +1,136 @@ """Layer parsing for the TMX Map Format. """ +import base64 +import gzip +import importlib.util import xml.etree.ElementTree as etree +import zlib from pathlib import Path -from typing import Optional +from typing import List, Optional from pytiled_parser.common_types import OrderedPair, Size -from pytiled_parser.layer import ImageLayer, Layer +from pytiled_parser.layer import ( + Chunk, + ImageLayer, + Layer, + LayerGroup, + ObjectLayer, + TileLayer, +) from pytiled_parser.parsers.tmx.properties import parse as parse_properties +from pytiled_parser.parsers.tmx.tiled_object import parse as parse_object from pytiled_parser.util import parse_color +zstd_spec = importlib.util.find_spec("zstd") +if zstd_spec: + import zstd +else: + zstd = None + + +def _convert_raw_tile_layer_data(data: List[int], layer_width: int) -> List[List[int]]: + """Convert raw layer data into a nested lit based on the layer width + + Args: + data: The data to convert + layer_width: Width of the layer + + Returns: + List[List[int]]: A nested list containing the converted data + """ + tile_grid: List[List[int]] = [[]] + + column_count = 0 + row_count = 0 + for item in data: + column_count += 1 + tile_grid[row_count].append(item) + if not column_count % layer_width and column_count < len(data): + row_count += 1 + tile_grid.append([]) + + return tile_grid + + +def _decode_tile_layer_data( + data: str, compression: str, layer_width: int +) -> List[List[int]]: + """Decode Base64 Encoded tile data. Optionally supports gzip and zlib compression. + + Args: + data: The base64 encoded data + compression: Either zlib, gzip, or empty. If empty no decompression is done. + + Returns: + List[List[int]]: A nested list containing the decoded data + + Raises: + ValueError: For an unsupported compression type. + """ + unencoded_data = base64.b64decode(data) + if compression == "zlib": + unzipped_data = zlib.decompress(unencoded_data) + elif compression == "gzip": + unzipped_data = gzip.decompress(unencoded_data) + elif compression == "zstd" and zstd is None: + raise ValueError( + "zstd compression support is not installed." + "To install use 'pip install pytiled-parser[zstd]'" + ) + elif compression == "zstd": + unzipped_data = zstd.decompress(unencoded_data) + else: + unzipped_data = unencoded_data + + tile_grid: List[int] = [] + + byte_count = 0 + int_count = 0 + int_value = 0 + for byte in unzipped_data: + int_value += byte << (byte_count * 8) + byte_count += 1 + if not byte_count % 4: + byte_count = 0 + int_count += 1 + tile_grid.append(int_value) + int_value = 0 + + return _convert_raw_tile_layer_data(tile_grid, layer_width) + + +def _parse_chunk( + raw_chunk: etree.Element, + encoding: Optional[str] = None, + compression: Optional[str] = None, +) -> Chunk: + """Parse the raw_chunk to a Chunk. + + Args: + raw_chunk: XML Element to be parsed to a Chunk + encoding: Encoding type. ("base64" or None) + compression: Either zlib, gzip, or empty. If empty no decompression is done. + + Returns: + Chunk: The Chunk created from the raw_chunk + """ + if encoding == "base64": + assert isinstance(compression, str) + data = _decode_tile_layer_data( + raw_chunk.text, compression, int(raw_chunk.attrib["width"]) # type: ignore + ) + else: + data = _convert_raw_tile_layer_data( + [int(v.strip) for v in raw_chunk.text], # type: ignore + int(raw_chunk.attrib["width"]), + ) + + return Chunk( + coordinates=OrderedPair(int(raw_chunk.attrib["x"]), int(raw_chunk.attrib["y"])), + size=Size(int(raw_chunk.attrib["width"]), int(raw_chunk.attrib["height"])), + data=data, + ) + def _parse_common(raw_layer: etree.Element) -> Layer: """Create a Layer containing all the attributes common to all layer types. @@ -56,6 +178,83 @@ def _parse_common(raw_layer: etree.Element) -> Layer: return common +def _parse_tile_layer(raw_layer: etree.Element) -> TileLayer: + """Parse the raw_layer to a TileLayer. + + Args: + raw_layer: XML Element to be parsed to a TileLayer. + + Returns: + TileLayer: The TileLayer created from raw_layer + """ + tile_layer = TileLayer( + size=Size(int(raw_layer.attrib["width"]), int(raw_layer.attrib["height"])), + **_parse_common(raw_layer).__dict__, + ) + + data_element = raw_layer.find("./data") + if data_element: + encoding = None + if data_element.attrib.get("encoding") is not None: + encoding = data_element.attrib["encoding"] + + compression = "" + if data_element.attrib.get("compression") is not None: + compression = data_element.attrib["compression"] + + raw_chunks = data_element.findall("./chunk") + + if not raw_chunks: + if encoding: + tile_layer.data = _decode_tile_layer_data( + data=data_element.text, # type: ignore + compression=compression, + layer_width=int(raw_layer.attrib["width"]), + ) + else: + tile_layer.data = _convert_raw_tile_layer_data( + [int(v.strip()) for v in data_element.text], # type: ignore + int(raw_layer.attrib["width"]), + ) + else: + chunks = [] + for raw_chunk in raw_chunks: + chunks.append( + _parse_chunk( + raw_chunk, + encoding, + compression, + ) + ) + + if chunks: + tile_layer.chunks = chunks + + return tile_layer + + +def _parse_object_layer( + raw_layer: etree.Element, parent_dir: Optional[Path] = None +) -> ObjectLayer: + """Parse the raw_layer to an ObjectLayer. + + Args: + raw_layer: XML Element to be parsed to an ObjectLayer. + + Returns: + ObjectLayer: The ObjectLayer created from raw_layer + """ + objects = [] + for object_ in raw_layer.findall("./object"): + objects.append(parse_object(object_, parent_dir)) + + return ObjectLayer( + tiled_objects=objects, + draw_order=raw_layer.attrib["draworder"], + **_parse_common(raw_layer).__dict__, + ) + + def _parse_image_layer(raw_layer: etree.Element) -> ImageLayer: """Parse the raw_layer to an ImageLayer. @@ -85,6 +284,26 @@ def _parse_image_layer(raw_layer: etree.Element) -> ImageLayer: raise RuntimeError("Tried to parse an image layer that doesn't have an image!") +def _parse_group_layer( + raw_layer: etree.Element, parent_dir: Optional[Path] = None +) -> LayerGroup: + """Parse the raw_layer to a LayerGroup. + + Args: + raw_layer: XML Element to be parsed to a LayerGroup. + + Returns: + LayerGroup: The LayerGroup created from raw_layer + """ + layers = [] + + for layer in raw_layer.iter(): + if layer.tag in ["layer", "objectgroup", "imagelayer", "group"]: + layers.append(parse(layer, parent_dir=parent_dir)) + + return LayerGroup(layers=layers, **_parse_common(raw_layer).__dict__) + + def parse( raw_layer: etree.Element, parent_dir: Optional[Path] = None, diff --git a/pytiled_parser/parsers/tmx/tiled_map.py b/pytiled_parser/parsers/tmx/tiled_map.py index a744495..c352a25 100644 --- a/pytiled_parser/parsers/tmx/tiled_map.py +++ b/pytiled_parser/parsers/tmx/tiled_map.py @@ -2,8 +2,11 @@ import xml.etree.ElementTree as etree from pathlib import Path from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.parsers.tmx.layer import parse as parse_layer +from pytiled_parser.parsers.tmx.properties import parse as parse_properties from pytiled_parser.parsers.tmx.tileset import parse as parse_tileset from pytiled_parser.tiled_map import TiledMap, TilesetDict +from pytiled_parser.util import parse_color def parse(file: Path) -> TiledMap: @@ -41,10 +44,15 @@ def parse(file: Path) -> TiledMap: raw_tileset, int(raw_tileset.attrib["firstgid"]) ) + layers = [] + for element in raw_map.iter(): + if element.tag in ["layer", "objectgroup", "imagelayer", "group"]: + layers.append(parse_layer(element, parent_dir)) + map_ = TiledMap( map_file=file, infinite=bool(int(raw_map.attrib["infinite"])), - layers=[parse_layer(layer_, parent_dir) for layer_ in raw_tiled_map["layers"]], + layers=layers, map_size=Size(int(raw_map.attrib["width"]), int(raw_map.attrib["height"])), next_layer_id=int(raw_map.attrib["nextlayerid"]), next_object_id=int(raw_map.attrib["nextobjectid"]), @@ -57,3 +65,52 @@ def parse(file: Path) -> TiledMap: tilesets=tilesets, version=raw_map.attrib["version"], ) + + layers = [layer for layer in map_.layers if hasattr(layer, "tiled_objects")] + + for my_layer in layers: + for tiled_object in my_layer.tiled_objects: + if hasattr(tiled_object, "new_tileset"): + if tiled_object.new_tileset: + already_loaded = None + for val in map_.tilesets.values(): + if val.name == tiled_object.new_tileset["name"]: + already_loaded = val + break + + if not already_loaded: + highest_firstgid = max(map_.tilesets.keys()) + last_tileset_count = map_.tilesets[highest_firstgid].tile_count + new_firstgid = highest_firstgid + last_tileset_count + map_.tilesets[new_firstgid] = parse_tileset( + tiled_object.new_tileset, + new_firstgid, + tiled_object.new_tileset_path, + ) + tiled_object.gid = tiled_object.gid + (new_firstgid - 1) + + else: + tiled_object.gid = tiled_object.gid + ( + already_loaded.firstgid - 1 + ) + + tiled_object.new_tileset = None + tiled_object.new_tileset_path = None + + if raw_map.attrib.get("backgroundcolor") is not None: + map_.background_color = parse_color(raw_map.attrib["backgroundcolor"]) + + if raw_map.attrib.get("hexsidelength") is not None: + map_.hex_side_length = int(raw_map.attrib["hexsidelength"]) + + properties_element = raw_map.find("./properties") + if properties_element: + map_.properties = parse_properties(properties_element) + + if raw_map.attrib.get("staggeraxis") is not None: + map_.stagger_axis = raw_map.attrib["staggeraxis"] + + if raw_map.attrib.get("staggerindex") is not None: + map_.stagger_index = raw_map.attrib["staggerindex"] + + return map_ diff --git a/pytiled_parser/parsers/tmx/tiled_object.py b/pytiled_parser/parsers/tmx/tiled_object.py new file mode 100644 index 0000000..1cf3430 --- /dev/null +++ b/pytiled_parser/parsers/tmx/tiled_object.py @@ -0,0 +1,275 @@ +import xml.etree.ElementTree as etree +from pathlib import Path +from typing import Callable, Optional + +from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.parsers.tmx.properties import parse as parse_properties +from pytiled_parser.tiled_object import ( + Ellipse, + Point, + Polygon, + Polyline, + Rectangle, + Text, + Tile, + TiledObject, +) +from pytiled_parser.util import parse_color + + +def _parse_common(raw_object: etree.Element) -> TiledObject: + """Create an Object containing all the attributes common to all types of objects. + + Args: + raw_object: XML Element to get common attributes from + + Returns: + Object: The attributes in common of all types of objects + """ + + common = TiledObject( + id=int(raw_object.attrib["id"]), + coordinates=OrderedPair( + float(raw_object.attrib["x"]), float(raw_object.attrib["y"]) + ), + visible=bool(int(raw_object.attrib["visible"])), + size=Size( + float(raw_object.attrib["width"]), float(raw_object.attrib["height"]) + ), + rotation=float(raw_object.attrib["rotation"]), + name=raw_object.attrib["name"], + type=raw_object.attrib["type"], + ) + + properties_element = raw_object.find("./properties") + if properties_element: + common.properties = parse_properties(properties_element) + + return common + + +def _parse_ellipse(raw_object: etree.Element) -> Ellipse: + """Parse the raw object into an Ellipse. + + Args: + raw_object: XML Element to be parsed to an Ellipse + + Returns: + Ellipse: The Ellipse object created from the raw object + """ + return Ellipse(**_parse_common(raw_object).__dict__) + + +def _parse_rectangle(raw_object: etree.Element) -> Rectangle: + """Parse the raw object into a Rectangle. + + Args: + raw_object: XML Element to be parsed to a Rectangle + + Returns: + Rectangle: The Rectangle object created from the raw object + """ + return Rectangle(**_parse_common(raw_object).__dict__) + + +def _parse_point(raw_object: etree.Element) -> Point: + """Parse the raw object into a Point. + + Args: + raw_object: XML Element to be parsed to a Point + + Returns: + Point: The Point object created from the raw object + """ + return Point(**_parse_common(raw_object).__dict__) + + +def _parse_polygon(raw_object: etree.Element) -> Polygon: + """Parse the raw object into a Polygon. + + Args: + raw_object: XML Element to be parsed to a Polygon + + Returns: + Polygon: The Polygon object created from the raw object + """ + polygon = [] + for raw_point in raw_object.attrib["points"].split(" "): + point = raw_point.split(",") + polygon.append(OrderedPair(float(point[0]), float(point[1]))) + + return Polygon(points=polygon, **_parse_common(raw_object).__dict__) + + +def _parse_polyline(raw_object: etree.Element) -> Polyline: + """Parse the raw object into a Polyline. + + Args: + raw_object: Raw object to be parsed to a Polyline + + Returns: + Polyline: The Polyline object created from the raw object + """ + polyline = [] + for raw_point in raw_object.attrib["polyline"].split(" "): + point = raw_point.split(",") + polyline.append(OrderedPair(float(point[0]), float(point[1]))) + + return Polyline(points=polyline, **_parse_common(raw_object).__dict__) + + +def _parse_tile( + raw_object: etree.Element, + new_tileset: Optional[etree.Element] = None, + new_tileset_path: Optional[Path] = None, +) -> Tile: + """Parse the raw object into a Tile. + + Args: + raw_object: XML Element to be parsed to a Tile + + Returns: + Tile: The Tile object created from the raw object + """ + return Tile( + gid=int(raw_object.attrib["gid"]), + new_tileset=new_tileset, + new_tileset_path=new_tileset_path, + **_parse_common(raw_object).__dict__ + ) + + +def _parse_text(raw_object: etree.Element) -> Text: + """Parse the raw object into Text. + + Args: + raw_object: XML Element to be parsed to a Text + + Returns: + Text: The Text object created from the raw object + """ + # required attributes + text = raw_object.text + + if not text: + text = "" + # create base Text object + text_object = Text(text=text, **_parse_common(raw_object).__dict__) + + # optional attributes + if raw_object.attrib.get("color") is not None: + text_object.color = parse_color(raw_object.attrib["color"]) + + if raw_object.attrib.get("fontfamily") is not None: + text_object.font_family = raw_object.attrib["fontfamily"] + + if raw_object.attrib.get("pixelsize") is not None: + text_object.font_size = float(raw_object.attrib["pixelsize"]) + + if raw_object.attrib.get("bold") is not None: + text_object.bold = bool(int(raw_object.attrib["bold"])) + + if raw_object.attrib.get("italic") is not None: + text_object.italic = bool(int(raw_object.attrib["italic"])) + + if raw_object.attrib.get("kerning") is not None: + text_object.kerning = bool(int(raw_object.attrib["kerning"])) + + if raw_object.attrib.get("strikeout") is not None: + text_object.strike_out = bool(int(raw_object.attrib["strikeout"])) + + if raw_object.attrib.get("underline") is not None: + text_object.underline = bool(int(raw_object.attrib["underline"])) + + if raw_object.attrib.get("halign") is not None: + text_object.horizontal_align = raw_object.attrib["halign"] + + if raw_object.attrib.get("valign") is not None: + text_object.vertical_align = raw_object.attrib["valign"] + + if raw_object.attrib.get("wrap") is not None: + text_object.wrap = bool(int(raw_object.attrib["wrap"])) + + return text_object + + +def _get_parser(raw_object: etree.Element) -> Callable[[etree.Element], TiledObject]: + """Get the parser function for a given raw object. + + Only used internally by the TMX parser. + + Args: + raw_object: XML Element that is analyzed to determine the parser function. + + Returns: + Callable[[Element], Object]: The parser function. + """ + if raw_object.find("./ellipse"): + return _parse_ellipse + + if raw_object.find("./point"): + return _parse_point + + if raw_object.attrib.get("gid"): + # Only tile objects have the `gid` attribute + return _parse_tile + + if raw_object.find("./polygon"): + return _parse_polygon + + if raw_object.find("./polyline"): + return _parse_polyline + + if raw_object.find("./text"): + return _parse_text + + # If it's none of the above, rectangle is the only one left. + # Rectangle is the only object which has no properties to signify that. + return _parse_rectangle + + +def parse(raw_object: etree.Element, parent_dir: Optional[Path] = None) -> TiledObject: + """Parse the raw object into a pytiled_parser version + + Args: + raw_object: XML Element that is to be parsed. + parent_dir: The parent directory that the map file is in. + + Returns: + TiledObject: A parsed Object. + + Raises: + RuntimeError: When a parameter that is conditionally required was not sent. + """ + new_tileset = None + new_tileset_path = None + + if raw_object.attrib.get("template"): + if not parent_dir: + raise RuntimeError( + "A parent directory must be specified when using object templates." + ) + template_path = Path(parent_dir / raw_object.attrib["template"]) + with open(template_path) as template_file: + template = etree.parse(template_file).getroot() + + tileset_element = template.find("./tileset") + if tileset_element: + tileset_path = Path( + template_path.parent / tileset_element.attrib["source"] + ) + with open(tileset_path) as tileset_file: + new_tileset = etree.parse(tileset_file).getroot() + new_tileset_path = tileset_path.parent + + new_object = template.find("./object") + if raw_object.attrib.get("id") and new_object: + new_object.attrib["id"] = raw_object.attrib["id"] + + if new_object: + raw_object = new_object + + if raw_object.attrib.get("gid"): + return _parse_tile(raw_object, new_tileset, new_tileset_path) + + return _get_parser(raw_object)(raw_object) diff --git a/pytiled_parser/parsers/tmx/tileset.py b/pytiled_parser/parsers/tmx/tileset.py index 3a123f7..cdc2e85 100644 --- a/pytiled_parser/parsers/tmx/tileset.py +++ b/pytiled_parser/parsers/tmx/tileset.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Optional from pytiled_parser.common_types import OrderedPair +from pytiled_parser.parsers.tmx.layer import parse as parse_layer from pytiled_parser.parsers.tmx.properties import parse as parse_properties from pytiled_parser.parsers.tmx.wang_set import parse as parse_wangset from pytiled_parser.tileset import Frame, Grid, Tile, Tileset, Transformations @@ -83,6 +84,10 @@ def _parse_tile(raw_tile: etree.Element, external_path: Optional[Path] = None) - for raw_frame in animation_element.findall("./frame"): tile.animation.append(_parse_frame(raw_frame)) + object_element = raw_tile.find("./objectgroup") + if object_element: + tile.objects = parse_layer(object_element) + properties_element = raw_tile.find("./properties") if properties_element: tile.properties = parse_properties(properties_element) diff --git a/pytiled_parser/tiled_object.py b/pytiled_parser/tiled_object.py index 17de14e..db5c6fc 100644 --- a/pytiled_parser/tiled_object.py +++ b/pytiled_parser/tiled_object.py @@ -1,6 +1,7 @@ # pylint: disable=too-few-public-methods +import xml.etree.ElementTree as etree from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union import attr @@ -145,5 +146,5 @@ class Tile(TiledObject): """ gid: int - new_tileset: Optional[Dict[str, Any]] = None + new_tileset: Optional[Union[etree.Element, Dict[str, Any]]] = None new_tileset_path: Optional[Path] = None