Refactor tilesets

This commit is contained in:
evilchili 2025-08-11 17:05:22 -07:00
parent 4b02cc4c9d
commit 60528926ee
6 changed files with 203 additions and 157 deletions

View File

@ -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, ' ')}"

View File

@ -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__":

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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