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>
540 lines
28 KiB
Python
540 lines
28 KiB
Python
"""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('<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':'g05','role':'ranged','name':'Funkenlanze','resource_path':'res://assets/characters/funkenlanze.glb',
|
|
'triangles':triangles,'mesh_parts':1,'parts':['FunkenBody'],
|
|
'bounds_godot':{'min':[mins[0],mins[2],-maxs[1]],'max':[maxs[0],maxs[2],-mins[1]]},
|
|
'skeletal':True,'bones':len(defs),'clips':list(clips),
|
|
'attack_cycle':['attack_a','attack_b','attack_a','ability'],
|
|
'contact_seconds':.9,'sha256':hashlib.sha256(raw).hexdigest(),
|
|
'source':'assets-source/phase18/funkenlanze.blend'}
|
|
(P/'assets/phase18_manifest.json').write_text(json.dumps({'assets':[record]},indent=2)+'\n')
|
|
audit={'record':record,'exported_clips':[a['name'] for a in doc['animations']],
|
|
'skins':len(doc.get('skins',[])),'source_parts':source_parts,
|
|
'materials':[m['name'] for m in doc['materials']],
|
|
'max_baked_wrist_contact_error':max(x['max_wrist_error'] for x in contact_samples),
|
|
'contact_sample_count':len(contact_samples),
|
|
'contact_validation':'Analytic two-bone arm IK baked at 30 Hz for all nine clips.',
|
|
'shot_seconds':.90,'recoil_peak_seconds':.933333,'breech_closes_seconds':1.24}
|
|
(SRC/'funkenlanze-audit.json').write_text(json.dumps(audit,indent=2)+'\n')
|
|
print('FUNKENLANZE_BUILD',triangles,'triangles',len(defs),'bones',len(doc['animations']),'clips',
|
|
'wrist error',audit['max_baked_wrist_contact_error'],flush=True)
|