Compare commits

..
5 Commits
Author SHA1 Message Date
evilchili ae3d40e91e Added CLI options for controlling text output 2025-08-17 19:29:09 -07:00
evilchili 2cddc902ab typo 2025-08-17 18:01:48 -07:00
evilchili b03f4c1193 Source data is now in toml format
The input text is now expected to be a toml file with a leading comment
section defining the battle map, according to the following rules:

1. Blank lines are ignored
2. Lines at the top of the file beginning with '#', optionally with
   leading spaces, is treated as map data
3. Map data continues until the first line not starting with a #.
4. Any subsequent lines are treated as metadata in toml format.
5. The metadata must contain the [map] section.
6. The [map] section must contain the "name" attribute.

The metadata is available as BattleMap.metadata; the map source is
available as BattleMap.map_string.

New convenience properties ahve been added to the BattleMap class for
accessing portions of the text-only output. This includes the header and
footer attributes.
2025-08-17 17:56:14 -07:00
evilchili ee8c2af2d8 Add coordinate axes to string rendering 2025-08-17 16:58:17 -07:00
evilchili cc35a60210 bug in legend 2025-08-17 14:43:07 -07:00
9 changed files with 292 additions and 75 deletions
+41
View File
@@ -0,0 +1,41 @@
# ..... 1
# ..... ..... 2
# ..........D....
# ..... ..D..
# .
# .
# S.........
# . .
# . .
# ..... 3 .L.... 4
# ..... ......
# ..... ......
# ....v.
#
# .^.........
# ...,,,,,...
# ...,___,...
# ...,,,,,...
# ........... 5
#
[map]
name = "Five Room Dungeon"
description = "An example dungeon"
author = "evilchili <hello@evilchi.li>"
license = "CC0"
[1]
description = "First Room"
[2]
description = "Second Room"
[3]
description = "Third Room"
[4]
description = "Forth Room"
[5]
description = "Fifth Room"
-20
View File
@@ -1,20 +0,0 @@
..... 1
..... ..... 2
..........D....
..... ..D..
.
.
S.........
. .
. .
..... 3 .L.... 4
..... ......
..... ......
....v.
.^.........
...,,,,,...
...,___,...
...,,,,,...
........... 5
+136 -22
View File
@@ -1,9 +1,11 @@
# import logging # import logging
import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property 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 types import SimpleNamespace
from typing import List, Union, get_type_hints from typing import List, Union, get_type_hints
from PIL import Image from PIL import Image
@@ -28,15 +30,22 @@ class BattleMap(BattleMapType):
Example Usage: Example Usage:
>>> console = TileSetManager().load("colorized") >>> console = TileSetManager().load("colorized")
>>> bmap = BattleMap(name="Example Map", source="input.txt", tileset=console) >>> bmap = BattleMap("input.toml", tileset=console)
>>> print(bmap.as_string()) >>> print(bmap.as_string())
""" """
name: str = ""
source: Union[StringIO, Path] = None source: Union[StringIO, Path] = None
source_data: str = "" map_string: str = ""
metadata: SimpleNamespace = None
tileset: TileSet = None tileset: TileSet = None
render_header: bool = True
render_border: bool = True
render_coordinates: bool = True
render_legend: bool = True
render_notes: bool = True
width: int = 0 width: int = 0
height: int = 0 height: int = 0
@@ -51,16 +60,39 @@ class BattleMap(BattleMapType):
except AttributeError: except AttributeError:
data = self.source.read() data = self.source.read()
data = data.strip("\n") data = data.strip("\n")
if not self.validate_source_data(data):
return map_data = ""
self.source_data = data metadata = ""
in_map = True
for line in data.splitlines():
line = line.lstrip(" ")
if in_map:
if line and line[0] == "#":
map_data += line[1:].lstrip() + "\n"
continue
in_map = False
metadata += line + "\n"
self.validate_map_string(map_data)
self.map_string = map_data
self.metadata = SimpleNamespace(**tomllib.loads(metadata))
# invalidate the cache # invalidate the cache
if hasattr(self, "grid"): if hasattr(self, "grid"):
del self.grid del self.grid
if hasattr(self, "header"):
del self.header
if hasattr(self, "footer"):
del self.footer
if hasattr(self, "legend"):
del self.legend
if hasattr(self, "coordinates"):
del self.coordinates
if hasattr(self, "notes"):
del self.notes
return self.grid return self.grid
def validate_source_data(self, data: str) -> bool: def validate_map_string(self, data: str) -> bool:
""" """
Return True if every terrain type in the input data has a corresponding tile in the current tile set. Return True if every terrain type in the input data has a corresponding tile in the current tile set.
""" """
@@ -81,7 +113,7 @@ class BattleMap(BattleMapType):
Returns an Image instance. Returns an Image instance.
""" """
if get_type_hints(self.tileset.render_grid)["return"] != Image: if get_type_hints(self.tileset.render_grid)["return"] != Image:
raise NotImplementedError(f"Tile set does not support image rendering.") raise NotImplementedError("Tile set does not support image rendering.")
return self.tileset.render_grid(grid=self.grid, width=self.width, height=self.height) return self.tileset.render_grid(grid=self.grid, width=self.width, height=self.height)
def as_string(self) -> str: def as_string(self) -> str:
@@ -95,38 +127,120 @@ class BattleMap(BattleMapType):
Return the current map's Grid instance. Return the current map's Grid instance.
""" """
matrix = [] matrix = []
for line in self.source_data.splitlines(): for line in self.map_string.splitlines():
matrix.append([self.tileset.get(char) for char in line]) matrix.append([self.tileset.get(char) for char in line])
self.width = max([len(line) for line in matrix]) self.width = max([len(line) for line in matrix])
self.height = len(matrix) self.height = len(matrix)
return Grid(data=matrix) return Grid(data=matrix)
@property @cached_property
def title(self) -> str: def header(self) -> str:
return f"BattleMap: {self.name} ({self.width} x {self.height}, {self.width * self.height * 5}sq.ft.)" lines = ["", f"BattleMap: {self.metadata.map['name']} ({self.width}x{self.height})"]
lines.append(("-" * len(lines[0])))
for key, value in self.metadata.map.items():
if key in ("name", "description"):
continue
lines.append(f"{key.title()}: {value}")
lines.append("")
return "\n".join(lines)
@property @cached_property
def footer(self) -> str:
lines = []
if "description" in self.metadata.map:
lines.append(f"{self.metadata.map['description']}")
lines.append("")
lines.append("Legend:")
lines.append(self.legend)
return "\n".join(lines)
@cached_property
def notes(self) -> str:
lines = []
for number in dir(self.metadata):
if not number.isdigit():
continue
location = getattr(self.metadata, number)
lines.append(f"[{number}]{location.get('name', '')}")
if hasattr(location, "notes"):
for note in location.notes:
lines.append(f" {note}")
lines.append("")
return "\n".join(lines)
@cached_property
def legend(self) -> str: def legend(self) -> str:
output = "" output = ""
locations = 0 locations = False
for char in sorted(set(list(self.source_data)), key=str.lower): for char in sorted(set(list(self.map_string)), key=str.lower):
if char in self.tileset.config.legend: if char in self.tileset.config.legend:
if char in '0123456789': if char.isdigit():
locations = max(locations, int(char)) locations = True
continue
output += f"{char} - {self.tileset.config.legend[char]}\n" output += f"{char} - {self.tileset.config.legend[char]}\n"
if locations: if locations:
location_key = "1" if locations == 1 else f"1-{locations}" output += "0-9 - locations"
output += f"{location_key} - location"
width = len(location_key)
justified = "" justified = ""
for line in output.splitlines(): for line in output.splitlines():
(key, sep, desc) = line.partition(" - ") (key, sep, desc) = line.partition(" - ")
justified += f"{key.rjust(width, ' ')}{sep}{desc}\n" justified += f"{key.rjust(3, ' ')}{sep}{desc}\n"
output = "".join(justified) output = "".join(justified)
return output return output
@cached_property
def top_coordinates(self) -> str:
row = next(row for row in self.grid.data if len(row) == self.width)
max_len = max([len(cell.x_coordinate) for cell in row])
lines = list(("" for i in range(max_len)))
for pos in row:
for i in range(len(pos.x_coordinate)):
lines[max_len - i - 1] += pos.x_coordinate[i]
return "\n".join((line.rjust(len(row), " ") for line in lines))
@cached_property
def left_coordinates(self) -> List[str]:
return [row[0].y_coordinate if row else "" for row in self.grid.data]
def __str__(self) -> str: def __str__(self) -> str:
return self.as_string() return self.as_string()
def __repr__(self) -> str: def __repr__(self) -> str:
return f"\n{self.title}\n\n{indent(str(self), ' ')}\n\nLegend:\n{indent(self.legend, ' ')}" lines = [""]
map_lines = ""
if self.render_coordinates:
left_coords = self.left_coordinates
i = 0
border = "" if self.render_border else " "
for line in str(self).splitlines():
map_lines += f"{left_coords[i].rjust(2, ' ')}{border}{line}".ljust(self.width, " ") + f"{border}\n"
i = i + 1
elif self.render_border:
for line in str(self).splitlines():
map_lines += f"{line}".ljust(self.width, " ") + "\n"
else:
map_lines = str(self)
top_break = bot_break = ""
if self.render_border:
top_break = "" + ("" * self.width) + ""
bot_break = "" + ("" * self.width) + ""
lines = [self.header] if self.render_header else []
if self.render_coordinates:
lines.append(indent(self.top_coordinates, " " * 3))
if top_break:
lines.append(indent(top_break, " " * 2) if self.render_coordinates else top_break)
lines.append(map_lines.rstrip())
if bot_break:
lines.append(indent(bot_break, " " * 2) if self.render_coordinates else bot_break)
lines.append("")
if self.render_legend:
lines.append(indent(self.footer, " "))
lines.append("")
if self.render_notes:
lines.append(self.notes)
return indent("\n".join(lines), " ")
+33 -4
View File
@@ -54,31 +54,60 @@ def convert(source: typer.FileText = typer.Argument(help="The donjon.sh .json fi
@app.command() @app.command()
def render( def render(
source: typer.FileText = typer.Argument( source: typer.FileText = typer.Argument(
help="The battle map text file to load.", default=INSTALL_DIR / "examples" / "five_room_dungeon.txt" help="The battle map text file to load.", default=INSTALL_DIR / "examples" / "five_room_dungeon.toml"
), ),
outfile: Path = typer.Option(help="The file to create. If not specified, print to STDOUT", default=None), outfile: Path = typer.Option(help="The file to create. If not specified, print to STDOUT", default=None),
tileset: str = typer.Option( tileset: str = typer.Option(
help="The name of the tile set to use (run mapper list to see what's available).", default="colorized" help="The name of the tile set to use (run mapper list to see what's available).", default="colorized"
), ),
header: bool = typer.Option(help="If True, include the map header.", default=True),
border: bool = typer.Option(help="If True, draw the map border.", default=True),
coordinates: bool = typer.Option(help="If True, draw the coordinate outside the border.", default=True),
legend: bool = typer.Option(help="If True, include the legend.", default=True),
notes: bool = typer.Option(help="If True, include the notes.", default=True),
map_only: bool = typer.Option(
help="Equivalent to --no-header --no-border --no-coordinates --no-legend --no-notes", default=False
),
): ):
""" """
Create a rendered battle map using a tile set. Will generate a PNG file if the tile set supports it, Create a rendered battle map using a tile set. Will generate a PNG file if the tile set supports it,
otherwise text output. otherwise text output.
""" """
render_map(source, outfile, tileset) render_map(
source=source,
outfile=outfile,
tileset=tileset,
header=header and not map_only,
border=border and not map_only,
coordinates=coordinates and not map_only,
legend=legend and not map_only,
notes=notes and not map_only,
)
def render_map( def render_map(
source: typer.FileText = INSTALL_DIR / "examples" / "five_room_dungeon.txt", source: typer.FileText = INSTALL_DIR / "examples" / "five_room_dungeon.txt",
outfile: Union[Path, None] = None, outfile: Union[Path, None] = None,
tileset: str = 'colorized' tileset: str = "colorized",
header: bool = True,
border: bool = True,
coordinates: bool = True,
legend: bool = True,
notes: bool = True,
): ):
manager = app_state["tileset_manager"] manager = app_state["tileset_manager"]
if tileset not in manager.available: if tileset not in manager.available:
raise RuntimeError(f"Could not locate the tile set {tileset} in {manager.config_dir}.") raise RuntimeError(f"Could not locate the tile set {tileset} in {manager.config_dir}.")
render_options = {
"render_header": header,
"render_border": border,
"render_coordinates": coordinates,
"render_notes": notes,
"render_legend": legend,
}
try: try:
bmap = battlemap.BattleMap(name=source.name, source=source, tileset=manager.load(tileset)) bmap = battlemap.BattleMap(source=source, tileset=manager.load(tileset), **render_options)
bmap.load() bmap.load()
except battlemap.UnsupportedTileException as e: except battlemap.UnsupportedTileException as e:
logging.error(e) logging.error(e)
+6 -11
View File
@@ -1,22 +1,18 @@
from types import SimpleNamespace
from collections import defaultdict
import json import json
from types import SimpleNamespace
terrain_map = { terrain_map = {
"0": " ", # nothing "0": " ", # nothing
"16": " ", # perimeter "16": " ", # perimeter
"4": ".", "4": ".",
"4194308": "v", # stair_down "4194308": "v", # stair_down
"8388612": "^", # stair_up "8388612": "^", # stair_up
"13107": "d", # door "13107": "d", # door
"26214": "L", # door, locked "26214": "L", # door, locked
"52429": "T", # door, trapped "52429": "T", # door, trapped
"10485": "S", # door, secret "10485": "S", # door, secret
"65540": "A", # arch "65540": "A", # arch
"20971": "H", # portcullis "20971": "H", # portcullis
"82208": "1", "82208": "1",
"83886": "2", "83886": "2",
"8556": "3", "8556": "3",
@@ -27,7 +23,6 @@ terrain_map = {
"93952": "8", "93952": "8",
"95630": "9", "95630": "9",
"80530": "0", "80530": "0",
} }
@@ -39,12 +34,12 @@ def convert_donjon(source: str) -> str:
src = SimpleNamespace(**json.loads(source)) src = SimpleNamespace(**json.loads(source))
textmap = "" textmap = ""
for y in range(len(src.cells)): for y in range(1, len(src.cells) - 1):
row = src.cells[y] row = src.cells[y]
for x in range(len(row)): for x in range(1, len(row) - 1):
char = get_char(str(row[x]), default=".") char = get_char(str(row[x]), default=".")
if not char: if not char:
raise Exception(f"{textmap}\nMissing value {row[x]} at ({y}, {x})") raise Exception(f"{textmap}\nMissing value {row[x]} at ({y}, {x})")
textmap += char textmap += char
textmap += "\n" textmap += "\n"
return textmap return textmap.rstrip()
+17 -3
View File
@@ -1,9 +1,9 @@
from collections import namedtuple import string
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Union from typing import Any, Union
# a position inside a grid. Y_COORDS = string.digits
Position = namedtuple("Position", ["y", "x", "value"]) X_COORDS = list(string.ascii_uppercase + string.ascii_lowercase)
@dataclass @dataclass
@@ -12,6 +12,20 @@ class Position:
x: int x: int
value: Any value: Any
@property
def coordinates(self) -> str:
return (self.y_coordinate, self.x_coordinate)
@property
def y_coordinate(self) -> str:
max_coord = len(Y_COORDS)
return str((int(self.y / max_coord) * max_coord) + (self.y % max_coord))
@property
def x_coordinate(self) -> str:
max_coord = len(X_COORDS)
return (int(self.x / max_coord) * X_COORDS[0]) + X_COORDS[(self.x % max_coord)]
def __str__(self): def __str__(self):
return f"posiiton-{self.y}-{self.x}-{self.value}" return f"posiiton-{self.y}-{self.x}-{self.value}"
+2
View File
@@ -96,6 +96,8 @@ class TileSet:
continue continue
if pos and pos.value: if pos and pos.value:
output += pos.value.render() output += pos.value.render()
else:
output += self.empty_space.render()
output += "\n" output += "\n"
return output.rstrip("\n") return output.rstrip("\n")
+4
View File
@@ -8,10 +8,13 @@ class = "tilemapper.tileset.TileSet"
"." = "ground" "." = "ground"
"," = "grass" "," = "grass"
"_" = "water" "_" = "water"
"A" = "archway"
"H" = "portcullis"
"d" = "door, open" "d" = "door, open"
"D" = "door, closed" "D" = "door, closed"
"L" = "door, locked" "L" = "door, locked"
"S" = "door, secret" "S" = "door, secret"
"T" = "door, trapped"
"v" = "stairs, down" "v" = "stairs, down"
"^" = "stairs, up" "^" = "stairs, up"
"0" = "location 0" "0" = "location 0"
@@ -25,3 +28,4 @@ class = "tilemapper.tileset.TileSet"
"8" = "location 8" "8" = "location 8"
"9" = "location 9" "9" = "location 9"
+53 -15
View File
@@ -4,6 +4,7 @@ from textwrap import dedent
import pytest import pytest
from tilemapper import battlemap, tileset from tilemapper import battlemap, tileset
from tilemapper.grid import X_COORDS
@pytest.fixture @pytest.fixture
@@ -15,18 +16,37 @@ def manager():
def sample_map(): def sample_map():
return dedent( return dedent(
""" """
........ 1 # ........ 1
........ ........ 2 # ........ ........ 2
........ ........ # ........ ........
.......L...D... D # .......L...D... D
........ .... ... # ........ .... ...
........ ...d ... # ........ ...d ...
. # .
.........S. 3 # .........S. 3
5 . . # 5 . .
....S... ...d.... 4 # ....S... ...d.... 4
........ ........ # ........ ........
........ ........ # ........ ........
[map]
name = "sample map"
description = "sample dungeon"
[1]
name = "room 1"
description = "A large empty room."
notes = [
"Locked door DC 15"
]
[2]
name = "room 2"
[3]
name = "room 3"
[4]
name = "room 4"
[5]
name = "room 5"
""" """
) )
@@ -38,9 +58,27 @@ def test_tileset_loader(manager):
def test_renderer(manager, sample_map): def test_renderer(manager, sample_map):
test_map = battlemap.BattleMap("test map", source=StringIO(sample_map), tileset=manager.load("ascii")) test_map = battlemap.BattleMap(source=StringIO(sample_map), tileset=manager.load("ascii"))
test_map.load() test_map.load()
assert test_map.width == 21 assert test_map.width == 21
assert test_map.height == 12 assert test_map.height == 12
assert test_map.source_data == sample_map.strip("\n")
assert str(test_map) == sample_map.strip("\n") srclines = sample_map.splitlines()[1:]
strlines = str(test_map).splitlines()
for i in range(len(strlines)):
assert strlines[i] == srclines[i].lstrip(" #").ljust(21, " ")
def test_grid_coordiates(manager):
coord_length = len(X_COORDS)
map_size = 2 * coord_length + 1
bigmap = StringIO((" # " + ("." * map_size) + "\n") * map_size)
test_map = battlemap.BattleMap(source=bigmap, tileset=manager.load("ascii"))
test_map.load()
assert test_map.grid.data[-1][-1].coordinates == (f"{map_size - 1}", X_COORDS[0] * 3)
lines = test_map.top_coordinates.splitlines()
assert len(lines) == 3
assert lines[0] == (" " * (map_size - 1)) + X_COORDS[0]
assert lines[1][: (coord_length + 1)] == (" " * coord_length) + X_COORDS[0]
assert lines[2] == "".join(X_COORDS) + ((coord_length + 1) * X_COORDS[0])