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>
321 lines
20 KiB
Python
321 lines
20 KiB
Python
#!/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.")
|