This commit is contained in:
Benjamin Kirkbride
2019-04-30 21:10:18 -04:00
parent b117bd6fc2
commit 5eaa03e9c3
9 changed files with 1284 additions and 1288 deletions

3
.style.yapf Normal file
View File

@@ -0,0 +1,3 @@
[style]
based_on_style = google
column_limit = 78

View File

@@ -0,0 +1,4 @@
from . import utilities
from . import objects
from .parser import parse_tile_map

File diff suppressed because it is too large Load Diff

611
pytiled_parser/objects.py Normal file
View File

@@ -0,0 +1,611 @@
"""
pytiled_parser objects for Tiled maps.
"""
import dataclasses
import functools
import re
from collections import OrderedDict
from pathlib import Path
import xml.etree.ElementTree as etree
from typing import * # pylint: disable=W0401
class EncodingError(Exception):
"""
Tmx layer encoding is of an unknown type.
"""
class TileNotFoundError(Exception):
"""
Tile not found in tileset.
"""
class ImageNotFoundError(Exception):
"""
Image not found.
"""
class Color(NamedTuple):
"""
Color object.
Attributes:
:red (int): Red, between 1 and 255.
:green (int): Green, between 1 and 255.
:blue (int): Blue, between 1 and 255.
:alpha (int): Alpha, between 1 and 255.
"""
red: int
green: int
blue: int
alpha: int
class OrderedPair(NamedTuple):
"""
OrderedPair NamedTuple.
Attributes:
:x (Union[int, float]): X coordinate.
:y (Union[int, float]): Y coordinate.
"""
x: Union[int, float]
y: Union[int, float]
class Template:
"""
FIXME TODO
"""
@dataclasses.dataclass
class Chunk:
"""
Chunk object for infinite maps.
See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#chunk
Attributes:
:location (OrderedPair): Location of chunk in tiles.
:width (int): The width of the chunk in tiles.
:height (int): The height of the chunk in tiles.
:layer_data (List[List(int)]): The global tile IDs in chunky
according to row.
"""
location: OrderedPair
width: int
height: int
chunk_data: List[List[int]]
class Image(NamedTuple):
"""
Image object.
This module does not support embedded data in image elements.
Attributes:
:source (Optional[str]): The reference to the tileset image file.
Not that this is a relative path compared to FIXME
:trans (Optional[Color]): Defines a specific color that is treated
as transparent.
:width (Optional[str]): The image width in pixels
(optional, used for tile index correction when the image changes).
:height (Optional[str]): The image height in pixels (optional).
"""
source: str
size: OrderedPair
trans: Optional[Color]
Properties = Dict[str, Union[int, float, Color, Path, str]]
class Grid(NamedTuple):
"""
Contains info for isometric maps.
This element is only used in case of isometric orientation, and
determines how tile overlays for terrain and collision information
are rendered.
"""
orientation: str
width: int
height: int
class Terrain(NamedTuple):
"""
Terrain object.
Args:
:name (str): The name of the terrain type.
:tile (int): The local tile-id of the tile that represents the
terrain visually.
"""
name: str
tile: int
class Frame(NamedTuple):
"""
Animation Frame object.
This is only used as a part of an animation for Tile objects.
Args:
:tile_id (int): The local ID of a tile within the parent tile set
object.
:duration (int): How long in milliseconds this frame should be
displayed before advancing to the next frame.
"""
tile_id: int
duration: int
@dataclasses.dataclass
class TileTerrain:
"""
Defines each corner of a tile by Terrain index in
'TileSet.terrain_types'.
Defaults to 'None'. 'None' means that corner has no terrain.
Attributes:
:top_left (Optional[int]): Top left terrain type.
:top_right (Optional[int]): Top right terrain type.
:bottom_left (Optional[int]): Bottom left terrain type.
:bottom_right (Optional[int]): Bottom right terrain type.
"""
top_left: Optional[int] = None
top_right: Optional[int] = None
bottom_left: Optional[int] = None
bottom_right: Optional[int] = None
@dataclasses.dataclass
class _LayerTypeBase:
id: int # pylint: disable=C0103
name: str
@dataclasses.dataclass
class _LayerTypeDefaults:
offset: OrderedPair = OrderedPair(0, 0)
opacity: int = 0xFF
properties: Optional[Properties] = None
@dataclasses.dataclass
class LayerType(_LayerTypeDefaults, _LayerTypeBase):
"""
Class that all layer classes inherit from.
Not to be directly used.
Args:
:layer_element (etree.Element): Element to be parsed into a
LayerType object.
Attributes:
:id (int): Unique ID of the layer. Each layer that added to a map
gets a unique id. Even if a layer is deleted, no layer ever gets
the same ID.
:name (Optional[str):] The name of the layer object.
:offset (OrderedPair): Rendering offset of the layer object in
pixels. (default: (0, 0).
:opacity (int): Value between 0 and 255 to determine opacity. NOTE:
this value is converted from a float provided by Tiled, so some
precision is lost.
:properties (Optional[Properties]): Properties object for layer
object.
"""
LayerData = Union[List[List[int]], List[Chunk]]
"""
The tile data for one layer.
Either a 2 dimensional array of integers representing the global tile IDs
for a map layer, or a lists of chunks for an infinite map layer.
"""
@dataclasses.dataclass
class _LayerBase:
size: OrderedPair
data: LayerData
@dataclasses.dataclass
class Layer(LayerType, _LayerBase):
"""
Map layer object.
See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#layer
Attributes:
:size (OrderedPair): The width of the layer in tiles. Always the same
as the map width for not infitite maps.
:data (LayerData): Either an 2 dimensional array of integers
representing the global tile IDs for the map layer, or a list of
chunks for an infinite map.
"""
@dataclasses.dataclass
class _ObjectBase:
id: int
location: OrderedPair
@dataclasses.dataclass
class _ObjectDefaults:
size: OrderedPair = OrderedPair(0, 0)
rotation: int = 0
opacity: int = 0xFF
name: Optional[str] = None
type: Optional[str] = None
properties: Optional[Properties] = None
template: Optional[Template] = None
@dataclasses.dataclass
class Object(_ObjectDefaults, _ObjectBase):
"""
ObjectGroup Object.
See: \
https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#object
Args:
:id (int): Unique ID of the object. Each object that is placed on a
map gets a unique id. Even if an object was deleted, no object
gets the same ID.
:location (OrderedPair): The location of the object in pixels.
:size (OrderedPair): The width of the object in pixels
(default: (0, 0)).
:rotation (int): The rotation of the object in degrees clockwise
(default: 0).
:opacity (int): The opacity of the object. (default: 255)
:name (Optional[str]): The name of the object.
:type (Optional[str]): The type of the object.
:properties (Properties): The properties of the Object.
:template Optional[Template]: A reference to a Template object
FIXME
"""
@dataclasses.dataclass
class RectangleObject(Object):
"""
Rectangle shape defined by a point, width, and height.
See: https://doc.mapeditor.org/en/stable/manual/objects/#insert-rectangle
(objects in tiled are rectangles by default, so there is no specific
documentation on the tmx-map-format page for it.)
"""
@dataclasses.dataclass
class ElipseObject(Object):
"""
Elipse shape defined by a point, width, and height.
See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#ellipse
"""
@dataclasses.dataclass
class PointObject(Object):
"""
Point defined by a point (x,y).
See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#point
"""
@dataclasses.dataclass
class _TileObjectBase(_ObjectBase):
gid: int
@dataclasses.dataclass
class TileObject(Object, _TileObjectBase):
"""
Polygon shape defined by a set of connections between points.
See: https://doc.mapeditor.org/en/stable/manual/objects/#insert-tile
Attributes:
:gid (int): Refference to a global tile id.
"""
@dataclasses.dataclass
class _PointsObjectBase(_ObjectBase):
points: List[OrderedPair]
@dataclasses.dataclass
class PolygonObject(Object, _PointsObjectBase):
"""
Polygon shape defined by a set of connections between points.
See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#polygon
Attributes:
:points (List[OrderedPair])
"""
@dataclasses.dataclass
class PolylineObject(Object, _PointsObjectBase):
"""
Polyline defined by a set of connections between points.
See: \
https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#polyline
Attributes:
:points (List[Tuple[int, int]]): List of coordinates relative to \
the location of the object.
"""
@dataclasses.dataclass
class _TextObjectBase(_ObjectBase):
text: str
@dataclasses.dataclass
class _TextObjectDefaults(_ObjectDefaults):
font_family: str = 'sans-serif'
font_size: int = 16
wrap: bool = False
color: Color = Color(0xFF, 0, 0, 0)
bold: bool = False
italic: bool = False
underline: bool = False
strike_out: bool = False
kerning: bool = False
horizontal_align: str = 'left'
vertical_align: str = 'top'
@dataclasses.dataclass
class TextObject(Object, _TextObjectDefaults, _TextObjectBase):
"""
Text object with associated settings.
See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#text
and https://doc.mapeditor.org/en/stable/manual/objects/#insert-text
Attributes:
:font_family (str): The font family used (default: “sans-serif”)
:font_size (int): The size of the font in pixels. (default: 16)
:wrap (bool): Whether word wrapping is enabled. (default: False)
:color (Color): Color of the text. (default: #000000)
:bold (bool): Whether the font is bold. (default: False)
:italic (bool): Whether the font is italic. (default: False)
:underline (bool): Whether the text is underlined. (default: False)
:strike_out (bool): Whether the text is striked-out. (default: False)
:kerning (bool): Whether kerning should be used while rendering the \
text. (default: False)
:horizontal_align (str): Horizontal alignment of the text \
(default: "left")
:vertical_align (str): Vertical alignment of the text (defalt: "top")
"""
@dataclasses.dataclass
class _ObjectGroupBase(_LayerTypeBase):
objects: List[Object]
@dataclasses.dataclass
class _ObjectGroupDefaults(_LayerTypeDefaults):
color: Optional[Color] = None
draw_order: Optional[str] = 'topdown'
@dataclasses.dataclass
class ObjectGroup(LayerType, _ObjectGroupDefaults, _ObjectGroupBase):
"""
Object Group Object.
The object group is in fact a map layer, and is hence called \
“object layer” in Tiled.
See: \
https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#objectgroup
Attributes:
:color (Optional[Color]): The color used to display the objects
in this group. FIXME: editor only?
:draworder (str): Whether the objects are drawn according to the
order of the object elements in the object group element
('manual'), or sorted by their y-coordinate ('topdown'). Defaults
to 'topdown'. See:
https://doc.mapeditor.org/en/stable/manual/objects/#changing-stacking-order
for more info.
:objects (Dict[int, Object]): Dict Object objects by Object.id.
"""
class _LayerGroupBase(_LayerTypeBase):
layers: Optional[List[LayerType]]
class LayerGroup(LayerType):
"""
Layer Group.
A LayerGroup can be thought of as a layer that contains layers
(potentially including other LayerGroups).
Offset and opacity recursively affect child layers.
See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#group
Attributes:
"""
@dataclasses.dataclass
class Tile:
"""
Individual tile object.
Args:
:id (int): The local tile ID within its tileset.
:type (str): The type of the tile. Refers to an object type and is
used by tile objects.
:terrain (int): Defines the terrain type of each corner of the tile.
:animation (List[Frame]): Each tile can have exactly one animation
associated with it.
"""
id: int
type: Optional[str]
terrain: Optional[TileTerrain]
animation: Optional[List[Frame]]
image: Optional[Image]
hit_box: Optional[List[Object]]
@dataclasses.dataclass
class TileSet:
"""
Object for storing a TSX with all associated collision data.
Args:
:name (str): The name of this tileset.
:max_tile_size (OrderedPair): The maximum size of a tile in this
tile set in pixels.
:spacing (int): The spacing in pixels between the tiles in this
tileset (applies to the tileset image).
:margin (int): The margin around the tiles in this tileset
(applies to the tileset image).
:tile_count (int): The number of tiles in this tileset.
:columns (int): The number of tile columns in the tileset.
For image collection tilesets it is editable and is used when
displaying the tileset.
:grid (Grid): Only used in case of isometric orientation, and
determines how tile overlays for terrain and collision information
are rendered.
:tileoffset (Optional[OrderedPair]): Used to specify an offset in
pixels when drawing a tile from the tileset. When not present, no
offset is applied.
:image (Image): Used for spritesheet tile sets.
:terrain_types (Dict[str, int]): List of of terrain types which
can be referenced from the terrain attribute of the tile object.
Ordered according to the terrain element's appearance in the TSX
file.
:tiles (Optional[Dict[int, Tile]]): Dict of Tile objects by Tile.id.
"""
name: str
max_tile_size: OrderedPair
spacing: Optional[int]
margin: Optional[int]
tile_count: Optional[int]
columns: Optional[int]
tile_offset: Optional[OrderedPair]
grid: Optional[Grid]
properties: Optional[Properties]
image: Optional[Image]
terrain_types: Optional[List[Terrain]]
tiles: Optional[Dict[int, Tile]]
@dataclasses.dataclass
class TileMap:
"""
Object for storing a TMX with all associated layers and properties.
See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#map
Attributes:
:parent_dir (Path): The directory the TMX file is in. Used for finding
relative paths to TSX files and other assets.
:version (str): The TMX format version.
:tiledversion (str): The Tiled version used to save the file. May
be a date (for snapshot builds).
:orientation (str): Map orientation. Tiled supports “orthogonal”,
“isometric”, “staggered” and “hexagonal”
:renderorder (str): The order in which tiles on tile layers are
rendered. Valid values are right-down, right-up, left-down and
left-up. In all cases, the map is drawn row-by-row. (only
supported for orthogonal maps at the moment)
:map_size (OrderedPair): The map width in tiles.
:tile_size (OrderedPair): The width of a tile.
:infinite (bool): If the map is infinite or not.
:hexsidelength (int): Only for hexagonal maps. Determines the width or
height (depending on the staggered axis) of the tiles edge, in
pixels.
:stagger_axis (str): For staggered and hexagonal maps, determines
which axis (“x” or “y”) is staggered.
:staggerindex (str): For staggered and hexagonal maps, determines
whether the “even” or “odd” indexes along the staggered axis are
shifted.
:backgroundcolor (##FIXME##): The background color of the map.
:nextlayerid (int): Stores the next available ID for new layers.
:nextobjectid (int): Stores the next available ID for new objects.
:tile_sets (dict[str, TileSet]): Dict of tile sets used
in this map. Key is the source for external tile sets or the name
for embedded ones. The value is a TileSet object.
:layers List[LayerType]: List of layer objects by draw order.
"""
parent_dir: Path
version: str
tiled_version: str
orientation: str
render_order: str
map_size: OrderedPair
tile_size: OrderedPair
infinite: bool
next_layer_id: int
next_object_id: int
tile_sets: Dict[int, TileSet]
layers: List[LayerType]
hex_side_length: Optional[int] = None
stagger_axis: Optional[int] = None
stagger_index: Optional[int] = None
background_color: Optional[Color] = None
properties: Optional[Properties] = None
'''
[22:16] <__m4ch1n3__> i would "[i for i in int_list if i < littler_then_value]"
[22:16] <__m4ch1n3__> it returns a list of integers below "littler_then_value"
[22:17] <__m4ch1n3__> !py3 [i for i in [1,2,3,4,1,2,3,4] if i < 3]
[22:17] <codebot> __m4ch1n3__: [1, 2, 1, 2]
[22:17] <__m4ch1n3__> !py3 [i for i in [1,2,3,4,1,2,3,4] if i < 4]
[22:17] <codebot> __m4ch1n3__: [1, 2, 3, 1, 2, 3]
[22:22] <__m4ch1n3__> !py3 max([i for i in [1,2,3,4,1,2,3,4] if i < 4])
[22:22] <codebot> __m4ch1n3__: 3
[22:22] <__m4ch1n3__> max(...) would return the maximum of resulting list
[22:23] <__m4ch1n3__> !py3 max([i for i in [1, 10, 100] if i < 20])
[22:23] <codebot> __m4ch1n3__: 10
[22:23] <__m4ch1n3__> !py3 max([i for i in [1, 10, 100] if i < 242])
[22:23] <codebot> __m4ch1n3__: 100
[22:23] == markb1 [~mbiggers@45.36.35.206] has quit [Ping timeout: 245 seconds]
[22:23] <__m4ch1n3__> !py3 max(i for i in [1, 10, 100] if i < 242)
'''
#buffer

