squad-proto/tools/blender/monster_rig.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

1167 lines
63 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.

"""Blender-сторона конвейера тварей. Запускается ВНУТРИ Blender.
blender --factory-startup -b --python tools/blender/monster_rig.py -- build --config cfg.json --out rig.blend
blender --factory-startup -b rig.blend --python tools/blender/monster_rig.py -- render --config cfg.json
Родственник tools/blender/agent_rig.py, но задача обратная. У агента слои
бумажной куклы обязаны совпасть пиксель в пиксель, поэтому там ОДИН скелет на
всё. Здесь пять видов обязаны различаться силуэтом с одного взгляда, поэтому
здесь ПЯТЬ независимых скелетов — и ровно одна общая вещь: неподвижная
ортографическая изометрическая камера и начало координат в точке опоры.
Правило выравнивания то же самое: камера СТОИТ, поворачивается модель.
Начало координат каждой твари (точка опоры на полу) всегда проецируется в
центр кадра, поэтому упаковщик на стороне Python режет все кадры одним и тем
же прямоугольником, и никакого подгона по bbox не существует в принципе.
Анатомия задана таблицами, а не кодом: кости, детали и четыре клипа на вид.
Клипы описаны в НОРМИРОВАННОМ времени (список опорных значений на 0..1) и
сэмплируются под то число кадров, которое пришло из каталога, — менять
frames в каталоге можно, не трогая этот файл.
PyYAML внутри Blender нет — конфиг приходит одним JSON-файлом.
"""
from __future__ import annotations
import json
import math
import os
import sys
import bmesh
import bpy
from mathutils import Matrix, Quaternion, Vector
# Фигура смотрит в -Y (на камеру) — это направление `south`.
# Правая сторона твари — это -X, как и у агента.
X = (1.0, 0.0, 0.0) # наклон вперёд/назад
Y = (0.0, 1.0, 0.0) # завал вбок
Z = (0.0, 0.0, 1.0) # поворот вокруг вертикали
def d(degrees):
return math.radians(degrees)
# ----------------------------------------------------------------------------
# Материалы: мокрое мясо, кость, хитин и вживлённое железо
# ----------------------------------------------------------------------------
MATERIALS = {
# name: (base color RGB, roughness, metallic)
"flesh": ((0.36, 0.15, 0.14), 0.44, 0.00), # низкий roughness = мокрый блик
"muscle": ((0.47, 0.10, 0.10), 0.38, 0.00),
"gut": ((0.26, 0.07, 0.09), 0.32, 0.00),
"membrane": ((0.54, 0.27, 0.31), 0.26, 0.00),
"sinew": ((0.57, 0.44, 0.36), 0.62, 0.00),
"bone": ((0.73, 0.69, 0.58), 0.58, 0.00),
"chitin": ((0.13, 0.12, 0.14), 0.34, 0.12),
"steel": ((0.31, 0.33, 0.36), 0.28, 0.85),
"rust": ((0.29, 0.16, 0.09), 0.82, 0.25),
"tube": ((0.19, 0.17, 0.20), 0.44, 0.08),
"ocellus": ((0.86, 0.78, 0.28), 0.18, 0.00),
}
# ----------------------------------------------------------------------------
# Конструкторы форм — таблицы ниже читаются глазами, а не разбираются
# ----------------------------------------------------------------------------
def cap(p0, p1, r0, r1=None):
"""Капсула (усечённый конус) между двумя мировыми точками позы покоя."""
return ("capsule", (p0, p1, r0, r0 if r1 is None else r1))
def box(center, size, yaw=0.0):
return ("box", (center, size, yaw))
def ball(center, radius, scale=(1.0, 1.0, 1.0)):
return ("sphere", (center, radius, scale))
def _flip_point(p):
return (-p[0], p[1], p[2])
def _flip_shape(shape):
kind, params = shape
if kind == "capsule":
p0, p1, r0, r1 = params
return ("capsule", (_flip_point(p0), _flip_point(p1), r0, r1))
if kind == "box":
center, size, yaw = params
return ("box", (_flip_point(center), size, -yaw))
center, radius, scale = params
return ("sphere", (_flip_point(center), radius, scale))
def _flip_name(name):
if name.endswith("_r"):
return name[:-2] + "_l"
if name.endswith("_l"):
return name[:-2] + "_r"
return name
def mirror_bones(bones):
"""Правые кости -> левые. Симметрия описывается один раз, а не дважды."""
out = list(bones)
for name, head, tail, parent in bones:
if not name.endswith("_r"):
continue
out.append((_flip_name(name), _flip_point(head), _flip_point(tail),
None if parent is None else _flip_name(parent)))
return out
def mirror_parts(parts):
"""Зеркалит по имени ДЕТАЛИ, а не кости: накладка на непарной кости
(`hip_plate_r` на `hips`) тоже обязана получить пару."""
out = list(parts)
for name, bone, mat, shape in parts:
if not name.endswith("_r"):
continue
out.append((_flip_name(name), _flip_name(bone), mat, _flip_shape(shape)))
return out
# ----------------------------------------------------------------------------
# SHAMBLER — «освежёванный»: грудная клетка вскрыта наружу, правая рука
# доросла до колена. Медленный, кривой, читается как человек, которому
# что-то сделали.
# ----------------------------------------------------------------------------
SHAMBLER_BONES = mirror_bones([
("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.12), None),
("hips", (0.00, 0.00, 0.86), (0.00, 0.00, 0.99), "root"),
("spine", (0.00, 0.00, 0.99), (0.00, -0.03, 1.17), "hips"),
("chest", (0.00, -0.03, 1.17), (0.00, -0.09, 1.36), "spine"),
("neck", (0.00, -0.09, 1.36), (0.00, -0.14, 1.44), "chest"),
("head", (0.00, -0.14, 1.44), (0.00, -0.20, 1.56), "neck"),
("thigh_r", (-0.10, 0.00, 0.86), (-0.12, 0.02, 0.48), "hips"),
("shin_r", (-0.12, 0.02, 0.48), (-0.12, -0.01, 0.08), "thigh_r"),
("foot_r", (-0.12, -0.01, 0.08), (-0.12, -0.15, 0.02), "shin_r"),
])
# Руки НЕ зеркальны и потому объявлены вручную: правая доросла до колена,
# левая осталась человеческой. Асимметрия — половина силуэта этой твари.
SHAMBLER_BONES += [
("arm_r", (-0.19, -0.04, 1.30), (-0.24, 0.02, 0.96), "chest"),
("fore_r", (-0.24, 0.02, 0.96), (-0.27, -0.04, 0.54), "arm_r"),
("claw_r", (-0.27, -0.04, 0.54), (-0.28, -0.09, 0.38), "fore_r"),
("arm_l", (0.18, -0.04, 1.30), (0.23, 0.02, 1.02), "chest"),
("fore_l", (0.23, 0.02, 1.02), (0.26, -0.03, 0.74), "arm_l"),
("claw_l", (0.26, -0.03, 0.74), (0.27, -0.07, 0.63), "fore_l"),
]
SHAMBLER_PARTS = mirror_parts([
("rib_a_r", "chest", "bone", cap((-0.04, -0.03, 1.17), (-0.16, -0.21, 1.13), 0.020, 0.009)),
("rib_b_r", "chest", "bone", cap((-0.04, -0.05, 1.25), (-0.18, -0.23, 1.23), 0.021, 0.009)),
("rib_c_r", "chest", "bone", cap((-0.04, -0.07, 1.32), (-0.16, -0.20, 1.33), 0.019, 0.009)),
("thigh_r", "thigh_r", "flesh", cap((-0.10, 0.00, 0.88), (-0.12, 0.02, 0.48), 0.075, 0.055)),
("shin_r", "shin_r", "flesh", cap((-0.12, 0.02, 0.48), (-0.12, -0.01, 0.09), 0.052, 0.038)),
("foot_r", "foot_r", "sinew", box((-0.12, -0.08, 0.045), (0.085, 0.215, 0.085))),
])
SHAMBLER_PARTS += [
("pelvis", "hips", "flesh", cap((0.00, 0.00, 0.86), (0.00, 0.00, 1.00), 0.110, 0.100)),
("gut", "spine", "gut", ball((0.00, -0.06, 1.05), 0.115, (1.05, 0.92, 0.86))),
("thorax", "chest", "flesh", cap((0.00, -0.02, 1.11), (0.00, -0.09, 1.34), 0.112, 0.098)),
("cavity", "chest", "gut", ball((0.00, -0.11, 1.23), 0.072, (1.00, 0.75, 1.25))),
("shoulders", "chest", "flesh", cap((-0.19, -0.04, 1.31), (0.18, -0.04, 1.31), 0.058, 0.058)),
("staple_a", "spine", "steel", box((0.00, 0.06, 1.03), (0.075, 0.030, 0.014))),
("staple_b", "spine", "steel", box((0.00, 0.05, 1.11), (0.075, 0.030, 0.014))),
("staple_c", "chest", "steel", box((0.00, 0.03, 1.20), (0.070, 0.030, 0.014))),
("neck", "neck", "sinew", cap((0.00, -0.09, 1.35), (0.00, -0.14, 1.45), 0.046, 0.042)),
("skull", "head", "flesh", ball((0.00, -0.17, 1.49), 0.086, (0.86, 1.06, 1.04))),
("jaw", "head", "bone", box((0.00, -0.24, 1.425), (0.098, 0.115, 0.050))),
("crown", "head", "steel", box((0.02, -0.14, 1.552), (0.115, 0.125, 0.026))),
("bolt_r", "head", "rust", ball((-0.06, -0.20, 1.535), 0.017)),
("bolt_l", "head", "rust", ball((0.07, -0.20, 1.535), 0.017)),
# Правая рука — освежёванная и длинная: мышца без кожи, кисть-крюк.
("arm_r", "arm_r", "muscle", cap((-0.19, -0.04, 1.30), (-0.24, 0.02, 0.96), 0.052, 0.042)),
("fore_r", "fore_r", "muscle", cap((-0.24, 0.02, 0.96), (-0.27, -0.04, 0.55), 0.043, 0.028)),
("claw_r_a", "claw_r", "bone", cap((-0.27, -0.05, 0.54), (-0.30, -0.13, 0.37), 0.014, 0.000)),
("claw_r_b", "claw_r", "bone", cap((-0.27, -0.04, 0.54), (-0.25, -0.12, 0.36), 0.013, 0.000)),
("claw_r_c", "claw_r", "bone", cap((-0.27, -0.03, 0.54), (-0.28, -0.07, 0.34), 0.012, 0.000)),
("arm_l", "arm_l", "flesh", cap((0.18, -0.04, 1.30), (0.23, 0.02, 1.02), 0.050, 0.040)),
("fore_l", "fore_l", "flesh", cap((0.23, 0.02, 1.02), (0.26, -0.03, 0.75), 0.040, 0.030)),
("hand_l", "claw_l", "sinew", cap((0.26, -0.03, 0.75), (0.27, -0.07, 0.63), 0.032, 0.022)),
]
SHAMBLER_ANIM = {
"walk": {
# Хромота: правая нога шагает шире, левая волочится и почти не гнётся.
"cycle": [("thigh_r", X, d(24), 0.00, d(-4)), ("thigh_l", X, d(14), 0.50, d(3)),
("shin_r", X, -d(22), 0.22, d(-12)), ("shin_l", X, -d(8), 0.72, d(-5)),
("arm_r", X, d(9), 0.50, d(7)), ("arm_l", X, d(6), 0.00, d(4)),
("fore_r", X, d(6), 0.50, d(5)), ("spine", Y, d(6), 0.10, 0.0),
("chest", Z, d(7), 0.00, 0.0), ("head", Y, d(8), 0.30, d(7))],
"bob": (0.032, 0.0),
},
"attack": {
"keys": [("arm_r", X, [d(8), d(-92), d(-112), d(44), d(62), d(26)]),
("fore_r", X, [d(6), d(-38), d(-52), d(24), d(36), d(14)]),
("claw_r", X, [0, d(-18), d(-24), d(30), d(22), d(8)]),
("chest", Z, [0, d(26), d(32), d(-24), d(-16), d(-4)]),
("hips", Z, [0, d(11), d(14), d(-10), d(-6), 0]),
("head", X, [d(5), d(-9), d(-11), d(15), d(10), d(6)]),
("thigh_l", X, [0, d(-8), d(-10), d(12), d(8), 0])],
"root_loc": [(0, 0.03, 0), (0, 0.06, -0.02), (0, 0.07, -0.02),
(0, -0.07, -0.04), (0, -0.04, -0.02), (0, 0, 0)],
},
"hit": {
"keys": [("chest", X, [0, d(-24), d(-14), d(-4)]),
("head", X, [0, d(-28), d(-16), d(-5)]),
("arm_r", X, [0, d(26), d(15), d(4)]),
("arm_l", X, [0, d(22), d(13), d(3)]),
("thigh_r", X, [0, d(-12), d(-7), 0])],
"root_loc": [(0, 0, 0), (0, 0.06, -0.02), (0, 0.03, -0.01), (0, 0.01, 0)],
},
"death": {
# Не падение во весь рост, а осадка в кучу: труп обязан помещаться
# в клетку при ЛЮБОМ повороте, а лежащий во весь рост — не помещается.
"keys": [("hips", X, [0, d(6), d(18), d(30), d(38), d(42), d(44), d(44)]),
("spine", X, [0, d(8), d(24), d(40), d(52), d(58), d(60), d(60)]),
("chest", X, [0, d(6), d(20), d(34), d(44), d(50), d(52), d(52)]),
("head", X, [0, d(-14), d(10), d(28), d(36), d(40), d(42), d(42)]),
("thigh_r", X, [0, d(20), d(52), d(78), d(92), d(96), d(98), d(98)]),
("thigh_l", X, [0, d(16), d(46), d(72), d(88), d(94), d(96), d(96)]),
("shin_r", X, [0, d(-24), d(-62), d(-96), d(-114), d(-120), d(-122), d(-122)]),
("shin_l", X, [0, d(-20), d(-58), d(-92), d(-110), d(-118), d(-120), d(-120)]),
("arm_r", X, [0, d(-20), d(10), d(46), d(64), d(70), d(72), d(72)]),
("arm_l", X, [0, d(-16), d(8), d(40), d(58), d(64), d(66), d(66)])],
"root_rot": [("x", [0, d(-6), d(8), d(24), d(34), d(38), d(40), d(40)]),
("y", [0, d(2), d(6), d(11), d(14), d(15), d(16), d(16)])],
"root_loc": [(0, 0, 0), (0, 0.01, -0.10), (0, 0.02, -0.26), (0, 0.02, -0.40),
(0, 0.02, -0.48), (0, 0.02, -0.52), (0, 0.02, -0.54), (0, 0.02, -0.54)],
},
}
# ----------------------------------------------------------------------------
# RUSHER — четвероногий спринтер: горизонтальный хребет, пальцеходящие задние
# ноги, косы вместо предплечий, вместо головы — раскрытый цветок челюстей.
# ----------------------------------------------------------------------------
RUSHER_BONES = mirror_bones([
("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.12), None),
("hips", (0.00, 0.30, 0.60), (0.00, 0.14, 0.63), "root"),
("spine", (0.00, 0.14, 0.63), (0.00, -0.02, 0.65), "hips"),
("chest", (0.00, -0.02, 0.65), (0.00, -0.20, 0.64), "spine"),
("neck", (0.00, -0.20, 0.64), (0.00, -0.33, 0.60), "chest"),
("head", (0.00, -0.33, 0.60), (0.00, -0.46, 0.55), "neck"),
("fore_r", (-0.13, -0.12, 0.60), (-0.16, -0.04, 0.34), "chest"),
("scythe_r", (-0.16, -0.04, 0.34), (-0.17, -0.16, 0.05), "fore_r"),
("thigh_r", (-0.12, 0.26, 0.58), (-0.15, 0.38, 0.30), "hips"),
("shin_r", (-0.15, 0.38, 0.30), (-0.15, 0.20, 0.14), "thigh_r"),
("toe_r", (-0.15, 0.20, 0.14), (-0.15, 0.06, 0.02), "shin_r"),
("tail_a", (0.00, 0.34, 0.60), (0.00, 0.56, 0.66), "hips"),
("tail_b", (0.00, 0.56, 0.66), (0.00, 0.76, 0.58), "tail_a"),
])
RUSHER_PARTS = mirror_parts([
("fore_r", "fore_r", "muscle", cap((-0.13, -0.12, 0.61), (-0.16, -0.04, 0.35), 0.042, 0.034)),
("scythe_r", "scythe_r", "bone", cap((-0.16, -0.05, 0.35), (-0.17, -0.17, 0.05), 0.030, 0.006)),
("spur_r", "scythe_r", "bone", cap((-0.16, -0.06, 0.30), (-0.19, -0.20, 0.24), 0.014, 0.000)),
("thigh_r", "thigh_r", "muscle", cap((-0.12, 0.25, 0.59), (-0.15, 0.38, 0.30), 0.070, 0.046)),
("shin_r", "shin_r", "sinew", cap((-0.15, 0.38, 0.30), (-0.15, 0.20, 0.14), 0.034, 0.026)),
("toe_r", "toe_r", "bone", cap((-0.15, 0.20, 0.14), (-0.15, 0.05, 0.02), 0.026, 0.012)),
("hip_plate_r", "hips", "chitin", box((-0.10, 0.28, 0.66), (0.090, 0.150, 0.070))),
("jaw_up_r", "head", "bone", cap((-0.03, -0.34, 0.60), (-0.17, -0.52, 0.62), 0.028, 0.005)),
("jaw_lo_r", "head", "bone", cap((-0.03, -0.34, 0.56), (-0.14, -0.50, 0.47), 0.026, 0.005)),
])
RUSHER_PARTS += [
("hips", "hips", "flesh", cap((0.00, 0.32, 0.60), (0.00, 0.14, 0.63), 0.095, 0.100)),
("belly", "spine", "gut", cap((0.00, 0.16, 0.58), (0.00, -0.06, 0.58), 0.082, 0.074)),
("spine", "spine", "flesh", cap((0.00, 0.14, 0.63), (0.00, -0.02, 0.66), 0.098, 0.094)),
("thorax", "chest", "flesh", cap((0.00, -0.02, 0.66), (0.00, -0.20, 0.64), 0.094, 0.072)),
("crest_a", "spine", "bone", box((0.00, 0.10, 0.73), (0.026, 0.090, 0.090))),
("crest_b", "spine", "bone", box((0.00, -0.02, 0.75), (0.026, 0.090, 0.110))),
("crest_c", "chest", "bone", box((0.00, -0.14, 0.73), (0.024, 0.080, 0.090))),
("port", "chest", "steel", box((0.00, -0.10, 0.58), (0.100, 0.090, 0.040))),
("neck", "neck", "sinew", cap((0.00, -0.20, 0.64), (0.00, -0.33, 0.60), 0.062, 0.050)),
("stump", "head", "muscle", ball((0.00, -0.36, 0.59), 0.058, (1.00, 0.90, 0.95))),
("jaw_up", "head", "bone", cap((0.00, -0.36, 0.63), (0.00, -0.55, 0.68), 0.028, 0.005)),
("jaw_lo", "head", "bone", cap((0.00, -0.36, 0.55), (0.00, -0.53, 0.44), 0.026, 0.005)),
("gullet", "head", "gut", ball((0.00, -0.39, 0.59), 0.034, (1.00, 1.20, 1.00))),
("tail_a", "tail_a", "flesh", cap((0.00, 0.34, 0.60), (0.00, 0.56, 0.66), 0.060, 0.038)),
("tail_b", "tail_b", "sinew", cap((0.00, 0.56, 0.66), (0.00, 0.76, 0.58), 0.036, 0.010)),
("barb", "tail_b", "bone", cap((0.00, 0.72, 0.60), (0.00, 0.84, 0.52), 0.018, 0.000)),
]
RUSHER_ANIM = {
"walk": {
# Галоп в два такта: передние вместе, задние вместе, хребет качает.
"cycle": [("fore_r", X, d(34), 0.00, d(-6)), ("fore_l", X, d(34), 0.12, d(-6)),
("scythe_r", X, d(26), 0.30, d(14)), ("scythe_l", X, d(26), 0.42, d(14)),
("thigh_r", X, d(32), 0.50, 0.0), ("thigh_l", X, d(32), 0.62, 0.0),
("shin_r", X, d(26), 0.76, 0.0), ("shin_l", X, d(26), 0.88, 0.0),
("spine", X, d(9), 0.25, 0.0), ("chest", X, d(7), 0.35, 0.0),
("neck", X, d(8), 0.55, 0.0),
("tail_a", X, d(12), 0.20, d(-6)), ("tail_b", Z, d(16), 0.45, 0.0)],
"bob": (0.055, 0.25),
},
"attack": {
# Рывок: тело выстреливает вперёд, косы сходятся, челюсти распахнуты.
"keys": [("fore_r", X, [d(-10), d(-56), d(-64), d(46), d(30), d(-4)]),
("fore_l", X, [d(-10), d(-56), d(-64), d(46), d(30), d(-4)]),
("scythe_r", X, [d(10), d(48), d(56), d(-38), d(-18), d(6)]),
("scythe_l", X, [d(10), d(48), d(56), d(-38), d(-18), d(6)]),
("neck", X, [0, d(22), d(26), d(-30), d(-14), 0]),
("head", X, [0, d(16), d(20), d(-24), d(-10), 0]),
("spine", X, [0, d(12), d(14), d(-16), d(-8), 0]),
("tail_a", X, [d(-4), d(-22), d(-26), d(18), d(10), 0])],
"root_loc": [(0, 0.04, 0), (0, 0.10, 0.04), (0, 0.12, 0.05),
(0, -0.16, 0.02), (0, -0.08, 0), (0, 0, 0)],
},
"hit": {
"keys": [("spine", X, [0, d(-20), d(-11), d(-3)]),
("neck", X, [0, d(-26), d(-15), d(-4)]),
("fore_r", X, [0, d(30), d(17), d(5)]),
("fore_l", X, [0, d(26), d(15), d(4)]),
("tail_a", X, [0, d(24), d(13), d(3)])],
"root_loc": [(0, 0, 0), (0, 0.08, -0.03), (0, 0.04, -0.01), (0, 0.01, 0)],
},
"death": {
# Ноги подламываются, туша садится на брюхо и заваливается набок.
"keys": [("thigh_r", X, [0, d(-18), d(-46), d(-70), d(-84), d(-90), d(-92), d(-92)]),
("thigh_l", X, [0, d(-16), d(-42), d(-66), d(-80), d(-86), d(-88), d(-88)]),
("fore_r", X, [0, d(24), d(58), d(84), d(96), d(100), d(102), d(102)]),
("fore_l", X, [0, d(20), d(52), d(78), d(92), d(98), d(100), d(100)]),
("scythe_r", X, [0, d(-18), d(-44), d(-64), d(-74), d(-78), d(-80), d(-80)]),
("scythe_l", X, [0, d(-16), d(-40), d(-60), d(-70), d(-74), d(-76), d(-76)]),
("neck", X, [0, d(-12), d(6), d(24), d(34), d(38), d(40), d(40)]),
("head", Z, [0, d(10), d(24), d(36), d(42), d(44), d(46), d(46)]),
("tail_a", Z, [0, d(-14), d(-30), d(-42), d(-48), d(-50), d(-52), d(-52)]),
("tail_b", Z, [0, d(-16), d(-34), d(-48), d(-56), d(-60), d(-62), d(-62)])],
"root_rot": [("y", [0, d(6), d(18), d(32), d(42), d(48), d(50), d(50)])],
"root_loc": [(0, 0, 0), (0, 0.01, -0.08), (0, 0.02, -0.20), (0, 0.02, -0.30),
(0, 0.02, -0.36), (0, 0.02, -0.39), (0, 0.02, -0.40), (0, 0.02, -0.40)],
},
}
# ----------------------------------------------------------------------------
# SPITTER — сосуд под давлением: волочащийся железистый мешок, вживлённый в
# горло клапан и сопло вместо лица, руки атрофированы.
# ----------------------------------------------------------------------------
SPITTER_BONES = mirror_bones([
("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.12), None),
("hips", (0.00, 0.00, 0.52), (0.00, 0.00, 0.66), "root"),
("sac", (0.00, 0.06, 0.58), (0.00, 0.34, 0.44), "hips"),
("spine", (0.00, 0.00, 0.66), (0.00, -0.03, 0.84), "hips"),
("chest", (0.00, -0.03, 0.84), (0.00, -0.05, 1.00), "spine"),
("valve", (0.00, -0.05, 1.00), (0.00, -0.10, 1.09), "chest"),
("head", (0.00, -0.10, 1.09), (0.00, -0.24, 1.14), "valve"),
("arm_r", (-0.15, -0.04, 0.94), (-0.21, 0.00, 0.76), "chest"),
("fore_r", (-0.21, 0.00, 0.76), (-0.24, -0.06, 0.62), "arm_r"),
("thigh_r", (-0.13, 0.00, 0.52), (-0.20, 0.04, 0.28), "hips"),
("shin_r", (-0.20, 0.04, 0.28), (-0.22, -0.02, 0.06), "thigh_r"),
])
SPITTER_PARTS = mirror_parts([
("arm_r", "arm_r", "flesh", cap((-0.15, -0.04, 0.94), (-0.21, 0.00, 0.77), 0.032, 0.024)),
("fore_r", "fore_r", "sinew", cap((-0.21, 0.00, 0.77), (-0.24, -0.06, 0.62), 0.023, 0.014)),
("thigh_r", "thigh_r", "flesh", cap((-0.13, 0.00, 0.53), (-0.20, 0.04, 0.28), 0.070, 0.050)),
("shin_r", "shin_r", "flesh", cap((-0.20, 0.04, 0.28), (-0.22, -0.02, 0.07), 0.048, 0.036)),
("foot_r", "shin_r", "sinew", box((-0.22, -0.07, 0.035), (0.110, 0.230, 0.070))),
("gland_r", "sac", "tube", cap((-0.06, 0.16, 0.62), (-0.05, -0.02, 0.96), 0.016, 0.013)),
])
SPITTER_PARTS += [
("pelvis", "hips", "flesh", cap((0.00, 0.00, 0.52), (0.00, 0.00, 0.68), 0.125, 0.110)),
# Мешок — главная читаемая деталь: он больше грудной клетки и волочится.
("sac", "sac", "membrane", ball((0.00, 0.24, 0.44), 0.215, (0.95, 1.25, 0.90))),
("sac_seam", "sac", "sinew", cap((0.00, 0.06, 0.56), (0.00, 0.42, 0.44), 0.030, 0.018)),
("sac_ring", "sac", "rust", box((0.00, 0.10, 0.52), (0.230, 0.040, 0.190))),
("gland_c", "sac", "tube", cap((0.00, 0.18, 0.66), (0.00, -0.02, 0.98), 0.018, 0.014)),
("spine", "spine", "flesh", cap((0.00, 0.00, 0.66), (0.00, -0.03, 0.85), 0.100, 0.096)),
("thorax", "chest", "flesh", cap((0.00, -0.03, 0.85), (0.00, -0.05, 1.00), 0.098, 0.086)),
("clamp", "chest", "steel", box((0.00, -0.04, 0.92), (0.190, 0.180, 0.030))),
# Клапан в горле: фланец, корпус, два болта. Это имплант, а не кость.
("valve", "valve", "steel", cap((0.00, -0.05, 1.00), (0.00, -0.10, 1.10), 0.050, 0.044)),
("flange", "valve", "steel", box((0.00, -0.07, 1.05), (0.140, 0.130, 0.022))),
("valve_bolt_r", "valve", "rust", ball((-0.055, -0.07, 1.05), 0.016)),
("valve_bolt_l", "valve", "rust", ball((0.055, -0.07, 1.05), 0.016)),
# Сопло вместо лица: раструб, а не голова.
("skull", "head", "flesh", ball((0.00, -0.14, 1.11), 0.070, (0.90, 1.00, 0.88))),
("nozzle", "head", "steel", cap((0.00, -0.16, 1.12), (0.00, -0.30, 1.13), 0.038, 0.062)),
("nozzle_lip", "head", "rust", cap((0.00, -0.29, 1.13), (0.00, -0.32, 1.13), 0.066, 0.060)),
("ocellus_r", "head", "ocellus", ball((-0.045, -0.19, 1.17), 0.014)),
("ocellus_l", "head", "ocellus", ball((0.045, -0.19, 1.17), 0.014)),
("ocellus_c", "head", "ocellus", ball((0.000, -0.20, 1.19), 0.012)),
]
SPITTER_ANIM = {
"walk": {
# Переваливается: мешок раскачивается противофазой к корпусу.
"cycle": [("thigh_r", X, d(19), 0.00, d(-3)), ("thigh_l", X, d(19), 0.50, d(-3)),
("shin_r", X, -d(14), 0.20, d(-8)), ("shin_l", X, -d(14), 0.70, d(-8)),
("hips", Y, d(9), 0.00, 0.0), ("sac", X, d(11), 0.50, d(-4)),
("sac", Y, d(8), 0.25, 0.0), ("spine", Y, -d(7), 0.00, 0.0),
("arm_r", X, d(10), 0.50, d(8)), ("arm_l", X, d(10), 0.00, d(8)),
("head", X, d(5), 0.15, 0.0)],
"bob": (0.028, 0.0),
},
"attack": {
# Присесть, накачать мешок, выпрямить шею — плевок на 3-4 кадре.
"keys": [("spine", X, [0, d(-18), d(-24), d(20), d(10), d(2)]),
("chest", X, [0, d(-12), d(-16), d(16), d(8), d(2)]),
("valve", X, [0, d(-14), d(-20), d(26), d(12), d(2)]),
("head", X, [0, d(-16), d(-22), d(30), d(14), d(3)]),
("sac", X, [0, d(-14), d(-20), d(16), d(8), 0]),
("arm_r", X, [d(6), d(-24), d(-30), d(18), d(10), d(6)]),
("arm_l", X, [d(6), d(-24), d(-30), d(18), d(10), d(6)]),
("thigh_r", X, [0, d(16), d(22), d(-8), d(-4), 0]),
("thigh_l", X, [0, d(16), d(22), d(-8), d(-4), 0])],
"root_loc": [(0, 0, 0), (0, 0.03, -0.06), (0, 0.04, -0.08),
(0, -0.04, 0.02), (0, -0.02, 0.01), (0, 0, 0)],
},
"hit": {
"keys": [("spine", X, [0, d(-22), d(-13), d(-4)]),
("head", X, [0, d(-26), d(-15), d(-4)]),
("sac", X, [0, d(20), d(11), d(3)]),
("arm_r", X, [0, d(24), d(14), d(4)]),
("arm_l", X, [0, d(24), d(14), d(4)])],
"root_loc": [(0, 0, 0), (0, 0.05, -0.02), (0, 0.03, -0.01), (0, 0.01, 0)],
},
"death": {
# Мешок сдувается первым, потом ноги разъезжаются и туша оседает.
"keys": [("sac", X, [0, d(10), d(26), d(38), d(44), d(46), d(48), d(48)]),
("spine", X, [0, d(10), d(28), d(46), d(58), d(64), d(66), d(66)]),
("chest", X, [0, d(8), d(22), d(36), d(46), d(50), d(52), d(52)]),
("head", X, [0, d(-10), d(14), d(34), d(44), d(48), d(50), d(50)]),
("thigh_r", Y, [0, d(-12), d(-28), d(-40), d(-46), d(-48), d(-50), d(-50)]),
("thigh_l", Y, [0, d(12), d(28), d(40), d(46), d(48), d(50), d(50)]),
("thigh_r", X, [0, d(18), d(44), d(64), d(74), d(78), d(80), d(80)]),
("thigh_l", X, [0, d(18), d(44), d(64), d(74), d(78), d(80), d(80)]),
("shin_r", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]),
("shin_l", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]),
("arm_r", X, [0, d(-14), d(10), d(38), d(52), d(58), d(60), d(60)]),
("arm_l", X, [0, d(-14), d(10), d(38), d(52), d(58), d(60), d(60)])],
"root_rot": [("x", [0, d(-4), d(8), d(20), d(28), d(32), d(34), d(34)])],
"root_loc": [(0, 0, 0), (0, 0.01, -0.06), (0, 0.02, -0.16), (0, 0.02, -0.24),
(0, 0.02, -0.29), (0, 0.02, -0.32), (0, 0.02, -0.33), (0, 0.02, -0.33)],
},
}
# ----------------------------------------------------------------------------
# BRUTE — «сросшийся»: к плечу приварено второе, наполовину поглощённое тело;
# грудь забрана болтами; правая рука — молот из сросшейся кости.
# ----------------------------------------------------------------------------
BRUTE_BONES = mirror_bones([
("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.14), None),
("hips", (0.00, 0.00, 0.98), (0.00, 0.00, 1.16), "root"),
("spine", (0.00, 0.00, 1.16), (0.00, -0.02, 1.38), "hips"),
("chest", (0.00, -0.02, 1.38), (0.00, -0.04, 1.60), "spine"),
("neck", (0.00, -0.04, 1.60), (0.00, -0.08, 1.66), "chest"),
("head", (0.00, -0.08, 1.66), (0.00, -0.12, 1.78), "neck"),
("thigh_r", (-0.17, 0.00, 0.98), (-0.19, 0.02, 0.54), "hips"),
("shin_r", (-0.19, 0.02, 0.54), (-0.19, -0.01, 0.10), "thigh_r"),
("foot_r", (-0.19, -0.01, 0.10), (-0.19, -0.16, 0.03), "shin_r"),
])
# Руки и привой не зеркальны: справа молот из сросшейся кости, слева — чужое
# тело, вросшее в плечо. Объявлены руками, иначе зеркало создало бы дубли.
BRUTE_BONES += [
("arm_r", (-0.32, -0.02, 1.54), (-0.44, 0.02, 1.18), "chest"),
("fore_r", (-0.44, 0.02, 1.18), (-0.50, -0.04, 0.82), "arm_r"),
("maul_r", (-0.50, -0.04, 0.82), (-0.52, -0.06, 0.64), "fore_r"),
("arm_l", (0.28, -0.02, 1.52), (0.38, 0.02, 1.20), "chest"),
("fore_l", (0.38, 0.02, 1.20), (0.42, -0.04, 0.90), "arm_l"),
("graft", (0.20, 0.06, 1.60), (0.32, 0.10, 1.78), "chest"),
("graft_head", (0.32, 0.10, 1.78), (0.40, 0.12, 1.90), "graft"),
("graft_arm", (0.32, 0.08, 1.72), (0.50, 0.04, 1.50), "graft"),
]
BRUTE_PARTS = mirror_parts([
("thigh_r", "thigh_r", "flesh", cap((-0.17, 0.00, 1.00), (-0.19, 0.02, 0.54), 0.115, 0.088)),
("shin_r", "shin_r", "flesh", cap((-0.19, 0.02, 0.54), (-0.19, -0.01, 0.11), 0.085, 0.062)),
("foot_r", "foot_r", "sinew", box((-0.19, -0.09, 0.055), (0.135, 0.290, 0.110))),
("knee_r", "shin_r", "bone", ball((-0.19, -0.05, 0.54), 0.062, (0.90, 0.80, 0.90))),
])
BRUTE_PARTS += [
("pelvis", "hips", "flesh", cap((0.00, 0.00, 0.98), (0.00, 0.00, 1.18), 0.180, 0.170)),
("gut", "spine", "gut", ball((0.00, -0.08, 1.24), 0.165, (1.10, 0.92, 0.86))),
("torso", "chest", "flesh", cap((0.00, -0.01, 1.28), (0.00, -0.04, 1.58), 0.215, 0.235)),
("shoulders", "chest", "muscle", cap((-0.32, -0.02, 1.54), (0.30, -0.02, 1.54), 0.115, 0.115)),
# Броня не надета, а вбита: пластины утоплены в мясо и держатся болтами.
("plate_a", "chest", "steel", box((0.00, -0.17, 1.50), (0.360, 0.130, 0.150))),
("plate_b", "chest", "steel", box((0.00, -0.16, 1.32), (0.330, 0.130, 0.140))),
("plate_back", "chest", "rust", box((0.00, 0.16, 1.42), (0.340, 0.110, 0.320))),
("bolt_a", "chest", "rust", ball((-0.15, -0.23, 1.50), 0.026)),
("bolt_b", "chest", "rust", ball((0.15, -0.23, 1.50), 0.026)),
("bolt_c", "chest", "rust", ball((-0.13, -0.22, 1.32), 0.024)),
("bolt_d", "chest", "rust", ball((0.13, -0.22, 1.32), 0.024)),
("collar", "neck", "steel", cap((-0.09, -0.05, 1.62), (0.09, -0.05, 1.62), 0.055, 0.055)),
("skull", "head", "flesh", ball((0.00, -0.10, 1.71), 0.078, (0.92, 1.00, 0.92))),
("maw", "head", "bone", box((0.00, -0.16, 1.68), (0.100, 0.090, 0.050))),
# Рука-молот: предплечье срослось в костяную колотушку с шипами.
("arm_r", "arm_r", "muscle", cap((-0.32, -0.02, 1.54), (-0.44, 0.02, 1.18), 0.115, 0.098)),
("fore_r", "fore_r", "muscle", cap((-0.44, 0.02, 1.18), (-0.50, -0.04, 0.86), 0.100, 0.086)),
("maul", "maul_r", "bone", ball((-0.51, -0.05, 0.74), 0.125, (0.95, 1.05, 1.00))),
("maul_spike_a", "maul_r", "bone", cap((-0.51, -0.05, 0.74), (-0.68, -0.10, 0.66), 0.030, 0.000)),
("maul_spike_b", "maul_r", "bone", cap((-0.51, -0.05, 0.74), (-0.50, -0.06, 0.56), 0.028, 0.000)),
("maul_spike_c", "maul_r", "bone", cap((-0.51, -0.05, 0.74), (-0.56, -0.22, 0.72), 0.026, 0.000)),
("arm_l", "arm_l", "flesh", cap((0.28, -0.02, 1.52), (0.38, 0.02, 1.20), 0.098, 0.082)),
("fore_l", "fore_l", "flesh", cap((0.38, 0.02, 1.20), (0.42, -0.04, 0.92), 0.082, 0.064)),
("fist_l", "fore_l", "sinew", ball((0.42, -0.05, 0.89), 0.070)),
# Привой: чужой торс, безвольная голова и рука, которая просто висит.
("graft", "graft", "flesh", cap((0.20, 0.06, 1.60), (0.33, 0.10, 1.78), 0.105, 0.090)),
("graft_seam", "graft", "steel", box((0.20, 0.06, 1.60), (0.130, 0.130, 0.030))),
("graft_head", "graft_head", "flesh", ball((0.38, 0.12, 1.87), 0.070, (0.92, 1.00, 0.96))),
("graft_jaw", "graft_head", "bone", box((0.40, 0.09, 1.83), (0.075, 0.070, 0.040))),
("graft_arm", "graft_arm", "sinew", cap((0.33, 0.08, 1.73), (0.50, 0.04, 1.50), 0.052, 0.034)),
("graft_hand", "graft_arm", "sinew", ball((0.51, 0.03, 1.47), 0.042)),
]
BRUTE_ANIM = {
"walk": {
# Тяжёлый шаг вразвалку: плечи катаются, привой болтается отдельно.
"cycle": [("thigh_r", X, d(20), 0.00, d(-3)), ("thigh_l", X, d(20), 0.50, d(-3)),
("shin_r", X, -d(16), 0.22, d(-9)), ("shin_l", X, -d(16), 0.72, d(-9)),
("hips", Y, d(8), 0.00, 0.0), ("chest", Y, -d(7), 0.05, 0.0),
("chest", Z, d(9), 0.25, 0.0),
("arm_r", X, d(13), 0.50, d(6)), ("arm_l", X, d(13), 0.00, d(6)),
("fore_r", X, d(7), 0.60, d(4)),
("graft", Y, d(11), 0.35, 0.0), ("graft_head", X, d(13), 0.45, d(6)),
("graft_arm", X, d(17), 0.15, 0.0),
("head", X, d(4), 0.30, d(3))],
"bob": (0.045, 0.0),
},
"attack": {
# Замах ВБОК, а не над головой: молот над головой вылезал бы из клетки.
"keys": [("arm_r", Z, [0, d(-52), d(-64), d(38), d(24), d(4)]),
("arm_r", X, [d(4), d(-34), d(-42), d(26), d(16), d(4)]),
("fore_r", X, [d(4), d(-24), d(-30), d(34), d(20), d(6)]),
("maul_r", X, [0, d(-16), d(-20), d(26), d(14), d(4)]),
("chest", Z, [0, d(30), d(36), d(-30), d(-18), d(-4)]),
("hips", Z, [0, d(14), d(17), d(-14), d(-8), 0]),
("graft", Z, [0, d(-16), d(-20), d(18), d(10), 0]),
("graft_arm", X, [0, d(-20), d(-26), d(30), d(16), 0]),
("thigh_l", X, [0, d(-10), d(-13), d(16), d(9), 0])],
"root_loc": [(0, 0.02, 0), (0, 0.05, 0.02), (0, 0.06, 0.02),
(0, -0.08, -0.05), (0, -0.05, -0.03), (0, 0, 0)],
},
"hit": {
"keys": [("chest", X, [0, d(-18), d(-10), d(-3)]),
("head", X, [0, d(-22), d(-13), d(-4)]),
("arm_r", X, [0, d(20), d(11), d(3)]),
("graft_head", X, [0, d(-26), d(-15), d(-4)]),
("graft_arm", X, [0, d(28), d(16), d(4)])],
"root_loc": [(0, 0, 0), (0, 0.05, -0.03), (0, 0.03, -0.01), (0, 0.01, 0)],
},
"death": {
# Оседает на колени и валится вперёд на молот. Куча, а не бревно.
"keys": [("hips", X, [0, d(8), d(20), d(32), d(40), d(44), d(46), d(46)]),
("spine", X, [0, d(10), d(26), d(42), d(54), d(60), d(62), d(62)]),
("chest", X, [0, d(8), d(22), d(36), d(46), d(52), d(54), d(54)]),
("head", X, [0, d(-12), d(12), d(30), d(40), d(44), d(46), d(46)]),
("thigh_r", X, [0, d(22), d(56), d(84), d(98), d(104), d(106), d(106)]),
("thigh_l", X, [0, d(20), d(52), d(80), d(94), d(100), d(102), d(102)]),
("shin_r", X, [0, d(-26), d(-66), d(-100), d(-118), d(-124), d(-126), d(-126)]),
("shin_l", X, [0, d(-24), d(-62), d(-96), d(-114), d(-120), d(-122), d(-122)]),
("arm_r", X, [0, d(-14), d(14), d(50), d(68), d(74), d(76), d(76)]),
("arm_l", X, [0, d(-12), d(12), d(44), d(62), d(68), d(70), d(70)]),
("graft", X, [0, d(-16), d(8), d(34), d(48), d(54), d(56), d(56)]),
("graft_arm", X, [0, d(-20), d(10), d(44), d(62), d(70), d(72), d(72)])],
"root_rot": [("x", [0, d(-8), d(6), d(22), d(32), d(36), d(38), d(38)]),
("y", [0, d(-2), d(-6), d(-11), d(-14), d(-16), d(-17), d(-17)])],
# Keep the fallen mass centred around the ground pivot. Driving the
# root farther below the floor only moves the projected corpse down
# screen and clips it; the limb rotations already sell the collapse.
"root_loc": [(0, 0, 0), (0, 0.01, -0.10), (0, 0.02, -0.22), (0, 0.02, -0.32),
(0, 0.02, -0.38), (0, 0.02, -0.40), (0, 0.02, -0.40), (0, 0.02, -0.40)],
},
}
# ----------------------------------------------------------------------------
# LURKER — жнец-паук: плоский зашитый панцирь подвешен под четырьмя длинными
# выгнутыми лапами, вместо головы — безглазый клин с крюками.
# ----------------------------------------------------------------------------
LURKER_BONES = mirror_bones([
("root", (0.00, 0.00, 0.00), (0.00, 0.00, 0.10), None),
("core", (0.00, 0.10, 0.36), (0.00, -0.08, 0.38), "root"),
("head", (0.00, -0.08, 0.38), (0.00, -0.26, 0.32), "core"),
("fcoxa_r", (-0.09, -0.04, 0.40), (-0.26, -0.18, 0.66), "core"),
("ffemur_r", (-0.26, -0.18, 0.66), (-0.44, -0.30, 0.28), "fcoxa_r"),
("ftarsus_r", (-0.44, -0.30, 0.28), (-0.50, -0.36, 0.01), "ffemur_r"),
("bcoxa_r", (-0.09, 0.12, 0.40), (-0.27, 0.24, 0.64), "core"),
("bfemur_r", (-0.27, 0.24, 0.64), (-0.45, 0.36, 0.26), "bcoxa_r"),
("btarsus_r", (-0.45, 0.36, 0.26), (-0.51, 0.42, 0.01), "bfemur_r"),
("hook_r", (-0.05, -0.20, 0.34), (-0.10, -0.34, 0.24), "head"),
])
LURKER_PARTS = mirror_parts([
("fcoxa_r", "fcoxa_r", "chitin", cap((-0.09, -0.04, 0.40), (-0.26, -0.18, 0.66), 0.040, 0.028)),
("ffemur_r", "ffemur_r", "chitin", cap((-0.26, -0.18, 0.66), (-0.44, -0.30, 0.28), 0.028, 0.017)),
("ftarsus_r", "ftarsus_r", "bone", cap((-0.44, -0.30, 0.28), (-0.50, -0.36, 0.01), 0.017, 0.004)),
("fjoint_r", "ffemur_r", "sinew", ball((-0.26, -0.18, 0.66), 0.036)),
("bcoxa_r", "bcoxa_r", "chitin", cap((-0.09, 0.12, 0.40), (-0.27, 0.24, 0.64), 0.040, 0.028)),
("bfemur_r", "bfemur_r", "chitin", cap((-0.27, 0.24, 0.64), (-0.45, 0.36, 0.26), 0.028, 0.017)),
("btarsus_r", "btarsus_r", "bone", cap((-0.45, 0.36, 0.26), (-0.51, 0.42, 0.01), 0.017, 0.004)),
("bjoint_r", "bfemur_r", "sinew", ball((-0.27, 0.24, 0.64), 0.036)),
("hook_r", "hook_r", "bone", cap((-0.05, -0.20, 0.34), (-0.11, -0.36, 0.23), 0.020, 0.000)),
])
LURKER_PARTS += [
("carapace", "core", "chitin", ball((0.00, 0.02, 0.38), 0.185, (0.85, 1.15, 0.42))),
("belly", "core", "flesh", ball((0.00, 0.02, 0.31), 0.150, (0.82, 1.05, 0.30))),
# Шов: панцирь не вырос, его ЗАШИЛИ. Скобы идут по всей спине.
("suture", "core", "sinew", box((0.00, 0.02, 0.455), (0.030, 0.290, 0.020))),
("staple_a", "core", "steel", box((0.00, -0.10, 0.462), (0.110, 0.026, 0.014))),
("staple_b", "core", "steel", box((0.00, 0.02, 0.468), (0.115, 0.026, 0.014))),
("staple_c", "core", "steel", box((0.00, 0.14, 0.462), (0.110, 0.026, 0.014))),
("vent_r", "core", "rust", box((-0.13, 0.16, 0.40), (0.060, 0.070, 0.070))),
("vent_l", "core", "rust", box((0.13, 0.16, 0.40), (0.060, 0.070, 0.070))),
("wedge", "head", "chitin", cap((0.00, -0.08, 0.38), (0.00, -0.28, 0.31), 0.085, 0.030)),
("mouth", "head", "gut", ball((0.00, -0.26, 0.32), 0.036, (1.00, 0.70, 1.00))),
]
LURKER_ANIM = {
"walk": {
# Семенит: лапы работают по диагонали, корпус почти не шевелится —
# именно это и должно читаться как «крадётся», а не «топает».
"cycle": [("fcoxa_r", X, d(20), 0.00, 0.0), ("fcoxa_l", X, d(20), 0.50, 0.0),
("bcoxa_r", X, d(20), 0.50, 0.0), ("bcoxa_l", X, d(20), 0.00, 0.0),
("ffemur_r", X, d(24), 0.18, 0.0), ("ffemur_l", X, d(24), 0.68, 0.0),
("bfemur_r", X, d(24), 0.68, 0.0), ("bfemur_l", X, d(24), 0.18, 0.0),
("ftarsus_r", X, d(16), 0.34, 0.0), ("ftarsus_l", X, d(16), 0.84, 0.0),
("btarsus_r", X, d(16), 0.84, 0.0), ("btarsus_l", X, d(16), 0.34, 0.0),
("core", Y, d(5), 0.00, 0.0), ("head", X, d(6), 0.25, 0.0)],
"bob": (0.022, 0.0),
},
"attack": {
# Вздыбить перед, ударить крюками сверху вниз.
"keys": [("fcoxa_r", X, [0, d(-46), d(-56), d(34), d(18), d(2)]),
("fcoxa_l", X, [0, d(-46), d(-56), d(34), d(18), d(2)]),
("ffemur_r", X, [0, d(-30), d(-38), d(28), d(14), d(2)]),
("ffemur_l", X, [0, d(-30), d(-38), d(28), d(14), d(2)]),
("core", X, [0, d(-26), d(-32), d(22), d(10), 0]),
("head", X, [0, d(-20), d(-26), d(38), d(16), 0]),
("hook_r", X, [0, d(-18), d(-24), d(34), d(14), 0]),
("hook_l", X, [0, d(-18), d(-24), d(34), d(14), 0]),
("bfemur_r", X, [0, d(14), d(18), d(-10), d(-5), 0]),
("bfemur_l", X, [0, d(14), d(18), d(-10), d(-5), 0])],
"root_loc": [(0, 0, 0), (0, 0.02, 0.06), (0, 0.03, 0.08),
(0, -0.07, -0.02), (0, -0.03, -0.01), (0, 0, 0)],
},
"hit": {
"keys": [("core", X, [0, d(-18), d(-10), d(-3)]),
("head", X, [0, d(-24), d(-14), d(-4)]),
("fcoxa_r", X, [0, d(22), d(13), d(4)]),
("fcoxa_l", X, [0, d(22), d(13), d(4)]),
("bcoxa_r", X, [0, d(-14), d(-8), d(-2)]),
("bcoxa_l", X, [0, d(-14), d(-8), d(-2)])],
"root_loc": [(0, 0, 0), (0, 0.06, -0.02), (0, 0.03, -0.01), (0, 0.01, 0)],
},
"death": {
# Лапы поджимаются НАВЕРХ и внутрь, панцирь падает на пол — классика.
"keys": [("fcoxa_r", X, [0, d(-16), d(-38), d(-56), d(-66), d(-70), d(-72), d(-72)]),
("fcoxa_l", X, [0, d(-16), d(-38), d(-56), d(-66), d(-70), d(-72), d(-72)]),
("bcoxa_r", X, [0, d(-14), d(-34), d(-52), d(-62), d(-66), d(-68), d(-68)]),
("bcoxa_l", X, [0, d(-14), d(-34), d(-52), d(-62), d(-66), d(-68), d(-68)]),
("ffemur_r", X, [0, d(26), d(64), d(96), d(112), d(118), d(120), d(120)]),
("ffemur_l", X, [0, d(26), d(64), d(96), d(112), d(118), d(120), d(120)]),
("bfemur_r", X, [0, d(24), d(60), d(92), d(108), d(114), d(116), d(116)]),
("bfemur_l", X, [0, d(24), d(60), d(92), d(108), d(114), d(116), d(116)]),
("ftarsus_r", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]),
("ftarsus_l", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]),
("btarsus_r", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]),
("btarsus_l", X, [0, d(-20), d(-48), d(-72), d(-84), d(-88), d(-90), d(-90)]),
("head", X, [0, d(8), d(20), d(30), d(36), d(38), d(40), d(40)])],
"root_rot": [("y", [0, d(6), d(16), d(26), d(32), d(35), d(36), d(36)])],
"root_loc": [(0, 0, 0), (0, 0.00, -0.05), (0, 0.01, -0.12), (0, 0.01, -0.18),
(0, 0.01, -0.22), (0, 0.01, -0.24), (0, 0.01, -0.25), (0, 0.01, -0.25)],
},
}
KINDS = {
"shambler": {"bones": SHAMBLER_BONES, "parts": SHAMBLER_PARTS, "anim": SHAMBLER_ANIM},
"rusher": {"bones": RUSHER_BONES, "parts": RUSHER_PARTS, "anim": RUSHER_ANIM},
"spitter": {"bones": SPITTER_BONES, "parts": SPITTER_PARTS, "anim": SPITTER_ANIM},
"brute": {"bones": BRUTE_BONES, "parts": BRUTE_PARTS, "anim": BRUTE_ANIM},
"lurker": {"bones": LURKER_BONES, "parts": LURKER_PARTS, "anim": LURKER_ANIM},
}
# ----------------------------------------------------------------------------
# Мелкие утилиты (родня agent_rig.py; расходиться им нельзя по смыслу камеры)
# ----------------------------------------------------------------------------
def log(message):
print("[monster_rig] " + message)
def collection(name, parent):
existing = bpy.data.collections.get(name)
if existing is not None:
return existing
made = bpy.data.collections.new(name)
parent.children.link(made)
return made
def _set_socket(node, name, value):
socket = node.inputs.get(name)
if socket is None:
return
try:
socket.default_value = value
except (TypeError, ValueError):
pass
def material(name):
existing = bpy.data.materials.get(name)
if existing is not None:
return existing
color, roughness, metallic = MATERIALS[name]
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
_set_socket(bsdf, "Base Color", (color[0], color[1], color[2], 1.0))
_set_socket(bsdf, "Roughness", roughness)
_set_socket(bsdf, "Metallic", metallic)
_set_socket(bsdf, "Specular IOR Level", 0.5)
_set_socket(bsdf, "Specular", 0.5)
mat.diffuse_color = (color[0], color[1], color[2], 1.0)
return mat
def mesh_object(name, bm, mat, coll):
mesh = bpy.data.meshes.new(name)
bm.to_mesh(mesh)
bm.free()
mesh.materials.append(mat)
for polygon in mesh.polygons:
polygon.use_smooth = False
obj = bpy.data.objects.new(name, mesh)
coll.objects.link(obj)
return obj
def build_shape(kind, params):
"""Возвращает bmesh детали в МИРОВЫХ координатах позы покоя."""
bm = bmesh.new()
if kind == "capsule":
p0, p1, r0, r1 = Vector(params[0]), Vector(params[1]), params[2], params[3]
axis = p1 - p0
length = axis.length
if length < 1e-6:
raise ValueError("degenerate capsule")
bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=10,
radius1=r0, radius2=r1, depth=length)
rot = axis.to_track_quat("Z", "Y").to_matrix().to_4x4()
bmesh.ops.transform(bm, matrix=rot, verts=bm.verts)
bmesh.ops.translate(bm, vec=(p0 + p1) * 0.5, verts=bm.verts)
elif kind == "box":
center, size, yaw = Vector(params[0]), Vector(params[1]), params[2]
bmesh.ops.create_cube(bm, size=1.0)
matrix = (Matrix.Translation(center)
@ Matrix.Rotation(yaw, 4, "Z")
@ Matrix.Diagonal(size.to_4d()))
bmesh.ops.transform(bm, matrix=matrix, verts=bm.verts)
elif kind == "sphere":
center, radius, scale = Vector(params[0]), params[1], Vector(params[2])
bmesh.ops.create_uvsphere(bm, u_segments=14, v_segments=8, radius=radius)
matrix = Matrix.Translation(center) @ Matrix.Diagonal(scale.to_4d())
bmesh.ops.transform(bm, matrix=matrix, verts=bm.verts)
else:
raise ValueError("unknown shape: " + kind)
return bm
def bone_parent(obj, armature, bone_name):
"""Жёстко цепляет объект к кости, сохраняя его положение в позе покоя."""
world = obj.matrix_world.copy()
obj.parent = armature
obj.parent_type = "BONE"
obj.parent_bone = bone_name
bpy.context.view_layer.update()
obj.matrix_world = world
# ----------------------------------------------------------------------------
# Сборка сцены
# ----------------------------------------------------------------------------
def build_armature(name, bones, coll):
data = bpy.data.armatures.new(name)
armature = bpy.data.objects.new(name, data)
coll.objects.link(armature)
bpy.context.view_layer.objects.active = armature
# Режим EDIT входит для ВЫДЕЛЕННЫХ объектов: без сброса выделения второй
# риг открылся бы вместе с первым и кости уехали бы в чужой скелет.
for other in bpy.context.view_layer.objects:
other.select_set(other is armature)
bpy.ops.object.mode_set(mode="EDIT")
made = {}
for bone_name, head, tail, parent in bones:
bone = data.edit_bones.new(bone_name)
bone.head = head
bone.tail = tail
bone.roll = 0.0
bone.use_connect = False
if parent is not None:
bone.parent = made[parent]
made[bone_name] = bone
bpy.ops.object.mode_set(mode="OBJECT")
for bone in armature.pose.bones:
bone.rotation_mode = "QUATERNION"
return armature
def build_parts(parts, prefix, armature, coll):
for suffix, bone, mat, (shape, params) in parts:
obj = mesh_object("%s_%s" % (prefix, suffix), build_shape(shape, params),
material(mat), coll)
bone_parent(obj, armature, bone)
def build_camera(scene, camera_cfg, root_coll):
elevation = math.radians(float(camera_cfg["elevation_deg"]))
distance = float(camera_cfg["distance"])
data = bpy.data.cameras.new("monster_cam")
data.type = "ORTHO"
data.clip_start = 0.01
data.clip_end = distance * 4.0
data.shift_x = 0.0
data.shift_y = 0.0
camera = bpy.data.objects.new("monster_cam", data)
# Смотрит строго в начало координат: ось взгляда проходит через (0,0,0),
# поэтому в ортографии оно всегда попадает точно в центр кадра.
camera.location = (0.0, -distance * math.cos(elevation), distance * math.sin(elevation))
camera.rotation_euler = (math.pi / 2.0 - elevation, 0.0, 0.0)
root_coll.objects.link(camera)
scene.camera = camera
return camera
def build_lights(root_coll):
# Свет фиксирован в МИРЕ: поворачивается тварь, значит каждое направление
# освещено по-своему. Ключ холодный сверху-слева, мясо подсвечено снизу
# тёплым — тот самый «мокрый» контур.
key = bpy.data.lights.new("key_sun", type="SUN")
key.energy = 3.0
key.color = (0.92, 0.95, 1.0)
key.angle = math.radians(8.0)
key_obj = bpy.data.objects.new("key_sun", key)
key_obj.rotation_euler = (math.radians(50.0), 0.0, math.radians(-40.0))
root_coll.objects.link(key_obj)
fill = bpy.data.lights.new("fill_sun", type="SUN")
fill.energy = 0.7
fill.color = (1.0, 0.52, 0.44)
fill_obj = bpy.data.objects.new("fill_sun", fill)
fill_obj.rotation_euler = (math.radians(112.0), 0.0, math.radians(155.0))
root_coll.objects.link(fill_obj)
rim = bpy.data.lights.new("rim_sun", type="SUN")
rim.energy = 1.1
rim.color = (0.60, 0.78, 0.92)
rim_obj = bpy.data.objects.new("rim_sun", rim)
rim_obj.rotation_euler = (math.radians(74.0), 0.0, math.radians(196.0))
root_coll.objects.link(rim_obj)
def build_world(scene):
world = bpy.data.worlds.new("monster_world")
world.use_nodes = True
background = world.node_tree.nodes.get("Background")
if background is not None:
_set_socket(background, "Color", (0.030, 0.036, 0.046, 1.0))
_set_socket(background, "Strength", 1.0)
scene.world = world
def configure_render(scene):
for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE", "CYCLES"):
try:
scene.render.engine = engine
break
except (TypeError, ValueError):
continue
scene.render.film_transparent = True
scene.render.use_motion_blur = False
scene.render.resolution_percentage = 100
scene.render.pixel_aspect_x = 1.0
scene.render.pixel_aspect_y = 1.0
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "8"
scene.render.image_settings.compression = 0
# Standard, а не AgX: спрайт должен получить ровно тот цвет, что в материале.
try:
scene.view_settings.view_transform = "Standard"
scene.view_settings.look = "None"
except (TypeError, ValueError):
pass
eevee = getattr(scene, "eevee", None)
if eevee is not None and hasattr(eevee, "taa_render_samples"):
eevee.taa_render_samples = 48
if scene.render.engine == "CYCLES":
scene.cycles.samples = 64
scene.cycles.use_denoising = True
# ----------------------------------------------------------------------------
# Анимации: нормированное время -> кадры каталога
# ----------------------------------------------------------------------------
def sample(values, t):
"""Кусочно-линейная выборка списка опорных значений на отрезке 0..1."""
if len(values) == 1:
return float(values[0])
x = max(0.0, min(1.0, t)) * (len(values) - 1)
index = min(int(x), len(values) - 2)
frac = x - index
return float(values[index]) * (1.0 - frac) + float(values[index + 1]) * frac
def world_axis_quat(armature, bone_name, axis, angle):
"""Кватернион позы, вращающий кость вокруг МИРОВОЙ оси на angle радиан."""
rest = armature.data.bones[bone_name].matrix_local.to_3x3()
local_axis = rest.inverted() @ Vector(axis)
if local_axis.length < 1e-9:
return Quaternion((1.0, 0.0, 0.0, 0.0))
return Quaternion(local_axis.normalized(), angle)
AXIS_BY_NAME = {"x": X, "y": Y, "z": Z}
def apply_pose(armature, frame, swings, root_offset, root_rot):
"""swings: [(bone, axis, angle)] — накапливаются, если кость встретилась дважды."""
for bone in armature.pose.bones:
bone.rotation_quaternion = Quaternion((1.0, 0.0, 0.0, 0.0))
armature.pose.bones["root"].location = Vector((0.0, 0.0, 0.0))
accumulated = {}
for bone_name, axis, angle in swings:
if bone_name not in armature.pose.bones:
raise SystemExit("[monster_rig] no bone '%s' in %s" % (bone_name, armature.name))
quat = world_axis_quat(armature, bone_name, axis, angle)
accumulated[bone_name] = accumulated.get(bone_name, Quaternion((1, 0, 0, 0))) @ quat
for bone_name, quat in accumulated.items():
armature.pose.bones[bone_name].rotation_quaternion = quat
root = armature.pose.bones["root"]
rest = armature.data.bones["root"].matrix_local.to_3x3()
# root_rot приходит списком (мировая ось, угол) — в локальную систему кости
# его переводит тот же world_axis_quat, что и все остальные вращения.
if root_rot:
quat = Quaternion((1.0, 0.0, 0.0, 0.0))
for axis, angle in root_rot:
quat = quat @ world_axis_quat(armature, "root", axis, angle)
root.rotation_quaternion = quat
if root_offset is not None:
root.location = rest.inverted() @ Vector(root_offset)
for bone in armature.pose.bones:
bone.keyframe_insert(data_path="rotation_quaternion", frame=frame)
root.keyframe_insert(data_path="location", frame=frame)
def make_action(armature, name):
if armature.animation_data is None:
armature.animation_data_create()
action = bpy.data.actions.new(name)
# Blender 5 не считает слоёное действие использованным, пока в нём нет
# каналов. Без fake user клип, совпавший с позой покоя, не переживёт save.
action.use_fake_user = True
armature.animation_data.action = action
return action
def build_clip(armature, kind, animation, spec, frames):
make_action(armature, "%s_%s" % (kind, animation))
cyclic = "cycle" in spec
for frame in range(frames):
# Циклический клип замыкается: последний кадр НЕ дублирует первый.
t = frame / float(frames) if cyclic else (frame / float(max(1, frames - 1)))
swings = []
offset = None
root_rot = []
if cyclic:
for bone_name, axis, amp, phase, bias in spec["cycle"]:
swings.append((bone_name, axis,
bias + amp * math.sin(2.0 * math.pi * (t + phase))))
bob, bob_phase = spec.get("bob", (0.0, 0.0))
offset = (0.0, 0.0, -bob * abs(math.sin(2.0 * math.pi * (t + bob_phase))))
else:
for bone_name, axis, values in spec.get("keys", []):
swings.append((bone_name, axis, sample(values, t)))
loc = spec.get("root_loc")
if loc is not None:
offset = (sample([v[0] for v in loc], t),
sample([v[1] for v in loc], t),
sample([v[2] for v in loc], t))
for axis_name, values in spec.get("root_rot", []):
root_rot.append((AXIS_BY_NAME[axis_name], sample(values, t)))
apply_pose(armature, frame + 1, swings, offset, root_rot)
log("action %s_%s: %d frame(s)" % (kind, animation, frames))
def build_actions(armature, kind, anim_specs, animations):
for animation, meta in animations.items():
spec = anim_specs.get(animation)
if spec is None:
raise SystemExit("[monster_rig] %s has no '%s' clip" % (kind, animation))
build_clip(armature, kind, animation, spec, int(meta["frames"]))
# ----------------------------------------------------------------------------
# build
# ----------------------------------------------------------------------------
def wipe_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
def cmd_build(cfg, out_path):
wipe_scene()
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.scale_length = 1.0
root = collection("MONSTERS", scene.collection)
# Пустышка над всеми ригами: направление задаётся её поворотом вокруг Z,
# камера при этом не двигается — иначе свет ездил бы вместе с тварью.
pivot = bpy.data.objects.new("monster_root", None)
pivot.empty_display_type = "ARROWS"
pivot.empty_display_size = 0.3
scene.collection.objects.link(pivot)
selected = cfg.get("kinds") or list(KINDS)
unknown = [kind for kind in selected if kind not in KINDS]
if unknown:
raise SystemExit("[monster_rig] unknown kind(s): " + ", ".join(unknown))
total_parts = 0
for kind in selected:
spec = KINDS[kind]
kind_coll = collection(kind.upper(), root)
armature = build_armature("rig_" + kind, spec["bones"], kind_coll)
armature.parent = pivot
build_parts(spec["parts"], kind, armature, kind_coll)
build_actions(armature, kind, spec["anim"], cfg["animations"])
total_parts += len(spec["parts"])
log("kind %s: %d bone(s), %d part(s)" % (kind, len(spec["bones"]), len(spec["parts"])))
build_camera(scene, cfg["camera"], scene.collection)
build_lights(scene.collection)
build_world(scene)
configure_render(scene)
scene.frame_start = 1
scene.frame_end = max(int(a["frames"]) for a in cfg["animations"].values())
os.makedirs(os.path.dirname(out_path), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=out_path)
log("saved %s (%d kinds, %d mesh objects)" % (out_path, len(selected), total_parts))
return 0
# ----------------------------------------------------------------------------
# render
# ----------------------------------------------------------------------------
def assign_action(armature, name):
action = bpy.data.actions.get(name)
if action is None:
raise SystemExit("[monster_rig] no action '%s' in the .blend" % name)
if armature.animation_data is None:
armature.animation_data_create()
anim = armature.animation_data
anim.action = action
# Blender 4.4+: у Action появились слоты, и без выбранного слота она молчит.
slots = getattr(action, "slots", None)
if slots is not None and hasattr(anim, "action_slot") and anim.action_slot is None and len(slots):
anim.action_slot = slots[0]
return action
def cmd_render(cfg, kind, animation, outdir, supersample):
scene = bpy.context.scene
armature = bpy.data.objects.get("rig_" + kind)
pivot = bpy.data.objects.get("monster_root")
if armature is None or pivot is None:
raise SystemExit("[monster_rig] the .blend has no rig_%s/monster_root; "
"run `monster-init` first" % kind)
prefix = kind + "_"
shown = 0
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
obj.hide_render = not obj.name.startswith(prefix)
shown += 0 if obj.hide_render else 1
if shown == 0:
raise SystemExit("[monster_rig] no objects named '%s*' in the .blend" % prefix)
configure_render(scene)
scene.render.resolution_x = int(cfg["render_width"]) * supersample
scene.render.resolution_y = int(cfg["render_height"]) * supersample
camera = scene.camera or build_camera(scene, cfg["camera"], scene.collection)
camera.data.type = "ORTHO"
camera.data.shift_x = 0.0
camera.data.shift_y = 0.0
# ortho_scale ложится на БОЛЬШУЮ сторону кадра.
longest = max(int(cfg["render_width"]), int(cfg["render_height"]))
camera.data.ortho_scale = longest / float(cfg["camera"]["pixels_per_unit"])
assign_action(armature, "%s_%s" % (kind, animation))
frames = int(cfg["frames"])
directions = cfg["directions"]
os.makedirs(outdir, exist_ok=True)
for index, name in enumerate(directions):
# Порядок направлений идёт по -45° от юга: юг, юго-запад, запад, …
pivot.rotation_euler = (0.0, 0.0, math.radians(-45.0 * index))
bpy.context.view_layer.update()
for frame in range(frames):
scene.frame_set(frame + 1)
cell = index * frames + frame
scene.render.filepath = os.path.join(outdir, "frame_%03d.png" % cell)
bpy.ops.render.render(write_still=True)
log("direction %d/%d (%s) done" % (index + 1, len(directions), name))
log("rendered %d cell(s) at %dx%d into %s"
% (len(directions) * frames, scene.render.resolution_x, scene.render.resolution_y, outdir))
return 0
def main(argv):
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
if not argv:
raise SystemExit("[monster_rig] usage: build|render --config <json>")
command = argv[0]
options = {}
index = 1
while index < len(argv) - 1:
if argv[index].startswith("--"):
options[argv[index][2:]] = argv[index + 1]
index += 2
else:
index += 1
with open(options["config"], "r", encoding="utf-8") as handle:
cfg = json.load(handle)
if command == "build":
return cmd_build(cfg, options["out"])
if command == "render":
return cmd_render(cfg, cfg["kind"], cfg["animation"], cfg["outdir"],
int(cfg.get("supersample", 1)))
raise SystemExit("[monster_rig] unknown command: " + command)
if __name__ == "__main__":
sys.exit(main(list(sys.argv)))