From 60528926eee336846b4044dde40be63c261657ac Mon Sep 17 00:00:00 2001 From: evilchili Date: Mon, 11 Aug 2025 17:05:22 -0700 Subject: [PATCH] Refactor tilesets --- src/tilemapper/battlemap.py | 54 ++++---- src/tilemapper/cli.py | 37 +++--- src/tilemapper/tileset.py | 186 ++++++++++++---------------- src/tilesets/ascii/tileset.toml | 27 ++++ src/tilesets/colorized/tileset.toml | 48 +++++++ test/test_mapper.py | 8 +- 6 files changed, 203 insertions(+), 157 deletions(-) create mode 100644 src/tilesets/ascii/tileset.toml create mode 100644 src/tilesets/colorized/tileset.toml diff --git a/src/tilemapper/battlemap.py b/src/tilemapper/battlemap.py index af29ac4..27b0a91 100644 --- a/src/tilemapper/battlemap.py +++ b/src/tilemapper/battlemap.py @@ -4,11 +4,11 @@ from functools import cached_property from io import StringIO from pathlib import Path from textwrap import indent -from typing import List, Union +from typing import List, Union, get_type_hints from PIL import Image -from tilemapper.grid import Grid, Position +from tilemapper.grid import Grid from tilemapper.tileset import Tile, TileSet @@ -16,8 +16,12 @@ class UnsupportedTileException(Exception): pass +class BattleMapType: + pass + + @dataclass -class BattleMap: +class BattleMap(BattleMapType): """ A 2D battle map built on a Grid of Tiles. @@ -25,7 +29,7 @@ class BattleMap: >>> console = TileSetManager().load("colorized") >>> bmap = BattleMap(name="Example Map", source="input.txt", tileset=console) - >>> bmap.render() + >>> print(bmap.as_string()) """ @@ -63,31 +67,27 @@ class BattleMap: for char in set(list(data)): if char == "\n": continue - if char not in self.tileset.character_map: + if char not in self.tileset.config.legend: raise UnsupportedTileException(f"The current tileset does not support the '{char}' character.") return True - def render(self) -> Image: + def as_image(self) -> Image: """ - Create a PNG image of the currenet grid. + Create an image of the current 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. + Returns an 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): - for x in range(0, self.width): - pos = self.grid.at(y, x) - if not pos or pos.value == self.tileset.empty_space: - continue - map_image.paste( - self.tileset.render_tile(pos, [a or pos for a in self.grid.adjacent(pos)]), - (self.tileset.tile_size * x, self.tileset.tile_size * y), - ) - return map_image + if get_type_hints(self.tileset.render_grid)["return"] != Image: + raise NotImplementedError(f"Tile set does not support image rendering.") + return self.tileset.render_grid(grid=self.grid, width=self.width, height=self.height) + + def as_string(self) -> str: + if get_type_hints(self.tileset.render_grid)["return"] != str: + raise NotImplementedError(f"{self.tileset} does not support string rendering.") + return self.tileset.render_grid(grid=self.grid, width=self.width, height=self.height) @cached_property def grid(self) -> List[List[Tile]]: @@ -109,20 +109,12 @@ class BattleMap: 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" + if char in self.tileset.config.legend: + output += f"{char} - {self.tileset.config.legend[char]}\n" return output def __str__(self) -> str: - output = "" - for y in range(0, self.height): - for x in range(0, self.width): - try: - output += str(self.grid.at(y, x).value) - except AttributeError: - continue - output += "\n" - return output.rstrip("\n") + return self.as_string() 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/cli.py b/src/tilemapper/cli.py index 57e3410..beb0d17 100644 --- a/src/tilemapper/cli.py +++ b/src/tilemapper/cli.py @@ -42,26 +42,17 @@ def list(): print("\n".join(manager.available)) -@app.command() -def inspect(source: Path = typer.Argument(help="The battle map text file to laod.")): - """ - Print information about the specified battle map text file. - """ - manager = app_state["tileset_manager"] - bmap = battlemap.BattleMap(name=source.name, source=source, tileset=manager.console_map_colorized) - bmap.load() - console = Console() - console.print(repr(bmap), highlight=False) - - @app.command() def render( source: Path = typer.Argument(help="The battle map text file to load."), - outfile: Path = typer.Argument(help="The file to create."), - tileset: str = typer.Option(help="The name of the tile set to use (run mapper list to see what's available)."), + outfile: Path = typer.Option(help="The file to create. If not specified, print to STDOUT", default=None), + tileset: str = typer.Option( + help="The name of the tile set to use (run mapper list to see what's available).", default="colorized" + ), ): """ - Create a PNG image of a battle map using a tile set. + Create a rendered battle map using a tile set. Will generate a PNG file if the tile set supports it, + otherwise text output. """ manager = app_state["tileset_manager"] if tileset not in manager.available: @@ -74,9 +65,19 @@ def render( logging.error(e) sys.exit(1) - image = bmap.render() - image.save(outfile) - print(f"Wrote {outfile.stat().st_size} bytes to {outfile}") + try: + image = bmap.as_image() + if outfile: + image.save(outfile) + outfile.write_text(bmap.render()) + print(f"Wrote {outfile.stat().st_size} bytes to {outfile}") + else: + image.save(sys.stdout, "png") + return + except NotImplementedError: + pass + console = Console() + console.print(repr(bmap), highlight=False) if __name__ == "__main__": diff --git a/src/tilemapper/tileset.py b/src/tilemapper/tileset.py index 441ae54..a8fc4b6 100644 --- a/src/tilemapper/tileset.py +++ b/src/tilemapper/tileset.py @@ -1,3 +1,4 @@ +import importlib import logging import random import tomllib @@ -5,36 +6,15 @@ 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 Position +from tilemapper.grid import Grid, Position DEFAULT_TILE_SIZE_IN_PIXELS = 128 -COLOR_MAP = { - ".": "white on grey58", - "d": "bold cyan on grey58", - "D": "bold green on grey58", - "L": "bold dark_red on grey58", - "S": "bold black on grey58", - "v": "bold steel_blue on grey3", - "^": "bold steel_blue on grey3", - "0": "white on dark_red", - "1": "white on dark_red", - "2": "white on dark_red", - "3": "white on dark_red", - "4": "white on dark_red", - "5": "white on dark_red", - "6": "white on dark_red", - "7": "white on dark_red", - "8": "white on dark_red", - "9": "white on dark_red", - "_": "white on steel_blue", - ",": "black on dark_green", -} - class UnsupportedTileException(Exception): pass @@ -44,6 +24,13 @@ class MissingImageDataException(Exception): pass +class MisconfiguredTileSetException(Exception): + pass + + +TileSetConfig = SimpleNamespace + + @dataclass(kw_only=True) class Tile: """ @@ -53,6 +40,7 @@ class Tile: name: str char: str + config: TileSetConfig def render(self): return str(self) @@ -65,7 +53,7 @@ class Tile: return self.char -@dataclass(kw_only=True) +@dataclass class TileSet: """ Base class representing all tiles in a set which can be used to render @@ -73,35 +61,15 @@ class TileSet: this class. """ - name: str - desc: str = "" + config: TileSetConfig tiles: Dict[str, Tile] = field(default_factory=lambda: defaultdict(list)) - # The default character map is suitable for dungeons and other structures. - character_map: Dict[str, str] = field( - default_factory=lambda: { - " ": "empty", - ".": "ground", - ",": "grass", - "_": "water", - "d": "door, open", - "D": "door, closed", - "L": "door, locked", - "S": "door, secret", - "v": "stairs, down", - "^": "stairs, up", - "0": "location 0", - "1": "location 1", - "2": "location 2", - "3": "location 3", - "4": "location 4", - "5": "location 5", - "6": "location 6", - "7": "location 7", - "8": "location 8", - "9": "location 9", - } - ) + 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): """ @@ -113,11 +81,24 @@ class TileSet: """ Return the Tile instance corrresponding to an input character. """ - if char not in self.character_map: + if char not in self.config.legend: raise UnsupportedTileException(f"'{char}' is not supported by the current tile set.") - name = self.character_map[char] + 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() + output += "\n" + return output.rstrip("\n") + @property def empty_space(self) -> Tile: """ @@ -126,7 +107,7 @@ class TileSet: return self.get(" ") def __str__(self): - return f"[Tileset] {self.name}: {self.desc}" + return f"[Tileset] {self.config.tileset['name']}: {self.config.tileset['desc']}" @dataclass @@ -136,10 +117,12 @@ class ColorizedTile(Tile): using ANSI color codes. """ - color: str + def render(self): + color = self.config.color_map[self.char] + return f"[{color}]{self.char}[/{color}]" def __str__(self): - return f"[{self.color}]{self.char}[/{self.color}]" + return self.render() class ColorizedTileSet(TileSet): @@ -149,6 +132,8 @@ class ColorizedTileSet(TileSet): tiles: Dict[str, ColorizedTile] = {} + tile_type = ColorizedTile + @dataclass class ImageTile(Tile): @@ -159,7 +144,6 @@ class ImageTile(Tile): is rendered, a random path from ImageTile.paths will be selected. """ - paths: List[Path] = [] paths: List[Path] = field(default_factory=list) buffer: Image = None @@ -242,7 +226,7 @@ class ImageTileSet(TileSet): TERRAIN_VARIANT.EXT. - where TERRAIN is the name of the terrain, matching the TileSet.character_map + 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. @@ -292,16 +276,13 @@ class ImageTileSet(TileSet): will be chosen at random when rendered. """ - image_dir: Path - tile_size: int = 128 - _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.image_dir.glob("*.png")): + for imgfile in sorted(self.config.path.glob("*.png")): (terrain_name, *parts) = imgfile.stem.rsplit("_") key = terrain_name if parts[0] in ("edge", "corner"): @@ -317,10 +298,10 @@ class ImageTileSet(TileSet): def load(self): """ - Walk the character_map and load the images associated with each terrain type. + Walk the config.legend and load the images associated with each terrain type. """ self.tiles = defaultdict(list) - for char, name in self.character_map.items(): + 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." @@ -328,11 +309,11 @@ class ImageTileSet(TileSet): 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)) + 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.character_map.values(): + 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: @@ -359,6 +340,19 @@ class ImageTileSet(TileSet): 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 @@ -367,7 +361,8 @@ class ImageTileSet(TileSet): 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.tile_size, *[self._image_cache.get(overlay) for overlay in self._get_overlays(position, adjacent)] + self.config.tileset["size"], + *[self._image_cache.get(overlay) for overlay in self._get_overlays(position, adjacent)], ) return self._image_cache[key] @@ -390,53 +385,36 @@ class TileSetManager: """ - def __init__(self, config_dir: Path): + DEFAULT_TILE_SET = ImageTileSet + + def __init__(self, config_dir: Path = Path(__file__).parent.parent / "tilesets"): self.config_dir = config_dir - self._available = {} - self._find_tilesets() - @property - 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 _find_tilesets(self): + @cached_property + def available(self): """ Parse the tileset.toml file of every tile set in the configuration directory. """ - self._available = {} + 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 + available[config_file.parent.name] = TileSetConfig(path=config_file.parent, **config) + return available - def load(self, name: str) -> ImageTileSet: + def load(self, name: str) -> TileSet: """ Load the specified tile set, which it is assumed should be an ImageTileSet. """ - config = self.available[name] - tileset = ImageTileSet( - image_dir=config["config_dir"], - name=config["tileset"]["name"], - desc=config["tileset"]["desc"], - tile_size=config["tileset"].get("size", DEFAULT_TILE_SIZE_IN_PIXELS), - character_map=config["legend"], - ) + 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 - - @cached_property - def console_map(self) -> TileSet: - ts = TileSet(name="console", desc="Tiles used for input and text rendering.") - for key, value in ts.character_map.items(): - ts.add(Tile(char=key, name=value)) - return ts - - @cached_property - def console_map_colorized(self) -> ColorizedTileSet: - ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.") - for key, value in ts.character_map.items(): - ts.add(ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey"))) - return ts diff --git a/src/tilesets/ascii/tileset.toml b/src/tilesets/ascii/tileset.toml new file mode 100644 index 0000000..57d5a8b --- /dev/null +++ b/src/tilesets/ascii/tileset.toml @@ -0,0 +1,27 @@ +[tileset] +name = "ascii" +desc = "Render maps as ascii." +class = "tilemapper.tileset.TileSet" + +[legend] +" " = "empty" +"." = "ground" +"," = "grass" +"_" = "water" +"d" = "door, open" +"D" = "door, closed" +"L" = "door, locked" +"S" = "door, secret" +"v" = "stairs, down" +"^" = "stairs, up" +"0" = "location 0" +"1" = "location 1" +"2" = "location 2" +"3" = "location 3" +"4" = "location 4" +"5" = "location 5" +"6" = "location 6" +"7" = "location 7" +"8" = "location 8" +"9" = "location 9" + diff --git a/src/tilesets/colorized/tileset.toml b/src/tilesets/colorized/tileset.toml new file mode 100644 index 0000000..8450faf --- /dev/null +++ b/src/tilesets/colorized/tileset.toml @@ -0,0 +1,48 @@ +[tileset] +name = "Colorized" +desc = "Colorized ANSI tiles (text-only)" +class = "tilemapper.tileset.ColorizedTileSet" + +[legend] +" " = "empty" +"." = "ground" +"," = "grass" +"_" = "water" +"d" = "door, open" +"D" = "door, closed" +"L" = "door, locked" +"S" = "door, secret" +"v" = "stairs, down" +"^" = "stairs, up" +"0" = "location 0" +"1" = "location 1" +"2" = "location 2" +"3" = "location 3" +"4" = "location 4" +"5" = "location 5" +"6" = "location 6" +"7" = "location 7" +"8" = "location 8" +"9" = "location 9" + +[color_map] +" " = "black" +"." = "white on grey58" +"d" = "bold cyan on grey58" +"D" = "bold green on grey58" +"L" = "bold dark_red on grey58" +"S" = "bold black on grey58" +"v" = "bold steel_blue on grey3" +"^" = "bold steel_blue on grey3" +"0" = "white on dark_red" +"1" = "white on dark_red" +"2" = "white on dark_red" +"3" = "white on dark_red" +"4" = "white on dark_red" +"5" = "white on dark_red" +"6" = "white on dark_red" +"7" = "white on dark_red" +"8" = "white on dark_red" +"9" = "white on dark_red" +"_" = "white on steel_blue" +"," = "black on dark_green" diff --git a/test/test_mapper.py b/test/test_mapper.py index d4f657b..5a44ffb 100644 --- a/test/test_mapper.py +++ b/test/test_mapper.py @@ -1,5 +1,4 @@ from io import StringIO -from pathlib import Path from textwrap import dedent import pytest @@ -9,7 +8,7 @@ from tilemapper import battlemap, tileset @pytest.fixture def manager(): - return tileset.TileSetManager(Path(__file__).parent / "fixtures") + return tileset.TileSetManager() @pytest.fixture @@ -34,11 +33,12 @@ def sample_map(): def test_tileset_loader(manager): - assert manager.console_map.name in manager.available + assert "colorized" in manager.available + assert "ascii" in manager.available def test_renderer(manager, sample_map): - test_map = battlemap.BattleMap("test map", source=StringIO(sample_map), tileset=manager.console_map) + test_map = battlemap.BattleMap("test map", source=StringIO(sample_map), tileset=manager.load("ascii")) test_map.load() assert test_map.width == 21 assert test_map.height == 12