from collections import defaultdict
from typing import Dict, List, Optional, Tuple, Union, cast
import pygame
import miniworlds.actors.actor as actor_mod
import miniworlds.appearances.background as background_mod
import miniworlds.base.exceptions as miniworlds_exception
import miniworlds.worlds.manager.camera_manager as world_camera_manager
import miniworlds.worlds.tiled_world.corner as corner_mod
import miniworlds.worlds.tiled_world.edge as edge_mod
import miniworlds.worlds.tiled_world.tile as tile_mod
import miniworlds.worlds.tiled_world.tile_factory as tile_factory
import miniworlds.worlds.tiled_world.tiled_world_camera_manager as tiled_camera_manager
import miniworlds.worlds.tiled_world.tiled_world_connector as tiled_world_connector
import miniworlds.worlds.world as world
from miniworlds.base.exceptions import TiledWorldTooBigError
[Doku]
class TiledWorld(world.World):
"""Grid-based world where actors are placed on tiles.
A `TiledWorld` uses tile coordinates instead of pixel coordinates for actor
positions. Actors can also be placed on tile corners or edges.
Examples:
::
Create a small grid world:
from miniworlds import TiledWorld, Actor
world = TiledWorld(6, 3)
player = Actor((1, 1))
player.fill_color = (255, 255, 255)
@player.register
def on_key_down_right(self):
self.move_in_direction("right")
world.run()
"""
[Doku]
def __init__(self, x: int = 20, y: int = 16, tile_size: int = 40, empty=False):
"""Create a tiled world.
Args:
x: Number of columns.
y: Number of rows.
tile_size: Tile size in pixels.
empty: If `True`, tiles, edges, and corners are not created
automatically.
Examples:
::
world = TiledWorld(8, 6)
empty_world = TiledWorld(8, 6, empty=True)
"""
self._tile_size: int = tile_size
"TiledWorld.tile_size Defines the size of a single tile (All Tiles are square)"
self.default_actor_speed: int = 1
self.empty = empty
self.tile_factory = self._get_tile_factory()
self.tiles: defaultdict = defaultdict()
self.corners: defaultdict = defaultdict()
self.edges: defaultdict = defaultdict()
self._static_tile_layer: pygame.Surface | None = None
self._static_tile_layer_dirty = True
# Initialize tiled spatial index for efficient actor queries
self._tiled_spatial_index = None
if x > 1000 or y > 1000:
raise TiledWorldTooBigError(x, y, 40)
super().__init__(x=x, y=y)
self.tick_rate = 20
self._dynamic_actors_dict: defaultdict = defaultdict(
list
) # the dict is regularly updated
self._dynamic_actors: "pygame.sprite.Group" = (
pygame.sprite.Group()
) # Set with all dynamic actors
self.static_actors_dict: defaultdict = defaultdict(list)
self.rotatable_actors = True
self.is_tiled = True
def _get_tile_factory(self):
return tile_factory.TileFactory()
[Doku]
def clear_tiles(self):
"""Remove all tiles, corners, and edges.
Use `empty=True` in the constructor when you want to build all tiles
manually from the start.
Examples:
::
world = TiledWorld(8, 8, empty=True)
world.add_tile_to_world((0, 0))
world.clear_tiles()
world.add_tile_to_world((1, 1))
"""
self.tiles.clear()
self.corners.clear()
self.edges.clear()
@staticmethod
def _get_camera_manager_class():
return tiled_camera_manager.TiledCameraManager
def _after_init_setup(self):
"""In this method, corners and edges are created."""
if not self.empty:
self._setup_tiles()
self._setup_corners()
self._setup_edges()
def _templates(self):
"""Returns Classes for Tile, Edge and Corner"""
return tile_mod.Tile, edge_mod.Edge, corner_mod.Corner
[Doku]
def add_tile_to_world(self, position):
"""Creates and registers a tile at a world grid position.
Args:
position: Tile position as `(column, row)`.
Returns:
The created tile instance.
Examples:
::
tile = world.add_tile_to_world((2, 3))
"""
tile_cls, edge_cls, corner_cls = self._templates()
tile_pos = position
tile = tile_cls(tile_pos, self)
self.tiles[tile.position] = tile
return tile
[Doku]
def add_corner_to_world(self, position, direction):
"""Create and register a corner.
Existing corners are merged when multiple tiles share the same corner.
Args:
position: Base tile position as `(column, row)`.
direction: Corner direction key, for example `"nw"`.
Returns:
The registered corner object.
Examples:
::
corner = world.add_corner_to_world((2, 3), "nw")
"""
tile_cls, edge_cls, corner_cls = self._templates()
corner = corner_cls(position, direction, self)
corner_pos = corner.position
if corner_pos not in self.corners:
self.corners[corner_pos] = corner
else:
self.corners[corner_pos].merge(corner)
return self.corners[corner_pos]
[Doku]
def add_edge_to_world(self, position, direction):
"""Create and register an edge.
Existing edges are merged when neighboring tiles describe the same edge.
Args:
position: Base tile position as `(column, row)`.
direction: Edge direction key, for example `"n"` or `"w"`.
Returns:
The registered edge object.
Examples:
::
edge = world.add_edge_to_world((2, 3), "w")
"""
edge_cls = self.tile_factory.edge_cls
edge = edge_cls(position, direction, self)
edge_pos = edge.position
if edge_pos not in self.edges:
self.edges[edge_pos] = edge
else:
self.edges[edge_pos].merge(edge)
return self.edges[edge_pos]
def _setup_tiles(self):
"""Adds Tile to World for each WorldPosition"""
for x in range(self.world_size_x):
for y in range(self.world_size_y):
self.add_tile_to_world((x, y))
def _setup_corners(self):
"""Add all Corner to World for each Tile.
Merges identical corners for different Tiles
"""
tile_cls = self.tile_factory.tile_cls
for position, tile in self.tiles.items():
for direction in tile_cls.corner_vectors:
self.add_corner_to_world(tile.position, direction)
def _setup_edges(self):
"""Add all Edges to World for each Tile
Merges identical edges for different tiles
"""
tile_cls = self.tile_factory.tile_cls
for position, tile in self.tiles.items():
for direction in tile_cls.edge_vectors:
self.add_edge_to_world(tile.position, direction)
[Doku]
def get_tile(self, position: Tuple[float, float]):
"""Return the tile at a tile position.
Args:
position: Tile position as `(column, row)`.
Returns:
The tile at the position.
Raises:
TileNotFoundError: If no tile exists at the position.
Examples:
::
tile = world.get_tile(actor.position)
tile = world.get_tile((1, 1))
if tile.get_actors():
print("Tile is occupied")
"""
if self.is_tile(position):
position = position
return self.tiles[position]
else:
raise miniworlds_exception.TileNotFoundError(position)
[Doku]
def detect_actors(
self, position: Union[Tuple[float, float], Tuple[float, float]]
) -> List["actor_mod.Actor"]:
return cast(
List["actor_mod.Actor"],
[actor for actor in self.actors if actor.position == position],
)
[Doku]
def get_actors_from_pixel(
self, position: Union[Tuple[float, float], Tuple[float, float]]
) -> List["actor_mod.Actor"]:
tile = tile_mod.Tile.from_pixel(position)
return self.detect_actors(tile.position)
[Doku]
def get_corner(
self, position: Tuple[float, float], direction: Optional[str] = None
):
"""Return a corner by corner position or tile position plus direction.
Args:
position: Corner position, or tile position when `direction` is set.
direction: Optional corner direction, for example `"nw"`.
Returns:
The matching corner.
Raises:
CornerNotFoundError: If no corner exists at the resolved position.
Examples:
::
corner = world.get_corner(actor.position)
corner = world.get_corner((3, 1), "nw")
"""
corner_cls = self.tile_factory.corner_cls
if direction is not None:
position = corner_cls(position, direction).position
if self.is_corner(position):
return self.corners[(position[0], position[1])]
else:
raise miniworlds_exception.CornerNotFoundError(position)
[Doku]
def get_edge(self, position, direction: Optional[str] = None):
"""Return an edge by edge position or tile position plus direction.
Args:
position: Edge position, or tile position when `direction` is set.
direction: Optional edge direction, for example `"n"` or `"w"`.
Returns:
The matching edge.
Examples:
::
edge = world.get_edge(actor.position)
edge = world.get_edge((5, 1), "w")
"""
edge_cls = self.tile_factory.edge_cls
if direction is not None:
position = edge_cls(position, direction).position
if self.is_edge(position):
return self.edges[(position[0], position[1])]
else:
raise miniworlds_exception.TileNotFoundError(position)
@staticmethod
def _get_world_connector_class():
return tiled_world_connector.TiledWorldConnector
[Doku]
def borders(self, value: Union[tuple, Tuple[float, float], pygame.Rect]) -> list:
"""Return borders touched by a position or rectangle.
Args:
value: Position or rectangle to check.
Returns:
List of border names such as `"left"` or `"top"`.
Examples:
::
borders = world.borders(actor.position)
"""
position = value
return self.get_borders_from_position(position)
def _update_actor_positions(self):
"""Updates the dynamic_actors_dict.
All positions of dynamic_actors_dict are updated by reading the dynamic_actors list.
This method is called very often in self.sensing_actors - The dynamic_actors list should therefore be as small as possible.
Other actors should be defined as static.
"""
self._dynamic_actors_dict.clear()
for actor in self._dynamic_actors:
# Skip actors that are explicitly marked as static to avoid unnecessary work
if getattr(actor, "static", False):
continue
x, y = actor.position[0], actor.position[1]
self._dynamic_actors_dict[(x, y)].append(actor)
[Doku]
def detect_actors_at_position(self, position):
"""Return all actors at a tile position.
Args:
position: Tile position as `(column, row)`.
Returns:
Actors located at that position.
Examples:
::
actors = world.detect_actors_at_position((2, 3))
"""
# Use tiled spatial index if available for better performance
tiled_spatial_index = getattr(self, "_tiled_spatial_index", None)
if tiled_spatial_index is not None:
return list(tiled_spatial_index.query_exact_position(position))
# Fallback to old method for compatibility
self._update_actor_positions() # This method can be a bottleneck!
actor_list = []
if self._dynamic_actors_dict[position[0], position[1]]:
actor_list.extend(self._dynamic_actors_dict[(position[0], position[1])])
if self.static_actors_dict[position[0], position[1]]:
actor_list.extend(self.static_actors_dict[(position[0], position[1])])
actor_list = [actor for actor in actor_list]
return actor_list
[Doku]
def detect_actor_at_position(self, position):
"""Return the first actor at a tile position.
Args:
position: Tile position as `(column, row)`.
Returns:
The first actor at that position, or `None`.
Examples:
::
actor = world.detect_actor_at_position((2, 3))
"""
actor_list = self.detect_actors_at_position(position)
if not actor_list:
return None
return actor_list[0]
def _rebuild_static_tile_layer(self) -> None:
layer = self.background.image.copy()
for actor in self.actors:
if not getattr(actor, "_static", False):
continue
if self.event_manager.registry.has_instance_handlers(actor):
continue
if not getattr(actor, "visible", True):
continue
costume = getattr(actor, "costume", None)
if costume is None:
continue
actor_rect = actor.position_manager.get_global_rect()
if not self.camera.rect.colliderect(actor_rect):
continue
try:
image = actor.image
except AttributeError:
continue
if image is None:
continue
local_rect = actor_rect.move(-self.camera.x, -self.camera.y)
layer.blit(image, local_rect)
actor.dirty = 0
self._static_tile_layer = layer
self._static_tile_layer_dirty = False
def _draw_static_tile_layer(self, surface: pygame.Surface) -> bool:
if not hasattr(self, "actors"):
return False
if getattr(self, "background", None) is None:
return False
if self._static_tile_layer_dirty or self._static_tile_layer is None:
self._rebuild_static_tile_layer()
surface.blit(self._static_tile_layer, (0, 0))
return True
def _refresh_static_tile_layer(self) -> tuple[pygame.Surface | None, bool]:
if not hasattr(self, "actors"):
return None, False
if getattr(self, "background", None) is None:
return None, False
rebuilt = False
if self._static_tile_layer_dirty or self._static_tile_layer is None:
self._rebuild_static_tile_layer()
rebuilt = True
return self._static_tile_layer, rebuilt
@property
def grid(self):
"""bool: Whether to display the grid overlay.
Examples:
::
world.grid = True
"""
return self.background.grid
@grid.setter
def grid(self, value):
self.background.grid = value
[Doku]
def draw_on_image(self, image, position):
"""Draw an image onto the tiled world background.
Args:
image: The image/surface to draw.
position: Tile position as `(column, row)`.
Examples:
::
world.draw_on_image(surface, (2, 3))
"""
position = self.to_pixel(position)
self.background.draw_on_image(image, position, self.tile_size, self.tile_size)
[Doku]
def get_from_pixel(self, position):
"""Return the tile position for a screen pixel.
Args:
position: Pixel position as `(x, y)`.
Returns:
Tile position, or `None` if the pixel is outside the camera view.
Examples:
::
tile_position = world.get_from_pixel((80, 120))
"""
x, y = position
if x < 0 or y < 0:
return None
if x >= self.camera.width or y >= self.camera.height:
return None
else:
return self.get_tile_from_pixel(position).position
[Doku]
def get_tile_from_pixel(self, position):
"""Return the tile under a screen pixel.
Args:
position: Pixel position as `(x, y)`.
Returns:
Tile under the pixel.
Examples:
::
tile = world.get_tile_from_pixel((80, 120))
"""
tile_cls = self.tile_factory.tile_cls
return tile_cls.from_pixel(position, self)
[Doku]
def get_edge_points(self) -> Dict[Tuple, Tuple[float, float]]:
edge_points = dict()
for position, edge in self.edges.items():
edge_points[position] = edge.to_pixel()
return edge_points
[Doku]
def get_corner_points(self) -> Dict[Tuple, Tuple[float, float]]:
corner_points = dict()
for position, corner in self.corners.items():
corner_points[position] = corner.to_pixel()
return corner_points
[Doku]
def is_edge(self, position):
"""Return whether a position is an edge position.
Examples:
::
if world.is_edge(actor.position):
actor.hide()
"""
if position in self.edges:
return True
else:
return False
[Doku]
def is_corner(self, position):
"""Return whether a position is a corner position.
Examples:
::
if world.is_corner(actor.position):
actor.hide()
"""
if position in self.corners:
return True
else:
return False
[Doku]
def is_tile(self, position):
"""Return whether a position is a tile position.
Examples:
::
if world.is_tile((1, 1)):
tile = world.get_tile((1, 1))
"""
if position in self.tiles:
return True
else:
return False
[Doku]
def to_pixel(self, position, size=(0, 0), origin=(0, 0)):
"""Convert a tile position to pixel coordinates.
Args:
position: Tile position as `(column, row)`.
size: Reserved for compatibility.
origin: Pixel offset added to the converted position.
Returns:
Pixel position as `(x, y)`.
Examples:
::
pixel = world.to_pixel((2, 3))
"""
x = position[0] * self.tile_size + origin[0]
y = position[1] * self.tile_size + origin[1]
return x, y
[Doku]
def set_columns(self, value: int):
self._columns = value
self.camera.width = value # * self.tile_size
self.world_size_x = value
[Doku]
def set_rows(self, value: int):
self._rows = value
self.camera.height = value # * self.tile_size
self.world_size_y = value
@property
def columns(self) -> int:
return self.camera.world_size_x
@columns.setter
def columns(self, value: int):
self.set_columns(value)
@property
def rows(self) -> int:
return self.camera.world_size_y
@rows.setter
def rows(self, value: int):
self.set_rows(value)
@property
def tile_size(self) -> int:
"""int: Size of one tile in pixels.
Examples:
::
world.tile_size = 32
"""
return self._tile_size
@tile_size.setter
def tile_size(self, value: int):
self.set_tile_size(value)
[Doku]
def set_tile_size(self, value):
"""Set the tile size in pixels.
Args:
value: New tile size in pixels.
Examples:
::
world.set_tile_size(32)
"""
self._tile_size = value
self.camera._reload_camera()
self.background.set_dirty("all", background_mod.Background.RELOAD_ACTUAL_IMAGE)