saumwacht-godot/assets-source/phase15/build_basaltbastion.py
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

496 lines
23 KiB
Python

"""Basaltbastion: weighted Glutpakt tank with magma seams and authored clips.
Blender Z up, -Y forward. Run in a fresh background Blender process.
"""
from pathlib import Path
import bpy, bmesh, math, random, json, hashlib
from mathutils import Vector, Quaternion
from math import sin, cos, pi
P = Path(__file__).resolve().parents[2]
OUT = P / 'assets/characters'
OUT.mkdir(exist_ok=True)
SRC = Path(__file__).resolve().parent
random.seed(63)
bpy.context.preferences.filepaths.save_version = 0
bpy.ops.wm.read_factory_settings(use_empty=True)
S = 1.0
parts = []
def coord(v):
return Vector(v) * S
def material(name, color, rough=.8, metal=0.0, emission=None):
m = bpy.data.materials.new('BB_' + name)
m.diffuse_color = (*color, 1)
m.use_nodes = True
n = m.node_tree.nodes
l = m.node_tree.links
b = n.get('Principled BSDF')
b.inputs['Base Color'].default_value = (*color, 1)
b.inputs['Roughness'].default_value = rough
b.inputs['Metallic'].default_value = metal
if emission is not None:
b.inputs['Emission Color'].default_value = (*emission, 1)
b.inputs['Emission Strength'].default_value = .65
else:
v = n.new('ShaderNodeVertexColor')
v.layer_name = 'SurfaceColor'
l.new(v.outputs['Color'], b.inputs['Base Color'])
noise = n.new('ShaderNodeTexNoise')
noise.inputs['Scale'].default_value = 18
noise.inputs['Detail'].default_value = 3.5
bump = n.new('ShaderNodeBump')
bump.inputs['Strength'].default_value = .35
bump.inputs['Distance'].default_value = .02
l.new(noise.outputs['Fac'], bump.inputs['Height'])
l.new(bump.outputs['Normal'], b.inputs['Normal'])
return m
M = {
'basalt': material('Basalt', (.040, .049, .059), .92),
'basalt2': material('BasaltLight', (.085, .101, .119), .88),
'crack': material('Magma', (.78, .19, .022), .55, emission=(.95, .11, .009)),
'iron': material('Iron', (.28, .30, .33), .4, .8),
'brass': material('Brass', (.55, .38, .14), .38, .7),
'ember': material('Ember', (.95, .32, .045), .4, emission=(1.0, .24, .018)),
}
def mesh(name, verts, faces, kind, bone, uv=None, colors=None, weights=None, smooth=True):
me = bpy.data.meshes.new(name)
me.from_pydata([coord(v) for v in verts], [], faces)
me.update()
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
me.materials.append(M[kind])
parts.append(ob)
for f in me.polygons:
f.use_smooth = smooth
tex = me.uv_layers.new(name='UVMap')
col = me.color_attributes.new(name='SurfaceColor', type='FLOAT_COLOR', domain='CORNER')
base = M[kind].diffuse_color[:3]
for f in me.polygons:
for li in f.loop_indices:
i = me.loops[li].vertex_index
v = verts[i]
tex.data[li].uv = uv[i] if uv else (v[0] * .8 + v[1] * .2, v[2] * .9)
fac = .88 + .12 * sin(v[0] * 17 + v[2] * 11) + .04 * cos(v[1] * 41)
c = colors[i] if colors else tuple(max(.01, x * fac) for x in base)
col.data[li].color = (*c, 1)
groups = {}
for i in range(len(verts)):
ws = weights[i] if weights else {bone: 1.0}
total = sum(ws.values())
for key, w in ws.items():
if key not in groups:
groups[key] = ob.vertex_groups.new(name=key)
groups[key].add([i], w / total, 'REPLACE')
return ob
def mixweights(t, bones):
if len(bones) == 1:
return {bones[0]: 1}
t = max(0, min(.999999, t)) * (len(bones) - 1)
i = int(t)
w = t - i
return {bones[i]: 1 - w, bones[i + 1]: w}
def tube(name, points, radii, kind, bones, sides=20, steps=36, flatten=1.0, groove=.06):
points = [Vector(x) for x in points]
vs, uv, ws, loops, fs = [], [], [], [], []
def point(t):
a = min(len(points) - 2, int(t * (len(points) - 1)))
u = t * (len(points) - 1) - a
p0, p1, p2, p3 = points[max(a - 1, 0)], points[a], points[a + 1], points[min(a + 2, len(points) - 1)]
p = .5 * ((2 * p1) + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u)
return p, radii[a] * (1 - u) + radii[a + 1] * u
for j in range(steps + 1):
t = j / steps
p, r = point(t)
tan = (point(min(1, t + .002))[0] - point(max(0, t - .002))[0]).normalized()
u = tan.cross(Vector((0, 1, 0))).normalized()
v = tan.cross(u)
loop = []
for k in range(sides):
a = 2 * pi * k / sides
mod = 1 + groove * (sin(a * 6 + t * 5) + .35 * sin(a * 11 - t * 7))
vs.append(p + r * mod * (u * cos(a) + v * sin(a) * flatten))
uv.append((k / sides, t))
ws.append(mixweights(t, bones))
loop.append(len(vs) - 1)
loops.append(loop)
for a, b in zip(loops, loops[1:]):
for k in range(sides):
q = (k + 1) % sides
fs.append((a[k], a[q], b[q], b[k]))
fs.extend([tuple(reversed(loops[0])), tuple(loops[-1])])
return mesh(name, vs, fs, kind, bones[0], uv, weights=ws)
def ell(name, c, r, kind, bone, segments=22, rings=12):
vs = [(c[0], c[1], c[2] + r[2])]
fs = []
for j in range(1, rings):
th = pi * j / rings
for k in range(segments):
a = 2 * pi * k / segments
f = 1 + .05 * sin(a * 4 + j * 1.3)
vs.append((c[0] + r[0] * sin(th) * cos(a) * f, c[1] + r[1] * sin(th) * sin(a) * f, c[2] + r[2] * cos(th)))
vs.append((c[0], c[1], c[2] - r[2]))
end = len(vs) - 1
for k in range(segments):
fs.append((0, 1 + k, 1 + (k + 1) % segments))
for j in range(rings - 2):
for k in range(segments):
a = 1 + j * segments + k
b = 1 + j * segments + (k + 1) % segments
fs.append((a, a + segments, b + segments, b))
for k in range(segments):
fs.append((end, end - 1 - k, end - 1 - (k + 1) % segments))
return mesh(name, vs, fs, kind, bone)
def plate(name, c, w, h, d, bone, tilt=0.0, cant=0.0, yaw=0.0, kind='basalt2'):
"""Chipped basalt lamina: irregular ten-sided outline and broad fracture facets.
Three offset perimeter rings create a real bevel and broken corners. Each
slab has a stable seeded outline, rather than a box with random shading.
"""
from mathutils import Euler
rng = random.Random(name)
rot = Euler((tilt, cant, yaw)).to_matrix()
n = 10
outline = []
for k in range(n):
a = 2 * pi * k / n + .08
f = rng.uniform(.88, 1.08)
outline.append((cos(a) * w * .5 * f, sin(a) * h * .5 * f))
vs, fs, colors = [], [], []
base = M[kind].diffuse_color[:3]
for ring, (scale, y) in enumerate([(.80, d * .5), (1, d * .06), (.83, -d * .5)]):
for k, (x, z) in enumerate(outline):
p = rot @ Vector((x * scale, y + rng.uniform(-.03, .03) * d, z * scale)) + Vector(c)
vs.append(p)
f = [.68, .83, 1.04][ring] * rng.uniform(.93, 1.09)
colors.append(tuple(v * f for v in base))
fs.append(tuple(reversed(range(n))))
for ring in range(2):
for k in range(n):
j = (k + 1) % n
fs.append((ring * n + k, ring * n + j, (ring + 1) * n + j, (ring + 1) * n + k))
# Off-center ridge gives three large readable fracture planes on the face.
p = rot @ Vector((w * .03, -d * .54, h * -.05)) + Vector(c)
vs.append(p)
colors.append(tuple(v * 1.06 for v in base))
for k in range(n):
fs.append((2 * n + k, 2 * n + (k + 1) % n, 3 * n))
return mesh(name, vs, fs, kind, bone, colors=colors, smooth=False)
def seam(name, points, bone, width=.007):
"""Narrow fissure in a recessed dark channel, never a surface rope."""
radii = [width * (.65 if k in (0, len(points) - 1) else 1) for k in range(len(points))]
tube(name + '_recess', [(x, y + .003, z) for x, y, z in points],
[r * 2.6 for r in radii], 'basalt', [bone], 6, 12, groove=0)
return tube(name, points, radii, 'crack', [bone], 6, 12, groove=0)
# ---- Compact, asymmetric guardian built from interlocking basalt strata ----
# Deep core volumes stay recessed under the overlapping hard plates.
plate('Rib_Core', (0, .035, 1.09), .89, .72, .53, 'Spine', kind='basalt')
plate('Pelvic_Core', (0, .015, .72), .70, .43, .45, 'Pelvis', kind='basalt')
# Broad diagonal chest strata, with gaps instead of a rectangular breastplate.
plate('Pectoral_L', (-.20, -.19, 1.26), .56, .38, .28, 'Chest', cant=-.18, yaw=-.12)
plate('Pectoral_R', (.21, -.18, 1.22), .53, .40, .26, 'Chest', cant=.23, yaw=.13)
plate('Sternum_Keystone', (-.01, -.22, 1.02), .31, .36, .26, 'Spine', cant=-.11)
plate('Rib_Low_L', (-.22, -.17, .91), .38, .30, .20, 'Spine', cant=-.20, yaw=-.20, kind='basalt')
plate('Rib_Low_R', (.23, -.17, .87), .39, .30, .22, 'Spine', cant=.26, yaw=.16)
plate('Hip_Keystone', (0, -.13, .69), .54, .25, .30, 'Pelvis', cant=.10)
for s, side in [(-1, 'L'), (1, 'R')]:
for j in range(3):
plate('Flank_%s%d' % (side, j), (s * (.34 - j * .025), .08, 1.14 - j * .18),
.24, .27, .36, 'Chest' if j == 0 else 'Spine', cant=s * .25, yaw=s * .32, kind='basalt')
seam('Sternum_Fault', [(-.045, -.343, 1.39), (-.025, -.335, 1.27), (.016, -.326, 1.19), (-.016, -.36, 1.10)], 'Chest', .006)
seam('Lower_Fault', [(.055, -.36, 1.11), (.071, -.365, 1.00), (.042, -.35, .90)], 'Spine', .006)
seam('Chest_Fault_L', [(-.38, -.27, 1.35), (-.25, -.321, 1.30), (-.14, -.326, 1.31)], 'Chest', .005)
seam('Chest_Fault_R', [(.14, -.309, 1.13), (.23, -.310, 1.17), (.38, -.28, 1.12)], 'Chest', .005)
# The recessed neck and slanted mask make the head part of the stone mass.
plate('Neck', (0, .045, 1.47), .35, .30, .33, 'Chest', kind='basalt')
plate('Cranium', (0, .015, 1.73), .53, .51, .46, 'Head', cant=-.035, kind='basalt')
plate('Temple_L', (-.185, -.13, 1.76), .20, .33, .18, 'Head', cant=-.13, yaw=-.22)
plate('Temple_R', (.185, -.13, 1.76), .21, .34, .18, 'Head', cant=.17, yaw=.18)
plate('Jaw', (0, -.175, 1.57), .35, .21, .22, 'Head', cant=.025)
plate('Nose_Ridge', (0, -.225, 1.72), .075, .27, .13, 'Head', kind='basalt2')
for s, side in [(-1, 'L'), (1, 'R')]:
plate('Eye_Recess_' + side, (s * .115, -.208, 1.785), .174, .080, .08, 'Head', cant=s * .10, kind='basalt')
plate('Eye_' + side, (s * .115, -.253, 1.786), .111, .023, .012, 'Head', cant=s * .11, kind='ember')
plate('Brow_' + side, (s * .113, -.214, 1.849), .252, .10, .13, 'Head', cant=s * .14)
plate('Cheek_' + side, (s * .118, -.211, 1.679), .18, .17, .13, 'Head', cant=-s * .30)
# Broken geological crown: no symmetric animal horns.
plate('Crown_Shard_L', (-.16, .08, 1.98), .21, .38, .28, 'Head', cant=-.23, tilt=.14)
plate('Crown_Shard_R', (.13, .10, 1.92), .23, .24, .29, 'Head', cant=.28, tilt=.12, kind='basalt')
plate('Crown_Shard_Back', (.02, .185, 1.87), .25, .31, .23, 'Head', cant=-.10, tilt=-.20)
seam('Jaw_Fissure', [(.020, -.29, 11.63), (.001, -.298, 1.58), (.022, -.26, 1.53)], 'Head', .004)
# Arms have dark narrow joints, layered mineral deltoids and hefty split fists.
for s, side in [(-1, 'L'), (1, 'R')]:
upper, fore, hand = ['UpperArm.' + side, 'Forearm.' + side, 'Hand.' + side]
tube('UpperArm_Core_' + side, [(s * .39, .02, 1.30), (s * .57, -.02, 1.06)], [.145, .12], 'basalt', [upper], 10, 12, groove=.035)
tube('Elbow_Sleeve_' + side, [(s * .55, -.012, 1.12), (s * .61, -.055, .98)], [.105, .10], 'basalt', [upper, fore], 10, 8, groove=.04)
plate('UpperArm_Shell_' + side, (s * .52, -.01, 1.18), .31, .37, .30, upper, cant=s * .20, kind='basalt')
large = 1.18 if side == 'L' else .92
plate('Shoulder_Cap_' + side, (s * .50, .005, 1.40), .50 * large, .34 * large, .48, upper, cant=s * .20)
plate('Shoulder_Upper_Stratum_' + side, (s * .50, .035, 1.53), .35 * large, .17, .37, upper, cant=s * .22, kind='basalt')
plate('Shoulder_Outer_Stratum_' + side, (s * .65, .015, 11.33), .27, .31, .33, upper, cant=-s * .20)
plate('Forearm_Core_' + side, (s * .64, -.09, .91), .31, .36, .31, fore, cant=s * .13, kind='basalt')
plate('Gauntlet_Front_' + side, (s * .665, -.20, .90), .32, .33, .22, fore, cant=s * .18)
plate('Gauntlet_Outer_' + side, (s * .77, -.08, .89), .19, .30, .30, fore, cant=-s * .12, kind='basalt')
plate('Fist_' + side, (s * .72, -.19, .665), .37, .29, .34, hand, cant=-s * .08)
for k in range(3):
plate('Knuckle_%s%d' % (side, k), (s * .72 + (k - 1) * .10, -.342, .69), .11, .12, .10, hand, cant=.08 * (k - 1), kind='basalt')
plate('Thumb_' + side, (s * .58, -.19, .65), .13, .20, .20, hand, cant=s * .20)
seam('Forearm_Fault_' + side, [(s * .65, -.308, 1.025), (s * .685, -.312, .92), (s * .65, -.306, .83)], fore, .0055)
plate('Bastion_Shoulder_Shard', (-.64, .08, 1.61), .20, .35, .32, 'UpperArm.L', cant=-.24, tilt=.12)
# Broad feet touch the floor; segmented leg armor does not bridge rotating joints.
for s, side in [(-1, 'L'), (1, 'R')]:
thigh, shin, foot = ['Thigh.' + side, 'Shin.' + side, 'Foot.' + side]
plate('Thigh_' + side, (s * .225, .01, .56), .35, .39, .36, thigh, cant=s * .04, kind='basalt')
plate('Thigh_Stratum_' + side, (s * .23, -.12, .62), .31, .22, .18, thigh, cant=s * .15)
tube('Knee_Joint_' + side, [(s * .24, 0, .47), (s * .245, -.006, .36)], [.125, .12], 'basalt', [thigh, shin], 10, 8, groove=.025)
plate('Shin_' + side, (s * .25, -.01, .255), .33, .36, .33, shin, cant=-s * .04)
plate('Knee_' + side, (s * .24, -.15, .43), .27, .21, .18, shin, cant=s * .12, kind='basalt')
plate('Foot_' + side, (s * .25, -.10, .086), .40, .135, .49, foot, kind='basalt2')
for k in range(2):
plate('Toe_%s%d' % (side, k), (s * .25 + (k - .5) * .15, -.285, .065), .18, .10, .17, foot, cant=.08 * (k - .5), kind='basalt')
seam('Shin_Fault_' + side, [(s * .23, -.177, .36), (s * .26, -.186, .26), (s * .24, -.16, .17)], shin, .005)
# Back reads as stacked columnar basalt; the vent glows between strata.
for j in range(4):
plate('Back_Stratum_%d' % j, ((-.03 if j % 2 else .02), .235, .86 + j * .86 + j * .16),
.62 - j * .025, .24, .28, 'Spine' if j < 2 else 'Chest', tilt=.10, cant=.10 * (-1) ** j, kind='basalt')
for s, side in [(-1, 'L'), (1, 'R')]:
seam('Back_Vent_' + side, [(s * .08, .365, 1.02), (s * .11, .38, 1.17), (s * .07, .37, 1.32)], 'Chest', .006)
# Cleanup and skinning.
for ob in parts:
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=.0000005)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bmesh.ops.dissolve_degenerate(bm, edges=list(bm.edges), dist=.0000001)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
if bm.calc_volume(signed=True) < 0:
bmesh.ops.reverse_faces(bm, faces=list(bm.faces))
bm.to_mesh(ob.data)
bm.free()
ob.data.update()
bpy.ops.object.armature_add(enter_editmode=True)
rig = bpy.context.object
rig.name = 'BasaltRig'
ed = rig.data.edit_bones
ed.remove(ed[0])
defs = {}
def bone(name, head, tail, parent=None):
b = ed.new(name)
b.head = coord(head)
b.tail = coord(tail)
if parent:
b.parent = ed[parent]
defs[name] = (head, tail, parent)
bone('Root', (0, 0, 0), (0, 0, .15))
bone('Pelvis', (0, .02, .70), (0, .02, .95), 'Root')
bone('Spine', (0, .02, .95), (0, .02, 1.25), 'Pelvis')
bone('Chest', (0, .02, 1.25), (0, .04, 1.50), 'Spine')
bone('Head', (0, .04, 1.50), (0, 0, 1.95), 'Chest')
for s, side in [(-1, 'L'), (1, 'R')]:
bone('UpperArm.' + side, (s * .38, .02, 1.30), (s * .58, -.02, 1.05), 'Chest')
bone('Forearm.' + side, (s * .58, -.02, 1.05), (s * .68, -.12, .78), 'UpperArm.' + side)
bone('Hand.' + side, (s * .68, -.12, .78), (s * .72, -.22, .65), 'Forearm.' + side)
bone('Thigh.' + side, (s * .20, .02, .72), (s * .24, 0, .42), 'Pelvis')
bone('Shin.' + side, (s * .24, 0, .42), (s * .25, -.02, .12), 'Thigh.' + side)
bone('Foot.' + side, (s * .25, -.02, .12), (s * .25, -.22, .06), 'Shin.' + side)
bpy.ops.object.mode_set(mode='OBJECT')
for ob in parts:
mod = ob.modifiers.new('Weighted basalt', 'ARMATURE')
mod.object = rig
ob.parent = rig
clips = {'idle': (3.0, True), 'start': (.4, False), 'walk': (1.5, True), 'stop': (.4, False),
'attack_a': (1.3, False), 'attack_b': (1.3, False), 'hit': (.5, False), 'ability': (1.7, False), 'death': (2.0, False)}
def curve(t, keys):
for i in range(len(keys) - 1):
a, x = keys[i]
b, y = keys[i + 1]
if t <= b:
u = max(0, (t - a) / (b - a))
u = u * u * (3 - 2 * u)
return x * (1 - u) + y * u
return keys[-1][1]
def animate(name, t, dur):
rotations = {}
locs = {}
def rot(b, x=0, y=0, z=0):
rotations[b] = (x, y, z)
if name == 'idle':
q = sin(2 * pi * t / dur)
rot('Spine', .01 * q)
rot('Chest', -.015 * q, 0, .008 * q)
rot('Head', .018 * q, 0, -.01 * q)
for s, side in [(-1, 'L'), (1, 'R')]:
rot('Forearm.' + side, .015 * q)
elif name in ('walk', 'start', 'stop'):
fade = 1 if name == 'walk' else sin(pi * .5 * t / dur) if name == 'start' else cos(pi * .5 * t / dur)
phase = t / 1.5 * 2 * pi
roll = sin(phase) * fade
rot('Pelvis', .012, 0, .03 * roll)
rot('Spine', -.04 * fade, 0, -.04 * roll)
rot('Chest', .015 * fade, 0, .02 * roll)
rot('Head', .03 * fade, 0, -.015 * roll)
for s, side in [(-1, 'L'), (1, 'R')]:
q = sin(phase + (0 if s < 0 else pi))
lift = max(0, q)
rot('Thigh.' + side, .30 * q * fade)
rot('Shin.' + side, -.36 * lift * fade)
rot('Foot.' + side, (-.30 * q + .36 * lift) * fade)
rot('UpperArm.' + side, -.10 * q * fade, 0, s * .02 * fade)
rot('Forearm.' + side, -.08 * fade)
locs['Root'] = (0, 0, (-.01 + .01 * cos(phase * 2)) * fade)
elif name == 'attack_a':
# Left magma punch, contact at 0.70
q = curve(t / dur, [(0, 0), (.30, -.35), (.52, -.55), (.70, 1), (.80, .82), (1, 0)])
rot('Spine', -.10 * q, .08 * q, .06 * q)
rot('Chest', -.08 * q, .06 * q, .10 * q)
rot('Head', .10 * q, 0, -.06 * q)
rot('UpperArm.L', -.95 * q, -.08 * q, .10 * q)
rot('Forearm.L', -.30 * q)
rot('Hand.L', .20 * q)
rot('UpperArm.R', .15 * q)
rot('Thigh.L', .05 * q)
locs['Root'] = (0, -.04 * q, -.01 * max(0, q))
elif name == 'attack_b':
# Right slam, contact at 0.70
q = curve(t / dur, [(0, 0), (.30, -.35), (.52, -.55), (.70, 1), (.80, .82), (1, 0)])
rot('Spine', -.10 * q, -.08 * q, -.06 * q)
rot('Chest', -.08 * q, -.06 * q, -.10 * q)
rot('Head', .10 * q, 0, .06 * q)
rot('UpperArm.R', -.95 * q, .08 * q, -.10 * q)
rot('Forearm.R', -.30 * q)
rot('Hand.R', .20 * q)
rot('UpperArm.L', .15 * q)
rot('Thigh.R', .05 * q)
locs['Root'] = (0, -.04 * q, -.01 * max(0, q))
elif name == 'hit':
q = curve(t, [(0, 0), (.08, 1), (.22, .5), (.5, 0)])
rot('Spine', .10 * q, 0, -.06 * q)
rot('Head', .12 * q, 0, .05 * q)
elif name == 'ability':
# Defensive stance: arms cross, magma vents flare.
q = curve(t / dur, [(0, 0), (.35, 1), (1.1, .9), (1.7, 0)])
rot('Spine', .06 * q)
rot('Chest', .04 * q)
rot('UpperArm.L', -.45 * q, -.30 * q, .55 * q)
rot('Forearm.L', -.70 * q)
rot('UpperArm.R', -.45 * q, .30 * q, -.55 * q)
rot('Forearm.R', -.70 * q)
rot('Head', -.08 * q)
locs['Root'] = (0, 0, -.03 * q)
elif name == 'death':
q = curve(t, [(0, 0), (.2, .08), (.7, .45), (1.25, 1), (1.55, .92), (2.0, 1)])
rot('Root', -.80 * q, 0, .10 * q)
locs['Root'] = (0, -.12 * q, -.10 * q)
rot('Pelvis', -.15 * q)
rot('Spine', -.20 * q)
rot('Head', -.16 * q)
for s, side in [(-1, 'L'), (1, 'R')]:
rot('UpperArm.' + side, -.25 * q, 0, s * .12 * q)
rot('Forearm.' + side, -.35 * q)
rot('Thigh.' + side, .20 * q)
rot('Shin.' + side, -.32 * q)
for pb in rig.pose.bones:
pb.rotation_mode = 'QUATERNION'
r = rotations.get(pb.name, (0, 0, 0))
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()
pb.rotation_quaternion = basis.inverted() @ q @ basis
pb.location = pb.bone.matrix_local.to_3x3().inverted() @ (Vector(locs.get(pb.name, (0, 0, 0))) * S)
for name, (dur, loop) in clips.items():
rig.animation_data_clear()
action = bpy.data.actions.new(name)
action.use_fake_user = True
rig.animation_data_create()
rig.animation_data.action = action
for frame in range(round(dur * 30) + 1):
animate(name, frame / 30, dur)
for pb in rig.pose.bones:
pb.keyframe_insert('rotation_quaternion', frame=frame)
pb.keyframe_insert('location', frame=frame)
action['loop'] = loop
rig.animation_data_clear()
rig.animation_data_create()
rig.animation_data.action = bpy.data.actions['idle']
for pb in rig.pose.bones:
pb.rotation_quaternion = Quaternion()
pb.location = (0, 0, 0)
bpy.context.scene.render.fps = 30
bpy.context.scene.frame_set(0)
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'basaltbastion.blend'))
bpy.ops.file.make_paths_relative()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'basaltbastion.blend'))
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.object
body.name = 'BasaltBody'
body.select_set(True)
rig.select_set(True)
bpy.context.view_layer.objects.active = rig
bpy.ops.export_scene.gltf(
filepath=str(OUT / 'basaltbastion.glb'),
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,
)
raw = (OUT / 'basaltbastion.glb').read_bytes()
import struct
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
triangles = sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
mins = [min(v.co[i] for v in body.data.vertices) for i in range(3)]
maxs = [max(v.co[i] for v in body.data.vertices) for i in range(3)]
record = {
'id': 'g04',
'role': 'tank',
'name': 'Basaltbastion',
'resource_path': 'res://assets/characters/basaltbastion.glb',
'triangles': triangles,
'mesh_parts': 1,
'parts': ['BasaltBody'],
'bounds_godot': {'min': [mins[0], mins[2], -maxs[1]], 'max': [maxs[0], maxs[2], -mins[1]]},
'skeletal': True,
'bones': len(defs),
'clips': list(clips),
'sha256': hashlib.sha256(raw).hexdigest(),
'source': 'assets-source/phase15/basaltbastion.blend',
}
# Merge into phase12 manifest as a second asset (or write phase15).
manifest_path = P / 'assets/phase15_manifest.json'
manifest_path.write_text(json.dumps({'assets': [record]}, indent=2) + '\n')
(SRC / 'basaltbastion-audit.json').write_text(json.dumps({
'record': record,
'exported_clips': [a.get('name') for a in doc.get('animations', [])],
'skins': len(doc.get('skins', [])),
'source_parts': len(parts),
'seed': 63,
}, indent=2) + '\n')
print('BASALT_BUILD', triangles, 'triangles', len(defs), 'bones', len(doc.get('animations', [])), 'clips', flush=True)