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:
parent
92850560f8
commit
ee5234a80b
2 changed files with 401 additions and 0 deletions
308
tools/standin-sprites/build_standins.py
Normal file
308
tools/standin-sprites/build_standins.py
Normal 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())
|
||||
93
tools/standin-sprites/mapping.json
Normal file
93
tools/standin-sprites/mapping.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue