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)