New per-unit male/female standin PNGs, build_standins.py + icon_rules updates, license/standins ledgers, manifest roster + DevSpritesPage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
673 lines
28 KiB
Python
673 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""Build OSS stand-in sprites for every Age-of-Dwarves Game-1 renderer slot.
|
|
|
|
Stand-ins are PLACEHOLDER art: CC-BY-3.0 silhouettes from github.com/game-icons/icons,
|
|
recoloured + framed to fill every renderer sprite slot so the Game-1 Godot client
|
|
ships visually complete pending bespoke paid art (objective 5ee5e73e).
|
|
|
|
DATA-DRIVEN COVERAGE (the key contract). We do NOT hardcode a slot list. The
|
|
Game-1 content surface is whatever `public/games/age-of-dwarves/manifest.json`
|
|
subscribes to. For each subscribed unit / building id we open its resource file
|
|
under `public/resources/<cat>/<id>.json` and emit a stand-in PNG to *every literal
|
|
`sprite` path it declares* — top-level `sprite`, each `gender.<sex>.sprite`, plus
|
|
the renderer-constructed on-map keys `<stem>_dwarf_male.png` / `<stem>_dwarf_female.png`
|
|
(unit_renderer.gd composes `<type_id>_<race_id>_<sex>`; race "dwarf", gender_preset
|
|
default "male"). Output paths therefore come from the data, never reconstructed
|
|
from id + a guessed suffix — this is what makes the standin land at the exact path
|
|
the renderer requests (resolving the `_m`/`_f` vs `_dwarves_m`/`_dwarves_f` spelling
|
|
inconsistency baked into the unit data).
|
|
|
|
Wonders (buildings with a `wonder_type` field) get a bronze plate at 96px and write
|
|
to `sprites/buildings/wonders/<id>.png` (a subdirectory the resource data names);
|
|
base buildings get a stone plate at 64px under `sprites/buildings/`. Cities get
|
|
five tier silhouettes `sprites/cities/city_q<N>.png`.
|
|
|
|
Icon selection per id: `icon_rules.json` — explicit `overrides` first, then the
|
|
longest matching substring in `keywords`, then the category `default`. Every chosen
|
|
icon is verified to resolve against the live game-icons repo by `--check` before a
|
|
full generation pass.
|
|
|
|
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] [--only CATS]
|
|
|
|
--check : do not write outputs; only report which chosen icons fail to resolve
|
|
and which subscribed ids have no resource file / no sprite path.
|
|
--only : comma-separated category list (e.g. `lairs`) — emit only those slots
|
|
and MERGE their rows into LICENSES.md / STANDINS.md, preserving every
|
|
existing row verbatim. Use this to add a category without clobbering a
|
|
coexisting hand-placed art layer (e.g. the Wesnoth demo sprites, which
|
|
replace the build_standins PNGs on disk but share this ledger).
|
|
"""
|
|
|
|
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
|
|
GAME_ROOT = REPO_ROOT / "public/games/age-of-dwarves"
|
|
RESOURCES_ROOT = REPO_ROOT / "public/resources"
|
|
# A unit/building `sprite` field is "sprites/<...>.png" — a path relative to the
|
|
# theme ASSETS dir (ThemeAssets._base_path = "res://public/games/<id>/assets",
|
|
# resolve() joins the relative key onto it). So sprites land under ASSETS_ROOT,
|
|
# NOT under ASSETS_ROOT/"sprites" (that would double the segment).
|
|
ASSETS_ROOT = GAME_ROOT / "assets"
|
|
SPRITES_ROOT = ASSETS_ROOT / "sprites" # ledger/manifest live here; rows are
|
|
# path-relative to this dir per schema.
|
|
MANIFEST_JSON = GAME_ROOT / "manifest.json"
|
|
RULES_JSON = TOOL_DIR / "icon_rules.json"
|
|
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*/>')
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Data resolution: manifest subscription -> slot list #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def load_resource(cat: str, rid: str) -> dict | None:
|
|
"""Load a resource JSON (object, or first element of a list)."""
|
|
p = RESOURCES_ROOT / cat / f"{rid}.json"
|
|
if not p.exists():
|
|
return None
|
|
d = json.loads(p.read_text())
|
|
if isinstance(d, list):
|
|
return d[0] if d and isinstance(d[0], dict) else None
|
|
return d if isinstance(d, dict) else None
|
|
|
|
|
|
def unit_sprite_paths(uid: str, d: dict) -> list[str]:
|
|
"""The paths the renderer actually requests for unit `uid`.
|
|
|
|
unit_renderer.gd / unit_renderer_draw.gd key everything off the unit's
|
|
`type_id`, which `unit.gd:_init` sets equal to the resource id (NOT the
|
|
`sprite` field — no Godot renderer reads that field). It builds:
|
|
generic: sprites/units/<id>.png (always)
|
|
race+sex: sprites/units/<id>_dwarf_<male|female>.png (gendered units;
|
|
race "dwarf", gender_preset default "male", sex token
|
|
"male"/"female" per player.gd / standin_sprite_proof.gd).
|
|
Wild units never carry a race/sex, so only the generic path is requested."""
|
|
paths = [f"sprites/units/{uid}.png"]
|
|
gender = d.get("gender") or {}
|
|
is_gendered = isinstance(gender, dict) and bool(gender)
|
|
if is_gendered and not is_wild(d):
|
|
paths.append(f"sprites/units/{uid}_dwarf_male.png")
|
|
paths.append(f"sprites/units/{uid}_dwarf_female.png")
|
|
return paths
|
|
|
|
|
|
def is_wild(d: dict) -> bool:
|
|
return d.get("faction") == "wild" or "wild" in (d.get("keywords") or [])
|
|
|
|
|
|
def build_slots(rules: dict) -> tuple[list[dict], list[str]]:
|
|
"""Return (slots, problems). Each slot:
|
|
{category, id, icon, out_paths:[...], size, style, glyph}
|
|
`problems` lists subscribed ids with no resource file or no sprite path."""
|
|
manifest = json.loads(MANIFEST_JSON.read_text())
|
|
sub = manifest["subscribes"]
|
|
render = rules["render"]
|
|
problems: list[str] = []
|
|
slots: list[dict] = []
|
|
|
|
# --- units (incl. wild) ---
|
|
for uid in sub.get("units", []):
|
|
d = load_resource("units", uid)
|
|
if d is None:
|
|
problems.append(f"unit {uid}: no resource file")
|
|
continue
|
|
paths = unit_sprite_paths(uid, d)
|
|
cat = "wild" if is_wild(d) else "units"
|
|
icon = resolve_icon(uid, rules["units"], wild=(cat == "wild"))
|
|
r = render[cat]
|
|
slots.append({
|
|
"category": cat, "id": uid, "icon": icon, "out_paths": paths,
|
|
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
|
|
})
|
|
|
|
# --- buildings (base + wonders) ---
|
|
# city_renderer.gd:223 builds `sprites/buildings/<id>.png` from the building
|
|
# id, flat, with no `wonders/` branch — wonders render at the same flat path
|
|
# (see standin_sprite_proof.gd MUNDANE WONDERS rows). `wonder_type` only
|
|
# selects the bronze plate / 96px size, not a subdirectory.
|
|
for bid in sub.get("buildings", []):
|
|
d = load_resource("buildings", bid)
|
|
if d is None:
|
|
problems.append(f"building {bid}: no resource file")
|
|
continue
|
|
is_wonder = bool(d.get("wonder_type"))
|
|
cat = "wonders" if is_wonder else "buildings"
|
|
icon = resolve_icon(bid, rules["buildings"], wonder=is_wonder)
|
|
r = render[cat]
|
|
slots.append({
|
|
"category": cat, "id": bid, "icon": icon,
|
|
"out_paths": [f"sprites/buildings/{bid}.png"],
|
|
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
|
|
})
|
|
|
|
# --- cities (no data dependency; fixed five tiers) ---
|
|
r = render["cities"]
|
|
for tier, icon in sorted(rules["cities"].items()):
|
|
slots.append({
|
|
"category": "cities", "id": tier, "icon": icon,
|
|
"out_paths": [f"sprites/cities/{tier}.png"],
|
|
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
|
|
})
|
|
|
|
# --- throne-room decorations ---
|
|
# throne_room.gd:112 reads each decoration's literal `sprite` field and loads
|
|
# it via ThemeAssets.load_sprite — so the fill set is the set of distinct
|
|
# `sprite` paths declared across public/resources/throne_rooms/*.json. The
|
|
# icon is resolved from the sprite *basename* (the slot-type token), not the
|
|
# decoration id, because the basename carries the semantic prefix.
|
|
r = render["throne_room"]
|
|
for basename, sprite_path in sorted(throne_room_sprites().items()):
|
|
icon = resolve_icon(basename, rules["throne_room"])
|
|
slots.append({
|
|
"category": "throne_room", "id": basename, "icon": icon,
|
|
"out_paths": [sprite_path],
|
|
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
|
|
})
|
|
|
|
# --- treasury items ---
|
|
# treasury_tab.gd:90 reads each item's `sprite` field and loads it. Fill set
|
|
# is the distinct `sprites/items/*` paths declared in resources/items/*.json.
|
|
r = render["items"]
|
|
for basename, sprite_path in sorted(item_sprites().items()):
|
|
icon = resolve_icon(basename, rules["items"])
|
|
slots.append({
|
|
"category": "items", "id": basename, "icon": icon,
|
|
"out_paths": [sprite_path],
|
|
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
|
|
})
|
|
|
|
# --- map resource / deposit overlays ---
|
|
# overlay_renderer.gd:118 constructs sprites/resources/<resource_id>.png. Fill
|
|
# set is the distinct ids declared in resources.json + deposits/*.json. Skip
|
|
# any id already covered by an on-disk .svg (would shadow it).
|
|
r = render["resources"]
|
|
for rid in sorted(resource_ids()):
|
|
if (ASSETS_ROOT / "sprites/resources" / f"{rid}.svg").exists():
|
|
continue
|
|
icon = resolve_icon(rid, rules["resources"])
|
|
slots.append({
|
|
"category": "resources", "id": rid, "icon": icon,
|
|
"out_paths": [f"sprites/resources/{rid}.png"],
|
|
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
|
|
})
|
|
|
|
# --- map lair POI overlays ---
|
|
# lair_overlay_renderer.gd:11 builds sprites/lairs/<type_id>.png, where the
|
|
# npc-building type_id is set by village_lair_placer.gd:222 from each
|
|
# wilds.json lair_types[].id (rust_fauna_integration.gd:63 confirms the
|
|
# Building.type_id == lair_types[].id contract). Emit one POI sprite per id.
|
|
r = render["lairs"]
|
|
for lid in sorted(lair_ids()):
|
|
icon = resolve_icon(lid, rules["lairs"])
|
|
slots.append({
|
|
"category": "lairs", "id": lid, "icon": icon,
|
|
"out_paths": [f"sprites/lairs/{lid}.png"],
|
|
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
|
|
})
|
|
|
|
# --- special terrain tile-features lacking an SVG ---
|
|
# hex_renderer.gd preloads sprites/terrain/<biome_id>.png. Most biomes ship an
|
|
# SVG (gen-fallback-sprites.py); a handful of wonder/feature tile ids do not.
|
|
# GUARD: load_sprite tries .png before .svg, so emitting a placeholder .png for
|
|
# an SVG-covered biome would SHADOW the real biome art — only emit for ids with
|
|
# neither .png nor .svg on disk.
|
|
r = render["terrain"]
|
|
terrain_tbl = rules["terrain"]
|
|
for tid in sorted(set(terrain_tbl.get("overrides", {}))):
|
|
tdir = ASSETS_ROOT / "sprites/terrain"
|
|
if (tdir / f"{tid}.svg").exists() or (tdir / f"{tid}.png").exists():
|
|
continue
|
|
icon = resolve_icon(tid, terrain_tbl)
|
|
slots.append({
|
|
"category": "terrain", "id": tid, "icon": icon,
|
|
"out_paths": [f"sprites/terrain/{tid}.png"],
|
|
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
|
|
})
|
|
|
|
return slots, problems
|
|
|
|
|
|
def throne_room_sprites() -> dict[str, str]:
|
|
"""{sprite_basename: sprite_path} for every distinct `sprite` field declared
|
|
in public/resources/throne_rooms/*.json (throne_room.gd loads these)."""
|
|
out: dict[str, str] = {}
|
|
for f in (RESOURCES_ROOT / "throne_rooms").glob("*.json"):
|
|
_collect_sprite_paths(json.loads(f.read_text()), "sprites/throne_room/", out)
|
|
return out
|
|
|
|
|
|
def item_sprites() -> dict[str, str]:
|
|
"""{item_basename: sprite_path} for every distinct `sprites/items/*` field
|
|
declared in public/resources/items/*.json (treasury_tab.gd loads these)."""
|
|
out: dict[str, str] = {}
|
|
items_dir = RESOURCES_ROOT / "items"
|
|
if items_dir.is_dir():
|
|
for f in items_dir.glob("*.json"):
|
|
_collect_sprite_paths(json.loads(f.read_text()), "sprites/items/", out)
|
|
return out
|
|
|
|
|
|
def resource_ids() -> set[str]:
|
|
"""Resource/deposit ids that overlay_renderer keys sprites/resources/<id>.png
|
|
off — from resources.json plus deposits/*.json."""
|
|
ids: set[str] = set()
|
|
rjson = RESOURCES_ROOT / "resources.json"
|
|
if rjson.exists():
|
|
_collect_ids(json.loads(rjson.read_text()), ids)
|
|
dep_dir = RESOURCES_ROOT / "deposits"
|
|
if dep_dir.is_dir():
|
|
for f in dep_dir.glob("*.json"):
|
|
_collect_ids(json.loads(f.read_text()), ids)
|
|
return ids
|
|
|
|
|
|
def lair_ids() -> set[str]:
|
|
"""Lair type ids the lair overlay keys sprites/lairs/<id>.png off — every
|
|
`lair_types[].id` in public/resources/wilds/wilds.json. The file nests the
|
|
array as `[ { "wilds": { …, "lair_types": [...] } } ]` (single-object,
|
|
list-wrapped, keyed by category id), so locate the array wherever it sits."""
|
|
p = RESOURCES_ROOT / "wilds" / "wilds.json"
|
|
if not p.exists():
|
|
return set()
|
|
|
|
def find_lair_types(obj: object) -> list | None:
|
|
if isinstance(obj, dict):
|
|
if isinstance(obj.get("lair_types"), list):
|
|
return obj["lair_types"]
|
|
for v in obj.values():
|
|
hit = find_lair_types(v)
|
|
if hit is not None:
|
|
return hit
|
|
elif isinstance(obj, list):
|
|
for v in obj:
|
|
hit = find_lair_types(v)
|
|
if hit is not None:
|
|
return hit
|
|
return None
|
|
|
|
lts = find_lair_types(json.loads(p.read_text())) or []
|
|
return {e["id"] for e in lts if isinstance(e, dict) and e.get("id")}
|
|
|
|
|
|
def _collect_sprite_paths(obj: object, prefix: str, out: dict[str, str]) -> None:
|
|
"""Walk `obj`, recording {basename: path} for every string `sprite` field
|
|
whose value starts with `prefix`."""
|
|
if isinstance(obj, dict):
|
|
sp = obj.get("sprite")
|
|
if isinstance(sp, str) and sp.startswith(prefix):
|
|
out[sp[len(prefix):-4]] = sp # strip prefix + ".png"
|
|
for v in obj.values():
|
|
_collect_sprite_paths(v, prefix, out)
|
|
elif isinstance(obj, list):
|
|
for v in obj:
|
|
_collect_sprite_paths(v, prefix, out)
|
|
|
|
|
|
def _collect_ids(obj: object, ids: set[str]) -> None:
|
|
"""Walk `obj`, recording every `id` field on a dict that also looks like a
|
|
resource entry (has a `category` or `sprite` or `yields` sibling)."""
|
|
if isinstance(obj, dict):
|
|
rid = obj.get("id")
|
|
if isinstance(rid, str) and (
|
|
"category" in obj or "sprite" in obj or "yields" in obj
|
|
or "yield" in obj or "rarity" in obj
|
|
):
|
|
ids.add(rid)
|
|
for v in obj.values():
|
|
_collect_ids(v, ids)
|
|
elif isinstance(obj, list):
|
|
for v in obj:
|
|
_collect_ids(v, ids)
|
|
|
|
|
|
def resolve_icon(rid: str, table: dict, *, wild: bool = False,
|
|
wonder: bool = False) -> str:
|
|
"""overrides[id] -> longest keyword substring -> (wonder_)default."""
|
|
ov = table.get("overrides", {})
|
|
if rid in ov:
|
|
return ov[rid]
|
|
kws = table.get("keywords", {})
|
|
best_key = ""
|
|
for kw in kws:
|
|
if kw in rid and len(kw) > len(best_key):
|
|
best_key = kw
|
|
if best_key:
|
|
return kws[best_key]
|
|
if wonder and table.get("wonder_default"):
|
|
return table["wonder_default"]
|
|
return table["default"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Rasterisation + framing #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def fetch_svg(icon: str, raw_base: str, allow_net: bool) -> bytes:
|
|
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:
|
|
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]
|
|
|
|
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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)
|
|
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:
|
|
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()
|
|
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_slot(mask: Image.Image, slot: dict) -> Image.Image:
|
|
glyph = tinted_glyph(mask, hex_to_rgb(slot["glyph"]))
|
|
style = slot["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 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"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Main #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
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 chosen icons resolve + report data gaps; write nothing")
|
|
ap.add_argument("--only", default="",
|
|
help="comma-separated categories to emit + merge (e.g. 'lairs')")
|
|
args = ap.parse_args(argv)
|
|
|
|
rules = json.loads(RULES_JSON.read_text())
|
|
src = rules["source"]
|
|
allow_net = not args.no_net
|
|
only = {c.strip() for c in args.only.split(",") if c.strip()}
|
|
|
|
slots, problems = build_slots(rules)
|
|
if only:
|
|
slots = [s for s in slots if s["category"] in only]
|
|
|
|
if args.check:
|
|
# Unique icons -> confirm each fetches.
|
|
icons = sorted({s["icon"] for s in slots})
|
|
bad: list[str] = []
|
|
for icon in icons:
|
|
try:
|
|
fetch_svg(icon, src["raw_base"], allow_net)
|
|
except Exception as e: # noqa: BLE001
|
|
bad.append(f"{icon}: {e}")
|
|
n_files = sum(len(s["out_paths"]) for s in slots)
|
|
print(f"slots: {len(slots)} unique icons: {len(icons)} "
|
|
f"output files: {n_files}")
|
|
if problems:
|
|
print(f"\nDATA GAPS ({len(problems)}):")
|
|
for p in problems:
|
|
print(" -", p)
|
|
if bad:
|
|
print(f"\nUNRESOLVED ICONS ({len(bad)}):")
|
|
for b in bad:
|
|
print(" -", b)
|
|
return 1
|
|
print("\nOK — every chosen icon resolves.")
|
|
return 0
|
|
|
|
today = date.today().isoformat()
|
|
# Keyed by output path so a sprite shared by >1 slot (e.g. warrior /
|
|
# dwarf_warrior both declare sprites/units/warrior.png) yields exactly one
|
|
# ledger + manifest row — the on-disk PNG is identical either way.
|
|
ledger_by_path: dict[str, str] = {}
|
|
manifest_by_path: dict[str, str] = {}
|
|
|
|
for slot in slots:
|
|
svg = fetch_svg(slot["icon"], src["raw_base"], allow_net)
|
|
mask = glyph_mask(svg, slot["size"])
|
|
img = render_slot(mask, slot)
|
|
|
|
author_slug = slot["icon"].split("/")[0]
|
|
author = src["authors"].get(author_slug, author_slug)
|
|
url = page_url(slot["icon"], src["page_base"])
|
|
|
|
for sprite_path in slot["out_paths"]:
|
|
# sprite_path is "sprites/<...>.png" relative to the assets dir.
|
|
out_path = ASSETS_ROOT / sprite_path
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
img.save(out_path, "PNG")
|
|
# Ledger/manifest rows are relative to the sprites dir (schema).
|
|
rel = out_path.relative_to(SPRITES_ROOT).as_posix()
|
|
sha = sha256_of(out_path)
|
|
ledger_by_path[rel] = (
|
|
f"| {rel} | game-icons.net (stand-in) | {src['license']} | "
|
|
f"{author} | {url} | {sha} | {today} |")
|
|
manifest_by_path[rel] = (
|
|
f"| {rel} | {slot['category']} | {slot['id']} | "
|
|
f"{slot['icon']} | {slot['size']}px |")
|
|
|
|
ledger_rows = list(ledger_by_path.values())
|
|
manifest_rows = list(manifest_by_path.values())
|
|
written = len(ledger_rows)
|
|
|
|
write_ledger(ledger_rows, merge=bool(only))
|
|
write_manifest(manifest_rows, len(slots), problems, merge=bool(only))
|
|
print(f"Wrote {written} stand-in PNGs across {len(slots)} slots "
|
|
f"-> {SPRITES_ROOT}" + (f" (--only {','.join(sorted(only))}, merged)" if only else ""))
|
|
if problems:
|
|
print(f"{len(problems)} data gaps (see STANDINS.md gap list).")
|
|
print(f"Updated ledger: {LEDGER}")
|
|
print(f"Updated manifest: {MANIFEST}")
|
|
return 0
|
|
|
|
|
|
def _row_path(line: str) -> str:
|
|
"""First markdown table cell (the sprite path) of a `| path | … |` row."""
|
|
return line.split("|")[1].strip()
|
|
|
|
|
|
def write_ledger(rows: list[str], merge: bool = False) -> None:
|
|
"""Replace the '## Assets' table body in LICENSES.md with `rows`,
|
|
preserving the header above and the '## Audit' section below.
|
|
|
|
merge=True: union `rows` (keyed by path) with the rows already in the file,
|
|
so a partial (--only) run adds its rows without dropping any others."""
|
|
text = LEDGER.read_text()
|
|
if merge:
|
|
existing = {_row_path(l): l for l in text.splitlines()
|
|
if l.startswith("| ") and "game-icons" in l}
|
|
for line in rows:
|
|
existing[_row_path(line)] = line
|
|
rows = list(existing.values())
|
|
header = (
|
|
"| Path | Source | License | Author | URL | SHA256 | Added |\n"
|
|
"|---|---|---|---|---|---|---|\n"
|
|
)
|
|
block = "## Assets\n\n" + header + "\n".join(sorted(rows)) + "\n\n"
|
|
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, problems: list[str],
|
|
merge: bool = False) -> None:
|
|
if merge:
|
|
_merge_manifest_rows(rows)
|
|
return
|
|
gap_block = ""
|
|
if problems:
|
|
gap_block = (
|
|
"\n## Coverage gaps (subscribed but no stand-in emitted)\n\n"
|
|
+ "\n".join(f"- {p}" for p in sorted(problems)) + "\n"
|
|
)
|
|
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).
|
|
|
|
Coverage is data-driven: the slot set is derived from
|
|
`public/games/age-of-dwarves/manifest.json` (`subscribes.units` / `.buildings`)
|
|
resolved against `public/resources/<cat>/<id>.json` sprite paths — not a frozen
|
|
list. Re-run after the subscription or any unit/building `sprite` field changes.
|
|
|
|
Those objectives stay **partial**: stand-ins give complete *slot coverage* but do
|
|
not meet their 256/512 px native-resolution + ranker 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`.
|
|
- Regenerate: `python3 tools/standin-sprites/build_standins.py`
|
|
(icon rules in `tools/standin-sprites/icon_rules.json`).
|
|
- Slots covered: {slot_count}. Files emitted: {len(rows)}.
|
|
{gap_block}
|
|
| Path | Category | Slot id | Source icon | Native size |
|
|
|---|---|---|---|---|
|
|
""" + "\n".join(sorted(rows)) + "\n"
|
|
MANIFEST.write_text(body)
|
|
|
|
|
|
def _merge_manifest_rows(new_rows: list[str]) -> None:
|
|
"""Insert `new_rows` into STANDINS.md's rows table, keyed by path, leaving the
|
|
prose, coverage-gap list, and every existing row byte-identical. Bumps the
|
|
'Slots covered: N. Files emitted: M.' line by the count of rows actually added
|
|
(each --only row is exactly one slot and one file)."""
|
|
text = MANIFEST.read_text()
|
|
lines = text.splitlines()
|
|
try:
|
|
hdr = next(i for i, l in enumerate(lines)
|
|
if l.startswith("| Path | Category |"))
|
|
except StopIteration as e:
|
|
raise RuntimeError("could not locate rows table header in STANDINS.md") from e
|
|
pre, sep = lines[:hdr + 2], hdr + 2 # header line + the |---| separator
|
|
rowmap = {_row_path(l): l for l in lines[sep:] if l.startswith("| ")}
|
|
before = len(rowmap)
|
|
for line in new_rows:
|
|
rowmap[_row_path(line)] = line
|
|
added = len(rowmap) - before
|
|
body = "\n".join(pre) + "\n" + "\n".join(sorted(rowmap.values())) + "\n"
|
|
m = re.search(r"Slots covered: (\d+)\. Files emitted: (\d+)\.", body)
|
|
if m:
|
|
body = body.replace(
|
|
m.group(0),
|
|
f"Slots covered: {int(m.group(1)) + added}. "
|
|
f"Files emitted: {int(m.group(2)) + added}.")
|
|
MANIFEST.write_text(body)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|