Files
AIGame/Tools/create_budbud_assets.py
udxandClaude Opus 5.5 89e0fa246f Add formal enemies, remaining formal partners and synthesized UI SFX
- 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>
2026-10-21 12:23:55 +08:00

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')