diff --git a/CHANGELOG.md b/CHANGELOG.md index 73470283..8cb8dbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -## [Unreleased] +## [2.0.0] - 2021-12-21 + +Welcome to pytiled-parser 2.0! A lot has changed under the hood with this release that has enabled a slew of new features and abilities. Most of the changes here are under the hood, and there is only really one major API change to be aware of. However the under the hood changes and the new features they've enabled are significant enough to call this a major release. + +The entire pytiled-parser API has been abstracted to a common interface, and the parsing functionality completely +seperated from it. This means that we are able to implement parsers for different formats, and enable cross-loading between formats. + +With the release of 2.0, we have added full support for the TMX spec. Meaning you can once again load TMX maps, TSX tilesets, and TX templates with pytiled-parser, just like the pre 1.0 days, except now we have 100% coverage of the spec, and it's behind the same 1.0 API interface you've come to know and love. + +If you're already using pytiled-parser, chances are you don't need to do anything other than upgrade to enable TMX support. The `parse_map` function still works exactly the same, but will now auto-analyze the file given to it and determine what format it is, and choose the parser accordingly. The same will happen for any tilesets that get loaded during this. Meaning you can load JSON tilesets in a TMX map, and TSX tilesets in a JSON map, with no extra configuration. + +The only thing that can't currently be cross-loaded between formats is object templates, if you're using a JSON object template, it will need to be within a JSON map, same for TMX. A `NotImplementedError` will be raised with an appropriate message if you try to cross-load these. Support is planned for this in likely 2.1.0. + +The only API change to be worried about here is related to World file loading. Previously in pytiled-parser if you loaded a World file, it would also parse all maps associated with the world. This is not great behavior, as the intention of worlds is to be able to load and unload maps on the fly. With the previous setup, if you had a large world, then every single map would be loaded into memory at startup and is generally not the behavior you'd want if using world files. + +To remedy this, the `WorldMap.map_file` attribute has been added to store a `pathlib.Path` to the map file. The previous API had a `WorldMap.tiled_map` attribute which was the fully parsed `pytiled_parser.TiledMap` map object. ## [1.5.4] - 2021-10-12 diff --git a/pytiled_parser/__init__.py b/pytiled_parser/__init__.py index 46255ba6..f37e2ae3 100644 --- a/pytiled_parser/__init__.py +++ b/pytiled_parser/__init__.py @@ -12,8 +12,10 @@ # pylint: disable=too-few-public-methods from .common_types import OrderedPair, Size +from .exception import UnknownFormat from .layer import ImageLayer, Layer, LayerGroup, ObjectLayer, TileLayer +from .parser import parse_map from .properties import Properties -from .tiled_map import TiledMap, parse_map +from .tiled_map import TiledMap from .tileset import Tile, Tileset from .version import __version__ diff --git a/pytiled_parser/exception.py b/pytiled_parser/exception.py new file mode 100644 index 00000000..8d753547 --- /dev/null +++ b/pytiled_parser/exception.py @@ -0,0 +1,2 @@ +class UnknownFormat(Exception): + pass diff --git a/pytiled_parser/layer.py b/pytiled_parser/layer.py index a0237b2a..a2cf80b4 100644 --- a/pytiled_parser/layer.py +++ b/pytiled_parser/layer.py @@ -8,27 +8,14 @@ # pylint: disable=too-few-public-methods -import base64 -import gzip -import importlib.util -import zlib from pathlib import Path -from typing import Any, List, Optional, Union -from typing import cast as type_cast +from typing import List, Optional, Union import attr -from typing_extensions import TypedDict -from . import properties as properties_ -from . import tiled_object -from .common_types import Color, OrderedPair, Size -from .util import parse_color - -zstd_spec = importlib.util.find_spec("zstd") -if zstd_spec: - import zstd # pylint: disable=import-outside-toplevel -else: - zstd = None # pylint: disable=invalid-name +from pytiled_parser.common_types import Color, OrderedPair, Size +from pytiled_parser.properties import Properties +from pytiled_parser.tiled_object import TiledObject @attr.s(auto_attribs=True, kw_only=True) @@ -51,8 +38,8 @@ class Layer: """ name: str - opacity: float - visible: bool + opacity: float = 1 + visible: bool = True coordinates: OrderedPair = OrderedPair(0, 0) parallax_factor: OrderedPair = OrderedPair(1, 1) @@ -60,7 +47,7 @@ class Layer: id: Optional[int] = None size: Optional[Size] = None - properties: Optional[properties_.Properties] = None + properties: Optional[Properties] = None tint_color: Optional[Color] = None @@ -127,7 +114,7 @@ class ObjectLayer(Layer): for more info. """ - tiled_objects: List[tiled_object.TiledObject] + tiled_objects: List[TiledObject] draw_order: Optional[str] = "topdown" @@ -162,341 +149,3 @@ class LayerGroup(Layer): """ layers: Optional[List[Layer]] - - -class RawChunk(TypedDict): - """The keys and their types that appear in a Chunk JSON Object. - - See: https://doc.mapeditor.org/en/stable/reference/json-map-format/#chunk - """ - - data: Union[List[int], str] - height: int - width: int - x: int - y: int - - -class RawLayer(TypedDict): - """The keys and their types that appear in a Layer JSON Object. - - See: https://doc.mapeditor.org/en/stable/reference/json-map-format/#layer - """ - - chunks: List[RawChunk] - compression: str - data: Union[List[int], str] - draworder: str - encoding: str - height: int - id: int - image: str - layers: List[Any] - name: str - objects: List[tiled_object.RawTiledObject] - offsetx: float - offsety: float - parallaxx: float - parallaxy: float - opacity: float - properties: List[properties_.RawProperty] - startx: int - starty: int - tintcolor: str - transparentcolor: str - type: str - visible: bool - width: int - x: int - y: int - - -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 _cast_chunk( - raw_chunk: RawChunk, - encoding: Optional[str] = None, - compression: Optional[str] = None, -) -> Chunk: - """Cast the raw_chunk to a Chunk. - - Args: - raw_chunk: RawChunk to be casted 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) - assert isinstance(raw_chunk["data"], str) - data = _decode_tile_layer_data( - raw_chunk["data"], compression, raw_chunk["width"] - ) - else: - data = _convert_raw_tile_layer_data( - raw_chunk["data"], raw_chunk["width"] # type: ignore - ) - - chunk = Chunk( - coordinates=OrderedPair(raw_chunk["x"], raw_chunk["y"]), - size=Size(raw_chunk["width"], raw_chunk["height"]), - data=data, - ) - - return chunk - - -def _get_common_attributes(raw_layer: RawLayer) -> Layer: - """Create a Layer containing all the attributes common to all layers. - - This is to create the stub Layer object that can then be used to create the actual - specific sub-classes of Layer. - - Args: - raw_layer: Raw Tiled object get common attributes from - - Returns: - Layer: The attributes in common of all layers - """ - common_attributes = Layer( - name=raw_layer["name"], - opacity=raw_layer["opacity"], - visible=raw_layer["visible"], - ) - - # if startx is present, starty is present - if raw_layer.get("startx") is not None: - common_attributes.coordinates = OrderedPair( - raw_layer["startx"], raw_layer["starty"] - ) - - if raw_layer.get("id") is not None: - common_attributes.id = raw_layer["id"] - - # if either width or height is present, they both are - if raw_layer.get("width") is not None: - common_attributes.size = Size(raw_layer["width"], raw_layer["height"]) - - if raw_layer.get("offsetx") is not None: - common_attributes.offset = OrderedPair( - raw_layer["offsetx"], raw_layer["offsety"] - ) - - if raw_layer.get("properties") is not None: - common_attributes.properties = properties_.cast(raw_layer["properties"]) - - parallax = [1.0, 1.0] - - if raw_layer.get("parallaxx") is not None: - parallax[0] = raw_layer["parallaxx"] - - if raw_layer.get("parallaxy") is not None: - parallax[1] = raw_layer["parallaxy"] - - common_attributes.parallax_factor = OrderedPair(parallax[0], parallax[1]) - - if raw_layer.get("tintcolor") is not None: - common_attributes.tint_color = parse_color(raw_layer["tintcolor"]) - - return common_attributes - - -def _cast_tile_layer(raw_layer: RawLayer) -> TileLayer: - """Cast the raw_layer to a TileLayer. - - Args: - raw_layer: RawLayer to be casted to a TileLayer - - Returns: - TileLayer: The TileLayer created from raw_layer - """ - tile_layer = TileLayer(**_get_common_attributes(raw_layer).__dict__) - - if raw_layer.get("chunks") is not None: - tile_layer.chunks = [] - for chunk in raw_layer["chunks"]: - if raw_layer.get("encoding") is not None: - tile_layer.chunks.append( - _cast_chunk(chunk, raw_layer["encoding"], raw_layer["compression"]) - ) - else: - tile_layer.chunks.append(_cast_chunk(chunk)) - - if raw_layer.get("data") is not None: - if raw_layer.get("encoding") is not None: - tile_layer.data = _decode_tile_layer_data( - data=type_cast(str, raw_layer["data"]), - compression=raw_layer["compression"], - layer_width=raw_layer["width"], - ) - else: - tile_layer.data = _convert_raw_tile_layer_data( - raw_layer["data"], raw_layer["width"] # type: ignore - ) - - return tile_layer - - -def _cast_object_layer( - raw_layer: RawLayer, - parent_dir: Optional[Path] = None, -) -> ObjectLayer: - """Cast the raw_layer to an ObjectLayer. - - Args: - raw_layer: RawLayer to be casted to an ObjectLayer - Returns: - ObjectLayer: The ObjectLayer created from raw_layer - """ - - tiled_objects = [] - for tiled_object_ in raw_layer["objects"]: - tiled_objects.append(tiled_object.cast(tiled_object_, parent_dir)) - - return ObjectLayer( - tiled_objects=tiled_objects, - draw_order=raw_layer["draworder"], - **_get_common_attributes(raw_layer).__dict__, - ) - - -def _cast_image_layer(raw_layer: RawLayer) -> ImageLayer: - """Cast the raw_layer to a ImageLayer. - - Args: - raw_layer: RawLayer to be casted to a ImageLayer - - Returns: - ImageLayer: The ImageLayer created from raw_layer - """ - image_layer = ImageLayer( - image=Path(raw_layer["image"]), **_get_common_attributes(raw_layer).__dict__ - ) - - if raw_layer.get("transparentcolor") is not None: - image_layer.transparent_color = parse_color(raw_layer["transparentcolor"]) - - return image_layer - - -def _cast_group_layer( - raw_layer: RawLayer, parent_dir: Optional[Path] = None -) -> LayerGroup: - """Cast the raw_layer to a LayerGroup. - - Args: - raw_layer: RawLayer to be casted to a LayerGroup - - Returns: - LayerGroup: The LayerGroup created from raw_layer - """ - - layers = [] - - for layer in raw_layer["layers"]: - layers.append(cast(layer, parent_dir=parent_dir)) - - return LayerGroup(layers=layers, **_get_common_attributes(raw_layer).__dict__) - - -def cast( - raw_layer: RawLayer, - parent_dir: Optional[Path] = None, -) -> Layer: - """Cast a raw Tiled layer into a pytiled_parser type. - - This function will determine the type of layer and cast accordingly. - - Args: - raw_layer: Raw layer to be cast. - parent_dir: The parent directory that the map file is in. - - Returns: - Layer: a properly typed Layer. - - Raises: - RuntimeError: For an invalid layer type being provided - """ - type_ = raw_layer["type"] - - if type_ == "objectgroup": - return _cast_object_layer(raw_layer, parent_dir) - elif type_ == "group": - return _cast_group_layer(raw_layer, parent_dir) - elif type_ == "imagelayer": - return _cast_image_layer(raw_layer) - elif type_ == "tilelayer": - return _cast_tile_layer(raw_layer) - - raise RuntimeError(f"An invalid layer type of {type_} was supplied") diff --git a/pytiled_parser/parser.py b/pytiled_parser/parser.py new file mode 100644 index 00000000..9a0a3892 --- /dev/null +++ b/pytiled_parser/parser.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from pytiled_parser import UnknownFormat +from pytiled_parser.parsers.json.tiled_map import parse as json_map_parse +from pytiled_parser.parsers.tmx.tiled_map import parse as tmx_map_parse +from pytiled_parser.tiled_map import TiledMap +from pytiled_parser.util import check_format + + +def parse_map(file: Path) -> TiledMap: + """Parse the raw Tiled map into a pytiled_parser type + + Args: + file: Path to the map file + + Returns: + Tiledmap: a properly typed TiledMap + """ + parser = check_format(file) + + # The type ignores are because mypy for some reaosn thinks those functions return Any + if parser == "tmx": + return tmx_map_parse(file) # type: ignore + elif parser == "json": + return json_map_parse(file) # type: ignore + else: + raise UnknownFormat( + "Unknown Map Format, please use either the TMX or JSON format." + ) diff --git a/pytiled_parser/parsers/json/layer.py b/pytiled_parser/parsers/json/layer.py new file mode 100644 index 00000000..49530454 --- /dev/null +++ b/pytiled_parser/parsers/json/layer.py @@ -0,0 +1,364 @@ +"""Layer parsing for the JSON Map Format. +""" +import base64 +import gzip +import importlib.util +import zlib +from pathlib import Path +from typing import Any, List, Optional, Union, cast + +from typing_extensions import TypedDict + +from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.layer import ( + Chunk, + ImageLayer, + Layer, + LayerGroup, + ObjectLayer, + TileLayer, +) +from pytiled_parser.parsers.json.properties import RawProperty +from pytiled_parser.parsers.json.properties import parse as parse_properties +from pytiled_parser.parsers.json.tiled_object import RawObject +from pytiled_parser.parsers.json.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 + + +class RawChunk(TypedDict): + """The keys and their types that appear in a Tiled JSON Chunk Object. + + Tiled Doc: https://doc.mapeditor.org/en/stable/reference/json-map-format/#chunk + """ + + data: Union[List[int], str] + height: int + width: int + x: int + y: int + + +class RawLayer(TypedDict): + """The keys and their types that appear in a Tiled JSON Layer Object. + + Tiled Doc: https://doc.mapeditor.org/en/stable/reference/json-map-format/#layer + """ + + chunks: List[RawChunk] + compression: str + data: Union[List[int], str] + draworder: str + encoding: str + height: int + id: int + image: str + layers: List[Any] + name: str + objects: List[RawObject] + offsetx: float + offsety: float + parallaxx: float + parallaxy: float + opacity: float + properties: List[RawProperty] + startx: int + starty: int + tintcolor: str + transparentcolor: str + type: str + visible: bool + width: int + x: int + y: int + + +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: RawChunk, + encoding: Optional[str] = None, + compression: Optional[str] = None, +) -> Chunk: + """Parse the raw_chunk to a Chunk. + + Args: + raw_chunk: RawChunk 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) + assert isinstance(raw_chunk["data"], str) + data = _decode_tile_layer_data( + raw_chunk["data"], compression, raw_chunk["width"] + ) + else: + data = _convert_raw_tile_layer_data( + raw_chunk["data"], raw_chunk["width"] # type: ignore + ) + + chunk = Chunk( + coordinates=OrderedPair(raw_chunk["x"], raw_chunk["y"]), + size=Size(raw_chunk["width"], raw_chunk["height"]), + data=data, + ) + + return chunk + + +def _parse_common(raw_layer: RawLayer) -> Layer: + """Create a Layer containing all the attributes common to all layer types. + + This is to create the stub Layer object that can then be used to create the actual + specific sub-classes of Layer. + + Args: + raw_layer: Raw layer get common attributes from + + Returns: + Layer: The attributes in common of all layer types + """ + common = Layer( + name=raw_layer["name"], + opacity=raw_layer["opacity"], + visible=raw_layer["visible"], + ) + + # if startx is present, starty is present + if raw_layer.get("startx") is not None: + common.coordinates = OrderedPair(raw_layer["startx"], raw_layer["starty"]) + + if raw_layer.get("id") is not None: + common.id = raw_layer["id"] + + # if either width or height is present, they both are + if raw_layer.get("width") is not None: + common.size = Size(raw_layer["width"], raw_layer["height"]) + + if raw_layer.get("offsetx") is not None: + common.offset = OrderedPair(raw_layer["offsetx"], raw_layer["offsety"]) + + if raw_layer.get("properties") is not None: + common.properties = parse_properties(raw_layer["properties"]) + + parallax = [1.0, 1.0] + + if raw_layer.get("parallaxx") is not None: + parallax[0] = raw_layer["parallaxx"] + + if raw_layer.get("parallaxy") is not None: + parallax[1] = raw_layer["parallaxy"] + + common.parallax_factor = OrderedPair(parallax[0], parallax[1]) + + if raw_layer.get("tintcolor") is not None: + common.tint_color = parse_color(raw_layer["tintcolor"]) + + return common + + +def _parse_tile_layer(raw_layer: RawLayer) -> TileLayer: + """Parse the raw_layer to a TileLayer. + + Args: + raw_layer: RawLayer to be parsed to a TileLayer. + + Returns: + TileLayer: The TileLayer created from raw_layer + """ + tile_layer = TileLayer(**_parse_common(raw_layer).__dict__) + + if raw_layer.get("chunks") is not None: + tile_layer.chunks = [] + for chunk in raw_layer["chunks"]: + if raw_layer.get("encoding") is not None: + tile_layer.chunks.append( + _parse_chunk(chunk, raw_layer["encoding"], raw_layer["compression"]) + ) + else: + tile_layer.chunks.append(_parse_chunk(chunk)) + + if raw_layer.get("data") is not None: + if raw_layer.get("encoding") is not None: + tile_layer.data = _decode_tile_layer_data( + data=cast(str, raw_layer["data"]), + compression=raw_layer["compression"], + layer_width=raw_layer["width"], + ) + else: + tile_layer.data = _convert_raw_tile_layer_data( + raw_layer["data"], raw_layer["width"] # type: ignore + ) + + return tile_layer + + +def _parse_object_layer( + raw_layer: RawLayer, + parent_dir: Optional[Path] = None, +) -> ObjectLayer: + """Parse the raw_layer to an ObjectLayer. + + Args: + raw_layer: RawLayer to be parsed to an ObjectLayer. + + Returns: + ObjectLayer: The ObjectLayer created from raw_layer + """ + objects = [] + for object_ in raw_layer["objects"]: + objects.append(parse_object(object_, parent_dir)) + + return ObjectLayer( + tiled_objects=objects, + draw_order=raw_layer["draworder"], + **_parse_common(raw_layer).__dict__, + ) + + +def _parse_image_layer(raw_layer: RawLayer) -> ImageLayer: + """Parse the raw_layer to an ImageLayer. + + Args: + raw_layer: RawLayer to be parsed to an ImageLayer. + + Returns: + ImageLayer: The ImageLayer created from raw_layer + """ + image_layer = ImageLayer( + image=Path(raw_layer["image"]), **_parse_common(raw_layer).__dict__ + ) + + if raw_layer.get("transparentcolor") is not None: + image_layer.transparent_color = parse_color(raw_layer["transparentcolor"]) + + return image_layer + + +def _parse_group_layer( + raw_layer: RawLayer, parent_dir: Optional[Path] = None +) -> LayerGroup: + """Parse the raw_layer to a LayerGroup. + + Args: + raw_layer: RawLayer to be parsed to a LayerGroup. + + Returns: + LayerGroup: The LayerGroup created from raw_layer + """ + layers = [] + + for layer in raw_layer["layers"]: + layers.append(parse(layer, parent_dir=parent_dir)) + + return LayerGroup(layers=layers, **_parse_common(raw_layer).__dict__) + + +def parse( + raw_layer: RawLayer, + parent_dir: Optional[Path] = None, +) -> Layer: + """Parse a raw Layer into a pytiled_parser object. + + This function will determine the type of layer and parse accordingly. + + Args: + raw_layer: Raw layer to be parsed. + parent_dir: The parent directory that the map file is in. + + Returns: + Layer: A parsed Layer. + + Raises: + RuntimeError: For an invalid layer type being provided + """ + type_ = raw_layer["type"] + + if type_ == "objectgroup": + return _parse_object_layer(raw_layer, parent_dir) + elif type_ == "group": + return _parse_group_layer(raw_layer, parent_dir) + elif type_ == "imagelayer": + return _parse_image_layer(raw_layer) + elif type_ == "tilelayer": + return _parse_tile_layer(raw_layer) + + raise RuntimeError(f"An invalid layer type of {type_} was supplied") diff --git a/pytiled_parser/parsers/json/properties.py b/pytiled_parser/parsers/json/properties.py new file mode 100644 index 00000000..4e9896f3 --- /dev/null +++ b/pytiled_parser/parsers/json/properties.py @@ -0,0 +1,48 @@ +"""Property parsing for the JSON Map Format +""" + +from pathlib import Path +from typing import List, Union, cast + +from typing_extensions import TypedDict + +from pytiled_parser.properties import Properties, Property +from pytiled_parser.util import parse_color + +RawValue = Union[float, str, bool] + + +class RawProperty(TypedDict): + """The keys and their values that appear in a Tiled JSON Property Object. + + Tiled Docs: https://doc.mapeditor.org/en/stable/reference/json-map-format/#property + """ + + name: str + type: str + value: RawValue + + +def parse(raw_properties: List[RawProperty]) -> Properties: + """Parse a list of `RawProperty` objects into `Properties`. + + Args: + raw_properties: The list of `RawProperty` objects to parse. + + Returns: + Properties: The parsed `Property` objects. + """ + + final: Properties = {} + value: Property + + for raw_property in raw_properties: + if raw_property["type"] == "file": + value = Path(cast(str, raw_property["value"])) + elif raw_property["type"] == "color": + value = parse_color(cast(str, raw_property["value"])) + else: + value = raw_property["value"] + final[raw_property["name"]] = value + + return final diff --git a/pytiled_parser/parsers/json/tiled_map.py b/pytiled_parser/parsers/json/tiled_map.py new file mode 100644 index 00000000..82f6c049 --- /dev/null +++ b/pytiled_parser/parsers/json/tiled_map.py @@ -0,0 +1,170 @@ +import json +import xml.etree.ElementTree as etree +from pathlib import Path +from typing import List, Union, cast + +from typing_extensions import TypedDict + +from pytiled_parser.common_types import Size +from pytiled_parser.exception import UnknownFormat +from pytiled_parser.parsers.json.layer import RawLayer +from pytiled_parser.parsers.json.layer import parse as parse_layer +from pytiled_parser.parsers.json.properties import RawProperty +from pytiled_parser.parsers.json.properties import parse as parse_properties +from pytiled_parser.parsers.json.tileset import RawTileSet +from pytiled_parser.parsers.json.tileset import parse as parse_json_tileset +from pytiled_parser.parsers.tmx.tileset import parse as parse_tmx_tileset +from pytiled_parser.tiled_map import TiledMap, TilesetDict +from pytiled_parser.util import check_format, parse_color + + +class RawTilesetMapping(TypedDict): + + firstgid: int + source: str + + +class RawTiledMap(TypedDict): + """The keys and their types that appear in a Tiled JSON Map Object. + + Tiled Docs: https://doc.mapeditor.org/en/stable/reference/json-map-format/#map + """ + + backgroundcolor: str + compressionlevel: int + height: int + hexsidelength: int + infinite: bool + layers: List[RawLayer] + nextlayerid: int + nextobjectid: int + orientation: str + properties: List[RawProperty] + renderorder: str + staggeraxis: str + staggerindex: str + tiledversion: str + tileheight: int + tilesets: List[RawTilesetMapping] + tilewidth: int + type: str + version: Union[str, float] + width: int + + +def parse(file: Path) -> TiledMap: + """Parse the raw Tiled map into a pytiled_parser type. + + Args: + file: Path to the map file. + + Returns: + TiledMap: A parsed TiledMap. + """ + with open(file) as map_file: + raw_tiled_map = json.load(map_file) + + parent_dir = file.parent + + raw_tilesets: List[Union[RawTileSet, RawTilesetMapping]] = raw_tiled_map["tilesets"] + tilesets: TilesetDict = {} + + for raw_tileset in raw_tilesets: + if raw_tileset.get("source") is not None: + # Is an external Tileset + tileset_path = Path(parent_dir / raw_tileset["source"]) + parser = check_format(tileset_path) + with open(tileset_path) as raw_tileset_file: + if parser == "json": + tilesets[raw_tileset["firstgid"]] = parse_json_tileset( + json.load(raw_tileset_file), + raw_tileset["firstgid"], + external_path=tileset_path.parent, + ) + elif parser == "tmx": + raw_tileset_external = etree.parse(raw_tileset_file).getroot() + tilesets[raw_tileset["firstgid"]] = parse_tmx_tileset( + raw_tileset_external, + raw_tileset["firstgid"], + external_path=tileset_path.parent, + ) + else: + raise UnknownFormat( + "Unkown Tileset format, please use either the TSX or JSON format." + ) + + else: + # Is an embedded Tileset + raw_tileset = cast(RawTileSet, raw_tileset) + tilesets[raw_tileset["firstgid"]] = parse_json_tileset( + raw_tileset, raw_tileset["firstgid"] + ) + + if isinstance(raw_tiled_map["version"], float): + version = str(raw_tiled_map["version"]) + else: + version = raw_tiled_map["version"] + + # `map` is a built-in function + map_ = TiledMap( + map_file=file, + infinite=raw_tiled_map["infinite"], + layers=[parse_layer(layer_, parent_dir) for layer_ in raw_tiled_map["layers"]], + map_size=Size(raw_tiled_map["width"], raw_tiled_map["height"]), + next_layer_id=raw_tiled_map["nextlayerid"], + next_object_id=raw_tiled_map["nextobjectid"], + orientation=raw_tiled_map["orientation"], + render_order=raw_tiled_map["renderorder"], + tiled_version=raw_tiled_map["tiledversion"], + tile_size=Size(raw_tiled_map["tilewidth"], raw_tiled_map["tileheight"]), + tilesets=tilesets, + version=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: # type: ignore + 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_json_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_tiled_map.get("backgroundcolor") is not None: + map_.background_color = parse_color(raw_tiled_map["backgroundcolor"]) + + if raw_tiled_map.get("hexsidelength") is not None: + map_.hex_side_length = raw_tiled_map["hexsidelength"] + + if raw_tiled_map.get("properties") is not None: + map_.properties = parse_properties(raw_tiled_map["properties"]) + + if raw_tiled_map.get("staggeraxis") is not None: + map_.stagger_axis = raw_tiled_map["staggeraxis"] + + if raw_tiled_map.get("staggerindex") is not None: + map_.stagger_index = raw_tiled_map["staggerindex"] + + return map_ diff --git a/pytiled_parser/parsers/json/tiled_object.py b/pytiled_parser/parsers/json/tiled_object.py new file mode 100644 index 00000000..2acc4b30 --- /dev/null +++ b/pytiled_parser/parsers/json/tiled_object.py @@ -0,0 +1,321 @@ +"""Object parsing for the JSON Map Format. +""" +import json +import xml.etree.ElementTree as etree +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from typing_extensions import TypedDict + +from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.parsers.json.properties import RawProperty +from pytiled_parser.parsers.json.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 load_object_template, parse_color + + +class RawText(TypedDict): + """The keys and their types that appear in a Tiled JSON Text Object. + + Tiled Doc: https://doc.mapeditor.org/en/stable/reference/json-map-format/#text-example + """ + + text: str + color: str + + fontfamily: str + pixelsize: float # this is `font_size` in Text + + bold: bool + italic: bool + strikeout: bool + underline: bool + kerning: bool + + halign: str + valign: str + wrap: bool + + +class RawObject(TypedDict): + """The keys and their types that appear in a Tiled JSON Object. + + Tiled Doc: https://doc.mapeditor.org/en/stable/reference/json-map-format/#object + """ + + id: int + gid: int + template: str + x: float + y: float + width: float + height: float + rotation: float + visible: bool + name: str + type: str + properties: List[RawProperty] + ellipse: bool + point: bool + polygon: List[Dict[str, float]] + polyline: List[Dict[str, float]] + text: RawText + + +def _parse_common(raw_object: RawObject) -> TiledObject: + """Create an Object containing all the attributes common to all types of objects. + + Args: + raw_object: Raw object to get common attributes from + + Returns: + Object: The attributes in common of all types of objects + """ + + common = TiledObject( + id=raw_object["id"], + coordinates=OrderedPair(raw_object["x"], raw_object["y"]), + visible=raw_object["visible"], + size=Size(raw_object["width"], raw_object["height"]), + rotation=raw_object["rotation"], + name=raw_object["name"], + type=raw_object["type"], + ) + + if raw_object.get("properties") is not None: + common.properties = parse_properties(raw_object["properties"]) + + return common + + +def _parse_ellipse(raw_object: RawObject) -> Ellipse: + """Parse the raw object into an Ellipse. + + Args: + raw_object: Raw object 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: RawObject) -> Rectangle: + """Parse the raw object into a Rectangle. + + Args: + raw_object: Raw object 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: RawObject) -> Point: + """Parse the raw object into a Point. + + Args: + raw_object: Raw object 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: RawObject) -> Polygon: + """Parse the raw object into a Polygon. + + Args: + raw_object: Raw object to be parsed to a Polygon + + Returns: + Polygon: The Polygon object created from the raw object + """ + polygon = [] + for point in raw_object["polygon"]: + polygon.append(OrderedPair(point["x"], point["y"])) + + return Polygon(points=polygon, **_parse_common(raw_object).__dict__) + + +def _parse_polyline(raw_object: RawObject) -> 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 point in raw_object["polyline"]: + polyline.append(OrderedPair(point["x"], point["y"])) + + return Polyline(points=polyline, **_parse_common(raw_object).__dict__) + + +def _parse_tile( + raw_object: RawObject, + new_tileset: Optional[Dict[str, Any]] = None, + new_tileset_path: Optional[Path] = None, +) -> Tile: + """Parse the raw object into a Tile. + + Args: + raw_object: Raw object to be parsed to a Tile + + Returns: + Tile: The Tile object created from the raw object + """ + gid = raw_object["gid"] + + return Tile( + gid=gid, + new_tileset=new_tileset, + new_tileset_path=new_tileset_path, + **_parse_common(raw_object).__dict__ + ) + + +def _parse_text(raw_object: RawObject) -> Text: + """Parse the raw object into Text. + + Args: + raw_object: Raw object to be parsed to a Text + + Returns: + Text: The Text object created from the raw object + """ + # required attributes + raw_text: RawText = raw_object["text"] + text = raw_text["text"] + + # create base Text object + text_object = Text(text=text, **_parse_common(raw_object).__dict__) + + # optional attributes + if raw_text.get("color") is not None: + text_object.color = parse_color(raw_text["color"]) + + if raw_text.get("fontfamily") is not None: + text_object.font_family = raw_text["fontfamily"] + + if raw_text.get("pixelsize") is not None: + text_object.font_size = raw_text["pixelsize"] + + if raw_text.get("bold") is not None: + text_object.bold = raw_text["bold"] + + if raw_text.get("italic") is not None: + text_object.italic = raw_text["italic"] + + if raw_text.get("kerning") is not None: + text_object.kerning = raw_text["kerning"] + + if raw_text.get("strikeout") is not None: + text_object.strike_out = raw_text["strikeout"] + + if raw_text.get("underline") is not None: + text_object.underline = raw_text["underline"] + + if raw_text.get("halign") is not None: + text_object.horizontal_align = raw_text["halign"] + + if raw_text.get("valign") is not None: + text_object.vertical_align = raw_text["valign"] + + if raw_text.get("wrap") is not None: + text_object.wrap = raw_text["wrap"] + + return text_object + + +def _get_parser(raw_object: RawObject) -> Callable[[RawObject], TiledObject]: + """Get the parser function for a given raw object. + + Only used internally by the JSON parser. + + Args: + raw_object: Raw object that is analyzed to determine the parser function. + + Returns: + Callable[[RawObject], Object]: The parser function. + """ + if raw_object.get("ellipse"): + return _parse_ellipse + + if raw_object.get("point"): + return _parse_point + + if raw_object.get("gid"): + # Only tile objects have the `gid` key + return _parse_tile + + if raw_object.get("polygon"): + return _parse_polygon + + if raw_object.get("polyline"): + return _parse_polyline + + if raw_object.get("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 special properties to signify that. + return _parse_rectangle + + +def parse( + raw_object: RawObject, + parent_dir: Optional[Path] = None, +) -> TiledObject: + """Parse the raw object into a pytiled_parser version + + Args: + raw_object: Raw object that is to be cast. + parent_dir: The parent directory that the map file is in. + + Returns: + Object: 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.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["template"]) + template, new_tileset, new_tileset_path = load_object_template(template_path) + + if isinstance(template, dict): + loaded_template = template["object"] + for key in loaded_template: + if key != "id": + raw_object[key] = loaded_template[key] # type: ignore + elif isinstance(template, etree.Element): + # load the XML object into the JSON object + raise NotImplementedError( + "Loading TMX object templates inside a JSON map is currently not supported, " + "but will be in a future release." + ) + + if raw_object.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/json/tileset.py b/pytiled_parser/parsers/json/tileset.py new file mode 100644 index 00000000..3206bacf --- /dev/null +++ b/pytiled_parser/parsers/json/tileset.py @@ -0,0 +1,272 @@ +from pathlib import Path +from typing import List, Optional, Union + +from typing_extensions import TypedDict + +from pytiled_parser.common_types import OrderedPair +from pytiled_parser.parsers.json.layer import RawLayer +from pytiled_parser.parsers.json.layer import parse as parse_layer +from pytiled_parser.parsers.json.properties import RawProperty +from pytiled_parser.parsers.json.properties import parse as parse_properties +from pytiled_parser.parsers.json.wang_set import RawWangSet +from pytiled_parser.parsers.json.wang_set import parse as parse_wangset +from pytiled_parser.tileset import Frame, Grid, Tile, Tileset, Transformations +from pytiled_parser.util import parse_color + + +class RawFrame(TypedDict): + """The keys and their types that appear in a Frame JSON Object.""" + + duration: int + tileid: int + + +class RawTileOffset(TypedDict): + """The keys and their types that appear in a TileOffset JSON Object.""" + + x: int + y: int + + +class RawTransformations(TypedDict): + """The keys and their types that appear in a Transformations JSON Object.""" + + hflip: bool + vflip: bool + rotate: bool + preferuntransformed: bool + + +class RawTile(TypedDict): + """The keys and their types that appear in a Tile JSON Object.""" + + animation: List[RawFrame] + id: int + image: str + imageheight: int + imagewidth: int + opacity: float + properties: List[RawProperty] + objectgroup: RawLayer + type: str + + +class RawGrid(TypedDict): + """The keys and their types that appear in a Grid JSON Object.""" + + height: int + width: int + orientation: str + + +class RawTileSet(TypedDict): + """The keys and their types that appear in a TileSet JSON Object.""" + + backgroundcolor: str + columns: int + firstgid: int + grid: RawGrid + image: str + imageheight: int + imagewidth: int + margin: int + name: str + properties: List[RawProperty] + source: str + spacing: int + tilecount: int + tiledversion: str + tileheight: int + tileoffset: RawTileOffset + tiles: List[RawTile] + tilewidth: int + transparentcolor: str + transformations: RawTransformations + version: Union[str, float] + wangsets: List[RawWangSet] + + +def _parse_frame(raw_frame: RawFrame) -> Frame: + """Parse the raw_frame to a Frame. + + Args: + raw_frame: RawFrame to be parsed to a Frame + + Returns: + Frame: The Frame created from the raw_frame + """ + + return Frame(duration=raw_frame["duration"], tile_id=raw_frame["tileid"]) + + +def _parse_tile_offset(raw_tile_offset: RawTileOffset) -> OrderedPair: + """Parse the raw_tile_offset to an OrderedPair. + + Args: + raw_tile_offset: RawTileOffset to be parsed to an OrderedPair + + Returns: + OrderedPair: The OrderedPair created from the raw_tile_offset + """ + + return OrderedPair(raw_tile_offset["x"], raw_tile_offset["y"]) + + +def _parse_transformations(raw_transformations: RawTransformations) -> Transformations: + """Parse the raw_transformations to a Transformations object. + + Args: + raw_transformations: RawTransformations to be parsed to a Transformations + + Returns: + Transformations: The Transformations created from the raw_transformations + """ + + return Transformations( + hflip=raw_transformations["hflip"], + vflip=raw_transformations["vflip"], + rotate=raw_transformations["rotate"], + prefer_untransformed=raw_transformations["preferuntransformed"], + ) + + +def _parse_grid(raw_grid: RawGrid) -> Grid: + """Parse the raw_grid to a Grid object. + + Args: + raw_grid: RawGrid to be parsed to a Grid + + Returns: + Grid: The Grid created from the raw_grid + """ + + return Grid( + orientation=raw_grid["orientation"], + width=raw_grid["width"], + height=raw_grid["height"], + ) + + +def _parse_tile(raw_tile: RawTile, external_path: Optional[Path] = None) -> Tile: + """Parse the raw_tile to a Tile object. + + Args: + raw_tile: RawTile to be parsed to a Tile + + Returns: + Tile: The Tile created from the raw_tile + """ + + id_ = raw_tile["id"] + tile = Tile(id=id_) + + if raw_tile.get("animation") is not None: + tile.animation = [] + for frame in raw_tile["animation"]: + tile.animation.append(_parse_frame(frame)) + + if raw_tile.get("objectgroup") is not None: + tile.objects = parse_layer(raw_tile["objectgroup"]) + + if raw_tile.get("properties") is not None: + tile.properties = parse_properties(raw_tile["properties"]) + + if raw_tile.get("image") is not None: + if external_path: + tile.image = Path(external_path / raw_tile["image"]).absolute().resolve() + else: + tile.image = Path(raw_tile["image"]) + + if raw_tile.get("imagewidth") is not None: + tile.image_width = raw_tile["imagewidth"] + + if raw_tile.get("imageheight") is not None: + tile.image_height = raw_tile["imageheight"] + + if raw_tile.get("type") is not None: + tile.type = raw_tile["type"] + + return tile + + +def parse( + raw_tileset: RawTileSet, + firstgid: int, + external_path: Optional[Path] = None, +) -> Tileset: + """Parse the raw tileset into a pytiled_parser type + + Args: + raw_tileset: Raw Tileset to be parsed. + firstgid: GID corresponding the first tile in the set. + external_path: The path to the tileset if it is not an embedded one. + + Returns: + TileSet: a properly typed TileSet. + """ + + tileset = Tileset( + name=raw_tileset["name"], + tile_count=raw_tileset["tilecount"], + tile_width=raw_tileset["tilewidth"], + tile_height=raw_tileset["tileheight"], + columns=raw_tileset["columns"], + spacing=raw_tileset["spacing"], + margin=raw_tileset["margin"], + firstgid=firstgid, + ) + + if raw_tileset.get("version") is not None: + if isinstance(raw_tileset["version"], float): + tileset.version = str(raw_tileset["version"]) + else: + tileset.version = raw_tileset["version"] + + if raw_tileset.get("tiledversion") is not None: + tileset.tiled_version = raw_tileset["tiledversion"] + + if raw_tileset.get("image") is not None: + if external_path: + tileset.image = ( + Path(external_path / raw_tileset["image"]).absolute().resolve() + ) + else: + tileset.image = Path(raw_tileset["image"]) + + if raw_tileset.get("imagewidth") is not None: + tileset.image_width = raw_tileset["imagewidth"] + + if raw_tileset.get("imageheight") is not None: + tileset.image_height = raw_tileset["imageheight"] + + if raw_tileset.get("backgroundcolor") is not None: + tileset.background_color = parse_color(raw_tileset["backgroundcolor"]) + + if raw_tileset.get("tileoffset") is not None: + tileset.tile_offset = _parse_tile_offset(raw_tileset["tileoffset"]) + + if raw_tileset.get("transparentcolor") is not None: + tileset.transparent_color = parse_color(raw_tileset["transparentcolor"]) + + if raw_tileset.get("grid") is not None: + tileset.grid = _parse_grid(raw_tileset["grid"]) + + if raw_tileset.get("properties") is not None: + tileset.properties = parse_properties(raw_tileset["properties"]) + + if raw_tileset.get("tiles") is not None: + tiles = {} + for raw_tile in raw_tileset["tiles"]: + tiles[raw_tile["id"]] = _parse_tile(raw_tile, external_path=external_path) + tileset.tiles = tiles + + if raw_tileset.get("wangsets") is not None: + wangsets = [] + for raw_wangset in raw_tileset["wangsets"]: + wangsets.append(parse_wangset(raw_wangset)) + tileset.wang_sets = wangsets + + if raw_tileset.get("transformations") is not None: + tileset.transformations = _parse_transformations(raw_tileset["transformations"]) + + return tileset diff --git a/pytiled_parser/parsers/json/wang_set.py b/pytiled_parser/parsers/json/wang_set.py new file mode 100644 index 00000000..ea689051 --- /dev/null +++ b/pytiled_parser/parsers/json/wang_set.py @@ -0,0 +1,104 @@ +from typing import List + +from typing_extensions import TypedDict + +from pytiled_parser.parsers.json.properties import RawProperty +from pytiled_parser.parsers.json.properties import parse as parse_properties +from pytiled_parser.util import parse_color +from pytiled_parser.wang_set import WangColor, WangSet, WangTile + + +class RawWangTile(TypedDict): + """The keys and their types that appear in a Wang Tile JSON Object.""" + + tileid: int + # Tiled stores these IDs as a list represented like so: + # [top, top_right, right, bottom_right, bottom, bottom_left, left, top_left] + wangid: List[int] + + +class RawWangColor(TypedDict): + """The keys and their types that appear in a Wang Color JSON Object.""" + + color: str + name: str + probability: float + tile: int + properties: List[RawProperty] + + +class RawWangSet(TypedDict): + """The keys and their types that appear in a Wang Set JSON Object.""" + + colors: List[RawWangColor] + name: str + properties: List[RawProperty] + tile: int + type: str + wangtiles: List[RawWangTile] + + +def _parse_wang_tile(raw_wang_tile: RawWangTile) -> WangTile: + """Parse the raw wang tile into a pytiled_parser type + + Args: + raw_wang_tile: RawWangTile to be parsed. + + Returns: + WangTile: A properly typed WangTile. + """ + return WangTile(tile_id=raw_wang_tile["tileid"], wang_id=raw_wang_tile["wangid"]) + + +def _parse_wang_color(raw_wang_color: RawWangColor) -> WangColor: + """Parse the raw wang color into a pytiled_parser type + + Args: + raw_wang_color: RawWangColor to be parsed. + + Returns: + WangColor: A properly typed WangColor. + """ + wang_color = WangColor( + name=raw_wang_color["name"], + color=parse_color(raw_wang_color["color"]), + tile=raw_wang_color["tile"], + probability=raw_wang_color["probability"], + ) + + if raw_wang_color.get("properties") is not None: + wang_color.properties = parse_properties(raw_wang_color["properties"]) + + return wang_color + + +def parse(raw_wangset: RawWangSet) -> WangSet: + """Parse the raw wangset into a pytiled_parser type + + Args: + raw_wangset: Raw Wangset to be parsed. + + Returns: + WangSet: A properly typed WangSet. + """ + + colors = [] + for raw_wang_color in raw_wangset["colors"]: + colors.append(_parse_wang_color(raw_wang_color)) + + tiles = {} + for raw_wang_tile in raw_wangset["wangtiles"]: + tiles[raw_wang_tile["tileid"]] = _parse_wang_tile(raw_wang_tile) + + wangset = WangSet( + name=raw_wangset["name"], + tile=raw_wangset["tile"], + wang_type=raw_wangset["type"], + wang_colors=colors, + wang_tiles=tiles, + ) + + if raw_wangset.get("properties") is not None: + wangset.properties = parse_properties(raw_wangset["properties"]) + + return wangset diff --git a/pytiled_parser/parsers/tmx/layer.py b/pytiled_parser/parsers/tmx/layer.py new file mode 100644 index 00000000..4ba1fa19 --- /dev/null +++ b/pytiled_parser/parsers/tmx/layer.py @@ -0,0 +1,360 @@ +"""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 List, Optional + +from pytiled_parser.common_types import OrderedPair, Size +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.split(",")], # 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. + + This is to create the stub Layer object that can then be used to create the actual + specific sub-classes of Layer. + + Args: + raw_layer: XML Element to get common attributes from + + Returns: + Layer: The attributes in common of all layer types + """ + if raw_layer.attrib.get("name") is None: + raw_layer.attrib["name"] = "" + + common = Layer( + name=raw_layer.attrib["name"], + ) + + if raw_layer.attrib.get("opacity") is not None: + common.opacity = float(raw_layer.attrib["opacity"]) + + if raw_layer.attrib.get("visible") is not None: + common.visible = bool(int(raw_layer.attrib["visible"])) + + if raw_layer.attrib.get("id") is not None: + common.id = int(raw_layer.attrib["id"]) + + if raw_layer.attrib.get("offsetx") is not None: + common.offset = OrderedPair( + float(raw_layer.attrib["offsetx"]), float(raw_layer.attrib["offsety"]) + ) + + properties_element = raw_layer.find("./properties") + if properties_element is not None: + common.properties = parse_properties(properties_element) + + parallax = [1.0, 1.0] + + if raw_layer.attrib.get("parallaxx") is not None: + parallax[0] = float(raw_layer.attrib["parallaxx"]) + + if raw_layer.attrib.get("parallaxy") is not None: + parallax[1] = float(raw_layer.attrib["parallaxy"]) + + common.parallax_factor = OrderedPair(parallax[0], parallax[1]) + + if raw_layer.attrib.get("tintcolor") is not None: + common.tint_color = parse_color(raw_layer.attrib["tintcolor"]) + + 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 + """ + common = _parse_common(raw_layer).__dict__ + del common["size"] + tile_layer = TileLayer( + size=Size(int(raw_layer.attrib["width"]), int(raw_layer.attrib["height"])), + **common, + ) + + data_element = raw_layer.find("data") + if data_element is not None: + 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 and encoding != "csv": + 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.split(",")], # 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)) + + object_layer = ObjectLayer( + tiled_objects=objects, + **_parse_common(raw_layer).__dict__, + ) + + if raw_layer.attrib.get("draworder") is not None: + object_layer.draw_order = raw_layer.attrib["draworder"] + + return object_layer + + +def _parse_image_layer(raw_layer: etree.Element) -> ImageLayer: + """Parse the raw_layer to an ImageLayer. + + Args: + raw_layer: XML Element to be parsed to an ImageLayer. + + Returns: + ImageLayer: The ImageLayer created from raw_layer + """ + image_element = raw_layer.find("./image") + if image_element is not None: + source = Path(image_element.attrib["source"]) + + transparent_color = None + if image_element.attrib.get("trans") is not None: + transparent_color = parse_color(image_element.attrib["trans"]) + + image_layer = ImageLayer( + image=source, + transparent_color=transparent_color, + **_parse_common(raw_layer).__dict__, + ) + print(image_layer.size) + return image_layer + + 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: List[Layer] = [] + for layer in raw_layer.findall("./layer"): + layers.append(_parse_tile_layer(layer)) + + for layer in raw_layer.findall("./objectgroup"): + layers.append(_parse_object_layer(layer, parent_dir)) + + for layer in raw_layer.findall("./imagelayer"): + layers.append(_parse_image_layer(layer)) + + for layer in raw_layer.findall("./group"): + layers.append(_parse_group_layer(layer, parent_dir)) + # layers = [] + # layers = [ + # parse(child_layer, parent_dir=parent_dir) + # for child_layer in raw_layer.iter() + # if child_layer.tag in ["layer", "objectgroup", "imagelayer", "group"] + # ] + + return LayerGroup(layers=layers, **_parse_common(raw_layer).__dict__) + + +def parse( + raw_layer: etree.Element, + parent_dir: Optional[Path] = None, +) -> Layer: + """Parse a raw Layer into a pytiled_parser object. + + This function will determine the type of layer and parse accordingly. + + Args: + raw_layer: Raw layer to be parsed. + parent_dir: The parent directory that the map file is in. + + Returns: + Layer: A parsed Layer. + + Raises: + RuntimeError: For an invalid layer type being provided + """ + type_ = raw_layer.tag + + if type_ == "objectgroup": + return _parse_object_layer(raw_layer, parent_dir) + elif type_ == "group": + return _parse_group_layer(raw_layer, parent_dir) + elif type_ == "imagelayer": + return _parse_image_layer(raw_layer) + elif type_ == "layer": + return _parse_tile_layer(raw_layer) + + raise RuntimeError(f"An invalid layer type of {type_} was supplied") diff --git a/pytiled_parser/parsers/tmx/properties.py b/pytiled_parser/parsers/tmx/properties.py new file mode 100644 index 00000000..173463b1 --- /dev/null +++ b/pytiled_parser/parsers/tmx/properties.py @@ -0,0 +1,33 @@ +import xml.etree.ElementTree as etree +from pathlib import Path +from typing import List, Union, cast + +from pytiled_parser.properties import Properties, Property +from pytiled_parser.util import parse_color + + +def parse(raw_properties: etree.Element) -> Properties: + + final: Properties = {} + value: Property + + for raw_property in raw_properties.findall("property"): + + type_ = raw_property.attrib.get("type") + value_ = raw_property.attrib["value"] + if type_ == "file": + value = Path(value_) + elif type_ == "color": + value = parse_color(value_) + elif type_ == "int" or type_ == "float": + value = float(value_) + elif type_ == "bool": + if value_ == "true": + value = True + else: + value = False + else: + value = value_ + final[raw_property.attrib["name"]] = value + + return final diff --git a/pytiled_parser/parsers/tmx/tiled_map.py b/pytiled_parser/parsers/tmx/tiled_map.py new file mode 100644 index 00000000..f12c6e21 --- /dev/null +++ b/pytiled_parser/parsers/tmx/tiled_map.py @@ -0,0 +1,132 @@ +import json +import xml.etree.ElementTree as etree +from pathlib import Path + +from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.exception import UnknownFormat +from pytiled_parser.parsers.json.tileset import parse as parse_json_tileset +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_tmx_tileset +from pytiled_parser.tiled_map import TiledMap, TilesetDict +from pytiled_parser.util import check_format, parse_color + + +def parse(file: Path) -> TiledMap: + """Parse the raw Tiled map into a pytiled_parser type. + + Args: + file: Path to the map file. + + Returns: + TiledMap: A parsed TiledMap. + """ + with open(file) as map_file: + raw_map = etree.parse(map_file).getroot() + + parent_dir = file.parent + + raw_tilesets = raw_map.findall("./tileset") + tilesets: TilesetDict = {} + + for raw_tileset in raw_tilesets: + if raw_tileset.attrib.get("source") is not None: + # Is an external Tileset + tileset_path = Path(parent_dir / raw_tileset.attrib["source"]) + parser = check_format(tileset_path) + with open(tileset_path) as tileset_file: + if parser == "tmx": + raw_tileset_external = etree.parse(tileset_file).getroot() + tilesets[int(raw_tileset.attrib["firstgid"])] = parse_tmx_tileset( + raw_tileset_external, + int(raw_tileset.attrib["firstgid"]), + external_path=tileset_path.parent, + ) + elif parser == "json": + tilesets[int(raw_tileset.attrib["firstgid"])] = parse_json_tileset( + json.load(tileset_file), + int(raw_tileset.attrib["firstgid"]), + external_path=tileset_path.parent, + ) + else: + raise UnknownFormat( + "Unkown Tileset format, please use either the TSX or JSON format." + ) + + else: + # Is an embedded Tileset + tilesets[int(raw_tileset.attrib["firstgid"])] = parse_tmx_tileset( + 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=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"]), + orientation=raw_map.attrib["orientation"], + render_order=raw_map.attrib["renderorder"], + tiled_version=raw_map.attrib["tiledversion"], + tile_size=Size( + int(raw_map.attrib["tilewidth"]), int(raw_map.attrib["tileheight"]) + ), + 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 is not None: + already_loaded = None + for val in map_.tilesets.values(): + if val.name == tiled_object.new_tileset.attrib["name"]: + already_loaded = val + break + + if not already_loaded: + print("here") + 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_tmx_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 00000000..ceb4e08f --- /dev/null +++ b/pytiled_parser/parsers/tmx/tiled_object.py @@ -0,0 +1,293 @@ +import json +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 load_object_template, 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"]) + ), + ) + + if raw_object.attrib.get("width") is not None: + common.size = Size( + float(raw_object.attrib["width"]), float(raw_object.attrib["height"]) + ) + + if raw_object.attrib.get("visible") is not None: + common.visible = bool(int(raw_object.attrib["visible"])) + + if raw_object.attrib.get("rotation") is not None: + common.rotation = float(raw_object.attrib["rotation"]) + + if raw_object.attrib.get("name") is not None: + common.name = raw_object.attrib["name"] + + if raw_object.attrib.get("type") is not None: + common.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 = [] + polygon_element = raw_object.find("./polygon") + if polygon_element is not None: + for raw_point in polygon_element.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 = [] + polyline_element = raw_object.find("./polyline") + if polyline_element is not None: + for raw_point in polyline_element.attrib["points"].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_element = raw_object.find("./text") + + if text_element is not None: + text = text_element.text + + if not text: + text = "" + # create base Text object + text_object = Text(text=text, **_parse_common(raw_object).__dict__) + + # optional attributes + + if text_element.attrib.get("color") is not None: + text_object.color = parse_color(text_element.attrib["color"]) + + if text_element.attrib.get("fontfamily") is not None: + text_object.font_family = text_element.attrib["fontfamily"] + + if text_element.attrib.get("pixelsize") is not None: + text_object.font_size = float(text_element.attrib["pixelsize"]) + + if text_element.attrib.get("bold") is not None: + text_object.bold = bool(int(text_element.attrib["bold"])) + + if text_element.attrib.get("italic") is not None: + text_object.italic = bool(int(text_element.attrib["italic"])) + + if text_element.attrib.get("kerning") is not None: + text_object.kerning = bool(int(text_element.attrib["kerning"])) + + if text_element.attrib.get("strikeout") is not None: + text_object.strike_out = bool(int(text_element.attrib["strikeout"])) + + if text_element.attrib.get("underline") is not None: + text_object.underline = bool(int(text_element.attrib["underline"])) + + if text_element.attrib.get("halign") is not None: + text_object.horizontal_align = text_element.attrib["halign"] + + if text_element.attrib.get("valign") is not None: + text_object.vertical_align = text_element.attrib["valign"] + + if text_element.attrib.get("wrap") is not None: + text_object.wrap = bool(int(text_element.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") is not None: + return _parse_ellipse + + if raw_object.find("./point") is not None: + return _parse_point + + if raw_object.find("./polygon") is not None: + return _parse_polygon + + if raw_object.find("./polyline") is not None: + return _parse_polyline + + if raw_object.find("./text") is not None: + 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"]) + template, new_tileset, new_tileset_path = load_object_template(template_path) + + if isinstance(template, etree.Element): + new_object = template.find("./object") + if new_object is not None: + if raw_object.attrib.get("id") is not None: + new_object.attrib["id"] = raw_object.attrib["id"] + + if raw_object.attrib.get("x") is not None: + new_object.attrib["x"] = raw_object.attrib["x"] + + if raw_object.attrib.get("y") is not None: + new_object.attrib["y"] = raw_object.attrib["y"] + + raw_object = new_object + elif isinstance(template, dict): + # load the JSON object into the XML object + raise NotImplementedError( + "Loading JSON object templates inside a TMX map is currently not supported, " + "but will be in a future release." + ) + + 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 new file mode 100644 index 00000000..712d1cfa --- /dev/null +++ b/pytiled_parser/parsers/tmx/tileset.py @@ -0,0 +1,194 @@ +import xml.etree.ElementTree as etree +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 +from pytiled_parser.util import parse_color + + +def _parse_frame(raw_frame: etree.Element) -> Frame: + """Parse the raw_frame to a Frame object. + + Args: + raw_frame: XML Element to be parsed to a Frame + + Returns: + Frame: The Frame created from the raw_frame + """ + + return Frame( + duration=int(raw_frame.attrib["duration"]), + tile_id=int(raw_frame.attrib["tileid"]), + ) + + +def _parse_grid(raw_grid: etree.Element) -> Grid: + """Parse the raw_grid to a Grid object. + + Args: + raw_grid: XML Element to be parsed to a Grid + + Returns: + Grid: The Grid created from the raw_grid + """ + + return Grid( + orientation=raw_grid.attrib["orientation"], + width=int(raw_grid.attrib["width"]), + height=int(raw_grid.attrib["height"]), + ) + + +def _parse_transformations(raw_transformations: etree.Element) -> Transformations: + """Parse the raw_transformations to a Transformations object. + + Args: + raw_transformations: XML Element to be parsed to a Transformations + + Returns: + Transformations: The Transformations created from the raw_transformations + """ + + return Transformations( + hflip=bool(int(raw_transformations.attrib["hflip"])), + vflip=bool(int(raw_transformations.attrib["vflip"])), + rotate=bool(int(raw_transformations.attrib["rotate"])), + prefer_untransformed=bool( + int(raw_transformations.attrib["preferuntransformed"]) + ), + ) + + +def _parse_tile(raw_tile: etree.Element, external_path: Optional[Path] = None) -> Tile: + """Parse the raw_tile to a Tile object. + + Args: + raw_tile: XML Element to be parsed to a Tile + + Returns: + Tile: The Tile created from the raw_tile + """ + + tile = Tile(id=int(raw_tile.attrib["id"])) + + if raw_tile.attrib.get("type") is not None: + tile.type = raw_tile.attrib["type"] + + animation_element = raw_tile.find("./animation") + if animation_element is not None: + tile.animation = [] + for raw_frame in animation_element.findall("./frame"): + tile.animation.append(_parse_frame(raw_frame)) + + object_element = raw_tile.find("./objectgroup") + if object_element is not None: + tile.objects = parse_layer(object_element) + + properties_element = raw_tile.find("./properties") + if properties_element is not None: + tile.properties = parse_properties(properties_element) + + image_element = raw_tile.find("./image") + if image_element is not None: + if external_path: + tile.image = ( + Path(external_path / image_element.attrib["source"]) + .absolute() + .resolve() + ) + else: + tile.image = Path(image_element.attrib["source"]) + + tile.image_width = int(image_element.attrib["width"]) + tile.image_height = int(image_element.attrib["height"]) + + return tile + + +def parse( + raw_tileset: etree.Element, + firstgid: int, + external_path: Optional[Path] = None, +) -> Tileset: + tileset = Tileset( + name=raw_tileset.attrib["name"], + tile_count=int(raw_tileset.attrib["tilecount"]), + tile_width=int(raw_tileset.attrib["tilewidth"]), + tile_height=int(raw_tileset.attrib["tileheight"]), + columns=int(raw_tileset.attrib["columns"]), + firstgid=firstgid, + ) + + if raw_tileset.attrib.get("version") is not None: + tileset.version = raw_tileset.attrib["version"] + + if raw_tileset.attrib.get("tiledversion") is not None: + tileset.tiled_version = raw_tileset.attrib["tiledversion"] + + if raw_tileset.attrib.get("backgroundcolor") is not None: + tileset.background_color = parse_color(raw_tileset.attrib["backgroundcolor"]) + + if raw_tileset.attrib.get("spacing") is not None: + tileset.spacing = int(raw_tileset.attrib["spacing"]) + + if raw_tileset.attrib.get("margin") is not None: + tileset.margin = int(raw_tileset.attrib["margin"]) + + image_element = raw_tileset.find("image") + if image_element is not None: + if external_path: + tileset.image = ( + Path(external_path / image_element.attrib["source"]) + .absolute() + .resolve() + ) + else: + tileset.image = Path(image_element.attrib["source"]) + + tileset.image_width = int(image_element.attrib["width"]) + tileset.image_height = int(image_element.attrib["height"]) + + if image_element.attrib.get("trans") is not None: + my_string = image_element.attrib["trans"] + if my_string[0] != "#": + my_string = f"#{my_string}" + tileset.transparent_color = parse_color(my_string) + + tileoffset_element = raw_tileset.find("./tileoffset") + if tileoffset_element is not None: + tileset.tile_offset = OrderedPair( + int(tileoffset_element.attrib["x"]), int(tileoffset_element.attrib["y"]) + ) + + grid_element = raw_tileset.find("./grid") + if grid_element is not None: + tileset.grid = _parse_grid(grid_element) + + properties_element = raw_tileset.find("./properties") + if properties_element is not None: + tileset.properties = parse_properties(properties_element) + + tiles = {} + for tile_element in raw_tileset.findall("./tile"): + tiles[int(tile_element.attrib["id"])] = _parse_tile( + tile_element, external_path=external_path + ) + if tiles: + tileset.tiles = tiles + + wangsets_element = raw_tileset.find("./wangsets") + if wangsets_element is not None: + wangsets = [] + for raw_wangset in wangsets_element.findall("./wangset"): + wangsets.append(parse_wangset(raw_wangset)) + tileset.wang_sets = wangsets + + transformations_element = raw_tileset.find("./transformations") + if transformations_element is not None: + tileset.transformations = _parse_transformations(transformations_element) + + return tileset diff --git a/pytiled_parser/parsers/tmx/wang_set.py b/pytiled_parser/parsers/tmx/wang_set.py new file mode 100644 index 00000000..b1672262 --- /dev/null +++ b/pytiled_parser/parsers/tmx/wang_set.py @@ -0,0 +1,74 @@ +import xml.etree.ElementTree as etree + +from pytiled_parser.parsers.tmx.properties import parse as parse_properties +from pytiled_parser.util import parse_color +from pytiled_parser.wang_set import WangColor, WangSet, WangTile + + +def _parse_wang_tile(raw_wang_tile: etree.Element) -> WangTile: + """Parse the raw wang tile into a pytiled_parser type + + Args: + raw_wang_tile: XML Element to be parsed. + + Returns: + WangTile: A properly typed WangTile. + """ + ids = [int(v.strip()) for v in raw_wang_tile.attrib["wangid"].split(",")] + return WangTile(tile_id=int(raw_wang_tile.attrib["tileid"]), wang_id=ids) + + +def _parse_wang_color(raw_wang_color: etree.Element) -> WangColor: + """Parse the raw wang color into a pytiled_parser type + + Args: + raw_wang_color: XML Element to be parsed. + + Returns: + WangColor: A properly typed WangColor. + """ + wang_color = WangColor( + name=raw_wang_color.attrib["name"], + color=parse_color(raw_wang_color.attrib["color"]), + tile=int(raw_wang_color.attrib["tile"]), + probability=float(raw_wang_color.attrib["probability"]), + ) + + properties = raw_wang_color.find("./properties") + if properties: + wang_color.properties = parse_properties(properties) + + return wang_color + + +def parse(raw_wangset: etree.Element) -> WangSet: + """Parse the raw wangset into a pytiled_parser type + + Args: + raw_wangset: XML Element to be parsed. + + Returns: + WangSet: A properly typed WangSet. + """ + + colors = [] + for raw_wang_color in raw_wangset.findall("./wangcolor"): + colors.append(_parse_wang_color(raw_wang_color)) + + tiles = {} + for raw_wang_tile in raw_wangset.findall("./wangtile"): + tiles[int(raw_wang_tile.attrib["tileid"])] = _parse_wang_tile(raw_wang_tile) + + wangset = WangSet( + name=raw_wangset.attrib["name"], + tile=int(raw_wangset.attrib["tile"]), + wang_type=raw_wangset.attrib["type"], + wang_colors=colors, + wang_tiles=tiles, + ) + + properties = raw_wangset.find("./properties") + if properties: + wangset.properties = parse_properties(properties) + + return wangset diff --git a/pytiled_parser/properties.py b/pytiled_parser/properties.py index e9b87b5a..f8bc0acf 100644 --- a/pytiled_parser/properties.py +++ b/pytiled_parser/properties.py @@ -1,55 +1,18 @@ """Properties Module -This module casts raw properties from Tiled maps into a dictionary of -properly typed Properties. +This module defines types for Property objects. +For more about properties in Tiled maps see the below link: +https://doc.mapeditor.org/en/stable/manual/custom-properties/ + +The types defined in this module get added to other objects +such as Layers, Maps, Objects, etc """ from pathlib import Path -from typing import Dict, List, Union -from typing import cast as type_cast - -from typing_extensions import TypedDict +from typing import Dict, Union from .common_types import Color -from .util import parse_color Property = Union[float, Path, str, bool, Color] - Properties = Dict[str, Property] - - -RawValue = Union[float, str, bool] - - -class RawProperty(TypedDict): - """A dictionary of raw properties.""" - - name: str - type: str - value: RawValue - - -def cast(raw_properties: List[RawProperty]) -> Properties: - """Cast a list of `RawProperty`s into `Properties` - - Args: - raw_properties: The list of `RawProperty`s to cast. - - Returns: - Properties: The casted `Properties`. - """ - - final: Properties = {} - value: Property - - for property_ in raw_properties: - if property_["type"] == "file": - value = Path(type_cast(str, property_["value"])) - elif property_["type"] == "color": - value = parse_color(type_cast(str, property_["value"])) - else: - value = property_["value"] - final[property_["name"]] = value - - return final diff --git a/pytiled_parser/tiled_map.py b/pytiled_parser/tiled_map.py index 1f568851..9c1c1ba9 100644 --- a/pytiled_parser/tiled_map.py +++ b/pytiled_parser/tiled_map.py @@ -1,19 +1,12 @@ -# pylint: disable=too-few-public-methods - -import json from pathlib import Path -from typing import Dict, List, Optional, Union -from typing import cast as typing_cast +from typing import Dict, List, Optional import attr -from typing_extensions import TypedDict -from . import layer, properties, tileset -from .common_types import Color, Size -from .layer import Layer, RawLayer -from .properties import Properties, RawProperty -from .tileset import RawTileSet, Tileset -from .util import parse_color +from pytiled_parser.common_types import Color, Size +from pytiled_parser.layer import Layer +from pytiled_parser.properties import Properties +from pytiled_parser.tileset import Tileset TilesetDict = Dict[int, Tileset] @@ -68,146 +61,3 @@ class TiledMap: hex_side_length: Optional[int] = None stagger_axis: Optional[str] = None stagger_index: Optional[str] = None - - -class _RawTilesetMapping(TypedDict): - """ The way that tilesets are stored in the Tiled JSON formatted map.""" - - firstgid: int - source: str - - -class _RawTiledMap(TypedDict): - """The keys and their types that appear in a Tiled JSON Map. - - Keys: - compressionlevel: not documented - https://github.com/bjorn/tiled/issues/2815 - """ - - backgroundcolor: str - compressionlevel: int - height: int - hexsidelength: int - infinite: bool - layers: List[RawLayer] - nextlayerid: int - nextobjectid: int - orientation: str - properties: List[RawProperty] - renderorder: str - staggeraxis: str - staggerindex: str - tiledversion: str - tileheight: int - tilesets: List[_RawTilesetMapping] - tilewidth: int - type: str - version: Union[str, float] - width: int - - -def parse_map(file: Path) -> TiledMap: - """Parse the raw Tiled map into a pytiled_parser type - - Args: - file: Path to the map's JSON file - - Returns: - TileSet: a properly typed TileSet. - """ - - with open(file) as map_file: - raw_tiled_map = json.load(map_file) - - parent_dir = file.parent - - raw_tilesets: List[Union[RawTileSet, _RawTilesetMapping]] = raw_tiled_map[ - "tilesets" - ] - tilesets: TilesetDict = {} - - for raw_tileset in raw_tilesets: - if raw_tileset.get("source") is not None: - # Is an external Tileset - tileset_path = Path(parent_dir / raw_tileset["source"]) - with open(tileset_path) as raw_tileset_file: - tilesets[raw_tileset["firstgid"]] = tileset.cast( - json.load(raw_tileset_file), - raw_tileset["firstgid"], - external_path=tileset_path.parent, - ) - else: - # Is an embedded Tileset - raw_tileset = typing_cast(RawTileSet, raw_tileset) - tilesets[raw_tileset["firstgid"]] = tileset.cast( - raw_tileset, raw_tileset["firstgid"] - ) - - if isinstance(raw_tiled_map["version"], float): - version = str(raw_tiled_map["version"]) - else: - version = raw_tiled_map["version"] - - # `map` is a built-in function - map_ = TiledMap( - map_file=file, - infinite=raw_tiled_map["infinite"], - layers=[layer.cast(layer_, parent_dir) for layer_ in raw_tiled_map["layers"]], - map_size=Size(raw_tiled_map["width"], raw_tiled_map["height"]), - next_layer_id=raw_tiled_map["nextlayerid"], - next_object_id=raw_tiled_map["nextobjectid"], - orientation=raw_tiled_map["orientation"], - render_order=raw_tiled_map["renderorder"], - tiled_version=raw_tiled_map["tiledversion"], - tile_size=Size(raw_tiled_map["tilewidth"], raw_tiled_map["tileheight"]), - tilesets=tilesets, - version=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: # type: ignore - 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] = tileset.cast( - 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_tiled_map.get("backgroundcolor") is not None: - map_.background_color = parse_color(raw_tiled_map["backgroundcolor"]) - - if raw_tiled_map.get("hexsidelength") is not None: - map_.hex_side_length = raw_tiled_map["hexsidelength"] - - if raw_tiled_map.get("properties") is not None: - map_.properties = properties.cast(raw_tiled_map["properties"]) - - if raw_tiled_map.get("staggeraxis") is not None: - map_.stagger_axis = raw_tiled_map["staggeraxis"] - - if raw_tiled_map.get("staggerindex") is not None: - map_.stagger_index = raw_tiled_map["staggerindex"] - - return map_ diff --git a/pytiled_parser/tiled_object.py b/pytiled_parser/tiled_object.py index 3d167d0d..a96d65a7 100644 --- a/pytiled_parser/tiled_object.py +++ b/pytiled_parser/tiled_object.py @@ -1,14 +1,12 @@ # pylint: disable=too-few-public-methods -import json +import xml.etree.ElementTree as etree from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union import attr -from typing_extensions import TypedDict from . import properties as properties_ from .common_types import Color, OrderedPair, Size -from .util import parse_color @attr.s(auto_attribs=True, kw_only=True) @@ -37,10 +35,9 @@ class TiledObject: coordinates: OrderedPair size: Size = Size(0, 0) rotation: float = 0 - visible: bool - - name: Optional[str] = None - type: Optional[str] = None + visible: bool = True + name: str = "" + type: str = "" properties: properties_.Properties = {} @@ -148,302 +145,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 - - -class RawTextDict(TypedDict): - """ The keys and their types that appear in a Text JSON Object.""" - - text: str - color: str - - fontfamily: str - pixelsize: float # this is `font_size` in Text - - bold: bool - italic: bool - strikeout: bool - underline: bool - kerning: bool - - halign: str - valign: str - wrap: bool - - -class RawTiledObject(TypedDict): - """ The keys and their types that appear in a Tiled JSON Object.""" - - id: int - gid: int - template: str - x: float - y: float - width: float - height: float - rotation: float - visible: bool - name: str - type: str - properties: List[properties_.RawProperty] - ellipse: bool - point: bool - polygon: List[Dict[str, float]] - polyline: List[Dict[str, float]] - text: Dict[str, Union[float, str]] - - -RawTiledObjects = List[RawTiledObject] - - -def _get_common_attributes(raw_tiled_object: RawTiledObject) -> TiledObject: - """Create a TiledObject containing all the attributes common to all tiled objects - - Args: - raw_tiled_object: Raw Tiled object get common attributes from - - Returns: - TiledObject: The attributes in common of all Tiled Objects - """ - - common_attributes = TiledObject( - id=raw_tiled_object["id"], - coordinates=OrderedPair(raw_tiled_object["x"], raw_tiled_object["y"]), - visible=raw_tiled_object["visible"], - size=Size(raw_tiled_object["width"], raw_tiled_object["height"]), - rotation=raw_tiled_object["rotation"], - name=raw_tiled_object["name"], - type=raw_tiled_object["type"], - ) - - if raw_tiled_object.get("properties") is not None: - common_attributes.properties = properties_.cast(raw_tiled_object["properties"]) - - return common_attributes - - -def _cast_ellipse(raw_tiled_object: RawTiledObject) -> Ellipse: - """Cast the raw_tiled_object to an Ellipse object. - - Args: - raw_tiled_object: Raw Tiled object to be casted to an Ellipse - - Returns: - Ellipse: The Ellipse object created from the raw_tiled_object - """ - return Ellipse(**_get_common_attributes(raw_tiled_object).__dict__) - - -def _cast_rectangle(raw_tiled_object: RawTiledObject) -> Rectangle: - """Cast the raw_tiled_object to a Rectangle object. - - Args: - raw_tiled_object: Raw Tiled object to be casted to a Rectangle - - Returns: - Rectangle: The Rectangle object created from the raw_tiled_object - """ - return Rectangle(**_get_common_attributes(raw_tiled_object).__dict__) - - -def _cast_point(raw_tiled_object: RawTiledObject) -> Point: - """Cast the raw_tiled_object to a Point object. - - Args: - raw_tiled_object: Raw Tiled object to be casted to a Point - - Returns: - Point: The Point object created from the raw_tiled_object - """ - return Point(**_get_common_attributes(raw_tiled_object).__dict__) - - -def _cast_tile( - raw_tiled_object: RawTiledObject, - new_tileset: Optional[Dict[str, Any]] = None, - new_tileset_path: Optional[Path] = None, -) -> Tile: - """Cast the raw_tiled_object to a Tile object. - - Args: - raw_tiled_object: Raw Tiled object to be casted to a Tile - - Returns: - Tile: The Tile object created from the raw_tiled_object - """ - gid = raw_tiled_object["gid"] - - return Tile( - gid=gid, - new_tileset=new_tileset, - new_tileset_path=new_tileset_path, - **_get_common_attributes(raw_tiled_object).__dict__ - ) - - -def _cast_polygon(raw_tiled_object: RawTiledObject) -> Polygon: - """Cast the raw_tiled_object to a Polygon object. - - Args: - raw_tiled_object: Raw Tiled object to be casted to a Polygon - - Returns: - Polygon: The Polygon object created from the raw_tiled_object - """ - polygon = [] - for point in raw_tiled_object["polygon"]: - polygon.append(OrderedPair(point["x"], point["y"])) - - return Polygon(points=polygon, **_get_common_attributes(raw_tiled_object).__dict__) - - -def _cast_polyline(raw_tiled_object: RawTiledObject) -> Polyline: - """Cast the raw_tiled_object to a Polyline object. - - Args: - raw_tiled_object: Raw Tiled Object to be casted to a Polyline - - Returns: - Polyline: The Polyline object created from the raw_tiled_object - """ - polyline = [] - for point in raw_tiled_object["polyline"]: - polyline.append(OrderedPair(point["x"], point["y"])) - - return Polyline( - points=polyline, **_get_common_attributes(raw_tiled_object).__dict__ - ) - - -def _cast_text(raw_tiled_object: RawTiledObject) -> Text: - """Cast the raw_tiled_object to a Text object. - - Args: - raw_tiled_object: Raw Tiled object to be casted to a Text object - - Returns: - Text: The Text object created from the raw_tiled_object - """ - # required attributes - raw_text_dict: RawTextDict = raw_tiled_object["text"] - text = raw_text_dict["text"] - - # create base Text object - text_object = Text(text=text, **_get_common_attributes(raw_tiled_object).__dict__) - - # optional attributes - if raw_text_dict.get("color") is not None: - text_object.color = parse_color(raw_text_dict["color"]) - - if raw_text_dict.get("fontfamily") is not None: - text_object.font_family = raw_text_dict["fontfamily"] - - if raw_text_dict.get("pixelsize") is not None: - text_object.font_size = raw_text_dict["pixelsize"] - - if raw_text_dict.get("bold") is not None: - text_object.bold = raw_text_dict["bold"] - - if raw_text_dict.get("italic") is not None: - text_object.italic = raw_text_dict["italic"] - - if raw_text_dict.get("kerning") is not None: - text_object.kerning = raw_text_dict["kerning"] - - if raw_text_dict.get("strikeout") is not None: - text_object.strike_out = raw_text_dict["strikeout"] - - if raw_text_dict.get("underline") is not None: - text_object.underline = raw_text_dict["underline"] - - if raw_text_dict.get("halign") is not None: - text_object.horizontal_align = raw_text_dict["halign"] - - if raw_text_dict.get("valign") is not None: - text_object.vertical_align = raw_text_dict["valign"] - - if raw_text_dict.get("wrap") is not None: - text_object.wrap = raw_text_dict["wrap"] - - return text_object - - -def _get_caster( - raw_tiled_object: RawTiledObject, -) -> Callable[[RawTiledObject], TiledObject]: - """Get the caster function for the raw tiled object. - - Args: - raw_tiled_object: Raw Tiled object that is analysed to determine which caster - to return. - - Returns: - Callable[[RawTiledObject], TiledObject]: The caster function. - """ - if raw_tiled_object.get("ellipse"): - return _cast_ellipse - - if raw_tiled_object.get("point"): - return _cast_point - - if raw_tiled_object.get("gid"): - # Only Tile objects have the `gid` key (I think) - return _cast_tile - - if raw_tiled_object.get("polygon"): - return _cast_polygon - - if raw_tiled_object.get("polyline"): - return _cast_polyline - - if raw_tiled_object.get("text"): - return _cast_text - - return _cast_rectangle - - -def cast( - raw_tiled_object: RawTiledObject, - parent_dir: Optional[Path] = None, -) -> TiledObject: - """Cast the raw tiled object into a pytiled_parser type - - Args: - raw_tiled_object: Raw Tiled object that is to be cast. - parent_dir: The parent directory that the map file is in. - - Returns: - TiledObject: a properly typed Tiled object. - - Raises: - RuntimeError: When a required parameter was not sent based on a condition. - """ - new_tileset = None - new_tileset_path = None - - if raw_tiled_object.get("template"): - if not parent_dir: - raise RuntimeError( - "A parent directory must be specified when using object templates" - ) - template_path = Path(parent_dir / raw_tiled_object["template"]) - with open(template_path) as raw_template_file: - template = json.load(raw_template_file) - if "tileset" in template: - tileset_path = Path( - template_path.parent / template["tileset"]["source"] - ) - with open(tileset_path) as raw_tileset_file: - new_tileset = json.load(raw_tileset_file) - new_tileset_path = tileset_path.parent - - loaded_template = template["object"] - for key in loaded_template: - if key != "id": - raw_tiled_object[key] = loaded_template[key] # type: ignore - - if raw_tiled_object.get("gid"): - return _cast_tile(raw_tiled_object, new_tileset, new_tileset_path) - - return _get_caster(raw_tiled_object)(raw_tiled_object) diff --git a/pytiled_parser/tileset.py b/pytiled_parser/tileset.py index 25bd190c..de480135 100644 --- a/pytiled_parser/tileset.py +++ b/pytiled_parser/tileset.py @@ -1,16 +1,13 @@ # pylint: disable=too-few-public-methods from pathlib import Path -from typing import Dict, List, NamedTuple, Optional, Union +from typing import Dict, List, NamedTuple, Optional import attr -from typing_extensions import TypedDict from . import layer from . import properties as properties_ from .common_types import Color, OrderedPair -from .util import parse_color -from .wang_set import RawWangSet, WangSet -from .wang_set import cast as cast_wangset +from .wang_set import WangSet class Grid(NamedTuple): @@ -153,261 +150,3 @@ class Tileset: properties: Optional[properties_.Properties] = None tiles: Optional[Dict[int, Tile]] = None wang_sets: Optional[List[WangSet]] = None - - -class RawFrame(TypedDict): - """ The keys and their types that appear in a Frame JSON Object.""" - - duration: int - tileid: int - - -class RawTileOffset(TypedDict): - """ The keys and their types that appear in a TileOffset JSON Object.""" - - x: int - y: int - - -class RawTransformations(TypedDict): - """ The keys and their types that appear in a Transformations JSON Object.""" - - hflip: bool - vflip: bool - rotate: bool - preferuntransformed: bool - - -class RawTile(TypedDict): - """ The keys and their types that appear in a Tile JSON Object.""" - - animation: List[RawFrame] - id: int - image: str - imageheight: int - imagewidth: int - opacity: float - properties: List[properties_.RawProperty] - objectgroup: layer.RawLayer - type: str - - -class RawGrid(TypedDict): - """ The keys and their types that appear in a Grid JSON Object.""" - - height: int - width: int - orientation: str - - -class RawTileSet(TypedDict): - """ The keys and their types that appear in a TileSet JSON Object.""" - - backgroundcolor: str - columns: int - firstgid: int - grid: RawGrid - image: str - imageheight: int - imagewidth: int - margin: int - name: str - properties: List[properties_.RawProperty] - source: str - spacing: int - tilecount: int - tiledversion: str - tileheight: int - tileoffset: RawTileOffset - tiles: List[RawTile] - tilewidth: int - transparentcolor: str - transformations: RawTransformations - version: Union[str, float] - wangsets: List[RawWangSet] - - -def _cast_frame(raw_frame: RawFrame) -> Frame: - """Cast the raw_frame to a Frame. - - Args: - raw_frame: RawFrame to be casted to a Frame - - Returns: - Frame: The Frame created from the raw_frame - """ - - return Frame(duration=raw_frame["duration"], tile_id=raw_frame["tileid"]) - - -def _cast_tile_offset(raw_tile_offset: RawTileOffset) -> OrderedPair: - """Cast the raw_tile_offset to an OrderedPair. - - Args: - raw_tile_offset: RawTileOffset to be casted to an OrderedPair - - Returns: - OrderedPair: The OrderedPair created from the raw_tile_offset - """ - - return OrderedPair(raw_tile_offset["x"], raw_tile_offset["y"]) - - -def _cast_tile(raw_tile: RawTile, external_path: Optional[Path] = None) -> Tile: - """Cast the raw_tile to a Tile object. - - Args: - raw_tile: RawTile to be casted to a Tile - - Returns: - Tile: The Tile created from the raw_tile - """ - - id_ = raw_tile["id"] - tile = Tile(id=id_) - - if raw_tile.get("animation") is not None: - tile.animation = [] - for frame in raw_tile["animation"]: - tile.animation.append(_cast_frame(frame)) - - if raw_tile.get("objectgroup") is not None: - tile.objects = layer.cast(raw_tile["objectgroup"]) - - if raw_tile.get("properties") is not None: - tile.properties = properties_.cast(raw_tile["properties"]) - - if raw_tile.get("image") is not None: - if external_path: - tile.image = Path(external_path / raw_tile["image"]).absolute().resolve() - else: - tile.image = Path(raw_tile["image"]) - - if raw_tile.get("imagewidth") is not None: - tile.image_width = raw_tile["imagewidth"] - - if raw_tile.get("imageheight") is not None: - tile.image_height = raw_tile["imageheight"] - - if raw_tile.get("type") is not None: - tile.type = raw_tile["type"] - - return tile - - -def _cast_transformations(raw_transformations: RawTransformations) -> Transformations: - """Cast the raw_transformations to a Transformations object. - - Args: - raw_transformations: RawTransformations to be casted to a Transformations - - Returns: - Transformations: The Transformations created from the raw_transformations - """ - - return Transformations( - hflip=raw_transformations["hflip"], - vflip=raw_transformations["vflip"], - rotate=raw_transformations["rotate"], - prefer_untransformed=raw_transformations["preferuntransformed"], - ) - - -def _cast_grid(raw_grid: RawGrid) -> Grid: - """Cast the raw_grid to a Grid object. - - Args: - raw_grid: RawGrid to be casted to a Grid - - Returns: - Grid: The Grid created from the raw_grid - """ - - return Grid( - orientation=raw_grid["orientation"], - width=raw_grid["width"], - height=raw_grid["height"], - ) - - -def cast( - raw_tileset: RawTileSet, - firstgid: int, - external_path: Optional[Path] = None, -) -> Tileset: - """Cast the raw tileset into a pytiled_parser type - - Args: - raw_tileset: Raw Tileset to be cast. - firstgid: GID corresponding the first tile in the set. - external_path: The path to the tileset if it is not an embedded one. - - Returns: - TileSet: a properly typed TileSet. - """ - - tileset = Tileset( - name=raw_tileset["name"], - tile_count=raw_tileset["tilecount"], - tile_width=raw_tileset["tilewidth"], - tile_height=raw_tileset["tileheight"], - columns=raw_tileset["columns"], - spacing=raw_tileset["spacing"], - margin=raw_tileset["margin"], - firstgid=firstgid, - ) - - if raw_tileset.get("version") is not None: - if isinstance(raw_tileset["version"], float): - tileset.version = str(raw_tileset["version"]) - else: - tileset.version = raw_tileset["version"] - - if raw_tileset.get("tiledversion") is not None: - tileset.tiled_version = raw_tileset["tiledversion"] - - if raw_tileset.get("image") is not None: - if external_path: - tileset.image = ( - Path(external_path / raw_tileset["image"]).absolute().resolve() - ) - else: - tileset.image = Path(raw_tileset["image"]) - - if raw_tileset.get("imagewidth") is not None: - tileset.image_width = raw_tileset["imagewidth"] - - if raw_tileset.get("imageheight") is not None: - tileset.image_height = raw_tileset["imageheight"] - - if raw_tileset.get("backgroundcolor") is not None: - tileset.background_color = parse_color(raw_tileset["backgroundcolor"]) - - if raw_tileset.get("tileoffset") is not None: - tileset.tile_offset = _cast_tile_offset(raw_tileset["tileoffset"]) - - if raw_tileset.get("transparentcolor") is not None: - tileset.transparent_color = parse_color(raw_tileset["transparentcolor"]) - - if raw_tileset.get("grid") is not None: - tileset.grid = _cast_grid(raw_tileset["grid"]) - - if raw_tileset.get("properties") is not None: - tileset.properties = properties_.cast(raw_tileset["properties"]) - - if raw_tileset.get("tiles") is not None: - tiles = {} - for raw_tile in raw_tileset["tiles"]: - tiles[raw_tile["id"]] = _cast_tile(raw_tile, external_path=external_path) - tileset.tiles = tiles - - if raw_tileset.get("wangsets") is not None: - wangsets = [] - for raw_wangset in raw_tileset["wangsets"]: - wangsets.append(cast_wangset(raw_wangset)) - tileset.wang_sets = wangsets - - if raw_tileset.get("transformations") is not None: - tileset.transformations = _cast_transformations(raw_tileset["transformations"]) - - return tileset diff --git a/pytiled_parser/util.py b/pytiled_parser/util.py index 75c7d1db..f8bb18bf 100644 --- a/pytiled_parser/util.py +++ b/pytiled_parser/util.py @@ -1,4 +1,8 @@ """Utility Functions for PyTiled""" +import json +import xml.etree.ElementTree as etree +from pathlib import Path +from typing import Any from pytiled_parser.common_types import Color @@ -27,3 +31,52 @@ def parse_color(color: str) -> Color: ) raise ValueError("Improperly formatted color passed to parse_color") + + +def check_format(file_path: Path) -> str: + with open(file_path) as file: + line = file.readline().rstrip().strip() + if line[0] == "<": + return "tmx" + else: + return "json" + + +def load_object_template(file_path: Path) -> Any: + template_format = check_format(file_path) + + new_tileset = None + new_tileset_path = None + + if template_format == "tmx": + with open(file_path) as template_file: + template = etree.parse(template_file).getroot() + + tileset_element = template.find("./tileset") + if tileset_element is not None: + tileset_path = Path(file_path.parent / tileset_element.attrib["source"]) + new_tileset = load_object_tileset(tileset_path) + new_tileset_path = tileset_path.parent + elif template_format == "json": + with open(file_path) as template_file: + template = json.load(template_file) + if "tileset" in template: + tileset_path = Path(file_path.parent / template["tileset"]["source"]) # type: ignore + new_tileset = load_object_tileset(tileset_path) + new_tileset_path = tileset_path.parent + + return (template, new_tileset, new_tileset_path) + + +def load_object_tileset(file_path: Path) -> Any: + tileset_format = check_format(file_path) + + new_tileset = None + + with open(file_path) as tileset_file: + if tileset_format == "tmx": + new_tileset = etree.parse(tileset_file).getroot() + elif tileset_format == "json": + new_tileset = json.load(tileset_file) + + return new_tileset diff --git a/pytiled_parser/version.py b/pytiled_parser/version.py index b7bdf02f..d948627b 100644 --- a/pytiled_parser/version.py +++ b/pytiled_parser/version.py @@ -1,3 +1,3 @@ """pytiled_parser version""" -__version__ = "1.5.4" +__version__ = "2.0.0" diff --git a/pytiled_parser/wang_set.py b/pytiled_parser/wang_set.py index 011410fe..92417426 100644 --- a/pytiled_parser/wang_set.py +++ b/pytiled_parser/wang_set.py @@ -1,11 +1,9 @@ from typing import Dict, List, Optional import attr -from typing_extensions import TypedDict -from . import properties as properties_ -from .common_types import Color -from .util import parse_color +from pytiled_parser.common_types import Color +from pytiled_parser.properties import Properties @attr.s(auto_attribs=True) @@ -22,7 +20,7 @@ class WangColor: name: str probability: float tile: int - properties: Optional[properties_.Properties] = None + properties: Optional[Properties] = None @attr.s(auto_attribs=True) @@ -33,100 +31,4 @@ class WangSet: wang_type: str wang_tiles: Dict[int, WangTile] wang_colors: List[WangColor] - properties: Optional[properties_.Properties] = None - - -class RawWangTile(TypedDict): - """ The keys and their types that appear in a Wang Tile JSON Object.""" - - tileid: int - # Tiled stores these IDs as a list represented like so: - # [top, top_right, right, bottom_right, bottom, bottom_left, left, top_left] - wangid: List[int] - - -class RawWangColor(TypedDict): - """ The keys and their types that appear in a Wang Color JSON Object.""" - - color: str - name: str - probability: float - tile: int - properties: List[properties_.RawProperty] - - -class RawWangSet(TypedDict): - """ The keys and their types that appear in a Wang Set JSON Object.""" - - colors: List[RawWangColor] - name: str - properties: List[properties_.RawProperty] - tile: int - type: str - wangtiles: List[RawWangTile] - - -def _cast_wang_tile(raw_wang_tile: RawWangTile) -> WangTile: - """Cast the raw wang tile into a pytiled_parser type - - Args: - raw_wang_tile: RawWangTile to be cast. - - Returns: - WangTile: A properly typed WangTile. - """ - return WangTile(tile_id=raw_wang_tile["tileid"], wang_id=raw_wang_tile["wangid"]) - - -def _cast_wang_color(raw_wang_color: RawWangColor) -> WangColor: - """Cast the raw wang color into a pytiled_parser type - - Args: - raw_wang_color: RawWangColor to be cast. - - Returns: - WangColor: A properly typed WangColor. - """ - wang_color = WangColor( - name=raw_wang_color["name"], - color=parse_color(raw_wang_color["color"]), - tile=raw_wang_color["tile"], - probability=raw_wang_color["probability"], - ) - - if raw_wang_color.get("properties") is not None: - wang_color.properties = properties_.cast(raw_wang_color["properties"]) - - return wang_color - - -def cast(raw_wangset: RawWangSet) -> WangSet: - """Cast the raw wangset into a pytiled_parser type - - Args: - raw_wangset: Raw Wangset to be cast. - - Returns: - WangSet: A properly typed WangSet. - """ - - colors = [] - for raw_wang_color in raw_wangset["colors"]: - colors.append(_cast_wang_color(raw_wang_color)) - - tiles = {} - for raw_wang_tile in raw_wangset["wangtiles"]: - tiles[raw_wang_tile["tileid"]] = _cast_wang_tile(raw_wang_tile) - - wangset = WangSet( - name=raw_wangset["name"], - tile=raw_wangset["tile"], - wang_type=raw_wangset["type"], - wang_colors=colors, - wang_tiles=tiles, - ) - - if raw_wangset.get("properties") is not None: - wangset.properties = properties_.cast(raw_wangset["properties"]) - - return wangset + properties: Optional[Properties] = None diff --git a/pytiled_parser/world.py b/pytiled_parser/world.py index 5d35322f..797ff395 100644 --- a/pytiled_parser/world.py +++ b/pytiled_parser/world.py @@ -8,8 +8,9 @@ import attr from typing_extensions import TypedDict -from .common_types import OrderedPair, Size -from .tiled_map import TiledMap, parse_map +from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.parser import parse_map +from pytiled_parser.tiled_map import TiledMap @attr.s(auto_attribs=True) @@ -55,7 +56,7 @@ class RawWorld(TypedDict): onlyShowAdjacentMaps: bool -def _cast_world_map(raw_world_map: RawWorldMap, map_file: Path) -> WorldMap: +def _parse_world_map(raw_world_map: RawWorldMap, map_file: Path) -> WorldMap: """Parse the RawWorldMap into a WorldMap. Args: @@ -94,7 +95,7 @@ def parse_world(file: Path) -> World: if raw_world.get("maps"): for raw_map in raw_world["maps"]: map_path = Path(parent_dir / raw_map["fileName"]) - maps.append(_cast_world_map(raw_map, map_path)) + maps.append(_parse_world_map(raw_map, map_path)) if raw_world.get("patterns"): for raw_pattern in raw_world["patterns"]: @@ -131,7 +132,7 @@ def parse_world(file: Path) -> World: } map_path = Path(parent_dir / map_file) - maps.append(_cast_world_map(raw_world_map, map_path)) + maps.append(_parse_world_map(raw_world_map, map_path)) world = World(maps=maps) diff --git a/setup.cfg b/setup.cfg index 86ce2325..2ccb3607 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,7 +44,7 @@ tests = pytest pytest-cov black - pylint + flake8 mypy isort<5,>=4.2.5 @@ -104,3 +104,7 @@ strict_optional = True [mypy-tests.*] ignore_errors = True + +[flake8] +max-line-length = 88 +exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache diff --git a/tests/test_cross_template/map.json b/tests/test_cross_template/map.json new file mode 100644 index 00000000..451323f0 --- /dev/null +++ b/tests/test_cross_template/map.json @@ -0,0 +1,73 @@ +{ "backgroundcolor":"#ff0004", + "compressionlevel":0, + "height":6, + "infinite":false, + "layers":[ + { + "draworder":"topdown", + "id":2, + "name":"Object Layer 1", + "objects":[ + { + "id":2, + "template":"template-rectangle.tx", + "x":98.4987608686521, + "y":46.2385012811358 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":3, + "nextobjectid":8, + "orientation":"orthogonal", + "properties":[ + { + "name":"bool property - true", + "type":"bool", + "value":true + }, + { + "name":"color property", + "type":"color", + "value":"#ff49fcff" + }, + { + "name":"file property", + "type":"file", + "value":"..\/..\/..\/..\/..\/..\/var\/log\/syslog" + }, + { + "name":"float property", + "type":"float", + "value":1.23456789 + }, + { + "name":"int property", + "type":"int", + "value":13 + }, + { + "name":"string property", + "type":"string", + "value":"Hello, World!!" + }], + "renderorder":"right-down", + "tiledversion":"1.7.1", + "tileheight":32, + "tilesets":[ + { + "firstgid":1, + "source":"tileset.json" + }, + { + "firstgid":49, + "source":"tile_set_image_for_template.json" + }], + "tilewidth":32, + "type":"map", + "version":"1.6", + "width":8 +} \ No newline at end of file diff --git a/tests/test_cross_template/map.tmx b/tests/test_cross_template/map.tmx new file mode 100644 index 00000000..b77d63b4 --- /dev/null +++ b/tests/test_cross_template/map.tmx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/test_cross_template/template-rectangle.json b/tests/test_cross_template/template-rectangle.json new file mode 100644 index 00000000..fc392298 --- /dev/null +++ b/tests/test_cross_template/template-rectangle.json @@ -0,0 +1,12 @@ +{ "object": + { + "height":38.2811778048473, + "id":1, + "name":"", + "rotation":0, + "type":"", + "visible":true, + "width":63.6585878103079 + }, + "type":"template" +} \ No newline at end of file diff --git a/tests/test_cross_template/template-rectangle.tx b/tests/test_cross_template/template-rectangle.tx new file mode 100644 index 00000000..6daa3644 --- /dev/null +++ b/tests/test_cross_template/template-rectangle.tx @@ -0,0 +1,4 @@ + + diff --git a/tests/test_cross_template/test_cross_template.py b/tests/test_cross_template/test_cross_template.py new file mode 100644 index 00000000..a1f8d86a --- /dev/null +++ b/tests/test_cross_template/test_cross_template.py @@ -0,0 +1,16 @@ +import os +from pathlib import Path + +import pytest + +from pytiled_parser import parse_map + + +def test_cross_template_tmx_json(): + with pytest.raises(NotImplementedError): + parse_map(Path(os.path.dirname(os.path.abspath(__file__))) / "map.tmx") + + +def test_cross_template_json_tmx(): + with pytest.raises(NotImplementedError): + parse_map(Path(os.path.dirname(os.path.abspath(__file__))) / "map.json") diff --git a/tests/test_cross_template/tile_set_image_for_template.json b/tests/test_cross_template/tile_set_image_for_template.json new file mode 100644 index 00000000..c0cbf4e2 --- /dev/null +++ b/tests/test_cross_template/tile_set_image_for_template.json @@ -0,0 +1,14 @@ +{ "columns":1, + "image":"..\/..\/images\/tile_04.png", + "imageheight":32, + "imagewidth":32, + "margin":0, + "name":"tile_set_image_for_template", + "spacing":0, + "tilecount":1, + "tiledversion":"1.7.1", + "tileheight":32, + "tilewidth":32, + "type":"tileset", + "version":"1.6" +} \ No newline at end of file diff --git a/tests/test_cross_template/tile_set_image_for_template.tsx b/tests/test_cross_template/tile_set_image_for_template.tsx new file mode 100644 index 00000000..9c597790 --- /dev/null +++ b/tests/test_cross_template/tile_set_image_for_template.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_cross_template/tileset.json b/tests/test_cross_template/tileset.json new file mode 100644 index 00000000..33028848 --- /dev/null +++ b/tests/test_cross_template/tileset.json @@ -0,0 +1,14 @@ +{ "columns":8, + "image":"..\/test_data\/images\/tmw_desert_spacing.png", + "imageheight":199, + "imagewidth":265, + "margin":1, + "name":"tile_set_image", + "spacing":1, + "tilecount":48, + "tiledversion":"1.6.0", + "tileheight":32, + "tilewidth":32, + "type":"tileset", + "version":"1.6" +} \ No newline at end of file diff --git a/tests/test_cross_template/tileset.tsx b/tests/test_cross_template/tileset.tsx new file mode 100644 index 00000000..8aee17a7 --- /dev/null +++ b/tests/test_cross_template/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/layer_tests/all_layer_types/map.tmx b/tests/test_data/layer_tests/all_layer_types/map.tmx new file mode 100644 index 00000000..c94d181f --- /dev/null +++ b/tests/test_data/layer_tests/all_layer_types/map.tmx @@ -0,0 +1,28 @@ + + + + + + + + +1,2,3,4,5,6,7,8, +9,10,11,12,13,14,15,16, +17,18,19,20,21,22,23,24, +25,26,27,28,29,30,31,32, +33,34,35,36,37,38,39,40, +41,42,43,44,45,46,47,48 + + + + + + + + + + + + + + diff --git a/tests/test_data/layer_tests/all_layer_types/tileset.tsx b/tests/test_data/layer_tests/all_layer_types/tileset.tsx new file mode 100644 index 00000000..8aee17a7 --- /dev/null +++ b/tests/test_data/layer_tests/all_layer_types/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/layer_tests/b64/map.tmx b/tests/test_data/layer_tests/b64/map.tmx new file mode 100644 index 00000000..060ebe12 --- /dev/null +++ b/tests/test_data/layer_tests/b64/map.tmx @@ -0,0 +1,17 @@ + + + + + + AQAAAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAJAAAACgAAAAsAAAAMAAAADQAAAA4AAAAPAAAAEAAAABEAAAASAAAAEwAAABQAAAAVAAAAFgAAABcAAAAYAAAAGQAAABoAAAAbAAAAHAAAAB0AAAAeAAAAHwAAACAAAAAhAAAAIgAAACMAAAAkAAAAJQAAACYAAAAnAAAAKAAAACkAAAAqAAAAKwAAACwAAAAtAAAALgAAAC8AAAAwAAAA + + + + + + + + + + + diff --git a/tests/test_data/layer_tests/b64/tileset.tsx b/tests/test_data/layer_tests/b64/tileset.tsx new file mode 100644 index 00000000..8aee17a7 --- /dev/null +++ b/tests/test_data/layer_tests/b64/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/layer_tests/b64_gzip/map.tmx b/tests/test_data/layer_tests/b64_gzip/map.tmx new file mode 100644 index 00000000..21316ba3 --- /dev/null +++ b/tests/test_data/layer_tests/b64_gzip/map.tmx @@ -0,0 +1,17 @@ + + + + + + H4sIAAAAAAAACg3DBRKCQAAAwDMRA7BQLMTE9v+vY3dmWyGEth279uwbOTB26MixExNTM6fOnLtwae7KtYUbt+7ce7D0aOXJsxev3rxb+/Dpy7cfv/782wAcvDirwAAAAA== + + + + + + + + + + + diff --git a/tests/test_data/layer_tests/b64_gzip/tileset.tsx b/tests/test_data/layer_tests/b64_gzip/tileset.tsx new file mode 100644 index 00000000..8aee17a7 --- /dev/null +++ b/tests/test_data/layer_tests/b64_gzip/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/layer_tests/b64_zlib/map.tmx b/tests/test_data/layer_tests/b64_zlib/map.tmx new file mode 100644 index 00000000..343bd2b3 --- /dev/null +++ b/tests/test_data/layer_tests/b64_zlib/map.tmx @@ -0,0 +1,17 @@ + + + + + + eJwNwwUSgkAAAMAzEQOwUCzExPb/r2N3ZlshhLYdu/bsGzkwdujIsRMTUzOnzpy7cGnuyrWFG7fu3Huw9GjlybMXr968W/vw6cu3H7/+/NsAMw8EmQ== + + + + + + + + + + + diff --git a/tests/test_data/layer_tests/b64_zlib/tileset.tsx b/tests/test_data/layer_tests/b64_zlib/tileset.tsx new file mode 100644 index 00000000..8aee17a7 --- /dev/null +++ b/tests/test_data/layer_tests/b64_zlib/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/layer_tests/infinite_map/map.tmx b/tests/test_data/layer_tests/infinite_map/map.tmx new file mode 100644 index 00000000..2a826bbe --- /dev/null +++ b/tests/test_data/layer_tests/infinite_map/map.tmx @@ -0,0 +1,35 @@ + + + + + + + + + + + + + +1,2,3,4, +9,10,11,12, +17,18,19,20, +25,26,27,28, +33,34,35,36, +41,42,43,44, +0,0,0,0, +0,0,0,0 + + +5,6,7,8, +13,14,15,16, +21,22,23,24, +29,30,31,32, +37,38,39,40, +45,46,47,48, +0,0,0,0, +0,0,0,0 + + + + diff --git a/tests/test_data/layer_tests/infinite_map_b64/map.tmx b/tests/test_data/layer_tests/infinite_map_b64/map.tmx new file mode 100644 index 00000000..d83d427b --- /dev/null +++ b/tests/test_data/layer_tests/infinite_map_b64/map.tmx @@ -0,0 +1,14 @@ + + + + + + + + + + AQAAAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAAKAAAACwAAAAwAAAANAAAADgAAAA8AAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAEgAAABMAAAAUAAAAFQAAABYAAAAXAAAAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGQAAABoAAAAbAAAAHAAAAB0AAAAeAAAAHwAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACEAAAAiAAAAIwAAACQAAAAlAAAAJgAAACcAAAAoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApAAAAKgAAACsAAAAsAAAALQAAAC4AAAAvAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + + + + diff --git a/tests/test_data/layer_tests/no_layers/map.tmx b/tests/test_data/layer_tests/no_layers/map.tmx new file mode 100644 index 00000000..fb995a34 --- /dev/null +++ b/tests/test_data/layer_tests/no_layers/map.tmx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/test_data/layer_tests/no_layers/tileset.tsx b/tests/test_data/layer_tests/no_layers/tileset.tsx new file mode 100644 index 00000000..8aee17a7 --- /dev/null +++ b/tests/test_data/layer_tests/no_layers/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/map_tests/embedded_tileset/map.tmx b/tests/test_data/map_tests/embedded_tileset/map.tmx new file mode 100644 index 00000000..c39ca67b --- /dev/null +++ b/tests/test_data/map_tests/embedded_tileset/map.tmx @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/test_data/map_tests/external_tileset_dif_dir/map.tmx b/tests/test_data/map_tests/external_tileset_dif_dir/map.tmx new file mode 100644 index 00000000..9c19e157 --- /dev/null +++ b/tests/test_data/map_tests/external_tileset_dif_dir/map.tmx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + eAFjYWBgYAZiJiBmBOKhBgAIGAAL + + + diff --git a/tests/test_data/map_tests/external_tileset_dif_dir/tileset/tileset.tsx b/tests/test_data/map_tests/external_tileset_dif_dir/tileset/tileset.tsx new file mode 100644 index 00000000..192b15e3 --- /dev/null +++ b/tests/test_data/map_tests/external_tileset_dif_dir/tileset/tileset.tsx @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_data/map_tests/hexagonal/map.tmx b/tests/test_data/map_tests/hexagonal/map.tmx new file mode 100644 index 00000000..482cf3cf --- /dev/null +++ b/tests/test_data/map_tests/hexagonal/map.tmx @@ -0,0 +1,18 @@ + + + + + +3,3,3,3,9,9,9,9,17,17, +3,3,3,9,9,9,9,17,17,17, +3,3,3,9,9,9,9,9,17,17, +3,3,1,7,9,9,9,15,17,17, +1,1,12,5,7,7,7,15,15,15, +12,1,5,5,7,7,7,15,15,15, +2,2,5,5,5,5,4,14,14,14, +2,2,5,5,5,4,14,14,14,14, +2,2,2,5,5,5,4,14,14,14, +2,2,2,2,5,5,4,4,14,14 + + + diff --git a/tests/test_data/map_tests/hexagonal/tileset.tsx b/tests/test_data/map_tests/hexagonal/tileset.tsx new file mode 100644 index 00000000..cba4d04e --- /dev/null +++ b/tests/test_data/map_tests/hexagonal/tileset.tsx @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/test_data/map_tests/no_background_color/map.tmx b/tests/test_data/map_tests/no_background_color/map.tmx new file mode 100644 index 00000000..7e71558c --- /dev/null +++ b/tests/test_data/map_tests/no_background_color/map.tmx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/map_tests/no_background_color/tileset.tsx b/tests/test_data/map_tests/no_background_color/tileset.tsx new file mode 100644 index 00000000..8b1cf24b --- /dev/null +++ b/tests/test_data/map_tests/no_background_color/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/map_tests/no_layers/map.tmx b/tests/test_data/map_tests/no_layers/map.tmx new file mode 100644 index 00000000..1ef8b7a0 --- /dev/null +++ b/tests/test_data/map_tests/no_layers/map.tmx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/test_data/map_tests/no_layers/tileset.tsx b/tests/test_data/map_tests/no_layers/tileset.tsx new file mode 100644 index 00000000..8b1cf24b --- /dev/null +++ b/tests/test_data/map_tests/no_layers/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/map_tests/template/map.tmx b/tests/test_data/map_tests/template/map.tmx new file mode 100644 index 00000000..24cc2f0d --- /dev/null +++ b/tests/test_data/map_tests/template/map.tmx @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/test_data/map_tests/template/template-rectangle.tx b/tests/test_data/map_tests/template/template-rectangle.tx new file mode 100644 index 00000000..6daa3644 --- /dev/null +++ b/tests/test_data/map_tests/template/template-rectangle.tx @@ -0,0 +1,4 @@ + + diff --git a/tests/test_data/map_tests/template/template-tile-image.tx b/tests/test_data/map_tests/template/template-tile-image.tx new file mode 100644 index 00000000..989b725d --- /dev/null +++ b/tests/test_data/map_tests/template/template-tile-image.tx @@ -0,0 +1,5 @@ + + diff --git a/tests/test_data/map_tests/template/template-tile-spritesheet.tx b/tests/test_data/map_tests/template/template-tile-spritesheet.tx new file mode 100644 index 00000000..d958c770 --- /dev/null +++ b/tests/test_data/map_tests/template/template-tile-spritesheet.tx @@ -0,0 +1,5 @@ + + diff --git a/tests/test_data/map_tests/template/tile_set_image_for_template.tsx b/tests/test_data/map_tests/template/tile_set_image_for_template.tsx new file mode 100644 index 00000000..9c597790 --- /dev/null +++ b/tests/test_data/map_tests/template/tile_set_image_for_template.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/map_tests/template/tile_set_single_image.tsx b/tests/test_data/map_tests/template/tile_set_single_image.tsx new file mode 100644 index 00000000..c881c112 --- /dev/null +++ b/tests/test_data/map_tests/template/tile_set_single_image.tsx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/test_data/map_tests/template/tileset.tsx b/tests/test_data/map_tests/template/tileset.tsx new file mode 100644 index 00000000..8aee17a7 --- /dev/null +++ b/tests/test_data/map_tests/template/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/tilesets/image/tileset.tsx b/tests/test_data/tilesets/image/tileset.tsx new file mode 100644 index 00000000..8aee17a7 --- /dev/null +++ b/tests/test_data/tilesets/image/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/tilesets/image_background_color/tileset.tsx b/tests/test_data/tilesets/image_background_color/tileset.tsx new file mode 100644 index 00000000..25cae9b0 --- /dev/null +++ b/tests/test_data/tilesets/image_background_color/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/tilesets/image_grid/tileset.tsx b/tests/test_data/tilesets/image_grid/tileset.tsx new file mode 100644 index 00000000..62ef87e4 --- /dev/null +++ b/tests/test_data/tilesets/image_grid/tileset.tsx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/test_data/tilesets/image_properties/tileset.tsx b/tests/test_data/tilesets/image_properties/tileset.tsx new file mode 100644 index 00000000..42478aeb --- /dev/null +++ b/tests/test_data/tilesets/image_properties/tileset.tsx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/test_data/tilesets/image_tile_offset/tileset.tsx b/tests/test_data/tilesets/image_tile_offset/tileset.tsx new file mode 100644 index 00000000..7c0c1bce --- /dev/null +++ b/tests/test_data/tilesets/image_tile_offset/tileset.tsx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/test_data/tilesets/image_transformations/tileset.tsx b/tests/test_data/tilesets/image_transformations/tileset.tsx new file mode 100644 index 00000000..5b20f698 --- /dev/null +++ b/tests/test_data/tilesets/image_transformations/tileset.tsx @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/test_data/tilesets/image_transparent_color/tileset.tsx b/tests/test_data/tilesets/image_transparent_color/tileset.tsx new file mode 100644 index 00000000..5ab03467 --- /dev/null +++ b/tests/test_data/tilesets/image_transparent_color/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/test_data/tilesets/individual_images/tileset.tsx b/tests/test_data/tilesets/individual_images/tileset.tsx new file mode 100644 index 00000000..877cf795 --- /dev/null +++ b/tests/test_data/tilesets/individual_images/tileset.tsx @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_data/tilesets/terrain/tileset.tsx b/tests/test_data/tilesets/terrain/tileset.tsx new file mode 100644 index 00000000..2a57297e --- /dev/null +++ b/tests/test_data/tilesets/terrain/tileset.tsx @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_layer.py b/tests/test_layer.py index 117761b7..bce74b32 100644 --- a/tests/test_layer.py +++ b/tests/test_layer.py @@ -2,11 +2,14 @@ import importlib.util import json import os +import xml.etree.ElementTree as etree from pathlib import Path import pytest -from pytiled_parser import layer +from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.parsers.json.layer import parse as parse_json +from pytiled_parser.parsers.tmx.layer import parse as parse_tmx TESTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) TEST_DATA = TESTS_DIR / "test_data" @@ -25,8 +28,36 @@ ] +def fix_object(my_object): + my_object.coordinates = OrderedPair( + round(my_object.coordinates[0], 4), + round(my_object.coordinates[1], 4), + ) + my_object.size = Size(round(my_object.size[0], 4), round(my_object.size[1], 4)) + + +def fix_layer(layer): + layer.offset = OrderedPair(round(layer.offset[0], 3), round(layer.offset[1], 3)) + layer.coordinates = OrderedPair( + round(layer.coordinates[0], 4), round(layer.coordinates[1], 4) + ) + if layer.size: + layer.size = Size(round(layer.size[0], 4), round(layer.size[1], 4)) + layer.parallax_factor = OrderedPair( + round(layer.parallax_factor[0], 4), + round(layer.parallax_factor[1], 4), + ) + if hasattr(layer, "tiled_objects"): + for tiled_object in layer.tiled_objects: + fix_object(tiled_object) + if hasattr(layer, "layers"): + for child_layer in layer.layers: + fix_layer(child_layer) + + +@pytest.mark.parametrize("parser_type", ["json", "tmx"]) @pytest.mark.parametrize("layer_test", ALL_LAYER_TESTS) -def test_layer_integration(layer_test): +def test_layer_integration(parser_type, layer_test): # it's a PITA to import like this, don't do it # https://stackoverflow.com/a/67692/1342874 spec = importlib.util.spec_from_file_location( @@ -35,10 +66,33 @@ def test_layer_integration(layer_test): expected = importlib.util.module_from_spec(spec) spec.loader.exec_module(expected) - raw_layers_path = layer_test / "map.json" + if parser_type == "json": + raw_layers_path = layer_test / "map.json" + with open(raw_layers_path) as raw_layers_file: + raw_layers = json.load(raw_layers_file)["layers"] + layers = [parse_json(raw_layer) for raw_layer in raw_layers] + elif parser_type == "tmx": + raw_layers_path = layer_test / "map.tmx" + with open(raw_layers_path) as raw_layers_file: + raw_layer = etree.parse(raw_layers_file).getroot() + layers = [] + for layer in raw_layer.findall("./layer"): + layers.append(parse_tmx(layer)) + + for layer in raw_layer.findall("./objectgroup"): + layers.append(parse_tmx(layer)) + + for layer in raw_layer.findall("./group"): + layers.append(parse_tmx(layer)) + + for layer in raw_layer.findall("./imagelayer"): + layers.append(parse_tmx(layer)) + + for layer in layers: + fix_layer(layer) - with open(raw_layers_path) as raw_layers_file: - raw_layers = json.load(raw_layers_file)["layers"] - layers = [layer.cast(raw_layer) for raw_layer in raw_layers] + for layer in expected.EXPECTED: + fix_layer(layer) + print(layer.size) assert layers == expected.EXPECTED diff --git a/tests/test_map.py b/tests/test_map.py index 00192387..424e802c 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -5,7 +5,8 @@ import pytest -from pytiled_parser import tiled_map +from pytiled_parser import parse_map +from pytiled_parser.common_types import OrderedPair, Size TESTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) TEST_DATA = TESTS_DIR / "test_data" @@ -21,17 +22,64 @@ ] +def fix_object(my_object): + my_object.coordinates = OrderedPair( + round(my_object.coordinates[0], 3), round(my_object.coordinates[1], 3) + ) + my_object.size = Size(round(my_object.size[0], 4), round(my_object.size[1], 4)) + + +def fix_tileset(tileset): + tileset.version = None + tileset.tiled_version = None + if tileset.tiles: + for tile in tileset.tiles.values(): + if tile.objects: + for my_object in tile.objects.tiled_objects: + fix_object(my_object) + + +def fix_layer(layer): + for tiled_object in layer.tiled_objects: + fix_object(tiled_object) + + +def fix_map(map): + map.version = None + map.tiled_version = None + for layer in [layer for layer in map.layers if hasattr(layer, "tiled_objects")]: + fix_layer(layer) + + for tileset in map.tilesets.values(): + fix_tileset(tileset) + + +@pytest.mark.parametrize("parser_type", ["json", "tmx"]) @pytest.mark.parametrize("map_test", ALL_MAP_TESTS) -def test_map_integration(map_test): +def test_map_integration(parser_type, map_test): # it's a PITA to import like this, don't do it # https://stackoverflow.com/a/67692/1342874 spec = importlib.util.spec_from_file_location("expected", map_test / "expected.py") expected = importlib.util.module_from_spec(spec) spec.loader.exec_module(expected) - raw_maps_path = map_test / "map.json" + if parser_type == "json": + raw_maps_path = map_test / "map.json" + elif parser_type == "tmx": + raw_maps_path = map_test / "map.tmx" - casted_map = tiled_map.parse_map(raw_maps_path) + casted_map = parse_map(raw_maps_path) + # file detection when running from unit tests is broken expected.EXPECTED.map_file = casted_map.map_file + + # who even knows what/how/when the gods determine what the + # version values in maps/tileset files are, so we're just not + # gonna check them, because they don't make sense anyways. + # + # Yes the values could be set to None in the expected objects + # directly, but alas, this is just test code that's already stupid fast + # and I'm lazy because there's too many of them already existing. + fix_map(expected.EXPECTED) + fix_map(casted_map) assert casted_map == expected.EXPECTED diff --git a/tests/test_tiled_object.py b/tests/test_tiled_object_json.py similarity index 96% rename from tests/test_tiled_object.py rename to tests/test_tiled_object_json.py index 10e71c4a..eacbc3ed 100644 --- a/tests/test_tiled_object.py +++ b/tests/test_tiled_object_json.py @@ -5,7 +5,17 @@ import pytest -from pytiled_parser import common_types, tiled_object +from pytiled_parser import common_types +from pytiled_parser.parsers.json.tiled_object import parse +from pytiled_parser.tiled_object import ( + Ellipse, + Point, + Polygon, + Polyline, + Rectangle, + Text, + Tile, +) ELLIPSES = [ ( @@ -23,7 +33,7 @@ "y":81.1913152210981 } """, - tiled_object.Ellipse( + Ellipse( id=6, size=common_types.Size(57.4013868364215, 18.5517790155735), name="name: ellipse", @@ -48,7 +58,7 @@ "y":53.9092872570194 } """, - tiled_object.Ellipse( + Ellipse( id=7, size=common_types.Size(6.32943048766625, 31.4288962146186), name="name: ellipse - invisible", @@ -73,7 +83,7 @@ "y":120.040923041946 } """, - tiled_object.Ellipse( + Ellipse( id=8, size=common_types.Size(29.6828464249176, 24.2264408321018), name="name: ellipse - rotated", @@ -98,7 +108,7 @@ "y":127.679890871888 } """, - tiled_object.Ellipse( + Ellipse( id=29, name="name: ellipse - no width or height", rotation=0, @@ -124,7 +134,7 @@ "y":23.571672160964 } """, - tiled_object.Rectangle( + Rectangle( id=1, size=common_types.Size(45.3972945322269, 41.4686825053996), name="name: rectangle", @@ -148,7 +158,7 @@ "y":91.0128452881664 } """, - tiled_object.Rectangle( + Rectangle( id=4, size=common_types.Size(30.9923837671934, 32.7384335568944), name="name: rectangle - invisible", @@ -172,7 +182,7 @@ "y":23.3534159372513 } """, - tiled_object.Rectangle( + Rectangle( id=5, size=common_types.Size(10, 22), name="name: rectangle - rotated", @@ -196,7 +206,7 @@ "y":53.4727748095942 } """, - tiled_object.Rectangle( + Rectangle( id=28, size=common_types.Size(0, 0), name="name: rectangle - no width or height", @@ -251,7 +261,7 @@ "y":131.826759122428 } """, - tiled_object.Rectangle( + Rectangle( id=30, size=common_types.Size(21.170853700125, 13.7501420938956), name="name: rectangle - properties", @@ -287,7 +297,7 @@ "y":82.9373650107991 } """, - tiled_object.Point( + Point( id=2, name="name: point", rotation=0, @@ -311,7 +321,7 @@ "y":95.8144822098443 } """, - tiled_object.Point( + Point( id=3, name="name: point invisible", rotation=0, @@ -338,7 +348,7 @@ "y":48.3019211094691 } """, - tiled_object.Tile( + Tile( id=13, size=common_types.Size(32, 32), name="name: tile", @@ -364,7 +374,7 @@ "y":168.779356598841 } """, - tiled_object.Tile( + Tile( id=14, size=common_types.Size(32, 32), name="name: tile - invisible", @@ -390,7 +400,7 @@ "y":59.8695009662385 } """, - tiled_object.Tile( + Tile( id=15, size=common_types.Size(32, 32), name="name: tile - horizontal flipped", @@ -416,7 +426,7 @@ "y":60.742525861089 } """, - tiled_object.Tile( + Tile( id=16, size=common_types.Size(32, 32), name="name: tile - vertical flipped", @@ -442,7 +452,7 @@ "y":95.6635216551097 } """, - tiled_object.Tile( + Tile( id=17, size=common_types.Size(32, 32), name="name: tile - both flipped", @@ -468,7 +478,7 @@ "y":142.62 } """, - tiled_object.Tile( + Tile( id=18, size=common_types.Size(32, 32), name="name: tile - rotated", @@ -517,7 +527,7 @@ "y":38.6313515971354 } """, - tiled_object.Polygon( + Polygon( id=9, name="name: polygon", points=[ @@ -560,7 +570,7 @@ "y":24.4446970558145 } """, - tiled_object.Polygon( + Polygon( id=10, name="name: polygon - invisible", points=[ @@ -613,7 +623,7 @@ "y":19.8613163578493 } """, - tiled_object.Polygon( + Polygon( id=11, name="name: polygon - rotated", points=[ @@ -660,7 +670,7 @@ "y":90.1398203933159 } """, - tiled_object.Polyline( + Polyline( id=12, name="name: polyline", points=[ @@ -701,7 +711,7 @@ "y":163.333333333333 } """, - tiled_object.Polyline( + Polyline( id=31, name="name: polyline - invisible", points=[ @@ -742,7 +752,7 @@ "y":128.666666666667 } """, - tiled_object.Polyline( + Polyline( id=32, name="name: polyline - rotated", points=[ @@ -778,7 +788,7 @@ "y":93.2986813686484 } """, - tiled_object.Text( + Text( id=19, name="name: text", text="Hello World", @@ -809,7 +819,7 @@ "y":112.068716607935 } """, - tiled_object.Text( + Text( id=20, name="name: text - invisible", text="Hello World", @@ -840,7 +850,7 @@ "y":78.4572581561896 } """, - tiled_object.Text( + Text( id=21, name="name: text - rotated", text="Hello World", @@ -874,7 +884,7 @@ "y":101.592417869728 } """, - tiled_object.Text( + Text( id=22, name="name: text - different font", text="Hello World", @@ -907,7 +917,7 @@ "y":154.192167784472 } """, - tiled_object.Text( + Text( id=23, name="name: text - no word wrap", text="Hello World", @@ -939,7 +949,7 @@ "y":1.19455496191883 } """, - tiled_object.Text( + Text( id=24, name="name: text - right bottom align", text="Hello World", @@ -973,7 +983,7 @@ "y": 3.81362964647039 } """, - tiled_object.Text( + Text( id=25, name="text: center center align", rotation=0, @@ -1006,7 +1016,7 @@ "y": 60.7785040354666 } """, - tiled_object.Text( + Text( id=26, name="name: text - justified", rotation=0, @@ -1038,7 +1048,7 @@ "y": 130.620495623508 } """, - tiled_object.Text( + Text( id=27, name="name: text - red", rotation=0, @@ -1075,7 +1085,7 @@ "y":22 } """, - tiled_object.Text( + Text( id=31, name="name: text - font options", rotation=0, @@ -1100,7 +1110,7 @@ @pytest.mark.parametrize("raw_object_json,expected", OBJECTS) def test_parse_layer(raw_object_json, expected): raw_object = json.loads(raw_object_json) - result = tiled_object.cast(raw_object) + result = parse(raw_object) assert result == expected @@ -1118,4 +1128,4 @@ def test_parse_no_parent_dir(): json_object = json.loads(raw_object) with pytest.raises(RuntimeError): - tiled_object.cast(json_object) + parse(json_object) diff --git a/tests/test_tiled_object_tmx.py b/tests/test_tiled_object_tmx.py new file mode 100644 index 00000000..d2777594 --- /dev/null +++ b/tests/test_tiled_object_tmx.py @@ -0,0 +1,492 @@ +"""Tests for objects""" +import xml.etree.ElementTree as etree +from contextlib import ExitStack as does_not_raise +from pathlib import Path + +import pytest + +from pytiled_parser import common_types +from pytiled_parser.parsers.tmx.tiled_object import parse +from pytiled_parser.tiled_object import ( + Ellipse, + Point, + Polygon, + Polyline, + Rectangle, + Text, + Tile, +) + +ELLIPSES = [ + ( + """ + + + + """, + Ellipse( + id=6, + size=common_types.Size(57.4014, 18.5518), + name="ellipse", + coordinates=common_types.OrderedPair(37.5401, 81.1913), + ), + ), + ( + """ + + + + """, + Ellipse( + id=7, + size=common_types.Size(6.3294, 31.4289), + name="ellipse - invisible", + visible=False, + coordinates=common_types.OrderedPair(22.6986, 53.9093), + ), + ), + ( + """ + + + + """, + Ellipse( + id=8, + size=common_types.Size(29.6828, 24.2264), + name="ellipse - rotated", + rotation=111, + coordinates=common_types.OrderedPair(35.7940, 120.0409), + ), + ), + ( + """ + + + + """, + Ellipse( + id=29, + name="ellipse - no width or height", + coordinates=common_types.OrderedPair(72.4611, 127.6799), + ), + ), +] + +RECTANGLES = [ + ( + """ + + """, + Rectangle( + id=1, + size=common_types.Size(45.3973, 41.4687), + coordinates=common_types.OrderedPair(27.7185, 23.5717), + name="rectangle", + ), + ), + ( + """ + + """, + Rectangle( + id=4, + size=common_types.Size(30.9924, 32.7384), + coordinates=common_types.OrderedPair(163.9104, 91.0128), + name="rectangle - invisible", + visible=False, + ), + ), + ( + """ + + """, + Rectangle( + id=5, + size=common_types.Size(10, 22), + coordinates=common_types.OrderedPair(183.3352, 23.3534), + name="rectangle - rotated", + rotation=10, + ), + ), + ( + """ + + """, + Rectangle( + id=28, + coordinates=common_types.OrderedPair(131.1720, 53.4728), + name="rectangle - no width or height", + ), + ), + ( + r""" + + + + + + + + + + + """, + Rectangle( + id=30, + size=common_types.Size(21.1709, 13.7501), + coordinates=common_types.OrderedPair(39.0679, 131.8268), + name="rectangle - properties", + properties={ + "bool property": False, + "color property": common_types.Color(170, 0, 0, 255), + "file property": Path("../../../../../../dev/null"), + "float property": 42.1, + "int property": 8675309, + "string property": "pytiled_parser rulez!1!!", + }, + ), + ), +] + +POINTS = [ + ( + """ + + + + """, + Point( + id=2, coordinates=common_types.OrderedPair(159.9818, 82.9374), name="point" + ), + ), + ( + """ + + + + """, + Point( + id=2, + coordinates=common_types.OrderedPair(159.9818, 82.9374), + name="point - invisible", + visible=False, + ), + ), +] + +POLYGONS = [ + ( + """ + + + + """, + Polygon( + id=9, + coordinates=common_types.OrderedPair(89.4851, 38.6314), + name="polygon", + points=[ + common_types.OrderedPair(0, 0), + common_types.OrderedPair(19.4248, 27.0638), + common_types.OrderedPair(19.6431, 3.0556), + common_types.OrderedPair(-2.6191, 15.9327), + common_types.OrderedPair(25.3177, 16.3692), + ], + ), + ), + ( + """ + + + + """, + Polygon( + id=9, + coordinates=common_types.OrderedPair(89.4851, 38.6314), + name="polygon - invisible", + points=[ + common_types.OrderedPair(0, 0), + common_types.OrderedPair(19.4248, 27.0638), + common_types.OrderedPair(19.6431, 3.0556), + common_types.OrderedPair(-2.6191, 15.9327), + common_types.OrderedPair(25.3177, 16.3692), + ], + visible=False, + ), + ), + ( + """ + + + + """, + Polygon( + id=9, + coordinates=common_types.OrderedPair(89.4851, 38.6314), + name="polygon - rotated", + points=[ + common_types.OrderedPair(0, 0), + common_types.OrderedPair(19.4248, 27.0638), + common_types.OrderedPair(19.6431, 3.0556), + common_types.OrderedPair(-2.6191, 15.9327), + common_types.OrderedPair(25.3177, 16.3692), + ], + rotation=123, + ), + ), +] + +POLYLINES = [ + ( + """ + + + + """, + Polyline( + id=12, + coordinates=common_types.OrderedPair(124.1878, 90.1398), + name="polyline", + points=[ + common_types.OrderedPair(0, 0), + common_types.OrderedPair(-13.3136, 41.0321), + common_types.OrderedPair(21.3891, 16.8057), + ], + ), + ), + ( + """ + + + + """, + Polyline( + id=12, + coordinates=common_types.OrderedPair(124.1878, 90.1398), + name="polyline - invisible", + points=[ + common_types.OrderedPair(0, 0), + common_types.OrderedPair(-13.3136, 41.0321), + common_types.OrderedPair(21.3891, 16.8057), + ], + visible=False, + ), + ), + ( + """ + + + + """, + Polyline( + id=12, + coordinates=common_types.OrderedPair(124.1878, 90.1398), + name="polyline - rotated", + points=[ + common_types.OrderedPair(0, 0), + common_types.OrderedPair(-13.3136, 41.0321), + common_types.OrderedPair(21.3891, 16.8057), + ], + rotation=110, + ), + ), +] + +TEXTS = [ + ( + """ + + Hello World + + """, + Text( + id=19, + name="text", + text="Hello World", + size=common_types.Size(92.375, 19), + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), + ( + """ + + Hello World + + """, + Text( + id=19, + name="text - wrap", + text="Hello World", + wrap=True, + size=common_types.Size(92.375, 19), + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), + ( + """ + + Hello World + + """, + Text( + id=19, + name="text - rotated", + text="Hello World", + rotation=110, + size=common_types.Size(92.375, 19), + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), + ( + """ + + Hello World + + """, + Text( + id=19, + name="text - different font", + text="Hello World", + font_size=19, + font_family="DejaVu Sans", + rotation=110, + size=common_types.Size(92.375, 19), + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), + ( + """ + + Hello World + + """, + Text( + id=19, + name="text - right bottom align", + text="Hello World", + horizontal_align="right", + vertical_align="bottom", + size=common_types.Size(92.375, 19), + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), + ( + """ + + Hello World + + """, + Text( + id=19, + name="text - center center align", + text="Hello World", + horizontal_align="center", + vertical_align="center", + size=common_types.Size(92.375, 19), + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), + ( + """ + + Hello World + + """, + Text( + id=19, + name="text - justified", + text="Hello World", + horizontal_align="justify", + size=common_types.Size(92.375, 19), + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), + ( + """ + + Hello World + + """, + Text( + id=19, + name="text - colored", + text="Hello World", + color=common_types.Color(170, 0, 0, 255), + size=common_types.Size(92.375, 19), + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), + ( + """ + + Hello World + + """, + Text( + id=19, + name="text - font options", + text="Hello World", + size=common_types.Size(92.375, 19), + bold=True, + italic=True, + kerning=True, + strike_out=True, + underline=True, + wrap=True, + coordinates=common_types.OrderedPair(93.2987, 81.7106), + ), + ), +] + +TILES = [ + ( + """ + + """, + Tile( + id=13, + size=common_types.Size(32, 32), + name="tile", + coordinates=common_types.OrderedPair(111.8981, 48.3019), + gid=79, + ), + ), + ( + """ + + """, + Tile( + id=13, + size=common_types.Size(32, 32), + name="tile - invisible", + type="tile", + coordinates=common_types.OrderedPair(111.8981, 48.3019), + gid=79, + visible=False, + ), + ), + ( + """ + + """, + Tile( + id=13, + size=common_types.Size(32, 32), + name="tile - rotated", + coordinates=common_types.OrderedPair(111.8981, 48.3019), + gid=79, + rotation=110, + ), + ), +] + +OBJECTS = ELLIPSES + RECTANGLES + POINTS + POLYGONS + POLYLINES + TEXTS + TILES + + +@pytest.mark.parametrize("raw_object_tmx,expected", OBJECTS) +def test_parse_layer(raw_object_tmx, expected): + raw_object = etree.fromstring(raw_object_tmx) + result = parse(raw_object) + + assert result == expected diff --git a/tests/test_tileset.py b/tests/test_tileset.py index d24e9d59..9f88f78f 100644 --- a/tests/test_tileset.py +++ b/tests/test_tileset.py @@ -2,11 +2,14 @@ import importlib.util import json import os +import xml.etree.ElementTree as etree from pathlib import Path import pytest -from pytiled_parser import tileset +from pytiled_parser.common_types import OrderedPair, Size +from pytiled_parser.parsers.json.tileset import parse as parse_json +from pytiled_parser.parsers.tmx.tileset import parse as parse_tmx TESTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) TEST_DATA = TESTS_DIR / "test_data" @@ -26,8 +29,26 @@ ] +def fix_object(my_object): + my_object.coordinates = OrderedPair( + round(my_object.coordinates[0], 4), round(my_object.coordinates[1], 4) + ) + my_object.size = Size(round(my_object.size[0], 4), round(my_object.size[1], 4)) + + +def fix_tileset(tileset): + tileset.version = None + tileset.tiled_version = None + if tileset.tiles: + for tile in tileset.tiles.values(): + if tile.objects: + for my_object in tile.objects.tiled_objects: + fix_object(my_object) + + +@pytest.mark.parametrize("parser_type", ["json", "tmx"]) @pytest.mark.parametrize("tileset_dir", ALL_TILESET_DIRS) -def test_tilesets_integration(tileset_dir): +def test_tilesets_integration(parser_type, tileset_dir): # it's a PITA to import like this, don't do it # https://stackoverflow.com/a/67692/1342874 spec = importlib.util.spec_from_file_location( @@ -36,9 +57,16 @@ def test_tilesets_integration(tileset_dir): expected = importlib.util.module_from_spec(spec) spec.loader.exec_module(expected) - raw_tileset_path = tileset_dir / "tileset.json" + if parser_type == "json": + raw_tileset_path = tileset_dir / "tileset.json" + with open(raw_tileset_path) as raw_tileset: + tileset_ = parse_json(json.loads(raw_tileset.read()), 1) + elif parser_type == "tmx": + raw_tileset_path = tileset_dir / "tileset.tsx" + with open(raw_tileset_path) as raw_tileset: + tileset_ = parse_tmx(etree.parse(raw_tileset).getroot(), 1) - with open(raw_tileset_path) as raw_tileset: - tileset_ = tileset.cast(json.loads(raw_tileset.read()), 1) + fix_tileset(tileset_) + fix_tileset(expected.EXPECTED) assert tileset_ == expected.EXPECTED