add docstrings and type hints
This commit is contained in:
parent
8435892c57
commit
e82c5ebc59
|
@ -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, ' ')}"
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user