diff --git a/src/tilemapper/battlemap.py b/src/tilemapper/battlemap.py index b954753..9cd133c 100644 --- a/src/tilemapper/battlemap.py +++ b/src/tilemapper/battlemap.py @@ -4,12 +4,12 @@ from functools import cached_property from io import StringIO from pathlib import Path from textwrap import indent -from typing import Union +from typing import Union, List from PIL import Image from tilemapper.grid import Grid, Position -from tilemapper.tileset import TileSet +from tilemapper.tileset import TileSet, Tile class UnsupportedTileException(Exception): @@ -18,6 +18,16 @@ class UnsupportedTileException(Exception): @dataclass 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 = "" source: Union[StringIO, Path] = None source_data: str = "" @@ -25,7 +35,12 @@ class BattleMap: width: 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: data = self.source.read_bytes().decode() except AttributeError: @@ -34,11 +49,16 @@ class BattleMap: if not self.validate_source_data(data): return self.source_data = data + + # invalidate the cache if hasattr(self, "grid"): del 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)): if char == "\n": continue @@ -46,7 +66,15 @@ class BattleMap: raise UnsupportedTileException(f"The current tileset does not support the '{char}' character.") 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)) empty_space = Position(y=-1, x=-1, value=self.tileset.empty_space) for y in range(0, self.height): @@ -59,7 +87,10 @@ class BattleMap: return map_image @cached_property - def grid(self): + def grid(self) -> List[List[Tile]]: + """ + Return the current map's Grid instance. + """ matrix = [] for line in self.source_data.splitlines(): matrix.append([self.tileset.get(char) for char in line]) @@ -68,18 +99,18 @@ class BattleMap: return Grid(data=matrix) @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.)" @property - def legend(self): + def legend(self) -> str: output = "" for char in sorted(set(list(self.source_data)), key=str.lower): if char in self.tileset.character_map: output += f"{char} - {self.tileset.character_map[char]}\n" return output - def __str__(self): + def __str__(self) -> str: output = "" for y in range(0, self.height): for x in range(0, self.width): @@ -90,5 +121,5 @@ class BattleMap: output += "\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, ' ')}" diff --git a/src/tilemapper/grid.py b/src/tilemapper/grid.py index 7fa8598..0f5d479 100644 --- a/src/tilemapper/grid.py +++ b/src/tilemapper/grid.py @@ -1,9 +1,14 @@ 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: + """ + A class wrapping a 2-d array arry of Position instances with some convience methods. + """ def __init__(self, data): self.data = [] for y in range(len(data)): @@ -12,37 +17,44 @@ class Grid: row.append(Position(y=y, x=x, value=data[y][x])) 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: return self.data[y][x] except IndexError: return default - def north(self, position: Position): + def north(self, position: Position) -> Union[Position, None]: 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) - def south(self, position: Position): + def south(self, position: Position) -> Union[Position, None]: 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) - def northwest(self, position: Position): + def northwest(self, position: Position) -> Union[Position, None]: 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) - def southwest(self, position: Position): + def southwest(self, position: Position) -> Union[Position, None]: 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) - 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 ( self.northwest(position), self.north(position), diff --git a/src/tilemapper/tileset.py b/src/tilemapper/tileset.py index a19c467..a33dd28 100644 --- a/src/tilemapper/tileset.py +++ b/src/tilemapper/tileset.py @@ -38,6 +38,10 @@ class MissingTileException(Exception): @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 @@ -54,9 +58,16 @@ class Tile: @dataclass(kw_only=True) 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 desc: str = "" 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( default_factory=lambda: { " ": "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: 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] try: return self.tiles[f"{t}"] @@ -92,7 +109,10 @@ class TileSet: raise MissingTileException(f'"{self}" does not contain a "{t}" tile.') @property - def empty_space(self): + def empty_space(self) -> Tile: + """ + Return the Tile instance representing empty space. + """ return self.tiles[self.character_map[" "]] def __str__(self): @@ -101,6 +121,10 @@ class TileSet: @dataclass class ColorizedTile(Tile): + """ + A variant of the base Tile type that supports colorized console output + using ANSI color codes. + """ color: str def __str__(self): @@ -108,26 +132,39 @@ class ColorizedTile(Tile): class ColorizedTileSet(TileSet): + """ + A variant of the base TileSet type that uses ColorizedTiles. + """ tiles: Dict[str, 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] buffer: Image = None @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: return self.buffer return Image.open(random.choice(self.paths)) @property - def width(self): + def width(self) -> int: return self.image.size[0] @property - def height(self): + def height(self) -> int: return self.image.size[1] def render( @@ -141,29 +178,37 @@ class ImageTile(Tile): 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. + """ rendered = self.image.copy() + border_size = (size or rendered.width) / 4 if nw: rendered.paste(nw.image, (0, 0)) if n: - rendered.paste(n.image, (32, 0)) - rendered.paste(n.image, (64, 0)) + rendered.paste(n.image, (border_size, 0)) + rendered.paste(n.image, (border_size * 2, 0)) if ne: - rendered.paste(ne.image, (96, 0)) + rendered.paste(ne.image, (border_size * 3, 0)) if e: - rendered.paste(e.image, (96, 32)) - rendered.paste(e.image, (96, 64)) + rendered.paste(e.image, (border_size * 3, border_size)) + rendered.paste(e.image, (border_size * 3, border_size * 2)) if se: - rendered.paste(se.image, (96, 96)) + rendered.paste(se.image, (border_size * 3, border_size * 3)) if s: - rendered.paste(s.image, (32, 96)) - rendered.paste(s.image, (64, 96)) + rendered.paste(s.image, (border_size, border_size * 3)) + rendered.paste(s.image, (border_size * 2, border_size * 3)) if sw: - rendered.paste(sw.image, (0, 96)) + rendered.paste(sw.image, (0, border_size * 3)) if w: - rendered.paste(w.image, (0, 32)) - rendered.paste(w.image, (0, 64)) + rendered.paste(w.image, (0, border_size)) + rendered.paste(w.image, (0, border_size * 2)) if size == rendered.width and size == rendered.height: return rendered @@ -172,16 +217,82 @@ class ImageTile(Tile): @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.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 tile_size: int = 128 _cache = {} 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 = [] lastkey = 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("_") key = terrain_name if parts[0] in ("edge", "corner"): @@ -189,19 +300,28 @@ class ImageTileSet(TileSet): if not lastkey: 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 paths = [] paths.append(imgfile) 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): + """ + Walk the characeter_map and load the images associated with it each + terrain type. + """ self.tiles = {} for char, name in self.character_map.items(): 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: return self._cache[name] buffer = None @@ -211,7 +331,11 @@ class ImageTileSet(TileSet): self._cache[name] = ImageTile(char=char, name=name, paths=paths, buffer=buffer) 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 (nw_terrain, n_terrain, ne_terrain, e_terrain, se_terrain, s_terrain, sw_terrain, w_terrain) = [ a.value.terrain_name for a in adjacent @@ -231,30 +355,59 @@ class ImageTileSet(TileSet): 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( self.tile_size, *[self._cache.get(overlay) for overlay in self._get_overlays(position, adjacent)] ) 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): self.config_dir = config_dir self._available = {} - self._load_tilesets() + self._find_tilesets() @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} - def _load_tilesets(self): + def _find_tilesets(self): + """ + Parse the tileset.toml file of every tile set in the configuration directory. + """ self._available = {} for config_file in self.config_dir.rglob("tileset.toml"): config = tomllib.loads(config_file.read_bytes().decode()) config["config_dir"] = config_file.parent 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] tileset = ImageTileSet( image_dir=config["config_dir"], @@ -267,18 +420,16 @@ class TileSetManager: return tileset @cached_property - def console_map(self): + def console_map(self) -> TileSet: 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 @cached_property - def console_map_colorized(self): + def console_map_colorized(self) -> ColorizedTileSet: ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.") - ts.add( - *[ + ts.add([ ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey")) for key, value in ts.character_map.items() - ] - ) + ]) return ts