Back to Outbreak: Soycoon City
SOURCE / PINNED RELEASE

Made of little things.

Outbreak: Soycoon City

Release
db7750b1d009…
Author-recorded commit
3ee3f5fd27a6…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b87c04571a9

Archive hash verified: dbebdc772b142a1c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

scripts/build-characters.py
"""Author low-poly rigs/actions in Blender and export editable .blend + embedded GLB.
Run: blender --background --factory-startup --python scripts/build-characters.py
"""
import bpy, math, json, hashlib, random
from pathlib import Path
from mathutils import Vector, Matrix
ROOT=Path(__file__).resolve().parent.parent
FPS=24
manifest={'version':1,'format':'glTF 2.0 / GLB','sources':'models/*.blend','models':[]}

def material(name,color):
    m=bpy.data.materials.new(name);m.diffuse_color=(*color,1);m.use_nodes=True
    shader=m.node_tree.nodes.get('Principled BSDF');shader.inputs['Base Color'].default_value=(*color,1);shader.inputs['Roughness'].default_value=1;shader.inputs['Metallic'].default_value=0
    return m

def part(name,center,size,mat,bone,kind='box',bevel=0):
    if kind=='ball':bpy.ops.mesh.primitive_uv_sphere_add(segments=8,ring_count=4,location=center)
    elif kind=='tube':bpy.ops.mesh.primitive_cone_add(vertices=6,radius1=1,radius2=.78,depth=2,location=center)
    else:bpy.ops.mesh.primitive_cube_add(size=2,location=center)
    obj=bpy.context.object;obj.name=name;obj.scale=tuple(s/2 for s in size);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    if bevel:
        mod=obj.modifiers.new('Soft silhouette','BEVEL');mod.width=bevel;mod.segments=1;bpy.ops.object.modifier_apply(modifier=mod.name)
    obj.data.materials.append(mat);group=obj.vertex_groups.new(name=bone);group.add(list(range(len(obj.data.vertices))),1,'REPLACE')
    return obj

def link_segment(name,a,b,width,depth,mat,bone):
    obj=part(name,(Vector(a)+Vector(b))/2,(width,depth,(Vector(b)-Vector(a)).length),mat,bone,'tube')
    obj.rotation_euler=(Vector(b)-Vector(a)).to_track_quat('Z','Y').to_euler();bpy.ops.object.transform_apply(location=False,rotation=True,scale=False);return obj

def combine(objects,name,rig):
    bpy.ops.object.select_all(action='DESELECT')
    for o in objects:o.select_set(True)
    bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();obj=bpy.context.object;obj.name=name
    mod=obj.modifiers.new('Rig','ARMATURE');mod.object=rig;obj.parent=rig;obj.matrix_parent_inverse=rig.matrix_world.inverted();return obj

def torso(mat):
    rings=[(.99,.29,.22),(1.16,.32,.245),(1.43,.445,.255),(1.52,.29,.205)]
    vertices=[]
    for z,w,d in rings:
        for i in range(8):
            a=math.pi/8+i*math.pi/4;vertices.append((math.cos(a)*w/2,math.sin(a)*d/2,z))
    faces=[tuple(reversed(range(8))),tuple(range(24,32))]
    for ring in range(3):
        for i in range(8):faces.append((ring*8+i,ring*8+(i+1)%8,(ring+1)*8+(i+1)%8,(ring+1)*8+i))
    mesh=bpy.data.meshes.new('Tapered shirt');mesh.from_pydata(vertices,[],faces);mesh.materials.append(mat)
    obj=bpy.data.objects.new('Tapered shirt',mesh);bpy.context.collection.objects.link(obj)
    for name in ['Spine','Chest']:
        group=obj.vertex_groups.new(name=name);group.add([i for i,v in enumerate(vertices) if (v[2]>=1.4)==(name=='Chest')],1,'REPLACE')
    return obj

