saumwacht-godot/assets-source/phase12/build_arbeiter.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

597 lines
28 KiB
Python

"""Funkenleser: compact weighted worker with tool-contact work clips.
Blender Z up, -Y forward. Run in a fresh background Blender process.
Replaces the Phase 4 worker presentation; gameplay values stay unchanged.
"""
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(52)
bpy.ops.wm.read_factory_settings(use_empty=True)
S = 1.0 # authored directly at ~1.15 m game height
def coord(v):
return Vector(v) * S
parts = []
def material(name, color, rough=.8, metal=0.0, emission=None, alpha=1.0, glass=False):
m = bpy.data.materials.new('AB_' + name)
m.diffuse_color = (*color, alpha)
m.use_nodes = True
n = m.node_tree.nodes
l = m.node_tree.links
b = n.get('Principled BSDF')
b.inputs['Base Color'].default_value = (*color, 1)
b.inputs['Roughness'].default_value = rough
b.inputs['Metallic'].default_value = metal
if alpha < 1.0 or glass:
b.inputs['Alpha'].default_value = alpha
m.blend_method = 'BLEND'
b.inputs['Transmission Weight'].default_value = .55 if glass else 0.0
b.inputs['IOR'].default_value = 1.45
if emission is not None:
b.inputs['Emission Color'].default_value = (*emission, 1)
b.inputs['Emission Strength'].default_value = 1.4
else:
v = n.new('ShaderNodeVertexColor')
v.layer_name = 'SurfaceColor'
l.new(v.outputs['Color'], b.inputs['Base Color'])
if name in ('Leather', 'Cloth', 'Clay'):
# Fine authored grain without external bitmaps.
noise = n.new('ShaderNodeTexNoise')
noise.inputs['Scale'].default_value = 48 if name != 'Clay' else 22
noise.inputs['Detail'].default_value = 3.2
bump = n.new('ShaderNodeBump')
bump.inputs['Strength'].default_value = .28 if name == 'Leather' else .16
bump.inputs['Distance'].default_value = .012
l.new(noise.outputs['Fac'], bump.inputs['Height'])
l.new(bump.outputs['Normal'], b.inputs['Normal'])
return m
M = {
'clay': material('Clay', (.36, .26, .18), .88),
'cloth': material('Cloth', (.16, .19, .23), .92),
'hood': material('Hood', (.12, .15, .14), .9),
'leather': material('Leather', (.30, .16, .08), .72),
'boot': material('Boot', (.10, .07, .05), .78),
'glove': material('Glove', (.32, .22, .12), .7),
'metal': material('Metal', (.42, .45, .48), .35, .85),
'brass': material('Brass', (.55, .40, .16), .4, .75),
'darkmetal': material('DarkMetal', (.12, .13, .14), .45, .7),
'glass': material('Glass', (.55, .78, .72), .12, 0.0, alpha=.38, glass=True),
'spark': material('Spark', (.55, .95, .40), .4, emission=(.45, .95, .35)),
'lamp': material('Lamp', (1.0, .88, .55), .3, emission=(1.0, .82, .40)),
'lens': material('Lens', (.35, .55, .50), .18, metal=.2),
'skin': material('Skin', (.48, .36, .26), .75),
}
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] * 1.4 + v[1] * .5, v[2] * 1.2)
fac = .92 + .08 * sin(v[0] * 31 + v[2] * 17) + .03 * cos(v[1] * 53)
c = colors[i] if colors else tuple(max(.01, x * fac) for x in base)
col.data[li].color = (*c, 1)
groups = {}
for i in range(len(verts)):
ws = weights[i] if weights else {bone: 1.0}
total = sum(ws.values())
for key, w in ws.items():
if key not in groups:
groups[key] = ob.vertex_groups.new(name=key)
groups[key].add([i], w / total, 'REPLACE')
return ob
def mixweights(t, bones):
if len(bones) == 1:
return {bones[0]: 1}
t = max(0, min(.999999, t)) * (len(bones) - 1)
i = int(t)
w = t - i
return {bones[i]: 1 - w, bones[i + 1]: w}
def tube(name, points, radii, kind, bones, sides=12, steps=20, flatten=1.0, groove=.04):
points = [Vector(x) for x in points]
vs, uv, ws, loops, fs = [], [], [], [], []
def point(t):
a = min(len(points) - 2, int(t * (len(points) - 1)))
u = t * (len(points) - 1) - a
p0, p1, p2, p3 = points[max(a - 1, 0)], points[a], points[a + 1], points[min(a + 2, len(points) - 1)]
p = .5 * ((2 * p1) + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u)
return p, radii[a] * (1 - u) + radii[a + 1] * u
for j in range(steps + 1):
t = j / steps
p, r = point(t)
tan = (point(min(1, t + .002))[0] - point(max(0, t - .002))[0]).normalized()
axis = Vector((1, 0, 0)) if abs(tan.y) > .94 else Vector((0, 1, 0))
u = tan.cross(axis).normalized()
v = tan.cross(u)
loop = []
for k in range(sides):
a = 2 * pi * k / sides
mod = 1 + groove * (sin(a * 5 + t * 4) + .3 * sin(a * 9 - t * 6))
vs.append(p + r * mod * (u * cos(a) + v * sin(a) * flatten))
uv.append((k / sides, t))
ws.append(mixweights(t, bones))
loop.append(len(vs) - 1)
loops.append(loop)
for a, b in zip(loops, loops[1:]):
for k in range(sides):
q = (k + 1) % sides
fs.append((a[k], a[q], b[q], b[k]))
fs.extend([tuple(reversed(loops[0])), tuple(loops[-1])])
return mesh(name, vs, fs, kind, bones[0], uv, weights=ws)
def ell(name, c, r, kind, bone, segments=14, rings=8):
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 + .03 * sin(a * 4 + j)
vs.append((c[0] + r[0] * sin(th) * cos(a) * f, c[1] + r[1] * sin(th) * sin(a) * f, c[2] + r[2] * cos(th)))
vs.append((c[0], c[1], c[2] - r[2]))
end = len(vs) - 1
for k in range(segments):
fs.append((0, 1 + k, 1 + (k + 1) % segments))
for j in range(rings - 2):
for k in range(segments):
a = 1 + j * segments + k
b = 1 + j * segments + (k + 1) % segments
fs.append((a, a + segments, b + segments, b))
for k in range(segments):
fs.append((end, end - 1 - k, end - 1 - (k + 1) % segments))
return mesh(name, vs, fs, kind, bone)
def box(name, c, size, kind, bone, rot_y=0.0, smooth=False):
sx, sy, sz = size[0] * .5, size[1] * .5, size[2] * .5
local = [(-sx, -sy, -sz), (sx, -sy, -sz), (sx, sy, -sz), (-sx, sy, -sz),
(-sx, -sy, sz), (sx, -sy, sz), (sx, sy, sz), (-sx, sy, sz)]
ca, sa = cos(rot_y), sin(rot_y)
vs = [(c[0] + x * ca - z * sa, c[1] + y, c[2] + x * sa + z * ca) for x, y, z in local]
faces = [(0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1), (1, 5, 6, 2), (2, 6, 7, 3), (3, 7, 4, 0)]
return mesh(name, vs, faces, kind, bone, smooth=smooth)
def frame(name, c, w, h, depth, kind, bone):
"""Open rectangular frame for the spark reservoir."""
t = .018
box(name + '_T', (c[0], c[1], c[2] + h * .5), (w, depth, t), kind, bone)
box(name + '_B', (c[0], c[1], c[2] - h * .5), (w, depth, t), kind, bone)
box(name + '_L', (c[0] - w * .5, c[1], c[2]), (t, depth, h), kind, bone)
box(name + '_R', (c[0] + w * .5, c[1], c[2]), (t, depth, h), kind, bone)
def fabric(name, rows, kind, bones, depth=.012, folds=.008, cols=18):
"""Tailored closed panel: (height, half-width, front, curvature) rows.
The two surfaces, side welt and blended weights form one deforming garment;
folds are geometric, so the silhouette survives export without shader tricks.
"""
vs, fs, ws, uv = [], [], [], []
for back in (0, 1):
for j, (z, width, front, curve) in enumerate(rows):
t = j / (len(rows) - 1)
for k in range(cols + 1):
u = k / cols * 2 - 1
y = front + curve * u * u + folds * sin(u * pi * 3 + t) * sin(pi * t)
vs.append((u * width, y + back * depth, z + .008 * cos(u * pi) * t))
ws.append(mixweights(t, bones)); uv.append((k / cols, 1-t))
stride = cols + 1; layer = len(rows) * stride
for back in (0, 1):
for j in range(len(rows) - 1):
for k in range(cols):
a = back * layer + j * stride + k
q = (a, a+1, a+stride+1, a+stride)
fs.append(q if back else tuple(reversed(q)))
boundary = list(range(stride)) + [j*stride+cols for j in range(1,len(rows))]
boundary += list(range(layer-2,layer-stride-1,-1)) + [j*stride for j in range(len(rows)-2,0,-1)]
for a,b in zip(boundary, boundary[1:]+boundary[:1]):
fs.append((a,b,b+layer,a+layer))
return mesh(name, vs, fs, kind, bones[0], uv, weights=ws)
def boot(name, s, side):
"""Rounded last, broad sole and fitted ankle rather than stacked cubes."""
bone = 'Foot.' + side
ell(name, (s*.13,-.053,.071), (.087,.145,.068), 'boot', bone, 24, 10)
# A closed, flattened swept sole has a real broad contact surface.
vs, fs = [], []
for z, scale in [(0.006,.96),(.018,1),(.037,.97)]:
for k in range(32):
a = 2*pi*k/32
vs.append((s*.13 + .086*cos(a)*scale,-.055+.147*sin(a)*scale,z))
for j in range(2):
for k in range(32):
q=(k+1)%32;fs.append((j*32+k,j*32+q,(j+1)*32+q,(j+1)*32+k))
fs += [tuple(reversed(range(32))),tuple(range(64,96))]
mesh('Sole_'+side,vs,fs,'darkmetal',bone)
tube('Ankle_'+side,[(s*.13,-.01,.06),(s*.125,-.008,.14),(s*.12,-.003,.19)],[.068,.076,.071],'boot',['Foot.'+side,'Shin.'+side],18,14,flatten=.91,groove=.01)
tube('Cuff_'+side,[(s*.125,-.008,.151),(s*.12,-.003,.182)],[.080,.077],'leather',['Shin.'+side],18,6,flatten=.91,groove=.01)
for z in [.080,.099,.118]:
tube('Lace_'+side+str(z),[(s*.13-.04,-.141,z),(s*.13,-.153,z+.007),(s*.13+.04,-.141,z)],[.005,.006,.005],'leather',[bone],6,8,groove=0)
# ---- Body: stocky gatherer, readable silhouette from all sides ----
tube('Tunic',[(0,.015,.40),(0,.015,.51),(0,.018,.65),(0,.016,.77),(0,.01,.82)],[.175,.204,.206,.184,.119],'cloth',['Pelvis','Spine','Chest'],28,32,flatten=.77,groove=.018)
fabric('Apron',[(.735,.095,-.153,.021),(.70,.112,-.166,.019),(.63,.154,-.172,.026),(.54,.179,-.182,.033),(.45,.173,-.175,.031),(.36,.163,-.166,.021),(.285,.148,-.16,.017)],'leather',['Chest','Spine','Pelvis'],depth=.014,folds=.012)
for s, side in [(-1,'L'),(1,'R')]:
tube('ApronWelt_'+side,[(s*.094,-.155,.737),(s*.112,-.149,.70),(s*.154,-.148,.63),(s*.179,-.151,.54),(s*.173,-.146,.45),(s*.163,-.147,.36),(s*.148,-.147,.285)],[.005]*7,'boot',['Chest','Spine','Pelvis'],6,30,groove=0)
tube('Harness_'+side,[(s*.085,-.155,.728),(s*.13,-.115,.805),(s*.145,.002,.84),(s*.13,.14,.785),(s*.105,.179,.59)],[.026,.025,.025,.023,.022],'leather',['Chest'],12,28,flatten=.28,groove=0)
ell('ApronRivet_'+side,(s*.084,-.163,.716),(.009,.006,.009),'brass','Chest',10,5)
for j in range(8):
z=.31+j*.035;x=s*(.151+.020*sin((z-.31)*9))
tube('ApronStitch_'+side+str(j),[(x,-.152,z),(x,-.154,z+.009)],[.0017,.0017],'brass',['Pelvis' if z<.48 else 'Spine'],5,2,groove=0)
tube('ApronHem',[(-.148,-.147,.285),(-.07,-.17,.291),(0,-.163,.293),(.07,-.167,.291),(.148,-.147,.285)],[.006]*5,'boot',['Pelvis'],8,26,groove=0)
# Curved chest pocket and stitched flap conform to the belly.
pocket=fabric('ToolPocket',[(.535,.067,-.20,.012),(.50,.070,-.219,.016),(.455,.062,-.221,.014),(.443,.046,-.208,.010)],'leather',['Spine','Pelvis'],depth=.008,folds=.002,cols=12)
for v in pocket.data.vertices:v.co.x+=.058
tube('PocketLip',[(0,-.197,.535),(.055,-.208,.538),(.12,-.195,.535)],[.005,.006,.005],'boot',['Spine'],8,14,groove=0)
for s in [-1,1]:
tube('Belt_'+str(s),[(0,-.178,.545),(s*.18,-.119,.548),(s*.204,.045,.555),(s*.14,.166,.565),(0,.178,.565)],[.016]*5,'leather',['Spine'],12,25,flatten=.44,groove=0)
# Framed glass reservoir on the back for gathered sparks.
frame('ReservoirFrame', (0, .22, .72), .28, .34, .16, 'brass', 'Chest')
box('ReservoirGlass', (0, .22, .72), (.24, .12, .30), 'glass', 'Chest')
ell('ReservoirLiquid', (0, .20, .64), (.10, .05, .08), 'spark', 'Chest', 10, 6)
for k, z in enumerate([.58, .72, .86]):
box('ReservoirBrace_%d' % k, (0, .22, z), (.30, .17, .025), 'brass', 'Chest')
# Head: hood, lamp, goggles, recessed face.
ell('Neck', (0, .01, .875), (.075, .077, .088), 'skin', 'Head',20,10)
tube('Collar',[(0,.015,.818),(0,.008,.868)],[.112,.094],'hood',['Chest','Head'],24,10,flatten=.79,groove=.014)
ell('Head', (0, -.014, 1.008), (.127, .128, .167), 'skin', 'Head',24,14)
ell('Hood', (0, .040, 1.032), (.155, .154, .190), 'hood', 'Head',24,14)
ell('FaceRecess', (0, -.115, .997), (.112, .058, .12), 'boot', 'Head',24,12)
ell('Face', (0, -.14, 1.00), (.090, .051, .108), 'skin', 'Head',24,12)
ell('Chin', (0, -.140, .931), (.064, .043, .040), 'skin', 'Head',16,8)
for s, side in [(-1,'L'),(1,'R')]:
tube('HoodWelt_'+side,[(s*.03,-.100,.889),(s*.115,-.125,.956),(s*.127,-.139,1.041),(s*.096,-.119,1.115)],[.011,.017,.013,.012],'cloth',['Head'],10,18,groove=.015)
ell('Cheek_'+side,(s*.065,-.162,.986),(.038,.031,.045),'skin','Head',16,8)
for s, side in [(-1, 'L'), (1, 'R')]:
ell('GoggleSocket_' + side, (s * .07, -.15, 1.05), (.05, .03, .04), 'darkmetal', 'Head', 10, 6)
ell('GoggleLens_' + side, (s * .07, -.17, 1.05), (.038, .018, .03), 'lens', 'Head', 10, 6)
ell('GoggleGlint_' + side, (s * .055, -.185, 1.06), (.01, .006, .008), 'lamp', 'Head', 8, 4)
box('GoggleBridge', (0, -.16, 1.05), (.05, .03, .025), 'darkmetal', 'Head')
ell('Cap', (0, .020, 1.135), (.158, .162, .086), 'cloth', 'Head',28,12)
fabric('CapPeak',[(1.133,.135,-.072,.015),(1.125,.163,-.14,.009),(1.108,.164,-.196,.018),(1.102,.129,-.222,.024)],'hood',['Head'],depth=.013,folds=.001,cols=24)
tube('CapSeam',[(-.151,.022,1.153),(-.098,.018,1.197),(0,.010,1.221),(.101,.018,1.197),(.151,.022,1.153)],[.004]*5,'hood',['Head'],6,26,groove=0)
tube('LampHousing',[(0,-.101,1.17),(0,-.158,1.17)],[.045,.044],'brass',['Head'],20,8,groove=0)
ell('Lamp', (0, -.171, 1.17), (.034, .014, .034), 'lamp', 'Head', 20, 8)
ell('Nose',(0,-.188,1.006),(.021,.031,.030),'skin','Head',14,8)
tube('Mouth',[(-.030,-.185,.957),(0,-.188,.953),(.030,-.185,.957)],[.0025,.003,.0025],'boot',['Head'],6,10,groove=0)
# Arms, gloves, fingers and tools.
for s, side in [(-1, 'L'), (1, 'R')]:
arm = ['UpperArm.' + side, 'Forearm.' + side, 'Hand.' + side]
tube('Sleeve_' + side, [(s*.17,.01,.797),(s*.24,.00,.75),(s*.30,-.02,.64),(s*.33,-.075,.54),(s*.34,-.10,.478)], [.08,.088,.073,.072,.057], 'cloth', [arm[0], arm[1]], 18, 32,groove=.018)
ell('ElbowPatch_' + side, (s * .311, .014, .644), (.068, .048, .071), 'leather', arm[1], 14, 8)
tube('SleeveCuff_'+side,[(s*.332,-.083,.523),(s*.341,-.103,.482)],[.066,.062],'leather',[arm[1]],18,8,groove=.014)
ell('Glove_' + side, (s * .348, -.12, .44), (.067, .055, .064), 'glove', arm[2],20,10)
for finger in range(3):
z=.432-finger*.029; hx=s*(.345+finger*.006)
tube('Finger_%s%d' % (side, finger), [(hx+s*.047,-.119,z+.002),(hx+s*.050,-.151,z),(hx+s*.024,-.177,z-.006),(hx-s*.010,-.173,z-.012)], [.016,.018,.015,.010], 'glove', [arm[2], 'Finger%d.%s' % (finger, side)], 10, 14,groove=.014)
tube('Thumb_' + side, [(s*.306,-.109,.459),(s*.310,-.158,.438),(s*.341,-.174,.429)], [.026,.020,.014], 'glove', [arm[2], 'Thumb.' + side], 10, 14,groove=.014)
# Left: gathering fork. Right: slender probe.
tube('ForkHandle', [(-.35, -.14, .40), (-.38, -.22, .22), (-.40, -.26, .08)], [.025, .022, .02], 'leather', ['Hand.L'], 8, 12)
for tine in range(3):
x = -.40 + (tine - 1) * .045
tube('ForkTine_%d' % tine, [(x, -.26, .08), (x, -.30, -.02), (x, -.28, -.10)], [.014, .012, .004], 'metal', ['Hand.L'], 8, 10)
box('ForkGuard', (-.40, -.25, .10), (.14, .04, .03), 'brass', 'Hand.L')
tube('Probe', [(.35, -.14, .40), (.38, -.20, .24), (.40, -.22, .12)], [.018, .014, .008], 'metal', ['Hand.R'], 8, 10)
ell('ProbeTip', (.40, -.23, .10), (.018, .018, .02), 'spark', 'Hand.R', 8, 4)
# Legs and boots with cuffs and toe ridges.
for s, side in [(-1, 'L'), (1, 'R')]:
leg = ['Thigh.' + side, 'Shin.' + side, 'Foot.' + side]
tube('Trousers_' + side, [(s*.10,.01,.435),(s*.113,.005,.32),(s*.12,0,.24),(s*.13,-.01,.15)], [.095,.098,.078,.068], 'cloth', [leg[0],leg[1]], 18, 26,groove=.018)
ell('KneePatch_'+side,(s*.12,-.066,.25),(.058,.027,.067),'leather',leg[1],14,8)
boot('Boot_'+side,s,side)
# Weld, triangulate and orient closed solids before skinning.
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 for a compact gatherer.
bpy.ops.object.armature_add(enter_editmode=True)
rig = bpy.context.object
rig.name = 'ArbeiterRig'
ed = rig.data.edit_bones
ed.remove(ed[0])
defs = {}
def bone(name, head, tail, parent=None):
b = ed.new(name)
b.head = coord(head)
b.tail = coord(tail)
if parent:
b.parent = ed[parent]
defs[name] = (head, tail, parent)
bone('Root', (0, 0, 0), (0, 0, .12))
bone('Pelvis', (0, .01, .40), (0, .02, .58), 'Root')
bone('Spine', (0, .02, .58), (0, .02, .74), 'Pelvis')
bone('Chest', (0, .02, .74), (0, .01, .88), 'Spine')
bone('Head', (0, .01, .88), (0, 0, 1.16), 'Chest')
for s, side in [(-1, 'L'), (1, 'R')]:
bone('UpperArm.' + side, (s * .20, .01, .78), (s * .30, -.02, .64), 'Chest')
bone('Forearm.' + side, (s * .30, -.02, .64), (s * .34, -.10, .48), 'UpperArm.' + side)
bone('Hand.' + side, (s * .34, -.10, .48), (s * .35, -.15, .38), 'Forearm.' + side)
for f in range(3):
x = s * .35 + (f - 1) * .035
bone('Finger%d.%s' % (f, side), (x, -.15, .40), (x + s * .01, -.19, .33), 'Hand.' + side)
bone('Thumb.' + side, (s * .30, -.10, .44), (s * .27, -.17, .37), 'Hand.' + side)
bone('Thigh.' + side, (s * .10, .01, .42), (s * .12, 0, .24), 'Pelvis')
bone('Shin.' + side, (s * .12, 0, .24), (s * .13, -.01, .08), 'Thigh.' + side)
bone('Foot.' + side, (s * .13, -.01, .08), (s * .13, -.16, .03), 'Shin.' + side)
bone('Toe.' + side, (s * .13, -.16, .03), (s * .13, -.20, .03), 'Foot.' + side)
bpy.ops.object.mode_set(mode='OBJECT')
for ob in parts:
mod = ob.modifiers.new('Weighted gatherer', 'ARMATURE')
mod.object = rig
ob.parent = rig
# Pose clips at 30 fps. Work cycle: strike (tool contact) → pickup → deposit → return.
clips = {
'idle': (2.8, True),
'start': (.35, False),
'walk': (1.1, True),
'stop': (.35, False),
'strike': (1.30, False),
'pickup': (1.15, False),
'deposit': (1.20, False),
'return': (.95, False),
'hit': (.42, False),
'death': (1.9, 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', .01 * q)
rot('Chest', -.014 * q, 0, .008 * q)
rot('Head', .02 * q, 0, -.012 * q)
for s, side in [(-1, 'L'), (1, 'R')]:
rot('Forearm.' + side, .02 * q, 0, s * .01 * q)
rot('Finger0.' + side, .04 * 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.1 * 2 * pi
roll = sin(phase) * fade
rot('Pelvis', .01, 0, .03 * roll)
rot('Spine', -.03 * fade, 0, -.04 * roll)
rot('Chest', .012 * fade, 0, .02 * roll)
rot('Head', .02 * fade, 0, -.015 * roll)
for s, side in [(-1, 'L'), (1, 'R')]:
q = sin(phase + (0 if s < 0 else pi))
lift = max(0, q)
rot('Thigh.' + side, .28 * q * fade)
rot('Shin.' + side, -.34 * lift * fade)
rot('Foot.' + side, (-.28 * q + .34 * lift) * fade)
rot('Toe.' + side, .10 * max(0, -q) * fade)
rot('UpperArm.' + side, -.08 * q * fade, 0, s * .03 * fade)
rot('Forearm.' + side, -.12 * fade - .04 * lift)
# Reservoir lag: chest sways against the carried load.
locs['Root'] = (0, 0, (-.008 + .008 * cos(phase * 2)) * fade)
elif name == 'strike':
# Contact at 0.62 — the fork tip reaches the ore at the damage frame.
q = curve(t / dur, [(0, 0), (.28, -.40), (.48, -.62), (.62, 1), (.72, .82), (1, 0)])
rot('Spine', -.14 * q, .08 * q, .06 * q)
rot('Chest', -.10 * q, .06 * q, .10 * q)
rot('Head', .12 * q, -.05 * q, -.06 * q)
rot('UpperArm.L', -.95 * q, -.10 * q, .12 * q)
rot('Forearm.L', -.35 * q)
rot('Hand.L', .22 * q)
rot('UpperArm.R', .18 * q, 0, -.08 * q)
rot('Forearm.R', -.15 * q)
for f in range(3):
rot('Finger%d.L' % f, -.30 * max(0, q))
rot('Thigh.L', .08 * q)
rot('Thigh.R', -.04 * q)
locs['Root'] = (0, -.03 * q, -.01 * max(0, q))
elif name == 'pickup':
q = curve(t / dur, [(0, 0), (.35, 1), (.70, .85), (1, 0)])
rot('Spine', .38 * q)
rot('Chest', .22 * q)
rot('Head', .18 * q)
rot('Thigh.L', .32 * q)
rot('Thigh.R', .28 * q)
rot('Shin.L', -.48 * q)
rot('Shin.R', -.42 * q)
rot('Foot.L', .20 * q)
rot('Foot.R', .18 * q)
rot('UpperArm.L', -.55 * q, 0, .18 * q)
rot('Forearm.L', -.40 * q)
rot('UpperArm.R', -.48 * q, 0, -.18 * q)
rot('Forearm.R', -.38 * q)
for s, side in [(-1, 'L'), (1, 'R')]:
for f in range(3):
rot('Finger%d.%s' % (f, side), -.45 * max(0, q))
locs['Root'] = (0, -.02 * q, -.08 * q)
elif name == 'deposit':
# Lift the gathered spark into the back reservoir.
q = curve(t / dur, [(0, 0), (.30, .7), (.55, 1), (.80, .6), (1, 0)])
rot('Spine', -.08 * q, -.25 * q, 0)
rot('Chest', -.05 * q, -.20 * q, 0)
rot('Head', -.10 * q, .15 * q, 0)
rot('UpperArm.R', -.70 * q, -.35 * q, -.25 * q)
rot('Forearm.R', -.90 * q)
rot('Hand.R', .40 * q)
rot('UpperArm.L', .15 * q, 0, .10 * q)
for f in range(3):
rot('Finger%d.R' % f, -.55 * max(0, q))
locs['Root'] = (0, .01 * q, .02 * q)
elif name == 'return':
# Step back to the station and settle into the ready stance.
q = curve(t / dur, [(0, 0), (.40, 1), (1, 0)])
rot('Spine', .06 * q, 0, -.04 * q)
rot('Thigh.L', -.18 * q)
rot('Thigh.R', .14 * q)
rot('Shin.L', .12 * q)
rot('Foot.L', -.10 * q)
rot('UpperArm.L', .10 * q)
rot('UpperArm.R', .08 * q)
rot('Head', .04 * q)
locs['Root'] = (0, .04 * q, 0)
elif name == 'hit':
q = curve(t, [(0, 0), (.07, 1), (.20, .45), (.42, 0)])
rot('Spine', .14 * q, 0, -.08 * q)
rot('Head', .15 * q, 0, .06 * q)
rot('Forearm.L', -.12 * q)
rot('Forearm.R', -.10 * q)
elif name == 'death':
q = curve(t, [(0, 0), (.18, .08), (.65, .42), (1.20, 1), (1.50, .92), (1.9, 1)])
rot('Root', -.85 * q, 0, .10 * q)
locs['Root'] = (0, -.12 * q, -.10 * q)
rot('Pelvis', -.18 * q)
rot('Spine', -.22 * q)
rot('Head', -.18 * q)
for s, side in [(-1, 'L'), (1, 'R')]:
rot('UpperArm.' + side, -.28 * q, 0, s * .14 * q)
rot('Forearm.' + side, -.40 * q)
rot('Thigh.' + side, .22 * q)
rot('Shin.' + side, -.38 * q)
for pb in rig.pose.bones:
pb.rotation_mode = 'QUATERNION'
r = rotations.get(pb.name, (0, 0, 0))
q = Quaternion((0, 0, 1), r[2]) @ Quaternion((0, 1, 0), r[1]) @ Quaternion((1, 0, 0), r[0])
basis = pb.bone.matrix_local.to_quaternion()
pb.rotation_quaternion = basis.inverted() @ q @ basis
pb.location = pb.bone.matrix_local.to_3x3().inverted() @ (Vector(locs.get(pb.name, (0, 0, 0))) * S)
for name, (dur, loop) in clips.items():
rig.animation_data_clear()
action = bpy.data.actions.new(name)
action.use_fake_user = True
rig.animation_data_create()
rig.animation_data.action = action
for frame in range(round(dur * 30) + 1):
animate(name, frame / 30, dur)
for pb in rig.pose.bones:
pb.keyframe_insert('rotation_quaternion', frame=frame)
pb.keyframe_insert('location', frame=frame)
action['loop'] = loop
rig.animation_data_clear()
rig.animation_data_create()
rig.animation_data.action = bpy.data.actions['idle']
for pb in rig.pose.bones:
pb.rotation_quaternion = Quaternion()
pb.location = (0, 0, 0)
bpy.context.scene.render.fps = 30
bpy.context.scene.frame_set(0)
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'arbeiter.blend'))
bpy.ops.file.make_paths_relative()
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'arbeiter.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 = 'ArbeiterBody'
body.select_set(True)
rig.select_set(True)
bpy.context.view_layer.objects.active = rig
bpy.ops.export_scene.gltf(
filepath=str(OUT / 'arbeiter.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 / 'arbeiter.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': 'worker',
'role': 'worker',
'name': 'Funkenleser',
'resource_path': 'res://assets/characters/arbeiter.glb',
'triangles': triangles,
'mesh_parts': 1,
'parts': ['ArbeiterBody'],
'bounds_godot': {
'min': [mins[0], mins[2], -maxs[1]],
'max': [maxs[0], maxs[2], -mins[1]],
},
'skeletal': True,
'bones': len(defs),
'clips': list(clips),
'work_cycle': ['strike', 'pickup', 'deposit', 'return'],
'sha256': hashlib.sha256(raw).hexdigest(),
'source': 'assets-source/phase12/arbeiter.blend',
}
manifest = {'assets': [record]}
# Core presentation metadata for activation / operation / damage reactions.
manifest['cores'] = [
{'id': 'core_team0', 'activate_clip': 'activate', 'operate_clip': 'operate', 'damage_clip': 'damage'},
{'id': 'core_team1', 'activate_clip': 'activate', 'operate_clip': 'operate', 'damage_clip': 'damage'},
]
(P / 'assets/phase12_manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
(SRC / 'arbeiter-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': 52,
}, indent=2) + '\n')
print('ARBEITER_BUILD', triangles, 'triangles', len(defs), 'bones', len(doc.get('animations', [])), 'clips', flush=True)