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>
181 lines
7.9 KiB
Python
181 lines
7.9 KiB
Python
#!/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())
|