squad-proto/assets/sources/make_enemy_sheets.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

293 lines
10 KiB
Python
Raw 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.

"""Генератор листов анимации тварей для SpriteForge (бэкенд import).
Зачем скрипт, а не нарисованные руками PNG: силуэт твари обязан совпадать с
процедурным запасным вариантом из src/render/sprites.cpp — иначе переключение
--sf-enemies меняло бы не «плавность», а саму тварь, и игрок переставал бы её
узнавать. Поэтому здесь ровно та же геометрия (объединение эллипсов, свет по
верхне-левой кромке, обводка по силуэту), только конечности сдвигаются по фазе
шага.
Скрипт одноразовый и детерминированный: ни одного случайного числа, тот же
хеш-шум, что в C++. Перегенерация даёт побитово тот же PNG.
python assets/sources/make_enemy_sheets.py
Пишет листы в assets/sources/imports/<kind>_walk.png — по кадрам в ряд.
"""
from __future__ import annotations
import math
from pathlib import Path
from PIL import Image
OUT_DIR = Path(__file__).resolve().parent / "imports"
FRAMES = 6 # кадров в цикле шага
# --- цвет --------------------------------------------------------------------
# Тот же ShadeColor, что в src/render/sprites.cpp: множитель по RGB с обрезкой.
def shade(rgb: tuple[int, int, int], k: float) -> tuple[int, int, int]:
return tuple(max(0, min(255, int(c * k))) for c in rgb)
# Хеш из src/core/hash.h. Нужен, чтобы рваный низ у твари совпадал с
# процедурным вариантом кадр в кадр.
def hash2(a: int, b: int) -> int:
h = (a * 0x9E3779B1) & 0xFFFFFFFF
h ^= (b + 0x85EBCA6B + ((h << 6) & 0xFFFFFFFF) + (h >> 2)) & 0xFFFFFFFF
h &= 0xFFFFFFFF
h ^= h >> 15
h = (h * 0x2C1B3C6D) & 0xFFFFFFFF
h ^= h >> 12
return h
def hash01(h: int) -> float:
return (h & 0xFFFFFF) / float(0x1000000)
# --- силуэт ------------------------------------------------------------------
class Canvas:
def __init__(self, w: int, h: int) -> None:
self.w, self.h = w, h
self.px: list[list[tuple[int, int, int] | None]] = [[None] * w for _ in range(h)]
def get(self, x: int, y: int):
if x < 0 or y < 0 or x >= self.w or y >= self.h:
return None
return self.px[y][x]
def set(self, x: int, y: int, c) -> None:
if 0 <= x < self.w and 0 <= y < self.h:
self.px[y][x] = c
def paint(self, x: int, y: int, c) -> None:
"""Записать только по уже занятому пикселю: силуэт не расширяется."""
if self.get(x, y) is not None:
self.set(x, y, c)
def image(self) -> Image.Image:
img = Image.new("RGBA", (self.w, self.h), (0, 0, 0, 0))
out = img.load()
for y in range(self.h):
for x in range(self.w):
c = self.px[y][x]
if c is not None:
out[x, y] = (c[0], c[1], c[2], 255)
return img
def fill_blobs(cv: Canvas, blobs, body) -> None:
for y in range(cv.h):
for x in range(cv.w):
for cx, cy, rx, ry in blobs:
dx = (x - cx) / max(0.5, rx)
dy = (y - cy) / max(0.5, ry)
if dx * dx + dy * dy <= 1.0:
cv.set(x, y, body)
break
def shade_silhouette(cv: Canvas, body) -> None:
"""Объём по краю: свет сверху-слева. Копия ShadeSilhouette из sprites.cpp."""
hilite, shadow, deep = shade(body, 1.32), shade(body, 0.66), shade(body, 0.48)
snapshot = [row[:] for row in cv.px]
def at(x: int, y: int):
if x < 0 or y < 0 or x >= cv.w or y >= cv.h:
return None
return snapshot[y][x]
for y in range(cv.h):
for x in range(cv.w):
if at(x, y) is None:
continue
lit = at(x - 2, y - 1) is None
dark = at(x + 2, y + 1) is None
if lit and not dark:
cv.set(x, y, hilite)
elif dark and not lit:
cv.set(x, y, shadow)
elif dark:
cv.set(x, y, deep)
def outline(cv: Canvas, col) -> None:
snapshot = [row[:] for row in cv.px]
def at(x: int, y: int):
if x < 0 or y < 0 or x >= cv.w or y >= cv.h:
return None
return snapshot[y][x]
for y in range(cv.h):
for x in range(cv.w):
if at(x, y) is None:
continue
if (x == 0 or y == 0 or x == cv.w - 1 or y == cv.h - 1
or at(x - 1, y) is None or at(x + 1, y) is None
or at(x, y - 1) is None or at(x, y + 1) is None):
cv.set(x, y, col)
def tatter_bottom(cv: Canvas, seed: int, max_cut: int) -> None:
for x in range(cv.w):
cut = int(hash01(hash2(seed, x)) * (max_cut + 1))
for i in range(cut):
cv.set(x, cv.h - 1 - i, None)
def eyes(cv: Canvas, x0: int, x1: int, y: int, col) -> None:
cv.set(x0, y, col)
cv.set(x1, y, col)
# --- твари -------------------------------------------------------------------
# Фаза шага: swing — размах конечностей, bob — подскок корпуса. Обе выведены из
# одного угла, поэтому цикл замкнут и на стыке кадров не дёргается.
def phase(frame: int) -> tuple[float, float]:
a = 2.0 * math.pi * frame / FRAMES
return math.sin(a), math.cos(2.0 * a)
def shambler(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(13, 17)
dy = 0.6 * bob # тяжёлый переваливающийся шаг
fill_blobs(cv, [
(6.5, 10.0 + dy, 4.2, 6.0),
(8.0 + 0.5 * swing, 4.5 + dy, 3.0, 2.8),
(2.5 - 0.9 * swing, 10.5 + dy, 1.8, 4.5),
(10.5 + 0.9 * swing, 11.5 + dy, 1.6, 4.0),
(5.0 + 0.8 * swing, 15.0, 2.4, 2.4),
], body)
shade_silhouette(cv, body)
maw = shade(body, 0.20)
for x in range(7, 10):
cv.paint(int(x + 0.5 * swing), int(5 + dy), maw)
tatter_bottom(cv, 0x5A17, 2)
outline(cv, shade(body, 0.28))
return cv
def rusher(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(11, 18)
dy = 0.9 * bob # быстрый частый шаг: подскок заметнее
fill_blobs(cv, [
(5.0, 10.5 + dy, 2.6, 6.0),
(7.0 + 0.4 * swing, 3.8 + dy, 2.4, 3.2),
(8.6 + 1.1 * swing, 8.0 + dy, 1.4, 3.6),
(2.0 - 1.1 * swing, 11.0 + dy, 1.3, 3.4),
(4.5 + 1.0 * swing, 15.5, 2.2, 2.2),
], body)
shade_silhouette(cv, body)
eyes(cv, int(6 + 0.4 * swing), int(8 + 0.4 * swing), int(3 + dy), shade(body, 1.85))
tatter_bottom(cv, 0x9E37, 3)
outline(cv, shade(body, 0.30))
return cv
def spitter(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(16, 14)
dy = 0.5 * bob
fill_blobs(cv, [
(8.0, 8.0 + dy, 6.6, 4.8),
(8.0, 3.6 + dy, 3.4, 2.6),
(3.0 - 0.7 * swing, 12.0, 1.8, 1.8),
(13.0 + 0.7 * swing, 12.0, 1.8, 1.8),
], body)
shade_silhouette(cv, body)
maw, bile = shade(body, 0.18), shade(body, 1.70)
for x in range(5, 12):
cv.paint(x, int(4 + dy), maw)
for x in range(6, 11):
cv.paint(x, int(5 + dy), maw)
for y in range(7, 11):
for x in range(2, cv.w - 2):
if (x * 3 + y * 7) % 13 == 0:
cv.paint(x, y, bile)
outline(cv, shade(body, 0.30))
return cv
def brute(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(22, 25)
dy = 0.7 * bob # туша качается медленно и широко
fill_blobs(cv, [
(11.0, 9.0 + dy, 9.0, 6.0),
(11.0, 16.0 + dy, 5.5, 6.0),
(11.0, 4.5 + dy, 3.0, 2.8),
(2.5 - 0.8 * swing, 17.0 + dy, 2.6, 5.5),
(19.5 + 0.8 * swing, 17.0 + dy, 2.6, 5.5),
(7.0 + 1.2 * swing, 23.0, 3.0, 2.2),
(15.0 - 1.2 * swing, 23.0, 3.0, 2.2),
], body)
shade_silhouette(cv, body)
bone, dark = shade(body, 1.55), shade(body, 0.34)
for i in range(4):
cv.paint(3 + i, int(5 + i + dy), bone)
cv.paint(cv.w - 4 - i, int(5 + i + dy), bone)
for y in range(cv.h - 6, cv.h - 2):
for x in range(1, 4):
cv.paint(x, y, bone)
cv.paint(cv.w - 1 - x, y, bone)
eyes(cv, 10, 12, int(4 + dy), dark)
outline(cv, shade(body, 0.25))
return cv
def lurker(frame: int, body):
swing, bob = phase(frame)
cv = Canvas(17, 15)
dy = 0.5 * bob
# Паучий ход: лапы работают в противофазе парами, тело почти не качается.
fill_blobs(cv, [
(8.5, 9.0 + dy, 4.0, 3.6),
(8.5, 5.5 + dy, 2.4, 2.2),
(2.0 - 0.8 * swing, 7.0 - 0.9 * swing, 1.3, 4.6),
(15.0 + 0.8 * swing, 7.0 + 0.9 * swing, 1.3, 4.6),
(4.0 + 0.9 * swing, 12.5, 1.4, 2.2),
(13.0 - 0.9 * swing, 12.5, 1.4, 2.2),
], body)
shade_silhouette(cv, body)
claw = shade(body, 1.75)
cv.paint(1, 2, claw)
cv.paint(2, 3, claw)
cv.paint(cv.w - 2, 2, claw)
cv.paint(cv.w - 3, 3, claw)
eyes(cv, 7, 10, int(5 + dy), shade(body, 1.9))
outline(cv, shade(body, 0.26))
return cv
# Цвета — копия EnemyDef из src/sim/target.cpp. Разъедутся — тварь сменит цвет
# при переключении на собранные спрайты, и это будет видно сразу.
KINDS = [
("shambler", shambler, (116, 52, 52), 13, 17),
("rusher", rusher, (158, 74, 44), 11, 18),
("spitter", spitter, (96, 132, 72), 16, 14),
("brute", brute, (142, 60, 78), 22, 25),
("lurker", lurker, (88, 62, 118), 17, 15),
]
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
for name, maker, colour, w, h in KINDS:
sheet = Image.new("RGBA", (w * FRAMES, h), (0, 0, 0, 0))
for f in range(FRAMES):
sheet.paste(maker(f, colour).image(), (f * w, 0))
path = OUT_DIR / f"{name}_walk.png"
sheet.save(path)
print(f"{path.name}: {w * FRAMES}x{h} ({FRAMES} кадров по {w}x{h})")
if __name__ == "__main__":
main()