def create_rig(tofu):
    h=.72 if tofu else .91;shoulder=1.38 if tofu else 1.5;knee=.37 if tofu else .49;ankle=.1
    bpy.ops.object.armature_add();rig=bpy.context.object;rig.name='Soybert' if tofu else 'PrecinctZombie';bpy.ops.object.mode_set(mode='EDIT');bones=rig.data.edit_bones;bones.remove(bones[0])
    defs=[('Root',(0,0,0),(0,0,.15),None),('Hips',(0,0,h),(0,0,h+.16),'Root'),('Spine',(0,0,h+.16),(0,0,shoulder-.16),'Hips'),('Chest',(0,0,shoulder-.16),(0,0,shoulder+.08),'Spine'),('Head',(0,0,1.48 if tofu else 1.66),(0,0,1.78 if tofu else 1.90),'Chest')]
    for side,s in [('L',-1),('R',1)]:
        x=s*(.17 if tofu else .105);armx=s*(.365 if tofu else .235)
        defs += [(f'Thigh_{side}',(x,0,h),(x,0,knee),'Hips'),(f'Shin_{side}',(x,0,knee),(x,0,ankle),f'Thigh_{side}'),(f'Foot_{side}',(x,0,ankle),(x,-.18,.08),f'Shin_{side}'),(f'Arm_{side}',(armx,-.1 if tofu else 0,shoulder),(armx,-.1 if tofu else 0,shoulder-(.33 if tofu else .28)),'Chest'),(f'Forearm_{side}',(armx,-.1 if tofu else 0,shoulder-(.33 if tofu else .28)),(armx,-.1 if tofu else 0,shoulder-(.66 if tofu else .56)),f'Arm_{side}'),(f'Hand_{side}',(armx,-.1 if tofu else 0,shoulder-(.66 if tofu else .56)),(armx,-.1 if tofu else 0,shoulder-(.76 if tofu else .66)),f'Forearm_{side}')]
    if tofu:defs += [('Weapon',(0.0,-.13,1.17),(0.0,-.13,1.30),'Chest'),('Muzzle',(0.0,-1.11,1.20),(0.0,-1.11,1.28),'Weapon')]
    for name,a,b,parent in defs:
        bone=bones.new(name);bone.head=a;bone.tail=b
        if parent:bone.parent=bones[parent]
    bpy.ops.object.mode_set(mode='OBJECT');rig.show_in_front=True
    for bone in rig.pose.bones:bone.rotation_mode='XYZ'
    return rig,defs

