Source code for simvx.core.tilemap

"""TileMap and TileSet for grid-based 2D level design."""


from __future__ import annotations

import logging
from dataclasses import dataclass, field

import numpy as np

from .nodes_2d.node2d import Node2D
from .descriptors import Property

log = logging.getLogger(__name__)

__all__ = ["TileData", "TileSet", "TileMapLayer", "TileMap"]


# ============================================================================
# TileData — Per-tile metadata within a TileSet
# ============================================================================


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