- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting. - Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn): Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes. - Formal partners for all ten pets in the arena and portraits, including new VineSprite, ThunderLeopard and TideWhale. - Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands, configurable Unity run_script timeout, companion static stage. - Also includes concurrent work from another tool in this working tree (FlameLion, enemy combat FX, challenge mode, home explorer, server updates). - Resources registered in Doc/ResDesc_*.md; verified on isolated :18081. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
9.1 KiB
Python
167 lines
9.1 KiB
Python
import bpy, math, os, sys
|
|
from mathutils import Vector
|
|
from pathlib import Path
|
|
|
|
ROOT=Path(r"D:/UD/AI/AIGame")
|
|
OUT=ROOT/"Tools/output/companions/BudBud"
|
|
ASSET=ROOT/"AIClient/Assets/Bundles/Characters/Animal/BudBud"
|
|
# clean
|
|
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
|
|
for datablocks in (bpy.data.meshes, bpy.data.curves, bpy.data.armatures, bpy.data.materials, bpy.data.cameras, bpy.data.lights):
|
|
pass
|
|
|
|
# one atlas material; Unity receives BudBud_base/mix via SetupCharacter later
|
|
mat=bpy.data.materials.new('BudBud_Atlas'); mat.use_nodes=True
|
|
nodes=mat.node_tree.nodes; links=mat.node_tree.links
|
|
for n in list(nodes): nodes.remove(n)
|
|
outn=nodes.new('ShaderNodeOutputMaterial'); bs=nodes.new('ShaderNodeBsdfPrincipled'); tex=nodes.new('ShaderNodeTexImage')
|
|
try:
|
|
img=bpy.data.images.load(str(ASSET/'BudBud_base.png'), check_existing=True); tex.image=img
|
|
except Exception: pass
|
|
links.new(tex.outputs['Color'],bs.inputs['Base Color']); links.new(bs.outputs['BSDF'],outn.inputs['Surface'])
|
|
|
|
# category -> UV atlas swatch center
|
|
UV={'green':(0.18,0.82),'cream':(0.50,0.82),'dark':(0.82,0.82),'pink':(0.18,0.32),'yellow':(0.50,0.32),'blue':(0.82,0.32)}
|
|
|
|
def uv_color(obj, cat):
|
|
if not obj.data.uv_layers: obj.data.uv_layers.new(name='UVMap')
|
|
u,v=UV[cat]
|
|
for loop in obj.data.uv_layers.active.data:
|
|
loop.uv=(u,v)
|
|
obj.data.materials.append(mat)
|
|
|
|
def sphere(name, loc, scale, cat, bone, seg=10, rings=6):
|
|
bpy.ops.mesh.primitive_uv_sphere_add(segments=seg, ring_count=rings, location=loc)
|
|
o=bpy.context.object; o.name=name; o.scale=scale
|
|
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
|
|
uv_color(o,cat)
|
|
vg=o.vertex_groups.new(name=bone); vg.add(list(range(len(o.data.vertices))),1.0,'REPLACE')
|
|
return o
|
|
|
|
def cone(name, loc, radius1, radius2, depth, rot, cat, bone, verts=10):
|
|
bpy.ops.mesh.primitive_cone_add(vertices=verts, radius1=radius1, radius2=radius2, depth=depth, location=loc, rotation=rot)
|
|
o=bpy.context.object; o.name=name; uv_color(o,cat)
|
|
vg=o.vertex_groups.new(name=bone); vg.add(list(range(len(o.data.vertices))),1.0,'REPLACE'); return o
|
|
|
|
def create_rig():
|
|
data=bpy.data.armatures.new('BudBud_Armature'); arm=bpy.data.objects.new('Armature',data); bpy.context.collection.objects.link(arm)
|
|
bpy.context.view_layer.objects.active=arm; arm.select_set(True); bpy.ops.object.mode_set(mode='EDIT')
|
|
def eb(name,head,tail,parent=None):
|
|
b=data.edit_bones.new(name); b.head=head; b.tail=tail
|
|
if parent: b.parent=data.edit_bones.get(parent)
|
|
return b
|
|
eb('bone_0',(0,0.02,0),(0,0.18,0))
|
|
eb('bone_1',(0,0.18,0),(0,0.42,0),'bone_0')
|
|
eb('bone_2',(0,0.42,0),(0,0.62,0),'bone_1')
|
|
eb('bone_3',(-0.18,0.36,0),(-0.30,0.36,0),'bone_1')
|
|
eb('bone_4',(0.18,0.36,0),(0.30,0.36,0),'bone_1')
|
|
eb('bone_5',(-0.10,0.18,0),(-0.10,0.03,0),'bone_0')
|
|
eb('bone_6',(0.10,0.18,0),(0.10,0.03,0),'bone_0')
|
|
eb('bone_7',(0,0.57,0),(0,0.78,0),'bone_2')
|
|
bpy.ops.object.mode_set(mode='OBJECT'); arm.show_in_front=True
|
|
return arm
|
|
|
|
arm=create_rig()
|
|
parts=[]
|
|
# body and head; front is -Z
|
|
parts += [sphere('Body',(0,0.29,0),(0.27,0.28,0.22),'green','bone_1',10,6)]
|
|
parts += [sphere('Head',(0,0.52,-0.005),(0.29,0.24,0.23),'green','bone_2',10,6)]
|
|
# cream muzzle and belly
|
|
parts += [sphere('Muzzle',(0,0.48,-0.205),(0.13,0.095,0.045),'cream','bone_2',10,6)]
|
|
parts += [sphere('Belly',(0,0.27,-0.205),(0.16,0.18,0.035),'cream','bone_1',10,6)]
|
|
# eyes and pupils
|
|
parts += [sphere('EyeL',(-0.105,0.565,-0.205),(0.052,0.068,0.035),'cream','bone_2',10,6),sphere('EyeR',(0.105,0.565,-0.205),(0.052,0.068,0.035),'cream','bone_2',10,6)]
|
|
parts += [sphere('PupilL',(-0.105,0.565,-0.237),(0.026,0.036,0.015),'dark','bone_2',8,6),sphere('PupilR',(0.105,0.565,-0.237),(0.026,0.036,0.015),'dark','bone_2',8,6)]
|
|
# nose
|
|
parts += [sphere('Nose',(0,0.505,-0.253),(0.028,0.021,0.016),'pink','bone_2',8,6)]
|
|
# leaf crown (broad petals)
|
|
parts += [sphere('LeafTop',(0,0.73,0),(0.14,0.12,0.07),'green','bone_7',10,6)]
|
|
for i,x in enumerate((-0.105,0.105)):
|
|
p=sphere('LeafSide'+str(i),(x,0.70,0),(0.105,0.17,0.055),'green','bone_7',10,6); p.rotation_euler[2]=(-0.38 if x<0 else 0.38); parts.append(p)
|
|
# arms, legs and cream paws
|
|
parts += [sphere('ArmL',(-0.285,0.34,0),(0.075,0.16,0.075),'green','bone_3',10,6),sphere('ArmR',(0.285,0.34,0),(0.075,0.16,0.075),'green','bone_4',10,6)]
|
|
parts += [sphere('LegL',(-0.105,0.11,0),(0.09,0.14,0.10),'green','bone_5',10,6),sphere('LegR',(0.105,0.11,0),(0.09,0.14,0.10),'green','bone_6',10,6)]
|
|
parts += [sphere('FootL',(-0.105,0.035,-0.055),(0.12,0.045,0.13),'cream','bone_5',10,6),sphere('FootR',(0.105,0.035,-0.055),(0.12,0.045,0.13),'cream','bone_6',10,6)]
|
|
# armature modifiers
|
|
for o in parts:
|
|
mod=o.modifiers.new('Armature','ARMATURE'); mod.object=arm
|
|
# front flower accent (small yellow center on leaf crown)
|
|
parts += [sphere('Flower',(0,0.77,-0.03),(0.055,0.055,0.035),'yellow','bone_7',8,5)]
|
|
mod=parts[-1].modifiers.new('Armature','ARMATURE'); mod.object=arm
|
|
# Make armature active only for export
|
|
# Keep the exported mesh grounded while preserving the same bind pose for every clip.
|
|
for _o in parts + [arm]: _o.location.y += 0.03
|
|
|
|
def select_only(objs):
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for o in objs:o.select_set(True)
|
|
bpy.context.view_layer.objects.active=objs[0]
|
|
|
|
def export(path, objs, action=None, anim=False):
|
|
select_only(objs)
|
|
bpy.ops.export_scene.fbx(filepath=str(path), use_selection=True, object_types={'ARMATURE','MESH'} if any(o.type=='MESH' for o in objs) else {'ARMATURE'}, add_leaf_bones=False, bake_space_transform=False, axis_forward='-Z', axis_up='Y', apply_unit_scale=True, bake_anim=anim, bake_anim_use_all_actions=False, bake_anim_use_nla_strips=False, bake_anim_force_startend_keying=True, primary_bone_axis='Y', secondary_bone_axis='X')
|
|
|
|
# bind rigged
|
|
export(OUT/'BudBud_Rigged.fbx',[arm]+parts,anim=False)
|
|
# create and export source clips with same armature skeleton
|
|
|
|
def clear_actions(): arm.animation_data_clear()
|
|
def pose_action(name, seconds):
|
|
act=bpy.data.actions.new(name); arm.animation_data_create(); arm.animation_data.action=act
|
|
# bind pose at t=0 and end; 30fps
|
|
for b in arm.pose.bones: b.rotation_mode='XYZ'
|
|
frames=[0, int(seconds*15), int(seconds*30)]
|
|
for f in frames:
|
|
t=f/30.0
|
|
for b in arm.pose.bones:
|
|
b.location=(0,0,0); b.rotation_euler=(0,0,0); b.scale=(1,1,1)
|
|
if name=='Idle':
|
|
s=math.sin(math.pi*t/seconds)
|
|
arm.pose.bones['bone_1'].scale=(1+0.012*s,1+0.018*s,1+0.012*s)
|
|
arm.pose.bones['bone_7'].rotation_euler[1]=0.12*s
|
|
elif name=='Walk':
|
|
ph=math.sin(2*math.pi*t/seconds)
|
|
arm.pose.bones['bone_5'].rotation_euler[0]=0.28*ph; arm.pose.bones['bone_6'].rotation_euler[0]=-0.28*ph
|
|
arm.pose.bones['bone_3'].rotation_euler[0]=-0.20*ph; arm.pose.bones['bone_4'].rotation_euler[0]=0.20*ph
|
|
arm.pose.bones['bone_0'].location[1]=0.012*(1-math.cos(2*math.pi*t/seconds))
|
|
else:
|
|
u=max(0,min(1,t/seconds)); strike=math.sin(math.pi*u)**2
|
|
arm.pose.bones['bone_3'].rotation_euler[0]=math.radians(-55)*strike; arm.pose.bones['bone_4'].rotation_euler[0]=math.radians(-55)*strike
|
|
arm.pose.bones['bone_3'].location[2]=-0.025*strike; arm.pose.bones['bone_4'].location[2]=-0.025*strike
|
|
arm.pose.bones['bone_7'].rotation_euler[2]=0.18*strike
|
|
for b in arm.pose.bones:
|
|
b.keyframe_insert('location',frame=f); b.keyframe_insert('rotation_euler',frame=f); b.keyframe_insert('scale',frame=f)
|
|
act.frame_start=0; act.frame_end=frames[-1]
|
|
return act
|
|
for name in ('Idle','Walk','Attack'):
|
|
clear_actions(); act=pose_action(name,2.0); export(OUT/'motions'/name/'animated.fbx',[arm],anim=True); act=None
|
|
# static export by remove armature modifiers and armature
|
|
for o in parts:
|
|
if o.type=='MESH':
|
|
for m in list(o.modifiers): o.modifiers.remove(m)
|
|
arm.hide_render=True; arm.hide_viewport=True
|
|
export(ASSET/'BudBud.fbx',parts,anim=False)
|
|
# also copy rigged into output source location handled above
|
|
# preview render
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for o in parts:o.select_set(True)
|
|
# camera
|
|
bpy.ops.object.camera_add(location=(0,0.48,-2.0), rotation=(math.radians(82),0,math.radians(28)))
|
|
cam=bpy.context.object; bpy.context.scene.camera=cam
|
|
# point camera
|
|
def look_at(obj, target): obj.rotation_euler=(Vector(target)-obj.location).to_track_quat('-Z','Y').to_euler()
|
|
look_at(cam,(0,0.40,0)); cam.data.lens=58
|
|
bpy.ops.object.light_add(type='AREA', location=(-1.2,1.4,-1.3)); key=bpy.context.object; key.data.energy=650; key.data.shape='DISK'; key.data.size=2.2; look_at(key,(0,.4,0))
|
|
bpy.ops.object.light_add(type='AREA', location=(1.3,.7,.8)); fill=bpy.context.object; fill.data.energy=350; fill.data.size=1.8; look_at(fill,(0,.4,0))
|
|
scene=bpy.context.scene; scene.render.engine='BLENDER_EEVEE'; scene.render.resolution_x=640; scene.render.resolution_y=640; scene.render.resolution_percentage=100
|
|
scene.render.image_settings.file_format='PNG'; scene.render.filepath=str(OUT/'BudBud_preview.png'); scene.world.color=(0.92,0.95,0.96)
|
|
bpy.ops.render.render(write_still=True)
|
|
print('BUDBUD_EXPORT_OK')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|