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>
340 lines
22 KiB
Python
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)
|