def mesh_character(tofu,rig,defs):
    cream=material('Tofu / warm ivory',(.84,.72,.46));pale=material('Tofu / highlight',(.98,.90,.66));pore=material('Tofu / pores',(.63,.43,.20));dark=material('Face / charcoal',(.033,.016,.009));blush=material('Face / blush',(.72,.22,.20));white=material('Face / eye glint',(1,.96,.80))
    shirt=material('Uniform',(.15,.24,.25));skin=material('Undead / skin',(.38,.43,.30));trousers=material('Trousers',(.085,.11,.105));blood=material('Old blood',(.28,.035,.03));shoe=material('Boots',(.035,.044,.04));wood=material('Shotgun / walnut',(.24,.085,.025));metal=material('Shotgun / steel',(.10,.11,.105));brass=material('Badge / brass',(.61,.45,.19))
    body=[];head=[];weapon=[]
    if tofu:
        body += [part('Tofu block',(0,.015,1.25),(.64,.39,1.03),cream,'Spine',bevel=.045),part('Tofu top',(0,.01,1.76),(.60,.365,.04),pale,'Spine',bevel=.015)]
        for side in [-1,1]:
            body.append(part('eye',(side*.145,-.196,1.45),(.088,.024,.116),dark,'Spine','ball'));body.append(part('glint',(side*.145-.012,-.211,1.48),(.024,.012,.028),white,'Spine','ball'));body.append(part('cheek',(side*.225,-.195,1.32),(.12,.018,.072),blush,'Spine','ball'))
        for i in range(7):
            t=math.pi*(i/6);body.append(part('smile',(.08*math.cos(t),-.20,1.29-.043*math.sin(t)),(.03,.014,.028),dark,'Spine','ball'))
        rng=random.Random(17)
        for i in range(12):body.append(part('pore',(rng.uniform(-.27,.27),-.187,rng.uniform(.82,1.68)),(.017,.012,.012),pore,'Spine','ball'))
    else:
        body += [part('Pelvis',(0,.025,.92),(.30,.22,.22),trousers,'Hips',bevel=.045),torso(shirt),part('Neck',(0,0,1.60),(.115,.12,.14),skin,'Head','tube')]
        head += [part('head_skin',(0,-.005,1.775),(.23,.225,.255),skin,'Head','ball'),part('head_hair',(0,.02,1.875),(.235,.225,.064),dark,'Head','ball'),part('head_nose',(0,-.14,1.765),(.047,.065,.067),skin,'Head',bevel=.012)]
        for side in [-1,1]:head += [part('head_eye',(side*.060,-.111,1.81),(.045,.017,.022),dark,'Head'),part('head_ear',(side*.128,0,1.78),(.035,.065,.082),skin,'Head','ball')]
        head += [part('head_jaw',(0,-.125,1.70),(.10,.015,.025),blood,'Head'),part('head_teeth',(0,-.134,1.713),(.085,.007,.012),white,'Head')]
        body += [part('Police badge',(-.11,-.12,1.36),(.055,.012,.085),brass,'Spine'),part('Shirt placket',(0,-.126,1.24),(.022,.012,.35),trousers,'Spine'),part('Belt',(0,-.003,1.0),(.32,.245,.055),shoe,'Hips')]
        rng=random.Random(31)
        for i in range(9):body.append(part('Torn shirt',(.13+rng.uniform(-.075,.04),-.124,1.24+rng.uniform(-.13,.12)),(.015+rng.random()*.065,.014,.02+rng.random()*.06),blood,'Spine'))
    bones={name:(a,b) for name,a,b,parent in defs}
    for side in ['L','R']:
        for prefix,width,depth,mat in [('Thigh',.125 if tofu else .145,.135 if tofu else .16,cream if tofu else trousers),('Shin',.095 if tofu else .12,.11 if tofu else .13,cream if tofu else trousers),('Arm',.11 if tofu else .12,.12 if tofu else .13,cream if tofu else shirt),('Forearm',.095 if tofu else .10,.10 if tofu else .11,cream if tofu else skin)]:
            name=f'{prefix}_{side}';a,b=bones[name];body.append(link_segment(name,a,b,width,depth,mat,name))
        a,b=bones[f'Hand_{side}'];body.append(part('Hand',(Vector(a)+Vector(b))/2,(.115,.085,.12),cream if tofu else skin,f'Hand_{side}','ball'))
        a,b=bones[f'Foot_{side}'];body.append(part('Foot',(a[0],-.075,.065),(.15 if tofu else .155,.28,.13),cream if tofu else shoe,f'Foot_{side}',bevel=.035))
    if tofu:
        weapon += [part('Stock',(0.0,-.205,1.16),(.115,.32,.125),wood,'Weapon',bevel=.018),part('Receiver',(0.0,-.46,1.185),(.10,.22,.13),metal,'Weapon',bevel=.012),link_segment('Barrel',(0.0,-.48,1.22),(0.0,-1.08,1.22),.046,.046,metal,'Weapon'),link_segment('Magazine',(0.0,-.52,1.16),(0.0,-.91,1.16),.042,.042,metal,'Weapon'),part('Pump',(0.0,-.58,1.145),(.11,.22,.10),wood,'Weapon',bevel=.013),part('Trigger',(0.0,-.37,1.105),(.025,.055,.058),metal,'Weapon')]
    meshes=[combine(body,'BodyMesh',rig)]
    if head:meshes.append(combine(head,'HeadMesh',rig))
    if weapon:meshes.append(combine(weapon,'ShotgunMesh',rig))
    return meshes

