423 lines
14 KiB
Python
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
|