TetrArcade/arcade/tilemap.py

322 lines
12 KiB
Python

"""
Functions and classes for managing a map saved in the .tmx format.
Typically these .tmx maps are created using the `Tiled Map Editor`_.
For more information, see the `Platformer Tutorial`_.
.. _Tiled Map Editor: https://www.mapeditor.org/
.. _Platformer Tutorial: http://arcade.academy/examples/platform_tutorial/index.html
"""
from typing import Optional
import math
import copy
import pytiled_parser
from arcade import Sprite
from arcade import AnimatedTimeBasedSprite
from arcade import AnimationKeyframe
from arcade import SpriteList
FLIPPED_HORIZONTALLY_FLAG = 0x80000000
FLIPPED_VERTICALLY_FLAG = 0x40000000
FLIPPED_DIAGONALLY_FLAG = 0x20000000
def read_tmx(tmx_file: str) -> pytiled_parser.objects.TileMap:
"""
Given a .tmx, this will read in a tiled map, and return
a TiledMap object.
Given a tsx_file, the map will use it as the tileset.
If tsx_file is not specified, it will use the tileset specified
within the tmx_file.
Important: Tiles must be a "collection" of images.
Hitboxes can be drawn around tiles in the tileset editor,
but only polygons are supported.
(This is a great area for PR's to improve things.)
:param str tmx_file: String with name of our TMX file
:returns: Map
:rtype: TiledMap
"""
tile_map = pytiled_parser.parse_tile_map(tmx_file)
return tile_map
def get_tilemap_layer(map_object: pytiled_parser.objects.TileMap,
layer_name: str) -> Optional[pytiled_parser.objects.Layer]:
"""
Given a TileMap and a layer name, this returns the TileLayer.
:param pytiled_parser.objects.TileMap map_object: The map read in by the read_tmx function.
:param str layer_name: A string to match the layer name. Case sensitive.
:returns: A TileLayer, or None if no layer was found.
"""
assert isinstance(map_object, pytiled_parser.objects.TileMap)
assert isinstance(layer_name, str)
for layer in map_object.layers:
if layer.name == layer_name:
return layer
return None
def _get_tile_by_gid(map_object: pytiled_parser.objects.TileMap,
tile_gid: int) -> Optional[pytiled_parser.objects.Tile]:
flipped_diagonally = False
flipped_horizontally = False
flipped_vertically = False
if tile_gid & FLIPPED_HORIZONTALLY_FLAG:
flipped_horizontally = True
tile_gid -= FLIPPED_HORIZONTALLY_FLAG
if tile_gid & FLIPPED_DIAGONALLY_FLAG:
flipped_diagonally = True
tile_gid -= FLIPPED_DIAGONALLY_FLAG
if tile_gid & FLIPPED_VERTICALLY_FLAG:
flipped_vertically = True
tile_gid -= FLIPPED_VERTICALLY_FLAG
for tileset_key, tileset in map_object.tile_sets.items():
for tile_key, tile in tileset.tiles.items():
cur_tile_gid = tile.id_ + tileset_key
# print(f"-- {tile_gid} {cur_tile_gid} {tile.image.source}")
if cur_tile_gid == tile_gid:
my_tile = copy.copy(tile)
my_tile.tileset = tileset
my_tile.flipped_vertically = flipped_vertically
my_tile.flipped_diagonally = flipped_diagonally
my_tile.flipped_horizontally = flipped_horizontally
return my_tile
return None
def _get_tile_by_id(map_object: pytiled_parser.objects.TileMap,
tileset: pytiled_parser.objects.TileSet,
tile_id: int) -> Optional[pytiled_parser.objects.Tile]:
for tileset_key, cur_tileset in map_object.tile_sets.items():
if cur_tileset is tileset:
for tile_key, tile in cur_tileset.tiles.items():
if tile_id == tile.id_:
return tile
return None
def _create_sprite_from_tile(map_object, tile: pytiled_parser.objects.Tile,
scaling,
base_directory: str = ""):
tmx_file = base_directory + tile.image.source
# print(f"Creating tile: {tmx_file}")
if tile.animation:
# my_sprite = AnimatedTimeSprite(tmx_file, scaling)
my_sprite = AnimatedTimeBasedSprite(tmx_file, scaling)
else:
my_sprite = Sprite(tmx_file, scaling)
if tile.properties is not None and len(tile.properties) > 0:
for my_property in tile.properties:
my_sprite.properties[my_property.name] = my_property.value
# print(tile.image.source, my_sprite.center_x, my_sprite.center_y)
if tile.objectgroup is not None:
if len(tile.objectgroup) > 1:
print(f"Warning, only one hit box supported for tile with image {tile.image.source}.")
for hitbox in tile.objectgroup:
half_width = map_object.tile_size.width / 2
half_height = map_object.tile_size.height / 2
points = []
if isinstance(hitbox, pytiled_parser.objects.RectangleObject):
if hitbox.size is None:
print(f"Warning: Rectangle hitbox created for without a "
f"height or width for {tile.image.source}. Ignoring.")
continue
p1 = [hitbox.location[0] - half_width, half_height - hitbox.location[0]]
p2 = [hitbox.location[0] + hitbox.size[0] - half_width, half_height - hitbox.size[0]]
p3 = [hitbox.location[0] + hitbox.size[0] - half_width, half_height
- (hitbox.location[1] + hitbox.size[1])]
p4 = [hitbox.location[0] - half_width, half_height - (hitbox.location[1] + hitbox.size[1])]
points = [p4, p3, p2, p1]
elif isinstance(hitbox, pytiled_parser.objects.PolygonObject):
for point in hitbox.points:
adj_x = point[0] + hitbox.location[0] - half_width
adj_y = half_height - (point[1] + hitbox.location[1])
adj_point = [adj_x, adj_y]
points.append(adj_point)
elif isinstance(hitbox, pytiled_parser.objects.PolylineObject):
for point in hitbox.points:
adj_x = point[0] + hitbox.location[0] - half_width
adj_y = half_height - (point[1] + hitbox.location[1])
adj_point = [adj_x, adj_y]
points.append(adj_point)
# If we have a polyline, and it is closed, we need to
# remove the duplicate end-point
if points[0][0] == points[-1][0] and points[0][1] == points[-1][1]:
points.pop()
elif isinstance(hitbox, pytiled_parser.objects.ElipseObject):
if hitbox.size is None:
print(f"Warning: Ellipse hitbox created for without a height "
f"or width for {tile.image.source}. Ignoring.")
continue
w = hitbox.size[0] / 2
h = hitbox.size[1] / 2
cx = (hitbox.location[0] + hitbox.size[0] / 2) - half_width
cy = map_object.tile_size.height - (hitbox.location[1] + hitbox.size[1] / 2) - half_height
total_steps = 8
angles = [step / total_steps * 2 * math.pi for step in range(total_steps)]
for angle in angles:
x = w * math.cos(angle) + cx
y = h * math.sin(angle) + cy
point = [x, y]
points.append(point)
else:
print(f"Warning: Hitbox type {type(hitbox)} not supported.")
# Scale the points to our sprite scaling
for point in points:
point[0] *= scaling
point[1] *= scaling
my_sprite.points = points
if tile.animation is not None:
key_frame_list = []
for frame in tile.animation:
frame_tile = _get_tile_by_id(map_object, tile.tileset, frame.tile_id)
key_frame = AnimationKeyframe(frame.tile_id, frame.duration, frame_tile.image)
key_frame_list.append(key_frame)
my_sprite.frames = key_frame_list
return my_sprite
def _process_object_layer(map_object: pytiled_parser.objects.TileMap,
layer: pytiled_parser.objects.Layer,
scaling: float = 1,
base_directory: str = "") -> SpriteList:
sprite_list = SpriteList()
for cur_object in layer.tiled_objects:
if cur_object.gid is None:
print("Warning: Currently only tiles (not objects) are supported in object layers.")
continue
tile = _get_tile_by_gid(map_object, cur_object.gid)
my_sprite = _create_sprite_from_tile(map_object, tile, scaling=scaling,
base_directory=base_directory)
my_sprite.right = cur_object.location.x * scaling
my_sprite.top = (map_object.map_size.height * map_object.tile_size[1] - cur_object.location.y) * scaling
if cur_object.properties is not None and 'change_x' in cur_object.properties:
my_sprite.change_x = float(cur_object.properties['change_x'])
if cur_object.properties is not None and 'change_y' in cur_object.properties:
my_sprite.change_y = float(cur_object.properties['change_y'])
if cur_object.properties is not None and 'boundary_bottom' in cur_object.properties:
my_sprite.boundary_bottom = float(cur_object.properties['boundary_bottom'])
if cur_object.properties is not None and 'boundary_top' in cur_object.properties:
my_sprite.boundary_top = float(cur_object.properties['boundary_top'])
if cur_object.properties is not None and 'boundary_left' in cur_object.properties:
my_sprite.boundary_left = float(cur_object.properties['boundary_left'])
if cur_object.properties is not None and 'boundary_right' in cur_object.properties:
my_sprite.boundary_right = float(cur_object.properties['boundary_right'])
my_sprite.properties.update(cur_object.properties)
# sprite.properties
sprite_list.append(my_sprite)
return sprite_list
def _process_tile_layer(map_object: pytiled_parser.objects.TileMap,
layer: pytiled_parser.objects.TileLayer,
scaling: float = 1,
base_directory: str = "") -> SpriteList:
sprite_list = SpriteList()
map_array = layer.data
# Loop through the layer and add in the wall list
for row_index, row in enumerate(map_array):
for column_index, item in enumerate(row):
# Check for empty square
if item == 0:
continue
tile = _get_tile_by_gid(map_object, item)
if tile is None:
print(f"Warning, couldn't find tile for item {item} in layer "
f"'{layer.name}' in file '{map_object.tmx_file}'.")
continue
my_sprite = _create_sprite_from_tile(map_object, tile, scaling=scaling,
base_directory=base_directory)
my_sprite.right = column_index * (map_object.tile_size[0] * scaling)
my_sprite.top = (map_object.map_size.height - row_index - 1) * (map_object.tile_size[1] * scaling)
sprite_list.append(my_sprite)
return sprite_list
def process_layer(map_object: pytiled_parser.objects.TileMap,
layer_name: str,
scaling: float = 1,
base_directory: str = "") -> SpriteList:
"""
This takes a map layer returned by the read_tmx function, and creates Sprites for it.
:param map_object: The TileMap read in by read_tmx.
:param layer_name: The name of the layer that we are creating sprites for.
:param scaling: Scaling the layer up or down.
(Note, any number besides 1 can create a tearing effect,
if numbers don't evenly divide.)
:param base_directory: Base directory of the file, that we start from to
load images.
:returns: A SpriteList.
"""
if len(base_directory) > 0 and not base_directory.endswith("/"):
base_directory += "/"
layer = get_tilemap_layer(map_object, layer_name)
if layer is None:
print(f"Warning, no layer named '{layer_name}'.")
return SpriteList()
if isinstance(layer, pytiled_parser.objects.TileLayer):
return _process_tile_layer(map_object, layer, scaling, base_directory)
elif isinstance(layer, pytiled_parser.objects.ObjectLayer):
return _process_object_layer(map_object, layer, scaling, base_directory)