"""Funkenlanze: an editable, weighted mineral marksman and crystal lance rifle. Run: blender --background --python assets-source/phase18/build_funkenlanze.py Blender is Z-up, -Y-forward. Arm contact is solved analytically per sample, then baked to FK animation: no runtime IK, rigid-joint approximation, or constraints. """ from pathlib import Path import bpy, bmesh, math, json, hashlib, struct from math import sin, cos, pi, sqrt from mathutils import Vector, Quaternion, Matrix P = Path(__file__).resolve().parents[2] SRC = Path(__file__).resolve().parent OUT = P / 'assets/characters' OUT.mkdir(exist_ok=True) bpy.ops.wm.read_factory_settings(use_empty=True) bpy.context.preferences.filepaths.save_version = 0 parts, defs, contact_samples = [], {}, [] def material(name, color, rough=.6, metal=0, emission=0): m = bpy.data.materials.new('FL_' + name) m.diffuse_color = (*color, 1) m.use_nodes = True n, links = m.node_tree.nodes, m.node_tree.links bs = n.get('Principled BSDF') bs.inputs['Base Color'].default_value = (*color, 1) bs.inputs['Metallic'].default_value = metal bs.inputs['Roughness'].default_value = rough vertex = n.new('ShaderNodeVertexColor') vertex.layer_name = 'SurfaceColor' links.new(vertex.outputs['Color'], bs.inputs['Base Color']) if emission: bs.inputs['Emission Color'].default_value = (*color, 1) bs.inputs['Emission Strength'].default_value = emission else: tex = n.new('ShaderNodeTexNoise') tex.inputs['Scale'].default_value = 65 if metal else 26 tex.inputs['Detail'].default_value = 2 bump = n.new('ShaderNodeBump') bump.inputs['Strength'].default_value = .14 if metal else .24 bump.inputs['Distance'].default_value = .007 links.new(tex.outputs['Fac'], bump.inputs['Height']) links.new(bump.outputs['Normal'], bs.inputs['Normal']) return m M = { 'stone': material('Basalt', (.125, .145, .17), .86), 'facet': material('Obsidian', (.225, .245, .28), .64, .13), 'gold': material('BurnishedBronze', (.68, .405, .145), .31, .76), 'edge': material('BronzeEdge', (.92, .665, .28), .27, .70), 'steel': material('Iron', (.27, .30, .335), .33, .78), 'dark': material('Joint', (.055, .061, .07), .75, .20), 'ember': material('Ember', (1.0, .235, .018), .34, .15, 1.05), 'crystal': material('Crystal', (1.0, .47, .045), .22, .2, .7), } def mesh(name, vs, fs, kind, bone, weights=None, smooth=True, uv=None): me = bpy.data.meshes.new(name) me.from_pydata(vs, [], fs) me.update() ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) parts.append(ob) me.materials.append(M[kind]) col = me.color_attributes.new(name='SurfaceColor', type='FLOAT_COLOR', domain='CORNER') tex = me.uv_layers.new(name='UVMap') base = M[kind].diffuse_color[:3] for face in me.polygons: face.use_smooth = smooth for li in face.loop_indices: vi = me.loops[li].vertex_index x, y, z = vs[vi] # Broad, deliberate surface variation survives export without textures. variation = .93 + .055 * sin(17*x + 11*z) + .025 * cos(24*y - 7*z) col.data[li].color = (*(min(1, c*variation) for c in base), 1) tex.data[li].uv = uv[vi] if uv else (x*1.7 + y*.3, z*1.5) groups = {} for i in range(len(vs)): ws = weights[i] if weights else {bone: 1.0} total = sum(ws.values()) for key, weight in ws.items(): if weight <= 1e-8: continue groups.setdefault(key, ob.vertex_groups.get(key) or ob.vertex_groups.new(name=key)) groups[key].add([i], weight / total, 'REPLACE') return ob def blend(a, b, t): t = max(0, min(1, t)) return {a: 1-t, b: t} if a != b else {a: 1} def catmull(points, t): seg = min(len(points)-2, int(t*(len(points)-1))) u = t*(len(points)-1)-seg a, b, c, d = [points[max(0, min(len(points)-1, q))] for q in (seg-1,seg,seg+1,seg+2)] return .5*((2*b)+(-a+c)*u+(2*a-5*b+4*c-d)*u*u+(-a+3*b-3*c+d)*u*u*u) def tube(name, points, radii, kind, bone, sides=16, steps=20, flatten=1, weight_fn=None, groove=.015, smooth=True): pts = [Vector(x) for x in points] vs, fs, ws, uv = [], [], [], [] for j in range(steps+1): t = j/steps p = catmull(pts, t) seg = min(len(radii)-2, int(t*(len(radii)-1))) a = t*(len(radii)-1)-seg radius = radii[seg]*(1-a)+radii[seg+1]*a tangent = (catmull(pts, min(1,t+.002))-catmull(pts,max(0,t-.002))).normalized() ref = Vector((0,1,0)) if abs(tangent.y)<.9 else Vector((1,0,0)) u = tangent.cross(ref).normalized() v = tangent.cross(u) for k in range(sides): angle = 2*pi*k/sides detail = 1 + groove*cos(5*angle+t*4) vs.append(p + radius*detail*(u*cos(angle)+v*sin(angle)*flatten)) ws.append(weight_fn(t) if weight_fn else {bone:1}) uv.append((k/sides,t)) if j: for k in range(sides): a, b = (j-1)*sides+k, (j-1)*sides+(k+1)%sides fs.append((a,b,b+sides,a+sides)) fs += [tuple(reversed(range(sides))), tuple(range(steps*sides,(steps+1)*sides))] return mesh(name,vs,fs,kind,bone,ws,smooth,uv) def ell(name, c, r, kind, bone, segments=20, rings=12, smooth=True): vs, fs = [(c[0],c[1],c[2]+r[2])], [] for j in range(1,rings): th = pi*j/rings for k in range(segments): a = 2*pi*k/segments vs.append((c[0]+r[0]*sin(th)*cos(a),c[1]+r[1]*sin(th)*sin(a),c[2]+r[2]*cos(th))) vs.append((c[0],c[1],c[2]-r[2])) 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((len(vs)-1,len(vs)-2-k,len(vs)-2-(k+1)%segments)) return mesh(name,vs,fs,kind,bone,smooth=smooth) def shield(name, outline, center, depth, kind, bone): """Closed tapered plate with bevel belt and a proud central ridge, never a box.""" c = Vector(center) front = [Vector(p) for p in outline] vs = front + [p.lerp(c,.13)+Vector((0,-depth*.32,0)) for p in front] vs += [c+Vector((0,-depth,0))] vs += [p+Vector((0,depth*.22,0)) for p in front] n=len(front); fs=[] for i in range(n): q=(i+1)%n fs += [(i,q,n+q,n+i),(n+i,n+q,2*n),(i,2*n+1+i,2*n+1+q,q)] fs.append(tuple(reversed(range(2*n+1,3*n+1)))) return mesh(name,vs,fs,kind,bone,smooth=False) def ring(name, p, axis, radius, width, kind, bone, sides=16): axis=Vector(axis).normalized(); p=Vector(p) return tube(name,[p-axis*width/2,p+axis*width/2],[radius,radius],kind,bone,sides,3,groove=0,smooth=False) def shard(name, points, radius, kind, bone): return tube(name,points,[radius,.74*radius,.002],kind,bone,6,5,groove=0,smooth=False) def ik_elbow(a,c,l1,l2,pole): delta=c-a; d=delta.length if not abs(l1-l2)+.00001 < d < l1+l2-.00001: raise RuntimeError('Unreachable arm contact: %.6f for %.6f' % (d,l1+l2)) line=delta/d v=Vector(pole)-a perpendicular=(v-line*v.dot(line)).normalized() along=(l1*l1-l2*l2+d*d)/(2*d) return a+line*along+perpendicular*sqrt(max(0,l1*l1-along*along)) SHOULDERS={'L':Vector((-.265,.035,1.49)),'R':Vector((.265,.035,1.49))} WRISTS={'L':Vector((.12,-.57,1.275)),'R':Vector((.12,-.27,1.25))} POLES={'L':Vector((-.72,-.24,1.12)),'R':Vector((.78,-.12,1.16))} ELBOWS={side:ik_elbow(SHOULDERS[side],WRISTS[side],.405,.395,POLES[side]) for side in ('L','R')} WEAPON_ORIGIN=Vector((.12,-.30,1.34)) # Continuous abdomen/chest with smoothly shared pelvis, spine, and chest weights. sections=[(.71,.13,.105),(.80,.185,.13),(.95,.17,.12),(1.09,.195,.14), (1.22,.245,.165),(1.39,.28,.165),(1.50,.245,.135),(1.55,.12,.09)] vs,fs,ws=[],[],[] for j in range(43): t=j/42; f=t*(len(sections)-1); idx=min(len(sections)-2,int(f)); u=f-idx z,rx,ry=[sections[idx][k]*(1-u)+sections[idx+1][k]*u for k in range(3)] for k in range(24): a=2*pi*k/24; mod=1+.025*cos(a*4) vs.append((rx*cos(a)*mod,.025+ry*sin(a),z)) ws.append(blend('Pelvis','Spine',(z-.81)/.32) if z<1.13 else blend('Spine','Chest',(z-1.13)/.30)) if j: for k in range(24): a=(j-1)*24+k; b=(j-1)*24+(k+1)%24 fs.append((a,b,b+24,a+24)) fs += [tuple(reversed(range(24))),tuple(range(42*24,43*24))] mesh('Continuous_Mineral_Torso',vs,fs,'stone','Spine',ws) for s,side in [(-1,'L'),(1,'R')]: outline=[(s*.015,-.145,1.42),(s*.22,-.125,1.51),(s*.275,-.10,1.36), (s*.205,-.17,1.20),(s*.035,-.19,1.07)] shield('Bronze_Pectoral_Rim_'+side,outline,(s*.135,-.17,1.32),.065,'gold','Chest') inset=[Vector(p).lerp(Vector((s*.14,-.19,1.32)),.11)+Vector((0,-.025,0)) for p in outline] shield('Obsidian_Pectoral_'+side,inset,(s*.135,-.217,1.32),.06,'facet','Chest') tube('Rib_Conduit_'+side,[(s*.205,-.13,1.16),(s*.15,-.145,1.06),(s*.11,-.12,.97)], [.018,.024,.011],'ember','Spine',10,16,weight_fn=lambda t:blend('Chest','Spine',t)) outline=[(s*.03,-.10,.94),(s*.205,-.085,1.00),(s*.25,-.085,.85),(s*.16,-.14,.69)] shield('Hip_Petal_'+side,outline,(s*.14,-.13,.85),.045,'gold','Pelvis') shield('Hip_Dark_Inlay_'+side,[Vector(v).lerp(Vector((s*.14,-.155,.86)),.25) for v in outline], (s*.14,-.17,.86),.018,'facet','Pelvis') shard('Sternum_Ember',[(0,-.21,1.44),(0,-.242,1.28),(0,-.215,1.11)],.036,'ember','Chest') tube('Neck_Flexible_Core',[(0,.025,1.49),(0,.025,1.60),(0,-.012,1.68)], [.08,.076,.062],'dark','Neck',16,18,weight_fn=lambda t:blend('Chest','Head',t)) ring('Gorget',(0,.018,1.565),(0,0,1),.112,.045,'gold','Neck') # Helmet: swept mineral hood and pointed visor framed in burnished bronze. ell('Helmet_Core',(0,.017,1.73),(.135,.123,.181),'stone','Head',20,12,False) helmet=[(-.14,-.061,1.82),(-.125,-.09,1.68),(0,-.112,1.585),(.125,-.09,1.68),(.14,-.061,1.82),(0,-.096,1.91)] shield('Helmet_Bronze_Frame',helmet,(0,-.15,1.755),.057,'gold','Head') inner=[Vector(p).lerp(Vector((0,-.17,1.76)),.18)+Vector((0,-.018,0)) for p in helmet] shield('Helmet_Dark_Face',inner,(0,-.19,1.76),.032,'facet','Head') for s,side in [(-1,'L'),(1,'R')]: tube('Visor_'+side,[(s*.013,-.224,1.755),(s*.057,-.198,1.772),(s*.096,-.15,1.781)], [.012,.013,.006],'ember','Head',8,9,flatten=.75,groove=0) shard('Swept_Crest_'+side,[(s*.09,-.02,1.81),(s*.185,.05,1.925),(s*.205,.12,2.02)],.058,'gold','Head') shard('Crest_Dark_Inlay_'+side,[(s*.11,.01,1.84),(s*.168,.055,1.93),(s*.20,.12,2.01)],.027,'facet','Head') shard('Visor_Central_Seam',[(0,-.212,1.81),(0,-.221,1.75),(0,-.157,1.64)],.018,'ember','Head') # Arms are one continuous deforming core each; armor leaves the bend zones clear. for s,side in [(-1,'L'),(1,'R')]: a,b,c=SHOULDERS[side],ELBOWS[side],WRISTS[side] upper,fore,hand='UpperArm.'+side,'Forearm.'+side,'Hand.'+side points=[a,a.lerp(b,.42),b,b.lerp(c,.50),c] def arm_weights(t,upper=upper,fore=fore,hand=hand): if t<.12:return blend('Chest',upper,t/.12) if t<.43:return {upper:1} if t<.60:return blend(upper,fore,(t-.43)/.17) if t<.91:return {fore:1} return blend(fore,hand,(t-.91)/.09) tube('Continuous_Arm_'+side,points,[.079,.076,.055,.074,.052],'stone',upper,20,42,weight_fn=arm_weights) ell('Elbow_Recess_'+side,b,(.067,.067,.067),'dark',fore,16,10) ring('Elbow_Bearing_'+side,b+Vector((s*.025,0,0)),(s,0,0),.075,.018,'gold',fore) shoulder_outline=[(s*.19,-.05,1.54),(s*.29,-.08,1.62),(s*.43,-.03,1.48),(s*.37,-.09,1.405),(s*.22,-.095,1.43)] shield('Shoulder_Mantle_'+side,shoulder_outline,(s*.29,-.11,1.49),.05,'gold','Clavicle.'+side) for start,end,key,radius,kind in [(a.lerp(b,.20),a.lerp(b,.73),upper,.082,'facet'), (b.lerp(c,.18),b.lerp(c,.80),fore,.078,'facet')]: tube('Tapered_Armor_'+key,[start,start.lerp(end,.45),end],[radius*.75,radius,radius*.65],kind,key,8,9,groove=0,smooth=False) ring('Wrist_Bronze_Cuff_'+side,c.lerp(b,.13),c-b,.066,.037,'gold',fore) # Closed hand around the receiver/support socket, with individually separated fingers. ell('Palm_'+side,c+Vector((0,-.018,.027)),(.059,.067,.043),'facet',hand,16,10) for k in range(4): x=c.x+(k-1.5)*.026 tube('Grip_Finger_%s%d'%(side,k),[(x,c.y+.009,c.z+.049),(x,c.y-.049,c.z+.068), (x,c.y-.072,c.z+.036),(x,c.y-.052,c.z+.005)], [.012,.014,.013,.010],'facet','Index.'+side if k==0 else hand,10,10,groove=0) tube('Grip_Thumb_'+side,[c+Vector((s*.047,.014,.018)),c+Vector((s*.065,-.022,.034)), c+Vector((s*.036,-.055,.053))],[.016,.018,.012],'gold',hand,10,10,groove=0) # Long tapered leg cores and separate articulated boots. for s,side in [(-1,'L'),(1,'R')]: hip=Vector((s*.13,.025,.83)); knee=Vector((s*.19,-.04,.48)); ankle=Vector((s*.20,.02,.135)) thigh,shin,foot='Thigh.'+side,'Shin.'+side,'Foot.'+side def leg_weights(t,thigh=thigh,shin=shin,foot=foot): if t<.42:return {thigh:1} if t<.61:return blend(thigh,shin,(t-.42)/.19) if t<.92:return {shin:1} return blend(shin,foot,(t-.92)/.08) tube('Continuous_Leg_'+side,[hip,hip.lerp(knee,.46),knee,knee.lerp(ankle,.55),ankle], [.103,.105,.066,.078,.047],'stone',thigh,20,42,weight_fn=leg_weights) for j,(center,w,h,bone) in enumerate([(hip.lerp(knee,.42),.13,.24,thigh),(knee.lerp(ankle,.54),.10,.255,shin)]): x,y,z=center; outline=[(x-w*.42,y-.069,z+h*.48),(x+w*.46,y-.068,z+h*.31), (x+w*.37,y-.072,z-h*.33),(x,y-.097,z-h*.55),(x-w*.52,y-.07,z-h*.17)] shield('Leg_Bronze_Edge_%s%d'%(side,j),outline,(x,y-.102,z),.031,'gold',bone) shield('Leg_Obsidian_Inlay_%s%d'%(side,j),[Vector(p).lerp(Vector((x,y-.12,z)),.18) for p in outline], (x,y-.126,z),.028,'facet',bone) ring('Knee_Bearing_'+side,knee+Vector((s*.015,0,0)),(1,0,0),.077,.021,'gold',shin) ell('Knee_Glow_'+side,knee+Vector((0,-.065,0)),(.026,.016,.027),'ember',shin,12,8) tube('Boot_'+side,[(s*.20,.06,.095),(s*.20,-.065,.073),(s*.20,-.205,.043)], [.066,.085,.025],'facet',foot,10,14,flatten=.7,groove=0,smooth=False) shield('Boot_Instep_'+side,[(s*.20-.055,-.015,.136),(s*.20+.055,-.015,.136), (s*.20+.037,-.13,.090),(s*.20,-.19,.066),(s*.20-.037,-.13,.090)], (s*.20,-.073,.129),.012,'gold',foot) # Back capacitor is a crescent cradle with a visible but restrained ember core. ell('Back_Reactor_Cradle',(0,.19,1.34),(.17,.07,.18),'gold','Chest',20,12,False) ell('Back_Reactor_Obsidian',(0,.245,1.34),(.135,.045,.14),'facet','Chest',20,10,False) ell('Back_Reactor_Ember',(0,.277,1.34),(.074,.018,.084),'ember','Chest',14,8,False) for s,side in [(-1,'L'),(1,'R')]: tube('Reactor_Conduit_'+side,[(s*.06,.24,1.20),(s*.10,.20,1.08),(s*.095,.14,.96)], [.014,.017,.012],'gold','Spine',10,14,weight_fn=lambda t:blend('Chest','Spine',t)) # Lance rifle: forged receiver, offset shoulder stock, sliding breech and crystal cage. x,z=.12,1.34 tube('Lance_Forged_Spine',[(x,-.13,z),(x,-.63,z),(x,-1.07,z)], [.063,.053,.043],'facet','Weapon',10,24,groove=0,smooth=False) tube('Shoulder_Stock',[(x,-.16,z),(.21,.015,1.39),(.27,.12,1.42)], [.055,.048,.042],'stone','Weapon',10,14,groove=0,smooth=False) ring('Stock_Butt',(.27,.12,1.42),(0,1,0),.071,.035,'gold','Weapon',10) for k,y in enumerate([-.19,-.405,-.72,-1.045]): ring('Lance_Bronze_Band_%d'%k,(x,y,z),(0,1,0),.072 if k<2 else .060,.044,'gold','Weapon',10) ring('Lance_Band_Lip_%d'%k,(x,y-.020,z),(0,1,0),.074 if k<2 else .062,.008,'edge','Weapon',10) for s in (-1,1): tube('Lance_Energy_Rail_%s'%s,[(x+s*.046,-.39,z),(x+s*.046,-.73,z),(x+s*.037,-1.00,z)], [.008,.008,.006],'ember','Weapon',8,16,groove=0) tube('Lance_Rail_Frame_%s'%s,[(x+s*.055,-.44,z+.015),(x+s*.055,-.71,z+.015),(x+s*.044,-.96,z+.015)], [.009,.009,.007],'gold','Weapon',8,16,groove=0) tube('Trigger_Grip',[(x,-.27,z-.018),(x,-.255,1.24),(x,-.225,1.205)], [.031,.035,.026],'dark','Weapon',10,10,groove=0,smooth=False) tube('Trigger_Guard',[(x,-.23,1.25),(x,-.32,1.235),(x,-.36,1.29),(x,-.32,1.32)], [.007,.007,.007,.007],'gold','Weapon',8,18,groove=0) ring('Support_Socket',(x,-.585,z-.012),(0,1,0),.066,.08,'dark','Weapon',10) tube('Sliding_Breech',[(x,-.16,z+.052),(x,-.31,z+.052),(x,-.43,z+.048)], [.031,.032,.021],'steel','Bolt',8,9,groove=0,smooth=False) tube('Breech_Handle',[(x+.024,-.205,z+.05),(x+.09,-.205,z+.042)], [.013,.016],'gold','Bolt',10,4,groove=0) ell('Charge_Chamber',(x,-.355,z),(.087,.082,.073),'gold','Chamber',12,8,False) ell('Charge_Window',(x+.075,-.355,z),(.02,.053,.042),'ember','Chamber',12,8,False) for s in (-1,1): tube('Muzzle_Cage_%s'%s,[(x+s*.045,-1.02,z),(x+s*.105,-1.115,z),(x+s*.058,-1.26,z)], [.02,.025,.011],'gold','Weapon',6,10,groove=0,smooth=False) tube('Muzzle_Cage_V_%s'%s,[(x,-1.02,z+s*.043),(x,-1.115,z+s*.082),(x,-1.26,z+s*.048)], [.018,.021,.009],'gold','Weapon',6,10,groove=0,smooth=False) shard('Lance_Crystal',[(x,-1.04,z),(x,-1.205,z),(x,-1.435,z)],.062,'crystal','Crystal') shard('Lance_Crystal_Inner',[(x,-1.055,z),(x,-1.215,z),(x,-1.427,z)],.031,'ember','Crystal') tube('Optic_Rail',[(x,-.28,z+.062),(x,-.48,z+.072)], [.022,.022],'gold','Weapon',8,6,groove=0) ell('Rear_Sight',(x,-.29,z+.086),(.026,.016,.024),'dark','Weapon',12,8) ell('Rear_Sight_Glow',(x,-.279,z+.091),(.006,.004,.006),'ember','Weapon',10,6) # Recalculate coherent outward normals and export triangulated closed surfaces. for ob in parts: bm=bmesh.new(); bm.from_mesh(ob.data) bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.0000001) bmesh.ops.triangulate(bm,faces=list(bm.faces)) 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='FunkenRig' ed=rig.data.edit_bones; ed.remove(ed[0]) def bone(name,head,tail,parent=None): b=ed.new(name); b.head=head; b.tail=tail if parent:b.parent=ed[parent] defs[name]=(Vector(head),Vector(tail),parent) bone('Root',(0,0,0),(0,0,.15)) bone('Pelvis',(0,.025,.80),(0,.025,1.01),'Root') bone('Spine',(0,.025,1.01),(0,.025,1.28),'Pelvis') bone('Chest',(0,.025,1.28),(0,.025,1.52),'Spine') bone('Neck',(0,.025,1.52),(0,.015,1.64),'Chest') bone('Head',(0,.015,1.64),(0,.015,1.88),'Neck') for s,side in [(-1,'L'),(1,'R')]: a,b,c=SHOULDERS[side],ELBOWS[side],WRISTS[side] bone('Clavicle.'+side,(s*.09,.025,1.49),a,'Chest') bone('UpperArm.'+side,a,b,'Clavicle.'+side) bone('Forearm.'+side,b,c,'UpperArm.'+side) bone('Hand.'+side,c,c+Vector((0,-.085,0)),'Forearm.'+side) bone('Index.'+side,c+Vector((-.039,.009,.049)),c+Vector((-.039,-.052,.005)),'Hand.'+side) bone('Thigh.'+side,(s*.13,.025,.83),(s*.19,-.04,.48),'Pelvis') bone('Shin.'+side,(s*.19,-.04,.48),(s*.20,.02,.135),'Thigh.'+side) bone('Foot.'+side,(s*.20,.02,.135),(s*.20,-.19,.066),'Shin.'+side) bone('Weapon',WEAPON_ORIGIN,(.12,-.70,1.34),'Chest') bone('Bolt',(.12,-.16,1.392),(.12,-.42,1.392),'Weapon') bone('Crystal',(.12,-1.04,1.34),(.12,-1.40,1.34),'Weapon') bone('Chamber',(.12,-.355,1.34),(.12,-.405,1.34),'Weapon') bpy.ops.object.mode_set(mode='OBJECT') for ob in parts: mod=ob.modifiers.new('Weighted mineral deformation','ARMATURE'); mod.object=rig ob.parent=rig rig.show_in_front=True clips={'idle':(3.0,True),'start':(.4,False),'walk':(1.2,True),'stop':(.4,False), 'attack_a':(1.3,False),'attack_b':(1.3,False),'hit':(.5,False),'ability':(1.3,False),'death':(2.0,False)} def curve(t,keys): for (a,x),(b,y) in zip(keys,keys[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 pose_rotation(pb,r): 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 aim_bone(name,head,tail): pb=rig.pose.bones[name]; rest=pb.bone.matrix_local direction=defs[name][1]-defs[name][0] rotation=direction.rotation_difference(tail-head).to_matrix().to_4x4() pose=rotation@rest pose.translation=head pb.matrix=pose bpy.context.view_layer.update() def animate(name,t,dur): rs,locs={},{} def rot(b,x=0,y=0,z=0):rs[b]=(x,y,z) if name=='idle': breath=sin(2*pi*t/dur) rot('Spine',.008*breath);rot('Chest',-.010*breath,0,.005*breath) rot('Head',.008*breath,0,-.014*breath) rot('Weapon',-.035+.006*breath) 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=2*pi*t/1.2; roll=sin(phase)*fade rot('Pelvis',.025*fade,0,.035*roll);rot('Spine',-.025*fade,0,-.032*roll) rot('Chest',.015*fade,0,.012*roll);rot('Head',-.012*fade,0,-.01*roll) rot('Weapon',-.04*fade+.007*sin(phase*2)*fade) 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,-.48*lift*fade) rot('Foot.'+side,(-.30*q+.48*lift)*fade) locs['Root']=(0,0,-.011*(1-cos(phase*2))*fade) elif name in ('attack_a','attack_b','ability'): braced=name=='attack_b'; charged=name=='ability' aim=curve(t,[(0,0),(.30,.82),(.60,1),(.90,1),(1.04,.8),(1.30,0)]) # Contact is exactly .90 seconds; all backward recoil starts after it. recoil=curve(t,[(0,0),(.90,0),(.933333,1),(1.04,.24),(1.14,0),(1.30,0)]) reload=curve(t,[(0,0),(.90,0),(.98,1),(1.10,1),(1.24,0),(1.30,0)]) strength=1.35 if charged else .9 if braced else 1.0 rot('Spine',-.035*aim+.025*recoil,0,-.022*aim if braced else .01*aim) rot('Chest',-.015*aim+.015*recoil,0,.023*aim if braced else -.012*aim) rot('Neck',.014*aim);rot('Head',.018*aim,0,-.02*aim) rot('Weapon',.042*aim+.055*recoil*strength,0,.009*aim if braced else 0) locs['Weapon']=(0,.054*recoil*strength,-.006*reload) locs['Bolt']=(0,.075*reload,0) rot('Index.R',-.085*curve(t,[(0,0),(.82,0),(.90,1),(.99,0),(1.3,0)])) if braced: locs['Root']=(0,.008*aim,-.025*aim) rot('Thigh.L',.07*aim);rot('Shin.L',-.12*aim);rot('Foot.L',.05*aim) rot('Thigh.R',.07*aim);rot('Shin.R',-.12*aim);rot('Foot.R',.05*aim) if charged: charge=curve(t,[(0,0),(.35,.4),(.78,1),(.90,1),(.97,0),(1.30,0)]) locs['Chamber']=(.014*charge,0,0) rot('Crystal',0,.45*charge,0) rot('Head',.018*aim,0,-.035*aim) elif name=='hit': q=curve(t,[(0,0),(.066667,1),(.20,.4),(.5,0)]) rot('Spine',.065*q,0,-.04*q);rot('Head',.08*q,0,.05*q) rot('Weapon',.035*q) elif name=='death': q=curve(t,[(0,0),(.3,.1),(.72,.56),(1.25,1),(1.5,.97),(2,1)]) rot('Root',-.58*q,0,-.30*q);locs['Root']=(0,-.10*q,-.22*q) rot('Pelvis',-.1*q);rot('Spine',-.12*q);rot('Head',-.22*q) rot('Weapon',-.09*q,0,.08*q) for side in ('L','R'): rot('Thigh.'+side,.25*q);rot('Shin.'+side,-.64*q);rot('Foot.'+side,.15*q) 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()@Vector(locs.get(pb.name,(0,0,0))) pb.scale=(1,1,1) bpy.context.view_layer.update() weapon=rig.pose.bones['Weapon'].matrix@rig.data.bones['Weapon'].matrix_local.inverted() body=rig.pose.bones['Chest'].matrix@rig.data.bones['Chest'].matrix_local.inverted() errors=[] for side in ('L','R'): a=rig.pose.bones['UpperArm.'+side].head.copy() c=weapon@WRISTS[side] pole=body@POLES[side] elbow=ik_elbow(a,c,.405,.395,pole) aim_bone('UpperArm.'+side,a,elbow) aim_bone('Forearm.'+side,elbow,c) hand=weapon@rig.data.bones['Hand.'+side].matrix_local hand.translation=c rig.pose.bones['Hand.'+side].matrix=hand bpy.context.view_layer.update() errors.append((rig.pose.bones['Forearm.'+side].tail-c).length) contact_samples.append({'clip':name,'t':round(t,6),'max_wrist_error':max(errors)}) 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*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 action['contact_seconds']=.90 if name in ('attack_a','attack_b','ability') else -1 # Every sample is explicit; linear interpolation avoids wrist overshoot. 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=30;bpy.context.scene.frame_set(0) # Save named objects, editable weights, and actions before joining the export mesh. bpy.ops.wm.save_as_mainfile(filepath=str(SRC/'funkenlanze.blend')) source_parts=len(parts) 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='FunkenBody' body.select_set(True);rig.select_set(True);bpy.context.view_layer.objects.active=rig bpy.ops.export_scene.gltf(filepath=str(OUT/'funkenlanze.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/'funkenlanze.glb').read_bytes() doc=json.loads(raw[20:20+struct.unpack_from('