580
pytiled_parser/parser.py Normal file
View File

@@ -0,0 +1,580 @@
import functools
import re
from pathlib import Path
from typing import *
import pytiled_parser.objects as objects
import pytiled_parser.utilities as utilities
import xml.etree.ElementTree as etree
def _decode_base64_data(data_text, compression, layer_width):
tile_grid: List[List[int]] = [[]]
unencoded_data = base64.b64decode(data_text)
if compression == "zlib":
unzipped_data = zlib.decompress(unencoded_data)
elif compression == "gzip":
unzipped_data = gzip.decompress(unencoded_data)
elif compression is None:
unzipped_data = unencoded_data
else:
raise ValueError(f"Unsupported compression type '{compression}'.")
# Turn bytes into 4-byte integers
byte_count = 0
int_count = 0
int_value = 0
row_count = 0
for byte in unzipped_data:
int_value += byte << (byte_count * 8)
byte_count += 1
if byte_count % 4 == 0:
byte_count = 0
int_count += 1
tile_grid[row_count].append(int_value)
int_value = 0
if int_count % layer_width == 0:
row_count += 1
tile_grid.append([])
tile_grid.pop()
return tile_grid
def _decode_csv_layer(data_text):
"""
Decodes csv encoded layer data.
Credit:
"""
tile_grid = []
lines = data_text.split("\n")
# remove erronious empty lists due to a newline being on both ends of text
lines = lines[1:]
lines = lines[:-1]
for line in lines:
line_list = line.split(",")
while '' in line_list:
line_list.remove('')
line_list_int = [int(item) for item in line_list]
tile_grid.append(line_list_int)
return tile_grid
def _decode_data(element: etree.Element, layer_width: int, encoding: str,
compression: Optional[str]) -> List[List[int]]:
"""
Decodes data or chunk data.
Args:
:element (Element): Element to have text decoded.
:layer_width (int): Number of tiles per column in this layer. Used
for determining when to cut off a row when decoding base64
encoding layers.
:encoding (str): Encoding format of the layer data.
:compression (str): Compression format of the layer data.
"""
# etree.Element.text comes with an appended and a prepended '\n'
supported_encodings = ['base64', 'csv']
if encoding not in supported_encodings:
raise ValueError('{encoding} is not a valid encoding')
supported_compression = [None, 'gzip', 'zlib']
if compression is not None:
if encoding != 'base64':
raise ValueError('{encoding} does not support compression')
if compression not in supported_compression:
raise ValueError('{compression} is not a valid compression type')
try:
data_text = element.text # type: ignore
except AttributeError:
raise AttributeError('{element} lacks layer data.')
if encoding == 'csv':
return _decode_csv_layer(data_text)
return _decode_base64_data(data_text, compression, layer_width)
def _parse_data(element: etree.Element,
layer_width: int) -> objects.LayerData:
"""
Parses layer data.
Will parse CSV, base64, gzip-base64, or zlip-base64 encoded data.
Args:
:element (Element): Data element to parse.
:width (int): Layer width. Used for base64 decoding.
Returns:
:LayerData: Data object containing layer data or chunks of data.
"""
encoding = element.attrib['encoding']
compression = None
try:
compression = element.attrib['compression']
except KeyError:
pass
chunk_elements = element.findall('./chunk')
if chunk_elements:
chunks: List[objects.Chunk] = []
for chunk_element in chunk_elements:
x = int(chunk_element.attrib['x'])
y = int(chunk_element.attrib['y'])
location = objects.OrderedPair(x, y)
width = int(chunk_element.attrib['width'])
height = int(chunk_element.attrib['height'])
layer_data = _decode_data(chunk_element, layer_width, encoding,
compression)
chunks.append(objects.Chunk(location, width, height, layer_data))
return chunks
return _decode_data(element, layer_width, encoding, compression)
def _parse_layer(element: etree.Element,
layer_type: objects.LayerType) -> objects.Layer:
"""
Parse layer element given.
"""
width = int(element.attrib['width'])
height = int(element.attrib['height'])
size = objects.OrderedPair(width, height)
data_element = element.find('./data')
if data_element is not None:
data: objects.LayerData = _parse_data(data_element, width)
else:
raise ValueError('{element} has no child data element.')
return objects.Layer(size, data, **layer_type.__dict__)
def _parse_layer_type(layer_element: etree.Element) -> objects.LayerType:
"""
Parse layer type element given.
"""
id = int(layer_element.attrib['id'])
name = layer_element.attrib['name']
layer_type_object = objects.LayerType(id, name)
try:
offset_x = float(layer_element.attrib['offsetx'])
except KeyError:
offset_x = 0
try:
offset_y = float(layer_element.attrib['offsety'])
except KeyError:
offset_y = 0
offset = objects.OrderedPair(offset_x, offset_y)
try:
layer_type_object.opacity = round(
float(layer_element.attrib['opacity']) * 255)
except KeyError:
pass
properties_element = layer_element.find('./properties')
if properties_element is not None:
layer_type_object.properties = _parse_properties_element(
properties_element)
if layer_element.tag == 'layer':
return _parse_layer(layer_element, layer_type_object)
elif layer_element.tag == 'objectgroup':
return _parse_object_group(layer_element, layer_type_object)
# else:
# return _parse_layer_group(layer_element, layer_type_object)
def _parse_object_group(element: etree.Element,
layer_type: objects.LayerType) -> objects.ObjectGroup:
"""
Parse object group element given.
"""
object_elements = element.findall('./object')
tile_objects: List[objects.Object] = []
for object_element in object_elements:
id = int(object_element.attrib['id'])
location_x = float(object_element.attrib['x'])
location_y = float(object_element.attrib['y'])
location = objects.OrderedPair(location_x, location_y)
object = objects.Object(id, location)
try:
width = float(object_element.attrib['width'])
except KeyError:
width = 0
try:
height = float(object_element.attrib['height'])
except KeyError:
height = 0
object.size = objects.OrderedPair(width, height)
try:
object.opacity = round(
float(object_element.attrib['opacity']) * 255)
except KeyError:
pass
try:
object.rotation = int(object_element.attrib['rotation'])
except KeyError:
pass
try:
object.name = object_element.attrib['name']
except KeyError:
pass
properties_element = object_element.find('./properties')
if properties_element is not None:
object.properties = _parse_properties_element(properties_element)
tile_objects.append(object)
object_group = objects.ObjectGroup(tile_objects, **layer_type.__dict__)
try:
color = utilities.parse_color(element.attrib['color'])
except KeyError:
pass
try:
draw_order = element.attrib['draworder']
except KeyError:
pass
return object_group
@functools.lru_cache()
def _parse_external_tile_set(parent_dir: Path, tile_set_element: etree.Element
) -> objects.TileSet:
"""
Parses an external tile set.
Caches the results to speed up subsequent instances.
"""
source = Path(tile_set_element.attrib['source'])
tile_set_tree = etree.parse(str(parent_dir / Path(source))).getroot()
return _parse_tile_set(tile_set_tree)
def _parse_tiles(tile_element_list: List[etree.Element]
) -> Dict[int, objects.Tile]:
tiles: Dict[int, objects.Tile] = {}
for tile_element in tile_element_list:
# id is not optional
id = int(tile_element.attrib['id'])
# optional attributes
type = None
try:
type = tile_element.attrib['type']
except KeyError:
pass
tile_terrain = None
try:
tile_terrain_attrib = tile_element.attrib['terrain']
except KeyError:
pass
else:
# an attempt to explain how terrains are handled is below.
# 'terrain' attribute is a comma seperated list of 4 values,
# each is either an integer or blank
# convert to list of values
terrain_list_attrib = re.split(',', tile_terrain_attrib)
# terrain_list is list of indexes of Tileset.terrain_types
terrain_list: List[Optional[int]] = []
# each index in terrain_list_attrib reffers to a corner
for corner in terrain_list_attrib:
if corner == '':
terrain_list.append(None)
else:
terrain_list.append(int(corner))
tile_terrain = objects.TileTerrain(*terrain_list)
# tile element optional sub-elements
animation: Optional[List[objects.Frame]] = None
tile_animation_element = tile_element.find('./animation')
if tile_animation_element:
animation = []
frames = tile_animation_element.findall('./frame')
for frame in frames:
# tileid reffers to the Tile.id of the animation frame
tile_id = int(frame.attrib['tileid'])
# duration is in MS. Should perhaps be converted to seconds.
# FIXME: make decision
duration = int(frame.attrib['duration'])
animation.append(objects.Frame(tile_id, duration))
# if this is None, then the Tile is part of a spritesheet
tile_image = None
tile_image_element = tile_element.find('./image')
if tile_image_element:
tile_image = _parse_image_element(tile_image_element)
object_group = None
tile_object_group_element = tile_element.find('./objectgroup')
if tile_object_group_element:
### FIXME: why did they do this :(
pass
tiles[id] = objects.Tile(id, type, tile_terrain, animation,
tile_image, object_group)
return tiles
def _parse_image_element(image_element: etree.Element) -> objects.Image:
"""
Parse image element given.
Returns:
:Color: Color in Arcade's preffered format.
"""
source = image_element.attrib['source']
trans = None
try:
trans = utilities.parse_color(image_element.attrib['trans'])
except KeyError:
pass
width = int(image_element.attrib['width'])
height = int(image_element.attrib['height'])
size = objects.OrderedPair(width, height)
return objects.Image(source, size, trans)
def _parse_properties_element(properties_element: etree.Element
) -> objects.Properties:
"""
Adds Tiled property to Properties dict.
Args:
:name (str): Name of property.
:property_type (str): Type of property. Can be string, int, float,
bool, color or file. Defaults to string.
:value (str): The value of the property.
Returns:
:Properties: Properties Dict object.
"""
properties: objects.Properties = {}
for property_element in properties_element.findall('./property'):
name = property_element.attrib['name']
try:
property_type = property_element.attrib['type']
except KeyError:
# strings do not have an attribute in property elements
property_type = 'string'
value = property_element.attrib['value']
property_types = ['string', 'int', 'float', 'bool', 'color', 'file']
assert property_type in property_types, (
f"Invalid type for property {name}")
if property_type == 'int':
properties[name] = int(value)
elif property_type == 'float':
properties[name] = float(value)
elif property_type == 'color':
properties[name] = utilities.parse_color(value)
elif property_type == 'file':
properties[name] = Path(value)
elif property_type == 'bool':
if value == 'true':
properties[name] = True
else:
properties[name] = False
else:
properties[name] = value
return properties
def _parse_tile_set(tile_set_element: etree.Element) -> objects.TileSet:
"""
Parses a tile set that is embedded into a TMX.
"""
# get all basic attributes
name = tile_set_element.attrib['name']
max_tile_width = int(tile_set_element.attrib['tilewidth'])
max_tile_height = int(tile_set_element.attrib['tileheight'])
max_tile_size = objects.OrderedPair(max_tile_width, max_tile_height)
spacing = None
try:
spacing = int(tile_set_element.attrib['spacing'])
except KeyError:
pass
margin = None
try:
margin = int(tile_set_element.attrib['margin'])
except KeyError:
pass
tile_count = None
try:
tile_count = int(tile_set_element.attrib['tilecount'])
except KeyError:
pass
columns = None
try:
columns = int(tile_set_element.attrib['columns'])
except KeyError:
pass
tile_offset = None
tileoffset_element = tile_set_element.find('./tileoffset')
if tileoffset_element is not None:
tile_offset_x = int(tileoffset_element.attrib['x'])
tile_offset_y = int(tileoffset_element.attrib['y'])
tile_offset = objects.OrderedPair(tile_offset_x, tile_offset_y)
grid = None
grid_element = tile_set_element.find('./grid')
if grid_element is not None:
grid_orientation = grid_element.attrib['orientation']
grid_width = int(grid_element.attrib['width'])
grid_height = int(grid_element.attrib['height'])
grid = objects.Grid(grid_orientation, grid_width, grid_height)
properties = None
properties_element = tile_set_element.find('./properties')
if properties_element is not None:
properties = _parse_properties_element(properties_element)
terrain_types: Optional[List[objects.Terrain]] = None
terrain_types_element = tile_set_element.find('./terraintypes')
if terrain_types_element is not None:
terrain_types = []
for terrain in terrain_types_element.findall('./terrain'):
name = terrain.attrib['name']
terrain_tile = int(terrain.attrib['tile'])
terrain_types.append(objects.Terrain(name, terrain_tile))
image = None
image_element = tile_set_element.find('./image')
if image_element is not None:
image = _parse_image_element(image_element)
tile_element_list = tile_set_element.findall('./tile')
tiles = _parse_tiles(tile_element_list)
return objects.TileSet(name, max_tile_size, spacing, margin, tile_count,
columns, tile_offset, grid, properties, image,
terrain_types, tiles)
def parse_tile_map(tmx_file: Union[str, Path]):
# setting up XML parsing
map_tree = etree.parse(str(tmx_file))
map_element = map_tree.getroot()
# positional arguments for TileMap
parent_dir = Path(tmx_file).parent
version = map_element.attrib['version']
tiled_version = map_element.attrib['tiledversion']
orientation = map_element.attrib['orientation']
render_order = map_element.attrib['renderorder']
map_width = int(map_element.attrib['width'])
map_height = int(map_element.attrib['height'])
map_size = objects.OrderedPair(map_width, map_height)
tile_width = int(map_element.attrib['tilewidth'])
tile_height = int(map_element.attrib['tileheight'])
tile_size = objects.OrderedPair(tile_width, tile_height)
infinite_attribute = map_element.attrib['infinite']
infinite = True if infinite_attribute == 'true' else False
next_layer_id = int(map_element.attrib['nextlayerid'])
next_object_id = int(map_element.attrib['nextobjectid'])
# parse all tilesets
tile_sets: Dict[int, objects.TileSet] = {}
tile_set_element_list = map_element.findall('./tileset')
for tile_set_element in tile_set_element_list:
# tiled docs are ambiguous about the 'firstgid' attribute
# current understanding is for the purposes of mapping the layer
# data to the tile set data, add the 'firstgid' value to each
# tile 'id'; this means that the 'firstgid' is specific to each,
# tile set as they pertain to the map, not tile set specific as
# the tiled docs can make it seem
# 'firstgid' is saved beside each TileMap
firstgid = int(tile_set_element.attrib['firstgid'])
try:
# check if is an external TSX
source = tile_set_element.attrib['source']
except KeyError:
# the tile set in embedded
name = tile_set_element.attrib['name']
tile_sets[firstgid] = _parse_tile_set(tile_set_element)
else:
# tile set is external
tile_sets[firstgid] = _parse_external_tile_set(
parent_dir, tile_set_element)
# parse all layers
layers: List[objects.LayerType] = []
layer_tags = ['layer', 'objectgroup', 'group']
for element in map_element.findall('./'):
if element.tag not in layer_tags:
# only layer_tags are layer elements
continue
layers.append(_parse_layer_type(element))
tile_map = objects.TileMap(parent_dir, version, tiled_version,
orientation, render_order, map_size, tile_size,
infinite, next_layer_id, next_object_id,
tile_sets, layers)
try:
tile_map.hex_side_length = int(map_element.attrib['hexsidelength'])
except KeyError:
pass
try:
tile_map.stagger_axis = int(map_element.attrib['staggeraxis'])
except KeyError:
pass
try:
tile_map.stagger_index = int(map_element.attrib['staggerindex'])
except KeyError:
pass
try:
backgroundcolor = map_element.attrib['backgroundcolor']
except KeyError:
pass
else:
tile_map.background_color = utilities.parse_color(backgroundcolor)
properties_element = map_tree.find('./properties')
if properties_element is not None:
tile_map.properties = _parse_properties_element(properties_element)
return tile_map

