add docstrings and type hints

This commit is contained in:
evilchili 2025-08-08 17:22:15 -07:00
parent 8435892c57
commit e82c5ebc59
3 changed files with 250 additions and 56 deletions

View File

@ -4,12 +4,12 @@ from functools import cached_property
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
from textwrap import indent from textwrap import indent
from typing import Union from typing import Union, List
from PIL import Image from PIL import Image
from tilemapper.grid import Grid, Position from tilemapper.grid import Grid, Position
from tilemapper.tileset import TileSet from tilemapper.tileset import TileSet, Tile
class UnsupportedTileException(Exception): class UnsupportedTileException(Exception):
@ -18,6 +18,16 @@ class UnsupportedTileException(Exception):
@dataclass @dataclass
class BattleMap: class BattleMap:
"""
A 2D battle map built on a Grid of Tiles.
Example Usage:
>>> console = TileSetManager().load("colorized")
>>> bmap = BattleMap(name="Example Map", source="input.txt", tileset=console)
>>> bmap.render()
"""
name: str = "" name: str = ""
source: Union[StringIO, Path] = None source: Union[StringIO, Path] = None
source_data: str = "" source_data: str = ""
@ -25,7 +35,12 @@ class BattleMap:
width: int = 0 width: int = 0
height: int = 0 height: int = 0
def load(self): def load(self) -> Grid:
"""
Load a battle map text file and verify that the current tile set supports
all the terrain types used in the map. Returns a Grid instance consisting
of Tiles from the current set representing the input map.
"""
try: try:
data = self.source.read_bytes().decode() data = self.source.read_bytes().decode()
except AttributeError: except AttributeError:
@ -34,11 +49,16 @@ class BattleMap:
if not self.validate_source_data(data): if not self.validate_source_data(data):
return return
self.source_data = data self.source_data = data
# invalidate the cache
if hasattr(self, "grid"): if hasattr(self, "grid"):
del self.grid del self.grid
return self.grid return self.grid
def validate_source_data(self, data: str): def validate_source_data(self, data: str) -> bool:
"""
Return True if every terrain type in the input data has a corresponding tile in the current tile set.
"""
for char in set(list(data)): for char in set(list(data)):
if char == "\n": if char == "\n":
continue continue
@ -46,7 +66,15 @@ class BattleMap:
raise UnsupportedTileException(f"The current tileset does not support the '{char}' character.") raise UnsupportedTileException(f"The current tileset does not support the '{char}' character.")
return True return True
def render(self): def render(self) -> Image:
"""
Create a PNG image of the currenet grid.
If a grid position does not exist (as for example if one row is shorter than anoather), the
missing position will be rendered as empty space (using the tile set's empty space tile).
Returns the generated Image instance.
"""
map_image = Image.new("RGB", (self.tileset.tile_size * self.width, self.tileset.tile_size * self.height)) map_image = Image.new("RGB", (self.tileset.tile_size * self.width, self.tileset.tile_size * self.height))
empty_space = Position(y=-1, x=-1, value=self.tileset.empty_space) empty_space = Position(y=-1, x=-1, value=self.tileset.empty_space)
for y in range(0, self.height): for y in range(0, self.height):
@ -59,7 +87,10 @@ class BattleMap:
return map_image return map_image
@cached_property @cached_property
def grid(self): def grid(self) -> List[List[Tile]]:
"""
Return the current map's Grid instance.
"""
matrix = [] matrix = []
for line in self.source_data.splitlines(): for line in self.source_data.splitlines():
matrix.append([self.tileset.get(char) for char in line]) matrix.append([self.tileset.get(char) for char in line])
@ -68,18 +99,18 @@ class BattleMap:
return Grid(data=matrix) return Grid(data=matrix)
@property @property
def title(self): def title(self) -> str:
return f"BattleMap: {self.name} ({self.width} x {self.height}, {self.width * self.height * 5}sq.ft.)" return f"BattleMap: {self.name} ({self.width} x {self.height}, {self.width * self.height * 5}sq.ft.)"
@property @property
def legend(self): def legend(self) -> str:
output = "" output = ""
for char in sorted(set(list(self.source_data)), key=str.lower): for char in sorted(set(list(self.source_data)), key=str.lower):
if char in self.tileset.character_map: if char in self.tileset.character_map:
output += f"{char} - {self.tileset.character_map[char]}\n" output += f"{char} - {self.tileset.character_map[char]}\n"
return output return output
def __str__(self): def __str__(self) -> str:
output = "" output = ""
for y in range(0, self.height): for y in range(0, self.height):
for x in range(0, self.width): for x in range(0, self.width):
@ -90,5 +121,5 @@ class BattleMap:
output += "\n" output += "\n"
return output.rstrip("\n") return output.rstrip("\n")
def __repr__(self): def __repr__(self) -> str:
return f"\n{self.title}\n\n{indent(str(self), ' ')}\n\nLegend:\n{indent(self.legend, ' ')}" return f"\n{self.title}\n\n{indent(str(self), ' ')}\n\nLegend:\n{indent(self.legend, ' ')}"

