"""Monster authoring bridge: Blender -> листы -> манифест SpriteForge. Родственник tools/agent_assets.py, но задача другая, и разница принципиальная. Агент — бумажная кукла: тело, броня и оружие обязаны совпасть пиксель в пиксель, поэтому там один скелет, одна калибровка и растровый вход с чужих генераторов. Тварь — цельная фигура: слоёв нет, склеивать нечего, зато пять видов обязаны РАЗЛИЧАТЬСЯ силуэтом с одного взгляда. Поэтому здесь нет ни ingest, ни калибровки, зато есть проверка «морфологии не совпали». Общее у двух конвейеров ровно одно и намеренно: неподвижная ортографическая изометрическая камера и вырезка постоянным прямоугольником. Начало координат рига проецируется в центр кадра, значит упаковщик режет все кадры одинаково и подгона по bbox не существует в принципе — тварь не «дышит» при повороте. python tools/monster_assets.py monster-init python tools/monster_assets.py monster-render shambler walk python tools/monster_assets.py monster-render-all python tools/monster_assets.py validate python tools/monster_assets.py sync """ from __future__ import annotations import argparse import json import os import re import shutil import subprocess import sys import tempfile from dataclasses import dataclass from pathlib import Path from PIL import Image, ImageChops import yaml DEFAULT_BLENDER = r"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" # Допуски проверок, в пикселях листа. GROUND_SLACK = 4 # насколько низ силуэта может не дотягивать до pivot_y CENTER_SLACK = 6 # снос центра «розы направлений» от pivot_x # Порог зеркального совпадения силуэтов для проверки порядка направлений. MIRROR_IOU_MIN = 0.55 # Выше этого два направления (или два ВИДА) считаются одной картинкой. DISTINCT_IOU_MAX = 0.98 KIND_IOU_MAX = 0.90 def fnv1a(text: str) -> int: value = 0x811C9DC5 for byte in text.encode("utf-8"): value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF return value def symbol(text: str) -> str: return re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").upper() def asset_id(kind: str, animation: str) -> str: """ID ровно того вида, который требует tools/asset_coverage.py.""" return f"{kind}_{animation}" # --------------------------------------------------------------------------- # Пути и конфигурация # --------------------------------------------------------------------------- @dataclass(frozen=True) class Workspace: """Все пути конвейера в одном месте: тесты подставляют временный корень.""" root: Path @property def catalog(self) -> Path: return self.root / "assets/monsters/catalog.yaml" @property def source_root(self) -> Path: return self.root / "assets/sources/monsters" @property def manifest(self) -> Path: return self.root / "assets/manifests/monsters.generated.yaml" @property def manifest_dir(self) -> Path: return self.root / "assets/manifests" @property def header(self) -> Path: return self.root / "generated/monster_sprites.h" @property def blend(self) -> Path: return self.root / "assets/monsters/monster_rig.blend" @property def rig_script(self) -> Path: return self.root / "tools/blender/monster_rig.py" def source_path(self, kind: str, animation: str) -> Path: return self.source_root / f"{kind}_{animation}.png" WORKSPACE = Workspace(Path(__file__).resolve().parents[1]) @dataclass(frozen=True) class Config: width: int height: int pivot_x: int pivot_y: int directions: tuple[str, ...] animations: dict kinds: dict camera: dict # Кадр рендера симметричен относительно pivot: начало координат рига # смотрит в его центр, значит вырезка — постоянный прямоугольник. @property def render_width(self) -> int: return 2 * max(self.pivot_x, self.width - self.pivot_x) @property def render_height(self) -> int: return 2 * max(self.pivot_y, self.height - self.pivot_y) @property def crop_x(self) -> int: return self.render_width // 2 - self.pivot_x @property def crop_y(self) -> int: return self.render_height // 2 - self.pivot_y def frames(self, animation: str) -> int: return int(self.animations[animation]["frames"]) def cells(self, animation: str) -> int: return len(self.directions) * self.frames(animation) def sheet_size(self, animation: str) -> tuple[int, int]: return (self.width * self.cells(animation), self.height) def fps(self, kind: str, animation: str) -> int: """Темп клипа: общий fps анимации, растянутый под повадку вида.""" scale = float(self.kinds[kind].get("fps_scale", 1.0)) return max(1, int(round(float(self.animations[animation]["fps"]) * scale))) def load_config(ws: Workspace = WORKSPACE) -> Config: raw = yaml.safe_load(ws.catalog.read_text(encoding="utf-8")) if raw.get("version") != 1: raise ValueError("assets/monsters/catalog.yaml: only version 1 is supported") frame = raw["frame"] return Config(frame["width"], frame["height"], frame["pivot_x"], frame["pivot_y"], tuple(raw["directions"]), raw["animations"], raw["kinds"], raw.get("camera", {})) def check_names(cfg: Config, kind: str, animation: str) -> None: if kind not in cfg.kinds: raise ValueError(f"unknown monster kind: {kind}") if animation not in cfg.animations: raise ValueError(f"unknown animation: {animation}") # --------------------------------------------------------------------------- # Работа с картинками # --------------------------------------------------------------------------- def resize_rgba(image: Image.Image, size: tuple[int, int]) -> Image.Image: """LANCZOS по предумноженной альфе: иначе по кромке ползёт чёрная кайма.""" if image.size == size: return image.copy() r, g, b, a = image.convert("RGBA").split() premultiplied = Image.merge("RGBA", (ImageChops.multiply(r, a), ImageChops.multiply(g, a), ImageChops.multiply(b, a), a)) scaled = premultiplied.resize(size, Image.Resampling.LANCZOS) px = scaled.load() for y in range(scaled.height): for x in range(scaled.width): pr, pg, pb, pa = px[x, y] if pa: px[x, y] = (min(255, pr * 255 // pa), min(255, pg * 255 // pa), min(255, pb * 255 // pa), pa) else: px[x, y] = (0, 0, 0, 0) return scaled def mask_of(image: Image.Image) -> Image.Image: return image.convert("RGBA").getchannel("A").point(lambda v: 255 if v >= 24 else 0).convert("1") def cell_at(cfg: Config, sheet: Image.Image, index: int) -> Image.Image: x = index * cfg.width return sheet.crop((x, 0, x + cfg.width, cfg.height)) def paste_cell(cfg: Config, sheet: Image.Image, index: int, layer: Image.Image) -> None: sheet.paste(layer, (index * cfg.width, 0)) def bbox_of(cell: Image.Image): return mask_of(cell).convert("L").getbbox() # --------------------------------------------------------------------------- # Blender: создание .blend и рендер # --------------------------------------------------------------------------- def blender_binary(explicit: str | None = None) -> str: candidate = explicit or os.environ.get("BLENDER_BIN") or DEFAULT_BLENDER if Path(candidate).is_file(): return candidate found = shutil.which(candidate) or shutil.which("blender") if found: return found raise ValueError(f"blender not found: {candidate} (set BLENDER_BIN or pass --blender)") def run_blender(binary: str, args: list[str]) -> None: process = subprocess.run([binary, *args], capture_output=True, text=True, encoding="utf-8", errors="replace") for line in (process.stdout or "").splitlines(): if line.startswith("[monster_rig]") or line.startswith("Error"): print(line) if process.returncode != 0: sys.stderr.write(process.stdout or "") sys.stderr.write(process.stderr or "") raise ValueError(f"blender exited with {process.returncode}") def blender_config(cfg: Config) -> dict: return {"width": cfg.width, "height": cfg.height, "pivot_x": cfg.pivot_x, "pivot_y": cfg.pivot_y, "render_width": cfg.render_width, "render_height": cfg.render_height, "directions": list(cfg.directions), "animations": cfg.animations, "kinds": list(cfg.kinds), "camera": cfg.camera} def monster_init(ws: Workspace, cfg: Config, blender: str | None, force: bool, kind: str | None = None) -> Path: target = ws.root / "assets" / "monsters" / f"{kind}.blend" if kind else ws.blend if target.is_file() and not force: raise ValueError(f"{target.relative_to(ws.root).as_posix()} already exists; " "pass --force to rebuild") binary = blender_binary(blender) target.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory() as work: config_path = Path(work) / "config.json" payload = blender_config(cfg) if kind: payload["kinds"] = [kind] config_path.write_text(json.dumps(payload), encoding="utf-8") run_blender(binary, ["--factory-startup", "-b", "--python", str(ws.rig_script), "--", "build", "--config", str(config_path), "--out", str(target)]) if not target.is_file(): raise ValueError("blender finished but wrote no .blend") print(f"wrote {target.relative_to(ws.root).as_posix()}") return target def pack_render_frames(cfg: Config, animation: str, images: list[Image.Image], supersample: int) -> Image.Image: """Кадры Blender -> лист, direction-major. Одна вырезка на все ячейки. Здесь живёт гарантия общего pivot: прямоугольник вырезки зависит только от каталога, а не от содержимого кадра. Поэтому громила и затаившийся стоят на одной и той же строке, хотя габариты у них разные втрое. """ expected = cfg.cells(animation) if len(images) != expected: raise ValueError(f"{animation}: expected {expected} rendered cell(s), got {len(images)}") if supersample < 1: raise ValueError("supersample must be >= 1") source = (cfg.render_width * supersample, cfg.render_height * supersample) box = (cfg.crop_x * supersample, cfg.crop_y * supersample, (cfg.crop_x + cfg.width) * supersample, (cfg.crop_y + cfg.height) * supersample) sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) for index, image in enumerate(images): rgba = image.convert("RGBA") if rgba.size != source: raise ValueError(f"cell {index}: render is {rgba.size}, expected {source}") paste_cell(cfg, sheet, index, resize_rgba(rgba.crop(box), (cfg.width, cfg.height))) return sheet def monster_render(ws: Workspace, cfg: Config, kind: str, animation: str, blender: str | None, blend: Path | None, supersample: int, keep: Path | None = None) -> Path: check_names(cfg, kind, animation) blend_path = blend or ws.blend if not blend_path.is_file(): raise ValueError(f"no rig at {blend_path}; run `monster_assets.py monster-init` first") binary = blender_binary(blender) with tempfile.TemporaryDirectory() as work: outdir = Path(work) / "frames" payload = blender_config(cfg) payload.update({"kind": kind, "animation": animation, "frames": cfg.frames(animation), "supersample": supersample, "outdir": str(outdir)}) config_path = Path(work) / "config.json" config_path.write_text(json.dumps(payload), encoding="utf-8") run_blender(binary, ["--factory-startup", "-b", str(blend_path), "--python", str(ws.rig_script), "--", "render", "--config", str(config_path)]) images = [] for index in range(cfg.cells(animation)): frame = outdir / f"frame_{index:03d}.png" if not frame.is_file(): raise ValueError(f"blender did not write {frame.name}") images.append(Image.open(frame).copy()) if keep is not None: keep.mkdir(parents=True, exist_ok=True) for frame in sorted(outdir.glob("frame_*.png")): shutil.copy2(frame, keep / frame.name) sheet = pack_render_frames(cfg, animation, images, supersample) target = ws.source_path(kind, animation) target.parent.mkdir(parents=True, exist_ok=True) sheet.save(target) return target # --------------------------------------------------------------------------- # sync: манифест SpriteForge + заголовок с константами # --------------------------------------------------------------------------- SYNC_BANNER = [ "# Собрано `python tools/monster_assets.py sync`.", "# Записи с backend: import перегенерируются по PNG в assets/sources/monsters/.", "# Записи с любым другим backend sync СОХРАНЯЕТ как есть: правь их руками", "# здесь, это единственное место, где такая правка переживает sync.", "#", "# ID ассета — _: ровно то, что требует", "# tools/asset_coverage.py от группы enemy. Переименуешь — упадёт покрытие.", ] def existing_specs(ws: Workspace) -> dict: if not ws.manifest.is_file(): return {} raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8")) or {} return {key: value for key, value in raw.items() if isinstance(value, dict) and "backend" in value} def manual_specs(ws: Workspace) -> dict: """Ручные спеки — всё, что не `import`: их sync не трогает.""" return {key: value for key, value in existing_specs(ws).items() if value.get("backend") != "import"} def header_text(cfg: Config) -> str: """Заголовок с константами. Строковых ID ассетов в C++ быть не должно.""" anim_map = [("WALK", "walk"), ("ATTACK", "attack"), ("HURT", "hit"), ("DIE", "death")] out = ["/* Generated by tools/monster_assets.py sync. Do not edit. */", "#pragma once", "#include ", "", "#include \"render/anim.h\"", "#include \"sim/target.h\"", ""] for kind in cfg.kinds: for animation in cfg.animations: aid = asset_id(kind, animation) out.append(f"constexpr uint32_t MONSTER_ASSET_{symbol(aid)} = " f"UINT32_C(0x{fnv1a(aid):08X});") out += ["", "// AnimKind::IDLE у тварей нет: покоя у них не бывает, стоящая тварь", "// показывает первый кадр шага.", "constexpr uint32_t MonsterAsset(EnemyKind kind, AnimKind anim)", "{", " switch (kind) {"] for kind in cfg.kinds: out.append(f" case EnemyKind::{kind.upper()}:") out.append(" switch (anim) {") for enum_name, animation in anim_map: if animation not in cfg.animations: continue out.append(f" case AnimKind::{enum_name}: " f"return MONSTER_ASSET_{symbol(asset_id(kind, animation))};") out.append(f" default: return MONSTER_ASSET_{symbol(asset_id(kind, 'walk'))};") out.append(" }") out += [" default: return 0;", " }", "}", ""] return "\n".join(out) def legacy_collisions(ws: Workspace, ids: set[str]) -> dict[str, list[str]]: """ID, объявленные ЕЩЁ и в другом манифесте. Два манифеста с одним ID — это не «дубль», а неопределённость: какой файл выиграет, решает порядок обхода каталога. Ловим текстом, до сборки. """ found: dict[str, list[str]] = {} if not ws.manifest_dir.is_dir(): return found for path in sorted(ws.manifest_dir.glob("*.yaml")): if path.resolve() == ws.manifest.resolve(): continue raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} clash = sorted(key for key, value in raw.items() if isinstance(value, dict) and key in ids) if clash: found[path.name] = clash return found def sync(ws: Workspace, cfg: Config) -> None: manual = manual_specs(ws) lines = list(SYNC_BANNER) + ["version: 1", "defaults:", f" dirs: {len(cfg.directions)}", f" pivot: \"{cfg.pivot_x},{cfg.pivot_y}\"", ""] for aid, spec in manual.items(): lines.append(yaml.safe_dump({aid: spec}, sort_keys=False, allow_unicode=True, default_flow_style=False).rstrip("\n")) lines.append("") generated = {} for kind in cfg.kinds: for animation in cfg.animations: aid = asset_id(kind, animation) if aid in manual: continue path = ws.source_path(kind, animation) if not path.is_file(): continue rel = Path(os.path.relpath(path, ws.manifest.parent)).as_posix() lines += [f"{aid}:", " backend: import", f" source: {rel}", f" fps: {cfg.fps(kind, animation)}", " params: {frame_width: %d, frame_height: %d, frames: %d}" % (cfg.width, cfg.height, cfg.cells(animation)), f" tags: [enemy, {kind}, {animation}]", ""] generated[aid] = path ws.manifest.parent.mkdir(parents=True, exist_ok=True) ws.manifest.write_text("\n".join(lines), encoding="utf-8", newline="\n") ws.header.parent.mkdir(parents=True, exist_ok=True) ws.header.write_text(header_text(cfg), encoding="utf-8", newline="\n") print(f"wrote {ws.manifest.relative_to(ws.root).as_posix()} " f"({len(generated)} import asset(s), {len(manual)} hand-written spec(s) preserved)") print(f"wrote {ws.header.relative_to(ws.root).as_posix()}") clashes = legacy_collisions(ws, set(generated)) for name, ids in clashes.items(): print(f"NOTE: {name} also declares {', '.join(ids)} — retire the old entries " "before the game switches to the rendered sheets") # --------------------------------------------------------------------------- # validate # --------------------------------------------------------------------------- def mirror(cfg: Config, mask: Image.Image) -> Image.Image: """Отражение вокруг колонки pivot_x, а не вокруг центра холста.""" flipped = mask.convert("L").transform(mask.size, Image.AFFINE, (-1, 0, 2 * cfg.pivot_x, 0, 1, 0), resample=Image.Resampling.NEAREST) return flipped.point(lambda v: 255 if v >= 128 else 0).convert("1") def iou(a: Image.Image, b: Image.Image) -> float: intersection = sum(ImageChops.logical_and(a, b).convert("L").point(lambda v: 1 if v else 0).getdata()) union = sum(ImageChops.logical_or(a, b).convert("L").point(lambda v: 1 if v else 0).getdata()) return intersection / union if union else 1.0 def union_masks(cfg: Config, sheet: Image.Image, animation: str) -> list[Image.Image]: """По одной маске на направление: объединение всех кадров клипа. Сравнивать покадрово нельзя — в середине шага у зеркальных направлений вынесена вперёд разная нога, и честное отражение дало бы ложную тревогу. """ frames = cfg.frames(animation) out = [] for direction in range(len(cfg.directions)): merged = Image.new("1", (cfg.width, cfg.height), 0) for frame in range(frames): merged = ImageChops.logical_or(merged, mask_of(cell_at(cfg, sheet, direction * frames + frame))) out.append(merged) return out def total_mask(masks: list[Image.Image]) -> Image.Image: merged = masks[0] for mask in masks[1:]: merged = ImageChops.logical_or(merged, mask) return merged @dataclass class Report: errors: list[str] warnings: list[str] def fail(self, message: str) -> None: self.errors.append(message) def warn(self, message: str) -> None: self.warnings.append(message) def check_sheet(cfg: Config, path: Path, animation: str, report: Report) -> Image.Image | None: if not path.is_file(): report.fail(f"missing: {path.name}") return None sheet = Image.open(path).convert("RGBA") expected = cfg.sheet_size(animation) if sheet.size != expected: report.fail(f"{path.name}: canvas {sheet.size}, expected {expected} " f"({len(cfg.directions)} directions x {cfg.frames(animation)} frame(s) of " f"{cfg.width}x{cfg.height})") return None if sheet.getextrema()[3][0] != 0: report.fail(f"{path.name}: no transparent pixels — the render was not keyed out") for index in range(cfg.cells(animation)): if bbox_of(cell_at(cfg, sheet, index)) is None: direction = cfg.directions[index // cfg.frames(animation)] report.fail(f"{path.name}: empty cell {index} " f"({direction} frame {index % cfg.frames(animation)})") return sheet def check_framing(cfg: Config, name: str, sheet: Image.Image, animation: str, report: Report) -> None: """Силуэт не должен упираться в кромку клетки. Это главная ловушка ЭТОГО конвейера. Клетка 48x56 мала, а замах громилы и осевшая туша занимают больше места, чем стойка; кадр, срезанный кромкой, выглядит в игре как отрубленная лапа, и заметить это по цифрам иначе нельзя. """ for index in range(cfg.cells(animation)): box = bbox_of(cell_at(cfg, sheet, index)) if box is None: continue touched = [] if box[0] <= 0: touched.append("left") if box[1] <= 0: touched.append("top") if box[2] >= cfg.width: touched.append("right") if box[3] >= cfg.height: touched.append("bottom") if touched: direction = cfg.directions[index // cfg.frames(animation)] report.warn(f"{name}: cell {index} ({direction} frame " f"{index % cfg.frames(animation)}) is clipped by the " f"{'/'.join(touched)} edge — the creature does not fit the cell") def check_ground(cfg: Config, name: str, masks: list[Image.Image], animation: str, report: Report) -> None: """Тварь обязана стоять на общей линии пола, а не висеть над ней.""" if animation == "death": return # труп имеет право осесть куда угодно, лишь бы не за кромку for index, mask in enumerate(masks): box = mask.convert("L").getbbox() if box is None: continue if box[3] < cfg.pivot_y - GROUND_SLACK: report.fail(f"{name}: {cfg.directions[index]} floats — lowest pixel y={box[3]}, " f"pivot_y is {cfg.pivot_y}") def check_centering(cfg: Config, name: str, masks: list[Image.Image], report: Report) -> None: """Роза из восьми направлений центрируется на pivot_x. Покадрово центр проверять нельзя: у бредущего одна рука длиннее другой, и его силуэт честно несимметричен. А вот сумма восьми поворотов вокруг общей оси обязана стоять на оси. """ box = total_mask(masks).convert("L").getbbox() if box is None: return center = (box[0] + box[2]) / 2.0 if abs(center - cfg.pivot_x) > CENTER_SLACK: report.fail(f"{name}: the eight directions centre at x={center:.1f}, " f"pivot_x is {cfg.pivot_x} — the rig origin is off the turn axis") def check_direction_order(cfg: Config, name: str, masks: list[Image.Image], report: Report) -> None: """Порядок направлений — насколько его вообще видно текстом. Каталог обещает юг, юго-запад, запад, ... — значит пары (ЮЗ,ЮВ), (З,В), (СЗ,СВ) обязаны совпадать при отражении вокруг pivot_x. Это не доказательство порядка, но любую перестановку соседей оно ловит. """ names = cfg.directions if len(names) != 8: return for left, right in ((1, 7), (2, 6), (3, 5)): score = iou(masks[left], mirror(cfg, masks[right])) if score < MIRROR_IOU_MIN: report.warn(f"{name}: {names[left]} and {names[right]} are not mirror images " f"(IoU {score:.2f} < {MIRROR_IOU_MIN}) — direction order or camera differs") similarities = [iou(masks[0], masks[index]) for index in range(1, 8)] if all(score > DISTINCT_IOU_MAX for score in similarities): report.warn(f"{name}: all eight directions have the same silhouette " "— one direction may have been rendered eight times") def check_kinds_are_distinct(cfg: Config, silhouettes: dict, report: Report) -> None: """Пять видов обязаны различаться пятном. Ради этой проверки конвейер и отделён от агентского: там слои обязаны совпадать, здесь виды обязаны НЕ совпадать. Совпали — значит вместо морфологий получились пять одинаковых капсул. """ kinds = sorted(silhouettes) for i, left in enumerate(kinds): for right in kinds[i + 1:]: score = iou(silhouettes[left], silhouettes[right]) if score > KIND_IOU_MAX: report.warn(f"{left} and {right} have nearly the same walk silhouette " f"(IoU {score:.2f} > {KIND_IOU_MAX}) — the morphologies are " "not readable apart") def check_manifest(ws: Workspace, cfg: Config, report: Report) -> None: if not ws.manifest.is_file(): return raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8")) or {} defaults = raw.get("defaults", {}) if defaults.get("dirs") != len(cfg.directions): report.fail(f"manifest defaults.dirs is {defaults.get('dirs')}, " f"catalog has {len(cfg.directions)}") if defaults.get("pivot") != f"{cfg.pivot_x},{cfg.pivot_y}": report.fail(f"manifest defaults.pivot is {defaults.get('pivot')!r}, " f"catalog has '{cfg.pivot_x},{cfg.pivot_y}'") for aid, spec in raw.items(): if not isinstance(spec, dict) or spec.get("backend") != "import": continue params = spec.get("params", {}) if params.get("frame_width") != cfg.width or params.get("frame_height") != cfg.height: report.fail(f"manifest {aid}: frame {params.get('frame_width')}x" f"{params.get('frame_height')}, catalog has {cfg.width}x{cfg.height}") declared = {key for key, value in raw.items() if isinstance(value, dict) and "backend" in value} for name, ids in legacy_collisions(ws, declared).items(): report.warn(f"{name} declares the same asset id(s) as the generated manifest: " f"{', '.join(ids)} — which file wins is undefined") def validate(ws: Workspace, cfg: Config, complete: bool = False, strict: bool = False) -> int: report = Report([], []) check_manifest(ws, cfg, report) walk_silhouettes = {} for kind in cfg.kinds: for animation in cfg.animations: path = ws.source_path(kind, animation) if not (complete or path.is_file()): continue sheet = check_sheet(cfg, path, animation, report) if sheet is None: continue name = path.name masks = union_masks(cfg, sheet, animation) check_framing(cfg, name, sheet, animation, report) check_ground(cfg, name, masks, animation, report) check_centering(cfg, name, masks, report) if animation == "walk": # Attack, hit and death poses are intentionally asymmetric. # Only the cyclic locomotion silhouette is a useful camera / # direction-order invariant. check_direction_order(cfg, name, masks, report) walk_silhouettes[kind] = total_mask(masks) if len(walk_silhouettes) > 1: check_kinds_are_distinct(cfg, walk_silhouettes, report) for warning in report.warnings: print(f"{'ERROR' if strict else 'WARN'}: {warning}") for error in report.errors: print(f"ERROR: {error}") failures = len(report.errors) + (len(report.warnings) if strict else 0) print(f"monster assets: {len(report.errors)} error(s), {len(report.warnings)} warning(s)") return 1 if failures else 0 # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main() -> int: ws = WORKSPACE cfg = load_config(ws) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) init = sub.add_parser("monster-init", help="create the monster rig .blend with Blender") init.add_argument("--blender") init.add_argument("--force", action="store_true") init.add_argument("--kind", choices=list(cfg.kinds), help="build a lightweight one-kind .blend instead of the combined authoring scene") render = sub.add_parser("monster-render", help="render one kind+animation into a sheet") render.add_argument("kind", choices=list(cfg.kinds)) render.add_argument("animation", choices=list(cfg.animations)) render.add_argument("--blender") render.add_argument("--blend", type=Path) render.add_argument("--supersample", type=int, default=4) render.add_argument("--keep-renders", type=Path) render_all = sub.add_parser("monster-render-all", help="batch-render catalog kinds") render_all.add_argument("kinds", nargs="*", help="default: every catalog kind") render_all.add_argument("--animations", nargs="+", choices=list(cfg.animations), default=list(cfg.animations)) render_all.add_argument("--blender") render_all.add_argument("--blend", type=Path) render_all.add_argument("--supersample", type=int, default=3) render_all.add_argument("--force", action="store_true", help="replace existing sheets") sub.add_parser("sync", help="regenerate the SpriteForge manifest and the C++ header") val = sub.add_parser("validate", help="check canvas, framing, ground line, direction order") val.add_argument("--complete", action="store_true", help="missing sheets are errors") val.add_argument("--strict", action="store_true", help="warnings are errors") args = parser.parse_args() if args.command == "monster-init": monster_init(ws, cfg, args.blender, args.force, args.kind) return 0 if args.command == "monster-render": print(monster_render(ws, cfg, args.kind, args.animation, args.blender, args.blend, args.supersample, args.keep_renders)) sync(ws, cfg) return 0 if args.command == "monster-render-all": kinds = args.kinds or list(cfg.kinds) for kind in kinds: if kind not in cfg.kinds: raise ValueError(f"unknown monster kind: {kind}") done = skipped = 0 for kind in kinds: for animation in args.animations: target = ws.source_path(kind, animation) if target.is_file() and not args.force: # A catalog frame-size change invalidates old sheets even # though their filenames are unchanged. try: cached_size = Image.open(target).size except OSError: cached_size = None if cached_size == cfg.sheet_size(animation): print(f"skip {target.relative_to(ws.root).as_posix()}") skipped += 1 continue print(f"rebuild {target.relative_to(ws.root).as_posix()}: " f"canvas {cached_size}, expected {cfg.sheet_size(animation)}") print(monster_render(ws, cfg, kind, animation, args.blender, args.blend, args.supersample)) done += 1 sync(ws, cfg) print(f"batch render: {done} rendered, {skipped} cached") return 0 if args.command == "sync": sync(ws, cfg) return 0 return validate(ws, cfg, args.complete, args.strict) if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, ValueError, KeyError) as error: print(f"ERROR: {error}", file=sys.stderr) raise SystemExit(1)