"""TileMap and TileSet for grid-based 2D level design."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import numpy as np
from .nodes_2d.node2d import Node2D
from .descriptors import Property
log = logging.getLogger(__name__)
__all__ = ["TileData", "TileSet", "TileMapLayer", "TileMap"]
# ============================================================================
# TileData — Per-tile metadata within a TileSet
# ============================================================================
[docs]
@dataclass
class TileData:
"""Per-tile metadata within a TileSet."""
texture_region: tuple[int, int, int, int] = (0, 0, 0, 0) # x, y, w, h in atlas
collision_shapes: list = field(default_factory=list) # Polygon vertices
navigation_polygon: list | None = None # Walkable polygon for pathfinding
custom_data: dict = field(default_factory=dict)
animation_frames: list[int] | None = None # Tile IDs for animation
animation_speed: float = 5.0 # FPS for tile animation
terrain_type: str = "" # For auto-tiling: "grass", "water", etc.
terrain_set: int = 0 # Which terrain set this tile belongs to
terrain_bits: int = 0 # Bitmask for auto-tile neighbor matching
# ============================================================================
# TileSet — Collection of tiles from one or more texture atlases
# ============================================================================
[docs]
class TileSet:
"""Collection of tiles from one or more texture atlases."""
def __init__(self, tile_size: tuple[int, int] = (16, 16)):
self.tile_size = tile_size
self._tiles: dict[int, TileData] = {}
self._next_id = 0
self._terrain_sets: dict[int, dict[int, int]] = {} # set_id -> {bitmask: tile_id}
[docs]
def add_tile(self, tile_data: TileData | None = None) -> int:
"""Add a tile, return its ID."""
tid = self._next_id
self._next_id += 1
self._tiles[tid] = tile_data or TileData()
return tid
[docs]
def get_tile(self, tile_id: int) -> TileData | None:
return self._tiles.get(tile_id)
[docs]
def remove_tile(self, tile_id: int):
self._tiles.pop(tile_id, None)
@property
def tile_count(self) -> int:
return len(self._tiles)
[docs]
def create_from_grid(self, atlas_width: int, atlas_height: int) -> list[int]:
"""Auto-create tiles from a grid atlas. Returns list of tile IDs."""
tw, th = self.tile_size
ids = []
for y in range(0, atlas_height, th):
for x in range(0, atlas_width, tw):
tid = self.add_tile(TileData(texture_region=(x, y, tw, th)))
ids.append(tid)
return ids
[docs]
def add_terrain_set(self, set_id: int = 0):
"""Create a terrain set for auto-tiling."""
self._terrain_sets[set_id] = {}
[docs]
def set_terrain_tile(self, set_id: int, bitmask: int, tile_id: int):
"""Map a neighbor bitmask to a tile ID for auto-tiling."""
self._terrain_sets.setdefault(set_id, {})[bitmask] = tile_id
[docs]
def get_terrain_tile(self, set_id: int, bitmask: int) -> int | None:
"""Look up tile ID for a given neighbor bitmask."""
terrain = self._terrain_sets.get(set_id, {})
return terrain.get(bitmask)
# ============================================================================
# Chunk-based storage for large maps
# ============================================================================
CHUNK_SIZE = 32
class _TileChunk:
"""32x32 chunk of tile data."""
__slots__ = ("cells",)
def __init__(self):
# cells[sub_layer][local_y * CHUNK_SIZE + local_x] = tile_id (-1 = empty)
self.cells: dict[int, np.ndarray] = {}
def get_layer(self, sub_layer: int = 0) -> np.ndarray:
if sub_layer not in self.cells:
self.cells[sub_layer] = np.full(CHUNK_SIZE * CHUNK_SIZE, -1, dtype=np.int32)
return self.cells[sub_layer]
# ============================================================================
# TileMapLayer — A single layer within a TileMap
# ============================================================================
[docs]
class TileMapLayer:
"""A single layer within a TileMap. Stores tiles in chunks for large maps."""
def __init__(self, name: str = "Layer 0"):
self.name = name
self.visible = True
self.z_index = 0
self._chunks: dict[tuple[int, int], _TileChunk] = {}
def _chunk_key(self, x: int, y: int) -> tuple[int, int]:
return (x // CHUNK_SIZE, y // CHUNK_SIZE)
def _local_index(self, x: int, y: int) -> int:
return (y % CHUNK_SIZE) * CHUNK_SIZE + (x % CHUNK_SIZE)
[docs]
def set_cell(self, x: int, y: int, tile_id: int):
"""Set a tile at grid position (x, y)."""
ck = self._chunk_key(x, y)
if ck not in self._chunks:
self._chunks[ck] = _TileChunk()
self._chunks[ck].get_layer(0)[self._local_index(x, y)] = tile_id
[docs]
def get_cell(self, x: int, y: int) -> int:
"""Get tile ID at grid position. Returns -1 if empty."""
chunk = self._chunks.get(self._chunk_key(x, y))
if chunk is None:
return -1
layer_data = chunk.cells.get(0)
if layer_data is None:
return -1
return int(layer_data[self._local_index(x, y)])
[docs]
def erase_cell(self, x: int, y: int):
self.set_cell(x, y, -1)
[docs]
def get_used_cells(self) -> list[tuple[int, int]]:
"""Return all non-empty cell positions."""
result = []
for (cx, cy), chunk in self._chunks.items():
for layer_data in chunk.cells.values():
indices = np.where(layer_data >= 0)[0]
for i in indices:
lx = int(i) % CHUNK_SIZE
ly = int(i) // CHUNK_SIZE
result.append((cx * CHUNK_SIZE + lx, cy * CHUNK_SIZE + ly))
return result
[docs]
def get_used_rect(self) -> tuple[int, int, int, int]:
"""Return bounding rect (x, y, w, h) of used cells."""
cells = self.get_used_cells()
if not cells:
return (0, 0, 0, 0)
xs = [c[0] for c in cells]
ys = [c[1] for c in cells]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
return (min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
# ============================================================================
# TileMap — Node2D with grid-based tile storage
# ============================================================================
[docs]
class TileMap(Node2D):
"""Grid-based 2D map with multiple layers. Uses chunk-based storage."""
tile_set = Property(None)
cell_size = Property((16, 16))
def __init__(self, name="TileMap", **kwargs):
super().__init__(name=name, **kwargs)
self._layers: list[TileMapLayer] = [TileMapLayer("Layer 0")]
[docs]
def add_layer(self, name: str = "") -> int:
"""Add a new layer. Returns its index."""
idx = len(self._layers)
self._layers.append(TileMapLayer(name or f"Layer {idx}"))
return idx
[docs]
def get_layer(self, index: int) -> TileMapLayer:
return self._layers[index]
@property
def layer_count(self) -> int:
return len(self._layers)
[docs]
def set_cell(self, layer: int, x: int, y: int, tile_id: int):
self._layers[layer].set_cell(x, y, tile_id)
[docs]
def get_cell(self, layer: int, x: int, y: int) -> int:
return self._layers[layer].get_cell(x, y)
[docs]
def erase_cell(self, layer: int, x: int, y: int):
self._layers[layer].erase_cell(x, y)
[docs]
def world_to_map(self, world_pos: tuple[float, float]) -> tuple[int, int]:
"""Convert world position to grid coordinates."""
cx, cy = self.cell_size
return (int(world_pos[0] // cx), int(world_pos[1] // cy))
[docs]
def map_to_world(self, map_pos: tuple[int, int]) -> tuple[float, float]:
"""Convert grid coordinates to world position (center of cell)."""
cx, cy = self.cell_size
return (map_pos[0] * cx + cx / 2, map_pos[1] * cy + cy / 2)
[docs]
def get_neighbor_bitmask(self, layer: int, x: int, y: int) -> int:
"""Calculate 4-bit bitmask for auto-tiling.
Bits: up=1, right=2, down=4, left=8. A neighbor bit is set when the
adjacent tile shares the same terrain_type as the center tile.
"""
tile_id = self.get_cell(layer, x, y)
if tile_id < 0:
return 0
ts = self.tile_set
if ts is None:
return 0
tile_data = ts.get_tile(tile_id)
if tile_data is None:
return 0
terrain = tile_data.terrain_type
bitmask = 0
# 4-directional: up=1, right=2, down=4, left=8
for i, (dx, dy) in enumerate([(0, -1), (1, 0), (0, 1), (-1, 0)]):
nid = self.get_cell(layer, x + dx, y + dy)
if nid >= 0:
nd = ts.get_tile(nid)
if nd and nd.terrain_type == terrain:
bitmask |= 1 << i
return bitmask
[docs]
def auto_tile(self, layer: int, x: int, y: int, terrain_set: int = 0):
"""Update tile at (x, y) based on neighbors using auto-tile rules."""
bitmask = self.get_neighbor_bitmask(layer, x, y)
ts = self.tile_set
if ts:
new_id = ts.get_terrain_tile(terrain_set, bitmask)
if new_id is not None:
self.set_cell(layer, x, y, new_id)