Expand handpainted tactical maps

This commit is contained in:
2026-07-04 11:10:22 +09:00
parent d13e69e4d3
commit fe7d1167a7
17 changed files with 203 additions and 225 deletions

View File

@@ -12,12 +12,42 @@ from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageOp
ROOT = Path(__file__).resolve().parents[1]
SCENARIO_FILE = ROOT / "src" / "game" / "data" / "scenario.ts"
FIRST_BATTLE_MAP = ROOT / "src" / "assets" / "images" / "battle" / "first-battle-map.webp"
BATTLE_IMAGE_DIR = ROOT / "src" / "assets" / "images" / "battle"
DOCS_DIR = ROOT / "docs"
TILE = 224
RNG = random.Random(240704)
MAP_CONFIGS = {
"first": {
"slug": "first-battle-map",
"terrain_export": "firstBattleMap",
"units_export": "firstBattleUnits",
"ally_positions_export": None,
"out": BATTLE_IMAGE_DIR / "first-battle-map.webp",
"before": BATTLE_IMAGE_DIR / "first-battle-map.webp",
"seed": 240704,
},
"second": {
"slug": "second-battle-map",
"terrain_export": "secondBattleMap",
"units_export": "secondBattleUnits",
"ally_positions_export": "secondBattleAllyPositions",
"out": BATTLE_IMAGE_DIR / "second-battle-map.webp",
"before": BATTLE_IMAGE_DIR / "second-battle-map.svg",
"seed": 240714,
},
"third": {
"slug": "third-battle-map",
"terrain_export": "thirdBattleMap",
"units_export": "thirdBattleUnits",
"ally_positions_export": "thirdBattleAllyPositions",
"out": BATTLE_IMAGE_DIR / "third-battle-map.webp",
"before": BATTLE_IMAGE_DIR / "third-battle-map.svg",
"seed": 240724,
},
}
PALETTE = {
"plain": (88, 104, 62),
@@ -53,33 +83,76 @@ def jitter(color: tuple[int, int, int], amount: int) -> tuple[int, int, int]:
return tuple(clamp(channel + RNG.randint(-amount, amount)) for channel in color)
def parse_first_battle_terrain() -> list[list[str]]:
text = SCENARIO_FILE.read_text(encoding="utf-8")
start = text.index("export const firstBattleMap")
terrain_key = text.index("terrain:", start)
bracket_start = text.index("[", terrain_key)
def extract_balanced(text: str, start: int, open_char: str, close_char: str) -> str:
depth = 0
for index in range(bracket_start, len(text)):
for index in range(start, len(text)):
char = text[index]
if char == "[":
if char == open_char:
depth += 1
elif char == "]":
elif char == close_char:
depth -= 1
if depth == 0:
return ast.literal_eval(text[bracket_start : index + 1])
raise RuntimeError("Could not parse first battle terrain")
return text[start : index + 1]
raise RuntimeError(f"Could not find balanced block starting at {start}")
def parse_first_battle_units() -> list[dict[str, object]]:
def parse_battle_terrain(export_name: str) -> list[list[str]]:
text = SCENARIO_FILE.read_text(encoding="utf-8")
block_start = text.index("export const firstBattleUnits")
block_end = text.index("const secondBattleAllyPositions", block_start)
block = text[block_start:block_end]
start = text.index(f"export const {export_name}")
terrain_key = text.index("terrain:", start)
bracket_start = text.index("[", terrain_key)
return ast.literal_eval(extract_balanced(text, bracket_start, "[", "]"))
def parse_export_array_source(export_name: str) -> str:
text = SCENARIO_FILE.read_text(encoding="utf-8")
start = text.index(f"export const {export_name}")
bracket_start = text.index("[", text.index("=", start))
return extract_balanced(text, bracket_start, "[", "]")
def parse_ally_positions(export_name: str | None) -> list[dict[str, object]]:
if export_name is None:
return []
text = SCENARIO_FILE.read_text(encoding="utf-8")
marker = f"const {export_name}"
if marker not in text:
return []
start = text.index(marker)
brace_start = text.index("{", text.index("=", start))
block = extract_balanced(text, brace_start, "{", "}")
display_names = {
"liu-bei": "Liu Bei",
"guan-yu": "Guan Yu",
"zhang-fei": "Zhang Fei",
}
units: list[dict[str, object]] = []
for match in re.finditer(r"id: '([^']+)'.*?name: '([^']+)'.*?faction: '([^']+)'.*?x: (\d+),\s*y: (\d+)", block, re.S):
for match in re.finditer(r"'([^']+)':\s*\{\s*x:\s*(\d+),\s*y:\s*(\d+)\s*\}", block):
unit_id = match.group(1)
units.append(
{
"id": match.group(1),
"id": unit_id,
"name": display_names.get(unit_id, unit_id),
"faction": "ally",
"x": int(match.group(2)),
"y": int(match.group(3)),
}
)
return units
def parse_battle_units(units_export: str, ally_positions_export: str | None) -> list[dict[str, object]]:
block = parse_export_array_source(units_export)
units = parse_ally_positions(ally_positions_export)
seen = {str(unit["id"]) for unit in units}
for match in re.finditer(r"id: '([^']+)'.*?name: '([^']+)'.*?faction: '([^']+)'.*?x: (\d+),\s*y: (\d+)", block, re.S):
unit_id = match.group(1)
if unit_id in seen:
continue
seen.add(unit_id)
units.append(
{
"id": unit_id,
"name": match.group(2),
"faction": match.group(3),
"x": int(match.group(4)),
@@ -464,9 +537,12 @@ def add_subtle_grid_and_grade(canvas: Image.Image, terrain: list[list[str]]) ->
return graded
def build_map() -> Image.Image:
terrain = parse_first_battle_terrain()
units = parse_first_battle_units()
def build_map(config: dict[str, object]) -> Image.Image:
global RNG
RNG = random.Random(int(config["seed"]))
terrain = parse_battle_terrain(str(config["terrain_export"]))
ally_positions_export = config["ally_positions_export"]
units = parse_battle_units(str(config["units_export"]), None if ally_positions_export is None else str(ally_positions_export))
width = len(terrain[0]) * TILE
height = len(terrain) * TILE
canvas = add_base_texture(width, height)
@@ -477,6 +553,42 @@ def build_map() -> Image.Image:
return add_subtle_grid_and_grade(canvas, terrain)
def make_flat_legacy_preview(terrain: list[list[str]], size: tuple[int, int]) -> Image.Image:
colors = {
"plain": (97, 116, 75),
"road": (161, 124, 72),
"forest": (50, 78, 42),
"hill": (119, 105, 72),
"cliff": (76, 68, 57),
"river": (46, 93, 113),
"village": (134, 92, 48),
"camp": (171, 137, 74),
"fort": (94, 72, 48),
}
width = len(terrain[0]) * 96
height = len(terrain) * 96
image = Image.new("RGB", (width, height), colors["plain"])
draw = ImageDraw.Draw(image)
for y, row in enumerate(terrain):
for x, kind in enumerate(row):
left, top = x * 96, y * 96
draw.rectangle((left, top, left + 96, top + 96), fill=colors.get(kind, colors["plain"]))
if kind == "road":
draw.line((left, top + 52, left + 96, top + 44), fill=(199, 159, 93), width=9)
elif kind == "forest":
for ox, oy in ((28, 30), (54, 38), (40, 60)):
draw.ellipse((left + ox - 16, top + oy - 16, left + ox + 16, top + oy + 16), fill=(73, 111, 58))
elif kind == "river":
draw.arc((left + 8, top + 20, left + 90, top + 78), 190, 350, fill=(111, 165, 176), width=4)
return image.resize(size, Image.Resampling.LANCZOS)
def load_before_image(path: Path, terrain: list[list[str]], target_size: tuple[int, int]) -> Image.Image:
if path.exists() and path.suffix.lower() != ".svg":
return Image.open(path).convert("RGB")
return make_flat_legacy_preview(terrain, target_size)
def make_comparison(before: Image.Image, after: Image.Image, out_path: Path) -> None:
preview_width = 1400
before_small = before.resize((preview_width, int(before.height * preview_width / before.width)), Image.Resampling.LANCZOS)
@@ -488,43 +600,53 @@ def make_comparison(before: Image.Image, after: Image.Image, out_path: Path) ->
combined.paste(after_small, (padding * 2 + before_small.width, padding + label_height))
draw = ImageDraw.Draw(combined)
draw.text((padding, padding + 10), "Before", fill=(232, 219, 178))
draw.text((padding * 2 + before_small.width, padding + 10), "After: hand-painted tactical sample", fill=(232, 219, 178))
draw.text((padding * 2 + before_small.width, padding + 10), "After: hand-painted tactical map", fill=(232, 219, 178))
out_path.parent.mkdir(parents=True, exist_ok=True)
combined.save(out_path, quality=92)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--out", type=Path, default=FIRST_BATTLE_MAP)
parser.add_argument("--before", type=Path, default=FIRST_BATTLE_MAP)
parser.add_argument("--comparison", type=Path, default=DOCS_DIR / "first-battle-map-handpaint-before-after.png")
parser.add_argument("--preview", type=Path, default=DOCS_DIR / "first-battle-map-handpaint-preview.png")
parser.add_argument("--map", choices=sorted(MAP_CONFIGS), default="first")
parser.add_argument("--out", type=Path)
parser.add_argument("--before", type=Path)
parser.add_argument("--comparison", type=Path)
parser.add_argument("--preview", type=Path)
parser.add_argument("--webp-quality", type=int, default=90)
args = parser.parse_args()
before = Image.open(args.before).convert("RGB")
after = build_map()
if before.size != after.size:
raise RuntimeError(f"Map size changed from {before.size} to {after.size}")
config = MAP_CONFIGS[args.map]
out_path = args.out or Path(config["out"])
before_path = args.before or Path(config["before"])
comparison_path = args.comparison or (DOCS_DIR / f"{config['slug']}-handpaint-before-after.png")
preview_path = args.preview or (DOCS_DIR / f"{config['slug']}-handpaint-preview.png")
args.out.parent.mkdir(parents=True, exist_ok=True)
if args.out.suffix.lower() == ".webp":
after.save(args.out, "WEBP", quality=args.webp_quality, method=6)
terrain = parse_battle_terrain(str(config["terrain_export"]))
after = build_map(config)
before = load_before_image(before_path, terrain, after.size)
if before.size != after.size:
before = before.resize(after.size, Image.Resampling.LANCZOS)
out_path.parent.mkdir(parents=True, exist_ok=True)
if out_path.suffix.lower() == ".webp":
after.save(out_path, "WEBP", quality=args.webp_quality, method=6)
else:
output = after.quantize(colors=224, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE)
output.save(args.out, optimize=True, compress_level=9)
make_comparison(before, after, args.comparison)
args.preview.parent.mkdir(parents=True, exist_ok=True)
after.resize((1400, int(after.height * 1400 / after.width)), Image.Resampling.LANCZOS).save(args.preview, quality=92)
output.save(out_path, optimize=True, compress_level=9)
make_comparison(before, after, comparison_path)
preview_path.parent.mkdir(parents=True, exist_ok=True)
after.resize((1400, int(after.height * 1400 / after.width)), Image.Resampling.LANCZOS).save(preview_path, quality=92)
diff = ImageChops.difference(before.resize((400, 360)), after.resize((400, 360))).convert("L")
diff_size = (400, int(after.height * 400 / after.width))
diff = ImageChops.difference(before.resize(diff_size), after.resize(diff_size)).convert("L")
print(
{
"out": str(args.out),
"map": args.map,
"out": str(out_path),
"size": after.size,
"comparison": str(args.comparison),
"preview": str(args.preview),
"avg_preview_delta": round(sum(diff.tobytes()) / (400 * 360), 2),
"comparison": str(comparison_path),
"preview": str(preview_path),
"avg_preview_delta": round(sum(diff.tobytes()) / (diff_size[0] * diff_size[1]), 2),
}
)