#!/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()