saumwacht-godot/assets-source/luneraria/g2/build_schlundkrone.py
Martin Stickelbroeck 9f62e30377 G2: Ancients, Türme, Bossgrotte und Superboss; macOS- und Windows-ARM64-Pakete
Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone,
Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem
Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik.
Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion
(Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit
Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen,
Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation.
Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen
und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle
braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone
mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe,
dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein,
Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade
ohne dithernde Schatten. Keine Regeländerung.

Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73,
Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38,
verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463.
21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung,
Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten).
Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64
und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11
ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:12:49 +02:00

498 lines
26 KiB
Python

"""The Schlundkrone, superboss of Champions of Luneraria (G2): an ancient crystal-bound titan
of Luneraria that the old Lunarians chained in the Mondschlund.
Design (the silhouette carries it, not the glow):
* a hunched, broad basalt titan that walks on its knuckles: huge shoulder masses, very long
forearms and short pillar legs - no other unit stands like this;
* the "Krone": a broken crescent halo of crystal shards that floats behind its low head;
* the "Schlund": its chest opens like a maw (two plates with moonstone teeth) around a
glowing heart crystal;
* crescent horns, a crystal cluster that grows from the left shoulder only, broken bronze
shackles of the old Lunarians on both wrists;
* lunar veins in the basalt glow in the colour of the team it fights for (violet in the
grotto), so the same model reads as neutral boss and as lane reinforcement.
The footprint stays compact (the simulation keeps attackers 1.2-1.5 m from its centre).
Rig: rigid stone parts weighted 1.0 to 22 bones (Root, Hips, Spine, Chest, Neck, Head, Jaw,
Crown, Maw.L/R, Shoulder/UpperArm/Forearm/Hand.L/R, Thigh/Shin/Foot.L/R). The bone names used
by SkeletalFigure's overlays (Spine, Head, Forearm.L/R) exist. Clips at 30 fps: idle, start,
walk, stop, attack_a, attack_b (contact at 0.90 s as SkeletalFigure expects), hit, ability,
death, roar (the grotto boss wakes), victory (claim). Animations are presentation only; the
rules decide every hit.
Rest positions are written to UV (x, z) and UV2 (y) so the runtime hide shader does not swim
on the skinned mesh. Model units are metres at view scale 1; the game shows it at 1.7.
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/g2/build_schlundkrone.py
Godot +z is the front (glTF +Z forward, Blender -Y), .L sits at +x.
"""
import sys, json, hashlib, struct
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lune_bpy as L
from lune_bpy import V, pi, sin, cos, sqrt
import bpy, bmesh
from mathutils import Matrix, Quaternion
FPS = 30
CONTACT = .90
STRIDE = 2.0
VIEW_SCALE = 1.7
CLIPS = {'idle': (3.0, True), 'start': (.4, False), 'walk': (1.6, True), 'stop': (.4, False), 'attack_a': (1.3, False),
'attack_b': (1.3, False), 'hit': (.5, False), 'ability': (1.3, False), 'death': (2.2, False), 'roar': (2.0, False),
'victory': (2.0, False)}
SIDES = (('L', 1), ('R', -1))
# Bones in Godot coordinates: head, tail, parent.
BONES = {'Root': ((0, 0, 0), (0, .3, 0), None),
'Hips': ((0, .98, -.12), (0, 1.2, -.06), 'Root'),
'Spine': ((0, 1.2, -.06), (0, 1.4, .04), 'Hips'),
'Chest': ((0, 1.4, .04), (0, 1.84, .2), 'Spine'),
'Neck': ((0, 1.7, .3), (0, 1.72, .46), 'Chest'),
'Head': ((0, 1.72, .46), (0, 1.75, .78), 'Neck'),
'Jaw': ((0, 1.64, .52), (0, 1.58, .78), 'Head'),
'Crown': ((0, 2.1, -.16), (0, 2.42, -.16), 'Chest')}
for _side, _sx in SIDES:
BONES['Maw.' + _side] = ((_sx * .28, 1.5, .45), (_sx * .28, 1.22, .47), 'Chest')
BONES['Shoulder.' + _side] = ((_sx * .18, 1.78, .08), (_sx * .6, 1.84, .06), 'Chest')
BONES['UpperArm.' + _side] = ((_sx * .6, 1.84, .06), (_sx * .82, 1.22, .22), 'Shoulder.' + _side)
BONES['Forearm.' + _side] = ((_sx * .82, 1.22, .22), (_sx * .84, .5, .42), 'UpperArm.' + _side)
BONES['Hand.' + _side] = ((_sx * .84, .5, .42), (_sx * .84, .16, .5), 'Forearm.' + _side)
BONES['Thigh.' + _side] = ((_sx * .3, .98, -.12), (_sx * .38, .52, .04), 'Hips')
BONES['Shin.' + _side] = ((_sx * .38, .52, .04), (_sx * .38, .14, -.08), 'Thigh.' + _side)
BONES['Foot.' + _side] = ((_sx * .38, .14, -.08), (_sx * .38, .06, .22), 'Shin.' + _side)
parts = []
def add(ob, bone):
vg = ob.vertex_groups.new(name=bone)
vg.add(list(range(len(ob.data.vertices))), 1.0, 'REPLACE')
parts.append(ob)
return ob
def orient(ob, pivot, direction):
"""Turns an object built upright at the origin (Godot +y) towards `direction` and moves it
to `pivot` (Godot coordinates)."""
d = L.G(*direction).normalized()
rot = V((0, 0, 1)).rotation_difference(d).to_matrix().to_4x4()
ob.data.transform(Matrix.Translation(L.G(*pivot)) @ rot)
ob.data.update()
return ob
def rock(name, a, b, r, bone, mat='LN_Boss_Hide', seed=0.0, squash=1.0, subdiv=2, cuts=7, amp=.16):
"""Faceted stone segment between Godot points a and b."""
a, b = V(a), V(b)
half = (b - a).length * .5
ob = L.cliff(name, (0, 0, 0), (r, half * 1.08, r * squash), mat, seed=seed, subdiv=subdiv, cuts=cuts, amp=amp)
return add(orient(ob, tuple((a + b) * .5), tuple(b - a)), bone)
def mass(name, center, radii, bone, mat='LN_Boss_Hide', seed=0.0, lean=(0.0, 0.0), yaw=0.0, subdiv=2, cuts=7, amp=.16):
return add(L.cliff(name, center, radii, mat, seed=seed, yaw=yaw, lean=lean, subdiv=subdiv, cuts=cuts, amp=amp), bone)
def spike(name, base, direction, radius, length, bone, mat='LN_Crystal_Boss', sides=6, seed=0):
ob = L.crystal(name, (0, 0, 0), radius, length, length * .22, mat, sides=sides, seed=seed)
return add(orient(ob, base, direction), bone)
def claw(name, points, width, bone, mat='LN_Boss_Bone', steps=10):
n = len(points)
widths = [width * (1 - .85 * k / (n - 1)) for k in range(n)]
return add(L.sweep(name, points, widths, [w * .85 for w in widths], mat, up_hint=(0, 1, 0), steps=steps, lens=.35), bone)
# ================================================================ body
def build_body():
# Pelvis, belly and the leaning chest block, the back hump.
mass('Pelvis', (0, .98, -.1), (.42, .26, .32), 'Hips', seed=1.0)
mass('Loin', (0, .84, .14), (.26, .18, .1), 'Hips', seed=2.0)
mass('Belly', (0, 1.2, .02), (.38, .25, .3), 'Spine', seed=3.0)
mass('ChestMass', (0, 1.6, .12), (.6, .36, .42), 'Chest', seed=4.0, lean=(.3, 0))
mass('Hump', (0, 1.88, -.16), (.5, .28, .36), 'Chest', seed=5.0, lean=(-.2, 0))
for side, sx in SIDES:
mass('Flank.' + side, (sx * .3, 1.72, -.3), (.3, .26, .26), 'Chest', seed=6.0 + sx)
# The Schlund: two chest plates with moonstone teeth that open around the heart.
mass('MawPlate.' + side, (sx * .16, 1.36, .5), (.15, .21, .075), 'Maw.' + side, seed=8.0 + sx, subdiv=1, cuts=5)
for k in range(4):
y = 1.23 + k * .085
claw('Tooth.%s.%d' % (side, k), [(sx * .045, y, .55), (-sx * .015, y - .02, .57), (-sx * .05, y - .04, .565)], .05, 'Maw.' + side, steps=5)
# The heart inside the Schlund and the dark hollow around it.
mass('Hollow', (0, 1.37, .38), (.2, .24, .1), 'Chest', mat='LN_Boss_Hide', seed=9.0, subdiv=1, cuts=4)
spike('Heart', (0, 1.26, .43), (0, 1, .12), .11, .26, 'Chest', seed=3)
# Neck, skull, brow, eyes, crescent horns and the jaw with fangs.
mass('NeckMass', (0, 1.72, .36), (.17, .14, .15), 'Neck', seed=10.0)
mass('Skull', (0, 1.76, .62), (.19, .16, .22), 'Head', seed=11.0, lean=(.15, 0))
mass('Brow', (0, 1.83, .77), (.17, .05, .06), 'Head', mat='LN_Boss_Bone', seed=12.0, subdiv=1, cuts=4)
for side, sx in SIDES:
spike('Eye.' + side, (sx * .075, 1.775, .795), (sx * .3, .2, 1), .032, .05, 'Head', sides=5, seed=4 + sx)
claw('Horn.' + side, [(sx * .13, 1.83, .58), (sx * .34, 2.02, .52), (sx * .43, 2.2, .66), (sx * .32, 2.22, .92)], .16, 'Head', steps=14)
claw('Fang.' + side, [(sx * .08, 1.6, .74), (sx * .08, 1.69, .77), (sx * .07, 1.74, .78)], .05, 'Jaw', steps=5)
mass('JawMass', (0, 1.62, .66), (.15, .06, .16), 'Jaw', seed=13.0, subdiv=1, cuts=5)
# Shoulders: huge pauldron masses; crystals grow from the left one only (and a few from
# the right), so the silhouette is asymmetric and unmistakable.
for side, sx in SIDES:
mass('Pauldron.' + side, (sx * .64, 1.93, .02), (.37, .29, .37), 'Shoulder.' + side, seed=14.0 + sx, lean=(0, -sx * .25))
mass('PauldronRim.' + side, (sx * .8, 1.78, .08), (.2, .16, .3), 'Shoulder.' + side, mat='LN_Boss_Bone', seed=15.0 + sx,
subdiv=1, cuts=5)
for k, (base, direction, length) in enumerate([((.7, 2.1, .0), (.75, .75, -.15), .78), ((.86, 1.98, .12), (1, .35, .15), .5),
((.62, 2.08, -.2), (.55, .6, -.6), .55)]):
spike('ShoulderCrystal.L.%d' % k, base, direction, .09 - .012 * k, length, 'Shoulder.L', seed=20 + k)
spike('ShoulderCrystal.R.0', (-.74, 2.06, -.06), (-.8, .7, -.2), .06, .34, 'Shoulder.R', seed=30)
# Arms: upper arm, the massive forearm with crystal spurs and a bronze shackle, the fist
# with moonstone claws that the titan walks on.
for side, sx in SIDES:
sh, el, wr, fi = [V(p) for p in (BONES['UpperArm.' + side][0], BONES['Forearm.' + side][0], BONES['Hand.' + side][0],
BONES['Hand.' + side][1])]
rock('UpperArm.' + side, sh + V((0, .06, 0)), el, .2, 'UpperArm.' + side, seed=50 + sx)
mass('Elbow.' + side, tuple(el + V((sx * .04, 0, -.05))), (.17, .15, .17), 'Forearm.' + side, seed=52 + sx, subdiv=1, cuts=5)
rock('Forearm.' + side, el, wr + V((0, -.02, 0)), .25, 'Forearm.' + side, seed=54 + sx, squash=.92)
along = (wr - el).normalized()
out = V((sx, 0, -.35)).normalized()
for k in range(3):
base = el + (wr - el) * (.25 + .25 * k) + out * .2
spike('ArmSpur.%s.%d' % (side, k), tuple(base), tuple((out * 1.0 - along * .6 + V((0, .3, 0))).normalized()), .05,
.32 - .06 * k, 'Forearm.' + side, sides=5, seed=60 + k + (3 if sx < 0 else 0))
cuff = L.lathe('Shackle.' + side, [(.255, -.07), (.27, -.055), (.27, .055), (.255, .07), (.235, .07), (.235, -.07)], 10,
'LN_Boss_Bronze', cap_top=False, cap_bottom=False)
add(orient(cuff, tuple(wr + (el - wr).normalized() * .14), tuple(wr - el)), 'Forearm.' + side)
link = L.lathe('ShackleLink.' + side, [(.07, -.03), (.07, .03), (.045, .03), (.045, -.03)], 8, 'LN_Boss_Bronze', cap_top=False,
cap_bottom=False)
add(orient(link, tuple(wr + (el - wr).normalized() * .14 + V((sx * .3, -.1, -.05))), (1, .2, 0)), 'Forearm.' + side)
mass('Fist.' + side, tuple(wr + V((0, -.17, .05))), (.2, .19, .22), 'Hand.' + side, seed=70 + sx)
for k in range(3):
x = sx * (.1 - .1 * k)
claw('Claw.%s.%d' % (side, k), [tuple(fi + V((x, .02, .08))), tuple(fi + V((x * 1.1, -.08, .2))), tuple(fi + V((x * 1.1, -.15, .24)))],
.075, 'Hand.' + side, steps=6)
# Legs: short stone pillars, knee plates, broad feet with moonstone toes.
for side, sx in SIDES:
hip, knee, ankle, toe = [V(p) for p in (BONES['Thigh.' + side][0], BONES['Shin.' + side][0], BONES['Foot.' + side][0],
BONES['Foot.' + side][1])]
rock('Thigh.' + side, hip, knee, .22, 'Thigh.' + side, seed=80 + sx)
mass('Knee.' + side, tuple(knee + V((sx * .02, .02, .12))), (.15, .13, .1), 'Thigh.' + side, mat='LN_Boss_Bone', seed=82 + sx,
subdiv=1, cuts=5)
rock('Shin.' + side, knee, ankle, .19, 'Shin.' + side, seed=84 + sx)
mass('FootMass.' + side, (sx * .38, .1, .06), (.2, .1, .27), 'Foot.' + side, seed=86 + sx, subdiv=1, cuts=5)
for k in range(3):
x = sx * (.38 + (k - 1) * .11)
claw('Toe.%s.%d' % (side, k), [(x, .08, .26), (x, .05, .37), (x, .01, .42)], .07, 'Foot.' + side, steps=5)
# ================================================================ crown
def build_crown():
"""A broken crescent halo of crystal shards behind the head, bound by an old bronze band."""
center = V((0, 2.1, -.16))
radius = .66
band = []
for k in range(15):
a = pi * (.1 + .8 * k / 14)
band.append(center + V((cos(a) * (radius - .05), sin(a) * (radius - .05), 0)))
add(L.sweep('CrownBand', band, [.07] * 5, [.05] * 5, 'LN_Boss_Bronze', up_hint=(0, 0, 1), steps=28), 'Crown')
# One shard is missing (the broken crown), the others grow longer towards the apex.
for k, a in enumerate([.13, .27, .42, .5, .58, .73]):
ang = pi * a
direction = V((cos(ang), sin(ang), -.08))
length = .3 + .26 * (1 - abs(a - .5) * 2.4)
base = center + V((cos(ang) * radius, sin(ang) * radius, 0))
spike('CrownShard.%d' % k, tuple(base), tuple(direction), .07 + .025 * (1 - abs(a - .5) * 2), length, 'Crown', seed=90 + k)
# ================================================================ rig
def build_rig():
bpy.ops.object.armature_add(enter_editmode=True)
rig = bpy.context.object
rig.name = 'SchlundkroneRig'
ed = rig.data.edit_bones
ed.remove(ed[0])
for name, (head, tail, parent) in BONES.items():
b = ed.new(name)
b.head, b.tail = L.G(*head), L.G(*tail)
if parent:
b.parent = ed[parent]
bpy.ops.object.mode_set(mode='OBJECT')
return rig
def pose_rotation(pb, r):
"""Rotation r = (x, y, z) about the armature axes (Blender), turned into the bone's frame."""
q = Quaternion((0, 0, 1), r[2]) @ Quaternion((0, 1, 0), r[1]) @ Quaternion((1, 0, 0), r[0])
basis = pb.bone.matrix_local.to_quaternion()
return basis.inverted() @ q @ basis
def curve(t, keys):
for (a, x), (b, y) in zip(keys, keys[1:]):
if t <= b:
u = max(0.0, (t - a) / (b - a))
u = u * u * (3 - 2 * u)
return x * (1 - u) + y * u
return keys[-1][1]
# ================================================================ clips
def animate(name, t, dur):
"""Returns rotations (armature axes: x pitch, y roll, z yaw), locations (Blender axes, z up,
+y backwards) and scales per bone. Upward bones lean forward with +x; hanging limbs swing
forward with -x; the left arm spreads outward with -y, the right arm with +y."""
rs, locs, scl = {}, {}, {}
def rot(b, x=0.0, y=0.0, z=0.0):
o = rs.get(b, (0, 0, 0))
rs[b] = (o[0] + x, o[1] + y, o[2] + z)
def loc(b, x=0.0, y=0.0, z=0.0):
o = locs.get(b, (0, 0, 0))
locs[b] = (o[0] + x, o[1] + y, o[2] + z)
def spread(side, amount):
rot('UpperArm.' + side, 0, -amount if side == 'L' else amount, 0)
def maw(amount):
rot('Maw.L', 0, 0, .95 * amount)
rot('Maw.R', 0, 0, -.95 * amount)
# The crown always hovers a little behind the head.
hover = sin(2 * pi * t / 1.5)
loc('Crown', 0, 0, .035 * hover)
rot('Crown', 0, .03 * sin(2 * pi * t / 3.0), 0)
if name == 'idle':
b = sin(2 * pi * t / dur)
rot('Chest', .03 * b)
rot('Spine', -.012 * b)
rot('Head', -.02 * b, 0, .14 * sin(2 * pi * t / dur + .6))
rot('Neck', 0, 0, .05 * sin(2 * pi * t / dur + .6))
rot('UpperArm.L', .035 * b)
rot('UpperArm.R', .035 * sin(2 * pi * t / dur + 1.1))
maw(.05 + .05 * b)
rot('Jaw', .04 + .04 * b)
loc('Hips', 0, 0, -.012 * b)
elif name in ('walk', 'start', 'stop'):
fade = 1.0 if name == 'walk' else (sin(pi * .5 * t / dur) if name == 'start' else cos(pi * .5 * t / dur))
ph = 2 * pi * t / CLIPS['walk'][0]
for side, sx in SIDES:
q = ph + (0 if side == 'L' else pi)
thigh = -.36 * sin(q) * fade
knee = (.5 * max(0.0, cos(q)) + .05) * fade
rot('Thigh.' + side, thigh)
rot('Shin.' + side, knee)
rot('Foot.' + side, -(thigh + knee) * .6)
# Knuckle walk: the arm swings against its leg, the forearm folds on the swing.
rot('UpperArm.' + side, .32 * sin(q) * fade)
rot('Forearm.' + side, -.22 * max(0.0, -sin(q)) * fade)
loc('Hips', 0, 0, -.05 * (1 - cos(2 * ph)) * .5 * fade)
rot('Hips', 0, 0, .06 * sin(ph) * fade)
rot('Spine', .04 * fade, .04 * sin(ph) * fade, 0)
rot('Chest', 0, 0, -.06 * sin(ph) * fade)
rot('Head', 0, 0, .05 * sin(ph) * fade)
loc('Crown', 0, 0, .03 * sin(2 * ph - .8) * fade)
rot('Crown', 0, .05 * sin(ph - .6) * fade, 0)
elif name == 'attack_a':
# Double fist smash: both arms rise overhead, then slam down at contact (0.90 s).
arm = curve(t, [(0, 0), (.55, -2.3), (.8, -2.45), (.9, -.95), (1.05, -.95), (1.3, 0)])
fore = curve(t, [(0, 0), (.55, -.6), (.8, -.7), (.9, -.1), (1.05, -.1), (1.3, 0)])
chest = curve(t, [(0, 0), (.55, -.28), (.8, -.32), (.9, .42), (1.05, .42), (1.3, 0)])
crouch = curve(t, [(0, 0), (.55, .04), (.85, 0), (.9, -.11), (1.05, -.11), (1.3, 0)])
for side, sx in SIDES:
rot('UpperArm.' + side, arm)
rot('Forearm.' + side, fore)
spread(side, -.12 * min(0.0, arm) / 2.3)
rot('Thigh.' + side, -1.2 * crouch)
rot('Shin.' + side, 2.0 * crouch)
rot('Foot.' + side, -.8 * crouch)
rot('Chest', chest)
rot('Spine', chest * .45)
rot('Head', -chest * .5)
loc('Hips', 0, 0, crouch)
maw(.35 * max(0.0, -chest) / .32)
loc('Crown', 0, 0, .1 * max(0.0, -chest) / .32)
elif name == 'attack_b':
# Backhand sweep with the right arm: winds up across the body, strikes at 0.90 s.
wind = curve(t, [(0, 0), (.6, 1), (.82, 1.05), (.9, -1), (1.05, -1.05), (1.3, 0)])
rot('Chest', 0, 0, .45 * wind)
rot('Spine', 0, 0, .2 * wind)
rot('Hips', 0, 0, .08 * wind)
rot('Head', 0, 0, -.3 * wind)
# Raised to shoulder height, the right arm swings across the body (yaw), then out.
rot('UpperArm.R', -1.0 * min(1.0, abs(wind) * 1.6), 0, .9 * max(0.0, wind) - 1.0 * max(0.0, -wind))
rot('Forearm.R', -.55 * max(0.0, wind) - .15 * max(0.0, -wind))
rot('UpperArm.L', .2 * wind)
rot('Chest', .1 * abs(wind))
elif name == 'hit':
q = curve(t, [(0, 0), (.066667, 1), (.2, .4), (.5, 0)])
rot('Chest', -.18 * q)
rot('Head', -.2 * q, 0, .1 * q)
loc('Hips', 0, .05 * q, 0)
loc('Crown', 0, .04 * q, .06 * q)
rot('UpperArm.L', .15 * q)
rot('UpperArm.R', .15 * q)
maw(-.04 * q)
elif name == 'ability':
# "Mondschrei": the Schlund tears open, arms spread, the crown rises and flares.
o = curve(t, [(0, 0), (.5, 1), (.9, 1.15), (1.1, 1), (1.3, 0)])
maw(.9 * o)
spread('L', .7 * o)
spread('R', .7 * o)
rot('UpperArm.L', -.5 * o)
rot('UpperArm.R', -.5 * o)
rot('Chest', -.3 * o)
rot('Head', -.4 * o)
rot('Jaw', .5 * o)
loc('Crown', 0, 0, .18 * o)
scl['Crown'] = tuple([1 + .25 * o] * 3)
loc('Hips', 0, 0, .05 * o)
elif name == 'death':
stagger = curve(t, [(0, 0), (.35, 1), (.6, .6), (1.0, 0)])
buckle = curve(t, [(0, 0), (.5, 0), (1.1, 1), (2.2, 1)])
fall = curve(t, [(0, 0), (.9, 0), (1.6, 1), (1.8, .95), (2.2, 1)])
crown = curve(t, [(0, 0), (.3, 0), (1.25, 1), (1.4, .96), (2.2, 1)])
rot('Chest', -.25 * stagger + .2 * fall)
loc('Hips', 0, .1 * stagger, -.42 * buckle)
for side, sx in SIDES:
rot('Thigh.' + side, -.9 * buckle)
rot('Shin.' + side, 1.5 * buckle)
rot('Foot.' + side, -.6 * buckle)
rot('UpperArm.' + side, -.6 * fall + .2 * stagger)
spread(side, .25 * fall)
rot('Root', 1.1 * fall)
loc('Root', 0, 0, -.08 * fall)
rot('Head', .3 * fall, 0, .3 * fall)
maw(-.05 * fall)
# The crown breaks away and drops behind the titan.
loc('Crown', 0, .55 * crown, -1.55 * crown)
rot('Crown', 1.1 * crown, .6 * crown, 0)
elif name == 'roar':
rear = curve(t, [(0, 0), (.6, 1), (1.4, 1), (1.6, -.3), (1.75, -.3), (2.0, 0)])
up = max(0.0, rear)
rot('Spine', -.35 * up + .2 * max(0.0, -rear))
rot('Chest', -.25 * rear)
for side, sx in SIDES:
rot('UpperArm.' + side, -1.4 * up - .8 * max(0.0, -rear) / .3)
spread(side, .6 * up)
rot('Forearm.' + side, -.4 * up)
rot('Head', -.5 * up)
rot('Jaw', .6 * up)
maw(.8 * up)
loc('Crown', 0, 0, .2 * up)
scl['Crown'] = tuple([1 + .3 * up] * 3)
loc('Hips', 0, 0, .08 * up)
elif name == 'victory':
def pulse(center):
return max(0.0, 1 - abs(t - center) / .22)
left = pulse(.5) + pulse(1.3)
right = pulse(.9)
for side, beat in (('L', left), ('R', right)):
# Fist to the chest: raise forward, fold the forearm, turn inwards (yaw).
rot('UpperArm.' + side, -1.15 * beat, 0, (-.55 if side == 'L' else .55) * beat)
rot('Forearm.' + side, -1.3 * beat)
rot('Head', -.3 * curve(t, [(0, 0), (.3, 1), (1.7, 1), (2.0, 0)]))
rot('Jaw', .4 * curve(t, [(0, 0), (.3, 1), (1.7, 1), (2.0, 0)]))
loc('Crown', 0, 0, .1 * (left + right))
rot('Chest', -.12 * curve(t, [(0, 0), (.3, 1), (1.7, 1), (2.0, 0)]))
return rs, locs, scl
def bake(rig):
for name, (dur, loop) in CLIPS.items():
rig.animation_data_clear()
rig.animation_data_create()
action = bpy.data.actions.new(name)
action.use_fake_user = True
rig.animation_data.action = action
for frame in range(round(dur * FPS) + 1):
rs, locs, scl = animate(name, frame / FPS, dur)
for pb in rig.pose.bones:
pb.rotation_mode = 'QUATERNION'
pb.rotation_quaternion = pose_rotation(pb, rs.get(pb.name, (0, 0, 0)))
pb.location = pb.bone.matrix_local.to_3x3().inverted() @ V(locs.get(pb.name, (0, 0, 0)))
pb.scale = scl.get(pb.name, (1, 1, 1))
pb.keyframe_insert('rotation_quaternion', frame=frame)
pb.keyframe_insert('location', frame=frame)
if pb.name == 'Crown':
pb.keyframe_insert('scale', frame=frame)
action['loop'] = loop
action['contact_seconds'] = CONTACT if name in ('attack_a', 'attack_b') else -1
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for fc in bag.fcurves:
for key in fc.keyframe_points:
key.interpolation = 'LINEAR'
rig.animation_data_clear()
rig.animation_data_create()
rig.animation_data.action = bpy.data.actions['idle']
bpy.context.scene.render.fps = FPS
bpy.context.scene.frame_set(0)
def rest_uvs(ob):
"""UV = rest (x, z), UV2 = (rest y, 0) in Godot coordinates for the hide shader."""
me = ob.data
while me.uv_layers:
me.uv_layers.remove(me.uv_layers[0])
first = me.uv_layers.new(name='UVMap')
second = me.uv_layers.new(name='UVRest')
for loop in me.loops:
g = L.to_godot(me.vertices[loop.vertex_index].co)
first.data[loop.index].uv = (g[0], g[2])
second.data[loop.index].uv = (g[1], 0.0)
def build():
L.reset()
build_body()
build_crown()
rocks = [ob for ob in parts if not any(m.name.startswith(('LN_Crystal', 'LN_Boss_Bronze')) for m in ob.data.materials)]
rest = [ob for ob in parts if ob not in rocks]
L.finalize(rocks, sharp_angle=16.0, occluders=parts, reach=.45, ground=0.0, rays=20)
L.finalize(rest, occluders=parts, reach=.45, ground=0.0, rays=20)
for ob in parts:
L.triangulate(ob)
bpy.ops.object.select_all(action='DESELECT')
for ob in parts:
ob.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.join()
body = bpy.context.view_layer.objects.active
body.name = body.data.name = 'SchlundkroneBody'
rest_uvs(body)
rig = build_rig()
mod = body.modifiers.new('Rig', 'ARMATURE')
mod.object = rig
body.parent = rig
bake(rig)
source_count = len(parts)
bpy.ops.wm.save_as_mainfile(filepath=str(L.SRC / 'schlundkrone.blend'))
bpy.ops.object.select_all(action='DESELECT')
body.select_set(True)
rig.select_set(True)
bpy.context.view_layer.objects.active = rig
path = L.OUT / 'schlundkrone.glb'
bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True, export_animations=True,
export_animation_mode='ACTIONS', export_force_sampling=True, export_frame_range=False,
export_anim_slide_to_zero=True, export_skins=True, export_all_influences=False,
export_def_bones=True, export_anim_single_armature=True, export_materials='EXPORT', export_yup=True,
export_vertex_color='ACTIVE', export_texcoords=True, export_normals=True)
raw = path.read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
bounds = L.bounds_godot([body])
clips = sorted(a['name'] for a in doc.get('animations', []))
entry = {'id': 'schlundkrone', 'title': 'Schlundkrone (Superboss)', 'resource_path': 'res://assets/luneraria/g2/schlundkrone.glb',
'source': 'assets-source/luneraria/g2/schlundkrone.blend', 'generator': 'assets-source/luneraria/g2/build_schlundkrone.py',
'triangles': L.glb_triangles(doc), 'source_parts': source_count, 'skeletal': True, 'bones': len(BONES),
'bone_names': list(BONES), 'clips': clips, 'clip_seconds': {k: v[0] for k, v in CLIPS.items()},
'attack_cycle': ['attack_a', 'attack_b'], 'contact_seconds': CONTACT, 'stride': STRIDE, 'view_scale': VIEW_SCALE,
'materials': sorted(m['name'] for m in doc.get('materials', [])), 'skins': len(doc.get('skins', [])),
'bounds_godot': bounds, 'pivot': 'ground centre (0, 0, 0), faces +z', 'units': 'metres at view scale 1',
'sha256': hashlib.sha256(raw).hexdigest()}
L.write_manifest([entry])
missing = [c for c in CLIPS if c not in clips]
if missing:
raise SystemExit('SCHLUNDKRONE_MISSING_CLIPS: %s' % missing)
print('SCHLUNDKRONE_BUILD', entry['triangles'], 'triangles', len(BONES), 'bones', 'clips', clips, 'height %.2f' % bounds['max'][1],
flush=True)
build()