saumwacht-godot/assets-source/phase11/build_ringborke.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

340 lines
22 KiB
Python

"""Ringborke: authored surfaces, weighted skeleton and portable animation clips.
Blender Z up, -Y forward. Run in a fresh background Blender process.
The existing Phase 4 sources remain intact. No downloaded assets are used.
"""
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(41)
bpy.ops.wm.read_factory_settings(use_empty=True)
S=.82
def coord(v):
return Vector((v[0]*1.06,v[1],v[2]*.82 if v[2]<.8 else v[2]-.144))*S
parts=[]
def material(name, color, rough=.85, bark=False, emission=False):
m=bpy.data.materials.new('RB_'+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
if emission:
b.inputs['Emission Color'].default_value=(*color,1);b.inputs['Emission Strength'].default_value=.8
else:
v=n.new('ShaderNodeVertexColor');v.layer_name='SurfaceColor';l.new(v.outputs['Color'],b.inputs['Base Color'])
if name=='Cutwood':
for suffix,target in [('albedo','Base Color'),('roughness','Roughness'),('normal','Normal')]:
tex=n.new('ShaderNodeTexImage');tex.image=bpy.data.images.load(str(P/('assets/materials/phase11/ringborke-cut-'+suffix+'-4k.png')),check_existing=True)
tex.image.colorspace_settings.name='sRGB' if suffix=='albedo' else 'Non-Color'
if suffix=='normal':
normal=n.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.6;l.new(tex.outputs['Color'],normal.inputs['Color']);l.new(normal.outputs['Normal'],b.inputs[target])
else:l.new(tex.outputs['Color'],b.inputs[target])
if bark:
t=n.new('ShaderNodeTexImage');t.image=bpy.data.images.load(str(P/'assets/materials/phase10/oak-height-4k.png'),check_existing=True);t.image.colorspace_settings.name='Non-Color'
bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.4;bump.inputs['Distance'].default_value=.035;l.new(t.outputs['Color'],bump.inputs['Height']);l.new(bump.outputs['Normal'],b.inputs['Normal'])
return m
M={
'bark':material('Bark',(.18,.105,.055),bark=True),
'heart':material('Heartwood',(.38,.23,.115),bark=True),
'cut':material('Cutwood',(.55,.35,.17)),
'dark':material('Crevice',(.052,.035,.018)),
'moss':material('Moss',(.18,.28,.052)),
'leaf':material('Leaf',(.31,.40,.085),.72),
'flower':material('Petal',(.65,.26,.36),.65),
'eye':material('Saplight',(.35,.85,.06),.5,emission=True)}
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]*.7+v[1]*.3,v[2]*.8)
fac=.89+.11*sin(v[0]*19+v[2]*8)+.035*cos(v[1]*71+v[2]*32)
c=colors[i] if colors else tuple(max(.004,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=32,flatten=1,groove=.08):
"""Closed organic sweep, smooth interpolated centerline, longitudinal grain relief."""
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=points[max(a-1,0)];p1=points[a];p2=points[a+1];p3=points[min(a+2,len(points)-1)]
return .5*((2*p1)+(-p0+p2)*u+(2*p0-5*p1+4*p2-p3)*u*u+(-p0+3*p1-3*p2+p3)*u*u*u),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*7+t*3)+.4*sin(a*13-t*9))
vs.append(p+r*mod*(u*cos(a)+v*sin(a)*flatten));uv.append((k/sides,t*1.7));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=20,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+.045*sin(a*5+j*1.7)
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 shield(name,c,rx,rz,depth,bone,angle=0):
# Thick irregular cut log: no raised concentric torus ornaments.
n=96;radial=26;vs=[];uv=[];cols=[];fs=[]
def world(x,y,z):return(c[0]+x*cos(angle)-z*sin(angle),c[1]+y,c[2]+x*sin(angle)+z*cos(angle))
vs.append(world(0,-depth*.54,0));uv.append((.5,.5));cols.append((.42,.27,.135))
for j in range(1,radial+1):
r=j/radial
for k in range(n):
a=2*pi*k/n;edge=1+.038*sin(5*a+1.1)+.028*sin(9*a-.4)
rings=r*12+.10*sin(3*a+r*7)+.07*sin(7*a-r*9)
grain=abs(sin(rings*pi));groove=max(0,(.22-grain)/.22)
# Four narrow radial checks, restricted to the edge of the heartwood.
crack=max(max(0,1-abs(math.atan2(sin(a-q),cos(a-q)))/.025) for q in [.35,2.1,3.9,5.6])*max(0,(r-.53)/.47)
y=-depth*.55+.018*r*r+.008*groove+.028*crack
vs.append(world(rx*r*edge*cos(a),y,rz*r*edge*sin(a)));uv.append((.5+.5*r*cos(a),.5+.5*r*sin(a)))
val=(.91-.22*groove-.60*crack)*(1-.12*r)
cols.append(tuple(v*val for v in (.53,.35,.185)))
for k in range(n):fs.append((0,1+(k+1)%n,1+k))
for j in range(radial-1):
for k in range(n):a=1+j*n+k;b=1+j*n+(k+1)%n;fs.append((a,b,b+n,a+n))
front=len(vs)-n;back=len(vs)
for k in range(n):
a=2*pi*k/n;e=1+.038*sin(5*a+1.1)+.028*sin(9*a-.4);vs.append(world(rx*.95*e*cos(a),depth*.5,rz*.95*e*sin(a)));uv.append((k/n,0));cols.append((.13,.08,.038))
for k in range(n):q=(k+1)%n;fs.append((front+k,front+q,back+q,back+k))
fs.append(tuple(back+k for k in reversed(range(n))))
ob=mesh(name,vs,[tuple(reversed(f)) for f in fs],'cut',bone,uv,cols)
# Continuous chipped bark mantle around the log; longitudinal ridges, not beads.
vv=[];ff=[];tt=[]
profile=[(.94,-.56),(1.025,-.49),(1.07,-.18),(1.045,.36),(.94,.63),(.87,.42)]
for j,(radius,y) in enumerate(profile):
for k in range(n):
a=2*pi*k/n;edge=1+.038*sin(5*a+1.1)+.028*sin(9*a-.4)
ridge=1+.028*sin(a*19+.4*j)+.015*sin(a*31-.2*j)
vv.append(world(rx*radius*edge*ridge*cos(a),depth*y+.008*sin(a*13),rz*radius*edge*ridge*sin(a)))
tt.append((k/n*2,j/5))
for j in range(len(profile)):
for k in range(n):
q=(k+1)%n;h=(j+1)%len(profile);ff.append((j*n+k,j*n+q,h*n+q,h*n+k))
mesh(name+'_BarkMantle',vv,[tuple(reversed(f)) for f in ff],'bark',bone,tt)
return ob
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();v=d.normalized().cross(u);vs=[];fs=[]
for side in [-1,1]:
for j in range(9):
t=j/8;w=width*sin(pi*t)**.7
for k in [-1,0,1]:vs.append(a+d*t+u*w*k+v*(sin(pi*t)*(.025+.028*(1-abs(k))))+Vector((0,side*.003,0)))
for layer in range(2):
for j in range(8):
for k in range(2):
i=layer*27+j*3+k;quad=(i,i+1,i+4,i+3);fs.append(quad if layer else tuple(reversed(quad)))
for j in range(8):
i=j*3;fs.extend([(i,i+3,i+30,i+27),(i+2,i+29,i+32,i+5)])
fs.extend([(0,27,28,29,2,1),(24,25,26,53,52,51)])
return mesh(name,vs,fs,kind,bone)
# Thick connected trunk and flowing, softly weighted fibres around moving joints.
tube('Trunk',[(0,.08,.70),(-.02,.08,1.03),(0,.05,1.42),(0,.06,1.69)],[.28,.36,.46,.30],'bark',['Pelvis','Spine','Chest'],32,52,flatten=.77)
for k in range(11):
a=k*2*pi/11
tube('Trunk_Rib_%02d'%k,[(.23*cos(a),.08+.22*sin(a),.73),(.30*cos(a+.12),.03+.29*sin(a+.12),1.08),(.41*cos(a-.10),.03+.31*sin(a-.10),1.42),(.22*cos(a),.07+.19*sin(a),1.73)],[.055,.075,.09,.038],'heart' if k%3==0 else 'bark',['Pelvis','Spine','Chest'],10,30,groove=.14)
# Chest shield is recessed between shoulders; the face remains visible above it.
shield('Breast_Heart',(.05,-.365,1.28),.33,.38,.14,'Chest',-.14)
# Neck and carved angular mask, eyebrows and an actual dark recessed face.
tube('Neck',[(0,.07,1.58),(0,-.02,1.86),(.01,-.06,2.05)],[.21,.24,.19],'bark',['Chest','Head'],20,24,flatten=.8)
ell('Face_Recess',(0,-.23,1.90),(.22,.095,.18),'dark','Head')
for s,side in [(-1,'L'),(1,'R')]:
tube('Brow_'+side,[(s*.025,-.332,1.96),(s*.125,-.329,2.025),(s*.24,-.23,2.035)],[.068,.064,.045],'heart',['Head'],8,14,flatten=.7)
tube('Cheek_'+side,[(s*.22,-.245,1.98),(s*.21,-.318,1.88),(s*.12,-.31,1.79)],[.065,.045,.035],'bark',['Head'],8,14)
ell('Eye_'+side,(s*.105,-.326,1.934),(.052,.019,.018),'eye','Head',16,8)
for s,side in [(-1,'L'),(1,'R')]:
vs=[(s*.01,-.34,1.98),(s*.115,-.39,2.04),(s*.25,-.25,2.065),(s*.235,-.30,1.975),(s*.09,-.36,1.967),
(s*.01,-.29,1.98),(s*.115,-.30,2.04),(s*.25,-.18,2.065),(s*.235,-.23,1.975),(s*.09,-.28,1.967)]
fs=[(0,1,2,3,4),(9,8,7,6,5)]+[(k,(k+1)%5,(k+1)%5+5,k+5) for k in range(5)]
mesh('CarvedBrow_'+side,vs,fs if s>0 else [tuple(reversed(f)) for f in fs],'bark','Head',smooth=False)
tube('Nose_Ridge',[(0,-.335,2.01),(0,-.38,1.91),(0,-.35,1.83)],[.038,.051,.028],'heart',['Head'],8,14)
# Crown: tapered broken wood spires and a large asymmetrical rear stump.
shield('Back_Crown',(-.10,.07,1.95),.26,.29,.28,'Chest',-.30)
for k in range(5):
a=k*pi*.4
tube('Crown_Branch_%d'%k,[(.16*cos(a),.11+.17*sin(a),1.95),(.28*cos(a),.13+.22*sin(a),2.16+(.06 if k%2 else 0)),(.38*cos(a),.16+.27*sin(a),2.29+(k%2)*.11)],[.075,.045,.009],'bark',['Head'],10,16)
for s,side in [(-1,'L'),(1,'R')]:
arm=['UpperArm.'+side,'Forearm.'+side,'Hand.'+side]
tube('Arm_Fibres_'+side,[(s*.39,.04,1.65),(s*.68,.035,1.55),(s*.85,-.045,1.20),(s*.87,-.20,.85),(s*.89,-.24,.68)],[.22,.27,.21,.21,.18],'bark',arm,24,44,flatten=.86)
for k in range(5):
a=2*pi*k/5
tube('Arm_Rib_'+side+str(k),[(s*.50+.15*cos(a),.04+.15*sin(a),1.62),(s*.78+.16*cos(a),-.02+.16*sin(a),1.28),(s*.88+.15*cos(a),-.17+.15*sin(a),.88)],[.055,.048,.035],'heart' if k==1 else 'bark',arm[:2],9,24)
shield('Shoulder_'+side,(s*.63,-.17,1.60),.36 if s<0 else .30,.39 if s<0 else .33,.20,arm[0],s*-.16)
shield('Forearm_Plate_'+side,(s*.91,-.355,.97),.19,.25,.15,arm[1],s*.16)
ell('Palm_'+side,(s*.88,-.25,.70),(.22,.17,.16),'bark',arm[2])
for finger in range(3):
x=s*.88+(finger-1)*.12
tube('Finger_'+side+str(finger),[(x,-.30,.69),(x+s*.035,-.37,.55),(x+s*.03,-.40,.46)],[.065,.052,.027],'heart',[arm[2],'Finger%d.%s'%(finger,side)],10,16)
tube('Thumb_'+side,[(s*.76,-.21,.73),(s*.68,-.35,.65),(s*.71,-.42,.60)],[.075,.053,.026],'bark',[arm[2],'Thumb.'+side],10,16)
leg=['Thigh.'+side,'Shin.'+side,'Foot.'+side]
tube('Leg_'+side,[(s*.24,.07,.86),(s*.34,.035,.64),(s*.42,-.025,.38),(s*.43,-.025,.16)],[.22,.23,.18,.15],'bark',leg,24,42,flatten=.87)
for k in range(4):
a=2*pi*k/4
tube('Leg_Ridge_'+side+str(k),[(s*.25+.14*cos(a),.05+.13*sin(a),.84),(s*.40+.16*cos(a),.02+.14*sin(a),.43),(s*.46+.14*cos(a),-.04+.12*sin(a),.14)],[.06,.05,.035],'heart' if k==3 else 'bark',leg,9,24)
shield('Knee_'+side,(s*.39,-.19,.43),.14,.16,.08,leg[1],s*.22)
for toe in range(3):
x=s*.43+(toe-1)*.13
tube('Toe_'+side+str(toe),[(s*.43,-.03,.19),(x,-.20,.10),(x+(toe-1)*.03,-.40,.045)],[.095,.081,.020],'heart',[leg[2],'Toe.'+side],12,22)
tube('Heel_'+side,[(s*.43,.035,.20),(s*.49,.18,.11),(s*.55,.23,.04)],[.095,.064,.015],'bark',[leg[2]],10,14)
# Distinct layered back plates: tangential, following the torso rather than floating discs.
for row in range(4):
for k in range(7):
a=.1+k*pi/6;z=.90+row*.20;r=.31+.07*sin(row/3*pi)
pts=[(r*.84*cos(a),.08+r*.72*sin(a),z-.13),((r+.045)*cos(a),.08+(r+.045)*.75*sin(a),z),((r-.035)*cos(a),.08+(r-.035)*.75*sin(a),z+.19)]
tube('Back_Plate_%d_%d'%(row,k),pts,[.05,.105,.015],'bark',['Spine' if row<2 else 'Chest'],8,16,flatten=.42,groove=.12)
# Moss grows in upper seams. Small leaf clusters make porous growth, never a green body ball.
for j in range(190):
zone=j%3
if zone==0:
s=-1 if j%2 else 1;c=(s*(.45+random.random()*.25),-.15+random.random()*.25,(1.97 if s<0 else 1.92)+random.random()*.055);bone='UpperArm.'+('L' if s<0 else 'R')
elif zone==1:
a=random.uniform(.15,pi-.15);c=(.34*cos(a),.1+.28*sin(a),random.uniform(1.06,1.65));bone='Chest'
else:c=(random.uniform(-.22,.22),random.uniform(-.03,.25),2.225+random.random()*.04);bone='Head'
ell('Moss_%03d'%j,c,(.045+random.random()*.025,.036,.019),'moss',bone,8,4)
# Small overlapping foliage breaks up the shoulder and torso seams.
for j in range(84):
s=-1 if j%2 else 1
if j<52:
x=s*random.uniform(.40,.82);y=random.uniform(-.23,.13);z=1.96 if s<0 else 1.91;bone='UpperArm.'+('L' if s<0 else 'R')
else:
x=s*random.uniform(.29,.39);y=-.27;z=random.uniform(.98,1.56);bone='Spine' if z<1.2 else 'Chest'
leaf('SeamLeaf_%02d'%j,(x,y,z),(x+s*.065,y-.045,z+random.uniform(.035,.095)),random.uniform(.025,.048),bone,'moss')
for j in range(32):
s=-1 if j%2 else 1;bone='Head' if j<14 else 'Chest';z=2.15 if j<14 else 1.49;x=s*(.19+random.random()*.14);y=.11 if j<14 else .24
leaf('Sprout_%02d'%j,(x,y,z),(x+s*random.uniform(.08,.19),y-.07,z+random.uniform(.10,.24)),random.uniform(.03,.055),bone)
for j,(x,y,z,bone) in enumerate([(-.46,-.21,1.93,'UpperArm.L'),(.54,-.23,1.83,'UpperArm.R'),(-.23,-.28,1.41,'Chest'),(.26,-.29,1.57,'Chest')]):
for k in range(5):
a=2*pi*k/5;leaf('Blossom_%d_%d'%(j,k),(x+.007*cos(a),y,z+.007*sin(a)),(x+.045*cos(a),y-.015,z+.045*sin(a)),.014,bone,'flower')
ell('BlossomHeart'+str(j),(x,y-.014,z),(.009,.009,.009),'cut',bone,8,4)
# Weld closed leaf tips and consistently orient every disconnected solid before skinning.
# BMesh preserves UV corners, colours and deform groups during this cleanup.
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()
# Skeleton, anatomically placed hinges. Flexible fibre meshes blend adjacent joints.
bpy.ops.object.armature_add(enter_editmode=True);rig=bpy.context.object;rig.name='RingborkeRig';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,.2));bone('Pelvis',(0,.06,.72),(0,.05,1.02),'Root');bone('Spine',(0,.05,1.02),(0,.05,1.38),'Pelvis');bone('Chest',(0,.05,1.38),(0,.02,1.72),'Spine');bone('Head',(0,.02,1.72),(0,-.02,2.15),'Chest')
for s,side in [(-1,'L'),(1,'R')]:
bone('UpperArm.'+side,(s*.47,.04,1.63),(s*.84,-.04,1.19),'Chest');bone('Forearm.'+side,(s*.84,-.04,1.19),(s*.88,-.24,.73),'UpperArm.'+side);bone('Hand.'+side,(s*.88,-.24,.73),(s*.88,-.35,.59),'Forearm.'+side)
for f in range(3):
x=s*.88+(f-1)*.12;bone('Finger%d.%s'%(f,side),(x,-.32,.66),(x+s*.03,-.40,.46),'Hand.'+side)
bone('Thumb.'+side,(s*.76,-.21,.73),(s*.71,-.42,.60),'Hand.'+side)
bone('Thigh.'+side,(s*.25,.06,.79),(s*.41,-.02,.42),'Pelvis');bone('Shin.'+side,(s*.41,-.02,.42),(s*.43,-.02,.15),'Thigh.'+side);bone('Foot.'+side,(s*.43,-.02,.15),(s*.43,-.25,.07),'Shin.'+side);bone('Toe.'+side,(s*.43,-.25,.07),(s*.43,-.43,.045),'Foot.'+side)
bpy.ops.object.mode_set(mode='OBJECT')
for ob in parts:
mod=ob.modifiers.new('Weighted living wood','ARMATURE');mod.object=rig;ob.parent=rig
# Bake explicit character poses into editable actions at 30fps. Global axes are
# converted to each bone's rest basis. Imported engine clips need no Python.
clips={'idle':(3.2,True),'start':(.4,False),'walk':(1.4,True),'stop':(.4,False),'attack_a':(1.25,False),'attack_b':(1.25,False),'hit':(.48,False),'ability':(1.8,False),'death':(2.1,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',.012*q);rot('Chest',-.018*q,0,.009*q);rot('Head',.016*q,0,-.015*q)
for s,side in [(-1,'L'),(1,'R')]:rot('Forearm.'+side,.013*q,0,s*.007*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',.015,0,.035*roll);rot('Spine',-.045*fade,0,-.05*roll);rot('Chest',.018*fade,0,.025*roll);rot('Head',.035*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,.34*q*fade);rot('Shin.'+side,-.40*lift*fade);rot('Foot.'+side,(-.34*q+.40*lift)*fade);rot('Toe.'+side,.12*max(0,-q)*fade)
rot('UpperArm.'+side,-.13*q*fade,0,s*.02*fade);rot('Forearm.'+side,-.045*fade-.025*lift)
locs['Root']=(0,0,(-.012+.012*cos(phase*2))*fade)
elif name.startswith('attack'):
# Contact is at normalized attack progress 0.72, aligned by runtime to damage.
q=curve(t/dur,[(0,0),(.32,-.35),(.54,-.55),(.72,1),(.80,.85),(1,0)])
left=name=='attack_a';side='L' if left else 'R';other='R' if left else 'L';s=-1 if left else 1
rot('Spine',-.11*q,0,s*.09*q);rot('Chest',-.10*q,0,s*.14*q);rot('Head',.09*q,0,-s*.10*q)
rot('UpperArm.'+side,-.88*q,(-.14 if left else .08)*q,s*.13*q);rot('Forearm.'+side,-.20*q);rot('Hand.'+side,.16*q);rot('UpperArm.'+other,.11*q,0,-s*.10*q)
for f in range(3):rot('Finger%d.%s'%(f,side),-.25*max(0,q))
rot('Thigh.'+side,.06*q);rot('Shin.'+side,-.06*q);locs['Root']=(0,-.04*q,-.015*max(0,q))
elif name=='hit':
q=curve(t,[(0,0),(.08,1),(.22,.5),(.48,0)]);rot('Spine',.12*q,0,-.08*q);rot('Head',.13*q,0,.06*q);rot('Forearm.L',-.10*q);rot('Forearm.R',-.08*q)
elif name=='ability':
q=curve(t,[(0,0),(.35,.5),(.75,1),(1.15,.85),(1.8,0)]);rot('Spine',.08*q);rot('Chest',.10*q);rot('Head',-.15*q)
for s,side in [(-1,'L'),(1,'R')]:rot('UpperArm.'+side,-.22*q,0,s*.38*q);rot('Forearm.'+side,-.32*q);rot('Hand.'+side,.15*q)
elif name=='death':
q=curve(t,[(0,0),(.2,.05),(.7,.38),(1.30,1),(1.55,.93),(2.1,1)])
rot('Root',-.95*q,0,.13*q);locs['Root']=(0,-.15*q,-.12*q);rot('Pelvis',-.22*q);rot('Spine',-.24*q);rot('Head',-.20*q)
for s,side in [(-1,'L'),(1,'R')]:rot('UpperArm.'+side,-.32*q,0,s*.17*q);rot('Forearm.'+side,-.50*q);rot('Thigh.'+side,.26*q);rot('Shin.'+side,-.44*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)
# Save the editable source with separate parts and material nodes before export joining.
bpy.ops.wm.save_as_mainfile(filepath=str(SRC/'ringborke.blend'))
bpy.ops.file.make_paths_relative()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC/'ringborke.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='RingborkeBody'
# One skinned mesh; material surfaces and bone weights remain separate and portable.
body.select_set(True);rig.select_set(True);bpy.context.view_layer.objects.active=rig
bpy.ops.export_scene.gltf(filepath=str(OUT/'ringborke.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/'ringborke.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':'w04','role':'tank','resource_path':'res://assets/characters/ringborke.glb','triangles':triangles,'mesh_parts':1,'parts':['RingborkeBody'],'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/phase11/ringborke.blend'}
(P/'assets/phase11_manifest.json').write_text(json.dumps({'assets':[record]},indent=2)+'\n')
(SRC/'ringborke-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),'source_units':'metres','seed':41},indent=2)+'\n')
print('RINGBORKE_BUILD',triangles,'triangles',len(defs),'bones',len(doc.get('animations',[])),'clips',flush=True)