from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
from core.ecs import Component
[docs]
@dataclass
class Tileset:
image_path: str = ""
tile_width: int = 32
tile_height: int = 32
spacing: int = 0
margin: int = 0
[docs]
@dataclass
class TileLayer:
name: str = "Layer"
width: int = 10
height: int = 10
tiles: List[int] = field(default_factory=list) # row-major, 0 = empty, otherwise 1..N
visible: bool = True
# For infinite expansion: track offset from origin (0,0)
offset_x: int = 0 # Index of tile at world x=0 in the array
offset_y: int = 0 # Index of tile at world y=0 in the array
[docs]
def ensure_size(self, width: int, height: int):
width = max(1, int(width))
height = max(1, int(height))
if self.width == width and self.height == height and len(self.tiles) == width * height:
return
old_w = max(1, int(self.width))
old_h = max(1, int(self.height))
old_tiles = list(self.tiles or [])
new_tiles = [0] * (width * height)
for y in range(min(height, old_h)):
for x in range(min(width, old_w)):
old_index = y * old_w + x
new_index = y * width + x
if 0 <= old_index < len(old_tiles):
new_tiles[new_index] = int(old_tiles[old_index])
self.width = width
self.height = height
self.tiles = new_tiles
[docs]
def get(self, x: int, y: int) -> int:
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return 0
idx = y * self.width + x
if idx < 0 or idx >= len(self.tiles):
return 0
try:
return int(self.tiles[idx])
except Exception:
return 0
[docs]
def set(self, x: int, y: int, value: int):
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return
idx = y * self.width + x
needed = self.width * self.height
if len(self.tiles) != needed:
self.ensure_size(self.width, self.height)
self.tiles[idx] = int(value)
[docs]
def world_to_array(self, world_x: int, world_y: int) -> tuple[int, int]:
"""Convert world coordinates to array indices"""
array_x = world_x - self.offset_x
array_y = world_y - self.offset_y
return array_x, array_y
[docs]
def array_to_world(self, array_x: int, array_y: int) -> tuple[int, int]:
"""Convert array indices to world coordinates"""
world_x = array_x + self.offset_x
world_y = array_y + self.offset_y
return world_x, world_y
[docs]
def get_world(self, world_x: int, world_y: int) -> int:
"""Get tile value at world coordinates"""
array_x, array_y = self.world_to_array(world_x, world_y)
return self.get(array_x, array_y)
[docs]
def set_world(self, world_x: int, world_y: int, value: int):
"""Set tile value at world coordinates, expanding if necessary"""
array_x, array_y = self.world_to_array(world_x, world_y)
# Check if we need to expand
if array_x < 0 or array_y < 0 or array_x >= self.width or array_y >= self.height:
self.expand_to_include(world_x, world_y)
# Recalculate array position after expansion
array_x, array_y = self.world_to_array(world_x, world_y)
self.set(array_x, array_y, value)
[docs]
def expand_to_include(self, world_x: int, world_y: int):
"""Expand the tilemap to include the given world coordinate"""
array_x, array_y = self.world_to_array(world_x, world_y)
# Calculate new dimensions and offset
new_width = self.width
new_height = self.height
new_offset_x = self.offset_x
new_offset_y = self.offset_y
# Expand left if needed
if array_x < 0:
expand_left = -array_x
new_width += expand_left
new_offset_x -= expand_left
# Expand right if needed
if array_x >= self.width:
expand_right = array_x - self.width + 1
new_width += expand_right
# Expand up if needed
if array_y < 0:
expand_up = -array_y
new_height += expand_up
new_offset_y -= expand_up
# Expand down if needed
if array_y >= self.height:
expand_down = array_y - self.height + 1
new_height += expand_down
# Apply expansion if needed
if new_width != self.width or new_height != self.height:
self.resize_with_offset(new_width, new_height, new_offset_x, new_offset_y)
[docs]
def resize_with_offset(self, new_width: int, new_height: int, new_offset_x: int, new_offset_y: int):
"""Resize the tilemap with a new offset"""
new_width = max(1, int(new_width))
new_height = max(1, int(new_height))
# Create new tile array
new_tiles = [0] * (new_width * new_height)
# Copy existing tiles
for array_y in range(self.height):
for array_x in range(self.width):
world_x, world_y = self.array_to_world(array_x, array_y)
new_array_x, new_array_y = world_x - new_offset_x, world_y - new_offset_y
if 0 <= new_array_x < new_width and 0 <= new_array_y < new_height:
old_idx = array_y * self.width + array_x
new_idx = new_array_y * new_width + new_array_x
if old_idx < len(self.tiles):
new_tiles[new_idx] = self.tiles[old_idx]
# Update properties
self.width = new_width
self.height = new_height
self.offset_x = new_offset_x
self.offset_y = new_offset_y
self.tiles = new_tiles
[docs]
class TilemapComponent(Component):
"""
Spritesheet-based tilemap.
Coordinates:
- Tile coordinates are (0..width-1, 0..height-1)
- World origin is the parent entity Transform center by convention; editor treats tilemap origin as top-left.
Rendering uses the entity Transform as an anchor, see render system for details.
"""
def __init__(
self,
map_width: int = 20,
map_height: int = 15,
tileset: Optional[Tileset] = None,
cell_width: Optional[int] = None,
cell_height: Optional[int] = None,
layers: Optional[List[TileLayer]] = None,
):
self.entity = None
self.map_width = max(1, int(map_width))
self.map_height = max(1, int(map_height))
self.tileset = tileset if tileset is not None else Tileset()
self.cell_width = int(cell_width) if cell_width is not None else int(self.tileset.tile_width)
self.cell_height = int(cell_height) if cell_height is not None else int(self.tileset.tile_height)
self.layers: List[TileLayer] = layers if layers is not None else [TileLayer(name="Base", width=self.map_width, height=self.map_height, tiles=[0] * (self.map_width * self.map_height))]
self._tileset_cache_key = None
self._tileset_frames = None
[docs]
def ensure_layer_sizes(self):
for layer in self.layers:
layer.ensure_size(self.map_width, self.map_height)
[docs]
def get_tileset_frames(self):
"""
Lazy cache of sliced tile images.
Returns a list of pygame.Surface frames, index (tile_id - 1).
"""
from core.resources import ResourceManager
ts = self.tileset or Tileset()
key = (ts.image_path, int(ts.tile_width), int(ts.tile_height), int(ts.margin), int(ts.spacing))
if self._tileset_cache_key == key and self._tileset_frames is not None:
return self._tileset_frames
self._tileset_cache_key = key
if not ts.image_path:
self._tileset_frames = []
return self._tileset_frames
frames = ResourceManager.slice_spritesheet(
ts.image_path,
frame_width=int(ts.tile_width),
frame_height=int(ts.tile_height),
frame_count=0,
margin=int(ts.margin),
spacing=int(ts.spacing),
)
self._tileset_frames = frames or []
return self._tileset_frames