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