"""TileMap and TileSet for grid-based 2D level design."""
from __future__ import annotations
import base64
import gzip
import logging
import xml.etree.ElementTree as ET
import zlib
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from .asset_resolver import resolve_asset_path
from .descriptors import Property
from .nodes_2d.node2d import Node2D
log = logging.getLogger(__name__)
__all__ = ["TileData", "TileSet", "TileMapLayer", "TileMap"]
# Tiled GID flip-flag bits (top 3 bits): stripped before the local tile id is used.
_TMX_FLIP_FLAGS = 0xE0000000
_TMX_GID_MASK = ~_TMX_FLIP_FLAGS & 0xFFFFFFFF
# ============================================================================
# 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}
self._atlas_pixels: np.ndarray | None = None
self._atlas_width: int = 0
self._atlas_height: int = 0
# Set by TileMap.from_tmx when the atlas wasn't decoded inline (image_loader
# not provided). Renderer / asset preloader can pick this up to wire the
# GPU texture later.
self._atlas_source: Path | None = None
[docs]
@classmethod
def from_atlas_array(
cls,
pixels: np.ndarray,
width: int,
height: int,
tile_size: tuple[int, int] = (16, 16),
) -> TileSet:
"""Create a TileSet from an RGBA atlas array and auto-generate tiles.
``pixels`` is an RGBA ``uint8`` ndarray of shape ``(height, width, 4)``
that the scene adapter uploads to the GPU. Tiles are created in a grid
using ``create_from_grid``.
"""
ts = cls(tile_size=tile_size)
ts._atlas_pixels = pixels
ts._atlas_width = width
ts._atlas_height = height
ts.create_from_grid(width, height)
return ts
[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 set_tile(self, tile_id: int, tile_data: TileData) -> None:
"""Insert or replace a tile at a specific ID.
Used by loaders (e.g. :meth:`TileMap.from_tmx`) that need to place
tiles at predetermined IDs: Tiled's global tile IDs include a
``firstgid`` offset per tileset so the local index doesn't start at
zero, and the TileSet must mirror that addressing.
"""
self._tiles[tile_id] = tile_data
if tile_id >= self._next_id:
self._next_id = tile_id + 1
[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)
[docs]
@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.
The default :attr:`mode` is ``"orthogonal"``: tile ``(col, row)`` projects
to world ``(col * cell_w, row * cell_h)``. Switching ``mode`` to
``"isometric"`` rotates that lattice 45° and halves the row spacing so
tiles tile diamond-style (canonical for 2D strategy games like Tanks of
Freedom). Conversions in either direction go through :meth:`map_to_world`
/ :meth:`world_to_map`, so call those rather than rolling your own cell
arithmetic: projection details belong to the TileMap.
"""
tile_set = Property(None)
cell_size = Property((16, 16))
mode = Property("orthogonal", enum=("orthogonal", "isometric"),
hint="Tile projection: orthogonal (default) or isometric")
[docs]
@classmethod
def from_tmx(
cls,
path,
*,
project_root: Path | None = None,
image_loader: Callable[[Path], tuple[np.ndarray, int, int]] | None = None,
) -> TileMap:
"""Load a TileMap from a Tiled Map Editor ``.tmx`` XML file.
Supports orthogonal + isometric maps, multiple tile layers, CSV /
base64 (uncompressed, zlib, gzip) layer encodings, and external
``.tsx`` tilesets referenced via the ``source`` attribute. Tiled
object groups and infinite-map chunks are recognised but skipped with
a debug log entry: they're a port-by-port concern, not a tilemap
primitive.
Args:
path: ``.tmx`` filesystem path (or any spec accepted by
:func:`resolve_asset_path`).
project_root: Root for resolving relative paths in *path*. Image
/ external-tileset references inside the TMX are resolved
relative to the TMX file's directory, not *project_root*.
image_loader: Optional callable ``(Path) -> (pixels, width,
height)`` returning RGBA ``uint8`` pixels for the first
tileset image. When omitted, the source path is stored on
``tile_set._atlas_source`` for the renderer or a downstream
preloader to resolve.
Returns:
A fully populated ``TileMap`` with ``cell_size``, ``mode``,
``tile_set``, and one layer per ``<layer>`` element in the TMX.
Tile IDs in the layers are the TMX global tile IDs (firstgid
offset preserved), matching the TileSet entries.
"""
tmx_path = resolve_asset_path(path, project_root)
return cls._from_tmx_root(ET.parse(tmx_path).getroot(), tmx_path.parent, image_loader)
@classmethod
def _from_tmx_root(
cls,
root: ET.Element,
tmx_dir: Path,
image_loader: Callable[[Path], tuple[np.ndarray, int, int]] | None,
) -> TileMap:
orientation = root.get("orientation", "orthogonal")
if orientation not in ("orthogonal", "isometric"):
raise ValueError(
f"Unsupported TMX orientation {orientation!r} (supported: orthogonal, isometric)"
)
if root.get("infinite", "0") == "1":
raise ValueError("TMX infinite maps are not supported: re-export with a fixed size")
map_w = int(root.get("width", 0))
map_h = int(root.get("height", 0))
tile_w = int(root.get("tilewidth", 16))
tile_h = int(root.get("tileheight", 16))
tilemap = cls(cell_size=(tile_w, tile_h), mode=orientation)
combined = TileSet(tile_size=(tile_w, tile_h))
primary_image: tuple[Path, int, int] | None = None
for ts_elem in root.findall("tileset"):
firstgid, ts_root, ts_dir = _tmx_resolve_tileset(ts_elem, tmx_dir)
tw = int(ts_root.get("tilewidth", tile_w))
th = int(ts_root.get("tileheight", tile_h))
tilecount = int(ts_root.get("tilecount", 0))
columns = int(ts_root.get("columns", 0))
image_elem = ts_root.find("image")
if image_elem is not None:
img_src = (ts_dir / image_elem.get("source", "")).resolve()
img_w = int(image_elem.get("width", 0))
img_h = int(image_elem.get("height", 0))
if not columns and tw:
columns = img_w // tw
if not tilecount and columns and th:
tilecount = columns * (img_h // th)
if primary_image is None:
primary_image = (img_src, img_w, img_h)
for i in range(tilecount):
col = i % columns if columns else 0
row = i // columns if columns else 0
combined.set_tile(
firstgid + i,
TileData(texture_region=(col * tw, row * th, tw, th)),
)
if primary_image is not None:
img_src, img_w, img_h = primary_image
combined._atlas_source = img_src
combined._atlas_width = img_w
combined._atlas_height = img_h
if image_loader is not None:
pixels, lw, lh = image_loader(img_src)
combined._atlas_pixels = pixels
combined._atlas_width = lw or img_w
combined._atlas_height = lh or img_h
tilemap.tile_set = combined
first_layer = True
for child in root:
if child.tag != "layer":
if child.tag in ("objectgroup", "imagelayer", "group"):
log.debug("TMX: skipping %s element %r, not yet supported",
child.tag, child.get("name", ""))
continue
data_elem = child.find("data")
if data_elem is None:
continue
lw = int(child.get("width", map_w))
lh = int(child.get("height", map_h))
gids = _tmx_decode_layer(data_elem, lw, lh)
if first_layer:
layer = tilemap.get_layer(0)
layer.name = child.get("name", "Layer 0")
first_layer = False
else:
idx = tilemap.add_layer(child.get("name", ""))
layer = tilemap.get_layer(idx)
layer.visible = child.get("visible", "1") != "0"
layer.z_index = int(child.get("id", 0))
mask = gids > 0
if not mask.any():
continue
ys, xs = np.where(mask.reshape(lh, lw))
for y, x in zip(ys.tolist(), xs.tolist(), strict=True):
layer.set_cell(int(x), int(y), int(gids[y * lw + x]))
return tilemap
def __init__(self, name="TileMap", **kwargs):
super().__init__(name=name, **kwargs)
self._layers: list[TileMapLayer] = [TileMapLayer("Layer 0")]
# Movement-range / selection overlay: list of (cells, colour). Each
# entry is drawn as a translucent alpha-blended fill on top of the
# rendered tiles via ``draw(renderer)``. Multiple groups can coexist
# (e.g. blue for "move", red for "attack").
self._highlight_groups: list[tuple[list[tuple[int, int]], tuple]] = []
# Warn about iso Y-sort nesting only once per TileMap instance to avoid
# spamming the log for legitimate flat-ground use cases. Toggle off when
# the user has acknowledged it (or when a port deliberately groups by
# category and accepts the trade-off).
warn_on_nested_ysort = Property(True, hint="Warn once when a nested-group child is added", group="TileMap")
[docs]
def add_child(self, node): # noqa: D401: Node hook
"""Warn (once) when a direct child carries descendants of its own.
Y-sort only applies to direct children. Nesting a group like
``_floor`` or ``_units`` with per-cell sprites underneath causes the
group to sort as a single unit, NOT per descendant: fine for flat
ground, wrong for isometric / elevated scenes. See
``docs/core/tilemap.md`` "Y-sort caveats" for the full write-up.
"""
if (
self.warn_on_nested_ysort
and isinstance(node, Node2D)
and len(node.children) > 0
):
log.warning(
"TileMap '%s' received nested child '%s' with %d descendant(s). "
"Y-sort applies only to direct TileMap children: the nested "
"group sorts as a unit, not per descendant. Acceptable for flat "
"ground; for isometric / elevated scenes attach sprites as "
"direct children. See docs/core/tilemap.md (Y-sort caveats). "
"Set tilemap.warn_on_nested_ysort = False to silence.",
self.name,
getattr(node, "name", type(node).__name__),
len(node.children),
)
self.warn_on_nested_ysort = False
return super().add_child(node)
[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]
[docs]
@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.
Inverse of :meth:`map_to_world`; honours the active :attr:`mode`. The
result is the cell that contains ``world_pos``: for the isometric
case that's the diamond whose centre is closest, computed by
inverting the standard ``(col-row) * w/2, (col+row) * h/2`` mapping.
"""
cx, cy = self.cell_size
wx, wy = float(world_pos[0]), float(world_pos[1])
if self.mode == "isometric":
# Invert: x = (col - row) * cx/2; y = (col + row) * cy/2
# Solve for col, row in cell-space (avoid div-by-zero on degenerate sizes).
half_w = cx * 0.5 if cx else 1.0
half_h = cy * 0.5 if cy else 1.0
col_f = (wx / half_w + wy / half_h) * 0.5
row_f = (wy / half_h - wx / half_w) * 0.5
return (int(col_f), int(row_f))
return (int(wx // cx), int(wy // cy))
[docs]
def map_to_world(self, map_pos: tuple[int, int]) -> tuple[float, float]:
"""Convert grid coordinates to world position (centre of the cell).
Orthogonal: ``(col * cell_w + cell_w/2, row * cell_h + cell_h/2)``.
Isometric: rotates the lattice 45° so columns advance ``+x``/``+y``
and rows advance ``-x``/``+y``: the canonical diamond layout used by
Godot's ``TileMap`` in isometric mode and adopted by most 2D
isometric strategy ports.
"""
cx, cy = self.cell_size
col, row = map_pos
if self.mode == "isometric":
half_w = cx * 0.5
half_h = cy * 0.5
return ((col - row) * half_w, (col + row) * half_h)
return (col * cx + cx / 2, row * 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)
def _draw_recursive(self, renderer):
"""In isometric mode, draw children sorted by world-Y for correct depth.
Orthogonal mode falls through to the default Node traversal: children
keep their declared order, so HUD overlays / debug shapes parented to
the TileMap stay where the author put them. Sorting is only meaningful
when the projection itself is depth-ambiguous, which is what isometric
layouts are.
"""
if self.mode != "isometric":
super()._draw_recursive(renderer)
return
if not self.visible:
return
if self._script_error:
for child in self.children.safe_iter():
child._draw_recursive(renderer)
return
self._draw_dispatch(renderer)
# Sort by world-Y so back rows draw before front rows.
# Children without a world_position (rare: non-spatial helpers) keep
# their original order at the top.
def _sort_key(c):
wp = getattr(c, "world_position", None)
if wp is None:
return float("-inf")
return float(wp[1] if hasattr(wp, "__getitem__") else wp.y)
for child in sorted(self.children.safe_iter(), key=_sort_key):
child._draw_recursive(renderer)
# ------------------------------------------------------------------------
# Highlight overlays: translucent fills for movement-range / selection UI
# ------------------------------------------------------------------------
[docs]
def highlight_cells(
self,
cells: list[tuple[int, int]],
colour: tuple = (0.3, 0.6, 1.0, 0.45),
) -> None:
"""Overlay a translucent fill on each named cell.
Args:
cells: List of ``(x, y)`` grid coordinates to fill.
colour: RGBA tuple in 0.0-1.0 range (alpha defaults to ~0.45 for a
readable translucent overlay). 3-tuples are accepted and
receive a default alpha of 0.45.
Used by strategy/tactics games to show movement range, attack range,
targeting reticles, and similar overlays. Multiple groups may be
registered (e.g. blue for "move", red for "attack"); each call adds a
new group. Call :meth:`clear_highlights` to remove them.
"""
if len(colour) == 3:
colour = (*colour, 0.45)
self._highlight_groups.append((list(cells), tuple(colour)))
[docs]
def clear_highlights(self) -> None:
"""Remove every highlight group registered via :meth:`highlight_cells`."""
self._highlight_groups.clear()
[docs]
@property
def highlight_groups(self) -> list[tuple[list[tuple[int, int]], tuple]]:
"""Read-only view of the registered highlight groups (for inspection / tests)."""
return [(list(cells), tuple(colour)) for cells, colour in self._highlight_groups]
[docs]
def on_draw(self, renderer) -> None:
"""Layer translucent fills over the rendered tiles for each highlight group."""
if not self._highlight_groups:
return
cw, ch = self.cell_size
ox, oy = float(self.position.x), float(self.position.y)
for cells, colour in self._highlight_groups:
for cx, cy in cells:
renderer.draw_rect(
(ox + cx * cw, oy + cy * ch),
(cw, ch),
colour=colour,
filled=True,
)
# ============================================================================
# TMX (Tiled Map Editor) loader helpers
# ============================================================================
def _tmx_resolve_tileset(
ts_elem: ET.Element, tmx_dir: Path,
) -> tuple[int, ET.Element, Path]:
"""Resolve a ``<tileset>`` element to ``(firstgid, root_element, base_dir)``.
External tilesets (``source="foo.tsx"``) are parsed recursively and the
base directory used for their ``<image source=>`` lookups is the .tsx
file's directory, not the TMX's, matching Tiled's own behaviour.
"""
firstgid = int(ts_elem.get("firstgid", 1))
source = ts_elem.get("source")
if source:
tsx_path = (tmx_dir / source).resolve()
if not tsx_path.exists():
raise FileNotFoundError(f"TMX external tileset not found: {tsx_path}")
return firstgid, ET.parse(tsx_path).getroot(), tsx_path.parent
return firstgid, ts_elem, tmx_dir
def _tmx_decode_layer(data_elem: ET.Element, width: int, height: int) -> np.ndarray:
"""Decode a ``<data>`` element into a flat ``uint32`` array of GIDs.
Supports the three encodings Tiled ships by default: ``csv``, ``base64``
(with optional ``zlib``/``gzip`` compression), and the legacy
``<tile gid="N"/>`` XML form. Flip-flag bits (top 3) are stripped: the
engine doesn't yet support per-cell tile rotation/mirroring.
"""
encoding = data_elem.get("encoding", "xml")
compression = data_elem.get("compression")
expected = width * height
if encoding == "csv":
text = (data_elem.text or "").replace("\n", ",").replace(" ", "")
gids = np.array(
[int(v) for v in text.split(",") if v],
dtype=np.uint32,
)
elif encoding == "base64":
raw = base64.b64decode((data_elem.text or "").strip())
if compression == "zlib":
raw = zlib.decompress(raw)
elif compression == "gzip":
raw = gzip.decompress(raw)
elif compression == "zstd":
try:
import zstandard # type: ignore[import-not-found]
except ImportError as exc:
raise ImportError(
"TMX zstd compression requires the 'zstandard' package"
) from exc
raw = zstandard.ZstdDecompressor().decompress(raw)
elif compression:
raise ValueError(f"Unsupported TMX compression: {compression!r}")
gids = np.frombuffer(raw, dtype="<u4").copy()
elif encoding == "xml":
gids = np.array(
[int(c.get("gid", 0)) for c in data_elem.findall("tile")],
dtype=np.uint32,
)
else:
raise ValueError(f"Unsupported TMX encoding: {encoding!r}")
if gids.size != expected:
raise ValueError(
f"TMX layer data size mismatch: expected {expected} cells, decoded {gids.size}"
)
return gids & _TMX_GID_MASK