From ee5234a80b22d0cbff330ebf08420bff700b6a3b Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 3 Jun 2026 05:46:13 -0700 Subject: [PATCH] =?UTF-8?q?refactor(standin-sprites):=20=E2=99=BB=EF=B8=8F?= =?UTF-8?q?=20Refactor=20sprite=20build=20tool=20and=20mapping=20configura?= =?UTF-8?q?tion=20for=20cleaner=20sprite=20generation=20and=20configuratio?= =?UTF-8?q?n=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- tools/standin-sprites/build_standins.py | 308 ++++++++++++++++++++++++ tools/standin-sprites/mapping.json | 93 +++++++ 2 files changed, 401 insertions(+) create mode 100644 tools/standin-sprites/build_standins.py create mode 100644 tools/standin-sprites/mapping.json diff --git a/tools/standin-sprites/build_standins.py b/tools/standin-sprites/build_standins.py new file mode 100644 index 00000000..c5fd4d2f --- /dev/null +++ b/tools/standin-sprites/build_standins.py @@ -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/.png (CityRenderer placed-building + + city-screen production card; drawn scaled to ~14 px on tile) + cities -> sprites/cities/city_q.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'') + +GENDERED_SUFFIXES = ["", "_m", "_f", "_dwarf_male", "_dwarf_female"] + + +def fetch_svg(icon: str, raw_base: str, allow_net: bool) -> bytes: + """Return SVG bytes for `/`, 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()) diff --git a/tools/standin-sprites/mapping.json b/tools/standin-sprites/mapping.json new file mode 100644 index 00000000..f5d9c677 --- /dev/null +++ b/tools/standin-sprites/mapping.json @@ -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" } + ] +}