def pose(rig,name,t,tofu):
    for bone in rig.pose.bones:bone.rotation_euler=(0,0,0);bone.location=(0,0,0);bone.scale=(1,1,1)
    phase=t*math.pi*2
    def rot(b,x=0,y=0,z=0):rig.pose.bones[b].rotation_euler=(x,y,z)
    def loc(b,x=0,y=0,z=0):rig.pose.bones[b].location=(x,y,z)
    if tofu:
        for side in ['L','R']:rot(f'Arm_{side}',-1.2 if side=='L' else -1.45,0,.40 if side=='L' else -.40);rot(f'Forearm_{side}',.15 if side=='L' else 1.0)
        if name in ['Aim','AimUp','Shoot','ShootUp','WalkAim','WalkAimUp']:
            for side in ['L','R']:rot(f'Arm_{side}',-1.25 if side=='L' else -1.45,0,.40 if side=='L' else -.40);rot(f'Forearm_{side}',.15 if side=='L' else 1.0)
        if name in ['AimUp','ShootUp','WalkAimUp']:
            rot('Chest',-.30);rot('Weapon',-.37)
            for side in ['L','R']:rot(f'Arm_{side}',-1.40,0,.36 if side=='L' else -.36);rot(f'Forearm_{side}',.62)
        if name in ['Shoot','ShootUp']:
            kick=max(0,1-t/.35) if t>0 else 0
            rot('Spine',-.10*kick);rig.pose.bones['Weapon'].rotation_euler.x-=.30*kick;loc('Weapon',0,-.045*kick,0)
            for side in ['L','R']:rig.pose.bones[f'Forearm_{side}'].rotation_euler.x+=.24*kick
        if name=='Reload':
            rot('Weapon',.28+math.sin(t*math.pi)*.08,0,-.22);rot('Arm_L',-.50,0,.22);rot('Forearm_L',.65+math.sin(phase*2)*.38);rot('Arm_R',-.72);rot('Forearm_R',.6)
    else:
        rot('Spine',.12);rot('Head',-.08,0,.08)
        for side in ['L','R']:rot(f'Arm_{side}',-1.08+(side=='L')*.22,0,.17 if side=='L' else -.10);rot(f'Forearm_{side}',.18)
        if name=='Attack':
            lunge=math.sin(math.pi*min(1,t/.55)) if t<.55 else 0
            rot('Spine',.12+.23*lunge);loc('Root',0,0,-.11*lunge)
            for side in ['L','R']:rot(f'Arm_{side}',-1.35-.2*lunge);rot(f'Forearm_{side}',.08)
    if name in ['Walk','Run','WalkAim','WalkAimUp']:
        amount=.65 if name=='Run' else .35 if tofu else .26
        for side,s in [('L',1),('R',-1)]:
            rot(f'Thigh_{side}',math.sin(phase)*amount*s);rot(f'Shin_{side}',max(0,-math.sin(phase)*s)*.54);rot(f'Foot_{side}',-.06*math.sin(phase)*s)
        loc('Hips',0,.015*math.cos(phase*2),0);rig.pose.bones['Spine'].rotation_euler.z+=math.sin(phase)*.03
    if name in ['Idle','Aim','AimUp']:
        rig.pose.bones['Chest'].rotation_euler.x+=math.sin(phase)*.012
    if name=='Hit':rot('Spine',-.24*math.sin(math.pi*t));rot('Head',.12*math.sin(math.pi*t));rot('Root',0,0,.06*math.sin(math.pi*t))
    if name=='Death':
        collapse=min(1,max(0,(t-.12)/.88));rot('Root',-(math.pi/2)*collapse,0,.10*collapse);loc('Root',0,.16*collapse,0)
        for side in ['L','R']:rot(f'Thigh_{side}',-.12*collapse);rot(f'Shin_{side}',.20*collapse);rot(f'Arm_{side}',-.3*collapse,0,.50*collapse if side=='L' else -.50*collapse)
        rot('Head',.16*collapse)

    if tofu and name not in ['Death']:
        # Bake a two-bone solve into ordinary FK keys. Artists can edit the
        # resulting actions without runtime constraints or engine-specific IK.
        bpy.context.view_layer.update()
        weapon=rig.pose.bones['Weapon'];rest=rig.data.bones['Weapon'].matrix_local
        for side,grip in [('L',(0,-.58,1.13)),('R',(0,-.33,1.12))]:
            arm=rig.pose.bones[f'Arm_{side}'];fore=rig.pose.bones[f'Forearm_{side}'];hand=rig.pose.bones[f'Hand_{side}']
            a=arm.head.copy();target=weapon.matrix @ rest.inverted() @ Vector(grip)
            direction=(target-a).normalized();distance=min((target-a).length,arm.length+fore.length-.001)
            along=(arm.length**2-fore.length**2+distance**2)/(2*distance)
            pole=Vector((-.3 if side=='L' else .3,.2,-1));pole=(pole-direction*pole.dot(direction)).normalized()
            elbow=a+direction*along+pole*math.sqrt(max(0,arm.length**2-along**2))
            arm.matrix=Matrix.LocRotScale(a,(elbow-a).to_track_quat('Y','Z'),Vector((1,1,1)));bpy.context.view_layer.update()
            fore.matrix=Matrix.LocRotScale(elbow,(target-elbow).to_track_quat('Y','Z'),Vector((1,1,1)));bpy.context.view_layer.update()
            hand.rotation_euler=(0,0,0)

