"""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 ") 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)))