View File

@@ -0,0 +1,27 @@
import pytiled_parser.objects as objects
def parse_color(color: str) -> objects.Color:
"""
Converts the color formats that Tiled uses into ones that Arcade accepts.
Returns:
:Color: Color object in the format that Arcade understands.
"""
# strip initial '#' character
if not len(color) % 2 == 0: # pylint: disable=C2001
color = color[1:]
if len(color) == 6:
# full opacity if no alpha specified
alpha = 0xFF
red = int(color[0:2], 16)
green = int(color[2:4], 16)
blue = int(color[4:6], 16)
else:
alpha = int(color[0:2], 16)
red = int(color[2:4], 16)
green = int(color[4:6], 16)
blue = int(color[6:8], 16)
return objects.Color(red, green, blue, alpha)

View File

@@ -1,17 +1,19 @@
hi
{ 'background_color': None,
'height': 10,
'height': 6,
'hex_side_length': None,
'infinite': False,
'layers': [ Layer(width=10, height=10, data=[[9, 10, 11, 12, 13, 14, 15, 16, 30, 30], [17, 18, 19, 20, 21, 22, 23, 24, 30, 30], [25, 26, 27, 28, 29, 30, 31, 32, 30, 30], [33, 34, 35, 36, 37, 38, 39, 40, 30, 30], [41, 42, 43, 44, 45, 46, 47, 48, 30, 30], [30, 30, 30, 30, 30, 30, 30, 30, 30, 30], [30, 30, 30, 30, 30, 30, 30, 30, 30, 30], [30, 30, 30, 30, 30, 30, 30, 30, 30, 30]], id=1, name='Tile Layer 1', offset=OrderedPair(x=0, y=0), opacity=255, properties=None),
Layer(width=10, height=10, data=[[0, 0, 0, 0, 0, 0, 46, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 6, 7, 7, 7, 7, 7, 8, 0], [0, 0, 14, 15, 15, 15, 15, 15, 16, 0]], id=2, name='Tile Layer 2', offset=OrderedPair(x=0, y=0), opacity=128, properties=None),
None,
ObjectGroup(objects=[Object(id=1, location=OrderedPair(x=200.25, y=210.75), size=OrderedPair(x=47.25, y=25.0), rotation=15, opacity=255, name='rectangle 1', type=None, properties=None, template=None), Object(id=2, location=OrderedPair(x=252.5, y=87.75), size=OrderedPair(x=0, y=0), rotation=-21, opacity=255, name='polygon 1', type=None, properties=None, template=None), Object(id=3, location=OrderedPair(x=198.75, y=102.5), size=OrderedPair(x=17.75, y=14.25), rotation=0, opacity=255, name='elipse 1', type=None, properties=None, template=None), Object(id=4, location=OrderedPair(x=174.25, y=186.0), size=OrderedPair(x=0, y=0), rotation=0, opacity=255, name='point 1', type=None, properties=None, template=None), Object(id=7, location=OrderedPair(x=11.3958, y=48.5833), size=OrderedPair(x=107.625, y=27.25), rotation=0, opacity=255, name='insert text 1', type=None, properties=None, template=None), Object(id=6, location=OrderedPair(x=47.25, y=72.5), size=OrderedPair(x=47.0, y=53.0), rotation=31, opacity=255, name='inserted tile 1', type=None, properties={'tile property bool': True}, template=None), Object(id=8, location=OrderedPair(x=144.667, y=112.0), size=OrderedPair(x=0, y=0), rotation=0, opacity=255, name='polyline 1', type=None, properties=None, template=None), Object(id=9, location=OrderedPair(x=69.8333, y=168.333), size=OrderedPair(x=0, y=0), rotation=0, opacity=255, name='polygon 2', type=None, properties=None, template=None)], id=6, name='Object Layer 1', offset=OrderedPair(x=0, y=0), opacity=230, properties=None, color=None, draworder='topdown')],
'next_layer_id': 16,
'next_object_id': 10,
'layers': [ Layer(width=8, height=6, data=[[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]], id=1, name='Tile Layer 1', offset=OrderedPair(x=0, y=0), opacity=255, properties=None)],
'next_layer_id': 2,
'next_object_id': 1,
'orientation': 'orthogonal',
'parent_dir': PosixPath('/home/ben/Projects/arcade/arcade-venv/arcade/tests/test_data'),
'properties': None,
'parent_dir': PosixPath('/home/ben/Projects/pytiled_parser/pytiled_parser-venv/pytiled_parser/tests/test_data'),
'properties': { 'bool property - false': False,
'bool property - true': True,
'color property': Color(red=73, green=252, blue=255, alpha=255),
'file property': PosixPath('/var/log/syslog'),
'float property': 1.23456789,
'int property': 13,
'string property': 'Hello, World!!'},
'render_order': 'right-down',
'stagger_axis': None,
'stagger_index': None,
@@ -20,4 +22,4 @@ hi
'tile_width': 32,
'tiled_version': '1.2.3',
'version': '1.2',
'width': 10}
'width': 8}

