all repos — Legends-RPG @ ec5f95f22de795323e125c8f65989af74c1f312a

A fantasy mini-RPG built with Python and Pygame.

data/pytmx/pytmx.py (view raw)

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