diff options
Diffstat (limited to 'external/pytmx/pytmx.py')
-rw-r--r-- | external/pytmx/pytmx.py | 1115 |
1 files changed, 1115 insertions, 0 deletions
diff --git a/external/pytmx/pytmx.py b/external/pytmx/pytmx.py new file mode 100644 index 0000000..61120a0 --- /dev/null +++ b/external/pytmx/pytmx.py @@ -0,0 +1,1115 @@ +import logging +import six +import os +from itertools import chain, product +from collections import defaultdict, namedtuple +from xml.etree import ElementTree +from six.moves import zip, map +from operator import attrgetter + +logger = logging.getLogger(__name__) +ch = logging.StreamHandler() +ch.setLevel(logging.INFO) +logger.addHandler(ch) +logger.setLevel(logging.INFO) + +__all__ = ['TiledElement', + 'TiledMap', + 'TiledTileset', + 'TiledTileLayer', + 'TiledObject', + 'TiledObjectGroup', + 'TiledImageLayer', + 'TileFlags', + 'convert_to_bool', + 'parse_properties'] + +# internal flags +TRANS_FLIPX = 1 +TRANS_FLIPY = 2 +TRANS_ROT = 4 + +# Tiled gid flags +GID_TRANS_FLIPX = 1 << 31 +GID_TRANS_FLIPY = 1 << 30 +GID_TRANS_ROT = 1 << 29 + +# error message format strings go here +duplicate_name_fmt = 'Cannot set user {} property on {} "{}"; Tiled property already exists.' + +flag_names = ( + 'flipped_horizontally', + 'flipped_vertically', + 'flipped_diagonally',) + +TileFlags = namedtuple('TileFlags', flag_names) +AnimationFrame = namedtuple('AnimationFrame', ['gid', 'duration']) + + +def default_image_loader(filename, flags, **kwargs): + """ This default image loader just returns filename, rect, and any flags + """ + def load(rect=None, flags=None): + return filename, rect, flags + + return load + + +def decode_gid(raw_gid): + """ Decode a GID from TMX data + + as of 0.7.0 it determines if the tile should be flipped when rendered + as of 0.8.0 bit 30 determines if GID is rotated + + :param raw_gid: 32-bit number from TMX layer data + :return: gid, flags + """ + flags = TileFlags( + raw_gid & GID_TRANS_FLIPX == GID_TRANS_FLIPX, + raw_gid & GID_TRANS_FLIPY == GID_TRANS_FLIPY, + raw_gid & GID_TRANS_ROT == GID_TRANS_ROT) + gid = raw_gid & ~(GID_TRANS_FLIPX | GID_TRANS_FLIPY | GID_TRANS_ROT) + return gid, flags + + +def convert_to_bool(text): + """ Convert a few common variations of "true" and "false" to boolean + + :param text: string to test + :return: boolean + :raises: ValueError + """ + try: + return bool(int(text)) + except: + pass + + text = str(text).lower() + if text == "true": + return True + if text == "yes": + return True + if text == "false": + return False + if text == "no": + return False + + raise ValueError + +# used to change the unicode string returned from xml to +# proper python variable types. +types = defaultdict(lambda: str) +types.update({ + "version": float, + "orientation": str, + "width": float, + "height": float, + "tilewidth": int, + "tileheight": int, + "firstgid": int, + "source": str, + "name": str, + "spacing": int, + "margin": int, + "trans": str, + "id": int, + "opacity": float, + "visible": convert_to_bool, + "encoding": str, + "compression": str, + "gid": int, + "type": str, + "x": float, + "y": float, + "value": str, + "rotation": float, +}) + + +def parse_properties(node): + """ Parse a Tiled xml node and return a dict that represents a tiled "property" + + :param node: etree element + :return: dict + """ + d = dict() + for child in node.findall('properties'): + for subnode in child.findall('property'): + d[subnode.get('name')] = subnode.get('value') + return d + + +class TiledElement(object): + """ Base class for all pytmx types + """ + allow_duplicate_names = False + + def __init__(self): + self.properties = dict() + + @classmethod + def from_xml_string(cls, xml_string): + """Return a TileElement object from a xml string + + :param xml_string: string containing xml data + :rtype: TiledElement instance + """ + return cls().parse_xml(ElementTree.fromstring(xml_string)) + + def _cast_and_set_attributes_from_node_items(self, items): + for key, value in items: + casted_value = types[key](value) + setattr(self, key, casted_value) + + def _contains_invalid_property_name(self, items): + if self.allow_duplicate_names: + return False + + for k, v in items: + if hasattr(self, k): + msg = duplicate_name_fmt.format(k, self.__class__.__name__, self.name) + logger.error(msg) + return True + return False + + @staticmethod + def _log_property_error_message(self): + msg = 'Some name are reserved for {0} objects and cannot be used.' + logger.error(msg) + + def _set_properties(self, node): + """ Create dict containing Tiled object attributes from xml data + + read the xml attributes and tiled "properties" from a xml node and fill + in the values into the object's dictionary. Names will be checked to + make sure that they do not conflict with reserved names. + + :param node: etree element + :return: dict + """ + self._cast_and_set_attributes_from_node_items(node.items()) + properties = parse_properties(node) + if (not self.allow_duplicate_names and + self._contains_invalid_property_name(properties.items())): + self._log_property_error_message() + raise ValueError + + self.properties = properties + + def __getattr__(self, item): + try: + return self.properties[item] + except KeyError: + raise AttributeError + + def __repr__(self): + return '<{0}: "{1}">'.format(self.__class__.__name__, self.name) + + +class TiledMap(TiledElement): + """Contains the layers, objects, and images from a Tiled TMX map + + This class is meant to handle most of the work you need to do to use a map. + """ + + def __init__(self, filename=None, image_loader=default_image_loader, **kwargs): + """ Create new TiledMap + + :param filename: filename of tiled map to load + :param image_loader: function that will load images (see below) + :param optional_gids: load specific tile image GID, even if never used + :param invert_y: invert the y axis + :param load_all_tiles: load all tile images, even if never used + :param allow_duplicate_names: allow duplicates in objects' metatdata + + image_loader: + this must be a reference to a function that will accept a tuple: + (filename of image, bounding rect of tile in image, flags) + the function must return a reference to to the tile. + """ + TiledElement.__init__(self) + self.filename = filename + self.image_loader = image_loader + + # optional keyword arguments checked here + self.optional_gids = kwargs.get('optional_gids', set()) + self.load_all_tiles = kwargs.get('load_all', False) + self.invert_y = kwargs.get('invert_y', True) + + # allow duplicate names to be parsed and loaded + TiledElement.allow_duplicate_names = \ + kwargs.get('allow_duplicate_names', False) + + self.layers = list() # all layers in proper order + self.tilesets = list() # TiledTileset objects + self.tile_properties = dict() # tiles that have properties + self.layernames = dict() + + # only used tiles are actually loaded, so there will be a difference + # between the GIDs in the Tiled map data (tmx) and the data in this + # object and the layers. This dictionary keeps track of that. + self.gidmap = defaultdict(list) + self.imagemap = dict() # mapping of gid and trans flags to real gids + self.tiledgidmap = dict() # mapping of tiledgid to pytmx gid + self.maxgid = 1 + + # should be filled in by a loader function + self.images = list() + + # defaults from the TMX specification + self.version = 0.0 + self.orientation = None + self.width = 0 # width of map in tiles + self.height = 0 # height of map in tiles + self.tilewidth = 0 # width of a tile in pixels + self.tileheight = 0 # height of a tile in pixels + self.background_color = None + + # initialize the gid mapping + self.imagemap[(0, 0)] = 0 + + if filename: + self.parse_xml(ElementTree.parse(self.filename).getroot()) + + def __repr__(self): + return '<{0}: "{1}">'.format(self.__class__.__name__, self.filename) + + # iterate over layers and objects in map + def __iter__(self): + return chain(self.layers, self.objects) + + def _set_properties(self, node): + TiledElement._set_properties(self, node) + + # TODO: make class/layer-specific type casting + # layer height and width must be int, but TiledElement.set_properties() + # make a float by default, so recast as int here + self.height = int(self.height) + self.width = int(self.width) + + def parse_xml(self, node): + """ Parse a map from ElementTree xml node + + :param node: ElementTree xml node + :return: self + """ + self._set_properties(node) + self.background_color = node.get('backgroundcolor', + self.background_color) + + # *** do not change this load order! *** # + # *** gid mapping errors will occur if changed *** # + for subnode in node.findall('layer'): + self.add_layer(TiledTileLayer(self, subnode)) + + for subnode in node.findall('imagelayer'): + self.add_layer(TiledImageLayer(self, subnode)) + + for subnode in node.findall('objectgroup'): + self.add_layer(TiledObjectGroup(self, subnode)) + + for subnode in node.findall('tileset'): + self.add_tileset(TiledTileset(self, subnode)) + + # "tile objects", objects with a GID, have need to have their attributes + # set after the tileset is loaded, so this step must be performed last + # also, this step is performed for objects to load their tiles. + # height and width will automatically be set according to the tileset + # that the image is from + + # tiled stores the origin of GID objects by the lower right corner + # this is different for all other types, so i just adjust it here + # so all types loaded with pytmx are uniform. + + # iterate through tile objects and handle the image + for o in [o for o in self.objects if o.gid]: + + # gids might also have properties assigned to them + # in that case, assign the gid properties to the object as well + p = self.get_tile_properties_by_gid(o.gid) + if p: + o.properties.update(p) + + try: + tileset = self.get_tileset_from_gid(o.gid) + except ValueError: + msg = 'attempted to lookup invalid gid %s in object %s' + logger.error(msg, o.gid, o) + else: + if self.invert_y: + o.y -= tileset.tileheight + o.height = tileset.tileheight + o.width = tileset.tilewidth + + self.reload_images() + return self + + def reload_images(self): + """ Load the map images from disk + + This method will use the image loader passed in the constructor + to do the loading or will use a generic default, in which case no + images will be loaded. + + :return: None + """ + self.images = [None] * self.maxgid + + # iterate through tilesets to get source images + for ts in self.tilesets: + + # skip tilesets without a source + if ts.source is None: + continue + + path = os.path.join(os.path.dirname(self.filename), ts.source) + colorkey = getattr(ts, 'trans', None) + loader = self.image_loader(path, colorkey) + + p = product(range(ts.margin, + ts.height + ts.margin - ts.tileheight + 1, + ts.tileheight + ts.spacing), + range(ts.margin, + ts.width + ts.margin - ts.tilewidth + 1, + ts.tilewidth + ts.spacing)) + + # iterate through the tiles + for real_gid, (y, x) in enumerate(p, ts.firstgid): + rect = (x, y, ts.tilewidth, ts.tileheight) + gids = self.map_gid(real_gid) + + # gids is None if the tile is never used + # but give another chance to load the gid anyway + if gids is None: + if self.load_all_tiles or real_gid in self.optional_gids: + # TODO: handle flags? - might never be an issue, though + gids = [self.register_gid(real_gid, flags=0)] + + if gids: + # flags might rotate/flip the image, so let the loader + # handle that here + for gid, flags in gids: + self.images[gid] = loader(rect, flags) + + # load image layer images + for layer in (i for i in self.layers if isinstance(i, TiledImageLayer)): + source = getattr(layer, 'source', None) + if source: + colorkey = getattr(layer, 'trans', None) + real_gid = len(self.images) + gid = self.register_gid(real_gid) + layer.gid = gid + path = os.path.join(os.path.dirname(self.filename), source) + loader = self.image_loader(path, colorkey) + image = loader() + self.images.append(image) + + # load images in tiles. + # instead of making a new gid, replace the reference to the tile that + # was loaded from the tileset + for real_gid, props in self.tile_properties.items(): + source = props.get('source', None) + if source: + colorkey = props.get('trans', None) + path = os.path.join(os.path.dirname(self.filename), source) + loader = self.image_loader(path, colorkey) + image = loader() + self.images[real_gid] = image + + def get_tile_image(self, x, y, layer): + """ Return the tile image for this location + + :param x: x coordinate + :param y: y coordinate + :param layer: layer number + :rtype: surface if found, otherwise 0 + """ + try: + assert (x >= 0 and y >= 0) + except AssertionError: + raise ValueError + + try: + layer = self.layers[layer] + except IndexError: + raise ValueError + + assert (isinstance(layer, TiledTileLayer)) + + try: + gid = layer.data[y][x] + except (IndexError, ValueError): + raise ValueError + except TypeError: + msg = "Tiles must be specified in integers." + logger.debug(msg) + raise TypeError + + else: + return self.get_tile_image_by_gid(gid) + + def get_tile_image_by_gid(self, gid): + """ Return the tile image for this location + + :param gid: GID of image + :rtype: surface if found, otherwise ValueError + """ + try: + assert (int(gid) >= 0) + return self.images[gid] + except TypeError: + msg = "GIDs must be expressed as a number. Got: {0}" + logger.debug(msg.format(gid)) + raise TypeError + except (AssertionError, IndexError): + msg = "Coords: ({0},{1}) in layer {2} has invalid GID: {3}" + logger.debug(msg.format(gid)) + raise ValueError + + def get_tile_gid(self, x, y, layer): + """ Return the tile image GID for this location + + :param x: x coordinate + :param y: y coordinate + :param layer: layer number + :rtype: surface if found, otherwise ValueError + """ + try: + assert (x >= 0 and y >= 0 and layer >= 0) + except AssertionError: + raise ValueError + + try: + return self.layers[int(layer)].data[int(y)][int(x)] + except (IndexError, ValueError): + msg = "Coords: ({0},{1}) in layer {2} is invalid" + logger.debug(msg, (x, y, layer)) + raise ValueError + + def get_tile_properties(self, x, y, layer): + """ Return the tile image GID for this location + + :param x: x coordinate + :param y: y coordinate + :param layer: layer number + :rtype: python dict if found, otherwise None + """ + try: + assert (x >= 0 and y >= 0 and layer >= 0) + except AssertionError: + raise ValueError + + try: + gid = self.layers[int(layer)].data[int(y)][int(x)] + except (IndexError, ValueError): + msg = "Coords: ({0},{1}) in layer {2} is invalid." + logger.debug(msg.format(x, y, layer)) + raise Exception + + else: + try: + return self.tile_properties[gid] + except (IndexError, ValueError): + msg = "Coords: ({0},{1}) in layer {2} has invalid GID: {3}" + logger.debug(msg.format(x, y, layer, gid)) + raise Exception + except KeyError: + return None + + def get_tile_locations_by_gid(self, gid): + """ Search map for tile locations by the GID + + Note: Not a fast operation. Cache results if used often. + + :param gid: GID to be searched for + :rtype: generator of tile locations + """ + # use this func to make sure GID is valid + self.get_tile_image_by_gid(gid) + + p = product(range(self.width), + range(self.height), + range(len(self.layers))) + + return ((x, y, l) for (x, y, l) in p if + self.layers[l].data[y][x] == gid) + + def get_tile_properties_by_gid(self, gid): + """ Get the tile properties of a tile GID + + :param gid: GID + :rtype: python dict if found, otherwise None + """ + try: + return self.tile_properties[gid] + except KeyError: + return None + + def set_tile_properties(self, gid, properties): + """ Set the tile properties of a tile GID + + :param gid: GID + :param properties: python dict of properties for GID + """ + self.tile_properties[gid] = properties + + def get_tile_properties_by_layer(self, layer): + """ Get the tile properties of each GID in layer + + :param layer: layer number + :rtype: iterator of (gid, properties) tuples + """ + try: + assert (int(layer) >= 0) + layer = int(layer) + except (TypeError, AssertionError): + msg = "Layer must be a positive integer. Got {0} instead." + logger.debug(msg.format(type(layer))) + raise ValueError + + p = product(range(self.width), range(self.height)) + layergids = set(self.layers[layer].data[y][x] for x, y in p) + + for gid in layergids: + try: + yield gid, self.tile_properties[gid] + except KeyError: + continue + + def add_layer(self, layer): + """ Add a layer (TileTileLayer, TiledImageLayer, or TiledObjectGroup) + + :param layer: TileTileLayer, TiledImageLayer, TiledObjectGroup object + """ + assert ( + isinstance(layer, + (TiledTileLayer, TiledImageLayer, TiledObjectGroup))) + + self.layers.append(layer) + self.layernames[layer.name] = layer + + def add_tileset(self, tileset): + """ Add a tileset to the map + + :param tileset: TiledTileset + """ + assert (isinstance(tileset, TiledTileset)) + self.tilesets.append(tileset) + + def get_layer_by_name(self, name): + """Return a layer by name + + :param name: Name of layer. Case-sensitive. + :rtype: Layer object if found, otherwise ValueError + """ + try: + return self.layernames[name] + except KeyError: + msg = 'Layer "{0}" not found.' + logger.debug(msg.format(name)) + raise ValueError + + def get_object_by_name(self, name): + """Find an object + + :param name: Name of object. Case-sensitive. + :rtype: Object if found, otherwise ValueError + """ + for obj in self.objects: + if obj.name == name: + return obj + raise ValueError + + def get_tileset_from_gid(self, gid): + """ Return tileset that owns the gid + + Note: this is a slow operation, so if you are expecting to do this + often, it would be worthwhile to cache the results of this. + + :param gid: gid of tile image + :rtype: TiledTileset if found, otherwise ValueError + """ + try: + tiled_gid = self.tiledgidmap[gid] + except KeyError: + raise ValueError + + for tileset in sorted(self.tilesets, key=attrgetter('firstgid'), + reverse=True): + if tiled_gid >= tileset.firstgid: + return tileset + + raise ValueError + + @property + def objectgroups(self): + """Return iterator of all object groups + + :rtype: Iterator + """ + return (layer for layer in self.layers + if isinstance(layer, TiledObjectGroup)) + + @property + def objects(self): + """Return iterator of all the objects associated with this map + + :rtype: Iterator + """ + return chain(*self.objectgroups) + + @property + def visible_layers(self): + """Return iterator of Layer objects that are set 'visible' + + :rtype: Iterator + """ + return (l for l in self.layers if l.visible) + + @property + def visible_tile_layers(self): + """Return iterator of layer indexes that are set 'visible' + + :rtype: Iterator + """ + return (i for (i, l) in enumerate(self.layers) + if l.visible and isinstance(l, TiledTileLayer)) + + @property + def visible_object_groups(self): + """Return iterator of object group indexes that are set 'visible' + + :rtype: Iterator + """ + return (i for (i, l) in enumerate(self.layers) + if l.visible and isinstance(l, TiledObjectGroup)) + + def register_gid(self, tiled_gid, flags=None): + """ Used to manage the mapping of GIDs between the tmx and pytmx + + :param tiled_gid: GID that is found in TMX data + :rtype: GID that pytmx uses for the the GID passed + """ + if flags is None: + flags = TileFlags(0, 0, 0) + + if tiled_gid: + try: + return self.imagemap[(tiled_gid, flags)][0] + except KeyError: + gid = self.maxgid + self.maxgid += 1 + self.imagemap[(tiled_gid, flags)] = (gid, flags) + self.gidmap[tiled_gid].append((gid, flags)) + self.tiledgidmap[gid] = tiled_gid + return gid + + else: + return 0 + + def map_gid(self, tiled_gid): + """ Used to lookup a GID read from a TMX file's data + + :param tiled_gid: GID that is found in TMX data + :rtype: (GID, flags) for the the GID passed, None if not found + """ + try: + return self.gidmap[int(tiled_gid)] + except KeyError: + return None + except TypeError: + msg = "GIDs must be an integer" + logger.debug(msg) + raise TypeError + + +class TiledTileset(TiledElement): + """ Represents a Tiled Tileset + + External tilesets are supported. GID/ID's from Tiled are not guaranteed to + be the same after loaded. + """ + + def __init__(self, parent, node): + TiledElement.__init__(self) + self.parent = parent + self.offset = (0, 0) + + # defaults from the specification + self.firstgid = 0 + self.source = None + self.name = None + self.tilewidth = 0 + self.tileheight = 0 + self.spacing = 0 + self.margin = 0 + self.trans = None + self.width = 0 + self.height = 0 + + self.parse_xml(node) + + def parse_xml(self, node): + """ Parse a Tileset from ElementTree xml element + + A bit of mangling is done here so that tilesets that have external + TSX files appear the same as those that don't + + :param node: ElementTree element + :return: self + """ + import os + + # if true, then node references an external tileset + source = node.get('source', None) + if source: + if source[-4:].lower() == ".tsx": + + # external tilesets don't save this, store it for later + self.firstgid = int(node.get('firstgid')) + + # we need to mangle the path - tiled stores relative paths + dirname = os.path.dirname(self.parent.filename) + path = os.path.abspath(os.path.join(dirname, source)) + try: + node = ElementTree.parse(path).getroot() + except IOError: + msg = "Cannot load external tileset: {0}" + logger.error(msg.format(path)) + raise Exception + + else: + msg = "Found external tileset, but cannot handle type: {0}" + logger.error(msg.format(self.source)) + raise Exception + + self._set_properties(node) + + # since tile objects [probably] don't have a lot of metadata, + # we store it separately in the parent (a TiledMap instance) + register_gid = self.parent.register_gid + for child in node.getiterator('tile'): + tiled_gid = int(child.get("id")) + p = parse_properties(child) + + # handle tiles that have their own image + image = child.find('image') + if image is None: + p['width'] = self.tilewidth + p['height'] = self.tileheight + else: + p['source'] = image.get('source') + p['trans'] = image.get('trans', None) + p['width'] = image.get('width') + p['height'] = image.get('height') + + # handle tiles with animations + anim = child.find('animation') + frames = list() + p['frames'] = frames + if anim is not None: + for frame in anim.findall("frame"): + duration = int(frame.get('duration')) + gid = register_gid(int(frame.get('tileid')) + self.firstgid) + frames.append(AnimationFrame(gid, duration)) + + for gid, flags in self.parent.map_gid(tiled_gid + self.firstgid): + self.parent.set_tile_properties(gid, p) + + # handle the optional 'tileoffset' node + self.offset = node.find('tileoffset') + if self.offset is None: + self.offset = (0, 0) + else: + self.offset = (self.offset.get('x', 0), self.offset.get('y', 0)) + + image_node = node.find('image') + if image_node is not None: + self.source = image_node.get('source') + self.trans = image_node.get('trans', None) + self.width = int(image_node.get('width')) + self.height = int(image_node.get('height')) + + return self + + +class TiledTileLayer(TiledElement): + """ Represents a TileLayer + + To just get the tile images, use TiledTileLayer.tiles() + """ + + def __init__(self, parent, node): + TiledElement.__init__(self) + self.parent = parent + self.data = list() + + # defaults from the specification + self.name = None + self.opacity = 1.0 + self.visible = True + self.height = 0 + self.width = 0 + + self.parse_xml(node) + + def __iter__(self): + return self.iter_data() + + def iter_data(self): + """ Iterate over layer data + + Yields X, Y, GID tuples for each tile in the layer + + :return: Generator + """ + for y, x in product(range(self.height), range(self.width)): + yield x, y, self.data[y][x] + + def tiles(self): + """ Iterate over tile images of this layer + + This is an optimised generator function that returns + (tile_x, tile_y, tile_image) tuples, + + :rtype: Generator + :return: (x, y, image) tuples + """ + images = self.parent.images + data = self.data + for y, row in enumerate(data): + for x, gid in enumerate(row): + if gid: + yield x, y, images[gid] + + def _set_properties(self, node): + TiledElement._set_properties(self, node) + + # TODO: make class/layer-specific type casting + # layer height and width must be int, but TiledElement.set_properties() + # make a float by default, so recast as int here + self.height = int(self.height) + self.width = int(self.width) + + def parse_xml(self, node): + """ Parse a Tile Layer from ElementTree xml node + + :param node: ElementTree xml node + :return: self + """ + import struct + import array + + self._set_properties(node) + data = None + next_gid = None + data_node = node.find('data') + + encoding = data_node.get('encoding', None) + if encoding == 'base64': + from base64 import b64decode + + data = b64decode(data_node.text.strip()) + + elif encoding == 'csv': + next_gid = map(int, "".join( + line.strip() for line in data_node.text.strip() + ).split(",")) + + elif encoding: + msg = 'TMX encoding type: {0} is not supported.' + logger.error(msg.format(encoding)) + raise Exception + + compression = data_node.get('compression', None) + if compression == 'gzip': + import gzip + + with gzip.GzipFile(fileobj=six.BytesIO(data)) as fh: + data = fh.read() + + elif compression == 'zlib': + import zlib + + data = zlib.decompress(data) + + elif compression: + msg = 'TMX compression type: {0} is not supported.' + logger.error(msg.format(compression)) + raise Exception + + # if data is None, then it was not decoded or decompressed, so + # we assume here that it is going to be a bunch of tile elements + # TODO: this will/should raise an exception if there are no tiles + if encoding == next_gid is None: + def get_children(parent): + for child in parent.findall('tile'): + yield int(child.get('gid')) + + next_gid = get_children(data_node) + + elif data: + if type(data) == bytes: + fmt = struct.Struct('<L') + iterator = (data[i:i + 4] for i in range(0, len(data), 4)) + next_gid = (fmt.unpack(i)[0] for i in iterator) + else: + msg = 'layer data not in expected format ({})' + logger.error(msg.format(type(data))) + raise Exception + + init = lambda: [0] * self.width + reg = self.parent.register_gid + + # H (16-bit) may be a limitation for very detailed maps + self.data = tuple(array.array('H', init()) for i in range(self.height)) + for (y, x) in product(range(self.height), range(self.width)): + self.data[y][x] = reg(*decode_gid(next(next_gid))) + + return self + + +class TiledObject(TiledElement): + """ Represents a any Tiled Object + + Supported types: Box, Ellipse, Tile Object, Polyline, Polygon + """ + + def __init__(self, parent, node): + TiledElement.__init__(self) + self.parent = parent + + # defaults from the specification + self.name = None + self.type = None + self.x = 0 + self.y = 0 + self.width = 0 + self.height = 0 + self.rotation = 0 + self.gid = 0 + self.visible = 1 + + self.parse_xml(node) + + @property + def image(self): + if self.gid: + return self.parent.images[self.gid] + return None + + def parse_xml(self, node): + """ Parse an Object from ElementTree xml node + + :param node: ElementTree xml node + :return: self + """ + + def read_points(text): + """parse a text string of float tuples and return [(x,...),...] + """ + return tuple(tuple(map(float, i.split(','))) for i in text.split()) + + self._set_properties(node) + + # correctly handle "tile objects" (object with gid set) + if self.gid: + self.gid = self.parent.register_gid(self.gid) + + points = None + polygon = node.find('polygon') + if polygon is not None: + points = read_points(polygon.get('points')) + self.closed = True + + polyline = node.find('polyline') + if polyline is not None: + points = read_points(polyline.get('points')) + self.closed = False + + if points: + x1 = x2 = y1 = y2 = 0 + for x, y in points: + if x < x1: x1 = x + if x > x2: x2 = x + if y < y1: y1 = y + if y > y2: y2 = y + self.width = abs(x1) + abs(x2) + self.height = abs(y1) + abs(y2) + self.points = tuple( + [(i[0] + self.x, i[1] + self.y) for i in points]) + + return self + + +class TiledObjectGroup(TiledElement, list): + """ Represents a Tiled ObjectGroup + + Supports any operation of a normal list. + """ + + def __init__(self, parent, node): + TiledElement.__init__(self) + self.parent = parent + + # defaults from the specification + self.name = None + self.color = None + self.opacity = 1 + self.visible = 1 + + self.parse_xml(node) + + def parse_xml(self, node): + """ Parse an Object Group from ElementTree xml node + + :param node: ElementTree xml node + :return: self + """ + self._set_properties(node) + self.extend(TiledObject(self.parent, child) + for child in node.findall('object')) + + return self + + +class TiledImageLayer(TiledElement): + """ Represents Tiled Image Layer + + The image associated with this layer will be loaded and assigned a GID. + """ + + def __init__(self, parent, node): + TiledElement.__init__(self) + self.parent = parent + self.source = None + self.trans = None + self.gid = 0 + + # defaults from the specification + self.name = None + self.opacity = 1 + self.visible = 1 + + self.parse_xml(node) + + @property + def image(self): + if self.gid: + return self.parent.images[self.gid] + return None + + def parse_xml(self, node): + """ Parse an Image Layer from ElementTree xml node + + :param node: ElementTree xml node + :return: self + """ + self._set_properties(node) + self.name = node.get('name', None) + self.opacity = node.get('opacity', self.opacity) + self.visible = node.get('visible', self.visible) + image_node = node.find('image') + self.source = image_node.get('source') + self.trans = image_node.get('trans', None) + return self |