"""Генератор листов анимации тварей для SpriteForge (бэкенд import). Зачем скрипт, а не нарисованные руками PNG: силуэт твари обязан совпадать с процедурным запасным вариантом из src/render/sprites.cpp — иначе переключение --sf-enemies меняло бы не «плавность», а саму тварь, и игрок переставал бы её узнавать. Поэтому здесь ровно та же геометрия (объединение эллипсов, свет по верхне-левой кромке, обводка по силуэту), только конечности сдвигаются по фазе шага. Скрипт одноразовый и детерминированный: ни одного случайного числа, тот же хеш-шум, что в C++. Перегенерация даёт побитово тот же PNG. python assets/sources/make_enemy_sheets.py Пишет листы в assets/sources/imports/_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()