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(' 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