magicciv/tools/standin-sprites/build_demo_lairs.py
Natalie d41a65bd50 feat(@projects/@magic-civilization): lair POI sprites + tile tooltips (p2-85)
world_map lair POI overlay, tile_info_panel tooltip wiring, lair standin
sprites + build_demo_lairs.py, tooltip unit test, lair proof scenes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 05:29:54 -05:00

181 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Build the **DEMO-ONLY** Battle-for-Wesnoth lair POI overlays — composited.
Mirrors the existing demo-art layer (DEMO_SPRITES_LICENSES.md, commit 55c01e339):
Wesnoth scenery/monster sprites overwrite the commercial-safe game-icons lair
baseline at `sprites/lairs/<id>.png` so the playable demo reads consistently. The
CC-BY game-icons baseline stays regenerable via `build_standins.py --only lairs`.
Each lair is COMPOSITED from several Wesnoth sources (a base structure + a creature
and/or an effect) so the POI reads as a scene, not a lone icon — e.g. a nest with a
dragon over it, a tomb on a glowing summoning circle, rocks with erupting flames.
Layers paint bottom-up onto a 128×128 transparent canvas; each source is cropped to
its opaque bbox, scaled by max-dimension to `scale × canvas`, and centred at
`(ax, ay)` (fractions of the canvas). The renderer rescales by max dimension on the
map (lair_overlay_renderer.gd: 0.45 × hex width).
⚠️ COPYLEFT — NOT FOR COMMERCIAL SHIP. Wesnoth art is GPL-2.0-or-later OR
CC-BY-SA 4.0 (older monster/scenery sprites commonly GPL-only). Replace before any
sale build (tracked by p2-22 … p2-27). Per-source provenance + sha256 logged below.
Usage: tools/standin-sprites/.venv/bin/python tools/standin-sprites/build_demo_lairs.py [--no-net]
"""
from __future__ import annotations
import argparse
import hashlib
import io
import re
import sys
import urllib.request
from datetime import date
from pathlib import Path
from PIL import Image
TOOL_DIR = Path(__file__).resolve().parent
REPO_ROOT = TOOL_DIR.parent.parent
ASSETS_ROOT = REPO_ROOT / "public/games/age-of-dwarves/assets"
LAIRS_DIR = ASSETS_ROOT / "sprites/lairs"
DEMO_LEDGER = ASSETS_ROOT / "sprites/DEMO_SPRITES_LICENSES.md"
SRC_CACHE = TOOL_DIR / ".cache" / "wesnoth"
RAW_BASE = "https://raw.githubusercontent.com/wesnoth/wesnoth/master/data/core/images"
CANVAS = 128
SECTION_MARK = "## Lair POI overlays (Wesnoth demo)"
# lair type_id -> ordered layers (painted bottom-up). Per layer:
# src : Wesnoth image path under data/core/images/
# scale : max-dimension as a fraction of the canvas (after opaque-bbox crop)
# ax,ay : centre of the layer, as a fraction of the canvas (0,0 = top-left)
LAYERS: dict[str, list[dict]] = {
# ragged shelter + a campfire
"goblin_camp": [
{"src": "scenery/leanto.png", "scale": 0.80, "ax": 0.46, "ay": 0.56},
{"src": "scenery/fire4.png", "scale": 0.30, "ax": 0.75, "ay": 0.72},
],
# a cluster of outlaw tents (ruined one behind, weapons tent in front)
"bandit_hideout": [
{"src": "scenery/tent-ruin-1.png", "scale": 0.55, "ax": 0.30, "ay": 0.50},
{"src": "scenery/tent-shop-weapons.png", "scale": 0.78, "ax": 0.58, "ay": 0.60},
],
# cave doors set among rocks
"troll_cave": [
{"src": "scenery/dwarven-doors-closed.png", "scale": 0.86, "ax": 0.52, "ay": 0.52},
{"src": "scenery/rock2.png", "scale": 0.34, "ax": 0.26, "ay": 0.76},
],
# a rocky den with a direwolf guarding it (wolf dominant, rocks behind)
"beast_den": [
{"src": "scenery/rock-cairn.png", "scale": 0.60, "ax": 0.58, "ay": 0.42},
{"src": "units/monsters/direwolf.png", "scale": 0.80, "ax": 0.46, "ay": 0.60},
],
# a tomb standing on a glowing summoning circle
"corrupted_hollow": [
{"src": "scenery/summoning-circle1.png", "scale": 0.92, "ax": 0.50, "ay": 0.72},
{"src": "scenery/mausoleum01.png", "scale": 0.72, "ax": 0.50, "ay": 0.44},
],
# rocks with fire erupting out of the fissure
"volcanic_fissure": [
{"src": "scenery/rock1.png", "scale": 0.60, "ax": 0.50, "ay": 0.66},
{"src": "scenery/flames08.png", "scale": 0.72, "ax": 0.50, "ay": 0.44},
],
# a cracked ancient temple (dominant) with a standing monolith beside it
"ancient_construct_site": [
{"src": "scenery/monolith3.png", "scale": 0.50, "ax": 0.20, "ay": 0.50},
{"src": "scenery/temple-cracked1.png", "scale": 0.96, "ax": 0.56, "ay": 0.54},
],
# a dragon perched over its nest
"wyvern_nest": [
{"src": "scenery/nest-full.png", "scale": 0.78, "ax": 0.50, "ay": 0.72},
{"src": "units/monsters/fire-dragon.png", "scale": 0.64, "ax": 0.50, "ay": 0.40},
],
}
def fetch(src_rel: str, allow_net: bool) -> bytes:
cache = SRC_CACHE / src_rel
if cache.exists():
return cache.read_bytes()
if not allow_net:
raise FileNotFoundError(f"not cached and --no-net: {src_rel}")
with urllib.request.urlopen(f"{RAW_BASE}/{src_rel}", timeout=20) as resp:
data = resp.read()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_bytes(data)
return data
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def compose(layers: list[dict], allow_net: bool) -> tuple[Image.Image, list[tuple[str, str]]]:
"""Paint `layers` bottom-up onto a transparent CANVAS square. Returns the image
and the (source_path, source_sha256) list for the ledger."""
canvas = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
sources: list[tuple[str, str]] = []
for layer in layers:
raw = fetch(layer["src"], allow_net)
sources.append((layer["src"], sha256(raw)))
im = Image.open(io.BytesIO(raw)).convert("RGBA")
bbox = im.split()[3].getbbox() # opaque-region bounds (straight-alpha sprites)
if bbox is not None:
im = im.crop(bbox)
target = max(1, round(CANVAS * layer["scale"]))
s = target / max(im.width, im.height)
w, h = max(1, round(im.width * s)), max(1, round(im.height * s))
im = im.resize((w, h), Image.LANCZOS)
cx, cy = layer["ax"] * CANVAS, layer["ay"] * CANVAS
canvas.alpha_composite(im, (round(cx - w / 2), round(cy - h / 2)))
return canvas, sources
def write_ledger_section(rows: list[str]) -> None:
"""Insert/replace the lair-overlay provenance section in the demo ledger,
leaving all other content untouched."""
header = (
f"\n{SECTION_MARK}\n\n"
"Each lair POI is **composited** from the Wesnoth sources listed below "
"(base structure + creature/effect) onto a 128×128 transparent PNG, "
"overwriting the game-icons lair baseline. Source license = GNU "
"GPL-2.0-or-later OR CC-BY-SA 4.0 (Battle for Wesnoth art team, "
"collective). **COPYLEFT — demo-only.** Baseline regenerable via "
"`build_standins.py --only lairs`; rebuild via `build_demo_lairs.py`.\n\n"
"| output_path (lairs/) | wesnoth source layer | source_sha256 | out_sha256 | added |\n"
"|---|---|---|---|---|\n"
+ "\n".join(rows) + "\n"
)
text = DEMO_LEDGER.read_text()
pat = re.compile(rf"\n{re.escape(SECTION_MARK)}\n.*?(?=\n## |\Z)", re.DOTALL)
DEMO_LEDGER.write_text(pat.sub(header.rstrip() + "\n", text) if pat.search(text)
else text.rstrip() + "\n" + header)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--no-net", action="store_true", help="build from cache only")
args = ap.parse_args(argv)
allow_net = not args.no_net
today = date.today().isoformat()
LAIRS_DIR.mkdir(parents=True, exist_ok=True)
rows: list[str] = []
for lair_id, layers in sorted(LAYERS.items()):
img, sources = compose(layers, allow_net)
out = LAIRS_DIR / f"{lair_id}.png"
img.save(out, "PNG")
out_sha = sha256(out.read_bytes())
for i, (src_rel, src_sha) in enumerate(sources):
rows.append(
f"| lairs/{lair_id}.png | {src_rel} | `{src_sha}` | "
f"{'`' + out_sha + '`' if i == 0 else ''} | {today} |")
print(f" {lair_id:24} <- {' + '.join(s for s, _ in sources)}")
write_ledger_section(rows)
print(f"Wrote {len(LAYERS)} composited Wesnoth demo lair overlays -> {LAIRS_DIR}")
print(f"Updated demo ledger: {DEMO_LEDGER}")
return 0
if __name__ == "__main__":
sys.exit(main())