def animate(rig,tofu):
    durations={'Idle':2,'Walk':1.05 if tofu else 1.7,'Hit':.45,'Death':1.45}
    if tofu:durations.update({'Run':.65,'Aim':2,'AimUp':2,'WalkAim':1.25,'WalkAimUp':1.25,'Shoot':.55,'ShootUp':.55,'Reload':1.5})
    else:durations['Attack']=.85
    for name,duration in durations.items():
        rig.animation_data_create();action=bpy.data.actions.new(name);action.use_fake_user=True;rig.animation_data.action=action
        frames=max(2,round(duration*FPS))
        keys=sorted(set([1,2,*range(4,frames+1,3),frames]))
        for frame in keys:
            bpy.context.scene.frame_set(frame);pose(rig,name,(frame-1)/(frames-1),tofu)
            for bone in rig.pose.bones:
                bone.keyframe_insert(data_path='rotation_euler',frame=frame);bone.keyframe_insert(data_path='location',frame=frame)
        track=rig.animation_data.nla_tracks.new();track.name=name;strip=track.strips.new(name,1,action);track.mute=True
    rig.animation_data.action=bpy.data.actions['Idle'];pose(rig,'Idle',0,tofu);bpy.context.scene.frame_set(1)
    return durations

def presentation(rig):
    scene=bpy.context.scene;scene.world=scene.world or bpy.data.worlds.new('Studio');scene.world.color=(.08,.08,.08);scene.render.engine='BLENDER_EEVEE';scene.render.resolution_x=640;scene.render.resolution_y=640;scene.render.resolution_percentage=100
    bpy.ops.object.camera_add(location=(3.0,-5.5,2.6));camera=bpy.context.object;camera.name='Presentation camera';camera.rotation_euler=(Vector((0,0,1.05))-camera.location).to_track_quat('-Z','Y').to_euler();camera.data.type='ORTHO';camera.data.ortho_scale=2.55;scene.camera=camera
    for position,energy,size in [((1,-3,4),500,4),((-3,-1,2),240,3),((0,3,3),450,2)]:
        bpy.ops.object.light_add(type='AREA',location=position);light=bpy.context.object;light.data.energy=energy;light.data.shape='DISK';light.data.size=size;light.rotation_euler=(Vector((0,0,1))-light.location).to_track_quat('-Z','Y').to_euler()
    for screen in bpy.data.screens:
        for area in screen.areas:
            if area.type=='VIEW_3D':area.spaces.active.region_3d.view_distance=3;area.spaces.active.region_3d.view_location=(0,0,1.0)

for kind in ['soybert','zombie']:
    bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.preferences.filepaths.save_version=0;bpy.context.scene.render.fps=FPS;bpy.context.scene.unit_settings.system='METRIC'
    tofu=kind=='soybert';rig,defs=create_rig(tofu);meshes=mesh_character(tofu,rig,defs);clips=animate(rig,tofu);presentation(rig)
    source=ROOT/'models'/f'{kind}.blend';target=ROOT/'src/models'/f'{kind}.glb'
    bpy.ops.wm.save_as_mainfile(filepath=str(source),compress=True)
    bpy.ops.object.select_all(action='DESELECT');rig.select_set(True)
    for mesh in meshes:mesh.select_set(True)
    bpy.context.view_layer.objects.active=rig
    bpy.ops.export_scene.gltf(filepath=str(target),export_format='GLB',use_selection=True,export_animations=True,export_animation_mode='ACTIONS',export_anim_single_armature=True,export_skins=True,export_yup=True,export_apply=False,export_extras=True)
    binary=target.read_bytes();manifest['models'].append({'id':kind,'source':str(source.relative_to(ROOT)),'path':str(target.relative_to(ROOT)),'sha256':hashlib.sha256(binary).hexdigest(),'size':len(binary),'clips':list(clips),'bones':[d[0] for d in defs],'triangles':sum(len(poly.vertices)-2 for m in meshes for poly in m.data.polygons),'heightMeters':1.78 if tofu else 1.9})
    # Artist preview kept outside the public source tree.
    bpy.context.scene.render.filepath=str(ROOT/'.napplet-space/testing'/f'{kind}-blender.png');bpy.ops.render.render(write_still=True)
(ROOT/'models/manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
print('Exported editable character sources and animated GLBs')