cleanup; cache tile and image data
This commit refactors tileset.py to simplify the implementation and properly cache both tile data and image data. This results in a roughly 50% speedup in image generation.
This commit is contained in:
parent
78785fbea5
commit
4b02cc4c9d
|
@ -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, List
|
||||
from typing import List, Union
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from tilemapper.grid import Grid, Position
|
||||
from tilemapper.tileset import TileSet, Tile
|
||||
from tilemapper.tileset import Tile, TileSet
|
||||
|
||||
|
||||
class UnsupportedTileException(Exception):
|
||||
|
@ -28,6 +28,7 @@ class BattleMap:
|
|||
>>> bmap.render()
|
||||
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
source: Union[StringIO, Path] = None
|
||||
source_data: str = ""
|
||||
|
|
|
@ -18,8 +18,7 @@ app_state = {}
|
|||
def main(
|
||||
ctx: typer.Context,
|
||||
config_dir: Path = typer.Option(
|
||||
default=Path(__file__).parent.parent / "tilesets",
|
||||
help="The path containing tile sets to load."
|
||||
default=Path(__file__).parent.parent / "tilesets", help="The path containing tile sets to load."
|
||||
),
|
||||
verbose: bool = typer.Option(default=False, help="If True, increase verbosity of status messages."),
|
||||
):
|
||||
|
@ -59,8 +58,8 @@ def inspect(source: Path = typer.Argument(help="The battle map text file to laod
|
|||
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).")):
|
||||
tileset: str = typer.Option(help="The name of the tile set to use (run mapper list to see what's available)."),
|
||||
):
|
||||
"""
|
||||
Create a PNG image of a battle map using a tile set.
|
||||
"""
|
||||
|
@ -70,18 +69,12 @@ def render(
|
|||
|
||||
try:
|
||||
bmap = battlemap.BattleMap(name=source.name, source=source, tileset=manager.load(tileset))
|
||||
except _tileset.MissingTileException as e:
|
||||
logging.error(e)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
bmap.load()
|
||||
except battlemap.UnsupportedTileException as e:
|
||||
logging.error(e)
|
||||
sys.exit(1)
|
||||
|
||||
image = bmap.render()
|
||||
# image = image.resize((int(0.5 * image.size[0]), int(0.5 * image.size[1])))
|
||||
image.save(outfile)
|
||||
print(f"Wrote {outfile.stat().st_size} bytes to {outfile}")
|
||||
|
||||
|
|
|
@ -1,14 +1,26 @@
|
|||
from collections import namedtuple
|
||||
from typing import Union
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Union
|
||||
|
||||
# a position inside a grid.
|
||||
Position = namedtuple("Position", ["y", "x", "value"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
y: int
|
||||
x: int
|
||||
value: Any
|
||||
|
||||
def __str__(self):
|
||||
return f"posiiton-{self.y}-{self.x}-{self.value}"
|
||||
|
||||
|
||||
class Grid:
|
||||
"""
|
||||
A class wrapping a 2-d array of Position instances with some convience methods.
|
||||
"""
|
||||
|
||||
def __init__(self, data):
|
||||
self.data = []
|
||||
for y in range(len(data)):
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import logging
|
||||
import random
|
||||
import tomllib
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
|
@ -34,7 +36,11 @@ COLOR_MAP = {
|
|||
}
|
||||
|
||||
|
||||
class MissingTileException(Exception):
|
||||
class UnsupportedTileException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingImageDataException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
|
@ -44,6 +50,7 @@ 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
|
||||
|
||||
|
@ -65,9 +72,10 @@ class TileSet:
|
|||
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)
|
||||
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(
|
||||
|
@ -95,29 +103,27 @@ class TileSet:
|
|||
}
|
||||
)
|
||||
|
||||
def add(self, tiles: List[Tile]):
|
||||
def add(self, tile: Tile):
|
||||
"""
|
||||
Add a list of Tiles to the set.
|
||||
Add a Tile to the set.
|
||||
"""
|
||||
for tile in tiles:
|
||||
self.tiles[tile.name] = tile
|
||||
self.tiles[tile.name].append(tile)
|
||||
|
||||
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}"]
|
||||
except KeyError:
|
||||
raise MissingTileException(f'"{self}" does not contain a "{t}" tile.')
|
||||
if char not in self.character_map:
|
||||
raise UnsupportedTileException(f"'{char}' is not supported by the current tile set.")
|
||||
name = self.character_map[char]
|
||||
return random.choice(self.tiles[name]) if name in self.tiles else self.placeholder
|
||||
|
||||
@property
|
||||
def empty_space(self) -> Tile:
|
||||
"""
|
||||
Return the Tile instance representing empty space.
|
||||
"""
|
||||
return self.tiles[self.character_map[" "]]
|
||||
return self.get(" ")
|
||||
|
||||
def __str__(self):
|
||||
return f"[Tileset] {self.name}: {self.desc}"
|
||||
|
@ -129,6 +135,7 @@ class ColorizedTile(Tile):
|
|||
A variant of the base Tile type that supports colorized console output
|
||||
using ANSI color codes.
|
||||
"""
|
||||
|
||||
color: str
|
||||
|
||||
def __str__(self):
|
||||
|
@ -139,6 +146,7 @@ class ColorizedTileSet(TileSet):
|
|||
"""
|
||||
A variant of the base TileSet type that uses ColorizedTiles.
|
||||
"""
|
||||
|
||||
tiles: Dict[str, ColorizedTile] = {}
|
||||
|
||||
|
||||
|
@ -150,7 +158,9 @@ class ImageTile(Tile):
|
|||
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] = []
|
||||
paths: List[Path] = field(default_factory=list)
|
||||
buffer: Image = None
|
||||
|
||||
@property
|
||||
|
@ -281,59 +291,49 @@ class ImageTileSet(TileSet):
|
|||
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 = {}
|
||||
_tile_cache = {} # ImageTile data
|
||||
_image_cache = {} # rendered image data
|
||||
|
||||
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}_*.*")):
|
||||
@cached_property
|
||||
def paths(self) -> Dict[str, List[Path]]:
|
||||
paths = defaultdict(list)
|
||||
for imgfile in sorted(self.image_dir.glob("*.png")):
|
||||
(terrain_name, *parts) = imgfile.stem.rsplit("_")
|
||||
key = terrain_name
|
||||
if parts[0] in ("edge", "corner"):
|
||||
key = "_".join([terrain_name, *parts[:-1]])
|
||||
if not lastkey:
|
||||
lastkey = key
|
||||
if lastkey != key:
|
||||
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)])
|
||||
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 characeter_map and load the images associated with it each
|
||||
terrain type.
|
||||
Walk the character_map and load the images associated with each terrain type.
|
||||
"""
|
||||
self.tiles = {}
|
||||
self.tiles = defaultdict(list)
|
||||
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]) -> 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
|
||||
if not paths:
|
||||
buffer = Image.new("RGBA", (self.tile_size, self.tile_size))
|
||||
ImageDraw.Draw(buffer).text((3, 3), name, (255, 255, 255))
|
||||
self._cache[name] = ImageTile(char=char, name=name, paths=paths, buffer=buffer)
|
||||
return self._cache[name]
|
||||
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))
|
||||
self._tile_cache[key] = tile
|
||||
self.add(tile)
|
||||
for name in self.paths:
|
||||
if name not in self.character_map.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:
|
||||
"""
|
||||
|
@ -364,9 +364,12 @@ class ImageTileSet(TileSet):
|
|||
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)]
|
||||
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)]
|
||||
)
|
||||
return self._image_cache[key]
|
||||
|
||||
|
||||
class TileSetManager:
|
||||
|
@ -386,6 +389,7 @@ class TileSetManager:
|
|||
...
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Path):
|
||||
self.config_dir = config_dir
|
||||
self._available = {}
|
||||
|
@ -426,14 +430,13 @@ class TileSetManager:
|
|||
@cached_property
|
||||
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()])
|
||||
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.")
|
||||
ts.add([
|
||||
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():
|
||||
ts.add(ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey")))
|
||||
return ts
|
||||
|
|
Loading…
Reference in New Issue
Block a user