Initial import

This commit is contained in:
evilchili 2025-08-08 15:40:41 -07:00
parent 96fb86cb19
commit a566423e8a
34 changed files with 680 additions and 2 deletions

View File

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

@ -0,0 +1,10 @@
,,,,,,,,
m,__,,,_,
m____,,,,
m.__,,,,,
mm...,,,,
m,___.,,,,
m,,,,,,,

View File

@ -0,0 +1,5 @@
,,,,,,,
,,___,,
,,___,,
,,___,,
,,,,,,,

View File

@ -0,0 +1,20 @@
..... 1
..... ..... 2
..........D....
..... ..D..
.
.
S.........
. .
. .
..... 3 .L.... 4
..... ......
..... ......
....v.
.^.........
...........
...........
...........
........... 5

10
samples/outdoor_test.txt Normal file
View 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
View File

@ -0,0 +1,7 @@
,,,,,,,,,,,,,,
,,,........,,,
,,,.______.,,,
,,,._o____.,,,
,,,.______.,,,
,,,........,,,
,,,,,,,,,,,,,,

0
src/tilemapper/README.md Normal file
View File

View File

@ -0,0 +1 @@
from . import battlemap, tileset

View 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
View 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()

View File

@ -0,0 +1,2 @@
def random():
pass

55
src/tilemapper/grid.py Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,11 @@
[tileset]
name = "Test Tiles"
desc = "Testing edge-aware tiles"
size = 128
[legend]
" " = "background"
"," = "grass"
"_" = "water"
"." = "ground"
"m" = "mountain"

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

46
test/test_mapper.py Normal file
View 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")