View File

@@ -3,16 +3,15 @@ import pickle
from io import StringIO
import arcade
import arcade.tiled
import pytiled_parser
pp = pprint.PrettyPrinter(indent=4, compact=True, width=100)
pp = pp.pprint
MAP_NAME = '/home/ben/Projects/arcade/arcade-venv/arcade/tests/test_data/test_map_image_tile_set.tmx'
MAP_NAME = '/home/ben/Projects/pytiled_parser/pytiled_parser-venv/pytiled_parser/tests/test_data/test_map_simple.tmx'
map = arcade.tiled.parse_tile_map(MAP_NAME)
map = pytiled_parser.parse_tile_map(MAP_NAME)
pp(map.__dict__)

View File

@@ -13,58 +13,72 @@ os.chdir(os.path.dirname(os.path.abspath(__file__)))
def test_map_simple():
"""
TMX with a very simple tileset and some properties.
TMX with a very simple spritesheet tile set and some properties.
"""
map = pytiled_parser.parse_tile_map(Path("../test_data/test_map_simple.tmx"))
properties = {
"bool property - false": False,
"bool property - true": True,
"color property": (0x49, 0xfc, 0xff, 0xff),
"file property": Path("/var/log/syslog"),
"float property": 1.23456789,
"int property": 13,
"string property": "Hello, World!!"
}
# map
# unsure how to get paths to compare propperly
assert str(map.parent_dir) == "../test_data"
assert map.version == "1.2"
assert map.tiled_version == "1.2.3"
assert map.orientation == "orthogonal"
assert map.render_order == "right-down"
assert map.width == 8
assert map.height == 6
assert map.tile_width == 32
assert map.tile_height == 32
assert map.map_size == (8, 6)
assert map.tile_size == (32, 32)
assert map.infinite == False
assert map.next_layer_id == 2
assert map.next_object_id == 1
# optional, not for orthogonal maps
assert map.hex_side_length == None
assert map.stagger_axis == None
assert map.stagger_index == None
assert map.background_color == None
assert map.next_layer_id == 2
assert map.next_object_id == 1
assert map.properties == properties
assert map.properties == {
"bool property - false": False,
"bool property - true": True,
"color property": (0x49, 0xfc, 0xff, 0xff),
"file property": Path("/var/log/syslog"),
"float property": 1.23456789,
"int property": 13,
"string property": "Hello, World!!"
}
# tileset
assert map.tile_sets[1].name == "tile_set_image"
assert map.tile_sets[1].tilewidth == 32
assert map.tile_sets[1].tileheight == 32
assert map.tile_sets[1].max_tile_size == (32, 32)
assert map.tile_sets[1].spacing == 1
assert map.tile_sets[1].margin == 1
assert map.tile_sets[1].tilecount == 48
assert map.tile_sets[1].tile_count == 48
assert map.tile_sets[1].columns == 8
assert map.tile_sets[1].tileoffset == None
assert map.tile_sets[1].tile_offset == None
assert map.tile_sets[1].grid == None
assert map.tile_sets[1].properties == None
assert map.tile_sets[1].terraintypes == None
assert map.tile_sets[1].tiles == {}
# unsure how to get paths to compare propperly
assert str(map.tile_sets[1].image.source) == (
"images/tmw_desert_spacing.png")
"images/tmw_desert_spacing.png")
assert map.tile_sets[1].image.trans == None
assert map.tile_sets[1].image.width == 265
assert map.tile_sets[1].image.height == 199
assert map.tile_sets[1].image.size == (265, 199)
# assert map.layers ==
assert map.tile_sets[1].terrain_types == None
assert map.tile_sets[1].tiles == {}
# layers
assert map.layers[0].data == [[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]]
assert map.layers[0].id == 1
assert map.layers[0].name == "Tile Layer 1"
assert map.layers[0].offset == (0, 0)
assert map.layers[0].opacity == 0xFF
assert map.layers[0].properties == None
assert map.layers[0].size == (8, 6)
@pytest.mark.parametrize(
@@ -80,4 +94,4 @@ def test_color_parsing(test_input, expected):
"""
Tiled has a few different types of color representations.
"""
assert pytiled_parser._parse_color(test_input) == expected
assert pytiled_parser.utilities.parse_color(test_input) == expected