saumwacht-godot/assets-source/phase16/build_quellgeflecht.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

470 lines
19 KiB
Python

"""Quellgeflecht: living Wurzelbund healer with weighted vine body and spring core.
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(71)
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('QG_' + 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 = 1.5
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 = 22
noise.inputs['Detail'].default_value = 3.0
bump = n.new('ShaderNodeBump')
bump.inputs['Strength'].default_value = .22
bump.inputs['Distance'].default_value = .015
l.new(noise.outputs['Fac'], bump.inputs['Height'])
l.new(bump.outputs['Normal'], b.inputs['Normal'])
return m
M = {
'bark': material('Bark', (.22, .16, .10), .88),
'vine': material('Vine', (.28, .42, .18), .78),
'moss': material('Moss', (.34, .52, .16), .82),
'leaf': material('Leaf', (.42, .62, .22), .7),
'petal': material('Petal', (.72, .48, .55), .65),
'spring': material('Spring', (.45, .85, .78), .35, emission=(.35, .9, .75)),
'wood': material('Wood', (.40, .28, .14), .8),
}
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] * .9 + v[1] * .1, v[2] * 1.1)
fac = .90 + .10 * sin(v[0] * 21 + v[2] * 9) + .04 * cos(v[1] * 37)
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=16, steps=28, flatten=1.0, groove=.05):
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 * 5 + t * 6) + .3 * sin(a * 9 - t * 5))
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=18, rings=10):
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 + .06 * sin(a * 3 + j)
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 leaf(name, start, end, width, bone, kind='leaf'):
a = Vector(start)
b = Vector(end)
d = b - a
u = d.cross(Vector((0, 1, 0))).normalized() if abs(d.y) < .9 else d.cross(Vector((1, 0, 0))).normalized()
v = d.normalized().cross(u)
vs, fs = [], []
for side in [-1, 1]:
for j in range(7):
t = j / 6
w = width * sin(pi * t) ** .65
for k in [-1, 0, 1]:
vs.append(a + d * t + u * w * k + v * (sin(pi * t) * .03) + Vector((0, side * .004, 0)))
for layer in range(2):
for j in range(6):
for k in range(2):
i = layer * 21 + j * 3 + k
quad = (i, i + 1, i + 4, i + 3)
fs.append(quad if layer else tuple(reversed(quad)))
for j in range(6):
i = j * 3
fs.extend([(i, i + 3, i + 24, i + 21), (i + 2, i + 23, i + 26, i + 5)])
fs.extend([(0, 21, 22, 23, 2, 1), (18, 19, 20, 41, 40, 39)])
return mesh(name, vs, fs, kind, bone)
# ---- Body: braided living spring ----
# Root braids
for k in range(5):
a = 2 * pi * k / 5
tube('RootBraid_%d' % k,
[(.16 * cos(a), .12 * sin(a), .02), (.22 * cos(a + .3), .18 * sin(a + .3), .18),
(.14 * cos(a + .5), .10 * sin(a + .5), .38), (.08 * cos(a), .05 * sin(a), .52)],
[.07, .08, .06, .05], 'bark', ['Root', 'Pelvis'], 10, 16)
ell('Base_Wood', (0, 0, .08), (.22, .18, .08), 'wood', 'Root', 16, 8)
# Trunk weave
tube('Trunk', [(0, .02, .40), (0, .0, .75), (.02, -.02, 1.10), (0, 0, 1.38)], [.18, .22, .20, .15], 'vine', ['Pelvis', 'Spine', 'Chest'], 18, 32, flatten=.85)
for k in range(8):
a = k * 2 * pi / 8
tube('TrunkWeave_%d' % k,
[(.12 * cos(a), .08 + .10 * sin(a), .48), (.18 * cos(a + .2), .02 + .16 * sin(a + .2), .85),
(.16 * cos(a - .1), .0 + .14 * sin(a - .1), 1.15), (.08 * cos(a), .04 + .08 * sin(a), 1.36)],
[.04, .05, .04, .02], 'vine' if k % 2 else 'moss', ['Pelvis', 'Spine', 'Chest'], 8, 20)
# Spring orb in chest
ell('SpringCore', (0, -.12, 1.05), (.14, .10, .14), 'spring', 'Spine', 16, 10)
ell('SpringHalo', (0, -.10, 1.05), (.20, .12, .20), 'spring', 'Spine', 14, 8)
# Crown / head: woven nest with drooping leaves and a glowing bud
ell('Head_Nest', (0, 0, 1.55), (.22, .20, .18), 'bark', 'Head', 18, 10)
ell('Head_Bud', (0, -.08, 1.68), (.10, .08, .12), 'spring', 'Head', 14, 8)
for k in range(7):
a = 2 * pi * k / 7
leaf('CrownLeaf_%d' % k,
(.12 * cos(a), .08 * sin(a), 1.62),
(.28 * cos(a), .18 * sin(a), 1.82 + .04 * (k % 2)),
.06, 'Head', 'leaf' if k % 2 else 'moss')
for k in range(4):
a = 2 * pi * k / 4 + .3
leaf('Petal_%d' % k,
(.04 * cos(a), -.06 + .04 * sin(a), 1.72),
(.12 * cos(a), -.14 + .05 * sin(a), 1.84),
.035, 'Head', 'petal')
# Arms: long healing vines with leaf fans and droplet tips
for s, side in [(-1, 'L'), (1, 'R')]:
arm = ['UpperArm.' + side, 'Forearm.' + side, 'Hand.' + side]
tube('Arm_' + side,
[(s * .22, 0, 1.28), (s * .42, -.08, 1.10), (s * .55, -.18, .88), (s * .58, -.28, .72)],
[.08, .07, .055, .04], 'vine', arm, 12, 24, flatten=.9)
# Leaf fan at wrist
for j in range(4):
a = j * 1.2
leaf('HandLeaf_%s%d' % (side, j),
(s * .58, -.28, .72),
(s * (.72 + .04 * j), -.36 - .02 * j, .62 + .06 * sin(a)),
.04, arm[2], 'leaf')
ell('Drop_' + side, (s * .58, -.32, .66), (.035, .03, .045), 'spring', arm[2], 10, 6)
# Shoulder leaves
for j in range(3):
leaf('ShoulderLeaf_%s%d' % (side, j),
(s * .20, .02, 1.34),
(s * (.34 + .05 * j), .10, 1.48 + .04 * j),
.05, 'Chest', 'moss')
# Back canopy of vines and moss
for k in range(10):
a = k * .55
z = .9 + (k % 4) * .16
tube('BackVine_%d' % k,
[(.05 * cos(a), .12, z), (.18 * cos(a), .22, z + .18), (.24 * cos(a), .18, z + .32)],
[.03, .035, .01], 'vine', ['Chest' if z > 1.05 else 'Spine'], 8, 14)
leaf('BackLeaf_%d' % k,
(.18 * cos(a), .22, z + .18),
(.30 * cos(a), .12, z + .30),
.05, 'Chest' if z > 1.05 else 'Spine', 'leaf')
for j in range(24):
a = j * 1.7
z = .5 + (j % 8) * .12
ell('MossClump_%02d' % j,
(.12 * cos(a) * (1 + .05 * j), .08 * sin(a), z),
(.035, .028, .02), 'moss', 'Spine' if z < 1.0 else 'Chest', 8, 5)
# Cleanup and skin
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 = 'QuellRig'
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, .12))
bone('Pelvis', (0, 0, .40), (0, 0, .75), 'Root')
bone('Spine', (0, 0, .75), (0, 0, 1.10), 'Pelvis')
bone('Chest', (0, 0, 1.10), (0, 0, 1.40), 'Spine')
bone('Head', (0, 0, 1.40), (0, 0, 1.75), 'Chest')
for s, side in [(-1, 'L'), (1, 'R')]:
bone('UpperArm.' + side, (s * .22, 0, 1.28), (s * .42, -.08, 1.10), 'Chest')
bone('Forearm.' + side, (s * .42, -.08, 1.10), (s * .55, -.18, .88), 'UpperArm.' + side)
bone('Hand.' + side, (s * .55, -.18, .88), (s * .58, -.28, .68), 'Forearm.' + side)
bone('Thigh.' + side, (s * .10, .04, .40), (s * .12, .02, .22), 'Pelvis')
bone('Shin.' + side, (s * .12, .02, .22), (s * .12, 0, .06), 'Thigh.' + side)
bone('Foot.' + side, (s * .12, 0, .06), (s * .12, -.12, .03), 'Shin.' + side)
bpy.ops.object.mode_set(mode='OBJECT')
for ob in parts:
mod = ob.modifiers.new('Weighted vine', 'ARMATURE')
mod.object = rig
ob.parent = rig
clips = {'idle': (3.2, True), 'start': (.4, False), 'walk': (1.4, True), 'stop': (.4, False),
'attack_a': (1.3, False), 'attack_b': (1.3, False), 'hit': (.48, False), 'ability': (1.9, 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)
q2 = sin(2 * pi * t / dur * 2)
rot('Spine', .015 * q)
rot('Chest', -.02 * q, 0, .01 * q)
rot('Head', .025 * q, 0, -.012 * q)
for s, side in [(-1, 'L'), (1, 'R')]:
rot('Forearm.' + side, .03 * q, 0, s * .02 * q2)
locs['Root'] = (0, 0, .004 * 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.4 * 2 * pi
roll = sin(phase) * fade
rot('Pelvis', .01, 0, .04 * roll)
rot('Spine', -.03 * fade, 0, -.05 * roll)
rot('Chest', .02 * fade, 0, .03 * roll)
rot('Head', .04 * fade, 0, -.02 * 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, .28 * q * fade)
rot('Shin.' + side, -.32 * lift * fade)
rot('Foot.' + side, (-.28 * q + .32 * lift) * fade)
rot('UpperArm.' + side, -.12 * q * fade, 0, s * .03 * fade)
rot('Forearm.' + side, -.15 * fade - .05 * lift, 0, s * .04 * fade)
locs['Root'] = (0, 0, (-.008 + .008 * cos(phase * 2)) * fade)
elif name == 'attack_a':
# Left tendril pulse, contact at 0.70
q = curve(t / dur, [(0, 0), (.32, -.30), (.54, -.50), (.70, 1), (.82, .80), (1, 0)])
rot('Spine', -.08 * q, .06 * q, .05 * q)
rot('Chest', -.06 * q, .05 * q, .08 * q)
rot('Head', .08 * q, 0, -.05 * q)
rot('UpperArm.L', -.85 * q, -.10 * q, .15 * q)
rot('Forearm.L', -.40 * q, 0, .10 * q)
rot('Hand.L', .25 * q)
rot('UpperArm.R', .12 * q)
elif name == 'attack_b':
q = curve(t / dur, [(0, 0), (.32, -.30), (.54, -.50), (.70, 1), (.82, .80), (1, 0)])
rot('Spine', -.08 * q, -.06 * q, -.05 * q)
rot('Chest', -.06 * q, -.05 * q, -.08 * q)
rot('Head', .08 * q, 0, .05 * q)
rot('UpperArm.R', -.85 * q, .10 * q, -.15 * q)
rot('Forearm.R', -.40 * q, 0, -.10 * q)
rot('Hand.R', .25 * q)
rot('UpperArm.L', .12 * q)
elif name == 'hit':
q = curve(t, [(0, 0), (.08, 1), (.22, .5), (.48, 0)])
rot('Spine', .12 * q, 0, -.07 * q)
rot('Head', .14 * q, 0, .05 * q)
rot('Forearm.L', -.12 * q)
rot('Forearm.R', -.10 * q)
elif name == 'ability':
# Multi-heal: arms open, core flares, leaves lift
q = curve(t / dur, [(0, 0), (.35, .7), (.70, 1), (1.3, .85), (1.9, 0)])
rot('Spine', .10 * q)
rot('Chest', .08 * q)
rot('Head', -.18 * q)
for s, side in [(-1, 'L'), (1, 'R')]:
rot('UpperArm.' + side, -.35 * q, 0, s * .55 * q)
rot('Forearm.' + side, -.50 * q, 0, s * .25 * q)
rot('Hand.' + side, .20 * q)
locs['Root'] = (0, 0, .04 * q)
elif name == 'death':
q = curve(t, [(0, 0), (.2, .08), (.7, .45), (1.25, 1), (1.55, .90), (2.0, 1)])
rot('Root', -.55 * q, 0, .08 * q)
locs['Root'] = (0, -.08 * q, -.08 * q)
rot('Spine', -.20 * q)
rot('Chest', -.15 * q)
rot('Head', -.25 * q)
for s, side in [(-1, 'L'), (1, 'R')]:
rot('UpperArm.' + side, -.40 * q, 0, s * .25 * q)
rot('Forearm.' + side, -.55 * 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 / 'quellgeflecht.blend'))
bpy.ops.file.make_paths_relative()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'quellgeflecht.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 = 'QuellBody'
body.select_set(True)
rig.select_set(True)
bpy.context.view_layer.objects.active = rig
bpy.ops.export_scene.gltf(
filepath=str(OUT / 'quellgeflecht.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 / 'quellgeflecht.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': 'w10',
'role': 'support',
'name': 'Quellgeflecht',
'resource_path': 'res://assets/characters/quellgeflecht.glb',
'triangles': triangles,
'mesh_parts': 1,
'parts': ['QuellBody'],
'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/phase16/quellgeflecht.blend',
}
(P / 'assets/phase16_manifest.json').write_text(json.dumps({'assets': [record]}, indent=2) + '\n')
(SRC / 'quellgeflecht-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': 71,
}, indent=2) + '\n')
print('QUELL_BUILD', triangles, 'triangles', len(defs), 'bones', len(doc.get('animations', [])), 'clips', flush=True)