refactor(standin-sprites): ♻️ Refactor sprite build tool and mapping configuration for cleaner sprite generation and configuration rules

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 05:46:13 -07:00
parent 92850560f8
commit ee5234a80b
2 changed files with 401 additions and 0 deletions

View file

@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Build OSS stand-in sprites for every Age-of-Dwarves renderer slot.
Pulls CC-BY-3.0 silhouettes from github.com/game-icons/icons, recolours +
frames them at the renderer's hex-render sizes, and drops them at the exact
paths the GDScript renderers read:
units / wild -> sprites/units/<...>.png (UnitRenderer, native-size draw)
buildings/wonders-> sprites/buildings/<id>.png (CityRenderer placed-building +
city-screen production card; drawn scaled to ~14 px on tile)
cities -> sprites/cities/city_q<N>.png (CityRenderer tier bucket)
These are STAND-INS, deferring bespoke paid art (objective 5ee5e73e) to
post-launch. Every generated PNG is registered in LICENSES.md (provenance
ledger, p2-28) and STANDINS.md (the replace-list for the paid-art pass).
Re-runnable: SVGs are cached under .cache/svg/; pass --no-net to build only
from cache.
Usage:
python3 tools/standin-sprites/build_standins.py [--no-net] [--check]
--check : do not write outputs; only report which source SVGs are missing.
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import re
import sys
import urllib.request
from datetime import date
from pathlib import Path
import cairosvg
from PIL import Image, ImageDraw, ImageFilter
TOOL_DIR = Path(__file__).resolve().parent
REPO_ROOT = TOOL_DIR.parent.parent
SPRITES_ROOT = REPO_ROOT / "public/games/age-of-dwarves/assets/sprites"
SVG_CACHE = TOOL_DIR / ".cache" / "svg"
LEDGER = SPRITES_ROOT / "LICENSES.md"
MANIFEST = SPRITES_ROOT / "STANDINS.md"
# game-icons source canvas is 512x512; the glyph path renders white on a black
# background rect we strip out so the silhouette lands on transparency.
BG_PATH_RE = re.compile(r'<path d="M0 0h512v512H0z"\s*/>')
GENDERED_SUFFIXES = ["", "_m", "_f", "_dwarf_male", "_dwarf_female"]
def fetch_svg(icon: str, raw_base: str, allow_net: bool) -> bytes:
"""Return SVG bytes for `<author>/<name>`, caching under .cache/svg/."""
cache_path = SVG_CACHE / f"{icon}.svg"
if cache_path.exists():
return cache_path.read_bytes()
if not allow_net:
raise FileNotFoundError(f"SVG not cached and --no-net set: {icon}")
url = f"{raw_base}/{icon}.svg"
with urllib.request.urlopen(url, timeout=20) as resp:
data = resp.read()
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_bytes(data)
return data
def glyph_mask(svg_bytes: bytes, size: int) -> Image.Image:
"""Rasterise the icon glyph (background stripped) and return its alpha mask
as an 'L' image at `size`x`size`."""
stripped = BG_PATH_RE.sub("", svg_bytes.decode("utf-8")).encode("utf-8")
png = cairosvg.svg2png(
bytestring=stripped, output_width=size, output_height=size
)
img = Image.open(io.BytesIO(png)).convert("RGBA")
return img.split()[3] # alpha channel == glyph coverage
def hex_to_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
def tinted_glyph(mask: Image.Image, color: tuple[int, int, int]) -> Image.Image:
"""RGBA image: solid `color` where the glyph is, transparent elsewhere."""
out = Image.new("RGBA", mask.size, (*color, 0))
out.putalpha(mask)
solid = Image.new("RGBA", mask.size, (*color, 255))
return Image.composite(solid, out, mask)
def with_shadow(glyph: Image.Image, scale: float = 0.82) -> Image.Image:
"""Centre the glyph at `scale` of the canvas over a soft dark drop-shadow.
Transparent background -> the renderer's player-colour circle shows through
(advisor: never bake an opaque plate behind units/cities)."""
size = glyph.width
inner = max(8, int(size * scale))
g = glyph.resize((inner, inner), Image.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
off = (size - inner) // 2
# Shadow: dark silhouette from the glyph alpha, blurred + offset.
sh_alpha = g.split()[3].point(lambda a: int(a * 0.55))
shadow = Image.new("RGBA", (inner, inner), (0, 0, 0, 0))
shadow.putalpha(sh_alpha)
shadow = shadow.filter(ImageFilter.GaussianBlur(max(1.0, size * 0.025)))
canvas.alpha_composite(shadow, (off + max(1, size // 40), off + max(1, size // 28)))
canvas.alpha_composite(g, (off, off))
return canvas
def _rounded_plate(size: int, top: tuple, bottom: tuple, border: tuple) -> Image.Image:
"""Vertical-gradient rounded-rect plate, RGBA, for buildings/wonders."""
pad = max(2, size // 16)
radius = max(4, size // 6)
grad = Image.new("RGBA", (size, size), (0, 0, 0, 0))
px = grad.load()
for y in range(size):
t = y / (size - 1)
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(size):
px[x, y] = (r, g, b, 255)
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle(
[pad, pad, size - pad, size - pad], radius=radius, fill=255
)
plate = Image.new("RGBA", (size, size), (0, 0, 0, 0))
plate.paste(grad, (0, 0), mask)
# border
ImageDraw.Draw(plate).rounded_rectangle(
[pad, pad, size - pad - 1, size - pad - 1],
radius=radius, outline=(*border, 230), width=max(2, size // 32),
)
return plate
def with_plate(glyph: Image.Image, kind: str) -> Image.Image:
"""Frame a building/wonder glyph on a stone (building) or bronze (wonder)
plate so the silhouette reads when the renderer scales it to ~14 px on a
bare terrain tile (no player-colour circle behind buildings)."""
size = glyph.width
if kind == "plate_gold":
plate = _rounded_plate(size, (122, 92, 38), (60, 42, 14), (214, 176, 92))
gscale = 0.60
else: # plate_stone
plate = _rounded_plate(size, (74, 70, 64), (38, 35, 31), (150, 142, 128))
gscale = 0.62
inner = int(size * gscale)
g = glyph.resize((inner, inner), Image.LANCZOS)
off = (size - inner) // 2
out = plate.copy()
# subtle dark inset shadow under glyph for depth
sh = Image.new("RGBA", (inner, inner), (0, 0, 0, 0))
sh.putalpha(g.split()[3].point(lambda a: int(a * 0.5)))
sh = sh.filter(ImageFilter.GaussianBlur(max(1.0, size * 0.02)))
out.alpha_composite(sh, (off + 1, off + max(1, size // 40)))
out.alpha_composite(g, (off, off))
return out
def render(mask: Image.Image, cat: dict) -> Image.Image:
glyph = tinted_glyph(mask, hex_to_rgb(cat["glyph"]))
style = cat["style"]
if style == "glyph_shadow":
return with_shadow(glyph)
if style in ("plate_stone", "plate_gold"):
return with_plate(glyph, style)
raise ValueError(f"unknown style {style}")
def output_filenames(slot: dict) -> list[str]:
"""Bare filenames (no extension) this slot produces."""
if slot.get("filename"):
return [slot["filename"]]
if slot.get("gendered"):
return [f"{slot['id']}{sfx}" for sfx in GENDERED_SUFFIXES]
return [slot["id"]]
def sha256_of(path: Path) -> str:
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()
def page_url(icon: str, page_base: str) -> str:
return f"{page_base}/{icon}.html"
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")
ap.add_argument("--check", action="store_true",
help="verify source SVGs resolve; write nothing")
args = ap.parse_args(argv)
spec = json.loads((TOOL_DIR / "mapping.json").read_text())
src = spec["source"]
cats = spec["categories"]
allow_net = not args.no_net
# --check: confirm every source SVG fetches.
if args.check:
missing = []
for slot in spec["slots"]:
try:
fetch_svg(slot["icon"], src["raw_base"], allow_net)
except Exception as e: # noqa: BLE001
missing.append(f"{slot['id']} -> {slot['icon']}: {e}")
if missing:
print("MISSING SOURCE ICONS:")
for m in missing:
print(" -", m)
return 1
print(f"OK — all {len(spec['slots'])} source icons resolve.")
return 0
today = date.today().isoformat()
ledger_rows: list[str] = []
manifest_rows: list[str] = []
written = 0
for slot in spec["slots"]:
cat = cats[slot["category"]]
out_dir = SPRITES_ROOT / cat["dir"]
out_dir.mkdir(parents=True, exist_ok=True)
svg = fetch_svg(slot["icon"], src["raw_base"], allow_net)
mask = glyph_mask(svg, cat["size"])
img = render(mask, cat)
author_slug = slot["icon"].split("/")[0]
author = src["authors"].get(author_slug, author_slug)
url = page_url(slot["icon"], src["page_base"])
for fname in output_filenames(slot):
out_path = out_dir / f"{fname}.png"
img.save(out_path, "PNG")
rel = out_path.relative_to(SPRITES_ROOT).as_posix()
sha = sha256_of(out_path)
ledger_rows.append(
f"| {rel} | game-icons.net (stand-in) | {src['license']} | "
f"{author} | {url} | {sha} | {today} |"
)
manifest_rows.append(
f"| {rel} | {slot['category']} | {slot['id']} | "
f"{slot['icon']} | {cat['size']}px |"
)
written += 1
write_ledger(ledger_rows)
write_manifest(manifest_rows, len(spec["slots"]))
print(f"Wrote {written} stand-in PNGs across "
f"{len(spec['slots'])} slots -> {SPRITES_ROOT}")
print(f"Updated ledger: {LEDGER}")
print(f"Updated manifest: {MANIFEST}")
return 0
def write_ledger(rows: list[str]) -> None:
"""Replace the '## Assets' table body in LICENSES.md with `rows`,
preserving the header above and the '## Audit' section below."""
text = LEDGER.read_text()
header = (
"| Path | Source | License | Author | URL | SHA256 | Added |\n"
"|---|---|---|---|---|---|---|\n"
)
block = "## Assets\n\n" + header + "\n".join(sorted(rows)) + "\n\n"
# Replace everything from '## Assets' up to (not including) the next '## '.
pat = re.compile(r"## Assets\n.*?(?=\n## )", re.DOTALL)
if not pat.search(text):
raise RuntimeError("could not locate '## Assets' section in LICENSES.md")
LEDGER.write_text(pat.sub(block.rstrip() + "\n", text))
def write_manifest(rows: list[str], slot_count: int) -> None:
body = f"""# Stand-in Sprite Manifest — Age of Dwarves
**These are OSS stand-in sprites, not final art.** Every file listed here is a
CC-BY-3.0 silhouette from [game-icons.net](https://game-icons.net) recoloured
and framed to fill a renderer sprite slot so the Game 1 Godot client ships
visually complete. They are tracked for replacement by the bespoke paid-art
pass objective **5ee5e73e** (hire artist) and the per-category sprite
objectives **p2-23** (dwarf units), **p2-24** (wild creatures), **p2-25**
(buildings), **p2-26** (mundane wonders), **p2-27** (city tiers).
Those objectives stay **open / partial**: stand-ins do not meet their
256/512 px native-resolution + ranker 4.2 acceptance bars. This manifest is
the exact replace-list for the paid pass.
- Source pool: `github.com/game-icons/icons` (CC-BY-3.0).
- License provenance per file: `LICENSES.md` (audited by
`tools/sprite-license-audit.py`).
- Regenerate: `python3 tools/standin-sprites/build_standins.py`
(mapping in `tools/standin-sprites/mapping.json`).
- Slots covered: {slot_count}. Files emitted: {len(rows)}.
| Path | Category | Slot id | Source icon | Native size |
|---|---|---|---|---|
""" + "\n".join(sorted(rows)) + "\n"
MANIFEST.write_text(body)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,93 @@
{
"_comment": "Slot -> game-icons.net icon mapping for OSS stand-in sprites. Stand-ins fill every renderer sprite slot for Game 1 'Age of Dwarves' so the Godot client is visually complete pending bespoke paid art (objective 5ee5e73e). All icons are CC-BY-3.0 from github.com/game-icons/icons. Re-run tools/standin-sprites/build_standins.py after editing.",
"source": {
"repo": "game-icons/icons",
"raw_base": "https://raw.githubusercontent.com/game-icons/icons/master",
"page_base": "https://game-icons.net/1x1",
"license": "cc-by-3.0",
"authors": {
"lorc": "Lorc",
"delapouite": "Delapouite",
"sbed": "Sbed",
"skoll": "Skoll",
"heavenly-dog": "HeavenlyDog",
"carl-olsen": "Carl Olsen",
"faithtoken": "Faithtoken"
}
},
"categories": {
"units": { "size": 56, "style": "glyph_shadow", "glyph": "#F4ECD2", "dir": "units" },
"wild": { "size": 56, "style": "glyph_shadow", "glyph": "#E6DECB", "dir": "units" },
"cities": { "size": 56, "style": "glyph_shadow", "glyph": "#EFE6CC", "dir": "cities" },
"buildings": { "size": 64, "style": "plate_stone", "glyph": "#ECDCB4", "dir": "buildings" },
"wonders": { "size": 96, "style": "plate_gold", "glyph": "#FCEFC6", "dir": "buildings" }
},
"slots": [
{ "id": "archer", "category": "units", "icon": "delapouite/archer", "gendered": true },
{ "id": "berserker", "category": "units", "icon": "delapouite/barbarian", "gendered": true },
{ "id": "cavalry", "category": "units", "icon": "delapouite/cavalry", "gendered": true },
{ "id": "pikeman", "category": "units", "icon": "lorc/barbed-spear", "gendered": true },
{ "id": "runesmith", "category": "units", "icon": "lorc/rune-stone", "gendered": true },
{ "id": "spearmen", "category": "units", "icon": "lorc/spears", "gendered": true },
{ "id": "warrior", "category": "units", "icon": "lorc/broadsword", "gendered": true },
{ "id": "worker", "category": "units", "icon": "delapouite/miner", "gendered": true },
{ "id": "ancient_hydra", "category": "wild", "icon": "lorc/hydra" },
{ "id": "basilisk_wild", "category": "wild", "icon": "lorc/snake-totem" },
{ "id": "dire_bear", "category": "wild", "icon": "delapouite/bear-head" },
{ "id": "dire_wolf", "category": "wild", "icon": "lorc/wolf-head" },
{ "id": "drake_wild", "category": "wild", "icon": "lorc/sea-dragon" },
{ "id": "elder_wyrm", "category": "wild", "icon": "lorc/dragon-spiral" },
{ "id": "feral_spider", "category": "wild", "icon": "lorc/hanging-spider" },
{ "id": "fire_imp", "category": "wild", "icon": "lorc/imp" },
{ "id": "frostfang_alpha", "category": "wild", "icon": "skoll/fangs" },
{ "id": "garden_snail", "category": "wild", "icon": "lorc/snail" },
{ "id": "lava_elemental", "category": "wild", "icon": "sbed/lava" },
{ "id": "shambling_dead", "category": "wild", "icon": "delapouite/half-body-crawling" },
{ "id": "stone_sentinel", "category": "wild", "icon": "delapouite/rock-golem" },
{ "id": "wild_wyvern", "category": "wild", "icon": "lorc/wyvern" },
{ "id": "wolf_pack", "category": "wild", "icon": "lorc/wolf-howl" },
{ "id": "ale_hall", "category": "buildings", "icon": "lorc/beer-stein" },
{ "id": "barracks", "category": "buildings", "icon": "delapouite/barracks" },
{ "id": "bathhouse", "category": "buildings", "icon": "delapouite/bathtub" },
{ "id": "colosseum", "category": "buildings", "icon": "sbed/arena" },
{ "id": "forge", "category": "buildings", "icon": "lorc/anvil" },
{ "id": "library", "category": "buildings", "icon": "delapouite/bookshelf" },
{ "id": "marketplace", "category": "buildings", "icon": "delapouite/shop" },
{ "id": "monument", "category": "buildings", "icon": "delapouite/obelisk" },
{ "id": "temple", "category": "buildings", "icon": "delapouite/greek-temple" },
{ "id": "walls", "category": "buildings", "icon": "delapouite/stone-wall" },
{ "id": "ancestral_forge", "category": "wonders", "icon": "delapouite/fire-shrine" },
{ "id": "mead_hall", "category": "wonders", "icon": "delapouite/round-table" },
{ "id": "first_mineshaft", "category": "wonders", "icon": "delapouite/gold-mine" },
{ "id": "clan_moot_stone", "category": "wonders", "icon": "delapouite/menhir" },
{ "id": "iron_bulwark", "category": "wonders", "icon": "heavenly-dog/defensive-wall" },
{ "id": "hall_of_ancestors", "category": "wonders", "icon": "delapouite/family-tree" },
{ "id": "the_deep_road", "category": "wonders", "icon": "delapouite/cave-entrance" },
{ "id": "bardic_circle", "category": "wonders", "icon": "lorc/lyre" },
{ "id": "archive_of_runes", "category": "wonders", "icon": "lorc/scroll-unfurled" },
{ "id": "royal_runestone", "category": "wonders", "icon": "lorc/crowned-explosion" },
{ "id": "grand_observatory", "category": "wonders", "icon": "delapouite/observatory" },
{ "id": "covenant_stone", "category": "wonders", "icon": "lorc/stone-tablet" },
{ "id": "the_great_forge", "category": "wonders", "icon": "delapouite/blacksmith" },
{ "id": "iron_crown", "category": "wonders", "icon": "lorc/crown" },
{ "id": "undermount_vault", "category": "wonders", "icon": "lorc/locked-chest" },
{ "id": "hall_of_echoes", "category": "wonders", "icon": "skoll/sound-waves" },
{ "id": "world_pillar", "category": "wonders", "icon": "delapouite/atlas" },
{ "id": "well_of_ages", "category": "wonders", "icon": "delapouite/well" },
{ "id": "the_undying_flame", "category": "wonders", "icon": "lorc/burning-embers" },
{ "id": "voice_of_ages", "category": "wonders", "icon": "delapouite/megaphone" },
{ "id": "silent_cartograph", "category": "wonders", "icon": "lorc/treasure-map" },
{ "id": "shrine_of_names", "category": "wonders", "icon": "lorc/prayer" },
{ "id": "the_cold_anvil", "category": "wonders", "icon": "lorc/anvil-impact" },
{ "id": "hearthless_hall", "category": "wonders", "icon": "delapouite/fireplace" },
{ "id": "city_q1", "category": "cities", "icon": "delapouite/hut", "filename": "city_q1" },
{ "id": "city_q2", "category": "cities", "icon": "delapouite/huts-village", "filename": "city_q2" },
{ "id": "city_q3", "category": "cities", "icon": "delapouite/village", "filename": "city_q3" },
{ "id": "city_q4", "category": "cities", "icon": "delapouite/hill-fort", "filename": "city_q4" },
{ "id": "city_q5", "category": "cities", "icon": "delapouite/castle", "filename": "city_q5" }
]
}