tilemapper/src/tilemapper/tileset.py
2025-08-17 16:58:17 -07:00

423 lines
14 KiB
Python

import importlib
import logging
import random
import tomllib
from collections import defaultdict
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from types import SimpleNamespace
from typing import Dict, List
from PIL import Image, ImageDraw
from tilemapper.grid import Grid, Position
DEFAULT_TILE_SIZE_IN_PIXELS = 128
class UnsupportedTileException(Exception):
pass
class MissingImageDataException(Exception):
pass
class MisconfiguredTileSetException(Exception):
pass
TileSetConfig = SimpleNamespace
@dataclass(kw_only=True)
class Tile:
"""
A base class repesenting a single member of a TileSet. Only supports
text rendering; other tile types should subclass this class.
"""
name: str
char: str
config: TileSetConfig
def render(self):
return str(self)
@cached_property
def terrain_name(self):
return self.name.split("_", 1)[0]
def __str__(self):
return self.char
@dataclass
class TileSet:
"""
Base class representing all tiles in a set which can be used to render
battle maps. Only supports text rendering; other set types should subclass
this class.
"""
config: TileSetConfig
tiles: Dict[str, Tile] = field(default_factory=lambda: defaultdict(list))
tile_type = Tile
def load(self):
self.tiles = defaultdict(list)
for char, name in self.config.legend.items():
self.add(self.tile_type(char=char, name=name, config=self.config))
def add(self, tile: Tile):
"""
Add a Tile to the set.
"""
self.tiles[tile.name].append(tile)
def get(self, char: str) -> Tile:
"""
Return the Tile instance corrresponding to an input character.
"""
if char not in self.config.legend:
raise UnsupportedTileException(f"'{char}' is not supported by the current tile set.")
name = self.config.legend[char]
return random.choice(self.tiles[name]) if name in self.tiles else self.placeholder
def render_grid(self, grid: Grid, width: int = 0, height: int = 0) -> str:
output = ""
for y in range(0, height):
for x in range(0, width):
try:
pos = grid.at(y, x)
except AttributeError:
continue
if pos and pos.value:
output += pos.value.render()
else:
output += self.empty_space.render()
output += "\n"
return output.rstrip("\n")
@property
def empty_space(self) -> Tile:
"""
Return the Tile instance representing empty space.
"""
return self.get(" ")
def __str__(self):
return f"[Tileset] {self.config.tileset['name']}: {self.config.tileset['desc']}"
@dataclass
class ColorizedTile(Tile):
"""
A variant of the base Tile type that supports colorized console output
using ANSI color codes.
"""
def render(self):
color = self.config.color_map[self.char]
return f"[{color}]{self.char}[/{color}]"
def __str__(self):
return self.render()
class ColorizedTileSet(TileSet):
"""
A variant of the base TileSet type that uses ColorizedTiles.
"""
tiles: Dict[str, ColorizedTile] = {}
tile_type = ColorizedTile
@dataclass
class ImageTile(Tile):
"""
A Tile subclass that uses PNG images.
A single Tile must have one or more images in its paths. When an ImageTile
is rendered, a random path from ImageTile.paths will be selected.
"""
paths: List[Path] = field(default_factory=list)
buffer: Image = None
@property
def image(self) -> Image:
"""
Select a random image file from ImageTile.paths and return it as an
Image instace. If the buffer contains image data, return that instead.
"""
if self.buffer:
return self.buffer
return Image.open(random.choice(self.paths))
@property
def width(self) -> int:
return self.image.size[0]
@property
def height(self) -> int:
return self.image.size[1]
def render(
self,
size: int = 0,
nw: Tile = None,
n: Tile = None,
ne: Tile = None,
e: Tile = None,
se: Tile = None,
s: Tile = None,
sw: Tile = None,
w: Tile = None,
) -> Image:
"""
Render the image as a square SIZE pixels on a side.
If any of the directional parameters are defined, their images will be
pasted into the existing image as squares SIZE/4 pixels on a side, at
locations suitable for creating a 32-pixel border around the image.
"""
if not size:
size = self.image.width
rendered = self.image.copy().resize((size, size))
border_size = int(size / 4)
if nw:
rendered.paste(nw.image, (0, 0), nw.image)
if n:
rendered.paste(n.image, (border_size, 0), n.image)
rendered.paste(n.image, (border_size * 2, 0), n.image)
if ne:
rendered.paste(ne.image, (border_size * 3, 0), ne.image)
if e:
rendered.paste(e.image, (border_size * 3, border_size), e.image)
rendered.paste(e.image, (border_size * 3, border_size * 2), e.image)
if se:
rendered.paste(se.image, (border_size * 3, border_size * 3), se.image)
if s:
rendered.paste(s.image, (border_size, border_size * 3), s.image)
rendered.paste(s.image, (border_size * 2, border_size * 3), s.image)
if sw:
rendered.paste(sw.image, (0, border_size * 3), sw.image)
if w:
rendered.paste(w.image, (0, border_size), w.image)
rendered.paste(w.image, (0, border_size * 2), w.image)
return rendered
@dataclass
class ImageTileSet(TileSet):
"""
A set of image tiles. By default, image tiles are expected to be 128x128
pixels and will be cropped to match. This can be controlled by specifying
tile_size at instantiation.
FILENAMES
Images loaded for the tile set must use filenames following the pattern:
TERRAIN_VARIANT.EXT.
where TERRAIN is the name of the terrain, matching the TileSet.config.legend
values, and VARIANT is an integer. EXT is the filename extension; images
can be any format supported by the Pillow library on your system.
EDGES AND CORNERS
Besides terrain images, ImageTileSet also supports edge and corner images
that can be used to draw the transition between one terrain type those
adjacent to it. For example, to draw a shoreline between water terrain and
grass, you might create the following images:
water_edge_grass_n_1.png
water_edge_grass_e_1.png
water_edge_grass_s_1.png
water_edge_grass_w_1.png
water_edge_grass_ne_1.png
water_edge_grass_nw_1.png
water_edge_grass_se_1.png
water_edge_grass_sw_1.png
water_corner_ne_1.png
water_corner_nw_1.png
water_corner_se_1.png
water_corner_sw_1.png
A Tile with terrain_name "water" which is bordered by a "ground" Tile to the
north will have the 'water_edge_grass_n' tile pasted onto it at the following
positions on the water tile image, assuming a 128x128 tile and 32x32 edge tiles:
0,0 0,32 0,64 0,96
If the water Tile is also bordered by grass to the west and east, the
following overlays will be created:
OVERLAY POSITION
water_edge_grass_nw 0,0
water_edge_grass_n 0,32
water_edge_grass_n 0,64
water_edge_grass_ne 0,96
And so on, for all cardinal directions.
If a water Tile is bordered by water on the north and the west, but the
Tile to the northwest is grass, the 'water_corner_nw_1.png' tile will
be pasted onto it at position (0,0); so too the ne, se, and sw tiles at
their respsective positions.
Like base tiles, edge and corner tiles can have multiple variants and
will be chosen at random when rendered.
"""
_tile_cache = {} # ImageTile data
_image_cache = {} # rendered image data
@cached_property
def paths(self) -> Dict[str, List[Path]]:
paths = defaultdict(list)
for imgfile in sorted(self.config.path.glob("*.png")):
(terrain_name, *parts) = imgfile.stem.rsplit("_")
key = terrain_name
if parts[0] in ("edge", "corner"):
key = "_".join([terrain_name, *parts[:-1]])
paths[key].append(imgfile)
return paths
@cached_property
def placeholder(self):
buffer = Image.new("RGB", (self.tile_size, self.tile_size))
ImageDraw.Draw(buffer).text((3, 3), "?", (255, 255, 255))
return buffer
def load(self):
"""
Walk the config.legend and load the images associated with each terrain type.
"""
self.tiles = defaultdict(list)
for char, name in self.config.legend.items():
if name not in self.paths:
raise MissingImageDataException(
f"The tile set does not contain any images for the '{char}' ({name}) terrain."
)
for path in self.paths[name]:
key = f"{name}-{path.name}"
if key not in self._tile_cache:
tile = ImageTile(char=char, name=name, buffer=Image.open(path), config=self.config)
self._tile_cache[key] = tile
self.add(tile)
for name in self.paths:
if name not in self.config.legend.values():
logging.warn(f"{name} images exist but do not map to terrain types in the legend.")
def _get_overlays(self, position: Position, adjacent: List[Position] = []) -> tuple:
"""
Inspect the grid positions adjacent to the specified position, and
return a tuple of edge and corner tile names that should be applied.
"""
terrain = position.value.terrain_name
(nw_terrain, n_terrain, ne_terrain, e_terrain, se_terrain, s_terrain, sw_terrain, w_terrain) = [
a.value.terrain_name for a in adjacent
]
n = f"{terrain}_edge_{n_terrain}_n" if n_terrain != terrain else None
s = f"{terrain}_edge_{s_terrain}_s" if s_terrain != terrain else None
e = f"{terrain}_edge_{e_terrain}_e" if e_terrain != terrain else None
w = f"{terrain}_edge_{w_terrain}_w" if w_terrain != terrain else None
nw = f"{n}w" if n_terrain != terrain and w_terrain == n_terrain else n
nw = f"{terrain}_corner_nw" if n_terrain == terrain and nw_terrain != terrain else nw or w
sw = f"{s}w" if s_terrain != terrain and w_terrain == s_terrain else s
sw = f"{terrain}_corner_sw" if s_terrain == terrain and sw_terrain != terrain else sw or w
ne = f"{n}e" if n_terrain != terrain and e_terrain == n_terrain else n
ne = f"{terrain}_corner_ne" if n_terrain == terrain and ne_terrain != terrain else ne or e
se = f"{s}e" if s_terrain != terrain and e_terrain == s_terrain else s
se = f"{terrain}_corner_se" if s_terrain == terrain and se_terrain != terrain else se or e
return (nw, n, ne, e, se, s, sw, w)
def render_grid(self, grid: Grid, width: int, height: int) -> Image:
map_image = Image.new("RGBA", (self.config.tileset["size"] * width, self.config.tileset["size"] * height))
for y in range(0, height):
for x in range(0, width):
pos = grid.at(y, x)
if not pos or pos.value == self.empty_space:
continue
map_image.paste(
self.render_tile(pos, [a or pos for a in grid.adjacent(pos)]),
(self.config.tileset["size"] * x, self.config.tileset["size"] * y),
)
return map_image
def render_tile(self, position, adjacent) -> Image:
"""
Return a rendered image of the tile in the specified position, including any edge and
corner overlays implied by the tiles adjacent to it.
"""
key = ":".join([str(position), *[str(a) for a in adjacent]])
if key not in self._image_cache:
self._image_cache[key] = position.value.render(
self.config.tileset["size"],
*[self._image_cache.get(overlay) for overlay in self._get_overlays(position, adjacent)],
)
return self._image_cache[key]
class TileSetManager:
"""
Helper class for managing multiple tile sets in the specified path. The configuration directory is
expected to contain subdirectories, each with a tileset.toml file. ImageTileSets should also include
one or more tiles for each defined terrain type:
config_dir/
set1/
tileset.toml
terrain_1.png
OtherTerrain_1.png
...
set2/
tileset.toml
...
"""
DEFAULT_TILE_SET = ImageTileSet
def __init__(self, config_dir: Path = Path(__file__).parent.parent / "tilesets"):
self.config_dir = config_dir
@cached_property
def available(self):
"""
Parse the tileset.toml file of every tile set in the configuration directory.
"""
available = {}
for config_file in self.config_dir.rglob("tileset.toml"):
config = tomllib.loads(config_file.read_bytes().decode())
available[config_file.parent.name] = TileSetConfig(path=config_file.parent, **config)
return available
def load(self, name: str) -> TileSet:
"""
Load the specified tile set, which it is assumed should be an ImageTileSet.
"""
tileset_class = self.DEFAULT_TILE_SET
custom_class = self.available[name].tileset.get("class")
if custom_class:
try:
module, class_name = custom_class.rsplit(".", 1)
tileset_class = getattr(importlib.import_module(module), class_name)
except ImportError as e:
raise MisconfiguredTileSetException(
f"{self.config.path}: Could not import custom class {custom_class}: {e}"
)
tileset = tileset_class(self.available[name])
tileset.load()
return tileset