class ImageBase(helpers.SizeMixin): """Image base class This defines the basic image API, shared by :class:`~tmxlib.image_base.Image` and :class:`~tmxlib.image_base.ImageRegion`. Pixels are represented as (r, g, b, a) float tuples, with components in the range of 0 to 1. """ x, y = helpers.unpacked_properties('top_left') def __getitem__(self, pos): """Get a pixel or region With a pair of integers, this returns a pixel via :meth:`~tmxlib.image_base.Image.get_pixel`: :param pos: pair of integers, (x, y) :return: pixel at (x, y) as a (r, g, b, a) float tuple With a pair of slices, returns a sub-image: :param pos: pair of slices, (left:right, top:bottom) :return: a :class:`~tmxlib.image_base.ImageRegion` """ x, y = pos try: left = x.start right = x.stop top = y.start bottom = y.stop except AttributeError: return self.get_pixel(x, y) else: for c in x, y: if c.step not in (None, 1): raise ValueError('step not supported for slicing images') left, top = self._wrap_coords( 0 if left is None else left, 0 if top is None else top) right, bottom = self._wrap_coords( self.width if right is None else right, self.height if bottom is None else bottom) left = _clamp(left, 0, self.width) right = _clamp(right, left, self.width) top = _clamp(top, 0, self.height) bottom = _clamp(bottom, top, self.height) return ImageRegion(self, (left, top), (right - left, bottom - top)) def _parent_info(self): """Return (x offset, y offset, immutable image) Used to make sure the parents of ImageRegion is always an Image, not another region or a canvas. """ return 0, 0, self
class DrawImageCommand(DrawCommand): """Command to draw an image init arguments that become attributes: .. attribute:: image The image to draw .. attribute:: pos Position at which to draw the image. Will also available as ``x`` and ``y`` attributes. """ x, y = helpers.unpacked_properties('pos') def __init__(self, image, pos=(0, 0), opacity=1): self.image = image self.pos = pos self.opacity = opacity def draw(self, canvas): canvas.draw_image(self.image, self.pos, opacity=self.opacity)
class MapObject(helpers.LayerElementMixin): """A map object: something that's not placed on the fixed grid Has several subclasses. Can be either a "tile object", which has an associated tile much like a map-tile, or a regular (non-tile) object that has a settable size. init arguments, which become attributes: .. attribute:: layer The layer this object is on .. attribute:: pixel_pos The pixel coordinates .. attribute:: pixel_size Size of this object, as a (width, height) tuple, in pixels. Only one of ``pixel_size`` and ``size`` may be specified. .. attribute:: size Size of this object, as a (width, height) tuple, in units of map tiles. .. attribute:: name Name of the object. A string (or unicode) .. attribute:: type Type of the object. A string (or unicode). No semantics attached. .. attribute:: id Unique numeric ID of the object. Other attributes: .. attribute:: objtype Type of the object: ``'rectangle'``, ``'tile'`` or ``'ellipse'`` .. attribute:: properties Dict of string (or unicode) keys & values for custom data .. attribute:: pos Position of the object in tile coordinates, as a (x, y) float tuple .. attribute:: map The map associated with this object Unpacked position attributes: .. attribute:: x .. attribute:: y .. attribute:: pixel_x .. attribute:: pixel_y """ # XXX: Implement `id` -- "Even if an object is deleted, no object ever gets # the same ID." pixel_x, pixel_y = helpers.unpacked_properties('pixel_pos') def __init__(self, layer, pixel_pos, *, name=None, type=None, id=None, template=None): self.layer = layer self.pixel_pos = pixel_pos self.name = name self.type = type self.properties = {} self.id = id self.template = template @property def pos(self): return (self.pixel_pos[0] / self.layer.map.tile_width, self.pixel_pos[1] / self.layer.map.tile_height - 1) @pos.setter def pos(self, value): x, y = value y += 1 self.pixel_pos = (x * self.layer.map.tile_width, y * self.layer.map.tile_height) def to_dict(self, y=None): """Export to a dict compatible with Tiled's JSON plugin""" if y is None: y = self.pixel_y d = dict( name=self.name or '', type=self.type or '', x=self.pixel_x, y=y, visible=True, properties=self.properties, ) return d @classmethod def from_dict(cls, dct, layer): """Import from a dict compatible with Tiled's JSON plugin""" if dct.get('ellipse', False): return EllipseObject.from_dict(dct, layer) elif dct.get('polygon', False): return PolygonObject.from_dict(dct, layer) elif dct.get('polyline', False): return PolylineObject.from_dict(dct, layer) else: return RectangleObject.from_dict(dct, layer) @classmethod def _dict_helper(cls, dct, layer, **kwargs): helpers.assert_item(dct, 'visible', True) self = cls(layer=layer, pixel_pos=(dct.pop('x'), dct.pop('y')), name=dct.pop('name', None), type=dct.pop('type', None), **kwargs) self.properties.update(dct.pop('properties', {})) return self
class Map(fileio.ReadWriteBase, helpers.SizeMixin): """A tile map, tmxlib's core class init arguments, which become attributes: .. attribute:: size a (height, width) pair specifying the size of the map, in tiles .. attribute:: tile_size a pair specifying the size of one tile, in pixels .. attribute:: orientation The orientation of the map (``'orthogonal'``, ``'isometric'``, or ``'staggered'``) .. attribute:: background_color The background color for the map, as a triple of floats (0..1) Other attributes: .. attribute:: tilesets A :class:`TilesetList` of tilesets this map uses .. attribute:: layers A :class:`LayerList` of layers this map uses .. attribute:: properties A dict of properties, with string (or unicode) keys & values .. attribute:: pixel_size The size of the map, in pixels. Not settable directly: use `size` and `tile_size` for that. .. attribute:: end_gid The first GID that is not available for tiles. This is the end_gid for the map's last tileset. Unpacked size attributes: Each "size" property has corresponding "width" and "height" properties. .. attribute:: height .. attribute:: width .. attribute:: tile_height .. attribute:: tile_width .. attribute:: pixel_height .. attribute:: pixel_width """ _rw_obj_type = 'map' tile_width, tile_height = helpers.unpacked_properties('tile_size') pixel_width, pixel_height = helpers.unpacked_properties('pixel_size') # XXX: Fully implement, test, and document base_path: # This should be used for saving, so that relative paths work as # correctly as they can. # And it's not just here... def __init__(self, size, tile_size, orientation='orthogonal', background_color=None, base_path=None, render_order=None): self.orientation = orientation self.size = size self.tile_size = tile_size self.tilesets = tileset.TilesetList(self) self.layers = layer.LayerList(self) self.background_color = background_color self.properties = {} self.base_path = base_path self.render_order = render_order @property def pixel_size(self): return self.width * self.tile_width, self.height * self.tile_height @property def end_gid(self): try: last_tileset = self.tilesets[-1] except IndexError: return 0 else: return last_tileset.end_gid(self) def add_layer(self, name, before=None, after=None, layer_class=None): """Add an empty layer with the given name to the map. By default, the new layer is added at the end of the layer list. A different position may be specified with either of the `before` or `after` arguments, which may be integer indices or names. layer_class defaults to TileLayer """ if not layer_class: layer_class = layer.TileLayer new_layer = layer_class(self, name) if after is not None: if before is not None: raise ValueError("Can't specify both before and after") self.layers.insert_after(after, new_layer) elif before is not None: self.layers.insert(before, new_layer) else: self.layers.append(new_layer) return new_layer def add_tile_layer(self, name, before=None, after=None): """Add an empty tile layer with the given name to the map. See add_layer """ return self.add_layer(name, before, after, layer_class=layer.TileLayer) def add_object_layer(self, name, before=None, after=None): """Add an empty object layer with the given name to the map. See add_layer """ return self.add_layer(name, before, after, layer_class=layer.ObjectLayer) def add_image_layer(self, name, image, before=None, after=None): """Add an image layer with the given name and image to the map. See add_layer """ new_layer = self.add_layer(name, before, after, layer_class=layer.ImageLayer) new_layer.image = image return new_layer def all_tiles(self): """Yield all tiles in the map, including tile objects """ for layer in self.layers: for tile in layer.all_tiles(): yield tile def all_objects(self): """Yield all objects in the map """ for layer in self.layers: for obj in layer.all_objects(): yield obj def get_tiles(self, x, y): """For each tile layer, yield the tile at the given position. """ for layer in self.layers: if layer.type == 'tiles': yield layer[x, y] def check_consistency(self): """Check that this map is okay. Most checks are done when reading a map, but if more are required, call this method after reading. This will do a more expensive check than what's practical from within readers. """ large_gid = self.end_gid for tile in self.all_tiles(): assert tile.gid < large_gid def generate_draw_commands(self): return itertools.chain.from_iterable(layer.generate_draw_commands() for layer in self.layers if layer.visible) def render(self): from tmxlib.canvas import Canvas canvas = Canvas( self.pixel_size, #color=self.background_color, commands=self.generate_draw_commands()) return canvas def _repr_png_(self): return self.render()._repr_png_() def to_dict(self): """Export to a dict compatible with Tiled's JSON plugin You can use e.g. a JSON or YAML library to write such a dict to a file. """ d = dict( height=self.height, width=self.width, tileheight=self.tile_height, tilewidth=self.tile_width, orientation=self.orientation, properties=self.properties, version=1, layers=[la.to_dict() for la in self.layers], tilesets=[t.to_dict(map=self) for t in self.tilesets], ) if self.background_color: d['backgroundcolor'] = '#' + fileio.to_hexcolor( self.background_color) return d @helpers.from_dict_method def from_dict(cls, dct, base_path=None): """Import from a dict compatible with Tiled's JSON plugin Use e.g. a JSON or YAML library to read such a dict from a file. :param dct: Dictionary with data :param base_path: Base path of the file, for loading linked resources """ if dct.pop('version', 1) != 1: raise ValueError('tmxlib only supports Tiled JSON version 1') self = cls( size=(dct.pop('width'), dct.pop('height')), tile_size=(dct.pop('tilewidth'), dct.pop('tileheight')), orientation=dct.pop('orientation', 'orthogonal'), ) if base_path: self.base_path = base_path background_color = dct.pop('backgroundcolor', None) if background_color: self.background_color = fileio.from_hexcolor(background_color) self.properties = dct.pop('properties') self.tilesets = [ tileset.Tileset.from_dict(d, base_path) for d in dct.pop('tilesets') ] self.layers = [ layer.Layer.from_dict(d, self) for d in dct.pop('layers') ] self.properties.update(dct.pop('properties', {})) return self
class TilesetTile(object): """Reference to a tile within a tileset init arguents, which become attributes: .. attribute:: tileset the tileset this tile belongs to .. attribute:: number the number of the tile Other attributes: .. attribute:: pixel_size The size of the tile, in pixels. Also available as (``pixel_width``, ``pixel_height``). .. attribute:: properties A string-to-string dictionary holding custom properties of the tile .. attribute:: image Image this tile uses. Most often this will be a :class:`region <~tmxlib.image_base.ImageRegion>` of the tileset's image. .. attribute:: terrain_indices List of indices to the tileset's terrain list for individual corners of the tile. See the TMX documentation for details. .. attribute:: terrains Tuple of terrains for individual corners of the tile. If no terrain is given, None is used instead. .. attribute:: probability The probability that this tile will be chosen among others with the same terrain information. May be None. """ pixel_width, pixel_height = helpers.unpacked_properties('pixel_size') def __init__(self, tileset, number): self.tileset = tileset self.number = number def gid(self, map): """Return the GID of this tile for a given map The GID is a map-specific identifier unique for any tileset-tile the map uses. """ return self.tileset.first_gid(map) + self.number @property def pixel_size(self): return self.image.size @property def properties(self): return self.tileset.tile_attributes[self.number].setdefault( 'properties', {}) @properties.setter def properties(self, v): self.tileset.tile_attributes[self.number]['properties'] = v @property def probability(self): return self.tileset.tile_attributes[self.number].setdefault( 'probability', None) @probability.setter def probability(self, v): self.tileset.tile_attributes[self.number]['probability'] = v @property def terrain_indices(self): return self.tileset.tile_attributes[self.number].setdefault( 'terrain_indices', []) @terrain_indices.setter def terrain_indices(self, v): self.tileset.tile_attributes[self.number]['terrain_indices'] = v def __eq__(self, other): try: other_number = other.number other_tileset = other.tileset except AttributeError: return False return self.number == other_number and self.tileset is other_tileset def __hash__(self): return hash(('tmxlib tileset tile', self.number, self.tileset)) def __ne__(self, other): return not (self == other) def __repr__(self): return '<TilesetTile #%s of %s at 0x%x>' % ( self.number, self.tileset.name, id(self)) @property def image(self): return self.tileset.tile_image(self.number) @image.setter def image(self, value): return self.tileset.set_tile_image(self.number, value) def get_pixel(self, x, y): """Get a pixel at the specified location. Pixels are returned as RGBA 4-tuples. """ return self.image.get_pixel(x, y) @property def terrains(self): result = [] for index in self.terrain_indices: try: result.append(self.tileset.terrains[index]) except (IndexError, KeyError): result.append(None) return tuple(result)
class Tileset(fileio.ReadWriteBase): """Base class for a tileset: bank of tiles a map can use. There are two kinds of tilesets: external and internal. Internal tilesets are specific to a map, and their contents are saved inside the map file. External tilesets are saved to their own file, so they may be shared between several maps. (Of course, any tileset can be shared between maps at the Python level; this distinction only applies to what happens on disk.) External tilesets have the file path in their `source` attribute; internal ones have `source` set to None. tmxlib will try to ensure that each external tileset gets only loaded once, an the resulting Python objects are shared. See :meth:`ReadWriteBase.open` for more information. init arguments, which become attributes: .. attribute:: name Name of the tileset .. attribute:: tile_size: A (width, height) pair giving the size of a tile in this tileset. In cases where a tileset can have unequally sized tiles, the tile size is not defined. This means that this property should not be used unless working with a specific subclass that defines tile_size better. .. attribute:: source For external tilesets, the file name for this tileset. None for internal ones. Other attributes: .. attribute:: properties A dict with string (or unicode) keys and values. Note that the official TMX format does not support tileset properties (`yet <https://github.com/bjorn/tiled/issues/77>`_), so editors like Tiled will remove these. (tmxlib saves and loads them just fine, however.) .. attribute:: terrains A :class:`~tmxlib.terrain.TerrainList` of terrains belonging to this tileset. Note that tileset tiles reference these by index, and the indices are currently not updated when the TerrainList is modified. This may change in the future. .. attribute:: tile_offset An offset in pixels to be applied when drawing a tile from this tileset. Unpacked versions of tuple attributes: .. attribute:: tile_width .. attribute:: tile_height .. attribute:: tile_offset_x .. attribute:: tile_offset_y """ # XXX: When Serializers are official, include note for shared=True: (This # will only work if all the tilesets are loaded with the same Serializer.) column_count = None _rw_obj_type = 'tileset' tile_class = TilesetTile tile_offset_x, tile_offset_y = helpers.unpacked_properties('tile_offset') def __init__(self, name, tile_size): self.name = name self.tile_size = tile_size self.properties = {} self.terrains = terrain.TerrainList() self.tiles = {} self.tile_attributes = collections.defaultdict(dict) self.tile_offset = 0, 0 def __getitem__(self, n): """Get tileset tile with the given number. Supports negative indices by wrapping around, as one would expect. """ if n >= 0: try: tile = self.tiles[n] except KeyError: tile = self.tiles[n] = self.tile_class(self, n) return tile else: return self[len(self) + n] def __len__(self): """Return the number of tiles in this tileset. Subclasses need to override this method. """ raise NotImplementedError('Tileset.__len__ is abstract') def __iter__(self): """Iterate through tiles in this tileset. """ for i in range(len(self)): yield self[i] def first_gid(self, map): """Return the first gid used by this tileset in the given map """ num = 1 for tileset in map.tilesets: if tileset is self: return num else: num += len(tileset) error = helpers.TilesetNotInMapError('Tileset not in map') error.tileset = self raise error def end_gid(self, map): """Return the first gid after this tileset in the given map """ return self.first_gid(map) + len(self) def tile_image(self, number): """Return the image used by the given tile. Usually this will be a region of a larger image. Subclasses need to override this method. """ raise NotImplementedError('Tileset.tile_image') @property def tile_width(self): """Width of a tile in this tileset. See `size` in the class docstring. """ return self.tile_size[0] @tile_width.setter def tile_width(self, value): self.tile_size = value, self.tile_size[1] @property def tile_height(self): """Height of a tile in this tileset. See `size` in the class docstring. """ return self.tile_size[1] @tile_height.setter def tile_height(self, value): self.tile_size = self.tile_size[0], value def __repr__(self): return '<%s %r at 0x%x>' % (type(self).__name__, self.name, id(self)) def to_dict(self, **kwargs): """Export to a dict compatible with Tiled's JSON plugin""" d = dict( name=self.name, properties=self.properties, tilewidth=self.tile_width, tileheight=self.tile_height, ) if 'map' in kwargs: d['firstgid'] = self.first_gid(kwargs['map']) tile_properties = {} tiles = collections.defaultdict(dict) for tile in self: number = str(tile.number) if tile.properties: tile_properties[number] = tile.properties if tile.probability is not None: tiles[number]['probability'] = tile.probability if tile.terrain_indices: tiles[number]['terrain'] = list(tile.terrain_indices) if getattr(tile.image, 'source', None): tiles[number]['image'] = tile.image.source if tile_properties: d['tileproperties'] = tile_properties if tiles: d['tiles'] = dict(tiles) if self.terrains: d['terrains'] = [{ 'name': t.name, 'tile': t.tile.number } for t in self.terrains] if any(self.tile_offset): d['tileoffset'] = { 'x': self.tile_offset_x, 'y': self.tile_offset_y } return d @classmethod def from_dict(cls, dct, base_path=None): """Import from a dict compatible with Tiled's JSON plugin""" if 'image' in dct: return ImageTileset.from_dict(dct, base_path) else: return IndividualTileTileset.from_dict(dct, base_path) def _fill_from_dict(self, dct, base_path): dct.pop('firstgid', None) if base_path: self.base_path = base_path self.properties.update(dct.pop('properties', {})) for number, properties in dct.pop('tileproperties', {}).items(): self[int(number)].properties.update(properties) tile_info = dct.pop('tiles', {}) for number in sorted(tile_info, key=int): attrs = dict(tile_info[number]) number = int(number) probability = attrs.pop('probability', None) if probability is not None: self[number].probability = probability terrain_indices = attrs.pop('terrain', None) if terrain_indices is not None: self[number].terrain_indices = terrain_indices if number > len(tile_info): raise ValueError() while 0 <= len(self) <= number: self._append_placeholder() filename = attrs.pop('image', None) if filename: self[number].image = image.open(filename) if base_path: self[number].image.base_path = base_path if attrs: raise ValueError('Extra tile attributes: %s' % ', '.join(attrs)) for terrain in dct.pop('terrains', []): terrain = dict(terrain) self.terrains.append_new(terrain.pop('name'), self[int(terrain.pop('tile'))]) assert not terrain tileoffset = dct.pop('tileoffset', None) if tileoffset: self.tile_offset = tileoffset['x'], tileoffset['y'] dct.pop('margin', None) dct.pop('spacing', None)