Всё, из чего собираются спрайты, и ничего собранного. Источник истины —
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>
84 lines
3.6 KiB
Python
84 lines
3.6 KiB
Python
"""Make a human-readable grid from a direction-major technical sprite sheet.
|
|
|
|
This command is for the human art review loop. Agents should use the textual
|
|
validators instead of opening the generated preview.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("sheet", type=Path)
|
|
parser.add_argument("--overlay", action="append", type=Path, default=[],
|
|
help="aligned paper-doll sheet to composite over the base; repeatable")
|
|
parser.add_argument("--cell", required=True, metavar="WIDTHxHEIGHT")
|
|
parser.add_argument("--frames", required=True, type=int,
|
|
help="frames per direction")
|
|
parser.add_argument("--dirs", type=int, default=8)
|
|
parser.add_argument("--scale", type=int, default=3)
|
|
parser.add_argument("--output", type=Path)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
cell_w, cell_h = (int(value) for value in args.cell.lower().split("x", 1))
|
|
except (ValueError, TypeError) as error:
|
|
raise SystemExit("--cell must look like 70x90") from error
|
|
if min(cell_w, cell_h, args.frames, args.dirs, args.scale) < 1:
|
|
raise SystemExit("cell, frames, dirs and scale must be positive")
|
|
|
|
source = Image.open(args.sheet).convert("RGBA")
|
|
expected = (cell_w * args.frames * args.dirs, cell_h)
|
|
if source.size != expected:
|
|
raise SystemExit(f"sheet is {source.size[0]}x{source.size[1]}, expected "
|
|
f"{expected[0]}x{expected[1]}")
|
|
layers = [source]
|
|
for path in args.overlay:
|
|
layer = Image.open(path).convert("RGBA")
|
|
if layer.size != expected:
|
|
raise SystemExit(f"overlay {path} is {layer.size}, expected {expected}")
|
|
layers.append(layer)
|
|
|
|
label_h = 18
|
|
sw, sh = cell_w * args.scale, cell_h * args.scale
|
|
out = Image.new("RGBA", (args.frames * sw, args.dirs * (sh + label_h)),
|
|
(22, 24, 29, 255))
|
|
draw = ImageDraw.Draw(out)
|
|
for direction in range(args.dirs):
|
|
for frame in range(args.frames):
|
|
index = direction * args.frames + frame
|
|
box = (index * cell_w, 0, (index + 1) * cell_w, cell_h)
|
|
cell = layers[0].crop(box)
|
|
for layer in layers[1:]:
|
|
cell.alpha_composite(layer.crop(box))
|
|
cell = cell.resize((sw, sh), Image.Resampling.NEAREST)
|
|
x, y = frame * sw, direction * (sh + label_h) + label_h
|
|
# Neutral checker makes transparent padding and the exact cell edge visible.
|
|
checker = Image.new("RGBA", (sw, sh), (36, 39, 45, 255))
|
|
cd = ImageDraw.Draw(checker)
|
|
block = max(4, args.scale * 4)
|
|
for cy in range(0, sh, block):
|
|
for cx in range(0, sw, block):
|
|
if ((cx // block) + (cy // block)) & 1:
|
|
cd.rectangle((cx, cy, cx + block - 1, cy + block - 1),
|
|
fill=(48, 52, 59, 255))
|
|
checker.alpha_composite(cell)
|
|
out.alpha_composite(checker, (x, y))
|
|
draw.rectangle((x, y, x + sw - 1, y + sh - 1), outline=(105, 112, 126, 255))
|
|
draw.text((x + 4, y - label_h + 3), f"dir {direction} frame {frame}",
|
|
fill=(220, 224, 232, 255))
|
|
|
|
target = args.output or args.sheet.with_name(args.sheet.stem + ".preview.png")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
out.convert("RGB").save(target)
|
|
print(target.resolve())
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|