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>
151 lines
7.7 KiB
Python
151 lines
7.7 KiB
Python
#!/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()
|