"""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('