all repos — Legends-RPG @ f2dca0d393a15a440a0a97993509345ca515d575

A fantasy mini-RPG built with Python and Pygame.

pytmx/pytmx.py (view raw)

  1from itertools import chain, product
  2from xml.etree import ElementTree
  3from .utils import decode_gid, types, parse_properties, read_points
  4from .constants import *
  5
  6__all__ = ['TiledMap', 'TiledTileset', 'TiledLayer', 'TiledObject', 'TiledObjectGroup', 'TiledImageLayer']
  7
  8
  9class TiledElement(object):
 10    def set_properties(self, node):
 11        """
 12        read the xml attributes and tiled "properties" from a xml node and fill
 13        in the values into the object's dictionary.  Names will be checked to
 14        make sure that they do not conflict with reserved names.
 15        """
 16
 17        # set the attributes reserved for tiled
 18        [setattr(self, k, types[str(k)](v)) for (k, v) in node.items()]
 19
 20        # set the attributes that are derived from tiled 'properties'
 21        for k, v in parse_properties(node).items():
 22            if k in self.reserved:
 23                msg = "{0} \"{1}\" has a property called \"{2}\""
 24                print msg.format(self.__class__.__name__, self.name, k, self.__class__.__name__)
 25                msg = "This name is reserved for {0} objects and cannot be used."
 26                print msg.format(self.__class__.__name__)
 27                print "Please change the name in Tiled and try again."
 28                raise ValueError
 29            setattr(self, k, types[str(k)](v))
 30
 31
 32class TiledMap(TiledElement):
 33    """
 34    Contains the tile layers, tile images, object groups, and objects from a
 35    Tiled TMX map.
 36    """
 37
 38    reserved = "visible version orientation width height tilewidth tileheight properties tileset layer objectgroup".split()
 39
 40    def __init__(self, filename=None):
 41        from collections import defaultdict
 42
 43        TiledElement.__init__(self)
 44        self.tilesets = []  # list of TiledTileset objects
 45        self.tilelayers = []  # list of TiledLayer objects
 46        self.imagelayers = []  # list of TiledImageLayer objects
 47        self.objectgroups = []  # list of TiledObjectGroup objects
 48        self.all_layers = []  # list of all layers in proper order
 49        self.tile_properties = {}  # dict of tiles that have metadata
 50        self.filename = filename
 51
 52        self.layernames = {}
 53
 54        # only used tiles are actually loaded, so there will be a difference
 55        # between the GIDs in the Tile map data (tmx) and the data in this
 56        # class and the layers.  This dictionary keeps track of that difference.
 57        self.gidmap = defaultdict(list)
 58
 59        # should be filled in by a loader function
 60        self.images = []
 61
 62        # defaults from the TMX specification
 63        self.version = 0.0
 64        self.orientation = None
 65        self.width = 0       # width of map in tiles
 66        self.height = 0      # height of map in tiles
 67        self.tilewidth = 0   # width of a tile in pixels
 68        self.tileheight = 0  # height of a tile in pixels
 69        self.background_color = None
 70
 71        self.imagemap = {}  # mapping of gid and trans flags to real gids
 72        self.maxgid = 1
 73
 74        if filename:
 75            self.load()
 76
 77    def __repr__(self):
 78        return "<{0}: \"{1}\">".format(self.__class__.__name__, self.filename)
 79
 80    def getTileImage(self, x, y, layer):
 81        """
 82        return the tile image for this location
 83        x and y must be integers and are in tile coordinates, not pixel
 84
 85        return value will be 0 if there is no tile with that location.
 86        """
 87
 88        try:
 89            x, y, layer = map(int, (x, y, layer))
 90        except TypeError:
 91            msg = "Tile indexes/layers must be specified as integers."
 92            print msg
 93            raise TypeError
 94
 95        try:
 96            assert (x >= 0 and y >= 0)
 97        except AssertionError:
 98            raise ValueError
 99
