"""
Module defining the tile respresentation in dustmaker.
"""
import copy
from dataclasses import dataclass
from enum import IntEnum
import math
from typing import Generator, Optional, Tuple
from .transform import TxMatrix
[docs]class TileSpriteSet(IntEnum):
"""Used to describe what set of tiles a tile's sprite comes from."""
NONE_0 = 0
MANSION = 1
FOREST = 2
CITY = 3
LABORATORY = 4
TUTORIAL = 5
NEXUS = 6
NONE_7 = 7
[docs]class TileSide(IntEnum):
"""Used to index the sides of a tile. This is the indexing done by the
Dustforce engine itself."""
TOP = 0
BOTTOM = 1
LEFT = 2
RIGHT = 3
[docs]@dataclass
class TileEdgeData:
"""Data class for data stored on each tile edge. Many attributes are stored
as pairs of data to correspond to the two corners of the tile edge. These
corners are ordered clockwise around the tile (the tile should be on your
right as you traverse from the first to second corner).
"""
#: Should this edge produce collisions
solid: bool = False
#: Is this edge visible
visible: bool = False
#: Wether an edge cap should be drawn for each corner
caps: Tuple[bool, bool] = (False, False)
#: Edge join angle in degrees, should be in the range #: -90 < angle < 90.
#: Ignored if the corresponding cap flag is set.
angles: Tuple[int, int] = (0, 0)
#: Sprite set of dust or spikes on this edge. Use `TileSpriteSet.NONE_0`
#: to indicate no filth on this edge.
filth_sprite_set: TileSpriteSet = TileSpriteSet.NONE_0
#: If :attr:`filth_sprite_set` is not `TileSpriteSet.NONE_0` this flag
#: controls if there is dust or spikes on the edge.
filth_spike: bool = False
#: Same as :attr:`caps` but for drawing filth (dust/spikes) caps.
filth_caps: Tuple[bool, bool] = (False, False)
#: Same as :attr:`angles` but for filth join angles.
filth_angles: Tuple[int, int] = (0, 0)
[docs]class TileShape(IntEnum):
"""Tiles come in four main types; full, half, big, and small. Images of
those tiles can be seen below. Alternatively refer to
https://github.com/cmann1/PropUtils/blob/master/files/tiles_reference/TileShapes.jpg
for an image of all tiles in one place.
"""
#: .. image:: images/tiles/full.png
FULL = 0
#: .. image:: images/tiles/big_1.png
BIG_1 = 1
#: .. image:: images/tiles/small_1.png
SMALL_1 = 2
#: .. image:: images/tiles/big_2.png
BIG_2 = 3
#: .. image:: images/tiles/small_2.png
SMALL_2 = 4
#: .. image:: images/tiles/big_3.png
BIG_3 = 5
#: .. image:: images/tiles/small_3.png
SMALL_3 = 6
#: .. image:: images/tiles/big_4.png
BIG_4 = 7
#: .. image:: images/tiles/small_4.png
SMALL_4 = 8
#: .. image:: images/tiles/big_5.png
BIG_5 = 9
#: .. image:: images/tiles/small_5.png
SMALL_5 = 10
#: .. image:: images/tiles/big_6.png
BIG_6 = 11
#: .. image:: images/tiles/small_6.png
SMALL_6 = 12
#: .. image:: images/tiles/big_7.png
BIG_7 = 13
#: .. image:: images/tiles/small_7.png
SMALL_7 = 14
#: .. image:: images/tiles/big_8.png
BIG_8 = 15
#: .. image:: images/tiles/small_8.png
SMALL_8 = 16
#: .. image:: images/tiles/half_a.png
HALF_A = 17
#: .. image:: images/tiles/half_b.png
HALF_B = 18
#: .. image:: images/tiles/half_c.png
HALF_C = 19
#: .. image:: images/tiles/half_d.png
HALF_D = 20
[docs]class Tile:
"""Represents a single tile in a Dustforce level. Positional information
(x, y, layer) is stored within the containing :class:`Level` and not in
the Tile object itself.
Tiles support the equality and hashing interface.
The constructor will by default create a square virtual tile with 4
zeroed (non-solid nor visible) edges.
Attributes:
shape (TileShape): The shape of this particular tile.
tile_flags (3-bit int): Raw bitmask of flags from the Dustforce engine.
In practice this always seems to be 0x4 (which is set by default)
corresponding to just the "solid" flag set. In most cases you should
just ignore this flag. From the engine the definitions are:
* Bit 1 - "solid slope flag" (probably ignored)
* Bit 2 - "visible flag" (probably ignored)
* Bit 3 - solid flag
edge_data: List[TileEdgeData]: Edge data for each edge of the tile. This should always
be a list of length 4 regardless of the tile :attr:`shape`.
sprite_set (TileSpriteSet): The sprite set this tile comes from. (e.g.
forest, mansion)
sprite_tile (int): The index of the specific tile within this sprite set (e.g.
grass, dirt). Check https://github.com/cmann1/PropUtils/tree/master/files/tiles_reference
for a visual reference to get sprite index information.
sprite_palette (int): The color variant of this tile.
"""
def __init__(
self,
shape: TileShape = TileShape.FULL,
*,
tile_flags: int = 0x4,
sprite_set: TileSpriteSet = TileSpriteSet.TUTORIAL,
sprite_tile: int = 1,
sprite_palette: int = 0,
_tile_data: Optional[bytes] = None,
_dust_data: Optional[bytes] = None,
) -> None:
self.shape = shape
self.tile_flags = tile_flags
self.edge_data = [TileEdgeData() for _ in TileSide]
self.sprite_set = sprite_set
self.sprite_tile = sprite_tile
self.sprite_palette = sprite_palette
if _tile_data is not None:
self._unpack_tile_data(_tile_data)
if _dust_data is not None:
self._unpack_dust_data(_dust_data)
def __eq__(self, oth):
if not isinstance(oth, Tile):
return False
return self._vals() == oth._vals()
def __hash__(self):
return hash(self._vals())
def _vals(self):
"""Values used for __eq__ and __hash__"""
return (
self.shape,
self.tile_flags,
self.edge_data,
self.sprite_set,
self.sprite_tile,
self.sprite_palette,
)
[docs] def get_sprite_tuple(self) -> Tuple[TileSpriteSet, int, int]:
"""Convenience method for getting a tuple that describes the sprite
of a tile for easy sprite comparison and copying.
Returns:
A three-tuple containing the sprite set, tile, and palette of this tile.
"""
return self.sprite_set, self.sprite_tile, self.sprite_palette
[docs] def set_sprite_tuple(self, sprite_tuple: Tuple[TileSpriteSet, int, int]) -> None:
"""Convenience method for setting sprite information in the same format
as :meth:`get_sprite_tuple`.
Arguments:
sprite_tuple (TileSpriteSet, int, int): Sprite set, tile, and palette
information.
"""
self.sprite_set, self.sprite_tile, self.sprite_palette = sprite_tuple
@property
def sprite_path(self) -> str:
"""Gives the path within the extracted sprite metadata to the tile sprite
currently selected. You may retrieve the complete game sprites listing from
https://www.dropbox.com/s/jm37ew9p74olgca/sprites.zip?dl=0
"""
return "area/{}/tiles/tile{}_{}_0001.png".format(
self.sprite_set.name.lower(),
self.sprite_tile,
self.sprite_palette + 1,
)
[docs] def has_filth(self) -> bool:
"""Returns true if there is filth attached to any edges of this tile"""
return any(
edge.filth_sprite_set != TileSpriteSet.NONE_0 for edge in self.edge_data
)
[docs] def is_dustblock(self) -> bool:
"""Returns true if the tile is a dustblock tile. This is calculated
based on the current sprite information."""
return SPRITE_SET_DUSTBLOCK_TILE[self.sprite_set] == self.sprite_tile
[docs] def set_dustblock(self) -> None:
"""Update :attr:`sprite_tile` and :attr:`sprite_palette` to be the dustblock matching
the current :attr:`sprite_set`.
Raises:
ValueError: If there is no dustblock tile for the current sprite set.
"""
sprite_tile = SPRITE_SET_DUSTBLOCK_TILE[self.sprite_set]
if sprite_tile == -1:
raise ValueError("current sprite set does not have a dustblock tile")
self.sprite_tile = sprite_tile
self.sprite_palette = 1
[docs] def upscale(self, factor: int) -> Generator[Tuple[int, int, "Tile"], None, None]:
"""
Upscales a tile, returning a list of (dx, dy, tile) tuples giving
the relative position of the upscaled tiles and the new tile shape.
This is primarily used by :meth:`dustmaker.level.Level.upscale`.
Yields:
A three tuple (dx, dy, ntile) where (dx, dy) are the relative position
within the scaled up `factor` x `factor` tile square formed and
`ntile` is the tile that belongs at that position.
"""
if factor < 1:
return
if factor == 1:
yield 0, 0, copy.deepcopy(self)
return
def _tuple_set(data: Tuple, ind: int, value) -> Tuple:
return tuple((value if i == ind else x) for i, x in enumerate(data))
def _copy_side(dx: int, dy: int, tile: Tile, side: TileSide):
"""Copies a side from self into the upscaled tile. Adjust the
caps if the edge does not leave the upscaled tile boundary.
"""
edge_data = copy.deepcopy(self.edge_data[side])
cw_ind = SIDE_CLOCKWISE_INDEX[side]
for dr in range(2):
vert_a = SHAPE_VERTEXES[tile.shape][(cw_ind + 1 - dr) & 0x3]
vert_b = SHAPE_VERTEXES[tile.shape][(cw_ind + dr) & 0x3]
x = 2 * dx + vert_b[0]
y = 2 * dy + vert_b[1]
if vert_a[0] != vert_b[0] and x in (0, factor * 2):
continue
if vert_a[1] != vert_b[1] and y in (0, factor * 2):
continue
edge_data.caps = _tuple_set(edge_data.caps, dr, False) # type: ignore
edge_data.angles = _tuple_set(edge_data.angles, dr, 0) # type: ignore
edge_data.filth_caps = _tuple_set(edge_data.filth_caps, dr, False) # type: ignore
edge_data.filth_angles = _tuple_set(edge_data.filth_angles, dr, 0) # type: ignore
tile.edge_data[side] = edge_data
if self.shape == TileShape.FULL:
for dx in range(factor):
for dy in range(factor):
tile = Tile(TileShape.FULL)
tile.set_sprite_tuple(self.get_sprite_tuple())
if dx == 0:
_copy_side(dx, dy, tile, TileSide.LEFT)
if dx + 1 == factor:
_copy_side(dx, dy, tile, TileSide.RIGHT)
if dy == 0:
_copy_side(dx, dy, tile, TileSide.TOP)
if dy + 1 == factor:
_copy_side(dx, dy, tile, TileSide.BOTTOM)
yield dx, dy, tile
elif self.shape in (TileShape.BIG_1, TileShape.SMALL_1):
for dx in range(factor):
ddx = dx + (factor if self.shape == TileShape.SMALL_1 else 0)
for dy in range(ddx // 2, factor):
tile = Tile(TileShape.FULL)
tile.set_sprite_tuple(self.get_sprite_tuple())
if dy == ddx // 2:
tile.shape = TileShape.SMALL_1 if ddx % 2 else TileShape.BIG_1
_copy_side(dx, dy, tile, TileSide.TOP)
if dx == 0:
_copy_side(dx, dy, tile, TileSide.LEFT)
if dy + 1 == factor:
_copy_side(dx, dy, tile, TileSide.BOTTOM)
yield dx, dy, tile
elif self.shape == TileShape.HALF_A:
for dx in range(factor):
for dy in range(dx, factor):
tile = Tile(TileShape.FULL)
tile.set_sprite_tuple(self.get_sprite_tuple())
if dx == dy:
tile.shape = TileShape.HALF_A
_copy_side(dx, dy, tile, TileSide.TOP)
if dx == 0:
_copy_side(dx, dy, tile, TileSide.LEFT)
if dy + 1 == factor:
_copy_side(dx, dy, tile, TileSide.BOTTOM)
yield dx, dy, tile
else:
# Otherwise transform the tile into one of the above handled cases
# and transform the result back.
new_shape = self.shape
hflip = False
if TileShape.BIG_5 <= new_shape <= TileShape.SMALL_8:
# horizontal flip
hflip = True
new_shape = TileShape(new_shape - 8)
if TileShape.BIG_1 <= new_shape <= TileShape.SMALL_4:
rots = (new_shape - TileShape.BIG_1) // 2
else: # Half tile
rots = new_shape - TileShape.HALF_A
# Calculate rotation matrix and inverse
mat = TxMatrix.ROTATE[-rots % 4]
imat = TxMatrix.ROTATE[rots % 4]
# Apply horizontal flip
if hflip:
mat = mat * TxMatrix.HFLIP
imat = TxMatrix.HFLIP * imat
# Fix up offset so transformed positions stay in upscale square.
mat = mat.translate(
*(max(0, -val) for val in mat.sample(factor - 1, factor - 1))
)
imat = imat.translate(
*(max(0, -val) for val in imat.sample(factor - 1, factor - 1))
)
# Copy tile and transform it.
ntile = copy.deepcopy(self)
ntile.transform(mat) # type: ignore
assert ntile.shape in (TileShape.HALF_A, TileShape.BIG_1, TileShape.SMALL_1)
# For each of the new upscaled tiles inverse the transformation.
for dx, dy, tile in ntile.upscale(factor):
tile.transform(imat) # type: ignore
tx, ty = imat.sample(dx, dy)
yield (int(tx), int(ty), tile)
def _pack_tile_data(self) -> bytes:
"""Pack the dustmaker respresentation back into the binary representation"""
tile_data = [0 for _ in range(12)]
for side, edge in enumerate(self.edge_data):
vals = [edge.solid, edge.visible, *edge.caps]
offsets = [0 + side, 4 + side, 8 + 2 * side, 9 + 2 * side]
if side in (TileSide.LEFT, TileSide.BOTTOM):
# Need to swap the edge bit order to match IO order
offsets[2], offsets[3] = offsets[3], offsets[2]
for val, off in zip(vals, offsets):
if val:
tile_data[off >> 3] |= 1 << (off & 7)
assert -0x80 <= edge.angles[0] <= 0x7F and -0x80 <= edge.angles[1] <= 0x7F
v0, v1 = edge.angles
if side in (TileSide.LEFT, TileSide.BOTTOM):
v0, v1 = v1, v0
tile_data[2 + side * 2] = v0 & 0xFF
tile_data[3 + side * 2] = v1 & 0xFF
assert 0 <= self.sprite_set <= 0xF
assert 0 <= self.sprite_tile <= 0xFF
assert 0 <= self.sprite_palette <= 0xF
tile_data[10] = self.sprite_set + (self.sprite_palette << 4)
tile_data[11] = self.sprite_tile
return bytes(tile_data)
def _unpack_tile_data(self, tile_data: bytes) -> None:
"""Unpack tile data into the representation used by dustmaker."""
assert len(tile_data) == 12
# Extract the edge bits
for side, edge in enumerate(self.edge_data):
def test_offset(off: int) -> bool:
return bool(tile_data[off >> 3] & (1 << (off & 7)))
edge.solid = test_offset(0 + side)
edge.visible = test_offset(4 + side)
edge.caps = (test_offset(8 + 2 * side), test_offset(9 + 2 * side))
if side in (TileSide.LEFT, TileSide.BOTTOM):
edge.caps = (edge.caps[1], edge.caps[0])
v0, v1 = tile_data[2 + side * 2], tile_data[3 + side * 2]
if v0 >= 0x80:
v0 -= 0x100
if v1 >= 0x80:
v1 -= 0x100
if side in (TileSide.LEFT, TileSide.BOTTOM):
v0, v1 = v1, v0
edge.angles = (v0, v1)
self.sprite_set = TileSpriteSet(tile_data[10] & 0xF)
self.sprite_tile = tile_data[11]
self.sprite_palette = tile_data[10] >> 4
def _pack_dust_data(self) -> bytes:
"""Pack the dustmaker respresentation back into the binary representation"""
dust_data = [0 for _ in range(12)]
for side, edge in enumerate(self.edge_data):
sset, spike, caps, angles = (
edge.filth_sprite_set,
edge.filth_spike,
edge.filth_caps,
edge.filth_angles,
)
assert 0 <= sset <= 0xF
assert -0x80 <= angles[0] <= 0x7F
assert -0x80 <= angles[1] <= 0x7F
# Normalize for Dustforce binary format
if side in (TileSide.LEFT, TileSide.BOTTOM):
caps = caps[::-1]
angles = angles[::-1]
off = 4 * side
dust_data[off >> 3] |= (sset | (0x8 if spike else 0)) << (off & 0x7)
dust_data[2 + side * 2] = angles[0] & 0xFF
dust_data[3 + side * 2] = angles[1] & 0xFF
if caps[0]:
dust_data[10] |= 1 << (2 * side)
if caps[1]:
dust_data[10] |= 2 << (2 * side)
return bytes(dust_data)
def _unpack_dust_data(self, dust_data: bytes) -> None:
"""Unpack dust data into the representation used by dustmaker."""
assert len(dust_data) == 12
for side, edge in enumerate(self.edge_data):
off = 4 * side
val = dust_data[off >> 3] >> (off & 0x7)
edge.filth_sprite_set = TileSpriteSet(val & 0x7)
edge.filth_spike = bool(val & 0x8)
edge.filth_caps = (
bool((dust_data[10] >> (2 * side)) & 0x1),
bool((dust_data[10] >> (2 * side)) & 0x2),
)
v0, v1 = dust_data[2 + side * 2], dust_data[3 + side * 2]
if v0 >= 0x80:
v0 -= 0x100
if v1 >= 0x80:
v1 -= 0x100
edge.filth_angles = (v0, v1)
# Normalize for Dustforce binary format
if side in (TileSide.LEFT, TileSide.BOTTOM):
edge.filth_caps = edge.filth_caps[::-1]
edge.filth_angles = edge.filth_angles[::-1]
#: Mapping of :class:`TileSpriteSet` to the corresponding dustblock index
#: for that sprite set. Gives -1 if no dustblock tile is available for the
#: given sprite set.
#:
#: Type:
#: tuple mapping :class:`TileSpriteSet` -> int
#:
#: :meta hide-value:
SPRITE_SET_DUSTBLOCK_TILE = (
-1, # NONE_0
21, # MANSION
13, # FOREST
6, # CITY
9, # LABORATORY
2, # TUTORIAL
-1, # NEXUS
-1, # NONE_7
)
#: A mapping of :class:`TileShape` to a sequence of sides.
#:
#: For: :attr:`TileShape.FULL` this is ordered clockwise starting with
#: the top side.
#:
#: For half tiles and small slants the ordering starts on the diagonal
#: edge and procedes clockwise around the tile.
#:
#: For big slants the ordering starts on the diagonal, then the opposite
#: side, then the flat side that's not present on the small slants. For
#: BIG_1 through BIG_4 this is clockwise, for BIG_5 through BIG_8 this
#: is counter-clockwise.
#:
#: Type:
#: tuple mapping :class:`TileShape` -> (TileSide, ...)
#:
#: :meta hide-value:
SHAPE_ORDERED_SIDES = tuple(
tuple(TileSide(x) for x in y)
for y in (
(0, 3, 1, 2),
(0, 1, 2),
(0, 1),
(3, 2, 0),
(3, 2),
(1, 0, 3),
(1, 0),
(2, 3, 1),
(2, 3),
(0, 1, 3),
(0, 1),
(2, 3, 0),
(2, 3),
(1, 0, 2),
(1, 0),
(3, 2, 1),
(3, 2),
(0, 1, 2),
(1, 2, 0),
(1, 0, 3),
(0, 3, 1),
)
)
#: Mapping of :class:`TileShape` to the vertex coordinates of the tile in
#: half-tile units. Vertexes are listed top-left, top-right, bottom-right,
#: and bottom-left order.
#:
#: Type:
#: tuple mapping :class:`TileShape` -> ((int, int), (int, int), (int, int), (int, int))
#:
#: :meta hide-value:
SHAPE_VERTEXES = (
((0, 0), (2, 0), (2, 2), (0, 2)), # FULL
# Slants are rotations of each other
((0, 0), (2, 1), (2, 2), (0, 2)), # BIG_1
((0, 1), (2, 2), (2, 2), (0, 2)), # SMALL_1
((0, 0), (2, 0), (1, 2), (0, 2)), # BIG_2
((0, 0), (1, 0), (0, 2), (0, 2)), # SMALL_2
((0, 0), (2, 0), (2, 2), (0, 1)), # BIG_3
((0, 0), (2, 0), (2, 1), (0, 0)), # SMALL_3
((1, 0), (2, 0), (2, 2), (0, 2)), # BIG_4
((2, 0), (2, 0), (2, 2), (1, 2)), # SMALL_4
((0, 1), (2, 0), (2, 2), (0, 2)), # BIG_5
((0, 2), (2, 1), (2, 2), (0, 2)), # SMALL_5
((0, 0), (2, 0), (2, 2), (1, 2)), # BIG_6
((1, 0), (2, 0), (2, 2), (2, 2)), # SMALL_6
((0, 0), (2, 0), (2, 1), (0, 2)), # BIG_7
((0, 0), (2, 0), (2, 0), (0, 1)), # SMALL_7
((0, 0), (1, 0), (2, 2), (0, 2)), # BIG_8
((0, 0), (0, 0), (1, 2), (0, 2)), # SMALL_8
# Slopes are special because they always have a top and bottom so they
# don't simply rotate between each one. The repeated coordinate is
# always placed so it makes a left or right edge null.
((0, 0), (2, 2), (2, 2), (0, 2)), # HALF_A
((0, 0), (2, 0), (2, 0), (0, 2)), # HALF_B
((0, 0), (2, 0), (2, 2), (0, 0)), # HALF_C
((0, 2), (2, 0), (2, 2), (0, 2)), # HALF_D
)
#: Mapping of :class:`TileSide` to the index of that tile side when sides are
#: listed in clockwise order. This is useful for computing the edge vertexes
#: for a given side from :attr:`SHAPE_VERTEXES`.
#:
#: Type:
#: tuple mapping :class:`TileSide` -> int
#:
#: Examples:
#: ::
#:
#: shape, side = TileShape.BIG_1, TileSide.TOP
#: ind = SIDE_CLOCKWISE_INDEX[side]
#: vert_a = SHAPE_VERTEXES[shape][ind]
#: vert_b = SHAPE_VERTEXES[shape][(ind + 1) % 4]
#: # vert_a = (0, 0), vert_b = (2, 1)
#:
#: :meta hide-value:
SIDE_CLOCKWISE_INDEX = (
0, # TOP
2, # BOTTOM
3, # LEFT
1, # RIGHT
)