saumwacht-godot/tools/prepare_phase3_assets.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

151 lines
7.7 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
"""Validate and copy native Phase 3 PNGs; never resample or edit image pixels.
Requires Pillow for read-only PNG inspection. Run once, let Godot import, then run
with --import-settings and import again to apply the 256px icon texture limit.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import re
import shutil
from PIL import Image
PROJECT = Path(__file__).resolve().parents[1]
SOURCE = PROJECT / "assets-source" / "phase3"
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def source_master(record: dict) -> Path:
given = Path(record["master_path"])
# Preserve portability when the project is copied to another machine.
index = given.parts.index("masters")
candidate = SOURCE.joinpath(*given.parts[index:]).resolve()
if not candidate.is_relative_to(SOURCE / "masters"):
raise ValueError("Master path escapes the asset directory")
return candidate
def inspect_assets() -> list[dict]:
planned = json.loads((SOURCE / "asset_plan.json").read_text(encoding="utf-8"))["assets"]
if len(planned) != 66 or len({entry["id"] for entry in planned}) != 66:
raise ValueError("Expected 66 unique planned assets")
result = []
for entry in planned:
record_file = SOURCE / "records" / (entry["id"] + ".json")
record = json.loads(record_file.read_text(encoding="utf-8"))
for field in ("id", "display_name", "faction", "kind"):
if record[field] != entry[field]:
raise ValueError(f"{entry['id']}: {field} differs from plan")
if not record.get("prompt") or not record.get("description_de"):
raise ValueError(f"{entry['id']}: missing prompt or description")
master = source_master(record)
checksum = digest(master)
original = Path(record["source_path"])
if original.is_file() and digest(original) != checksum:
raise ValueError(f"{entry['id']}: master differs from generated original")
with Image.open(master) as image:
image.load()
width, height = image.size
if image.format != "PNG":
raise ValueError(f"{entry['id']}: not PNG")
alpha_range = None
if entry["requires_alpha"]:
if image.mode != "RGBA":
raise ValueError(f"{entry['id']}: expected actual RGBA transparency")
alpha = image.getchannel("A")
alpha_range = list(alpha.getextrema())
if alpha_range[0] != 0 or alpha_range[1] < 200:
raise ValueError(f"{entry['id']}: missing transparent or visible pixels")
if alpha.getpixel((0, 0)) > 2:
raise ValueError(f"{entry['id']}: outer corner is not transparent")
if entry["kind"] == "ui_frame" and alpha.getpixel((width // 2, height // 2)) > 2:
raise ValueError(f"{entry['id']}: frame center must be transparent")
if entry["kind"].endswith("_icon") and width != height:
raise ValueError(f"{entry['id']}: icon is not square")
mode = image.mode
folder = "icons/" + entry["faction"] if entry["kind"].endswith("_icon") else ("ui" if entry["faction"] == "ui" else "concepts")
target = PROJECT / "assets" / folder / master.name
result.append({**entry, "description_de": record["description_de"],
"resource_path": "res://" + target.relative_to(PROJECT).as_posix(),
"master_path": master.relative_to(PROJECT).as_posix(),
"record_path": record_file.relative_to(PROJECT).as_posix(),
"width": width, "height": height, "mode": mode,
"alpha_range": alpha_range, "sha256": checksum,
"generation_mode": record.get("generation_mode", "built-in"),
"status": "prepared"})
return result
def apply_import_settings(assets: list[dict]) -> None:
configs = []
for asset in assets:
path = PROJECT / (asset["resource_path"].removeprefix("res://") + ".import")
if not path.is_file():
raise ValueError(f"Import config missing: {path}; run Godot --editor --import --quit first")
content = path.read_text(encoding="utf-8")
settings = {"process/size_limit": str(asset["import_size_limit"]),
"compress/mode": "0", "mipmaps/generate": "false",
"process/fix_alpha_border": "true", "process/premult_alpha": "false"}
for key, value in settings.items():
content, count = re.subn(r"(?m)^" + re.escape(key) + r"=.*$", key + "=" + value, content)
if count != 1:
raise ValueError(f"{path.name}: missing or repeated setting {key}")
configs.append((path, content))
for path, content in configs:
if path.read_text(encoding="utf-8") != content:
path.write_text(content, encoding="utf-8")
def prepare(assets: list[dict]) -> None:
for asset in assets:
target = PROJECT / asset["resource_path"].removeprefix("res://")
master = PROJECT / asset["master_path"]
target.parent.mkdir(parents=True, exist_ok=True)
if not target.is_file() or digest(target) != asset["sha256"]:
shutil.copyfile(master, target)
if digest(target) != asset["sha256"]:
raise ValueError(f"Copy checksum mismatch: {asset['id']}")
manifest = {"phase": 3, "asset_count": len(assets),
"note": "Native PNG masters; 56 icon textures use a Godot import limit of 256px. Gameplay integration follows in Phase 5.",
"assets": assets}
write_json(PROJECT / "assets" / "phase3_manifest.json", manifest)
write_json(PROJECT / "assets" / "icons" / "manifest.json",
{"phase": 3, "assets": [a for a in assets if a["kind"].endswith("_icon")]})
records = [json.loads((PROJECT / a["record_path"]).read_text(encoding="utf-8")) for a in assets]
write_json(SOURCE / "all-prompts.json", {"phase": 3, "assets": records})
lines = ["# Phase 3 · Assetkatalog", "", "66 einzeln generierte Assets für Saumwacht. Alle PNG-Masters bleiben in nativer Auflösung erhalten; die 56 Icons werden in Godot als 256 × 256 Texturen importiert.", "", "[Interaktive Galerie](gallery.html) · [Prompts und Herkunft](all-prompts.json)", "", "| ID | Name | Gruppe | PNG | Beschreibung |", "| --- | --- | --- | --- | --- |"]
for asset in assets:
path = Path(asset["master_path"]).relative_to("assets-source/phase3").as_posix()
lines.append(f"| `{asset['id']}` | [{asset['display_name']}]({path}) | {asset['faction']} | {asset['width']} × {asset['height']} | {asset['description_de'].replace('|', '/')} |")
(SOURCE / "catalog.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--import-settings", action="store_true", help="Apply limits to existing Godot .import files")
args = parser.parse_args()
assets = inspect_assets() # Validate the complete batch before copying any files.
prepare(assets)
if args.import_settings:
apply_import_settings(assets)
print(f"PASS: {len(assets)} native PNGs validated and copied without pixel changes; manifests and catalog written.")
if not args.import_settings:
print("Next: Godot import, rerun with --import-settings, then Godot reimport and verify_phase3_imports.gd.")
if __name__ == "__main__":
main()