View File

@ -1,9 +1,14 @@
from collections import namedtuple from collections import namedtuple
from typing import Union
Position = namedtuple("GridPosition", ["y", "x", "value"]) # a position inside a grid.
Position = namedtuple("Position", ["y", "x", "value"])
class Grid: class Grid:
"""
A class wrapping a 2-d array arry of Position instances with some convience methods.
"""
def __init__(self, data): def __init__(self, data):
self.data = [] self.data = []
for y in range(len(data)): for y in range(len(data)):
@ -12,37 +17,44 @@ class Grid:
row.append(Position(y=y, x=x, value=data[y][x])) row.append(Position(y=y, x=x, value=data[y][x]))
self.data.append(row) self.data.append(row)
def at(self, y, x, default=None): def at(self, y: int, x: int, default: Union[Position, None] = None) -> Union[Position, None]:
"""
Return the Position instance at the given grid coordinates. If the specified position does not exist,
return the specified default value, or None.
"""
try: try:
return self.data[y][x] return self.data[y][x]
except IndexError: except IndexError:
return default return default
def north(self, position: Position): def north(self, position: Position) -> Union[Position, None]:
return self.at(position.y - 1, position.x) return self.at(position.y - 1, position.x)
def east(self, position: Position): def east(self, position: Position) -> Union[Position, None]:
return self.at(position.y, position.x + 1) return self.at(position.y, position.x + 1)
def south(self, position: Position): def south(self, position: Position) -> Union[Position, None]:
return self.at(position.y + 1, position.x) return self.at(position.y + 1, position.x)
def west(self, position: Position): def west(self, position: Position) -> Union[Position, None]:
return self.at(position.y, position.x - 1) return self.at(position.y, position.x - 1)
def northwest(self, position: Position): def northwest(self, position: Position) -> Union[Position, None]:
return self.at(position.y - 1, position.x - 1) return self.at(position.y - 1, position.x - 1)
def northeast(self, position: Position): def northeast(self, position: Position) -> Union[Position, None]:
return self.at(position.y - 1, position.x + 1) return self.at(position.y - 1, position.x + 1)
def southwest(self, position: Position): def southwest(self, position: Position) -> Union[Position, None]:
return self.at(position.y + 1, position.x - 1) return self.at(position.y + 1, position.x - 1)
def southeast(self, position: Position): def southeast(self, position: Position) -> Union[Position, None]:
return self.at(position.y + 1, position.x + 1) return self.at(position.y + 1, position.x + 1)
def adjacent(self, position: Position): def adjacent(self, position: Position) -> tuple:
"""
Return a tuple of all grid positions adjacent to the specified position, including diagonals.
"""
return ( return (
self.northwest(position), self.northwest(position),
self.north(position), self.north(position),

View File

@ -38,6 +38,10 @@ class MissingTileException(Exception):
@dataclass(kw_only=True) @dataclass(kw_only=True)
class Tile: 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 name: str
char: str char: str
@ -54,9 +58,16 @@ class Tile:
@dataclass(kw_only=True) @dataclass(kw_only=True)
class TileSet: 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.
"""
name: str name: str
desc: str = "" desc: str = ""
tiles: Dict[str, Tile] = field(default_factory=dict) tiles: Dict[str, Tile] = field(default_factory=dict)
# The default character map is suitable for dungeons and other structures.
character_map: Dict[str, str] = field( character_map: Dict[str, str] = field(
default_factory=lambda: { default_factory=lambda: {
" ": "empty", " ": "empty",
@ -80,11 +91,17 @@ class TileSet:
} }
) )
def add(self, *tiles: Tile): def add(self, tiles: List[Tile]):
"""
Add a list of Tiles to the set.
"""
for tile in tiles: for tile in tiles:
self.tiles[tile.name] = tile self.tiles[tile.name] = tile
def get(self, char: str): def get(self, char: str) -> Tile:
"""
Return the Tile instance corrresponding to an input character.
"""
t = self.character_map[char] t = self.character_map[char]
try: try:
return self.tiles[f"{t}"] return self.tiles[f"{t}"]
@ -92,7 +109,10 @@ class TileSet:
raise MissingTileException(f'"{self}" does not contain a "{t}" tile.') raise MissingTileException(f'"{self}" does not contain a "{t}" tile.')
@property @property
def empty_space(self): def empty_space(self) -> Tile:
"""
Return the Tile instance representing empty space.
"""
return self.tiles[self.character_map[" "]] return self.tiles[self.character_map[" "]]
def __str__(self): def __str__(self):
@ -101,6 +121,10 @@ class TileSet:
@dataclass @dataclass
class ColorizedTile(Tile): class ColorizedTile(Tile):
"""
A variant of the base Tile type that supports colorized console output
using ANSI color codes.
"""
color: str color: str
def __str__(self): def __str__(self):
@ -108,26 +132,39 @@ class ColorizedTile(Tile):
class ColorizedTileSet(TileSet): class ColorizedTileSet(TileSet):
"""
A variant of the base TileSet type that uses ColorizedTiles.
"""
tiles: Dict[str, ColorizedTile] = {} tiles: Dict[str, ColorizedTile] = {}
@dataclass @dataclass
class ImageTile(Tile): 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] paths: List[Path]
buffer: Image = None buffer: Image = None
@property @property
def image(self): 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: if self.buffer:
return self.buffer return self.buffer
return Image.open(random.choice(self.paths)) return Image.open(random.choice(self.paths))
@property @property
def width(self): def width(self) -> int:
return self.image.size[0] return self.image.size[0]
@property @property
def height(self): def height(self) -> int:
return self.image.size[1] return self.image.size[1]
def render( def render(
@ -141,29 +178,37 @@ class ImageTile(Tile):
s: Tile = None, s: Tile = None,
sw: Tile = None, sw: Tile = None,
w: 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.
"""
rendered = self.image.copy() rendered = self.image.copy()
border_size = (size or rendered.width) / 4
if nw: if nw:
rendered.paste(nw.image, (0, 0)) rendered.paste(nw.image, (0, 0))
if n: if n:
rendered.paste(n.image, (32, 0)) rendered.paste(n.image, (border_size, 0))
rendered.paste(n.image, (64, 0)) rendered.paste(n.image, (border_size * 2, 0))
if ne: if ne:
rendered.paste(ne.image, (96, 0)) rendered.paste(ne.image, (border_size * 3, 0))
if e: if e:
rendered.paste(e.image, (96, 32)) rendered.paste(e.image, (border_size * 3, border_size))
rendered.paste(e.image, (96, 64)) rendered.paste(e.image, (border_size * 3, border_size * 2))
if se: if se:
rendered.paste(se.image, (96, 96)) rendered.paste(se.image, (border_size * 3, border_size * 3))
if s: if s:
rendered.paste(s.image, (32, 96)) rendered.paste(s.image, (border_size, border_size * 3))
rendered.paste(s.image, (64, 96)) rendered.paste(s.image, (border_size * 2, border_size * 3))
if sw: if sw:
rendered.paste(sw.image, (0, 96)) rendered.paste(sw.image, (0, border_size * 3))
if w: if w:
rendered.paste(w.image, (0, 32)) rendered.paste(w.image, (0, border_size))
rendered.paste(w.image, (0, 64)) rendered.paste(w.image, (0, border_size * 2))
if size == rendered.width and size == rendered.height: if size == rendered.width and size == rendered.height:
return rendered return rendered
@ -172,16 +217,82 @@ class ImageTile(Tile):
@dataclass @dataclass
class ImageTileSet(TileSet): 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.character_map
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.
"""
image_dir: Path image_dir: Path
tile_size: int = 128 tile_size: int = 128
_cache = {} _cache = {}
def _load_images(self, char: str, name: str): def _load_images(self, char: str, name: str):
"""
Load the images in the tile set corresponding to a terrain type in the
character map, and organize them into ImageTiles, potentially with
variants. Loaded image data is added to the ImageTileSet._cache; each
ImageTile will point at the cached data.
"""
paths = [] paths = []
lastkey = None lastkey = None
key = None key = None
for imgfile in sorted(self.image_dir.glob(f"{name}_*.png")): for imgfile in sorted(self.image_dir.glob(f"{name}_*.*")):
(terrain_name, *parts) = imgfile.stem.rsplit("_") (terrain_name, *parts) = imgfile.stem.rsplit("_")
key = terrain_name key = terrain_name
if parts[0] in ("edge", "corner"): if parts[0] in ("edge", "corner"):
@ -189,19 +300,28 @@ class ImageTileSet(TileSet):
if not lastkey: if not lastkey:
lastkey = key lastkey = key
if lastkey != key: if lastkey != key:
self.add(*[self._get_or_create_tile(char=char, name=lastkey, paths=paths)]) self.add([self._get_or_create_tile(char=char, name=lastkey, paths=paths)])
lastkey = key lastkey = key
paths = [] paths = []
paths.append(imgfile) paths.append(imgfile)
if key: if key:
self.add(*[self._get_or_create_tile(char=char, name=key, paths=paths)]) self.add([self._get_or_create_tile(char=char, name=key, paths=paths)])
def load(self): def load(self):
"""
Walk the characeter_map and load the images associated with it each
terrain type.
"""
self.tiles = {} self.tiles = {}
for char, name in self.character_map.items(): for char, name in self.character_map.items():
self._load_images(char, name) self._load_images(char, name)
def _get_or_create_tile(self, char: str, name: str, paths: List[Path]): def _get_or_create_tile(self, char: str, name: str, paths: List[Path]) -> ImageTile:
"""
Load image data for the specified terrain type. If the expected image
file cannot be found, generated a placeholder image. The resulting image
data is cached for reuse, making this method idempotent.
"""
if name in self._cache: if name in self._cache:
return self._cache[name] return self._cache[name]
buffer = None buffer = None
@ -211,7 +331,11 @@ class ImageTileSet(TileSet):
self._cache[name] = ImageTile(char=char, name=name, paths=paths, buffer=buffer) self._cache[name] = ImageTile(char=char, name=name, paths=paths, buffer=buffer)
return self._cache[name] return self._cache[name]
def _get_overlays(self, position: Position, adjacent: List[Position] = []): 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 terrain = position.value.terrain_name
(nw_terrain, n_terrain, ne_terrain, e_terrain, se_terrain, s_terrain, sw_terrain, w_terrain) = [ (nw_terrain, n_terrain, ne_terrain, e_terrain, se_terrain, s_terrain, sw_terrain, w_terrain) = [
a.value.terrain_name for a in adjacent a.value.terrain_name for a in adjacent
@ -231,30 +355,59 @@ class ImageTileSet(TileSet):
return (nw, n, ne, e, se, s, sw, w) return (nw, n, ne, e, se, s, sw, w)
def render_tile(self, position, adjacent): 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.
"""
return position.value.render( return position.value.render(
self.tile_size, *[self._cache.get(overlay) for overlay in self._get_overlays(position, adjacent)] self.tile_size, *[self._cache.get(overlay) for overlay in self._get_overlays(position, adjacent)]
) )
class TileSetManager: 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
...
"""
def __init__(self, config_dir: Path): def __init__(self, config_dir: Path):
self.config_dir = config_dir self.config_dir = config_dir
self._available = {} self._available = {}
self._load_tilesets() self._find_tilesets()
@property @property
def available(self): def available(self) -> dict:
"""
Return a mapping of available tile sets by name.
"""
return {"console": self.console_map, "colorized": self.console_map_colorized, **self._available} return {"console": self.console_map, "colorized": self.console_map_colorized, **self._available}
def _load_tilesets(self): def _find_tilesets(self):
"""
Parse the tileset.toml file of every tile set in the configuration directory.
"""
self._available = {} self._available = {}
for config_file in self.config_dir.rglob("tileset.toml"): for config_file in self.config_dir.rglob("tileset.toml"):
config = tomllib.loads(config_file.read_bytes().decode()) config = tomllib.loads(config_file.read_bytes().decode())
config["config_dir"] = config_file.parent config["config_dir"] = config_file.parent
self._available[config["config_dir"].name] = config self._available[config["config_dir"].name] = config
def load(self, name: str): def load(self, name: str) -> ImageTileSet:
"""
Load the specified tile set, which it is assumed should be an ImageTileSet.
"""
config = self.available[name] config = self.available[name]
tileset = ImageTileSet( tileset = ImageTileSet(
image_dir=config["config_dir"], image_dir=config["config_dir"],
@ -267,18 +420,16 @@ class TileSetManager:
return tileset return tileset
@cached_property @cached_property
def console_map(self): def console_map(self) -> TileSet:
ts = TileSet(name="console", desc="Tiles used for input and text rendering.") ts = TileSet(name="console", desc="Tiles used for input and text rendering.")
ts.add(*[Tile(char=key, name=value) for key, value in ts.character_map.items()]) ts.add([Tile(char=key, name=value) for key, value in ts.character_map.items()])
return ts return ts
@cached_property @cached_property
def console_map_colorized(self): def console_map_colorized(self) -> ColorizedTileSet:
ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.") ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.")
ts.add( ts.add([
*[
ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey")) ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey"))
for key, value in ts.character_map.items() for key, value in ts.character_map.items()
] ])
)
return ts return ts