Всё, из чего собираются спрайты, и ничего собранного. Источник истины —
assets/manifests и assets/*/catalog.yaml; PNG, .sfa и contact sheet остаются
за бортом, потому что воспроизводятся командой (docs/12-assets.md).
assets/manifests что и как рендерить: окружение, твари, агенты, предметы
assets/agents паперdoll-риг восьми направлений, каталог снаряжения
assets/monsters пять морфологий, каждая своим ригом (docs/14-monsters.md)
assets/sources исходные развёртки и импорты
assets/art_masters мастер-развёртки, по которым сверяется стиль
tools/ сборка ассетов, покрытие, генераторы, blender-скрипты
generated/ заголовки с константами SF_ASSET_*: без них игра не
соберётся, поэтому они в репозитории, хотя и производные
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
754 lines
35 KiB
Python
754 lines
35 KiB
Python
"""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 ассета — <kind>_<animation>: ровно то, что требует",
|
||
"# 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 <cstdint>",
|
||
"",
|
||
"#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)
|