squad-proto/assets/sources/make_enemy_sheets.py

293 lines
10 KiB
Python
Raw Normal View History

"""Генератор листов анимации тварей для 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()