"""
This module provides functionality to load in JSON map files from
the Tiled Map Editor. This is achieved using the pytiled-parser
library.
For more info on Tiled see: https://www.mapeditor.org/
For more info on pytiled-parser see: https://github.com/Beefy-Swain/pytiled_parser
"""
from __future__ import annotations
import copy
import math
import os
from collections import OrderedDict
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import pytiled_parser
import pytiled_parser.tiled_object
from pytiled_parser import Color
import arcade
from arcade import (
Sprite,
SpriteList,
TextureAnimation,
TextureAnimationSprite,
TextureKeyframe,
get_window,
)
from arcade.hitbox import HitBoxAlgorithm, RotatableHitBox
from arcade.types import RGBA255
from arcade.types import Color as ArcadeColor
if TYPE_CHECKING:
from arcade import DefaultTextureAtlas, Texture
from arcade.texture_atlas import TextureAtlasBase
from pyglet.math import Vec2
from arcade.math import rotate_point
from arcade.resources import resolve
from arcade.types import Point2, TiledObject
_FLIPPED_HORIZONTALLY_FLAG = 0x80000000
_FLIPPED_VERTICALLY_FLAG = 0x40000000
_FLIPPED_DIAGONALLY_FLAG = 0x20000000
__all__ = ["TileMap", "load_tilemap"]
prop_to_float = cast(Callable[[pytiled_parser.Property], float], float)
def _get_image_info_from_tileset(tile: pytiled_parser.Tile) -> tuple[int, int, int, int]:
image_x = 0
image_y = 0
if not tile.tileset:
raise ValueError("Tileset was None. This should not happen")
if tile.tileset.image is not None:
margin = tile.tileset.margin or 0
spacing = tile.tileset.spacing or 0
row = tile.id // tile.tileset.columns
image_y = margin + row * (tile.tileset.tile_height + spacing)
col = tile.id % tile.tileset.columns
image_x = margin + col * (tile.tileset.tile_width + spacing)
if tile.tileset.image:
width = tile.tileset.tile_width
height = tile.tileset.tile_height
else:
image_x = tile.x
image_y = tile.y
width = tile.width
height = tile.height
return image_x, image_y, width, height
def _get_image_source(
tile: pytiled_parser.Tile,
map_directory: str | None,
) -> Path | None:
image_file = None
if tile.image:
image_file = tile.image
elif tile.tileset is not None:
image_file = tile.tileset.image
if not image_file:
print(
f"Warning for tile {tile.id}, no image source listed either for "
"individual tile, or as a tileset."
)
return None
if os.path.exists(image_file):
return image_file
if map_directory:
try2 = Path(map_directory, image_file)
if os.path.exists(try2):
return try2
print(f"Warning, can't find image {image_file} for tile {tile.id}")
return None
def _may_be_flip(tile: pytiled_parser.Tile, texture: Texture) -> Texture:
if tile.flipped_diagonally:
texture = texture.flip_diagonally()
if tile.flipped_horizontally:
texture = texture.flip_horizontally()
if tile.flipped_vertically:
texture = texture.flip_vertically()
return texture
[docs]
class TileMap:
"""
Class that represents a fully parsed and loaded map from Tiled.
For examples on how to use this class, see: :ref:`platformer_part_twelve`
Args:
map_file:
A JSON map file for a Tiled map to initialize from
scaling:
Global scaling to apply to all Sprites.
layer_options:
Extra parameters for each layer.
use_spatial_hash:
If set to True, this will make moving a sprite
in the SpriteList slower, but it will speed up collision detection
with items in the SpriteList. Great for doing collision detection
with static walls/platforms.
hit_box_algorithm:
The hit box algorithm to use for the Sprite's in this layer.
tiled_map:
An already parsed pytiled-parser map object.
Passing this means that the ``map_file`` argument will be ignored, and the pre-parsed
map will instead be used. This can be helpful for working with Tiled World files.
offset:
Can be used to offset the position of all sprites and objects
within the map. This will be applied in addition to any offsets from Tiled. This value
can be overridden with the layer_options dict.
texture_atlas:
A default texture atlas to use for the SpriteLists created by this map.
If not supplied the global default atlas will be used.
lazy:
SpriteLists will be created lazily.
texture_cache_manager:
The texture cache manager to use for loading textures.
The ``layer_options`` parameter can be used to specify per layer arguments.
The available options for this are:
- ``use_spatial_hash`` - A boolean to enable spatial hashing on this layer's SpriteList.
- ``scaling`` - A float providing layer specific Sprite scaling.
- ``hit_box_algorithm`` - The hit box algorithm to use for the Sprite's in this layer.
- ``offset`` - A tuple containing X and Y position offsets for the layer
- ``custom_class`` - All objects in the layer are created from this class instead of Sprite. \
Must be subclass of Sprite.
- ``custom_class_args`` - Custom arguments, passed into the constructor of the custom_class
- ``texture_atlas`` - A texture atlas to use for the SpriteList from this layer, if none is \
supplied then the one defined at the map level will be used.
Example configuring layer options for a layer named "Platforms"::
layer_options = {
"Platforms": {
"use_spatial_hash": True,
"scaling": 2.5,
"offset": (-128, 64),
"custom_class": Platform,
"custom_class_args": {
"health": 100
}
},
}
The keys and their values in each layer are passed to the layer processing functions
using the `**` operator on the dictionary.
"""
tiled_map: pytiled_parser.TiledMap
"""
The pytiled-parser map object. This can be useful for implementing features
that aren't supported by this class by accessing the raw map data directly.
"""
width: float
"The width of the map in tiles. This is the number of tiles, not pixels."
height: float
"The height of the map in tiles. This is the number of tiles, not pixels."
tile_width: float
"The width in pixels of each tile."
tile_height: float
"The height in pixels of each tile."
background_color: Color | None
"The background color of the map."
scaling: float
"A global scaling value to be applied to all Sprites in the map."
sprite_lists: dict[str, SpriteList]
"""A dictionary mapping SpriteLists to their layer names. This is used
for all tile layers of the map."""
object_lists: dict[str, list[TiledObject]]
"""
A dictionary mapping TiledObjects to their layer names. This is used
for all object layers of the map.
"""
offset: Vec2
"A tuple containing the X and Y position offset values."
def __init__(
self,
map_file: str | Path = "",
scaling: float = 1.0,
layer_options: dict[str, dict[str, Any]] | None = None,
use_spatial_hash: bool = False,
hit_box_algorithm: HitBoxAlgorithm | None = None,
tiled_map: pytiled_parser.TiledMap | None = None,
offset: Vec2 = Vec2(0, 0),
texture_atlas: TextureAtlasBase | None = None,
lazy: bool = False,
texture_cache_manager: arcade.TextureCacheManager | None = None,
) -> None:
if not map_file and not tiled_map:
raise AttributeError(
"Initialized TileMap with an empty map_file or no tiled_map argument"
)
if tiled_map:
self.tiled_map = tiled_map
else:
# If we should pull from local resources, replace with proper path
map_file = resolve(map_file)
# This attribute stores the pytiled-parser map object
self.tiled_map = pytiled_parser.parse_map(map_file)
if self.tiled_map.infinite:
raise AttributeError(
"Attempted to load an infinite TileMap. Arcade currently cannot load "
"infinite maps. Disable the infinite map property and re-save the file."
)
if not texture_atlas:
try:
texture_atlas = get_window().ctx.default_atlas
except RuntimeError:
pass
self._lazy = lazy
self.texture_cache_manager = texture_cache_manager or arcade.texture.default_texture_cache
# Set Map Attributes
self.width = self.tiled_map.map_size.width
self.height = self.tiled_map.map_size.height
self.tile_width = self.tiled_map.tile_size.width
self.tile_height = self.tiled_map.tile_size.height
self.background_color = self.tiled_map.background_color
# Global Layer Defaults
self.scaling = scaling
self.use_spatial_hash = use_spatial_hash
self.hit_box_algorithm = hit_box_algorithm
self.offset = offset
# Dictionaries to store the SpriteLists for processed layers
self.sprite_lists: dict[str, SpriteList] = OrderedDict()
self.object_lists: dict[str, list[TiledObject]] = OrderedDict()
self.properties = self.tiled_map.properties
global_options = { # type: ignore
"scaling": self.scaling,
"use_spatial_hash": self.use_spatial_hash,
"hit_box_algorithm": self.hit_box_algorithm,
"offset": self.offset,
"custom_class": None,
"custom_class_args": {},
"texture_atlas": texture_atlas,
}
for layer in self.tiled_map.layers:
if (layer.name in self.sprite_lists) or (layer.name in self.object_lists):
raise AttributeError(
f"You have a duplicate layer name '{layer.name}' in your Tiled map. "
"Please use unique names for all layers and tilesets in your map."
)
self._process_layer(layer, global_options, layer_options)
def _process_layer(
self,
layer: pytiled_parser.Layer,
global_options: dict[str, Any],
layer_options: dict[str, dict[str, Any]] | None = None,
) -> None:
processed: SpriteList | tuple[SpriteList | None, list[TiledObject] | None]
options = global_options
if layer_options:
if layer.name in layer_options:
new_options = {
key: layer_options[layer.name].get(key, global_options[key])
for key in global_options
}
options = new_options
if isinstance(layer, pytiled_parser.TileLayer):
processed = self._process_tile_layer(layer, **options)
self.sprite_lists[layer.name] = processed
elif isinstance(layer, pytiled_parser.ObjectLayer):
processed = self._process_object_layer(layer, **options)
if processed[0]:
sprite_list = processed[0]
if sprite_list:
self.sprite_lists[layer.name] = sprite_list
if processed[1]:
object_list = processed[1]
if object_list:
self.object_lists[layer.name] = object_list
elif isinstance(layer, pytiled_parser.ImageLayer):
processed = self._process_image_layer(layer, **options)
self.sprite_lists[layer.name] = processed
elif isinstance(layer, pytiled_parser.LayerGroup):
if layer.layers:
for sub_layer in layer.layers:
self._process_layer(sub_layer, global_options, layer_options)
[docs]
def get_cartesian(
self,
x: float,
y: float,
) -> tuple[float, float]:
"""
Given a set of coordinates in pixel units, this returns the cartesian coordinates.
This assumes the supplied coordinates are pixel coordinates, and bases the cartesian
grid off of the Map's tile size.
If you have a map with 128x128 pixel Tiles, and you supply coordinates 500, 250 to
this function you'll receive back 3, 2
Args:
x: The X Coordinate to convert
y: The Y Coordinate to convert
"""
x = math.floor(x / (self.tile_width * self.scaling))
y = math.floor(y / (self.tile_height * self.scaling))
return x, y
[docs]
def get_tilemap_layer(self, layer_path: str) -> pytiled_parser.Layer | None:
"""
Given a path to a layer, this will attempt to return the layer.
Args:
layer_path:
A string representing the path to the layer. For example,
"Layer Group 1/Layer Group 2/Tile Layer 1"
"""
assert isinstance(layer_path, str)
def _get_tilemap_layer(my_path: list[str], layers):
layer_name = my_path.pop(0)
for my_layer in layers:
if my_layer.name == layer_name:
if isinstance(my_layer, pytiled_parser.LayerGroup) and len(my_path) != 0:
return _get_tilemap_layer(my_path, my_layer.layers)
else:
return my_layer
return None
path = layer_path.strip("/").split("/")
layer = _get_tilemap_layer(path, self.tiled_map.layers)
return layer
def _get_tile_by_gid(self, tile_gid: int) -> pytiled_parser.Tile | None:
tile_ref: pytiled_parser.Tile | None
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 self.tiled_map.tilesets.items():
if tile_gid < tileset_key:
continue
# No specific tile info, but there is a tile sheet
# print(
# f"data {tileset_key} {tileset.tiles} {tileset.image} "
# f"{tileset_key} {tile_gid} {tileset.tile_count}"
# )
if (
tileset.image is not None
and tileset_key <= tile_gid < tileset_key + tileset.tile_count
):
tile_id = tile_gid - tileset_key
existing_ref = None
if tileset.tiles is not None:
if (tile_gid - tileset_key) in tileset.tiles:
existing_ref = tileset.tiles[tile_id]
existing_ref.image = tileset.image
# No specific tile info, but there is a tile sheet
if existing_ref:
tile_ref = existing_ref
else:
tile_ref = pytiled_parser.Tile(id=tile_id, image=tileset.image)
elif tileset.tiles is None and tileset.image is not None:
# Not in this tileset, move to the next
continue
else:
if tileset.tiles is None:
return None
tile_ref = tileset.tiles.get(tile_gid - tileset_key)
if tile_ref:
my_tile = copy.copy(tile_ref)
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
print(f"Returning NO tile for {tile_gid}.")
return None
def _get_tile_by_id(
self, tileset: pytiled_parser.Tileset, tile_id: int
) -> pytiled_parser.Tile | None:
for tileset_key, cur_tileset in self.tiled_map.tilesets.items():
if cur_tileset is tileset:
if cur_tileset.tiles:
for tile_key, tile in cur_tileset.tiles.items():
if tile_id == tile.id:
return tile
return None
def _create_sprite_from_tile(
self,
tile: pytiled_parser.Tile,
scaling: float = 1.0,
hit_box_algorithm: HitBoxAlgorithm | None = None,
custom_class: type | None = None,
custom_class_args: dict[str, Any] = {},
) -> Sprite:
"""Given a tile from the parser, try and create a Sprite from it."""
# --- Step 1, Find a reference to an image this is going to be based off of
map_source = self.tiled_map.map_file
map_directory = os.path.dirname(map_source)
image_file = _get_image_source(tile, map_directory)
if tile.animation:
if not custom_class:
custom_class = TextureAnimationSprite
elif not issubclass(custom_class, TextureAnimationSprite):
raise RuntimeError(
f"""
Tried to use a custom class {custom_class.__name__} for animated tiles
that doesn't subclass TextureAnimationSprite.
Custom classes for animated tiles must subclass TextureAnimationSprite.
"""
)
# print(custom_class.__name__)
args = {"path_or_texture": image_file, "scale": scaling}
my_sprite = custom_class(**custom_class_args, **args) # type: ignore
else:
if not custom_class:
custom_class = Sprite
elif not issubclass(custom_class, Sprite):
raise RuntimeError(
f"""
Tried to use a custom class {custom_class.__name__} for
a tile that doesn't subclass arcade.Sprite.
Custom classes for tiles must subclass arcade.Sprite.
"""
)
# Can image_file be None?
image_x, image_y, width, height = _get_image_info_from_tileset(tile)
texture = self.texture_cache_manager.load_or_get_texture(
image_file, # type: ignore
x=image_x,
y=image_y,
width=width,
height=height,
hit_box_algorithm=hit_box_algorithm,
)
texture = _may_be_flip(tile, texture)
args = {
"path_or_texture": texture, # type: ignore
"scale": scaling,
}
my_sprite = custom_class(**custom_class_args, **args) # type: ignore
if tile.properties is not None and len(tile.properties) > 0:
for key, value in tile.properties.items():
my_sprite.properties[key] = value
if tile.class_:
my_sprite.properties["class"] = tile.class_
# Add tile ID to sprite properties
my_sprite.properties["tile_id"] = tile.id
if tile.objects is not None:
if not isinstance(tile.objects, pytiled_parser.ObjectLayer):
print("Warning, tile.objects is not an ObjectLayer as expected.")
return my_sprite
if len(tile.objects.tiled_objects) > 1:
if tile.image:
print(f"Warning, only one hit box supported for tile with image {tile.image}.")
else:
print("Warning, only one hit box supported for tile.")
for hitbox in tile.objects.tiled_objects:
points: list[Point2] = []
if isinstance(hitbox, pytiled_parser.tiled_object.Rectangle):
if hitbox.size is None:
print(
"Warning: Rectangle hitbox created for without a "
"height or width Ignoring."
)
continue
sx = hitbox.coordinates.x - (my_sprite.width / (scaling * 2))
sy = -(hitbox.coordinates.y - (my_sprite.height / (scaling * 2)))
ex = (hitbox.coordinates.x + hitbox.size.width) - (
my_sprite.width / (scaling * 2)
)
# issue #1068
# fixed size of rectangular hitbox
ey = -(hitbox.coordinates.y + hitbox.size.height) + (
my_sprite.height / (scaling * 2)
)
points = [(sx, sy), (ex, sy), (ex, ey), (sx, ey)]
elif isinstance(hitbox, pytiled_parser.tiled_object.Polygon) or isinstance(
hitbox, pytiled_parser.tiled_object.Polyline
):
for point in hitbox.points:
adj_x = point.x + hitbox.coordinates.x - my_sprite.width / (scaling * 2)
adj_y = -(point.y + hitbox.coordinates.y - my_sprite.height / (scaling * 2))
adj_point = adj_x, adj_y
points.append(adj_point)
if points[0][0] == points[-1][0] and points[0][1] == points[-1][1]:
points.pop()
elif isinstance(hitbox, pytiled_parser.tiled_object.Ellipse):
if not hitbox.size:
print(
f"Warning: Ellipse hitbox created without a height "
f" or width for {tile.image}. Ignoring."
)
continue
hw = hitbox.size.width / 2
hh = hitbox.size.height / 2
cx = hitbox.coordinates.x + hw
cy = hitbox.coordinates.y + hh
acx = cx - (my_sprite.width / (scaling * 2))
acy = cy - (my_sprite.height / (scaling * 2))
total_steps = 8
angles = [step / total_steps * 2 * math.pi for step in range(total_steps)]
for angle in angles:
x = hw * math.cos(angle) + acx
y = -(hh * math.sin(angle) + acy)
points.append((x, y))
else:
print(f"Warning: Hitbox type {type(hitbox)} not supported.")
if tile.flipped_vertically:
points = [(point[0], -point[1]) for point in points]
if tile.flipped_horizontally:
points = [(-point[0], point[1]) for point in points]
if tile.flipped_diagonally:
points = [(point[1], point[0]) for point in points]
my_sprite.hit_box = RotatableHitBox(
cast(list[Point2], points),
position=my_sprite.position,
angle=my_sprite.angle,
scale=my_sprite.scale,
)
if tile.animation:
key_frame_list = []
for frame in tile.animation:
frame_tile = self._get_tile_by_gid(tile.tileset.firstgid + frame.tile_id) # type: ignore
if frame_tile:
image_file = _get_image_source(frame_tile, map_directory)
if not frame_tile.tileset.image and image_file: # type: ignore
texture = self.texture_cache_manager.load_or_get_texture(
image_file, hit_box_algorithm=hit_box_algorithm
)
elif image_file:
# No image for tile, pull from tilesheet
(
image_x,
image_y,
width,
height,
) = _get_image_info_from_tileset(frame_tile)
texture = self.texture_cache_manager.load_or_get_texture(
image_file,
x=image_x,
y=image_y,
width=width,
height=height,
hit_box_algorithm=hit_box_algorithm,
)
else:
raise RuntimeError(
f"Warning: failed to load image for animation frame for "
f"tile '{frame_tile.id}', '{image_file}'."
)
texture = _may_be_flip(tile, texture)
key_frame = TextureKeyframe( # type: ignore
texture=texture, duration=frame.duration, tile_id=frame.tile_id
)
key_frame_list.append(key_frame)
if len(key_frame_list) == 1:
my_sprite.texture = key_frame.texture
# type: ignore
cast(TextureAnimationSprite, my_sprite).animation = TextureAnimation(
keyframes=key_frame_list
)
return my_sprite
def _process_image_layer(
self,
layer: pytiled_parser.ImageLayer,
texture_atlas: DefaultTextureAtlas,
scaling: float = 1.0,
use_spatial_hash: bool = False,
hit_box_algorithm: HitBoxAlgorithm | None = None,
offset: Vec2 = Vec2(0, 0),
custom_class: type | None = None,
custom_class_args: dict[str, Any] = {},
) -> SpriteList:
sprite_list: SpriteList = SpriteList(
use_spatial_hash=use_spatial_hash,
atlas=texture_atlas,
lazy=self._lazy,
)
map_source = self.tiled_map.map_file
map_directory = os.path.dirname(map_source)
image_file = layer.image
if not os.path.exists(image_file) and (map_directory):
try2 = Path(map_directory, image_file)
if not os.path.exists(try2):
print(f"Warning, can't find image {image_file} for Image Layer {layer.name}")
image_file = try2
my_texture = self.texture_cache_manager.load_or_get_texture(
image_file,
hit_box_algorithm=hit_box_algorithm,
)
if layer.transparent_color:
# The pillow source doesn't annotate a return type for this method, but:
# 1. The docstring does specify the returned object is sequence-like
# 2. We convert to RGBA mode implicitly in load_or_get_texture above
data: Sequence[RGBA255] = my_texture.image.getdata() # type:ignore
target = layer.transparent_color
new_data = []
for item in data:
if item[0] == target[0] and item[1] == target[1] and item[2] == target[2]:
new_data.append((255, 255, 255, 0))
else:
new_data.append(item)
my_texture.image.putdata(new_data)
if not custom_class:
custom_class = Sprite
elif not issubclass(custom_class, Sprite):
raise RuntimeError(
f"""
Tried to use a custom class {custom_class.__name__} for an
Image Layer that doesn't subclass arcade.Sprite.
Custom classes for image layers must subclass arcade.Sprite.
"""
)
args = {
"filename": image_file,
"scale": scaling,
"path_or_texture": my_texture,
"hit_box_algorithm": hit_box_algorithm,
}
my_sprite = custom_class(**custom_class_args, **args) # type: ignore
if layer.properties:
sprite_list.properties = layer.properties
for key, value in layer.properties.items():
my_sprite.properties[key] = value
if layer.tint_color:
my_sprite.color = ArcadeColor.from_iterable(layer.tint_color)
if layer.opacity:
my_sprite.alpha = int(layer.opacity * 255)
my_sprite.center_x = ((layer.offset[0] * scaling) + my_sprite.width / 2) + offset[0]
my_sprite.top = (
self.tiled_map.map_size.height * self.tiled_map.tile_size[1] - layer.offset[1]
) * scaling + offset[1]
sprite_list.visible = layer.visible
sprite_list.append(my_sprite)
return sprite_list
def _process_tile_layer(
self,
layer: pytiled_parser.TileLayer,
texture_atlas: DefaultTextureAtlas,
scaling: float = 1.0,
use_spatial_hash: bool = False,
hit_box_algorithm: HitBoxAlgorithm | None = None,
offset: Vec2 = Vec2(0, 0),
custom_class: type | None = None,
custom_class_args: dict[str, Any] = {},
) -> SpriteList:
sprite_list: SpriteList = SpriteList(
use_spatial_hash=use_spatial_hash,
atlas=texture_atlas,
lazy=self._lazy,
)
map_array = layer.data
if TYPE_CHECKING:
# Can never be None because we already detect and reject infinite maps
assert map_array
# Loop through the layer and add in the list
for row_index, row in enumerate(map_array):
for column_index, item in enumerate(row):
# Check for an empty tile
if item == 0:
continue
tile = self._get_tile_by_gid(item)
if tile is None:
raise ValueError(
f"Couldn't find tile for item {item} in layer "
f"'{layer.name}' in file '{self.tiled_map.map_file}'"
f"at ({column_index}, {row_index})."
)
my_sprite = self._create_sprite_from_tile(
tile,
scaling=scaling,
hit_box_algorithm=hit_box_algorithm,
custom_class=custom_class,
custom_class_args=custom_class_args,
)
if my_sprite is None:
print(
f"Warning: Could not create sprite number {item} "
f"in layer '{layer.name}' {tile.image}"
)
else:
my_sprite.center_x = (
column_index * (self.tiled_map.tile_size[0] * scaling) + my_sprite.width / 2
) + offset[0]
my_sprite.center_y = (
(self.tiled_map.map_size.height - row_index - 1)
* (self.tiled_map.tile_size[1] * scaling)
+ my_sprite.height / 2
) + offset[1]
# Tint
if layer.tint_color:
my_sprite.color = ArcadeColor.from_iterable(layer.tint_color)
# Opacity
opacity = layer.opacity
if opacity:
my_sprite.alpha = int(opacity * 255)
sprite_list.visible = layer.visible
sprite_list.append(my_sprite)
if layer.properties:
sprite_list.properties = layer.properties
return sprite_list
def _process_object_layer(
self,
layer: pytiled_parser.ObjectLayer,
texture_atlas: DefaultTextureAtlas,
scaling: float = 1.0,
use_spatial_hash: bool = False,
hit_box_algorithm: HitBoxAlgorithm | None = None,
offset: Vec2 = Vec2(0, 0),
custom_class: type | None = None,
custom_class_args: dict[str, Any] = {},
) -> tuple[SpriteList | None, list[TiledObject] | None]:
if not scaling:
scaling = self.scaling
sprite_list: SpriteList | None = None
objects_list: list[TiledObject] | None = []
shape: list[Point2] | tuple[int, int, int, int] | Point2 | None = None
for cur_object in layer.tiled_objects:
# shape: Optional[Point | PointList | Rect] = None
if isinstance(cur_object, pytiled_parser.tiled_object.Tile):
if not sprite_list:
sprite_list = SpriteList(
use_spatial_hash=use_spatial_hash,
atlas=texture_atlas,
lazy=self._lazy,
)
tile = self._get_tile_by_gid(cur_object.gid)
if tile is None:
raise Exception(f"Tile with gid not found: {cur_object.gid}")
my_sprite = self._create_sprite_from_tile(
tile,
scaling=scaling,
hit_box_algorithm=hit_box_algorithm,
custom_class=custom_class,
custom_class_args=custom_class_args,
)
x = (cur_object.coordinates.x * scaling) + offset[0]
y = (
(
self.tiled_map.map_size.height * self.tiled_map.tile_size[1]
- cur_object.coordinates.y
)
* scaling
) + offset[1]
my_sprite.width = width = cur_object.size[0] * scaling
my_sprite.height = height = cur_object.size[1] * scaling
# center_x = width / 2
# center_y = height / 2
if cur_object.rotation:
rotation = math.radians(cur_object.rotation)
else:
rotation = 0
angle_degrees = math.degrees(rotation)
rotated_center_x, rotated_center_y = rotate_point(
width / 2, height / 2, 0, 0, angle_degrees
)
my_sprite.position = (x + rotated_center_x, y + rotated_center_y)
my_sprite.angle = angle_degrees
if layer.tint_color:
my_sprite.color = ArcadeColor.from_iterable(layer.tint_color)
opacity = layer.opacity
if opacity:
my_sprite.alpha = int(opacity * 255)
if cur_object.properties and "change_x" in cur_object.properties:
my_sprite.change_x = prop_to_float(cur_object.properties["change_x"])
if cur_object.properties and "change_y" in cur_object.properties:
my_sprite.change_y = prop_to_float(cur_object.properties["change_y"])
if cur_object.properties and "boundary_bottom" in cur_object.properties:
my_sprite.boundary_bottom = prop_to_float(
cur_object.properties["boundary_bottom"]
)
if cur_object.properties and "boundary_top" in cur_object.properties:
my_sprite.boundary_top = prop_to_float(cur_object.properties["boundary_top"])
if cur_object.properties and "boundary_left" in cur_object.properties:
my_sprite.boundary_left = prop_to_float(cur_object.properties["boundary_left"])
if cur_object.properties and "boundary_right" in cur_object.properties:
my_sprite.boundary_right = prop_to_float(
cur_object.properties["boundary_right"]
)
if cur_object.properties:
my_sprite.properties.update(cur_object.properties)
if cur_object.class_:
my_sprite.properties["class"] = cur_object.class_
if cur_object.name:
my_sprite.properties["name"] = cur_object.name
sprite_list.visible = layer.visible
sprite_list.append(my_sprite)
continue
elif isinstance(cur_object, pytiled_parser.tiled_object.Point):
x = cur_object.coordinates.x * scaling
y = (
self.tiled_map.map_size.height * self.tiled_map.tile_size[1]
- cur_object.coordinates.y
) * scaling
shape = (x + offset[0], y + offset[1])
elif isinstance(cur_object, pytiled_parser.tiled_object.Rectangle):
if cur_object.size.width == 0 and cur_object.size.height == 0:
print(
f"WARNING: Tiled object with ID {cur_object.id} is a rectangle "
"with a width and height of 0. Loading it as a single point."
)
x = cur_object.coordinates.x * scaling
y = (
self.tiled_map.map_size.height * self.tiled_map.tile_size[1]
- cur_object.coordinates.y
) * scaling
shape = (x + offset[0], y + offset[1])
else:
sx = cur_object.coordinates.x * scaling + offset[0]
sy = (
self.tiled_map.map_size.height * self.tiled_map.tile_size[1]
- cur_object.coordinates.y
) * scaling + offset[1]
ex = sx + cur_object.size.width * scaling
ey = sy - cur_object.size.height * scaling
p1 = (sx, sy)
p2 = (ex, sy)
p3 = (ex, ey)
p4 = (sx, ey)
shape = [p1, p2, p3, p4]
elif isinstance(cur_object, pytiled_parser.tiled_object.Polygon) or isinstance(
cur_object, pytiled_parser.tiled_object.Polyline
):
points: list[Point2] = []
shape = points
for point in cur_object.points:
x = point.x + cur_object.coordinates.x
y = (self.height * self.tile_height) - (point.y + cur_object.coordinates.y)
point = (x + offset[0], y + offset[1]) # type: ignore
points.append(point)
# If shape is 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(cur_object, pytiled_parser.tiled_object.Ellipse):
hw = cur_object.size.width / 2
hh = cur_object.size.height / 2
cx = cur_object.coordinates.x + hw
cy = cur_object.coordinates.y + hh
total_steps = 8
angles = [step / total_steps * 2 * math.pi for step in range(total_steps)]
points = []
shape = points
for angle in angles:
x = hw * math.cos(angle) + cx
y = -(hh * math.sin(angle) + cy)
point = (x + offset[0], y + offset[1]) # type: ignore
points.append(point)
elif isinstance(cur_object, pytiled_parser.tiled_object.Text):
pass
else:
continue
if shape:
tiled_object = TiledObject(
shape, cur_object.properties, cur_object.name, cur_object.class_
)
if not objects_list:
objects_list = []
objects_list.append(tiled_object)
return sprite_list or None, objects_list or None
[docs]
def load_tilemap(
map_file: str | Path,
scaling: float = 1.0,
layer_options: dict[str, dict[str, Any]] | None = None,
use_spatial_hash: bool = False,
hit_box_algorithm: HitBoxAlgorithm | None = None,
offset: Vec2 = Vec2(0, 0),
texture_atlas: DefaultTextureAtlas | None = None,
lazy: bool = False,
) -> TileMap:
"""
Given a .json map file, loads in and returns a `TileMap` object.
A TileMap can be created directly using the classes `__init__` function.
This function exists for ease of use.
For more clarification on the layer_options key, see the `__init__` function
of the `TileMap` class
Args:
map_file:
The JSON map file.
scaling:
The global scaling to apply to all Sprite's within the map.
use_spatial_hash:
If set to True, this will make moving a sprite
in the SpriteList slower, but it will speed up collision detection
with items in the SpriteList. Great for doing collision detection
with static walls/platforms.
hit_box_algorithm:
The hit box algorithm to use for collision detection.
layer_options:
Layer specific options for the map.
offset:
Can be used to offset the position of all sprites and objects
within the map. This will be applied in addition to any offsets from Tiled. This value
can be overridden with the layer_options dict.
texture_atlas:
A default texture atlas to use for the SpriteLists created by this map.
If not supplied the global default atlas will be used.
lazy:
SpriteLists will be created lazily.
"""
return TileMap(
map_file=map_file,
scaling=scaling,
layer_options=layer_options,
use_spatial_hash=use_spatial_hash,
hit_box_algorithm=hit_box_algorithm,
offset=offset,
texture_atlas=texture_atlas,
lazy=lazy,
)