"""Tilesets"""
from __future__ import division
import collections
import contextlib
from tmxlib import helpers, fileio, tile, image, terrain
[docs]class TilesetList(helpers.NamedElementList):
"""A list of tilesets.
Allows indexing by name.
Whenever the list is changed, GIDs of tiles in the associated map are
renumbered to match the new set of tilesets.
"""
def __init__(self, map, lst=None):
self.map = map
self._being_modified = False
super(TilesetList, self).__init__(lst)
@contextlib.contextmanager
[docs] def modification_context(self):
"""Context manager that "wraps" modifications to the tileset list
While this manager is active, the map's tiles are invalid and should
not be touched.
After all modification_contexts exit, tiles are renumbered to match the
new tileset list. This means that multiple operations on the tileset
list can be wrapped in a modification_context for efficiency.
If a used tileset is removed, an exception will be raised whenever the
outermost modification_context exits.
"""
if self._being_modified:
# Ignore inner context
yield
else:
self._being_modified = True
try:
with super(TilesetList, self).modification_context():
previous_tilesets = list(self.list)
yield
# skip renumbering if tilesets were appended, or unchanged
if previous_tilesets != self.list[:len(previous_tilesets)]:
self._renumber_map(previous_tilesets)
if self.map.end_gid > tile.MapTile.gid.value:
raise ValueError('Too many tiles to be represented')
finally:
self._being_modified = False
def _renumber_map(self, previous_tilesets):
"""Renumber tiles in the map after tilesets are changed
This reassigns the GIDs of tiles to match the new situation.
If an used tileset was removed, raise a ValueError. (Note that this
method by itself won't restore the previous state.)
"""
gid_map = dict()
for tile in self.map.all_tiles():
if tile and tile.gid not in gid_map:
tileset_tile = tile._tileset_tile(previous_tilesets)
try:
gid_map[tile.gid] = tileset_tile.gid(self.map)
except helpers.TilesetNotInMapError:
msg = 'Cannot remove %s: map contains its tiles'
raise helpers.UsedTilesetError(msg % tileset_tile.tileset)
for tile in self.map.all_tiles():
if tile:
tile.gid = gid_map[tile.gid]
[docs]class TilesetTile(helpers.PixelSizeMixin):
"""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 <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.
"""
def __init__(self, tileset, number):
self.tileset = tileset
self.number = number
[docs] 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
[docs] def pixel_size(self):
return self.tileset.tile_size
@property
def properties(self):
return self.tileset.tile_attributes[self.number].setdefault(
'properties', {})
@properties.setter
[docs] 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
[docs] 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
[docs] 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
[docs] def image(self):
return self.tileset.tile_image(self.number)
[docs] 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
[docs] 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)
TileOffsetMixin = helpers.tuple_mixin(
'TileOffsetMixin', 'tile_offset', ['tile_offset_x', 'tile_offset_y'])
[docs]class Tileset(fileio.ReadWriteBase, TileOffsetMixin):
"""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'
def __init__(self, name, tile_size, source=None):
self.name = name
self.tile_size = tile_size
self.source = source
self.properties = {}
self.terrains = terrain.TerrainList()
self.tiles = {}
self.tile_attributes = collections.defaultdict(dict)
self.tile_offset = 0, 0
[docs] 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] = TilesetTile(self, n)
return tile
else:
return self[len(self) + n]
[docs] def __len__(self):
"""Return the number of tiles in this tileset.
Subclasses need to override this method.
"""
raise NotImplementedError('Tileset.__len__ is abstract')
[docs] def __iter__(self):
"""Iterate through tiles in this tileset.
"""
for i in range(len(self)):
yield self[i]
[docs] 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
[docs] def end_gid(self, map):
"""Return the first gid after this tileset in the given map
"""
return self.first_gid(map) + len(self)
[docs] 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
[docs] 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
[docs] 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))
[docs] def to_dict(self, **kwargs):
"""Export to a dict compatible with Tiled's JSON plugin"""
d = dict(
name=self.name,
properties=self.properties,
)
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 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
[docs] def from_dict(cls, dct):
"""Import from a dict compatible with Tiled's JSON plugin"""
raise NotImplementedError(
'Tileset.from_dict must be implemented in subclasses')
[docs]class ImageTileset(Tileset):
"""A tileset whose tiles form a rectangular grid on a single image.
This is the default tileset type in Tiled.
init arguments, which become attributes:
.. attribute:: name
.. attribute:: tile_size
.. attribute:: source
see :class:`Tileset`
.. attribute:: image
The :class:`Image` this tileset is based on.
.. attribute:: margin
Size of a border around the image that does not contain tiles,
in pixels.
.. attribute:: spacing
Space between adjacent tiles, in pixels.
Other attributes:
.. attribute:: column_count
Number of columns of tiles in the tileset
.. attribute:: row_count
Number of rows of tiles in the tileset
"""
def __init__(self, name, tile_size, image, margin=0, spacing=0,
source=None, base_path=None):
super(ImageTileset, self).__init__(name, tile_size, source)
self.image = image
self.margin = margin
self.spacing = spacing
self.base_path = base_path
def __len__(self):
return self.column_count * self.row_count
def _count(self, axis):
return (
(self.image.size[axis] - 2 * self.margin + self.spacing) //
(self.tile_size[axis] + self.spacing)
)
@property
[docs] def column_count(self):
"""Number of columns in the tileset"""
return self._count(0)
@property
[docs] def row_count(self):
"""Number of rows in the tileset"""
return self._count(1)
[docs] def tile_image(self, number):
"""Return the image used by the given tile"""
y, x = divmod(number, self.column_count)
left = self.margin + x * (self.tile_width + self.spacing)
top = self.margin + y * (self.tile_height + self.spacing)
return image.ImageRegion(self.image, (left, top), self.tile_size)
[docs] def to_dict(self, **kwargs):
"""Export to a dict compatible with Tiled's JSON plugin"""
d = super(ImageTileset, self).to_dict(**kwargs)
d.update(dict(
image=self.image.source,
imageheight=self.image.height,
imagewidth=self.image.width,
margin=self.margin,
spacing=self.spacing,
tilewidth=self.tile_width,
tileheight=self.tile_height,
))
if self.image.trans:
d['transparentcolor'] = '#' + fileio.to_hexcolor(self.image.trans)
return d
@helpers.from_dict_method
[docs] def from_dict(cls, dct):
"""Import from a dict compatible with Tiled's JSON plugin"""
dct.pop('firstgid', None)
html_trans = dct.pop('transparentcolor', None)
if html_trans:
trans = fileio.from_hexcolor(html_trans)
else:
trans = None
self = cls(
name=dct.pop('name'),
tile_size=(dct.pop('tilewidth'), dct.pop('tileheight')),
image=image.open(
dct.pop('image'),
size=(dct.pop('imagewidth'), dct.pop('imageheight')),
trans=trans,
),
margin=dct.pop('margin', 0),
spacing=dct.pop('spacing', 0),
)
self.properties.update(dct.pop('properties', {}))
for number, properties in dct.pop('tileproperties', {}).items():
self[int(number)].properties.update(properties)
for number, attrs in dct.pop('tiles', {}).items():
attrs = dict(attrs)
probability = attrs.pop('probability', None)
if probability is not None:
self[int(number)].probability = probability
terrain_indices = attrs.pop('terrain', None)
if terrain_indices is not None:
self[int(number)].terrain_indices = terrain_indices
assert not 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']
return self