saumwacht-godot/tools/build_asset_gallery.py
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

321 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Build the offline Phase 3 gallery; run from any working directory.
Reads the plan, records and PNG headers only. Native image bytes are never edited.
Usage: python3 /path/to/saumwacht-godot/tools/build_asset_gallery.py
"""
from __future__ import annotations
import html
import json
import os
from pathlib import Path
import struct
import sys
from urllib.parse import quote
PROJECT = Path(__file__).resolve().parents[1]
ASSET_ROOT = PROJECT / "assets-source" / "phase3"
OUTPUT = ASSET_ROOT / "gallery.html"
FACTIONS = {
"glutpakt": "Glutpakt",
"nietenkonvent": "Nietenkonvent",
"nebelchor": "Nebelchor",
"wurzelbund": "Wurzelbund",
"neutral": "Neutrale",
"resources": "Ressourcen",
"types": "Schaden & Rüstung",
"map": "Karte",
"ui": "Oberfläche",
}
KINDS = {
"unit_icon": "Einheitenicon",
"mercenary_icon": "Söldnericon",
"resource_icon": "Ressourcenicon",
"worker_icon": "Arbeitericon",
"type_icon": "Typicon",
"map_concept": "Kartenkonzept",
"tile_concept": "Bodenkachel",
"ui_frame": "UI-Rahmen",
"ui_button": "Button",
}
def esc(value: object) -> str:
return html.escape(str(value), quote=True)
def local_url(path: Path) -> str:
return quote(Path(os.path.relpath(path, ASSET_ROOT)).as_posix(), safe="/.")
def png_size(path: Path) -> tuple[int, int] | None:
"""Inspect the native PNG header without decoding or changing pixels."""
try:
with path.open("rb") as source:
header = source.read(24)
if len(header) == 24 and header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR":
return struct.unpack(">II", header[16:24])
except OSError:
pass
return None
def read_assets() -> list[dict]:
plan = json.loads((ASSET_ROOT / "asset_plan.json").read_text(encoding="utf-8"))
assets = []
for planned in plan["assets"]:
asset = dict(planned)
record_path = ASSET_ROOT / "records" / f"{planned['id']}.json"
record = {}
issue = "Datensatz und Bild stehen noch aus."
if record_path.is_file():
try:
record = json.loads(record_path.read_text(encoding="utf-8"))
if not isinstance(record, dict) or record.get("id") != planned["id"]:
raise ValueError("Datensatz-ID stimmt nicht mit dem Plan überein")
except (OSError, ValueError) as error:
print(f"Hinweis: {record_path.name}: {error}", file=sys.stderr)
record = {}
issue = "Datensatz ist noch nicht lesbar."
asset.update(record)
asset["id"] = planned["id"]
master = None
if record.get("master_path"):
given = Path(record["master_path"])
candidates = [given] if given.is_absolute() else [ASSET_ROOT / given, PROJECT / given]
# Keep a copied project usable when records retain their original absolute paths.
if "masters" in given.parts:
index = given.parts.index("masters")
candidates.insert(0, ASSET_ROOT.joinpath(*given.parts[index:]))
master = next((path for path in candidates if path.is_file()), None)
issue = "Originaldatei fehlt noch im Projekt."
size = png_size(master) if master else None
if master and not size:
issue = "Originaldatei ist noch kein lesbares PNG."
asset["ready"] = bool(record and master and size)
asset["issue"] = issue
asset["image_url"] = local_url(master) if asset["ready"] else ""
asset["record_url"] = local_url(record_path) if record else ""
asset["dimensions"] = size
assets.append(asset)
return assets
def card(asset: dict) -> str:
asset_id = asset["id"]
name = asset.get("display_name", asset_id)
faction = asset.get("faction", "unknown")
kind = asset.get("kind", "unknown")
faction_name = FACTIONS.get(faction, faction)
kind_name = KINDS.get(kind, kind)
description = asset.get("description_de", "Beschreibung folgt mit dem erzeugten Asset.")
ready = asset["ready"]
state = "ready" if ready else "pending"
is_icon = kind.endswith("_icon")
dimensions = asset["dimensions"]
wide = not is_icon and (kind == "map_concept" or (dimensions and dimensions[0] / dimensions[1] > 1.6))
classes = "asset-card" + (" wide-card" if wide else "") + (" pending-card" if not ready else "")
search = " ".join(str(item) for item in (asset_id, name, faction_name, kind_name, description))
if ready:
width, height = dimensions
preview = f'''<a class="preview-link" href="{esc(asset['image_url'])}" target="_blank" rel="noopener" aria-label="{esc(name)}: PNG-Original öffnen">
<img src="{esc(asset['image_url'])}" alt="{esc(name)}" width="{width}" height="{height}" loading="lazy" decoding="async">
</a>'''
size_label = f"{width} × {height} px"
links = f'''<a href="{esc(asset['image_url'])}" target="_blank" rel="noopener">PNG-Original <span aria-hidden="true">↗</span></a>
<a href="{esc(asset['record_url'])}" target="_blank" rel="noopener">Datensatz <span aria-hidden="true">↗</span></a>'''
else:
preview = '<div class="placeholder"><span class="placeholder-mark" aria-hidden="true">◇</span><strong>Ausstehend</strong><span>' + esc(asset["issue"]) + '</span></div>'
size_label = "Noch kein Original"
links = f'<a href="{esc(asset["record_url"])}" target="_blank" rel="noopener">Datensatz ↗</a>' if asset["record_url"] else ""
alpha = "Alpha vorgesehen" if asset.get("requires_alpha") else "Flächiges Motiv"
prompt = asset.get("prompt")
prompt_html = f'<details><summary>Generierungsprompt</summary><pre>{esc(prompt)}</pre></details>' if prompt else '<p class="prompt-pending">Prompt folgt mit dem Datensatz.</p>'
stage_class = "icon-stage" if is_icon else "media-stage"
return f'''<article class="{classes}" data-id="{esc(asset_id)}" data-faction="{esc(faction)}" data-kind="{esc(kind)}" data-state="{state}" data-search="{esc(search)}">
<div class="card-top"><code>{esc(asset_id)}</code><span class="status {state}"><span aria-hidden="true">●</span> {"Vorhanden" if ready else "Ausstehend"}</span></div>
<div class="preview-stage {stage_class}">{preview}</div>
<div class="card-content"><p class="eyebrow">{esc(faction_name)} <span aria-hidden="true">/</span> {esc(kind_name)}</p>
<h2>{esc(name)}</h2><p class="description">{esc(description)}</p>
<div class="metadata"><span>{size_label}</span><span>{alpha}</span></div>
{prompt_html}<div class="card-links">{links}</div>
</div>
</article>'''
CSS = r"""
:root { color-scheme: dark; --page:#101a20; --panel:#18262e; --line:#30424a; --ink:#edf0e9; --muted:#a9b9b8; --gold:#d9b66b; --mint:#82dbce; --icon-size:128px; }
* { box-sizing:border-box; }
body { margin:0; background:var(--page); color:var(--ink); font:15px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
button,input,select { font:inherit; }
button,select { cursor:pointer; }
a { color:var(--mint); text-underline-offset:4px; }
a:hover { color:#fff; }
:focus-visible { outline:3px solid var(--mint); outline-offset:4px; }
.skip { position:absolute; top:-100px; left:20px; padding:12px; background:var(--page); z-index:20; }
.skip:focus { top:10px; }
.shell { max-width:1580px; margin:0 auto; padding:0 36px; }
.hero { padding:48px 0 28px; display:flex; gap:36px; justify-content:space-between; align-items:flex-end; border-bottom:1px solid var(--line); }
.kicker { margin:0 0 12px; color:var(--gold); text-transform:uppercase; letter-spacing:.2em; font-size:11px; font-weight:750; }
h1 { margin:0; font-size:clamp(36px,5vw,66px); letter-spacing:-.045em; line-height:1.05; font-weight:650; }
.intro { margin:18px 0 0; color:var(--muted); max-width:700px; }
.stats { display:flex; gap:32px; margin:0 0 2px; flex-shrink:0; }
.stats div { min-width:68px; display:flex; flex-direction:column; }
.stats dt { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.1em; }
.stats dd { order:-1; margin:0 0 6px; font-size:35px; line-height:1; color:var(--gold); font-variant-numeric:tabular-nums; }
.toolbar { margin:26px 0 0; padding:22px; background:var(--panel); border:1px solid var(--line); border-radius:14px; }
.filter-row { display:grid; grid-template-columns:minmax(220px,2fr) repeat(3,minmax(155px,1fr)); gap:14px; }
label { display:block; color:var(--muted); font-size:12px; font-weight:600; }
input,select { display:block; width:100%; height:44px; margin-top:7px; border:1px solid #41545c; border-radius:7px; background:#111e25; color:var(--ink); padding:0 12px; }
input::placeholder { color:#859a9e; }
.view-row { display:flex; flex-wrap:wrap; align-items:center; gap:20px 30px; border-top:1px solid var(--line); padding-top:18px; margin-top:18px; }
fieldset { display:flex; gap:4px; padding:0; border:0; margin:0; min-width:0; }
legend { padding:0; margin-bottom:8px; color:var(--muted); font-size:12px; }
.choice { min-height:36px; padding:6px 12px; color:var(--muted); border:1px solid #41545c; border-radius:6px; background:transparent; }
.choice[aria-pressed="true"] { background:#294844; color:#dafff5; border-color:#71b7a9; }
.choice:hover { border-color:var(--gold); color:var(--ink); }
.view-note { font-size:12px; color:var(--muted); margin:20px 0 0; flex:1; min-width:170px; }
.results-line { display:flex; gap:16px; align-items:center; justify-content:space-between; padding:24px 0 16px; }
#result-count { color:var(--muted); margin:0; font-size:13px; }
.reset { color:var(--mint); background:none; border:0; padding:6px 0; font-size:13px; }
.gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:20px; align-items:start; }
.asset-card { min-width:0; overflow:hidden; border:1px solid var(--line); border-radius:12px; background:var(--panel); }
.asset-card:hover { border-color:#637968; }
.wide-card { grid-column:span 2; }
.card-top { padding:11px 16px; display:flex; justify-content:space-between; gap:10px; align-items:center; }
code { color:#bac9c8; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; }
.status { font-size:10px; letter-spacing:.025em; white-space:nowrap; }
.status span { font-size:8px; margin-right:3px; }
.status.ready { color:var(--mint); }
.status.pending { color:var(--gold); }
.preview-stage { display:flex; align-items:center; justify-content:center; overflow:hidden; border-top:1px solid #ffffff09; border-bottom:1px solid #ffffff09; background-color:#26343d; }
body[data-background="checker"] .preview-stage { background-color:#33434b; background-image:linear-gradient(45deg,#27353d 25%,transparent 25%),linear-gradient(-45deg,#27353d 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#27353d 75%),linear-gradient(-45deg,transparent 75%,#27353d 75%); background-size:24px 24px; background-position:0 0,0 12px,12px -12px,-12px 0; }
body[data-background="light"] .preview-stage { background-color:#edf0e8; background-image:none; }
body[data-background="dark"] .preview-stage { background-color:#0a1118; background-image:none; }
.icon-stage { height:286px; padding:14px; }
.preview-link { display:flex; width:100%; height:100%; align-items:center; justify-content:center; }
.preview-link img { display:block; object-fit:contain; max-width:100%; }
.icon-stage img { width:var(--icon-size); height:auto; max-height:var(--icon-size); }
.media-stage { min-height:230px; padding:18px; }
.media-stage .preview-link { height:auto; }
.media-stage img { width:auto; height:auto; max-width:100%; max-height:390px; }
.card-content { padding:18px; }
.eyebrow { color:var(--gold); text-transform:uppercase; font-size:10px; letter-spacing:.09em; margin:0 0 8px; }
.eyebrow span { color:#64797b; margin:0 5px; }
h2 { font-size:20px; line-height:1.2; letter-spacing:-.025em; margin:0 0 11px; }
.description { font-size:13px; color:#b7c7c7; line-height:1.6; margin:0 0 16px; min-height:62px; }
.metadata { display:flex; flex-wrap:wrap; gap:6px; font-size:10px; color:var(--muted); }
.metadata span { border:1px solid #3c4d52; border-radius:4px; padding:3px 6px; }
details { margin-top:16px; padding-top:12px; border-top:1px solid var(--line); }
summary { font-size:12px; cursor:pointer; color:#d5dfd9; }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; color:#b9cbc6; margin:12px 0 0; }
.card-links { display:flex; flex-wrap:wrap; gap:16px; margin-top:15px; font-size:12px; }
.card-links a { text-decoration:none; }
.card-links a:hover { text-decoration:underline; }
.prompt-pending { font-size:12px; color:var(--muted); margin:16px 0 0; }
.placeholder { display:flex; flex-direction:column; gap:6px; align-items:center; text-align:center; color:#c6b88f; max-width:220px; padding:20px; background:#152229de; border:1px dashed #6d735f; border-radius:8px; }
.placeholder-mark { font-size:35px; line-height:1.2; }
.placeholder strong { font-size:14px; font-weight:550; }
.placeholder span:last-child { font-size:11px; }
.pending-card { border-style:dashed; }
.empty { padding:64px 24px; text-align:center; border:1px dashed var(--line); border-radius:12px; color:var(--muted); }
.empty strong { display:block; color:var(--ink); font-size:21px; margin-bottom:8px; }
footer { padding:32px 0 40px; color:#8ba3a5; font-size:12px; display:flex; justify-content:space-between; gap:20px; }
footer p { margin:0; }
noscript p { color:var(--gold); border:1px solid var(--gold); border-radius:8px; padding:12px; }
[hidden] { display:none !important; }
@media (max-width:1050px) { .hero { align-items:flex-start; flex-direction:column; gap:24px; } .stats { gap:40px; } .filter-row { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width:680px) { .shell { padding:0 18px; } .hero { padding-top:32px; } .toolbar { padding:16px; } .filter-row { grid-template-columns:1fr; } .gallery { grid-template-columns:1fr; } .wide-card { grid-column:auto; } .stats { gap:24px; } .stats dd { font-size:28px; } .results-line { align-items:flex-start; } footer { flex-direction:column; } }
@media (prefers-reduced-motion:no-preference) { .asset-card,.choice { transition:border-color .15s ease,background-color .15s ease; } }
"""
JS = r"""
(() => {
const cards = [...document.querySelectorAll('.asset-card')];
const search = document.getElementById('search');
const faction = document.getElementById('faction');
const kind = document.getElementById('kind');
const state = document.getElementById('state');
const count = document.getElementById('result-count');
const normalize = value => value.toLocaleLowerCase('de').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss');
const texts = new Map(cards.map(card => [card, normalize(card.dataset.search)]));
function filter() {
const terms = normalize(search.value).trim().split(/\s+/).filter(Boolean);
let visible = 0;
let ready = 0;
for (const card of cards) {
const match = (!faction.value || card.dataset.faction === faction.value)
&& (!kind.value || card.dataset.kind === kind.value)
&& (!state.value || card.dataset.state === state.value)
&& terms.every(term => texts.get(card).includes(term));
card.hidden = !match;
if (match) { visible++; if (card.dataset.state === 'ready') ready++; }
}
count.textContent = `${visible} von ${cards.length} Assets · ${ready} vorhanden · ${visible - ready} ausstehend`;
document.getElementById('empty').hidden = visible !== 0;
}
search.addEventListener('input', filter);
for (const select of [faction, kind, state]) select.addEventListener('change', filter);
document.getElementById('reset').addEventListener('click', () => {
search.value = ''; faction.value = ''; kind.value = ''; state.value = ''; filter(); search.focus();
});
for (const button of document.querySelectorAll('[data-background-choice]')) {
button.addEventListener('click', () => {
document.body.dataset.background = button.dataset.backgroundChoice;
document.querySelectorAll('[data-background-choice]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
});
}
for (const button of document.querySelectorAll('[data-size]')) {
button.addEventListener('click', () => {
document.documentElement.style.setProperty('--icon-size', `${button.dataset.size}px`);
document.querySelectorAll('[data-size]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
});
}
filter();
})();
"""
def options(assets: list[dict], key: str, labels: dict[str, str]) -> str:
values = dict.fromkeys(asset.get(key, "unknown") for asset in assets)
return "".join(f'<option value="{esc(value)}">{esc(labels.get(value, value))}</option>' for value in values)
def build() -> tuple[int, int]:
assets = read_assets()
ready = sum(asset["ready"] for asset in assets)
pending = len(assets) - ready
content = f'''<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark"><title>Saumwacht · Assetgalerie · Phase 3</title>
<style>{CSS}</style></head>
<body data-background="checker"><a class="skip" href="#gallery">Zur Galerie springen</a>
<div class="shell"><header class="hero"><div><p class="kicker">Saumwacht / Werkstatt / Phase 03</p>
<h1>Die Welt nimmt Form an.</h1><p class="intro">Einheiten, Ressourcen und Oberflächen in einer gemeinsamen Bildsprache. Native PNG-Originale vergleichen, Details prüfen und Prompts nachlesen.</p></div>
<dl class="stats"><div><dt>Geplant</dt><dd>{len(assets)}</dd></div><div><dt>Vorhanden</dt><dd>{ready}</dd></div><div><dt>Ausstehend</dt><dd>{pending}</dd></div></dl></header>
<main><section class="toolbar" aria-label="Galerie filtern und Darstellung ändern"><div class="filter-row">
<label for="search">Suche<input id="search" type="search" placeholder="Name, ID oder Beschreibung …" autocomplete="off"></label>
<label for="faction">Fraktion & Bereich<select id="faction"><option value="">Alle Bereiche</option>{options(assets, 'faction', FACTIONS)}</select></label>
<label for="kind">Assettyp<select id="kind"><option value="">Alle Typen</option>{options(assets, 'kind', KINDS)}</select></label>
<label for="state">Status<select id="state"><option value="">Alle Assets</option><option value="ready">Vorhanden</option><option value="pending">Ausstehend</option></select></label></div>
<div class="view-row"><fieldset><legend>Hintergrund zur Alpha-Prüfung</legend><button class="choice" data-background-choice="checker" aria-pressed="true">Schachbrett</button><button class="choice" data-background-choice="light" aria-pressed="false">Hell</button><button class="choice" data-background-choice="dark" aria-pressed="false">Dunkel</button></fieldset>
<fieldset><legend>Icongröße</legend><button class="choice" data-size="48" aria-pressed="false">48 px</button><button class="choice" data-size="64" aria-pressed="false">64 px</button><button class="choice" data-size="128" aria-pressed="true">128 px</button><button class="choice" data-size="256" aria-pressed="false">256 px</button></fieldset>
<p class="view-note">Karten und UI behalten ihr Seitenverhältnis. Ein Klick aufs Bild öffnet das Original.</p></div></section>
<noscript><p>Alle Assets sind sichtbar. Suche, Filter und Darstellungswechsel benötigen JavaScript.</p></noscript>
<div class="results-line"><p id="result-count" role="status" aria-live="polite">{len(assets)} Assets · {ready} vorhanden · {pending} ausstehend</p><button class="reset" id="reset">Filter zurücksetzen</button></div>
<section class="gallery" id="gallery" aria-label="Assetgalerie">{''.join(card(asset) for asset in assets)}</section>
<div class="empty" id="empty" hidden><strong>Keine passenden Assets.</strong>Andere Suchbegriffe wählen oder die Filter zurücksetzen.</div></main>
<footer><p>Offline-Galerie · Bilder unverändert aus den nativen PNG-Masters.</p><p>Aktualisieren: <code>python3 tools/build_asset_gallery.py</code></p></footer></div>
<script>{JS}</script></body></html>
'''
OUTPUT.write_text(content, encoding="utf-8")
return len(assets), ready
if __name__ == "__main__":
total, available = build()
print(f"Galerie: {OUTPUT}\n{total} Assets: {available} vorhanden, {total - available} ausstehend.")