100        try:
101            gid = self.tilelayers[layer].data[y][x]
102        except IndexError:
103            msg = "Coords: ({0},{1}) in layer {2} is not valid."
104            print msg.format(x, y, layer)
105            raise ValueError
106
107        return self.getTileImageByGid(gid)
108
109    def getTileImageByGid(self, gid):
110        try:
111            assert (gid >= 0)
112            return self.images[gid]
113        except (IndexError, ValueError, AssertionError):
114            msg = "Invalid GID specified: {}"
115            print msg.format(gid)
116            raise ValueError
117        except TypeError:
118            msg = "GID must be specified as integer: {}"
119            print msg.format(gid)
120            raise TypeError
121
122    def getTileGID(self, x, y, layer):
123        """
124        return GID of a tile in this location
125        x and y must be integers and are in tile coordinates, not pixel
126        """
127
128        try:
129            return self.tilelayers[int(layer)].data[int(y)][int(x)]
130        except (IndexError, ValueError):
131            msg = "Coords: ({0},{1}) in layer {2} is invalid"
132            raise Exception, msg.format(x, y, layer)
133
134    def getDrawOrder(self):
135        """
136        return a list of objects in the order that they should be drawn
137        this will also exclude any layers that are not set to visible
138
139        may be useful if you have objects and want to control rendering
140        from tiled
141        """
142
143        raise NotImplementedError
144
145    def getTileImages(self, r, layer):
146        """
147        return a group of tiles in an area
148        expects a pygame rect or rect-like list/tuple
149
150        useful if you don't want to repeatedly call getTileImage
151        """
152
153        raise NotImplementedError
154
155    def getObjects(self):
156        """
157        Return iterator of all the objects associated with this map
158        """
159
160        return chain(*(i for i in self.objectgroups))
161
162    def getTileProperties(self, (x, y, layer)):
163        """
164        return the properties for the tile, if any
165        x and y must be integers and are in tile coordinates, not pixel
166
167        returns a dict of there are properties, otherwise will be None
168        """
169
170        try:
171            gid = self.tilelayers[int(layer)].data[int(y)][int(x)]
172        except (IndexError, ValueError):
173            msg = "Coords: ({0},{1}) in layer {2} is invalid."
174            raise Exception, msg.format(x, y, layer)
175
176        else:
177            try:
178                return self.tile_properties[gid]
179            except (IndexError, ValueError):
180                msg = "Coords: ({0},{1}) in layer {2} has invalid GID: {3}"
181                raise Exception, msg.format(x, y, layer, gid)
182            except KeyError:
183                return None
184
185    def getLayerData(self, layer):
186        """
187        Return the data for a layer.
188
189        Data is an array of arrays.
190
191        >>> pos = data[y][x]
192        """
193
194        try:
195            return self.tilelayers[layer].data
196        except IndexError:
197            msg = "Layer {0} does not exist."
198            raise ValueError, msg.format(layer)
199
200    def getTileLocation(self, gid):
201        # experimental way to find locations of a tile by the GID
202
203        p = product(xrange(self.width),
204                    xrange(self.height),
205                    xrange(len(self.tilelayers)))
206
207        return [(x, y, l) for (x, y, l) in p
208                if self.tilelayers[l].data[y][x] == gid]
209
210    def getTilePropertiesByGID(self, gid):
211        try:
212            return self.tile_properties[gid]
213        except KeyError:
214            return None
215
216    def setTileProperties(self, gid, d):
217        """
218        set the properties of a tile by GID.
219        must use a standard python dict as d
220        """
221
222        try:
223            self.tile_properties[gid] = d
224        except KeyError:
225            msg = "GID #{0} does not exist."
226            raise ValueError, msg.format(gid)
227
228    def getTilePropertiesByLayer(self, layer):
229        """
230        Return a list of tile properties (dict) in use in this tile layer.
231        """
232
233        try:
234            layer = int(layer)
235        except:
236            msg = "Layer must be an integer.  Got {0} instead."
237            raise ValueError, msg.format(type(layer))
238
239        p = product(range(self.width), range(self.height))
240        layergids = set(self.tilelayers[layer].data[y][x] for x, y in p)
241
242        props = []
243        for gid in layergids:
244            try:
245                props.append((gid, self.tile_properties[gid]))
246            except:
247                continue
248
249        return props
250
251    def register_gid(self, real_gid, flags=0):
252        """
253        used to manage the mapping of GID between the tmx data and the internal
254        data.
255
256        number returned is gid used internally
257        """
258
259        if real_gid:
260            try:
261                return self.imagemap[(real_gid, flags)][0]
262            except KeyError:
263                # this tile has not been encountered before, or it has been
264                # transformed in some way.  make a new GID for it.
265                gid = self.maxgid
266                self.maxgid += 1
267                self.imagemap[(real_gid, flags)] = (gid, flags)
268                self.gidmap[real_gid].append((gid, flags))
269                return gid
270        else:
271            return 0
272
273    def map_gid(self, real_gid):
274        """
275        used to lookup a GID read from a TMX file's data
276        """
277
278        try:
279            return self.gidmap[int(real_gid)]
280        except KeyError:
281            return None
282        except TypeError:
283            msg = "GIDs must be an integer"
284            raise TypeError, msg
285
286    def loadTileImages(self, filename):
287        raise NotImplementedError
288
289    def load(self):
290        """
291        parse a map node from a tiled tmx file
292        """
293        etree = ElementTree.parse(self.filename).getroot()
294        self.set_properties(etree)
295
296        # initialize the gid mapping
297        self.imagemap[(0, 0)] = 0
298
299        self.background_color = etree.get('backgroundcolor', self.background_color)
300
301        # *** do not change this load order!  gid mapping errors will occur if changed ***
302        for node in etree.findall('layer'):
303            self.addTileLayer(TiledLayer(self, node))
304
305        for node in etree.findall('imagelayer'):
306            self.addImageLayer(TiledImageLayer(self, node))
307
308        for node in etree.findall('objectgroup'):
309            self.objectgroups.append(TiledObjectGroup(self, node))
310
311        for node in etree.findall('tileset'):
312            self.tilesets.append(TiledTileset(self, node))
313
314        # "tile objects", objects with a GID, have need to have their
315        # attributes set after the tileset is loaded, so this step must be performed last
316        for o in self.objects:
317            p = self.getTilePropertiesByGID(o.gid)
318            if p:
319                o.__dict__.update(p)
320
321    def addTileLayer(self, layer):
322        """
323        Add a TiledLayer layer object to the map.
324        """
325
326        if not isinstance(layer, TiledLayer):
327            msg = "Layer must be an TiledLayer object.  Got {0} instead."
328            raise ValueError, msg.format(type(layer))
329
330        self.tilelayers.append(layer)
331        self.all_layers.append(layer)
332        self.layernames[layer.name] = layer
333
334    def addImageLayer(self, layer):
335        """
336        Add a TiledImageLayer layer object to the map.
337        """
338
339        if not isinstance(layer, TiledImageLayer):
340            msg = "Layer must be an TiledImageLayer object.  Got {0} instead."
341            raise ValueError, msg.format(type(layer))
342
343        self.imagelayers.append(layer)
344        self.all_layers.append(layer)
345        self.layernames[layer.name] = layer
346
347    def getTileLayerByName(self, name):
348        """
349        Return a TiledLayer object with the name.
350        This is case-sensitive.
351        """
352
353        try:
354            return self.layernames[name]
355        except KeyError:
356            msg = "Layer \"{0}\" not found."
357            raise ValueError, msg.format(name)
358
359    def getTileLayerOrder(self):
360        """
361        Return a list of the map's layers in drawing order.
362        """
363
364        return list(self.tilelayers)
365
366    @property
367    def visibleTileLayers(self):
368        """
369        Returns a list of TileLayer objects that are set 'visible'.
370
371        Layers have their visibility set in Tiled.  Optionally, you can over-
372        ride the Tiled visibility by creating a property named 'visible'.
373        """
374
375        return [layer for layer in self.tilelayers if layer.visible]
376
377    @property
378    def objects(self):
379        """
380        Return iterator of all the objects associated with this map
381        """
382        return chain(*self.objectgroups)
383
384    @property
385    def visibleLayers(self):
386        """
387        Returns a generator of [Image/Tile]Layer objects that are set 'visible'.
388
389        Layers have their visibility set in Tiled.
390        """
391        return (l for l in self.all_layers if l.visible)
392
393
394class TiledTileset(TiledElement):
395    reserved = "visible firstgid source name tilewidth tileheight spacing margin image tile properties".split()
396
397    def __init__(self, parent, node):
398        TiledElement.__init__(self)
399        self.parent = parent
400
401        # defaults from the specification
402        self.firstgid = 0
403        self.source = None
404        self.name = None
405        self.tilewidth = 0
406        self.tileheight = 0
407        self.spacing = 0
408        self.margin = 0
409        self.tiles = {}
410        self.trans = None
411        self.width = 0
412        self.height = 0
413
414        self.parse(node)
415
416    def __repr__(self):
417        return "<{0}: \"{1}\">".format(self.__class__.__name__, self.name)
418
419    def parse(self, node):
420        """
421        parse a tileset element and return a tileset object and properties for
422        tiles as a dict
423
424        a bit of mangling is done here so that tilesets that have external
425        TSX files appear the same as those that don't
426        """
427        import os
428
429        # if true, then node references an external tileset
430        source = node.get('source', False)
431        if source:
432            if source[-4:].lower() == ".tsx":
433
434                # external tilesets don't save this, store it for later
435                self.firstgid = int(node.get('firstgid'))
436
437                # we need to mangle the path - tiled stores relative paths
438                dirname = os.path.dirname(self.parent.filename)
439                path = os.path.abspath(os.path.join(dirname, source))
440                try:
441                    node = ElementTree.parse(path).getroot()
442                except IOError:
443                    msg = "Cannot load external tileset: {0}"
444                    raise Exception, msg.format(path)
445
446            else:
447                msg = "Found external tileset, but cannot handle type: {0}"
448                raise Exception, msg.format(self.source)
449
450        self.set_properties(node)
451
452        # since tile objects [probably] don't have a lot of metadata,
453        # we store it separately in the parent (a TiledMap instance)
454        for child in node.getiterator('tile'):
455            real_gid = int(child.get("id"))
456            p = parse_properties(child)
457            p['width'] = self.tilewidth
458            p['height'] = self.tileheight
459            for gid, flags in self.parent.map_gid(real_gid + self.firstgid):
460                self.parent.setTileProperties(gid, p)
461
462        image_node = node.find('image')
463        self.source = image_node.get('source')
464        self.trans = image_node.get("trans", None)
465
466
467class TiledLayer(TiledElement):
468    reserved = "visible name x y width height opacity properties data".split()
469
470    def __init__(self, parent, node):
471        TiledElement.__init__(self)
472        self.parent = parent
473        self.data = []
474
475        # defaults from the specification
476        self.name = None
477        self.opacity = 1.0
478        self.visible = True
479
480        self.parse(node)
481
482    def __iter__(self):
483        return self.iter_tiles()
484
485    def iter_tiles(self):
486        for y, x in product(range(self.height), range(self.width)):
487            yield x, y, self.data[y][x]
488
489    def __repr__(self):
490        return "<{0}: \"{1}\">".format(self.__class__.__name__, self.name)
491
492    def parse(self, node):
493        """
494        parse a layer element
495        """
496        from utils import group
497        from itertools import product, imap
498        from struct import unpack
499        import array
500
501        self.set_properties(node)
502
503        data = None
504        next_gid = None
505
506        data_node = node.find('data')
507
508        encoding = data_node.get("encoding", None)
509        if encoding == "base64":
510            from base64 import decodestring
511
512            data = decodestring(data_node.text.strip())
513
514        elif encoding == "csv":
515            next_gid = imap(int, "".join(
516                line.strip() for line in data_node.text.strip()
517            ).split(","))
518
519        elif encoding:
520            msg = "TMX encoding type: {0} is not supported."
521            raise Exception, msg.format(encoding)
522
523        compression = data_node.get("compression", None)
524        if compression == "gzip":
525            from StringIO import StringIO
526            import gzip
527
528            fh = gzip.GzipFile(fileobj=StringIO(data))
529            data = fh.read()
530            fh.close()
531
532        elif compression == "zlib":
533            import zlib
534
535            data = zlib.decompress(data)
536
537        elif compression:
538            msg = "TMX compression type: {0} is not supported."
539            raise Exception, msg.format(str(attr["compression"]))
540
541        # if data is None, then it was not decoded or decompressed, so
542        # we assume here that it is going to be a bunch of tile elements
543        # TODO: this will probably raise an exception if there are no tiles
544        if encoding == next_gid is None:
545            def get_children(parent):
546                for child in parent.findall('tile'):
547                    yield int(child.get('gid'))
548
549            next_gid = get_children(data_node)
550
551        elif data:
552            # data is a list of gids. cast as 32-bit ints to format properly
553            # create iterator to efficiently parse data
554            next_gid = imap(lambda i: unpack("<L", "".join(i))[0], group(data, 4))
555
556        # using bytes here limits the layer to 256 unique tiles
557        # may be a limitation for very detailed maps, but most maps are not
558        # so detailed.
559        [self.data.append(array.array("H")) for i in xrange(self.height)]
560
561        for (y, x) in product(xrange(self.height), xrange(self.width)):
562            self.data[y].append(self.parent.register_gid(*decode_gid(next(next_gid))))
563
564
565class TiledObjectGroup(TiledElement, list):
566    """
567    Stores TiledObjects.  Supports any operation of a normal list.
568    """
569    reserved = "visible name color x y width height opacity object properties".split()
570
571    def __init__(self, parent, node):
572        TiledElement.__init__(self)
573        self.parent = parent
574
575        # defaults from the specification
576        self.name = None
577        self.color = None
578        self.opacity = 1
579        self.visible = 1
580        self.parse(node)
581
582    def __repr__(self):
583        return "<{0}: \"{1}\">".format(self.__class__.__name__, self.name)
584
585    def parse(self, node):
586        """
587        parse a objectgroup element and return a object group
588        """
589
590        self.set_properties(node)
591
592        for child in node.findall('object'):
593            o = TiledObject(self.parent, child)
594            self.append(o)
595
596
597class TiledObject(TiledElement):
598    reserved = "visible name type x y width height gid properties polygon polyline image".split()
599
600    def __init__(self, parent, node):
601        TiledElement.__init__(self)
602        self.parent = parent
603
604        # defaults from the specification
605        self.name = None
606        self.type = None
607        self.x = 0
608        self.y = 0
609        self.width = 0
610        self.height = 0
611        self.rotation = 0
612        self.gid = 0
613        self.visible = 1
614
615        self.parse(node)
616
617    def __repr__(self):
618        return "<{0}: \"{1}\">".format(self.__class__.__name__, self.name)
619
620    def parse(self, node):
621        self.set_properties(node)
622
623        # correctly handle "tile objects" (object with gid set)
624        if self.gid:
625            self.gid = self.parent.register_gid(self.gid)
626
627        points = None
628
629        polygon = node.find('polygon')
630        if polygon is not None:
631            points = read_points(polygon.get('points'))
632            self.closed = True
633
634        polyline = node.find('polyline')
635        if polyline is not None:
636            points = read_points(polyline.get('points'))
637            self.closed = False
638
639        if points:
640            x1 = x2 = y1 = y2 = 0
641            for x, y in points:
642                if x < x1: x1 = x
643                if x > x2: x2 = x
644                if y < y1: y1 = y
645                if y > y2: y2 = y
646            self.width = abs(x1) + abs(x2)
647            self.height = abs(y1) + abs(y2)
648            self.points = tuple([(i[0] + self.x, i[1] + self.y) for i in points])
649
650class TiledImageLayer(TiledElement):
651    reserved = "visible source name width height opacity visible".split()
652
653    def __init__(self, parent, node):
654        TiledElement.__init__(self)
655        self.parent = parent
656        self.source = None
657        self.trans = None
658
659        # unify the structure of layers
660        self.gid = 0
661
662        # defaults from the specification
663        self.name = None
664        self.opacity = 1
665        self.visible = 1
666
667        self.parse(node)
668
669    def parse(self, node):
670        self.set_properties(node)
671
672        self.name = node.get('name', None)
673        self.opacity = node.get('opacity', self.opacity)
674        self.visible = node.get('visible', self.visible)
675
676        image_node = node.find('image')
677        self.source = image_node.get('source')
678        self.trans = image_node.get('trans', None)