squad-proto/tools/agent_assets.py
z.kirill 8d35f4c2ec Конвейер ассетов: манифесты, риги, каталоги, инструменты
Всё, из чего собираются спрайты, и ничего собранного. Источник истины —
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>
2026-08-13 21:06:09 +03:00

905 lines
44 KiB
Python
Raw Permalink 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.

"""Agent paper-doll authoring bridge: Blender / raster PNG -> SpriteForge.
Два входа, один выход. Blender-конвейер (`blender-init` + `blender-render`)
детерминированно рендерит компонент под одной неподвижной ортографической
изометрической камерой. Растровый конвейер (`ingest`) принимает 4x2 доски с
хромакеем от генератора картинок. Оба дают один и тот же лист: направления
подряд, ячейка 70x90, pivot (35,87).
Ключ к выравниванию бумажной куклы — общий для слоёв, а не индивидуальный
подгон:
* в Blender начало координат рига всегда проецируется в центр кадра, поэтому
упаковщик режет все кадры ОДНИМ прямоугольником — bbox не участвует вовсе;
* в растре масштаб и смещение считаются один раз по слою тела и лежат в
`_calibration.yaml`; броня и оружие проходят через ту же самую аффинную
подстановку, поэтому сохраняют свои координаты относительно ПОЛНОЙ ячейки.
Индивидуальный fit по bbox для экипировки запрещён: он растянул бы кинжал на
всю клетку и посадил бы его на уровень стоп.
"""
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"
# Фигура тела занимает по высоте ячейку минус этот зазор.
FIT_MARGIN_Y = 6
# Допуски проверок (в пикселях листа).
PIVOT_BOTTOM_SLACK = 5
PIVOT_CENTER_SLACK = 8
# Насколько экипировке разрешено выходить за силуэт тела.
EQUIP_MARGIN = 10
# Порог зеркального совпадения силуэтов для проверки порядка направлений.
MIRROR_IOU_MIN = 0.55
# Выше этого юг и север считаются одной и той же картинкой.
DISTINCT_IOU_MAX = 0.98
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()
# ---------------------------------------------------------------------------
# Пути и конфигурация
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Workspace:
"""Все пути конвейера в одном месте: тесты подставляют временный корень."""
root: Path
@property
def catalog(self) -> Path: return self.root / "assets/agents/catalog.yaml"
@property
def source_root(self) -> Path: return self.root / "assets/sources/agents/sleek"
@property
def manifest(self) -> Path: return self.root / "assets/manifests/agents.generated.yaml"
@property
def header(self) -> Path: return self.root / "generated/agent_paperdoll.h"
@property
def blend(self) -> Path: return self.root / "assets/agents/agent_rig.blend"
@property
def calibration(self) -> Path: return self.source_root / "_calibration.yaml"
@property
def rig_script(self) -> Path: return self.root / "tools/blender/agent_rig.py"
def source_path(self, component: str, animation: str) -> Path:
return self.source_root / f"{component}_{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
components: 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 slot(self, component: str) -> str:
return self.components[component]["slot"]
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/agents/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["components"],
raw.get("camera", {}))
def check_names(cfg: Config, component: str, animation: str) -> None:
if component not in cfg.components:
raise ValueError(f"unknown component: {component}")
if animation not in cfg.animations:
raise ValueError(f"unknown animation: {animation}")
def asset_id(component: str, animation: str) -> str:
return f"agent_{component}_{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 remove_key(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
px = rgba.load(); key = px[0, 0][:3]
for y in range(rgba.height):
for x in range(rgba.width):
r, g, b, _ = px[x, y]
distance = max(abs(r-key[0]), abs(g-key[1]), abs(b-key[2]))
alpha = 0 if distance <= 18 else (255 if distance >= 64 else (distance-18)*255//46)
px[x, y] = (r, g, b, alpha)
return rgba
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))
# ---------------------------------------------------------------------------
# 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("[agent_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,
"camera": cfg.camera}
def blender_init(ws: Workspace, cfg: Config, blender: str | None, force: bool) -> Path:
if ws.blend.is_file() and not force:
raise ValueError(f"{ws.blend.relative_to(ws.root).as_posix()} already exists; pass --force to rebuild")
binary = blender_binary(blender)
ws.blend.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as work:
config_path = Path(work) / "config.json"
config_path.write_text(json.dumps(blender_config(cfg)), encoding="utf-8")
run_blender(binary, ["--factory-startup", "-b", "--python", str(ws.rig_script), "--",
"build", "--config", str(config_path), "--out", str(ws.blend)])
if not ws.blend.is_file():
raise ValueError("blender finished but wrote no .blend")
print(f"wrote {ws.blend.relative_to(ws.root).as_posix()}")
return ws.blend
def pack_render_frames(cfg: Config, animation: str, images: list[Image.Image],
supersample: int) -> Image.Image:
"""Кадры Blender -> лист. Одна вырезка на все ячейки, никакого bbox.
Именно здесь живёт гарантия выравнивания: прямоугольник вырезки зависит
только от каталога, а не от содержимого кадра, поэтому тело, броня и
оружие не могут разъехаться, даже если их силуэты совсем разные.
"""
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 blender_render(ws: Workspace, cfg: Config, component: str, animation: str,
blender: str | None, blend: Path | None, supersample: int,
keep: Path | None = None) -> Path:
check_names(cfg, component, animation)
blend_path = blend or ws.blend
if not blend_path.is_file():
raise ValueError(f"no rig at {blend_path}; run `agent_assets.py blender-init` first")
binary = blender_binary(blender)
with tempfile.TemporaryDirectory() as work:
outdir = Path(work) / "frames"
payload = blender_config(cfg)
payload.update({"component": component, "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(component, animation)
target.parent.mkdir(parents=True, exist_ok=True)
sheet.save(target)
return target
# ---------------------------------------------------------------------------
# Растровый конвейер: калибровка и ingest
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Calibration:
"""Преобразование ячейки доски в кадр листа, общее для ВСЕХ слоёв.
Числа нормированы на размер ячейки, поэтому доски разных компонентов могут
приходить в разном разрешении — подстановка от этого не зависит.
"""
height_frac: float # высота силуэта тела в долях высоты ячейки
anchor_u: float # центр силуэта по X в долях ширины ячейки
anchor_v: float # низ силуэта по Y в долях высоты ячейки
source: str = ""
def to_yaml(self) -> str:
return ("# Записано `agent_assets.py ingest` по слою тела.\n"
"# Броня и оружие проходят через ЭТУ ЖЕ подстановку — руками не править.\n"
"version: 1\n"
f"source: {self.source}\n"
f"height_frac: {self.height_frac:.6f}\n"
f"anchor_u: {self.anchor_u:.6f}\n"
f"anchor_v: {self.anchor_v:.6f}\n")
@staticmethod
def from_yaml(text: str) -> "Calibration":
raw = yaml.safe_load(text) or {}
return Calibration(float(raw["height_frac"]), float(raw["anchor_u"]),
float(raw["anchor_v"]), str(raw.get("source", "")))
def load_calibration(ws: Workspace) -> Calibration | None:
if not ws.calibration.is_file():
return None
return Calibration.from_yaml(ws.calibration.read_text(encoding="utf-8"))
def board_cells(cfg: Config, boards: list[Image.Image]) -> list[list[Image.Image]]:
"""boards[frame] -> cells[direction][frame], строгая сетка 4x2."""
for board in boards:
if board.width % 4 or board.height % 2:
raise ValueError("every direction board must divide exactly into a 4x2 grid")
grid = []
for direction in range(len(cfg.directions)):
row = []
for board in boards:
cw, ch = board.width // 4, board.height // 2
row.append(board.crop(((direction % 4) * cw, (direction // 4) * ch,
(direction % 4 + 1) * cw, (direction // 4 + 1) * ch)))
grid.append(row)
return grid
def measure_calibration(cfg: Config, grid: list[list[Image.Image]], source: str) -> Calibration:
"""Один масштаб на весь ingest: объединённый bbox по всем направлениям.
Подгонять каждое направление отдельно нельзя — фигура бы дышала при
повороте, а слои разъехались бы уже между собой.
"""
cw, ch = grid[0][0].size
left, top, right, bottom = cw, ch, 0, 0
for row in grid:
for cell in row:
box = cell.getchannel("A").getbbox()
if box is None:
continue
left, top = min(left, box[0]), min(top, box[1])
right, bottom = max(right, box[2]), max(bottom, box[3])
if right <= left or bottom <= top:
raise ValueError("every cell of the calibration board is empty")
return Calibration(height_frac=(bottom - top) / ch,
anchor_u=(left + right) / 2.0 / cw,
anchor_v=bottom / ch, source=source)
def cell_transform(cfg: Config, cal: Calibration, cell_size: tuple[int, int]):
"""(scale, offset_x, offset_y) для ячейки: куда ложится её левый верхний угол."""
cw, ch = cell_size
scale = (cfg.height - FIT_MARGIN_Y) / (cal.height_frac * ch)
return scale, cfg.pivot_x - cal.anchor_u * cw * scale, cfg.pivot_y - cal.anchor_v * ch * scale
def place_cell(cfg: Config, cal: Calibration, cell: Image.Image) -> Image.Image:
"""Кладёт ЦЕЛУЮ ячейку в кадр 70x90 общей подстановкой.
Обрезки по bbox тут нет намеренно: кинжал в руке обязан остаться в руке,
а не съехать к стопам и не растянуться на всю клетку.
"""
cw, ch = cell.size
scale, ox, oy = cell_transform(cfg, cal, cell.size)
scaled = resize_rgba(cell, (max(1, round(cw * scale)), max(1, round(ch * scale))))
layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0))
layer.paste(scaled, (round(ox), round(oy)))
return layer
def ingest(ws: Workspace, cfg: Config, component: str, animation: str,
inputs: list[Path], calibrate: bool = False) -> Path:
check_names(cfg, component, animation)
expected = cfg.frames(animation)
if len(inputs) != expected:
raise ValueError(f"{animation} requires {expected} direction board(s), got {len(inputs)}")
boards = [remove_key(Image.open(path)) for path in inputs]
grid = board_cells(cfg, boards)
for direction, row in enumerate(grid):
for frame, cell in enumerate(row):
if cell.getchannel("A").getbbox() is None:
raise ValueError(f"empty cell: {cfg.directions[direction]} frame {frame}")
is_body = cfg.slot(component) == "body"
cal = load_calibration(ws)
if calibrate and not is_body:
raise ValueError("--calibrate is only allowed on a body layer: it sets the shared scale")
if cal is None or calibrate:
if not is_body:
raise ValueError("no _calibration.yaml: ingest the body layer first — equipment "
"must reuse the body's scale, not fit itself")
cal = measure_calibration(cfg, grid, f"{component}/{animation}")
ws.calibration.parent.mkdir(parents=True, exist_ok=True)
ws.calibration.write_text(cal.to_yaml(), encoding="utf-8", newline="\n")
print(f"wrote {ws.calibration.relative_to(ws.root).as_posix()}")
sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0))
for direction, row in enumerate(grid):
for frame, cell in enumerate(row):
paste_cell(cfg, sheet, direction * expected + frame, place_cell(cfg, cal, cell))
target = ws.source_path(component, animation)
target.parent.mkdir(parents=True, exist_ok=True)
sheet.save(target)
return target
# ---------------------------------------------------------------------------
# prompt
# ---------------------------------------------------------------------------
def prompt_for(cfg: Config, component: str, animation: str) -> str:
check_names(cfg, component, animation)
meta = cfg.components[component]
frames = cfg.frames(animation)
elevation = cfg.camera.get("elevation_deg", 26.565)
layer_rule = ("render only the unarmored fitted base body; hands remain visible" if meta["slot"] == "body" else
"render only this equipment layer on an otherwise invisible body; preserve every occluded gap")
return f"""Production sprite source for the existing sleek dark-sci-fi agent paper-doll.
Component: {component} ({meta['style']}). Animation: {animation}, {frames} frame(s) per direction.
{layer_rule}. Use one fixed orthographic isometric camera, {elevation:g} degrees downward,
identical in every board — the normalizer does NOT re-fit layers to each other.
Directions in exact order: {', '.join(cfg.directions)}. Exact same skeleton, pose timing,
scale, lighting and foot pivot in every component. Realistic lean adult proportions, fitted
near-future covert-operations design; no bulky power armor, space marine, astronaut, chibi,
Among Us, medieval clothing or cape. Fixed dim world-space upper-left light.
For EACH animation frame produce a separate strict 4 columns x 2 rows direction board.
One complete direction per equal cell, flat uniform #ff00ff background, no shadow, text,
grid, labels or extra objects. Keep the figure at the SAME place and size inside every cell:
equipment is mapped by the body's calibration, so a cell-relative shift moves the gear off
the body. Leave generous padding. Output is source art; the normalizer maps the whole cell
into a {cfg.width}x{cfg.height} frame with pivot ({cfg.pivot_x},{cfg.pivot_y})."""
# ---------------------------------------------------------------------------
# sync
# ---------------------------------------------------------------------------
SYNC_BANNER = [
"# Собрано `python tools/agent_assets.py sync`.",
"# Записи с backend: import перегенерируются по PNG в assets/sources/agents/sleek/.",
"# Записи с любым другим backend (например blender) sync СОХРАНЯЕТ как есть:",
"# правь их руками здесь, это единственное место, где такая правка переживает sync.",
]
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 write_consolidated_header(ws: Workspace) -> None:
"""Generate runtime IDs for multi-animation SpriteForge paper-doll layers."""
body = fnv1a("agent_body_sleek")
armor = fnv1a("agent_armor_vest")
weapon = fnv1a("agent_weapon_rifle")
lines = [
"/* Generated by tools/agent_assets.py sync (consolidated Blender assets). */",
"#pragma once", "#include <cstdint>", '#include "render/anim.h"',
'#include "sim/items.h"', "",
f"constexpr uint32_t AGENT_BODY_ASSET = UINT32_C(0x{body:08X});", "",
"constexpr uint32_t AgentBodyAsset(AnimKind) { return AGENT_BODY_ASSET; }", "",
"constexpr uint32_t AgentArmorAsset(ItemId id, AnimKind = AnimKind::IDLE) {",
" switch (id) {",
" case ItemId::JACKET: case ItemId::VEST: case ItemId::PLATE: case ItemId::SHROUD:",
f" return UINT32_C(0x{armor:08X});",
" default: return 0;", " }", "}", "",
"constexpr uint32_t AgentWeaponAsset(ItemId id, AnimKind = AnimKind::IDLE) {",
" switch (id) {",
" case ItemId::RIFLE: case ItemId::PISTOL: case ItemId::SHOTGUN:",
" case ItemId::SMG: case ItemId::MAGNUM: case ItemId::BOW:",
" case ItemId::SABER: case ItemId::KNIFE: case ItemId::CLEAVER:",
f" return UINT32_C(0x{weapon:08X});",
" default: return 0;", " }", "}", "",
]
ws.header.parent.mkdir(parents=True, exist_ok=True)
ws.header.write_text("\n".join(lines), encoding="utf-8", newline="\n")
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 = 0
for component in cfg.components:
for animation, anim in cfg.animations.items():
aid = asset_id(component, animation)
if aid in manual:
continue
path = ws.source_path(component, animation)
if not path.is_file():
continue
rel = Path(os.path.relpath(path, ws.manifest.parent)).as_posix()
slot = cfg.slot(component)
tags = f"agent, {slot}, {animation}"
if slot in {"armor", "weapon"}:
tags += ", paperdoll_layer"
lines += [f"{aid}:", " backend: import", f" source: {rel}", f" fps: {anim['fps']}",
" params: {frame_width: %d, frame_height: %d, frames: %d}"
% (cfg.width, cfg.height, cfg.frames(animation) * len(cfg.directions)),
f" tags: [{tags}]", ""]
generated += 1
ws.manifest.parent.mkdir(parents=True, exist_ok=True)
ws.manifest.write_text("\n".join(lines), encoding="utf-8", newline="\n")
# A project may keep legacy PNG imports while migrating, but once the
# production Blender manifest exists runtime addressing must use its single
# multi-clip asset per paper-doll layer.
if (ws.root / "assets" / "manifests" / "agents.blender.yaml").is_file():
write_consolidated_header(ws)
print(f"wrote {ws.manifest.relative_to(ws.root).as_posix()} "
f"({generated} import asset(s), {len(manual)} hand-written spec(s) preserved)")
print(f"wrote {ws.header.relative_to(ws.root).as_posix()} (consolidated Blender IDs)")
return
body = "agent_body_sleek_idle"
armor = {"JACKET": "jacket", "VEST": "vest", "PLATE": "plate", "SHROUD": "shroud"}
weapon = {"RIFLE": "rifle", "PISTOL": "pistol", "SHOTGUN": "shotgun", "SMG": "rifle",
"MAGNUM": "pistol", "BOW": "bow", "SABER": "sword", "KNIFE": "dagger", "CLEAVER": "sword"}
def anim_switch(prefix: str, indent: str) -> list[str]:
return [f"{indent}case AnimKind::{animation.upper()}: return UINT32_C(0x{fnv1a(prefix+'_'+animation):08X});"
for animation in ("idle", "walk", "attack")]
h = ["/* Generated by tools/agent_assets.py sync. */", "#pragma once", "#include <cstdint>",
"#include \"render/anim.h\"", "#include \"sim/items.h\"", "",
f"constexpr uint32_t AGENT_BODY_ASSET = UINT32_C(0x{fnv1a(body):08X});", "",
"constexpr uint32_t AgentBodyAsset(AnimKind anim) {", " switch (anim) {"]
h += anim_switch("agent_body_sleek", " ")
h += [" default: return AGENT_BODY_ASSET;", " }", "}", "",
"constexpr uint32_t AgentArmorAsset(ItemId id, AnimKind anim = AnimKind::IDLE) {",
" switch (id) {"]
for key, name in armor.items():
h += [f" case ItemId::{key}:", " switch (anim) {"]
h += anim_switch(f"agent_armor_{name}", " ")
h += [" default: return 0;", " }"]
h += [" default: return 0;", " }", "}", "",
"constexpr uint32_t AgentWeaponAsset(ItemId id, AnimKind anim = AnimKind::IDLE) {", " switch (id) {"]
for key, name in weapon.items():
h += [f" case ItemId::{key}:", " switch (anim) {"]
h += anim_switch(f"agent_weapon_{name}", " ")
h += [" default: return 0;", " }"]
h += [" default: return 0;", " }", "}", ""]
ws.header.parent.mkdir(parents=True, exist_ok=True)
ws.header.write_text("\n".join(h), encoding="utf-8", newline="\n")
print(f"wrote {ws.manifest.relative_to(ws.root).as_posix()} "
f"({generated} import asset(s), {len(manual)} hand-written spec(s) preserved)")
print(f"wrote {ws.header.relative_to(ws.root).as_posix()}")
# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------
def mirror(cfg: Config, mask: Image.Image) -> Image.Image:
"""Отражение вокруг колонки pivot_x, а не вокруг центра холста.
Разница в полпикселя, но именно она и решает: холст 70 широкий, а pivot
стоит на 35 — центр холста 34.5, и зеркало вокруг него сдвигало бы силуэт.
"""
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 bbox_of(cell: Image.Image):
return mask_of(cell).convert("L").getbbox()
@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 key was not removed")
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} ({direction} frame {index % cfg.frames(animation)})")
return sheet
def check_body_pivot(cfg: Config, name: str, sheet: Image.Image, animation: str, report: Report) -> None:
for index in range(cfg.cells(animation)):
box = bbox_of(cell_at(cfg, sheet, index))
if box is None:
continue
if not (cfg.pivot_y - PIVOT_BOTTOM_SLACK <= box[3] <= cfg.height):
report.fail(f"{name}: cell {index} stands on y={box[3]}, pivot_y is {cfg.pivot_y}")
# A dynamic attack may extend one arm far to the side; bbox centre is
# not the character origin. The static idle pose remains a useful
# authoring check, while every animation still has to stand on pivot_y.
if animation == "idle":
center = (box[0] + box[2]) / 2.0
if abs(center - cfg.pivot_x) > PIVOT_CENTER_SLACK:
report.fail(f"{name}: cell {index} centred at x={center:.1f}, pivot_x is {cfg.pivot_x}")
def check_layer_alignment(cfg: Config, name: str, sheet: Image.Image, body: Image.Image,
animation: str, slot: str, report: Report) -> None:
"""Экипировка обязана лежать внутри силуэта тела плюс небольшой запас.
Так ловится ровно та поломка, ради которой переписан ingest: слой, который
подогнали по собственному bbox, вылезает за тело сразу во всех ячейках.
"""
for index in range(cfg.cells(animation)):
gear = bbox_of(cell_at(cfg, sheet, index))
host = bbox_of(cell_at(cfg, body, index))
if gear is None or host is None:
continue
if slot == "weapon":
# Blades and bows legitimately extend well outside the body. What
# the broken per-bbox fitter produced was a layer inflated to
# almost the entire 70x90 cell, independent of attachment point.
if gear[2] - gear[0] >= cfg.width - 8 or gear[3] - gear[1] >= cfg.height - 10:
report.fail(f"{name}: cell {index} bbox {gear} fills almost the whole frame "
"— weapon was fitted on its own")
continue
limits = (host[0] - EQUIP_MARGIN, host[1] - EQUIP_MARGIN,
host[2] + EQUIP_MARGIN, host[3] + EQUIP_MARGIN)
if gear[0] < limits[0] or gear[1] < limits[1] or gear[2] > limits[2] or gear[3] > limits[3]:
report.fail(f"{name}: cell {index} bbox {gear} escapes the body {host} "
f"by more than {EQUIP_MARGIN}px — layer was fitted on its own")
def check_direction_order(cfg: Config, name: str, sheet: Image.Image, animation: str,
report: Report) -> None:
"""Порядок направлений — насколько его вообще видно текстом.
Каталог обещает юг, юго-запад, запад, ... — значит пары (ЮЗ,ЮВ), (З,В),
(СЗ,СВ) обязаны совпадать при отражении вокруг pivot_x, а юг и север —
различаться. Это не доказательство порядка, но любую перестановку соседей
оно ловит.
"""
names = cfg.directions
if len(names) != 8:
return
masks = union_masks(cfg, sheet, animation)
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")
for index in (0, 4):
score = iou(masks[index], mirror(cfg, masks[index]))
if score < MIRROR_IOU_MIN:
report.warn(f"{name}: {names[index]} is not symmetric about pivot_x "
f"(IoU {score:.2f}) — that cell may not face the camera")
# Side-on cardinals should normally be no wider than their neighbouring
# diagonals. This catches the common board-order swap that mirror-pair
# checks alone cannot see for simple/symmetric silhouettes.
if animation == "idle":
widths = []
for mask in masks:
box = mask.convert("L").getbbox()
widths.append(0 if box is None else box[2] - box[0])
for side, before, after in ((2, 1, 3), (6, 5, 7)):
if widths[side] > max(widths[before], widths[after]):
report.warn(f"{name}: {names[side]} is wider than both adjacent diagonals "
"— direction order may be shuffled")
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 "
"— the board may hold one direction eight times")
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')}, 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{params.get('frame_height')}, "
f"catalog has {cfg.width}x{cfg.height}")
def validate(ws: Workspace, cfg: Config, complete: bool = False, strict: bool = False) -> int:
report = Report([], [])
check_manifest(ws, cfg, report)
bodies = [c for c in cfg.components if cfg.slot(c) == "body"]
for animation in cfg.animations:
sheets = {}
for component in cfg.components:
path = ws.source_path(component, animation)
if not (complete or path.is_file()):
continue
sheet = check_sheet(cfg, path, animation, report)
if sheet is None:
continue
sheets[component] = sheet
name = path.name
if cfg.slot(component) == "body":
check_body_pivot(cfg, name, sheet, animation, report)
check_direction_order(cfg, name, sheet, animation, report)
body = next((sheets[c] for c in bodies if c in sheets), None)
if body is not None:
for component, sheet in sheets.items():
if cfg.slot(component) != "body":
check_layer_alignment(cfg, ws.source_path(component, animation).name,
sheet, body, animation, cfg.slot(component), report)
elif sheets:
report.warn(f"{animation}: no body layer — cross-layer alignment unchecked")
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"agent 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)
prompt = sub.add_parser("prompt", help="art brief for a raster generator")
prompt.add_argument("component"); prompt.add_argument("animation")
ingest_p = sub.add_parser("ingest", help="normalize 4x2 chroma-key boards into a sheet")
ingest_p.add_argument("component"); ingest_p.add_argument("animation")
ingest_p.add_argument("inputs", nargs="+", type=Path)
ingest_p.add_argument("--calibrate", action="store_true",
help="re-measure the shared scale from this body layer")
init = sub.add_parser("blender-init", help="create the rig .blend with Blender")
init.add_argument("--blender"); init.add_argument("--force", action="store_true")
render = sub.add_parser("blender-render", help="render one component+animation into a sheet")
render.add_argument("component"); render.add_argument("animation")
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("blender-render-all", help="batch-render catalog components")
render_all.add_argument("components", nargs="*", help="default: every catalog component")
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=2)
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, pivot, layer alignment, direction order")
val.add_argument("--complete", action="store_true", help="missing layers are errors")
val.add_argument("--strict", action="store_true", help="warnings are errors")
args = parser.parse_args()
if args.command == "prompt":
print(prompt_for(cfg, args.component, args.animation)); return 0
if args.command == "ingest":
print(ingest(ws, cfg, args.component, args.animation, args.inputs, args.calibrate))
sync(ws, cfg); return 0
if args.command == "blender-init":
blender_init(ws, cfg, args.blender, args.force); return 0
if args.command == "blender-render":
print(blender_render(ws, cfg, args.component, args.animation, args.blender,
args.blend, args.supersample, args.keep_renders))
sync(ws, cfg); return 0
if args.command == "blender-render-all":
components = args.components or list(cfg.components)
for component in components:
if component not in cfg.components:
raise ValueError(f"unknown component: {component}")
done = skipped = 0
for component in components:
for animation in args.animations:
target = ws.source_path(component, animation)
if target.is_file() and not args.force:
print(f"skip {target.relative_to(ws.root).as_posix()}")
skipped += 1
continue
print(blender_render(ws, cfg, component, 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)