"""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
from .properties import Colour
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
def _coerce_rgba(colour) -> tuple[float, float, float, float]:
"""Coerce a colour-ish value to an RGBA float tuple (3-tuples get alpha 1.0).
Reuses :meth:`Colour.coerce` so tile tints accept exactly what every other
colour entry point does. ``None`` is rejected here: callers gate on
``colour is not None`` before tinting so the "no tint" path never allocates.
"""
rgba = Colour.coerce(colour, name="tile colour")
if rgba is None:
raise ValueError("tile colour cannot be None at coercion time")
r, g, b = rgba[0], rgba[1], rgba[2]
a = rgba[3] if len(rgba) == 4 else 1.0
return (r, g, b, a)
class _TileChunk:
"""32x32 chunk of tile data.
Per-tile colour tints live in :attr:`colours`, parallel to :attr:`cells`
but allocated lazily: a chunk that only ever stores tile ids keeps
``colours`` empty and pays zero bytes for the tint plane. The plane fills
with white ``(1, 1, 1, 1)`` so an untinted cell multiplies its texel by
identity in the shader.
"""
__slots__ = ("cells", "colours")
def __init__(self):
# cells[sub_layer][local_y * CHUNK_SIZE + local_x] = tile_id (-1 = empty)
self.cells: dict[int, np.ndarray] = {}
# colours[sub_layer][idx] = RGBA float32; absent until the first tint.
self.colours: 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]
def get_colour_layer(self, sub_layer: int = 0) -> np.ndarray:
"""Return the tint plane for ``sub_layer``, allocating white on first use."""
arr = self.colours.get(sub_layer)
if arr is None:
arr = np.ones((CHUNK_SIZE * CHUNK_SIZE, 4), dtype=np.float32)
self.colours[sub_layer] = arr
return arr
# ============================================================================
# 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] = {}
# Monotonic content stamp bumped on every mutation (tile id OR tint). A
# downstream packer (the scene adapter) caches the layer's packed instance
# buffer keyed by this int and rebuilds + re-uploads ONLY when it changes,
# so a static layer pays one build and the version int is the only per-frame
# cost. Just an int counter: zero allocation, free when the layer is static.
self._content_version = 0
[docs]
@property
def content_version(self) -> int:
"""Monotonic mutation counter: bumped by every ``set_cell`` / ``set_cells``
/ ``erase_cell`` (tile id or colour). Unchanged across frames means the
layer's tiles are byte-identical, so the renderer can reuse a cached
instance buffer rather than rebuild and re-upload it."""
return self._content_version
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, *, colour=None) -> None:
"""Set the tile (and optionally its tint) at grid position ``(x, y)``.
``colour`` tints the tile in the shader (``texel * colour``); a tile
with no region (or a ``None`` tile_set) samples white, so a tint turns
it into a solid filled cell. Pass an RGB or RGBA tuple in ``[0, 1]``
(3-tuples get alpha ``1.0``); ``None`` leaves the cell untinted
(white) and never allocates the chunk's tint plane.
"""
ck = self._chunk_key(x, y)
chunk = self._chunks.get(ck)
if chunk is None:
chunk = self._chunks[ck] = _TileChunk()
idx = self._local_index(x, y)
chunk.get_layer(0)[idx] = tile_id
if colour is not None:
chunk.get_colour_layer(0)[idx] = _coerce_rgba(colour)
self._content_version += 1
[docs]
def set_cells(self, tile_ids: np.ndarray, *, origin: tuple[int, int] = (0, 0), colours=None) -> None:
"""Bulk-write a rectangular block of tiles in one vectorised pass.
Args:
tile_ids: ``(H, W)`` int array of tile ids, ``-1`` marking empty
cells. The block's top-left maps to ``origin``.
origin: ``(x, y)`` grid coordinate of ``tile_ids[0, 0]``.
colours: Optional ``(H, W, 4)`` float array of RGBA tints in
``[0, 1]`` (or ``(H, W, 3)``, alpha defaults to ``1.0``).
``None`` writes no tint and never allocates a tint plane.
Tiles are scattered straight into the sparse 32x32 chunks with numpy
slicing: no per-cell Python loop, O(chunks-touched) work.
"""
ids = np.ascontiguousarray(tile_ids, dtype=np.int32)
if ids.ndim != 2:
raise ValueError(f"set_cells expects a 2D (H, W) tile_ids array, got shape {ids.shape}")
h, w = ids.shape
rgba = None
if colours is not None:
rgba = np.ascontiguousarray(colours, dtype=np.float32)
if rgba.shape[:2] != (h, w) or rgba.shape[-1] not in (3, 4):
raise ValueError(f"set_cells colours must be (H, W, 3|4) matching tile_ids {(h, w)}, got {rgba.shape}")
if rgba.shape[-1] == 3:
rgba = np.concatenate([rgba, np.ones((h, w, 1), dtype=np.float32)], axis=-1)
ox, oy = origin
# Walk the chunks the block overlaps, slicing the matching sub-rect of
# the source arrays into each chunk's flat (CHUNK_SIZE*CHUNK_SIZE) plane.
cx0, cy0 = ox // CHUNK_SIZE, oy // CHUNK_SIZE
cx1, cy1 = (ox + w - 1) // CHUNK_SIZE, (oy + h - 1) // CHUNK_SIZE
for cy in range(cy0, cy1 + 1):
for cx in range(cx0, cx1 + 1):
# Intersection of this chunk's world rect with the block, in world coords.
wx0 = max(ox, cx * CHUNK_SIZE)
wy0 = max(oy, cy * CHUNK_SIZE)
wx1 = min(ox + w, (cx + 1) * CHUNK_SIZE)
wy1 = min(oy + h, (cy + 1) * CHUNK_SIZE)
if wx0 >= wx1 or wy0 >= wy1:
continue
chunk = self._chunks.get((cx, cy))
if chunk is None:
chunk = self._chunks[(cx, cy)] = _TileChunk()
cell_plane = chunk.get_layer(0).reshape(CHUNK_SIZE, CHUNK_SIZE)
# Local (within-chunk) and source (within-block) slices.
ly0, ly1 = wy0 - cy * CHUNK_SIZE, wy1 - cy * CHUNK_SIZE
lx0, lx1 = wx0 - cx * CHUNK_SIZE, wx1 - cx * CHUNK_SIZE
sy0, sy1 = wy0 - oy, wy1 - oy
sx0, sx1 = wx0 - ox, wx1 - ox
cell_plane[ly0:ly1, lx0:lx1] = ids[sy0:sy1, sx0:sx1]
if rgba is not None:
colour_plane = chunk.get_colour_layer(0).reshape(CHUNK_SIZE, CHUNK_SIZE, 4)
colour_plane[ly0:ly1, lx0:lx1] = rgba[sy0:sy1, sx0:sx1]
self._content_version += 1
[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 get_cell_colour(self, x: int, y: int) -> tuple[float, float, float, float] | None:
"""Return the RGBA tint at ``(x, y)``, or ``None`` if the cell is untinted.
Untinted cells (no colour ever set in their chunk) return ``None`` so
the packer/serializer can skip the tint plane entirely; the renderer
treats that as white.
"""
chunk = self._chunks.get(self._chunk_key(x, y))
if chunk is None:
return None
colour_data = chunk.colours.get(0)
if colour_data is None:
return None
r, g, b, a = colour_data[self._local_index(x, y)]
return (float(r), float(g), float(b), float(a))
[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
# ============================================================================
def _iso_sort_key(c) -> float:
"""World-Y sort key for isometric TileMap children (non-spatial → -inf)."""
wp = getattr(c, "world_position", None)
if wp is None:
return float("-inf")
return float(wp[1] if hasattr(wp, "__getitem__") else wp.y)
[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 overlays. Each entry is ``(cells, colour)``
# where ``cells`` is an ``(N, 2)`` int32 array of grid coordinates and
# ``colour`` an RGBA float tuple. The scene adapter submits each group as
# one textureless solid-fill tile layer (tex_id -1 -> the shader samples
# white, the per-cell tint makes a translucent fill), layered on top of
# the rendered tiles in the SAME GPU tilemap pass. Multiple groups can
# coexist (e.g. blue "move", red "attack") and blend in submission order.
self._highlight_groups: list[tuple[np.ndarray, 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, *, colour=None):
self._layers[layer].set_cell(x, y, tile_id, colour=colour)
[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 _ordered_children(self):
"""Ordering KEY: isometric → world-Y (self first); orthogonal → Node2D z.
In isometric mode children draw self-first in world-Y-ascending order so
back rows draw before front rows (children without a ``world_position``
keep their original order at the top, key ``-inf``). Orthogonal mode
defers to the Node2D z-band policy: children keep declared order unless a
z_index reorders them, so HUD overlays / debug shapes parented to the
TileMap stay where the author put them. Sorting by depth is only
meaningful when the projection is depth-ambiguous (isometric).
"""
if self.mode != "isometric":
return super()._ordered_children()
return [], sorted(self.children.safe_iter(), key=_iso_sort_key)
# ------------------------------------------------------------------------
# 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.
The overlay renders entirely on the GPU: the scene adapter submits each
group as one instanced, textureless solid-fill tile layer in the tilemap
pass, so a thousand-cell movement range costs one draw call and O(1)
Python per frame (the per-cell ``draw_rect`` loop is retired).
"""
if len(colour) == 3:
colour = (*colour, 0.45)
# Snapshot the cells into an owned (N, 2) int32 array: caller-list
# isolation, and the per-frame submit slices straight out of it.
cell_array = np.array(cells, dtype=np.int32).reshape(-1, 2)
self._highlight_groups.append((cell_array, tuple(float(c) for c in 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 [([(int(x), int(y)) for x, y in cells], tuple(colour)) for cells, colour in self._highlight_groups]
# ============================================================================
# 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