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)