Initial import
12
README.md
|
@ -1,3 +1,11 @@
|
||||||
# tilemapper
|
# TileMapper
|
||||||
|
|
||||||
A TTRPG battle map generator using custom tile sets.
|
A TTRPG battle map generator using custom tile sets.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
WIP
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
WIP
|
||||||
|
|
51
pyproject.toml
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = "tilemapper"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = ["evilchili <evilchili@gmail.com>"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.11"
|
||||||
|
typer = "^0.16.0"
|
||||||
|
pillow = "^11.3.0"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
pytest = "^8.4.1"
|
||||||
|
pytest-cov = "^6.2.1"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
|
||||||
|
### SLAM
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
target-version = ['py310']
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
multi_line_output = 3
|
||||||
|
line_length = 120
|
||||||
|
include_trailing_comma = true
|
||||||
|
|
||||||
|
[tool.autoflake]
|
||||||
|
check = false # return error code if changes are needed
|
||||||
|
in-place = true # make changes to files instead of printing diffs
|
||||||
|
recursive = true # drill down directories recursively
|
||||||
|
remove-all-unused-imports = true # remove all unused imports (not just those from the standard library)
|
||||||
|
ignore-init-module-imports = true # exclude __init__.py when removing unused imports
|
||||||
|
remove-duplicate-keys = true # remove all duplicate keys in objects
|
||||||
|
remove-unused-variables = true # remove unused variables
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
log_cli_level = "DEBUG"
|
||||||
|
addopts = "--cov=src --cov-report=term-missing"
|
||||||
|
|
||||||
|
### ENDSLAM
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry.scripts]
|
||||||
|
mapper = "tilemapper.cli:app"
|
10
samples/edge_test.txt
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
,,,,,,,,
|
||||||
|
m,__,,,_,
|
||||||
|
m____,,,,
|
||||||
|
m.__,,,,,
|
||||||
|
mm...,,,,
|
||||||
|
m,___.,,,,
|
||||||
|
m,,,,,,,
|
||||||
|
|
||||||
|
|
||||||
|
|
5
samples/edge_test_small.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
,,,,,,,
|
||||||
|
,,___,,
|
||||||
|
,,___,,
|
||||||
|
,,___,,
|
||||||
|
,,,,,,,
|
20
samples/five_room_dungeon.txt
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
..... 1
|
||||||
|
..... ..... 2
|
||||||
|
..........D....
|
||||||
|
..... ..D..
|
||||||
|
.
|
||||||
|
.
|
||||||
|
S.........
|
||||||
|
. .
|
||||||
|
. .
|
||||||
|
..... 3 .L.... 4
|
||||||
|
..... ......
|
||||||
|
..... ......
|
||||||
|
....v.
|
||||||
|
|
||||||
|
.^.........
|
||||||
|
...........
|
||||||
|
...........
|
||||||
|
...........
|
||||||
|
........... 5
|
||||||
|
|
10
samples/outdoor_test.txt
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
,,,,TTTTT
|
||||||
|
,,,.....TTT
|
||||||
|
,,,.....TTTTT
|
||||||
|
,,.......TTT
|
||||||
|
,,........TT
|
||||||
|
MM._________..M
|
||||||
|
MM.________..MM
|
||||||
|
MM._____o_____..M
|
||||||
|
M__o___________.M
|
||||||
|
|
7
samples/test.txt
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
,,,,,,,,,,,,,,
|
||||||
|
,,,........,,,
|
||||||
|
,,,.______.,,,
|
||||||
|
,,,._o____.,,,
|
||||||
|
,,,.______.,,,
|
||||||
|
,,,........,,,
|
||||||
|
,,,,,,,,,,,,,,
|
0
src/tilemapper/README.md
Normal file
1
src/tilemapper/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from . import battlemap, tileset
|
94
src/tilemapper/battlemap.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
# import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import cached_property
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from textwrap import indent
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from tilemapper.grid import Grid, Position
|
||||||
|
from tilemapper.tileset import TileSet
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedTileException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BattleMap:
|
||||||
|
name: str = ""
|
||||||
|
source: Union[StringIO, Path] = None
|
||||||
|
source_data: str = ""
|
||||||
|
tileset: TileSet = None
|
||||||
|
width: int = 0
|
||||||
|
height: int = 0
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
try:
|
||||||
|
data = self.source.read_bytes().decode()
|
||||||
|
except AttributeError:
|
||||||
|
data = self.source.read()
|
||||||
|
data = data.strip("\n")
|
||||||
|
if not self.validate_source_data(data):
|
||||||
|
return
|
||||||
|
self.source_data = data
|
||||||
|
if hasattr(self, "grid"):
|
||||||
|
del self.grid
|
||||||
|
return self.grid
|
||||||
|
|
||||||
|
def validate_source_data(self, data: str):
|
||||||
|
for char in set(list(data)):
|
||||||
|
if char == "\n":
|
||||||
|
continue
|
||||||
|
if char not in self.tileset.character_map:
|
||||||
|
raise UnsupportedTileException(f"The current tileset does not support the '{char}' character.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
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, empty_space)
|
||||||
|
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
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def grid(self):
|
||||||
|
matrix = []
|
||||||
|
for line in self.source_data.splitlines():
|
||||||
|
matrix.append([self.tileset.get(char) for char in line])
|
||||||
|
self.width = max([len(line) for line in matrix])
|
||||||
|
self.height = len(matrix)
|
||||||
|
return Grid(data=matrix)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self):
|
||||||
|
return f"BattleMap: {self.name} ({self.width} x {self.height}, {self.width * self.height * 5}sq.ft.)"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def legend(self):
|
||||||
|
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):
|
||||||
|
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")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"\n{self.title}\n\n{indent(str(self), ' ')}\n\nLegend:\n{indent(self.legend, ' ')}"
|
74
src/tilemapper/cli.py
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
|
from tilemapper import battlemap
|
||||||
|
from tilemapper import tileset as _tileset
|
||||||
|
|
||||||
|
app = typer.Typer()
|
||||||
|
app_state = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback(invoke_without_command=True)
|
||||||
|
def main(
|
||||||
|
ctx: typer.Context,
|
||||||
|
config_dir: Path = Path(__file__).parent.parent / "tilesets",
|
||||||
|
verbose: bool = typer.Option(default=False, help="If True, increase verbosity of status messages."),
|
||||||
|
):
|
||||||
|
app_state["tileset_manager"] = _tileset.TileSetManager(config_dir)
|
||||||
|
app_state["config_dir"] = config_dir
|
||||||
|
app_state["verbose"] = verbose
|
||||||
|
debug = os.getenv("DEBUG", None)
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(name)s %(message)s",
|
||||||
|
level=logging.DEBUG if debug else logging.INFO,
|
||||||
|
handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def list():
|
||||||
|
manager = app_state["tileset_manager"]
|
||||||
|
print("\n".join(manager.available))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def inspect(source: Path):
|
||||||
|
manager = app_state["tileset_manager"]
|
||||||
|
bmap = battlemap.BattleMap(name=source.name, source=source, tileset=manager.console_map)
|
||||||
|
bmap.load()
|
||||||
|
console = Console()
|
||||||
|
console.print(repr(bmap), highlight=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def render(source: Path, outfile: Path, tileset: str = typer.Option(help="The tile set to use.")):
|
||||||
|
manager = app_state["tileset_manager"]
|
||||||
|
if tileset not in manager.available:
|
||||||
|
raise RuntimeError(f"Could not locate the tile set {tileset} in {manager.config_dir}.")
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
2
src/tilemapper/generator.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
def random():
|
||||||
|
pass
|
55
src/tilemapper/grid.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
Position = namedtuple("GridPosition", ["y", "x", "value"])
|
||||||
|
|
||||||
|
|
||||||
|
class Grid:
|
||||||
|
def __init__(self, data):
|
||||||
|
self.data = []
|
||||||
|
for y in range(len(data)):
|
||||||
|
row = []
|
||||||
|
for x in range(len(data[y])):
|
||||||
|
row.append(Position(y=y, x=x, value=data[y][x]))
|
||||||
|
self.data.append(row)
|
||||||
|
|
||||||
|
def at(self, y, x, default=None):
|
||||||
|
try:
|
||||||
|
return self.data[y][x]
|
||||||
|
except IndexError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def north(self, position: Position):
|
||||||
|
return self.at(position.y - 1, position.x)
|
||||||
|
|
||||||
|
def east(self, position: Position):
|
||||||
|
return self.at(position.y, position.x + 1)
|
||||||
|
|
||||||
|
def south(self, position: Position):
|
||||||
|
return self.at(position.y + 1, position.x)
|
||||||
|
|
||||||
|
def west(self, position: Position):
|
||||||
|
return self.at(position.y, position.x - 1)
|
||||||
|
|
||||||
|
def northwest(self, position: Position):
|
||||||
|
return self.at(position.y - 1, position.x - 1)
|
||||||
|
|
||||||
|
def northeast(self, position: Position):
|
||||||
|
return self.at(position.y - 1, position.x + 1)
|
||||||
|
|
||||||
|
def southwest(self, position: Position):
|
||||||
|
return self.at(position.y + 1, position.x - 1)
|
||||||
|
|
||||||
|
def southeast(self, position: Position):
|
||||||
|
return self.at(position.y + 1, position.x + 1)
|
||||||
|
|
||||||
|
def adjacent(self, position: Position):
|
||||||
|
return (
|
||||||
|
self.northwest(position),
|
||||||
|
self.north(position),
|
||||||
|
self.northeast(position),
|
||||||
|
self.east(position),
|
||||||
|
self.southeast(position),
|
||||||
|
self.south(position),
|
||||||
|
self.southwest(position),
|
||||||
|
self.west(position),
|
||||||
|
)
|
284
src/tilemapper/tileset.py
Normal file
|
@ -0,0 +1,284 @@
|
||||||
|
import random
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import cached_property
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from tilemapper.grid import 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MissingTileException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class Tile:
|
||||||
|
name: str
|
||||||
|
char: str
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def terrain_name(self):
|
||||||
|
return self.name.split("_", 1)[0]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.char
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True)
|
||||||
|
class TileSet:
|
||||||
|
name: str
|
||||||
|
desc: str = ""
|
||||||
|
tiles: Dict[str, Tile] = field(default_factory=dict)
|
||||||
|
character_map: Dict[str, str] = field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
" ": "empty",
|
||||||
|
".": "ground",
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def add(self, *tiles: Tile):
|
||||||
|
for tile in tiles:
|
||||||
|
self.tiles[tile.name] = tile
|
||||||
|
|
||||||
|
def get(self, char: str):
|
||||||
|
t = self.character_map[char]
|
||||||
|
try:
|
||||||
|
return self.tiles[f"{t}"]
|
||||||
|
except KeyError:
|
||||||
|
raise MissingTileException(f'"{self}" does not contain a "{t}" tile.')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def empty_space(self):
|
||||||
|
return self.tiles[self.character_map[" "]]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"[Tileset] {self.name}: {self.desc}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ColorizedTile(Tile):
|
||||||
|
color: str
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"[{self.color}]{self.char}[/{self.color}]"
|
||||||
|
|
||||||
|
|
||||||
|
class ColorizedTileSet(TileSet):
|
||||||
|
tiles: Dict[str, ColorizedTile] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImageTile(Tile):
|
||||||
|
paths: List[Path]
|
||||||
|
buffer: Image = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image(self):
|
||||||
|
if self.buffer:
|
||||||
|
return self.buffer
|
||||||
|
return Image.open(random.choice(self.paths))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self):
|
||||||
|
return self.image.size[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self):
|
||||||
|
return self.image.size[1]
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
size: int = 0,
|
||||||
|
nw: Tile = None,
|
||||||
|
n: Tile = None,
|
||||||
|
ne: Tile = None,
|
||||||
|
e: Tile = None,
|
||||||
|
se: Tile = None,
|
||||||
|
s: Tile = None,
|
||||||
|
sw: Tile = None,
|
||||||
|
w: Tile = None,
|
||||||
|
):
|
||||||
|
rendered = self.image.copy()
|
||||||
|
|
||||||
|
if nw:
|
||||||
|
rendered.paste(nw.image, (0, 0))
|
||||||
|
if n:
|
||||||
|
rendered.paste(n.image, (32, 0))
|
||||||
|
rendered.paste(n.image, (64, 0))
|
||||||
|
if ne:
|
||||||
|
rendered.paste(ne.image, (96, 0))
|
||||||
|
if e:
|
||||||
|
rendered.paste(e.image, (96, 32))
|
||||||
|
rendered.paste(e.image, (96, 64))
|
||||||
|
if se:
|
||||||
|
rendered.paste(se.image, (96, 96))
|
||||||
|
if s:
|
||||||
|
rendered.paste(s.image, (32, 96))
|
||||||
|
rendered.paste(s.image, (64, 96))
|
||||||
|
if sw:
|
||||||
|
rendered.paste(sw.image, (0, 96))
|
||||||
|
if w:
|
||||||
|
rendered.paste(w.image, (0, 32))
|
||||||
|
rendered.paste(w.image, (0, 64))
|
||||||
|
|
||||||
|
if size == rendered.width and size == rendered.height:
|
||||||
|
return rendered
|
||||||
|
return rendered.resize((size, size))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImageTileSet(TileSet):
|
||||||
|
image_dir: Path
|
||||||
|
tile_size: int = 128
|
||||||
|
|
||||||
|
_cache = {}
|
||||||
|
|
||||||
|
def _load_images(self, char: str, name: str):
|
||||||
|
paths = []
|
||||||
|
lastkey = None
|
||||||
|
key = None
|
||||||
|
for imgfile in sorted(self.image_dir.glob(f"{name}_*.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)])
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
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]):
|
||||||
|
if name in self._cache:
|
||||||
|
return self._cache[name]
|
||||||
|
buffer = None
|
||||||
|
if not paths:
|
||||||
|
buffer = Image.new("RGB", (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]
|
||||||
|
|
||||||
|
def _get_overlays(self, position: Position, adjacent: List[Position] = []):
|
||||||
|
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
|
||||||
|
]
|
||||||
|
n = f"{terrain}_edge_{n_terrain}_n" if n_terrain != terrain else None
|
||||||
|
s = f"{terrain}_edge_{s_terrain}_s" if s_terrain != terrain else None
|
||||||
|
e = f"{terrain}_edge_{e_terrain}_e" if e_terrain != terrain else None
|
||||||
|
w = f"{terrain}_edge_{w_terrain}_w" if w_terrain != terrain else None
|
||||||
|
nw = f"{n}w" if n_terrain != terrain and w_terrain == n_terrain else n
|
||||||
|
nw = f"{terrain}_corner_nw" if n_terrain == terrain and nw_terrain != terrain else nw or w
|
||||||
|
sw = f"{s}w" if s_terrain != terrain and w_terrain == s_terrain else s
|
||||||
|
sw = f"{terrain}_corner_sw" if s_terrain == terrain and sw_terrain != terrain else sw or w
|
||||||
|
ne = f"{n}e" if n_terrain != terrain and e_terrain == n_terrain else n
|
||||||
|
ne = f"{terrain}_corner_ne" if n_terrain == terrain and ne_terrain != terrain else ne or e
|
||||||
|
se = f"{s}e" if s_terrain != terrain and e_terrain == s_terrain else s
|
||||||
|
se = f"{terrain}_corner_se" if s_terrain == terrain and se_terrain != terrain else se or e
|
||||||
|
|
||||||
|
return (nw, n, ne, e, se, s, sw, w)
|
||||||
|
|
||||||
|
def render_tile(self, position, adjacent):
|
||||||
|
return position.value.render(
|
||||||
|
self.tile_size, *[self._cache.get(overlay) for overlay in self._get_overlays(position, adjacent)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TileSetManager:
|
||||||
|
def __init__(self, config_dir: Path):
|
||||||
|
self.config_dir = config_dir
|
||||||
|
self._available = {}
|
||||||
|
self._load_tilesets()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
return {"console": self.console_map, "colorized": self.console_map_colorized, **self._available}
|
||||||
|
|
||||||
|
def _load_tilesets(self):
|
||||||
|
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):
|
||||||
|
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.load()
|
||||||
|
return tileset
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def console_map(self):
|
||||||
|
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()])
|
||||||
|
return ts
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def console_map_colorized(self):
|
||||||
|
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()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return ts
|
BIN
src/tilesets/test/background_1.png
Normal file
After Width: | Height: | Size: 89 B |
BIN
src/tilesets/test/grass_1.png
Normal file
After Width: | Height: | Size: 415 B |
BIN
src/tilesets/test/grass_2.png
Normal file
After Width: | Height: | Size: 415 B |
BIN
src/tilesets/test/ground_1.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
src/tilesets/test/mountain_1.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
11
src/tilesets/test/tileset.toml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
[tileset]
|
||||||
|
name = "Test Tiles"
|
||||||
|
desc = "Testing edge-aware tiles"
|
||||||
|
size = 128
|
||||||
|
|
||||||
|
[legend]
|
||||||
|
" " = "background"
|
||||||
|
"," = "grass"
|
||||||
|
"_" = "water"
|
||||||
|
"." = "ground"
|
||||||
|
"m" = "mountain"
|
BIN
src/tilesets/test/water_1.png
Normal file
After Width: | Height: | Size: 415 B |
BIN
src/tilesets/test/water_corner_ne_1.png
Normal file
After Width: | Height: | Size: 243 B |
BIN
src/tilesets/test/water_corner_nw_1.png
Normal file
After Width: | Height: | Size: 240 B |
BIN
src/tilesets/test/water_corner_se_1.png
Normal file
After Width: | Height: | Size: 227 B |
BIN
src/tilesets/test/water_corner_sw_1.png
Normal file
After Width: | Height: | Size: 237 B |
BIN
src/tilesets/test/water_edge_grass_e_1.png
Normal file
After Width: | Height: | Size: 738 B |
BIN
src/tilesets/test/water_edge_grass_n_1.png
Normal file
After Width: | Height: | Size: 755 B |
BIN
src/tilesets/test/water_edge_grass_ne_1.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
src/tilesets/test/water_edge_grass_nw_1.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
src/tilesets/test/water_edge_grass_s_1.png
Normal file
After Width: | Height: | Size: 742 B |
BIN
src/tilesets/test/water_edge_grass_se_1.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
src/tilesets/test/water_edge_grass_sw_1.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
src/tilesets/test/water_edge_grass_w_1.png
Normal file
After Width: | Height: | Size: 765 B |
46
test/test_mapper.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tilemapper import battlemap, tileset
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager():
|
||||||
|
return tileset.TileSetManager(Path(__file__).parent / "fixtures")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_map():
|
||||||
|
return dedent(
|
||||||
|
"""
|
||||||
|
........ 1
|
||||||
|
........ ........ 2
|
||||||
|
........ ........
|
||||||
|
.......L...D... D
|
||||||
|
........ .... ...
|
||||||
|
........ ...d ...
|
||||||
|
.
|
||||||
|
.........S. 3
|
||||||
|
5 . .
|
||||||
|
....S... ...d.... 4
|
||||||
|
........ ........
|
||||||
|
........ ........
|
||||||
|
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tileset_loader(manager):
|
||||||
|
assert manager.console_map.name 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.load()
|
||||||
|
assert test_map.width == 21
|
||||||
|
assert test_map.height == 12
|
||||||
|
assert test_map.source_data == sample_map.strip("\n")
|
||||||
|
assert str(test_map) == sample_map.strip("\n")
|