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

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