Source code for simvx.core.tilemap

"""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