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>
This commit is contained in:
udx
2026-10-21 12:23:55 +08:00
co-authored by Claude Opus 5.5
parent 6c64210331
commit 89e0fa246f
780 changed files with 7117051 additions and 15906 deletions
+17
View File
@@ -0,0 +1,17 @@
import bpy
from pathlib import Path
p=Path('Tools/output/companions/BudBud/motions/Walk/animated.fbx')
bpy.ops.wm.read_factory_settings(use_empty=True); bpy.ops.import_scene.fbx(filepath=str(p),use_anim=True)
for o in bpy.context.scene.objects:
if o.type=='ARMATURE':
print('arm',o.name,'frame',bpy.context.scene.frame_start,bpy.context.scene.frame_end,'anim',bool(o.animation_data))
a=o.animation_data.action if o.animation_data else None
print('action',a,a.layers if a else None)
if a:
for l in a.layers:
print('layer',l.name,'strips',len(l.strips))
for s in l.strips:
print('strip',type(s),dir(s)[:20])
cb=s.channelbag(0) if hasattr(s,'channelbag') else None
print('cb',cb, 'fcurves',len(cb.fcurves) if cb else None)
+9
View File
@@ -0,0 +1,9 @@
import bpy,sys
from pathlib import Path
for fp in [Path('AIClient/Assets/Bundles/Characters/Animal/BudBud/BudBud.fbx'),Path('Tools/output/companions/BudBud/BudBud_Rigged.fbx')]:
bpy.ops.wm.read_factory_settings(use_empty=True); bpy.ops.import_scene.fbx(filepath=str(fp))
vs=[]
for o in bpy.context.scene.objects:
if o.type=='MESH': vs.extend([o.matrix_world@v.co for v in o.data.vertices])
mn=[min(v[i] for v in vs) for i in range(3)]; mx=[max(v[i] for v in vs) for i in range(3)]
print(fp.name,'min',*[round(x,5) for x in mn],'max',*[round(x,5) for x in mx],'height',round(mx[1]-mn[1],5),'center_xz',round((mn[0]+mx[0])/2,5),round((mn[2]+mx[2])/2,5))
+13
View File
@@ -0,0 +1,13 @@
import bpy,sys,os,json
from pathlib import Path
p=Path(sys.argv[-1]); bpy.ops.wm.open_mainfile(filepath='') if False else None
# importer each fbx
for f in [p/'BudBud_Rigged.fbx', Path('AIClient/Assets/Bundles/Characters/Animal/BudBud/BudBud.fbx')]+[p/'motions'/n/'animated.fbx' for n in ('Idle','Walk','Attack')]:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(f),use_anim=True)
objs=list(bpy.context.scene.objects); tris=0; bones=[]; acts=[]
for o in objs:
if o.type=='MESH': tris += sum(len(poly.vertices)-2 for poly in o.data.polygons)
if o.type=='ARMATURE': bones += [b.name for b in o.data.bones]
print(f.name,'objects',len(objs),'tris',tris,'bones',len(bones),'bone_names',bones[:10])
print('actions',[(a.name,a.frame_start,a.frame_end) for a in bpy.data.actions])
+126
View File
@@ -0,0 +1,126 @@
"""Build isolated skill-system design examples; never edits live gameplay config."""
from pathlib import Path
import hashlib
import json
ROOT=Path(__file__).resolve().parents[1]
def write(path,data):
target=ROOT/path;target.parent.mkdir(parents=True,exist_ok=True)
target.write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
def build():
source=ROOT/'Config/Gameplay/greenfield.json'
gameplay=json.loads(source.read_text(encoding='utf-8-sig'))
def template(id,selector,delivery,effect,release,recovery,parameters):
if effect=='damage':action=dict(id='main',trigger='delivery_hit',type='damage',amount='attack_scaled',scale='ability_value' if id.startswith('ability_') else 'one')
elif effect=='heal':action=dict(id='main',trigger='delivery_hit',type='heal',amount='ability_value')
else:action=dict(id='main',trigger='delivery_hit',type='apply_status',statusId=effect+'_main',value='ability_value',duration='ability_duration')
return dict(id=id,selector=selector,delivery=delivery,actions=[action],
timing=dict(releaseTicks=release,recoveryTicks=recovery),parameters=parameters,
targetLost='expire',channel='main_action',snapshot='caster_on_start_target_on_hit')
basic=dict(cooldown='basic_cooldown',range='basic_range',amount='actor_attack')
special=dict(cooldown='ability_cooldown',range='ability_range',amount='ability_value',targetCount='ability_target_count')
templates=[
template('basic_melee','front_enemy_same_lane','instant','damage',9,11,basic),
template('basic_ranged','front_enemy_same_lane','projectile','damage',6,6,basic),
template('ability_burst','front_enemy_same_lane','projectile','damage',8,8,special),
template('ability_chain','front_enemy_same_lane','instant','damage',8,8,special),
template('ability_heal','lowest_hp_ratio_ally','instant','heal',8,8,{**special,'radius':'ability_radius'}),
template('ability_shield','self','instant','shield',0,8,{**special,'duration':'ability_duration'}),
template('ability_slow','front_enemy_same_lane','instant','slow',8,8,{**special,'duration':'ability_duration','bossScale':'boss_control_scale'}),
template('enemy_melee','front_blocker_same_lane','instant','damage',6,6,basic),
]
skills=[]
for p in gameplay['pets']:
for slot in ('basic','ability'):
kind=('basic_ranged' if p['range']>2 else 'basic_melee') if slot=='basic' else 'ability_'+p['ability']['kind']
presentation='prototype_'+kind
if p['id']==1:presentation='firecat_'+slot
if p['id']==0:presentation='rockball_'+slot
row=dict(id=f"pet.{p['id']}.{slot}",actorKind='pet',actorId=p['id'],slot=slot,
sourcePointer='/pets/'+str(p['id']),template=kind,presentation=presentation)
if p['id']==1:row['timing']=dict(releaseTicks=9 if slot=='basic' else 11,recoveryTicks=9 if slot=='basic' else 11)
skills.append(row)
for e in gameplay['enemies']:
skills.append(dict(id=f"enemy.{e['id']}.basic",actorKind='enemy',actorId=e['id'],slot='basic',
sourcePointer='/enemies/'+str(e['id']),template='enemy_melee',presentation='prototype_enemy_melee'))
plan=dict(schemaVersion=1,status='design_only',runtimeEnabled=False,proposedBattleRules=3,
source=dict(path='Config/Gameplay/greenfield.json',sha256AtAuthoring=hashlib.sha256(source.read_bytes()).hexdigest(),
values='resolve_from_frozen_gameplay_snapshot'),
clock=dict(tickMs=50,cooldownRounding='ceil',simulationUsesWallClock=False),
numeric=dict(hpScale=1000,positionScale=1000,factorScale=10000,rounding='explicit_integer_rules_v1'),
scheduling=dict(channel='main_action',priorities=dict(ability=100,basic=10),
cooldownStarts='cast_start',cancelRefund='none',initialCooldownTicks=0,
interruptBeforeRelease=['death','hard_control'],releasedProjectileSurvivesCaster=True),
projectile=dict(speedMmPerTick=800,maxLifetimeTicks=40,arrival='ceil_distance_at_release',
targetLost='expire',retarget=False,collisionAuthority='simulation'),
statuses=[dict(id='shield_main',merge='max_remaining_replace_expiry',value='flat_hp'),
dict(id='slow_main',merge='min_factor_max_expiry',value='remaining_speed_factor')],
templates=templates,skills=skills)
write('Config/Combat/skill_system_v1.proposal.json',plan)
assets=[]
def asset(id,type,path,status='planned'):
assets.append(dict(id=id,type=type,path=path,status=status))
for name,seconds,mark in [('FireCat',1.1,.5),('RockBall',1,.45)]:
asset('anim.'+name.lower()+'.attack','animation',f'Assets/Bundles/Characters/Animal/{name}/Clips/Attack.anim','existing')
assets[-1].update(clipSeconds=seconds,releaseNormalized=mark)
for name in ['FireCharge','FireProjectile','FireHit','RockHit','Heal','Shield','Slow','Lightning','EnemyHit']:
asset('vfx.'+name,'vfx',f'Assets/Bundles/Effects/BattleSkills/{name}/{name}.prefab')
if name!='FireProjectile':asset('sfx.'+name,'sfx',f'Assets/Bundles/Sound/SFX/BattleSkills/Sfx_{name}.wav')
def cue(kind,asset,anchor,**kwargs):return dict(kind=kind,asset=asset,anchor=anchor,**kwargs)
def profile(id,element,animation=None,fire=False):
hits=[cue('vfx','vfx.'+element,'target_hit',when='valid_hit'),cue('sfx','sfx.'+element,'target_hit',when='valid_hit')]
bindings={'HitResolved':hits}
if animation:bindings['CastStarted']=[cue('animation',animation,'model',fit='cast_duration',rootMotion=False)]
if fire:
bindings.setdefault('CastStarted',[]).extend([cue('vfx','vfx.FireCharge','cast',token='charge'),cue('sfx','sfx.FireCharge','cast',token='charge')])
bindings['CastReleased']=[dict(kind='stop_token',token='charge'),cue('sfx','sfx.FireLaunch','cast')]
bindings['ProjectileSpawned']=[cue('vfx','vfx.FireProjectile','projectile',token='projectile')]
return dict(id=id,bindings=bindings,cancelStops=['charge'],endStops=['charge'],
resourceFallback='prototype',seekReplaysOneShots=False)
asset('sfx.FireLaunch','sfx','Assets/Bundles/Sound/SFX/BattleSkills/Sfx_FireLaunch.wav')
elements={'basic_melee':'RockHit','basic_ranged':'EnemyHit','ability_burst':'FireHit','ability_chain':'Lightning',
'ability_heal':'Heal','ability_shield':'Shield','ability_slow':'Slow','enemy_melee':'EnemyHit'}
profiles=[profile('prototype_'+k,v) for k,v in elements.items()]
profiles.extend([profile('firecat_basic','FireHit','anim.firecat.attack',True),
profile('firecat_ability','FireHit','anim.firecat.attack',True),
profile('rockball_basic','RockHit','anim.rockball.attack'),profile('rockball_ability','Shield')])
for p in profiles:
if 'shield' in p['id'] or p['id']=='rockball_ability':
p['bindings']={'StatusApplied':[cue('vfx','vfx.Shield','body',token='status'),cue('sfx','sfx.Shield','body')]}
view=dict(schemaVersion=1,status='design_only',runtimeEnabled=False,
clock='battle_playback_clock',eventDedup=['battleId','eventSeq','cueIndex'],
anchors=['model','cast','body','target_hit','projectile'],
anchorPolicy=dict(configuredSocketFirst=True,fallback='bounds_based',localForward='-Z',worldFacing='source_to_target'),
audio=dict(bus='BattleSfx',initialVoiceCap=12,mutedStillConsumesEvent=True,
pause='pause_owned_sources',speed='keep_pitch_drop_expired_one_shots'),
lifecycle=dict(statusStop='StatusRemoved',projectileStop='ProjectileExpired_or_HitResolved',battleStop='BattleEnded'),
assets=assets,profiles=profiles)
write('Config/Combat/skill_presentations_v1.proposal.json',view)
prompts={
'FireCharge':'A short gentle magical flame gathering sound, soft rising airy crackle, cute fantasy cat casting, close and dry, one isolated event, no music, no speech',
'FireLaunch':'One short compact magical fireball launch, bright soft ignition and a quick airy whoosh, cute mobile fantasy game, no explosion, no music, no speech',
'FireHit':'One small magical fireball impact, crisp warm pop with a brief soft fiery crackle tail, friendly cartoon fantasy, no heavy bass, no music, no speech',
'RockHit':'One small rounded stone creature striking, compact stone knock and a little gravel scatter, friendly cartoon fantasy game, no music, no speech',
'Heal':'One gentle magical healing chime, warm bell sparkle with a short soft airy tail, friendly pastoral fantasy game, no music bed, no speech',
'Shield':'One soft magical stone shield forming, low rounded stone resonance with a tiny clear sparkle, short dry fantasy game effect, no music, no speech',
'Slow':'One gentle icy magical pulse, light crystalline tick with a soft watery freeze texture, short isolated cartoon game effect, no music, no speech',
'Lightning':'One short small magical electric zap, crisp bright crackle and a soft pop, friendly cartoon fantasy, no harsh buzz, no music, no speech',
'EnemyHit':'One short soft cartoon creature hit, compact muted thump, dry isolated mobile fantasy game effect, no voice, no music, no speech',
}
sound=dict(schemaVersion=1,status='planned_not_generated',batchId='battle_skills_v1',
service='http://127.0.0.1:8012',guide='D:/UD/AI/XModel/TangoFlux_AI调用指南.md',
designDir='Doc/Design/Sound/BattleSkills',outputDir='Tools/output/battle_skills_sfx_v1',
bundleDir='AIClient/Assets/Bundles/Sound/SFX/BattleSkills',
defaults=dict(duration=2,steps=50,guidance_scale=4.5),maxInflight=2,
sourceFormat=dict(container='wav',sampleRate=44100,channels=2),
releaseLicenseReview='pending_upstream_review_per_guide',
items=[dict(id='Sfx_'+name,seed=52001+i,prompt=prompt,duration=2) for i,(name,prompt) in enumerate(prompts.items())])
write('Doc/Design/Sound/BattleSkills/manifest.json',sound)
return plan,view,sound
if __name__=='__main__':
p,v,s=build();print(f"Wrote design examples: {len(p['skills'])} skill bindings, {len(v['profiles'])} presentation profiles, {len(s['items'])} planned SFX")
+166
View File
@@ -0,0 +1,166 @@
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')
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""G3 economy samples from Config/Gameplay/greenfield.json.
Three trajectories from GameDesign 8.2: first day, seven logins, and stuck before a boss.
Ignores natural marble recovery. Pinball candy/coins use an equal chance across the five bins.
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ROOT / "Config/Gameplay/greenfield.json"
OUT_MD = ROOT / "Doc/Design/G3/EconomySample.md"
OUT_JSON = ROOT / "Doc/Design/G3/economy_sample.json"
# GameDesign 8.2, aligned to the live first-five clear and pinball means.
DESIGN_DAY1_BALLS = 67
DESIGN_TEN_SHOTS = {"candy": 200, "coins": 520, "tickets": 1}
def reward_of(node: dict) -> dict:
src = node.get("reward") or node.get("contents") or {}
return {k: int(src.get(k, 0) or 0) for k in ("coins", "candy", "balls", "tickets")}
def add(total: dict, part: dict, times: int = 1) -> None:
for k in total:
total[k] += part.get(k, 0) * times
def line(name: str, part: dict, times: int = 1) -> str:
shown = {k: part.get(k, 0) * times for k in ("coins", "candy", "balls", "tickets") if part.get(k, 0)}
body = "、".join(f"{label}{shown[key]}" for key, label in (
("balls", "弹珠"), ("candy", "糖果"), ("coins", "金币"), ("tickets", "券")
) if key in shown) or "无资源"
suffix = f" ×{times}" if times > 1 else ""
return f"| {name}{suffix} | {body} |"
def main() -> None:
cfg = json.loads(CONFIG.read_text(encoding="utf-8"))
economy = cfg["economy"]
stages = cfg["stages"]
pinball = cfg["pinball"]
candy_mean = sum(pinball["candy"]) / len(pinball["candy"])
coin_mean = sum(pinball["coins"]) / len(pinball["coins"])
ten = {
"candy": int(round(candy_mean * 10)),
"coins": int(round(coin_mean * 10)),
"tickets": 10 // economy["ticketEvery"],
}
train_to = lambda level: economy["trainCost"] * sum(range(1, level))
zero = {"coins": 0, "candy": 0, "balls": 0, "tickets": 0}
day1 = dict(zero)
rows = []
def take(name: str, part: dict, times: int = 1) -> None:
add(day1, part, times)
rows.append(line(name, part, times))
take("初始弹珠", {"balls": economy["initialBalls"]})
take("欢迎邮件", reward_of(cfg["mail"]["initial"][0]))
take("七日签到第1天", reward_of({"reward": cfg["daily"]["signin"][0]}))
for task in cfg["daily"]["tasks"]:
take(task["title"], reward_of(task))
take(cfg["shop"]["goods"][0]["name"], reward_of(cfg["shop"]["goods"][0]))
take("教学目标(通过1-1)", {"balls": economy["goalBalls"]})
first5 = [s for s in stages if s["id"] <= 5]
for stage in first5:
take(f"首通 {stage['code']}", reward_of(stage))
chest_by_stage = {d["stageId"]: cfg["rewards"]["chests"][d["chest"]] for d in cfg["rewards"]["stageDrops"]}
for stage_id in (1, 3):
chest = chest_by_stage[stage_id]
take(f"打开{chest['name']}({stages[stage_id - 1]['code']}掉落)", reward_of(chest))
design_stage_balls = 17
config_stage_balls = sum(s["reward"]["balls"] for s in first5)
design_matched = (
economy["initialBalls"] + cfg["daily"]["signin"][0]["balls"]
+ sum(t["reward"]["balls"] for t in cfg["daily"]["tasks"])
+ cfg["shop"]["goods"][0]["reward"]["balls"] + design_stage_balls
)
week = dict(zero)
week_rows = []
signin = {"balls": sum(d["balls"] for d in cfg["daily"]["signin"])}
tasks = {"balls": 7 * sum(t["reward"]["balls"] for t in cfg["daily"]["tasks"])}
shop = {"balls": 7 * cfg["shop"]["goods"][0]["reward"]["balls"]}
for name, part in (("七日签到全部领取", signin), ("七日每日任务全部领取", tasks), ("七日免费补给", shop)):
add(week, part)
week_rows.append(line(name, part))
stuck = dict(zero)
stuck_rows = []
def stuck_take(name: str, part: dict, times: int = 1) -> None:
add(stuck, part, times)
stuck_rows.append(line(name, part, times))
stuck_take("初始弹珠", {"balls": economy["initialBalls"]})
stuck_take("欢迎邮件", reward_of(cfg["mail"]["initial"][0]))
stuck_take("签到第1天", reward_of({"reward": cfg["daily"]["signin"][0]}))
for task in cfg["daily"]["tasks"]:
stuck_take(task["title"], reward_of(task))
stuck_take("免费补给", reward_of(cfg["shop"]["goods"][0]))
stuck_take("教学目标", {"balls": economy["goalBalls"]})
for stage in stages:
if stage["id"] >= 5:
break
stuck_take(f"首通 {stage['code']}", reward_of(stage))
stuck_take("打开绿野补给箱", reward_of(chest_by_stage[1]))
need_candy = train_to(5)
need_coins = economy["evolveCoins"]
candy_gap = max(0, need_candy - stuck["candy"])
coin_gap = max(0, need_coins - stuck["coins"])
shots_for_candy = (candy_gap + candy_mean - 1) // candy_mean if candy_mean else 0
shots_for_coins = (coin_gap + coin_mean - 1) // coin_mean if coin_mean else 0
shard_from_chest = 10
shard_gap = economy["evolveShards"] - shard_from_chest
report = {
"config": str(CONFIG.relative_to(ROOT)).replace("\\", "/"),
"ignored": ["natural marble recovery", "pinball path variance beyond bin average"],
"pinballAverage": {"candy": candy_mean, "coins": coin_mean, "perTenShots": ten},
"designDay1Balls": DESIGN_DAY1_BALLS,
"designFormulaWithLiveNonStageSources": design_matched,
"configFirstFiveStageBalls": config_stage_balls,
"day1": day1,
"sevenDaysWithoutClears": week,
"stuckBefore1_5": {
"walletBeforePinball": stuck,
"trainToLevel5Candy": need_candy,
"candyGap": candy_gap,
"pinballShotsToCloseCandy": int(shots_for_candy),
"evolveCoins": need_coins,
"coinGap": coin_gap,
"pinballShotsToCloseCoins": int(shots_for_coins),
"evolveShards": economy["evolveShards"],
"shardsFromChoiceChest": shard_from_chest,
"shardGap": shard_gap,
},
}
OUT_JSON.parent.mkdir(parents=True, exist_ok=True)
OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
def wallet(w: dict) -> str:
return f"弹珠 {w['balls']}、糖果 {w['candy']}、金币 {w['coins']}、捕捉券 {w['tickets']}"
md = f"""# G3 经济样例
由 `Tools/economy_sample.py` 读取 `Config/Gameplay/greenfield.json` 生成。不计弹珠自然恢复。弹珠台按五个落区等概率取均值:糖果 {candy_mean:g}、金币 {coin_mean:g}。实际单次受路径影响。
## 对照设计稿
`GameDesign` 8.2 的首日上限是 **{DESIGN_DAY1_BALLS}** 枚弹珠:初始 20、签到 10、每日任务 15、免费补给 5、前五关首通 {design_stage_balls}。按同一口径合计得到 **{design_matched}**。现行 1-1~1-5 首通弹珠合计是 **{config_stage_balls}**,与稿一致。
稿中「10 发预计糖果 200、金币 520、券 1」。现行均值为糖果 {ten['candy']}、金币 {ten['coins']}、券 {ten['tickets']},与稿一致。
## 首日获取上限
假设当天领完邮件、签到、三个每日任务、免费补给和教学目标,打通 1-1~1-5 并打开已掉落的绿野补给箱与碎片自选箱。自选箱只给碎片,不进下表钱包。溪口大礼箱要先通过 1-5,打开后另有金币 150、糖果 80、弹珠 5、券 1,未计入本表。
| 来源 | 到账 |
| --- | --- |
{chr(10).join(rows)}
合计:**{wallet(day1)}**。这是获取上限,不是要求当天花完。
## 七日回访
不打新关、不发射。每日未领奖励过期,下表按七天都当天领完。签到不循环。
| 来源 | 到账 |
| --- | --- |
{chr(10).join(week_rows)}
合计:**{wallet(week)}**。
## 卡在 1-5 之前
已通过 1-1~1-4,打不过溪口大角王。当天可领来源如下,不含为了拿糖果而发射的弹珠台产出。
| 来源 | 到账 |
| --- | --- |
{chr(10).join(stuck_rows)}
合计:**{wallet(stuck)}**。
- 训练到建议 Lv.5 需要糖果 {need_candy}。账上差 {candy_gap},按均值再发射 {int(shots_for_candy)} 次可补上。主线不另扣体力。
- 进化需要金币 {need_coins}。账上差 {coin_gap},按均值再发射 {int(shots_for_coins)} 次可补上。通过 1-1 后商店每天还能用金币换糖果,那是把金币换成糖果,不增加总资源。
- 进化需要碎片 {economy['evolveShards']}。1-3 自选箱提供 {shard_from_chest},还差 {shard_gap}。重复伙伴每次 {economy['duplicateShards']} 片,不能把碎片换回捕捉券。
训练、进化和再抽一只伙伴都能从免费来源走到,碎片比糖果和金币更慢。
"""
OUT_MD.write_text(md, encoding="utf-8")
print(json.dumps({"day1": day1, "week": week, "stuck": stuck, "tenShots": ten}, ensure_ascii=False))
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""Join an enemy's static_audit front/side/back/perspective captures into sheet.png for review."""
import sys
from pathlib import Path
from PIL import Image
base = Path(__file__).resolve().parents[1] / "Tools/output"
d = next(p for p in (base / "enemy_characters" / sys.argv[1] / "static_audit", base / "companions" / sys.argv[1] / "static_audit") if p.exists())
ims = [Image.open(d / f"{v}.png").convert("RGB") for v in ("front", "side", "back", "perspective")]
ims = [i.resize((int(i.width * 300 / i.height), 300)) for i in ims]
s = Image.new("RGB", (sum(i.width for i in ims), 300), "white"); x = 0
for i in ims: s.paste(i, (x, 0)); x += i.width
s.save(d / "sheet.png"); print(d / "sheet.png")
+68 -8
View File
@@ -31,20 +31,67 @@ def recipe(path):
r=json.loads(path.read_text(encoding='utf-8-sig'))
if r.get('schemaVersion')!=1 or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*',r['id']):
raise ValueError('Unsupported schema / invalid actor id')
if r['profile']!='biped_short':
raise ValueError('This offline motion adapter supports biped_short. Add and validate a different anatomy adapter before using it.')
if r['profile'] not in ('biped_short','avian_grounded','quadruped_turtle','quadruped_heavy'):
raise ValueError('Unsupported anatomy. Add and validate a different anatomy adapter before using it.')
r.setdefault('usage','enemy_visual')
if r['usage'] not in ('enemy_visual','companion_visual'):
raise ValueError('Unsupported character usage')
r.setdefault('visualPrefab',r.get('enemyPrefab',''))
r.setdefault('attackStyle','body_slam')
if r['attackStyle'] not in ('body_slam','ranged_cast'):
if r['attackStyle'] not in ('body_slam','ranged_cast','wing_cast','turtle_bubble','braced_bash','flame_roar'):
raise ValueError('Unsupported attack style')
if (r['profile']=='avian_grounded') != (r['attackStyle']=='wing_cast'):
raise ValueError('Grounded birds require wing_cast; wing_cast requires avian_grounded')
if (r['profile']=='quadruped_turtle') != (r['attackStyle']=='turtle_bubble'):
raise ValueError('Turtles require their own four-legged bubble adapter')
if (r['profile']=='quadruped_heavy') != (r['attackStyle']=='braced_bash'):
raise ValueError('Heavy quadrupeds require their own braced bash adapter')
r.setdefault('attackEvent','OnCompanionAttackImpact' if r['usage']=='companion_visual' else 'OnEnemyAttackImpact')
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*',r['attackEvent']):
raise ValueError('Invalid attack event')
if r.get('boneMappingReady') is False:
raise ValueError('Inspect this character\'s generated rig and complete its bone mapping before production')
if r['attackStyle']=='flame_roar':
required=('rootBone','chestBone','neckBone','headBone','rightArm','leftArm','rightPawTip','leftPawTip',
'rightFoot','leftFoot','rightFootTip','leftFootTip','faceRight','faceLeft')
if r['profile']!='biped_short' or any(not re.fullmatch(r'bone_\d+',r.get(k,'')) for k in required):
raise ValueError('Flame roar requires this biped\'s own head, neck, two arms/paws and feet')
for right,left in (('rightArm','leftArm'),('rightPawTip','leftPawTip'),('rightFoot','leftFoot'),('rightFootTip','leftFootTip'),('faceRight','faceLeft'),('headBone','chestBone')):
if r[right]==r[left]:raise ValueError('Flame roar requires independent head/body and left/right mappings')
lo,hi=r.get('maneRegionMin',[]),r.get('maneRegionMax',[])
if len(lo)!=3 or len(hi)!=3 or any(a>=b for a,b in zip(lo,hi)) or not 0<r.get('maneRigidityTolerance',0)<=.002:
raise ValueError('Inspect the mane geometry region and rigidity budget')
if not (0<r.get('attackHeadReach',0)<r['height']*.1 and 0<r.get('attackHeadPitchDegrees',0)<=15 and 0<r.get('attackSpreadDegrees',0)<=45):
raise ValueError('Invalid flame roar head reach, pitch or paw spread')
if r['profile'] in ('quadruped_turtle','quadruped_heavy'):
feet=('frontRightFoot','frontLeftFoot','hindRightFoot','hindLeftFoot')
tips=tuple(k+'Tip' for k in feet)
body='shellBone' if r['profile']=='quadruped_turtle' else 'chestBone'
if any(not re.fullmatch(r'bone_\d+',r.get(k,'')) for k in ('rootBone',body,'neckBone','headBone','faceRight','faceLeft',*feet,*tips)):
raise ValueError('Map this quadruped\'s own body, head/neck and four paws')
if len({r[k] for k in feet})!=4 or len({r[k] for k in tips})!=4 or r['faceRight']==r['faceLeft']:
raise ValueError('Four separate paw mappings and distinct facing references required')
if r.get('rootTiltReference')!='bind_up' or not 0<r.get('walkSwingFraction',0)<=.25:
raise ValueError('Quadrupeds require bind-up orientation and a four-beat support gait')
if not 0<r.get('attackHeadReach',0)<r['height']*.15:
raise ValueError('Invalid quadruped head reach')
regions=('shell',) if r['profile']=='quadruped_turtle' else ('armor','horn')
for region in regions:
lo,hi=r.get(region+'RegionMin',[]),r.get(region+'RegionMax',[])
if len(lo)!=3 or len(hi)!=3 or any(a>=b for a,b in zip(lo,hi)) or not 0<r.get(region+'RigidityTolerance',0)<=.002:
raise ValueError('Inspected '+region+' geometry region and rigidity budget required')
if r['profile']=='quadruped_heavy' and (r['chestBone']==r['headBone'] or not 0<r.get('attackLunge',0)<r['height']*.12 or not 0<r.get('attackHeadPitchDegrees',0)<=20):
raise ValueError('Heavy bash requires independent head/chest, a short shoulder lunge and controlled head pitch')
if r['profile']=='avian_grounded':
required=('rootBone','chestBone','rightArm','leftArm','rightWingTip','leftWingTip',
'rightFoot','leftFoot','rightFootTip','leftFootTip','faceRight','faceLeft')
if any(not re.fullmatch(r'bone_\d+',r.get(k,'')) for k in required):
raise ValueError('Inspect and map this bird\'s own wings, wing tips and feet')
for right,left in (('rightArm','leftArm'),('rightWingTip','leftWingTip'),
('rightFoot','leftFoot'),('rightFootTip','leftFootTip'),('faceRight','faceLeft')):
if r[right]==r[left]:raise ValueError('Bird left/right mappings must be distinct')
if not 0<r.get('wingSpreadDegrees',0)<=60:
raise ValueError('Bird wingSpreadDegrees must be in (0,60]')
if r['assetRoot']!=f"Assets/Bundles/Characters/{r['kind']}/{r['id']}":
raise ValueError('assetRoot must match kind/id')
for key in ('assetRoot','visualPrefab','staticPrefab','reviewScene'):
@@ -69,10 +116,19 @@ def recipe(path):
return r
def unity(command,**kwargs):
args=['unity','--json','command','--project-path',str(PROJECT),command]
args=['unity','--json','command','--project-path',str(PROJECT)]
if command=='run_script':
# Import/refresh and ephemeral compilation can delay dispatch in a live
# Editor. Bound both server dispatch and CLI response, without retrying
# a mutation whose outcome is unknown.
# AIGAME_RUN_SCRIPT_TIMEOUT_MS raises the window for heavy QA (31-bone quadruped skin sampling took >90s).
server_ms=int(os.environ.get('AIGAME_RUN_SCRIPT_TIMEOUT_MS','90000'))
args.extend(['--timeout',str(server_ms//1000+30)])
kwargs.setdefault('timeout_ms',server_ms)
args.append(command)
for k,v in kwargs.items():
args.extend(['--'+k, json.dumps(v,ensure_ascii=False) if isinstance(v,(list,dict,bool)) else str(v)])
proc=subprocess.run(args,cwd=REPO,capture_output=True,text=True,encoding='utf-8',errors='replace',timeout=180)
proc=subprocess.run(args,cwd=REPO,capture_output=True,text=True,encoding='utf-8',errors='replace',timeout=int(os.environ.get('AIGAME_RUN_SCRIPT_TIMEOUT_MS','90000'))//1000+90)
if proc.returncode:
raise RuntimeError(proc.stderr or proc.stdout)
data=json.loads(proc.stdout)
@@ -156,10 +212,14 @@ def main():
for name,record in sources.items():
unity('import_asset',source=record['source'],path=r['assetRoot']+'/Anims/'+r['id']+'_'+name+'.fbx',confirm=True)
script('SetupAnimalAnimator','SetupAnimalAnimator.Run',[r['kind'],r['id'],r['height'],False])
script('FinalizeAnimalWalk','FinalizeAnimalWalk.RunFor',[r['assetRoot'],r['id'],r['height'],r['rootBone'],r['rightFoot'],r['leftFoot'],r['walkStride'],r['walkLift'],r['walkSeconds'],r['outputDir'],r.get('walkBindCalibration',False),r.get('walkSourceWeight',1)])
relative=recipe_path.relative_to(REPO).as_posix()
if r['profile'] in ('quadruped_turtle','quadruped_heavy'):
script('FinalizeAnimalWalk','FinalizeAnimalWalk.RunQuadruped',[relative])
else:
script('FinalizeAnimalWalk','FinalizeAnimalWalk.RunFor',[r['assetRoot'],r['id'],r['height'],r['rootBone'],r['rightFoot'],r['leftFoot'],r['walkStride'],r['walkLift'],r['walkSeconds'],r['outputDir'],r.get('walkBindCalibration',False),r.get('walkSourceWeight',1)])
script('FinalizeEnemyMotions','FinalizeEnemyMotions.Run',[relative])
script('VerifyEnemyMotions','VerifyEnemyMotions.Run',[relative])
verifier='VerifyQuadrupedMotions' if r['profile'] in ('quadruped_turtle','quadruped_heavy') else 'VerifyEnemyMotions'
script(verifier,verifier+'.Run',[relative])
script('BuildEnemyReview','BuildEnemyReview.Publish',[relative])
if args.build_review:
script('BuildEnemyReview','BuildEnemyReview.Build',[relative])
@@ -169,7 +229,7 @@ def main():
unity('editor_play')
try:
wait_editor('playing')
live_result=script('VerifyEnemyMotions','VerifyEnemyMotions.Live',[relative])['result']
live_result=script(verifier,verifier+'.Live',[relative])['result']
prefix='CompanionReview_'+r['id'] if r['usage']=='companion_visual' else 'EnemyReview'
role='companion' if r['usage']=='companion_visual' else 'enemy'
unity('capture_game_view',camera=prefix+'Camera',width=1900,height=760,
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Contact sheet of enemy concept drafts (rows = enemies, columns = seeds) for picking: Doc/Design/Character/Enemy/drafts_sheet.png"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
DESIGN = ROOT / "Doc/Design/Character/Enemy"
ORDER = ["Hornlet", "Dashlet", "Shellhorn", "HornCaptain", "GreatHorn"]
CELL, LABEL = 320, 28
rows = [(n, sorted((DESIGN / n / "drafts").glob("*.png"))) for n in ORDER if (DESIGN / n / "drafts").exists()]
cols = max(len(f) for _, f in rows)
sheet = Image.new("RGB", (cols * CELL, len(rows) * (CELL + LABEL)), "white")
draw = ImageDraw.Draw(sheet)
font = ImageFont.load_default()
for r, (name, files) in enumerate(rows):
for c, f in enumerate(files):
im = Image.open(f).convert("RGB"); im.thumbnail((CELL, CELL))
x, y = c * CELL, r * (CELL + LABEL)
sheet.paste(im, (x, y + LABEL))
draw.text((x + 6, y + 8), f"{name} #{c + 1} ({f.stem.split('_')[-1]})", fill="black", font=font)
sheet.save(DESIGN / "drafts_sheet.png")
print(DESIGN / "drafts_sheet.png")
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""P0 enemy (or companion, --kind Animal) static stage: Hunyuan high GLB -> 2000-tri XPbr bake -> Bundles/Characters/Enemy/{Name} -> static Prefab -> static audit.
Usage:
python Tools/enemy_static_pipeline.py Dashlet 0.5 # bake + install + SetupCharacter + AuditCharacterVisual
python Tools/enemy_static_pipeline.py Dashlet 0.5 --source Tools/output/enemy_3d/Dashlet/dashlet_forward.glb
The high model must already face -Y in Blender (check with renders first; rotate with model_tools/bl_orient_character.py).
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PROJECT = ROOT / "AIClient"
def run(cmd: list[str], cwd: Path = ROOT) -> str:
print("$", " ".join(cmd), flush=True)
done = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace")
if done.returncode != 0:
raise SystemExit(f"failed ({done.returncode}): {' '.join(cmd)}\n{done.stdout[-2000:]}\n{done.stderr[-2000:]}")
return done.stdout
def unity(file: str, entry: str, args: list) -> str:
out = run(["unity", "command", "run_script", "--project-path", str(PROJECT), "--file", file, "--entry", entry,
"--args", json.dumps(args), "--timeout_ms", "90000", "--timeout", "120"], PROJECT)
if '"success":true' not in out.replace(" ", ""):
raise SystemExit(f"Unity {entry} failed:\n{out[-2000:]}")
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("name")
ap.add_argument("height", type=float)
ap.add_argument("--source", type=Path)
ap.add_argument("--kind", default="Enemy", choices=("Enemy", "Animal"), help="Animal installs a companion under Characters/Animal")
a = ap.parse_args()
low = a.name.lower()
tag = "enemy" if a.kind == "Enemy" else "companion"
source = a.source or Path(f"Tools/output/{tag}_3d/{a.name}/{low}.glb")
game = Path(f"Tools/output/{tag}_game/{low}_game")
stem = source.stem
run([sys.executable, "Tools/model_tools/model_lowpoly_bake.py", str(source), "-o", str(game), "--tris", "2000", "--size", "1024",
"--callback-file", str(game / "bake_status.json")])
run([sys.executable, "Tools/install_character_bundles.py", "-a", a.name, "--kind", a.kind, "-b", str(game / f"{stem}_xpbr")])
unity("AgentScripts/SetupCharacter.cs", "SetupCharacter.Run", [a.kind, a.name, a.height])
audit = ROOT / (f"Tools/output/enemy_characters/{a.name}/static_audit" if a.kind == "Enemy" else f"Tools/output/companions/{a.name}/static_audit")
unity("AgentScripts/AuditCharacterVisual.cs", "AuditCharacterVisual.Capture",
[f"Assets/Bundles/Characters/{a.kind}/{a.name}/{a.name}.prefab", str(audit).replace("\\", "/"), a.height])
print((audit / "static_verification.txt").read_text(encoding="utf-8"))
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
from PIL import Image,ImageDraw
p='AIClient/Assets/Bundles/Characters/Animal/BudBud/BudBud_base.png'
im=Image.new('RGBA',(1024,1024),(146,220,100,255)); d=ImageDraw.Draw(im)
sw=[(146,220,100,255),(255,238,201,255),(42,56,55,255),(245,150,170,255),(255,220,75,255),(80,185,220,255)]
i=0
for y in (0,512):
for x in (0,341,682):
d.rectangle((x,y,min(x+340,1023),min(y+511,1023)),fill=sw[i]); i+=1
im.save(p)
Image.new('RGBA',(1024,1024),(128,128,128,255)).save('AIClient/Assets/Bundles/Characters/Animal/BudBud/BudBud_mix.png')
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""T1 companion concept drafts for pets 7-9 via the LAN local Turbo API (same flow as generate_enemy_designs.py).
Each companion uses its prototype portrait as identity reference (image 1) and FireCat as style reference (image 2).
Body plans are chosen to fit the validated anatomy adapters: VineSprite / TideWhale biped_short, ThunderLeopard quadruped_heavy.
Drafts go to Doc/Design/Character/{Id}/drafts/.
Usage:
python Tools/generate_companion_designs.py # all three
python Tools/generate_companion_designs.py TideWhale # selected only
"""
from __future__ import annotations
import json
import sys
import requests
from generate_enemy_designs import ROOT, run, status
DESIGN = ROOT / "Doc/Design/Character"
PORTRAITS = ROOT / "AIClient/Assets/Bundles/UI/Texture/Prototype"
STYLE = ROOT / "Doc/Design/Character/FireCat/firecat.png"
COMMON = (
"Image 1 is the EXISTING prototype identity reference made of primitive shapes: keep its recognizable palette and idea while replacing "
"the primitive proxy geometry with a real character. Image 2 is STYLE ONLY: match the FireCat companion's friendly polished chibi 3D "
"mobile-game render, big expressive glossy eyes, appealing rounded volumes and clean readable shapes; do not copy cat features. "
"One friendly T1 companion creature for a cozy meadow tower-defense game. Calm neutral rigging-friendly standing pose, bilaterally "
"symmetrical, all feet grounded on a common flat plane with visible gaps between the limbs, head facing forward. Single full-body "
"character centered, very shallow front three-quarter view (about 15 degrees off frontal), everything in frame with generous margin. "
"Pure solid white #FFFFFF background to all corners, soft studio light, tiny contact shadow under the feet. Every part is solid and "
"attached to the body: no floating pieces, no separate water drops, sparks, petals or particles. Simple smooth surfaces suitable for a "
"2000-triangle mobile model with a 1024 texture. Exactly one character; no text, no watermark, no UI, no props, no scenery, no floor base."
)
COMPANIONS = [
{"id": "VineSprite", "name": "藤蔓灵", "role": "pet 7 grass support (heal)", "seed": 71001, "portrait": 7, "size": (1024, 1024),
"subject": "VineSprite, a small upright two-legged forest-green vine spirit, clearly different from a mint leaf bud: a rounded "
"forest-green head with a pale lime face, a soft leaf hood that drapes over the back of the head, two thick curling vine "
"tendrils springing from the top of the head and attached along their whole length, one small pink five-petal flower on the "
"hood, a slim body wrapped by a single attached vine spiral, two short vine arms ending in rounded leaf hands, two short "
"stubby root-like legs with flat rounded feet both grounded, gentle caring smile."},
{"id": "ThunderLeopard", "name": "雷鸣豹", "role": "pet 8 lightning damage (chain)", "seed": 81001, "portrait": 8, "size": (1536, 1024),
"subject": "ThunderLeopard, a small sturdy FOUR-LEGGED leopard cub standing on all four paws: soft violet-purple fur with a few darker "
"purple rosette spots, cream muzzle and belly, round ears, big bright golden eyes, a confident playful grin. Two broad "
"solid cyan lightning-bolt-shaped crystal plates fused onto the shoulders like small armor, and one short cyan zigzag "
"crystal crest on the forehead; the crystals are solid attached parts with shallow seams, not floating. Medium thick tail "
"with a cyan crystal tip, attached. Four distinct stout legs with rounded paws all grounded, level back, head held forward "
"at rest, neck readable and separable from the shoulders for a later short head-and-shoulder pounce animation. "
"Landscape composition showing the whole body length."},
{"id": "TideWhale", "name": "潮汐鲸", "role": "pet 9 water guardian (shield)", "seed": 91001, "portrait": 9, "size": (1024, 1024),
"subject": "TideWhale, a chubby baby whale standing UPRIGHT on two short stubby feet like a small round plush toy, not swimming and not "
"floating: a round cyan-blue whale body with a smooth cream belly with soft grooves, a gently domed head, big friendly "
"eyes, a small happy smile, two short rounded flippers held at the sides as arms, a small rounded dorsal fin, a short "
"whale tail with two rounded flukes resting behind on the ground, two short separate stubby legs with flat round feet, "
"a protective calm look. No water spout, no splash, no bubbles."},
]
def main() -> None:
wanted = set(sys.argv[1:])
with requests.Session() as session:
state = status(session)
if not (state.get("connected") and state.get("ready")):
raise SystemExit(f"local images not ready: {state}")
for c in COMPANIONS:
if wanted and c["id"] not in wanted:
continue
refs = [PORTRAITS / f"proto_pet_{c['portrait']}.png", STYLE]
rec = run(c, session, design=DESIGN, refs=refs, common=COMMON, size=c["size"])
print(json.dumps({"id": rec["id"], "status": rec["status"], "drafts": len(rec["drafts"]), "error": rec["error"]}, ensure_ascii=False))
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""P0 enemy concept drafts via the LAN LLM3DGraphics local Turbo image API (DGX3D_AI调用指南.md §4.4).
All five enemies are one 'horn imp' family built as short-legged bipeds (biped_short), so the validated
RockBall enemy rig/motion adapter applies. Drafts go to Doc/Design/Character/Enemy/{id}/drafts/.
Usage:
python Tools/generate_enemy_designs.py # all enemies, 4 drafts each
python Tools/generate_enemy_designs.py Hornlet Boss # selected enemies only
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
import requests
BASE = "http://192.168.130.16:8008"
ROOT = Path(__file__).resolve().parents[1]
DESIGN = ROOT / "Doc/Design/Character/Enemy"
STYLE_REFS = [ROOT / "Doc/Design/Character/FireCat/firecat.png", ROOT / "Doc/Design/Character/RockRhino/rockrhino.png"]
COMMON = (
"Image 1 and image 2 are STYLE ONLY: match their friendly polished chibi 3D mobile-game render, glossy expressive eyes, "
"rounded appealing volumes and clean readable shapes; do not copy their species, colors or features. "
"Subject family: small mischievous 'horn imp' monsters that invade a cozy meadow camp in a cute tower-defense game; "
"cheeky and naughty, never scary, gory or realistic. Base palette violet / lilac skin with cream-ivory horns. "
"Body plan for rigging: a SHORT-LEGGED BIPED standing upright, two short stubby legs with flat round feet both grounded, "
"two short separate arms held slightly away from the body, bilaterally symmetrical, neutral calm standing pose, "
"head facing the viewer. Composition: single full-body character centered, very shallow front three-quarter view "
"(about 15 degrees off frontal), whole body including horns and feet in frame with generous margin. "
"Pure solid white #FFFFFF background to all corners, soft studio light, tiny contact shadow under the feet. "
"Simple smooth matte surfaces suitable for a 2000-triangle mobile model with a 1024 texture. "
"Exactly one character; no text, no watermark, no UI, no props held in hands, no weapons, no floor base, no scenery, "
"no particles, no glow, no extra limbs, no wings."
)
ENEMIES = [
{"id": "Hornlet", "name": "小角怪", "role": "normal", "seed": 61001,
"subject": "Hornlet, the basic grunt: a small round violet imp, egg-shaped body, one short stubby cream horn on top of the head, "
"big round eyes and a toothy cheeky grin with one small fang, tiny pointed ears, short tail stub."},
{"id": "Dashlet", "name": "疾跑怪", "role": "fast", "seed": 62001,
"subject": "Dashlet, the fast runner: a slimmer, lighter lilac imp with a narrow teardrop body leaning slightly forward, "
"one sleek swept-back cream horn, long pointed ears swept back like it is in a hurry, eager grinning face, "
"slightly longer but still short legs with springy feet, thin tail; reads as quick and light."},
{"id": "Shellhorn", "name": "硬壳怪", "role": "armored", "seed": 63101,
# v1 (seeds 63001-63004) drifted into four-legged turtles; v2 forces an upright biped with a compact back plate.
"subject": "Shellhorn, the armored one: an UPRIGHT standing two-legged violet imp, same upright body plan as a small chubby person, "
"standing on its two short hind legs only, two short arms hanging at its sides, NOT on all fours, not a turtle, not a quadruped. "
"Stocky wide body; a compact rounded gray-brown stone plate covers its back like a small backpack-sized shell, staying behind the body "
"and not wider than the shoulders, plus a smooth stone brow plate on its forehead with one short blunt cream horn poking out of it; "
"stubborn frowning but cute face. The stone plates are solid attached armor with shallow seams, not floating rocks."},
{"id": "HornCaptain", "name": "角怪队长", "role": "elite", "seed": 64001,
"subject": "Horn Captain, the elite squad leader: a taller, chunkier deep-violet imp with TWO curved cream horns, a short red scarf "
"tied around the neck with the knot at the front, confident smug grin with two small fangs, small spiky tuft on the head, "
"broader shoulders and slightly bigger fists; still short-legged and cute."},
{"id": "GreatHorn", "name": "大角首领", "role": "boss", "seed": 65001,
"subject": "Great Horn, the boss: a big heavy dark plum-violet imp chief with a round belly, two large curved ram-like cream horns "
"and a small crown-like ridge of three tiny horns between them, thick bushy eyebrows, proud bossy grin with fangs, "
"a short dark purple cape draped over the shoulders, chunky short legs and big round feet; imposing but still cute."},
]
def status(session: requests.Session) -> dict:
r = session.get(f"{BASE}/api/local-images/status", timeout=20)
r.raise_for_status()
return r.json()
def run(enemy: dict, session: requests.Session, design: Path = DESIGN, refs: list[Path] = STYLE_REFS, common: str = COMMON,
size: tuple[int, int] = (1024, 1024)) -> dict:
"""Generate four drafts for one character into design/{id}/drafts; enemy defaults, overridable for companions."""
out = design / enemy["id"] / "drafts"
out.mkdir(parents=True, exist_ok=True)
prompt = f"{common} {enemy['subject']}"
data = dict(model="turbo", profile="concept", prompt=prompt, width=size[0], height=size[1], count=4,
seed=enemy["seed"], transparent="false", negative_prompt="")
files = [("images", (p.name, p.open("rb"), "image/png")) for p in refs]
try:
r = session.post(f"{BASE}/api/local-images/jobs", data=data, files=files, timeout=60)
finally:
for _, (_, fh, _) in files:
fh.close()
r.raise_for_status()
job = r.json()
while job.get("status") not in ("completed", "failed"):
time.sleep(3)
job = session.get(f"{BASE}/api/local-images/jobs/{job['id']}", timeout=20).json()
saved = []
for img in job.get("images", []):
path = out / f"{enemy['id']}_{img['seed']}.png"
d = session.get(f"{BASE}{img['url']}" if img["url"].startswith("/") else img["url"], timeout=60)
d.raise_for_status()
path.write_bytes(d.content)
saved.append(str(path.relative_to(ROOT)).replace("\\", "/"))
record = {"id": enemy["id"], "name": enemy["name"], "role": enemy["role"], "jobId": job["id"], "status": job["status"],
"error": job.get("error"), "model": "Qwen/Qwen-Image-2.1-Turbo (LAN LLM3DGraphics local-images)", "profile": "concept",
"size": list(size), "seedStart": enemy["seed"], "references": [str(p.relative_to(ROOT)).replace("\\", "/") for p in refs],
"prompt": prompt, "drafts": saved, "generatedUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
(design / enemy["id"] / "design_drafts.json").write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
return record
def main() -> None:
wanted = set(sys.argv[1:])
with requests.Session() as session:
state = status(session)
if not (state.get("connected") and state.get("ready")):
raise SystemExit(f"local images not ready: {state}")
for enemy in ENEMIES:
if wanted and enemy["id"] not in wanted:
continue
rec = run(enemy, session)
print(json.dumps({"id": rec["id"], "status": rec["status"], "drafts": len(rec["drafts"]), "error": rec["error"]}, ensure_ascii=False))
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
"""TangoFlux enemy combat one-shots; keep source jobs, hashes and deterministic edits."""
import hashlib
import json
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import numpy as np
import requests
from scipy.io import wavfile
from scipy.signal import butter, sosfilt
from generate_ui_sfx import wait_job, download
ROOT = Path(__file__).resolve().parents[1]
DESIGN = ROOT / 'Doc/Design/Sound/EnemyCombat'
OUT = ROOT / 'Tools/output/enemy_combat_sfx'
BUNDLE = ROOT / 'AIClient/Assets/Bundles/Sound/SFX/EnemyCombat'
BASE = 'http://127.0.0.1:8012'
SOUNDS = [
('Enemy_HornletAttack', 'One small cartoon horn creature headbutt, quick airy swish and compact woody knock, dry close recording', .48),
('Enemy_DashletAttack', 'One fast cartoon creature dash strike, sharp short sweeping air whip and light punch, dry close recording', .42),
('Enemy_ShellhornAttack', 'One heavy shell creature shoulder bash, solid hollow stone knock with tiny gravel clicks, dry close recording', .60),
('Enemy_HornCaptainAttack', 'One forceful cartoon horn creature strike, broad air swoosh then firm wooden thud, dry close recording', .58),
('Enemy_GreatHornAttack', 'One large fantasy stone creature ground slam, deep rounded punch with brief gritty stone debris, dry close recording', .82),
('Enemy_HitSoft', 'One small soft cartoon body impact, short clean muted punch and tiny airy pop, dry close recording, no vocalization', .34),
('Enemy_HitArmor', 'One small rock armor impact, crisp stone clack with two tiny gravel ticks, dry close recording', .42),
('Enemy_HitShield', 'One small magical shield deflecting a hit, clear bright glass ping with short soft resonant tail, dry close recording', .55),
]
def submit_request(req):
deadline = time.monotonic()+120
while time.monotonic() < deadline:
response = requests.post(BASE+'/api/generate', json={**req, 'sync': False}, timeout=60)
if response.status_code == 429:
time.sleep(3)
continue
response.raise_for_status()
return response.json()
raise TimeoutError('TangoFlux queue stayed full for 120 seconds')
def generate(row):
index, (name, prompt, length) = row
req = dict(prompt=prompt + ', single isolated event at the beginning, silence afterwards, no music, no speech, no ambience', duration=2, steps=50, guidance_scale=4.5, seed=62100+index)
key = hashlib.sha256(json.dumps(req, sort_keys=True).encode()).hexdigest()
receipt = OUT / (name+'.json')
source = OUT / (name+'_source.wav')
old = json.loads(receipt.read_text()) if receipt.exists() else {}
if old.get('requestHash') == key and source.exists() and old.get('sourceSha256') == hashlib.sha256(source.read_bytes()).hexdigest():
job = old['job']
else:
job = wait_job(BASE, submit_request(req)['id'])
download(BASE, job, source)
rate, samples = wavfile.read(source)
assert rate == 44100 and samples.ndim == 2 and samples.shape[1] == 2
x = samples.astype(np.float64)
if np.issubdtype(samples.dtype, np.integer): x /= max(abs(np.iinfo(samples.dtype).min), np.iinfo(samples.dtype).max)
x = sosfilt(butter(2, 65, fs=rate, btype='highpass', output='sos'), x, axis=0)
# Locate the strongest 10ms onset; retain a 15ms lead and a bounded gameplay tail.
hop = rate//100
env = np.array([np.sqrt(np.mean(x[i:i+hop]**2)) for i in range(0, len(x), hop)])
peak = int(np.argmax(env))
start = peak
while start > 0 and env[start-1] > env[peak]*.2: start -= 1
start = max(0, start*hop-int(.015*rate))
n = int(length*rate)
y = x[start:start+n].copy()
if len(y) < n: y = np.pad(y, ((0,n-len(y)), (0,0)))
y[:220] *= np.linspace(0,1,220)[:,None]
fade = min(2205, len(y)//4)
y[-fade:] *= np.linspace(1,0,fade)[:,None]
y *= .80/max(np.max(np.abs(y)), 1e-8)
target = BUNDLE/(name+'.wav')
wavfile.write(target, rate, (y*32767).astype(np.int16))
meta = dict(id=name, request=req, requestHash=key, job=job, source=str(source.relative_to(ROOT)), sourceSha256=hashlib.sha256(source.read_bytes()).hexdigest(), output=str(target.relative_to(ROOT)), outputSha256=hashlib.sha256(target.read_bytes()).hexdigest(), seconds=length, trimStartSeconds=start/rate, sampleRate=rate, channels=2, peakDb=float(20*np.log10(np.max(np.abs(y)))), rmsDb=float(20*np.log10(np.sqrt(np.mean(y*y)))), postprocess='65Hz highpass; onset trim; 5ms fade in / 50ms fade out; peak -1.94 dBFS', license='declare-lab/TangoFlux: noncommercial research weights per project guide')
receipt.write_text(json.dumps(meta, indent=2), encoding='utf-8')
print(name+' complete', flush=True)
return meta
if __name__ == '__main__':
status = requests.get(BASE+'/api/status', timeout=15).json()
if not status.get('ready') or status.get('error'):
raise RuntimeError('TangoFlux is not ready; inspect status/warmup per guide: '+str(status))
for folder in (DESIGN, OUT, BUNDLE): folder.mkdir(parents=True, exist_ok=True)
# At most two in flight; service limit is four across all callers.
with ThreadPoolExecutor(max_workers=2) as pool: items = list(pool.map(generate, enumerate(SOUNDS)))
(DESIGN/'generation.json').write_text(json.dumps(dict(service=BASE, guide='D:/UD/AI/XModel/TangoFlux_AI调用指南.md', status='generated', items=items), ensure_ascii=False, indent=2), encoding='utf-8')
preview=[]
for item in items:
_, data=wavfile.read(ROOT/item['output'])
preview.extend((data, np.zeros((13230,2), dtype=np.int16)))
wavfile.write(DESIGN/'preview.wav', 44100, np.concatenate(preview))
+40 -2
View File
@@ -55,13 +55,13 @@ class Screen:
if title:
self.text(name, 'Btn_' + name, 12, 6, w - 24, h - 12, title, 32, '#FFFFFF', 'Center', True)
def save(self):
def save(self, description=None):
sprites = {c[k] for n in self.nodes for c in n['components'] for k in ['sprite', 'spritePressed', 'spriteDisabled'] if c.get(k)}
def asset(s):
folder = 'Prototype' if s.startswith('proto_') else 'Home' if s.startswith('home_') else 'Common'
return f'Assets/Bundles/UI/Texture/{folder}/{s}.png'
doc = dict(schemaVersion=1, prefabName=self.name, prefabPath=f'Assets/Bundles/UI/Prefab/{self.name}.prefab',
description='G0 玩法原型 / Candy。JSON→Prefab;PrototypeFlow 绑定节点并更新表现,不运行时拼接业务骨架。',
description=description or 'G0 玩法原型 / Candy。JSON→Prefab;PrototypeFlow 绑定节点并更新表现,不运行时拼接业务骨架。',
canvas=dict(canvasScaler=dict(referenceResolution=[1080, 1920], matchWidthOrHeight=0)),
assets=dict(sprites={s: asset(s) for s in sorted(sprites)}), nodes=self.nodes,
bindings=[n['name'] for n in self.nodes if n['name'].startswith(('Btn_', 'Txt_'))], events=self.events)
@@ -72,6 +72,8 @@ class Screen:
def main():
chest()
mail()
challenges()
rank()
settings()
s = Screen('UI_PrototypeBag', '冒险背包')
for i, title in enumerate(['消耗品', '伙伴材料', '装饰', '活动道具']):
@@ -368,6 +370,38 @@ def chest():
s.save()
def challenges():
s = Screen('UI_PrototypeChallenges', '属性挑战')
s.text('Summary', s.name, 48, 190, 984, 90, '通过 1-1 后开放。只上阵对应元素,首次通过获得徽章和碎片。', 28, MUTED, 'Center')
for i in range(5):
s.button(f'Challenge{i}', s.name, 48, 300 + i * 140, 984, 124, '试炼', False, 'ui_list_item_normal')
s.nodes[-2]['components'][1]['spritePressed'] = 'ui_list_item_selected'
s.nodes[-1]['rect']['sizeDelta'][1] = 52
s.text(f'ChallengeState{i}', f'Btn_Challenge{i}', 28, 68, 928, 40, '可挑战', 24, MUTED)
s.image('DetailPanel', s.name, 48, 1020, 984, 340)
s.text('Detail', 'DetailPanel', 28, 20, 928, 300, '选择一场试炼', 28)
s.button('Start', s.name, 48, 1390, 984, 108, '开始试炼')
s.button('Capture', s.name, 48, 1520, 984, 96, '前往捕捉', False)
s.text('Rules', s.name, 64, 1640, 952, 80, '不消耗弹珠,不改主线阵容。徽章只领一次,再打只刷新用时纪录。', 24, MUTED, 'Center')
s.save('G4 属性挑战 / Candy。JSON→Prefab;五场元素试炼由 PrototypeFlow 绑定,不运行时拼接业务骨架。')
def rank():
s = Screen('UI_PrototypeRank', '冒险排行')
s.text('Summary', s.name, 48, 186, 984, 84, '正在读取排行…', 28, MUTED, 'Center')
for i, label in enumerate(['主线', '火', '草', '岩', '电', '水']):
s.button(f'RankTab{i}', s.name, 32 + i * 172, 280, 164, 80, label, False)
for i in range(20):
row = f'RankRow{i}'
s.image(row, s.name, 48, 376 + i * 66, 984, 60)
s.text(f'RankPlace{i}', row, 16, 8, 88, 44, str(i + 1), 26)
s.text(f'RankName{i}', row, 112, 8, 520, 44, '探险家', 26)
s.text(f'RankScore{i}', row, 640, 8, 320, 44, '成绩', 26, align='Right')
s.text('RankEmpty', s.name, 64, 620, 952, 160, '还没有已确认的成绩\n通关主线或刷新试炼纪录后会出现在这里', 32, MUTED, 'Center')
s.text('Rules', s.name, 48, 1710, 984, 110, '只统计服务器确认的成绩。成绩相同比达成时间,时间相同比玩家编号。没有纪录时间的旧成绩排在同成绩之后。', 24, MUTED, 'Center')
s.save('G4 冒险排行 / Candy。JSON→Prefab;主线榜和五场试炼榜由 PrototypeFlow 绑定,不运行时拼接业务骨架。')
def mail():
s = Screen('UI_PrototypeMail', '小镇邮件')
s.image('MailWallet', s.name, 40, 200, 1000, 104)
@@ -398,5 +432,9 @@ if __name__ == '__main__':
import sys
if len(sys.argv) > 1 and sys.argv[1] == 'settings':
settings()
elif len(sys.argv) > 1 and sys.argv[1] == 'challenges':
challenges()
elif len(sys.argv) > 1 and sys.argv[1] == 'rank':
rank()
else:
main()
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Generate UI SFX via local TangoFluxService (see D:/UD/AI/XModel/TangoFlux_AI调用指南.md)."""
from __future__ import annotations
import argparse
import json
import shutil
import time
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = ROOT / "Doc/Design/Sound/UiSfx/manifest.json"
def wait_ready(base: str, timeout: float = 1800) -> dict:
deadline = time.time() + timeout
last = {}
while time.time() < deadline:
try:
last = requests.get(f"{base}/api/status", timeout=30).json()
if last.get("ready"):
return last
if not last.get("loaded") and not last.get("busy"):
try:
requests.post(f"{base}/api/warmup", timeout=10)
except requests.Timeout:
pass
except requests.RequestException as exc:
last = {"error": str(exc)}
time.sleep(2)
raise TimeoutError(f"TangoFlux not ready: {last}")
def submit(base: str, item: dict, defaults: dict) -> dict:
body = {
"prompt": item["prompt"],
"duration": float(item.get("duration", defaults.get("duration", 2))),
"steps": int(item.get("steps", defaults.get("steps", 50))),
"guidance_scale": float(item.get("guidance_scale", defaults.get("guidance_scale", 4.5))),
"seed": int(item.get("seed", 0)),
"sync": False,
}
while True:
r = requests.post(f"{base}/api/generate", json=body, timeout=60)
if r.status_code == 429:
time.sleep(3)
continue
r.raise_for_status()
return r.json()
def wait_job(base: str, job_id: str, timeout: float = 900) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
j = requests.get(f"{base}/api/jobs/{job_id}", timeout=30).json()
status = j.get("status")
if status == "succeeded":
return j
if status == "failed":
raise RuntimeError(j.get("error") or f"job failed: {job_id}")
time.sleep(0.8)
raise TimeoutError(job_id)
def download(base: str, job: dict, out_path: Path) -> Path:
url = job.get("url") or job.get("download_url") or f"/api/jobs/{job['id']}/audio"
if url.startswith("/"):
url = base + url
audio = requests.get(url, timeout=120)
audio.raise_for_status()
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(audio.content)
return out_path
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
ap.add_argument("--base", default="")
ap.add_argument("--force", action="store_true", help="Regenerate even if WAV exists")
ap.add_argument("--max-inflight", type=int, default=3)
args = ap.parse_args()
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
base = (args.base or manifest.get("service") or "http://127.0.0.1:8012").rstrip("/")
out_dir = ROOT / manifest["outputDir"]
design_dir = ROOT / manifest["designDir"]
bundle_dir = ROOT / manifest["bundleDir"]
defaults = manifest.get("defaults") or {}
out_dir.mkdir(parents=True, exist_ok=True)
design_dir.mkdir(parents=True, exist_ok=True)
bundle_dir.mkdir(parents=True, exist_ok=True)
status = wait_ready(base)
print(json.dumps({"ready": True, "status": status}, ensure_ascii=False))
results = []
pending: list[tuple[dict, dict]] = []
def flush_one() -> None:
item, job = pending.pop(0)
done = wait_job(base, job["id"])
wav = out_dir / f"{item['id']}.wav"
download(base, done, wav)
dest = bundle_dir / f"{item['id']}.wav"
shutil.copy2(wav, dest)
meta = {
"id": item["id"],
"jobId": done["id"],
"prompt": item["prompt"],
"duration": item.get("duration", defaults.get("duration")),
"steps": item.get("steps", defaults.get("steps")),
"guidance_scale": item.get("guidance_scale", defaults.get("guidance_scale")),
"seed": item.get("seed", 0),
"bytes": wav.stat().st_size,
"outputWav": str(wav.relative_to(ROOT)).replace("\\", "/"),
"bundleWav": str(dest.relative_to(ROOT)).replace("\\", "/"),
"status": done.get("status"),
}
(out_dir / f"{item['id']}.json").write_text(
json.dumps({**meta, "serverJob": done}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
results.append(meta)
print(json.dumps({"done": item["id"], "bytes": meta["bytes"]}, ensure_ascii=False))
for item in manifest["items"]:
wav = out_dir / f"{item['id']}.wav"
if wav.exists() and not args.force:
dest = bundle_dir / f"{item['id']}.wav"
if not dest.exists() or args.force:
shutil.copy2(wav, dest)
results.append({
"id": item["id"],
"skipped": True,
"outputWav": str(wav.relative_to(ROOT)).replace("\\", "/"),
"bundleWav": str(dest.relative_to(ROOT)).replace("\\", "/"),
"bytes": wav.stat().st_size,
})
print(json.dumps({"skip": item["id"]}, ensure_ascii=False))
continue
while len(pending) >= args.max_inflight:
flush_one()
job = submit(base, item, defaults)
pending.append((item, job))
print(json.dumps({"queued": item["id"], "jobId": job["id"]}, ensure_ascii=False))
while pending:
flush_one()
report = {
"batchId": manifest.get("batchId"),
"base": base,
"generatedUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"count": len(results),
"items": results,
}
(out_dir / "batch_report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
(design_dir / "generation.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps({"batchComplete": True, "count": len(results)}, ensure_ascii=False))
if __name__ == "__main__":
main()
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""UI SFX v2: generate 1s TangoFlux one-shots per seed, trim to button-length, auto-pick a candidate.
Usage:
python Tools/generate_ui_sfx_v2.py # generate missing candidates, process, install picks
python Tools/generate_ui_sfx_v2.py --process # reprocess existing candidates only (no service needed)
python Tools/generate_ui_sfx_v2.py --pick Ui_Click=52003 # override the automatic pick
"""
from __future__ import annotations
import argparse
import json
import shutil
import time
import wave
from pathlib import Path
import numpy as np
from generate_ui_sfx import download, submit, wait_job, wait_ready
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = ROOT / "Doc/Design/Sound/UiSfx/manifest_v2.json"
def read_wav(path: Path) -> tuple[np.ndarray, int]:
with wave.open(str(path)) as w:
if w.getsampwidth() != 2:
raise ValueError(f"{path}: expected 16-bit PCM")
data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).reshape(-1, w.getnchannels())
return data.astype(np.float64) / 32768.0, w.getframerate()
def write_wav(path: Path, data: np.ndarray, rate: int) -> None:
pcm = (np.clip(data, -1, 1) * 32767).round().astype(np.int16)
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as w:
w.setnchannels(pcm.shape[1])
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(pcm.tobytes())
def process(data: np.ndarray, rate: int, target: float, peak_db: float, fade_in_ms: float) -> tuple[np.ndarray, dict]:
"""Trim leading silence, cap at target with an exponential-ish fade, normalize peak. Returns clip and score metrics."""
level = np.abs(data).max(axis=1)
peak = level.max()
if peak < 0.1:
# Near-silent generations would only amplify the noise floor once normalized.
return data[:1], {"onsetSec": 0.0, "kept": 0.0, "score": -1.0, "rawPeak": round(float(peak), 4), "silent": True}
onset = int(np.argmax(level > peak * 0.08))
onset = max(0, onset - int(rate * 0.003))
energy = level ** 2
n = int(target * rate)
kept = float(energy[onset:onset + n].sum() / energy.sum())
clip = data[onset:onset + n].copy()
fade_in = min(len(clip), max(1, int(rate * fade_in_ms / 1000)))
clip[:fade_in] *= np.linspace(0, 1, fade_in)[:, None]
# Fade the last 40% to zero so the cap never clicks; squared curve keeps the attack intact.
tail = max(1, int(len(clip) * 0.4))
clip[-tail:] *= (np.linspace(1, 0, tail) ** 2)[:, None]
clip *= (10 ** (peak_db / 20)) / max(1e-6, np.abs(clip).max())
onset_sec = onset / rate
# Prefer energy already inside the window (less truncation) and an immediate attack.
score = kept - 2.0 * max(0.0, onset_sec - 0.02)
return clip, {"onsetSec": round(onset_sec, 4), "kept": round(kept, 4), "score": round(score, 4), "rawPeak": round(float(peak), 4), "silent": False}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
ap.add_argument("--process", action="store_true", help="Skip generation; reprocess existing candidates")
ap.add_argument("--pick", action="append", default=[], help="Override pick, e.g. Ui_Click=52003")
ap.add_argument("--max-inflight", type=int, default=3)
args = ap.parse_args()
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
defaults = manifest["defaults"]
out_dir = ROOT / manifest["outputDir"]
bundle_dir = ROOT / manifest["bundleDir"]
design_dir = ROOT / manifest["designDir"]
picks = dict(p.split("=", 1) for p in args.pick)
base = manifest["service"].rstrip("/")
if not args.process:
wait_ready(base)
pending: list[tuple[Path, dict, dict]] = []
def flush_one() -> None:
path, body, job = pending.pop(0)
done = wait_job(base, job["id"])
download(base, done, path)
path.with_suffix(".json").write_text(json.dumps({**body, "serverJob": done}, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"done": path.name}))
for item in manifest["items"]:
for seed in item.get("seeds", defaults["seeds"]):
path = out_dir / "raw" / f"{item['id']}_{seed}.wav"
if path.exists():
continue
while len(pending) >= args.max_inflight:
flush_one()
body = {"prompt": item["prompt"] + defaults["suffix"], "duration": defaults["duration"], "seed": seed}
job = submit(base, body, defaults)
pending.append((path, body, job))
while pending:
flush_one()
report = []
for item in manifest["items"]:
candidates = []
for seed in item.get("seeds", defaults["seeds"]):
raw = out_dir / "raw" / f"{item['id']}_{seed}.wav"
data, rate = read_wav(raw)
clip, metrics = process(data, rate, item["targetSec"], item["peakDb"], defaults["fadeInMs"])
trimmed = out_dir / "trimmed" / f"{item['id']}_{seed}.wav"
write_wav(trimmed, clip, rate)
candidates.append({"seed": seed, "raw": str(raw.relative_to(ROOT)).replace("\\", "/"),
"trimmed": str(trimmed.relative_to(ROOT)).replace("\\", "/"),
"lengthSec": round(len(clip) / rate, 4), **metrics})
auto = max(candidates, key=lambda c: c["score"])
chosen = next((c for c in candidates if str(c["seed"]) == picks.get(item["id"])), auto)
shutil.copy2(ROOT / chosen["trimmed"], bundle_dir / f"{item['id']}.wav")
report.append({"id": item["id"], "targetSec": item["targetSec"], "peakDb": item["peakDb"],
"prompt": item["prompt"] + defaults["suffix"], "pickedSeed": chosen["seed"],
"pickedBy": "manual" if chosen is not auto else "auto", "candidates": candidates})
print(json.dumps({"id": item["id"], "seed": chosen["seed"], "kept": chosen["kept"], "onset": chosen["onsetSec"]}))
result = {"batchId": manifest["batchId"], "generatedUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "items": report}
(out_dir / "batch_report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
(design_dir / "generation_v2.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
"""Drop loose mesh islands that TokenRig discards (thin floating tail tips, specks) so the baked game mesh and the rig agree.
blender -b --factory-startup --python Tools/model_tools/bl_keep_main_island.py -- in.glb out.glb [--min-fraction 0.05]
Islands are found after welding UV-seam duplicates (1e-4); only the welding used for the test, the export keeps the original split vertices and UVs.
"""
import argparse, json, sys
from pathlib import Path
import bmesh, bpy
p = argparse.ArgumentParser(); p.add_argument('source', type=Path); p.add_argument('output', type=Path)
p.add_argument('--min-fraction', type=float, default=.05, help='keep islands with at least this fraction of the largest island vertices')
a = p.parse_args(sys.argv[sys.argv.index('--') + 1:])
bpy.ops.wm.read_factory_settings(use_empty=True); bpy.ops.import_scene.gltf(filepath=str(a.source.resolve()))
report = []
for o in [o for o in bpy.data.objects if o.type == 'MESH']:
bm = bmesh.new(); bm.from_mesh(o.data); bm.verts.ensure_lookup_table()
# Union-find over original vertices, joining edges and co-located (seam-split) vertices.
parent = list(range(len(bm.verts)))
def find(i):
while parent[i] != i: parent[i] = parent[parent[i]]; i = parent[i]
return i
def union(i, j): parent[find(i)] = find(j)
for e in bm.edges: union(e.verts[0].index, e.verts[1].index)
cells = {}
for v in bm.verts:
key = tuple(round(c / 1e-4) for c in v.co)
if key in cells: union(v.index, cells[key])
else: cells[key] = v.index
sizes = {}
for v in bm.verts: r = find(v.index); sizes[r] = sizes.get(r, 0) + 1
largest = max(sizes.values()); keep = {r for r, n in sizes.items() if n >= largest * a.min_fraction}
drop = [v for v in bm.verts if find(v.index) not in keep]
report.append(dict(object=o.name, islands=len(sizes), kept=len(keep), droppedVertices=len(drop), droppedIslandSizes=sorted((n for r, n in sizes.items() if r not in keep), reverse=True)[:20]))
bmesh.ops.delete(bm, geom=drop, context='VERTS'); bm.to_mesh(o.data); bm.free(); o.data.update()
a.output.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(a.output.resolve()), export_format='GLB')
rec = dict(source=str(a.source), output=str(a.output), minFraction=a.min_fraction, objects=report, note='Only loose islands removed; remaining geometry, UVs and textures unchanged.')
a.output.with_suffix('.islands.json').write_text(json.dumps(rec, indent=2), encoding='utf-8'); print(json.dumps(rec))
+29
View File
@@ -0,0 +1,29 @@
"""Rigidly orient a generated character before baking/rigging, preserving UVs."""
import argparse
import json
import math
from pathlib import Path
import sys
import bpy
from mathutils import Matrix,Vector
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('source',type=Path);p.add_argument('output',type=Path)
p.add_argument('--blender-yaw',type=float,required=True)
a=p.parse_args(sys.argv[sys.argv.index('--')+1:])
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(a.source.resolve()))
if any(o.type=='ARMATURE' for o in bpy.data.objects):raise ValueError('Orient the static high source before rigging')
rotation=Matrix.Rotation(math.radians(a.blender_yaw),4,'Z')
roots=[o for o in bpy.context.scene.objects if o.parent is None]
for o in roots:o.matrix_world=rotation@o.matrix_world
bpy.context.view_layer.update()
points=[o.matrix_world@v.co for o in bpy.context.scene.objects if o.type=='MESH' for v in o.data.vertices]
mn=Vector([min(p[c] for p in points) for c in range(3)]);mx=Vector([max(p[c] for p in points) for c in range(3)])
offset=Matrix.Translation(-Vector(((mn.x+mx.x)/2,(mn.y+mx.y)/2,mn.z)))
for o in roots:o.matrix_world=offset@o.matrix_world
a.output.parent.mkdir(parents=True,exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(a.output.resolve()),export_format='GLB')
record=dict(source=str(a.source),output=str(a.output),blender_yaw_degrees=a.blender_yaw,orientation_note='Verify final forward in Unity views: FBX handedness conversion must be included.',height=mx.z-mn.z,mesh_unchanged_except_rigid_transform=True)
a.output.with_suffix('.orientation.json').write_text(json.dumps(record,indent=2),encoding='utf-8')
print(json.dumps(record))
+27
View File
@@ -157,6 +157,33 @@ def main():
# recipe-defined geodesic masks can bind that appendage to an existing
# stable bone without changing geometry, UVs or the animation skeleton.
override_reports=[]
# Explicit source-space regions can bind a rigid carapace without altering
# geometry/UVs. The recipe must be fitted to this character's inspected mesh.
if args.weight_overrides:
rules=json.loads(args.weight_overrides.read_text(encoding='utf-8-sig')).get('skinRegions',[])
extent=max(p.y for p in original_points)-min(p.y for p in original_points)
points=[p/extent for p in original_points]
for rule in rules:
if rule['bone'] not in arm.data.bones:raise ValueError('Region bone missing')
lo,hi=rule['min'],rule['max'];feather=rule.get('feather',0)
if feather<0 or any(lo[c]>=hi[c] for c in range(3)):raise ValueError('Invalid rigid region')
target=low.vertex_groups[rule['bone']];affected=[];full=[]
for i,p in enumerate(points):
distance=max(max(lo[c]-p[c],0,p[c]-hi[c]) for c in range(3))
if distance>feather:continue
weight=1 if distance==0 else 1-distance/feather
weight=weight*weight*(3-2*weight)
previous={g.group:g.weight for g in low.data.vertices[i].groups}
for group,value in previous.items():low.vertex_groups[group].add([i],value*(1-weight),'REPLACE')
target.add([i],previous.get(target.index,0)*(1-weight)+weight,'REPLACE')
affected.append(i)
if distance==0:full.append(i)
if not full:raise ValueError('Rigid region selected no vertices')
override_reports.append(dict(kind='source_space_region',bone=rule['bone'],affected=len(affected),full_weight_vertices=len(full),
normalized_min=[min(points[i][c] for i in affected) for c in range(3)],
normalized_max=[max(points[i][c] for i in affected) for c in range(3)]))
# More precise geodesic appendage overrides run after broad spatial regions.
if args.weight_overrides:
overrides=json.loads(args.weight_overrides.read_text(encoding='utf-8-sig')).get('skinOverrides',[])
extent=max(p.y for p in original_points)-min(p.y for p in original_points)
+12 -4
View File
@@ -1,6 +1,7 @@
"""Rig a same-character GLB on configured DGXA and retain source provenance.
Only explicit --generate starts inference. Reuses a byte-verified cached result.
--backend remote requires a verified remote receipt and refuses local fallback.
The approved low-poly mesh is preserved and receives these weights later.
"""
import argparse
@@ -22,6 +23,7 @@ def main():
parser.add_argument('--source',type=Path,required=True)
parser.add_argument('--out',type=Path,required=True)
parser.add_argument('--generate',action='store_true')
parser.add_argument('--backend',choices=('auto','remote'),default='auto',help='remote refuses an implicit local fallback')
args=parser.parse_args()
source=args.source.resolve();out=args.out.resolve()
if not source.is_relative_to(REPO) or not out.is_relative_to(REPO):
@@ -31,19 +33,25 @@ def main():
source_hash=digest(source);receipt=out/'rig_request.json';target=out/'rigged.glb'
if receipt.is_file() and target.is_file():
record=json.loads(receipt.read_text(encoding='utf-8'))
if record['source_sha256']==source_hash and record['rig_sha256']==digest(target):
if record['source_sha256']==source_hash and record['rig_sha256']==digest(target) and (args.backend=='auto' or record.get('backend')=='remote'):
print('PASS cached same-source rig',target);return
if not args.generate:
parser.error('No verified cache. --generate explicitly requests DGXA rigging')
sys.path.insert(0,str(REPO/'Tools'))
from dgx3d_animal_pipeline import _ensure_llm3d
_ensure_llm3d()
from backend.tokenrig_runner import run_tokenrig
from backend.tokenrig_runner import run_tokenrig,_run_remote
stage=out/'generations'/uuid.uuid4().hex;stage.mkdir(parents=True)
result=Path(run_tokenrig(source,stage,stage/'tokenrig.log',skeleton_template='original'))
runner=_run_remote if args.backend=='remote' else run_tokenrig
result=Path(runner(source,stage,stage/'tokenrig.log',skeleton_template='original'))
if not result.is_file() or result.stat().st_size<100:
raise RuntimeError('Missing TokenRig result')
record=dict(status='generated',source=str(source.relative_to(REPO)),source_sha256=source_hash,
# The local SkinTokens fallback produced a degenerate single-chain skeleton (Hornlet 2026-10-21); only accept DGXA runs.
if 'tokenrig remote failed' in (stage/'tokenrig.log').read_text(encoding='utf-8',errors='replace'):
raise RuntimeError('TokenRig fell back to local SkinTokens; reconnect DGXA (Tools/dgx_ensure_ready.py) and retry')
log=(stage/'tokenrig.log').read_text(encoding='utf-8',errors='replace')
backend='local' if 'tokenrig local:' in log else 'remote'
record=dict(status='generated',backend=backend,source=str(source.relative_to(REPO)),source_sha256=source_hash,
rig_sha256=digest(result),template='original',stage=str(stage.relative_to(REPO)),
generated_at_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()))
shutil.copy2(result,target)
+29
View File
@@ -0,0 +1,29 @@
"""Seed only the isolated formal-heroes database so 1-1 fields SparkBird and BubbleTurtle."""
import json
from pathlib import Path
import sqlite3
root = Path(__file__).resolve().parents[1]
test_root = (root / "Tools/output/formal_heroes").resolve()
database = (test_root / "bird_turtle.db").resolve()
username = (test_root / "account.txt").read_text(encoding="utf-8-sig").strip()
if test_root not in database.parents or not username.startswith("formalheroes_"):
raise RuntimeError("Refusing to edit a database or account outside the isolated formal-heroes fixture")
with sqlite3.connect(database) as conn:
row = conn.execute(
"SELECT u.pid, g.state FROM users u JOIN game_states g ON g.pid = u.pid WHERE u.username = ?",
(username,),
).fetchone()
if row is None:
raise RuntimeError("Isolated account has no game state")
state = json.loads(row[1])
if state.get("schema") != 9 or state.get("pendingShot") or state.get("pendingBattle"):
raise RuntimeError("Isolated save is not a settled schema 9 state")
if len(state.get("stages", [])) != 10 or len(state.get("formation", [])) != 6:
raise RuntimeError("Unexpected roster or formation width")
state["stages"][3] = 1
state["stages"][4] = 1
state["formation"] = [4, 3, -1, -1, -1, -1]
state["revision"] = int(state.get("revision", 0)) + 1
conn.execute("UPDATE game_states SET state = ? WHERE pid = ?", (json.dumps(state, ensure_ascii=False), row[0]))
print("Seeded isolated account to own and field SparkBird (3) and BubbleTurtle (4)")
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""UI SFX v3: procedurally synthesize crisp, light UI one-shots (marimba / glockenspiel / bubble-pop voices).
Replaces the TangoFlux v2 clips, which sounded muddy. Pure numpy and deterministic, so there is no model license.
Usage:
python Tools/synth_ui_sfx.py # write the 10 clips into the bundle dir and Doc/Design/Sound/UiSfx/synth_v3.json
python Tools/synth_ui_sfx.py --preview # also write Tools/output/ui_sfx_v3/preview.wav (all clips back to back)
"""
from __future__ import annotations
import argparse
import json
import time
import wave
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
BUNDLE_DIR = ROOT / "AIClient/Assets/Bundles/Sound/SFX/UI"
OUT_DIR = ROOT / "Tools/output/ui_sfx_v3"
REPORT = ROOT / "Doc/Design/Sound/UiSfx/synth_v3.json"
RATE = 44100
NYQUIST = RATE / 2 * 0.9
# Note frequencies (equal temperament, A4 = 440).
G5, C6, D6, E6, G6, A6, B6, C7, D7, E7, G7, A7, C8 = 784.0, 1046.5, 1174.7, 1318.5, 1568.0, 1760.0, 1975.5, 2093.0, 2349.3, 2637.0, 3136.0, 3520.0, 4186.0
def axis(dur: float) -> np.ndarray:
return np.arange(int(dur * RATE)) / RATE
def attack(t: np.ndarray, ms: float = 1.5) -> np.ndarray:
return np.minimum(1.0, t / (ms / 1000))
def partials(freq: float, dur: float, ratios, amps, decays) -> np.ndarray:
t = axis(dur)
out = np.zeros_like(t)
for r, a, d in zip(ratios, amps, decays):
if freq * r < NYQUIST:
out += a * np.sin(2 * np.pi * freq * r * t) * np.exp(-t / d)
return out * attack(t)
def marimba(freq: float, decay: float = .07, bright: float = .35, dur: float | None = None) -> np.ndarray:
"""Woody mallet: strong fundamental plus the bar's ~3.9x and ~9.2x modes, which die fast for the 'tok'."""
return partials(freq, dur or decay * 6, [1, 3.93, 9.2], [1, bright, bright * .3], [decay, decay * .3, decay * .12])
def glock(freq: float, decay: float = .2, dur: float | None = None) -> np.ndarray:
"""Glockenspiel / small bell: inharmonic bar partials give the sparkle."""
return partials(freq, dur or decay * 5, [1, 2.76, 5.40, 8.93], [1, .4, .2, .08], [decay, decay * .45, decay * .25, decay * .15])
def pop(f0: float, f1: float, dur: float = .06, decay: float = .02, glide: float = .012) -> np.ndarray:
"""Bubble pop: a sine whose pitch flicks from f0 to f1, the classic cute button blip."""
t = axis(dur)
freq = f1 + (f0 - f1) * np.exp(-t / glide)
phase = 2 * np.pi * np.cumsum(freq) / RATE
return (np.sin(phase) + .15 * np.sin(2 * phase)) * np.exp(-t / decay) * attack(t, 1)
def sweep(f0: float, f1: float, dur: float) -> np.ndarray:
"""Smooth glide with a rounded envelope: the soft 'fwip' under open/close."""
t = axis(dur)
freq = f0 * (f1 / f0) ** (t / dur)
phase = 2 * np.pi * np.cumsum(freq) / RATE
return np.sin(phase) * np.sin(np.pi * t / dur) ** 1.5
def tick(seed: int, dur: float = .004) -> np.ndarray:
"""A few ms of high-passed noise: the crisp transient on top of tonal hits."""
t = axis(dur)
noise = np.diff(np.random.default_rng(seed).standard_normal(len(t) + 1))
return noise / np.abs(noise).max() * np.exp(-t / .0008)
def mix(length: float, *layers: tuple[float, float, np.ndarray]) -> np.ndarray:
out = np.zeros(int(length * RATE))
for at, gain, sig in layers:
start = int(at * RATE)
n = min(len(sig), len(out) - start)
out[start:start + n] += gain * sig[:n]
return out
def finish(sig: np.ndarray, peak_db: float, tail_ms: float = 15) -> np.ndarray:
n = min(len(sig), int(RATE * tail_ms / 1000))
sig = sig.copy()
sig[-n:] *= np.linspace(1, 0, n)
return sig * (10 ** (peak_db / 20)) / np.abs(sig).max()
def sparkle(seed: int, start: float, end: float, count: int) -> list[tuple[float, float, np.ndarray]]:
rng = np.random.default_rng(seed)
notes = [E7, G7, A7, C8]
return [(float(rng.uniform(start, end)), float(rng.uniform(.12, .22)), glock(notes[int(rng.integers(len(notes)))], .06)) for _ in range(count)]
# Each recipe: (description, peak dB, signal)
RECIPES = {
"Ui_Click": ("bubble pop 700→1250 Hz + noise tick", -6,
lambda: mix(.08, (0, 1, pop(700, 1250, .08, .02)), (0, .18, tick(1)))),
"Ui_Confirm": ("marimba E6→B6 rising, glockenspiel on top note", -5,
lambda: mix(.36, (0, 1, marimba(E6, .07)), (.075, 1, marimba(B6, .08)), (.075, .35, glock(B6, .09)), (0, .15, tick(2)))),
"Ui_Back": ("soft marimba G6→D6 falling", -7,
lambda: mix(.22, (0, .8, marimba(G6, .045, .2)), (.06, 1, marimba(D6, .05, .2)))),
"Ui_Open": ("rising fwip 520→1400 Hz into an E7 ping", -7,
lambda: mix(.34, (0, .55, sweep(520, 1400, .11)), (.09, 1, glock(E7, .08)), (.09, .4, marimba(E6, .05)))),
"Ui_Close": ("falling fwip 1300→480 Hz into a soft G5 tok", -7,
lambda: mix(.24, (0, .55, sweep(1300, 480, .11)), (.09, 1, marimba(G5, .045, .25)))),
"Ui_Tab": ("light marimba A6 tick", -8,
lambda: mix(.07, (0, 1, marimba(A6, .022, .25)), (0, .2, tick(3)))),
"Ui_Toggle": ("double pop 900→1500 then 1200→1900 Hz", -8,
lambda: mix(.1, (0, .8, pop(900, 1500, .05, .015)), (.035, 1, pop(1200, 1900, .06, .016)))),
"Ui_Claim": ("glockenspiel arpeggio C6-E6-G6-C7 with marimba body and high sparkles", -3,
lambda: mix(.78, (0, .7, glock(C6, .12)), (.06, .75, glock(E6, .12)), (.12, .8, glock(G6, .14)), (.18, 1, glock(C7, .2)),
(0, .35, marimba(C6, .05)), (.06, .35, marimba(E6, .05)), (.12, .35, marimba(G6, .05)), (.18, .4, marimba(C7, .06)),
*sparkle(7, .22, .5, 6))),
"Ui_Deny": ("two soft low boops 330→262 Hz, gentle not harsh", -7,
lambda: mix(.27, (0, 1, pop(380, 330, .1, .035, .02)), (.11, 1, pop(300, 262, .14, .045, .02)))),
"Ui_Notify": ("bell ding-ding G6 then D7", -6,
lambda: mix(.52, (0, .8, glock(G6, .14)), (.11, 1, glock(D7, .16)))),
}
def write_wav(path: Path, mono: np.ndarray) -> None:
pcm = (np.clip(mono, -1, 1) * 32767).round().astype(np.int16)
stereo = np.repeat(pcm[:, None], 2, axis=1) # same layout as earlier batches (44.1 kHz stereo)
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(stereo.tobytes())
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--preview", action="store_true", help="Also write a back-to-back preview wav")
args = ap.parse_args()
rows, preview = [], []
for name, (desc, peak_db, build) in RECIPES.items():
clip = finish(build(), peak_db)
write_wav(BUNDLE_DIR / f"{name}.wav", clip)
write_wav(OUT_DIR / f"{name}.wav", clip)
preview += [clip, np.zeros(int(RATE * .45))]
rows.append({"id": name, "recipe": desc, "peakDb": peak_db, "lengthSec": round(len(clip) / RATE, 3)})
print(json.dumps(rows[-1], ensure_ascii=False))
if args.preview:
write_wav(OUT_DIR / "preview.wav", np.concatenate(preview))
REPORT.write_text(json.dumps({"batchId": "ui_sfx_v3", "method": "procedural synthesis (Tools/synth_ui_sfx.py, numpy)",
"format": "44.1 kHz 16-bit stereo (dual mono)", "generatedUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"items": rows}, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()
@@ -48,6 +48,69 @@ class EnemyProductionTests(unittest.TestCase):
def test_generation_success_commits_matching_receipt_last(self):
self.run_generation(True)
def test_flame_roar_requires_own_head_paws_and_mane_region(self):
lion={**self.data,'profile':'biped_short','attackStyle':'flame_roar','boneMappingReady':True,
'rootBone':'bone_0','chestBone':'bone_1','neckBone':'bone_2','headBone':'bone_3',
'rightArm':'bone_4','leftArm':'bone_6','rightPawTip':'bone_5','leftPawTip':'bone_7',
'rightFoot':'bone_8','leftFoot':'bone_10','rightFootTip':'bone_9','leftFootTip':'bone_11',
'faceRight':'bone_8','faceLeft':'bone_10','maneRegionMin':[-.5,.5,-.3],
'maneRegionMax':[.5,1,.3],'maneRigidityTolerance':.001,'attackHeadReach':.015,
'attackHeadPitchDegrees':8,'attackSpreadDegrees':25}
with mock.patch.object(Path,'read_text',return_value=json.dumps(lion)):
self.assertEqual(pipeline.recipe(Path('fixture'))['attackStyle'],'flame_roar')
for change in ({'headBone':''},{'headBone':'bone_1'},{'leftPawTip':'bone_5'},
{'maneRegionMin':[]},{'maneRigidityTolerance':.1},{'attackHeadPitchDegrees':40},
{'attackSpreadDegrees':0},{'profile':'quadruped_heavy'},{'boneMappingReady':False}):
with mock.patch.object(Path,'read_text',return_value=json.dumps({**lion,**change})),self.assertRaises(ValueError):
pipeline.recipe(Path('fixture'))
def test_turtle_requires_four_feet_shell_region_and_bind_up(self):
turtle={**self.data,'profile':'quadruped_turtle','attackStyle':'turtle_bubble','boneMappingReady':True,
'rootBone':'bone_0','shellBone':'bone_0','neckBone':'bone_1','headBone':'bone_2',
'faceRight':'bone_4','faceLeft':'bone_6','frontRightFoot':'bone_4','frontLeftFoot':'bone_6',
'hindRightFoot':'bone_8','hindLeftFoot':'bone_10','frontRightFootTip':'bone_5','frontLeftFootTip':'bone_7',
'hindRightFootTip':'bone_9','hindLeftFootTip':'bone_11','rootTiltReference':'bind_up','walkSwingFraction':.2,
'shellRegionMin':[-1,.3,-.2],'shellRegionMax':[1,1,1],'shellRigidityTolerance':.001,'attackHeadReach':.015}
with mock.patch.object(Path,'read_text',return_value=json.dumps(turtle)):
self.assertEqual(pipeline.recipe(Path('fixture'))['profile'],'quadruped_turtle')
for change in ({'hindLeftFoot':'bone_4'},{'hindLeftFootTip':'bone_5'},{'shellRegionMin':[]},
{'rootTiltReference':'bone_up'},{'walkSwingFraction':.5},{'attackStyle':'ranged_cast'},
{'headBone':''},{'boneMappingReady':False},{'shellRigidityTolerance':.1}):
with mock.patch.object(Path,'read_text',return_value=json.dumps({**turtle,**change})),self.assertRaises(ValueError):
pipeline.recipe(Path('fixture'))
def test_heavy_bash_rejects_turtle_style_missing_rigid_regions_and_shared_head(self):
heavy={**self.data,'profile':'quadruped_heavy','attackStyle':'braced_bash','boneMappingReady':True,
'rootBone':'bone_0','chestBone':'bone_1','neckBone':'bone_2','headBone':'bone_3',
'faceRight':'bone_5','faceLeft':'bone_7','frontRightFoot':'bone_5','frontLeftFoot':'bone_7',
'hindRightFoot':'bone_9','hindLeftFoot':'bone_11','frontRightFootTip':'bone_6','frontLeftFootTip':'bone_8',
'hindRightFootTip':'bone_10','hindLeftFootTip':'bone_12','rootTiltReference':'bind_up','walkSwingFraction':.2,
'armorRegionMin':[-.3,.3,-.1],'armorRegionMax':[.3,.7,.5],'armorRigidityTolerance':.001,
'hornRegionMin':[-.1,.7,-.6],'hornRegionMax':[.1,1,-.4],'hornRigidityTolerance':.001,
'attackHeadReach':.015,'attackLunge':.025,'attackHeadPitchDegrees':10}
with mock.patch.object(Path,'read_text',return_value=json.dumps(heavy)):
self.assertEqual(pipeline.recipe(Path('fixture'))['profile'],'quadruped_heavy')
for change in ({'attackStyle':'turtle_bubble'},{'hornRegionMin':[]},{'armorRigidityTolerance':.1},
{'headBone':'bone_1'},{'hindLeftFoot':'bone_5'},{'attackLunge':.5},
{'attackHeadPitchDegrees':0},{'boneMappingReady':False}):
with mock.patch.object(Path,'read_text',return_value=json.dumps({**heavy,**change})),self.assertRaises(ValueError):
pipeline.recipe(Path('fixture'))
def test_bird_requires_own_two_wing_mapping_and_matching_style(self):
bird={**self.data,'profile':'avian_grounded','attackStyle':'wing_cast',
'boneMappingReady':True,'rootBone':'bone_0','chestBone':'bone_1',
'rightArm':'bone_2','leftArm':'bone_4','rightWingTip':'bone_3','leftWingTip':'bone_5',
'rightFoot':'bone_6','leftFoot':'bone_8','rightFootTip':'bone_7','leftFootTip':'bone_9',
'faceRight':'bone_6','faceLeft':'bone_8','wingSpreadDegrees':25}
with mock.patch.object(Path,'read_text',return_value=json.dumps(bird)):
self.assertEqual(pipeline.recipe(Path('fixture'))['profile'],'avian_grounded')
for change in ({'rightWingTip':''},{'leftArm':'bone_2'},{'leftFootTip':'bone_7'},
{'attackStyle':'ranged_cast'},{'wingSpreadDegrees':90},{'boneMappingReady':False}):
with mock.patch.object(Path,'read_text',return_value=json.dumps({**bird,**change})),self.assertRaises(ValueError):
pipeline.recipe(Path('fixture'))
with mock.patch.object(Path,'read_text',return_value=json.dumps({**self.data,'attackStyle':'wing_cast'})),self.assertRaises(ValueError):
pipeline.recipe(Path('fixture'))
def run_generation(self,success):
root=REPO/'Tools/output/enemy_pipeline_tests'
root.mkdir(exist_ok=True)
+56
View File
@@ -0,0 +1,56 @@
"""Contract guards for timing, frozen-source coverage and production readiness."""
import sys
from pathlib import Path
import unittest
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/'Tools'))
from validate_skill_design import load,validate
class SkillDesignTests(unittest.TestCase):
def setUp(self):
self.data=[load(ROOT/p) for p in ['Config/Combat/skill_system_v1.proposal.json',
'Config/Combat/skill_presentations_v1.proposal.json','Doc/Design/Sound/BattleSkills/manifest.json',
'Config/Gameplay/greenfield.json']]
def test_covers_current_actors_without_claiming_runtime(self):
r=validate(*self.data)
self.assertEqual(r['skills'],len(self.data[3]['pets'])*2+len(self.data[3]['enemies']))
self.assertFalse(r['runtimeIntegrated']);self.assertFalse(r['generatedAudio'])
def test_missing_actor_or_wrong_skill_kind_is_rejected(self):
self.data[0]['skills'].pop()
with self.assertRaisesRegex(ValueError,'coverage'):validate(*self.data)
self.setUp();self.data[0]['skills'][1]['template']='ability_heal'
with self.assertRaisesRegex(ValueError,'semantics'):validate(*self.data)
def test_animation_and_cooldown_conflicts_are_rejected(self):
skill=next(s for s in self.data[0]['skills'] if s['id']=='pet.1.basic')
skill['timing']=dict(releaseTicks=11,recoveryTicks=11)
with self.assertRaisesRegex(ValueError,'cooldown'):validate(*self.data)
skill['timing']=dict(releaseTicks=2,recoveryTicks=16)
with self.assertRaisesRegex(ValueError,'marker'):validate(*self.data)
def test_missing_resource_cannot_claim_ready(self):
asset=next(a for a in self.data[1]['assets'] if a['status']=='planned')
asset['status']='existing'
with self.assertRaisesRegex(ValueError,'missing'):validate(*self.data)
def test_unresolved_cue_and_invalid_audio_request_are_rejected(self):
self.data[1]['profiles'][0]['bindings']['HitResolved'][0]['asset']='missing'
with self.assertRaisesRegex(ValueError,'Unresolved'):validate(*self.data)
self.setUp();self.data[2]['items'][0]['duration']=.5
with self.assertRaisesRegex(ValueError,'1..30'):validate(*self.data)
def test_actions_compose_but_cannot_repeat_an_action_id(self):
t=self.data[0]['templates'][0]
t['actions'].append({**t['actions'][0],'id':'additional_hit'})
validate(*self.data)
t['actions'][1]['id']=t['actions'][0]['id']
with self.assertRaisesRegex(ValueError,'Duplicate logical action'):validate(*self.data)
def test_model_forward_does_not_force_world_direction(self):
self.data[1]['anchorPolicy']['worldFacing']='fixed_world_minus_z'
with self.assertRaisesRegex(ValueError,'world facing'):validate(*self.data)
if __name__=='__main__':unittest.main()
+110
View File
@@ -0,0 +1,110 @@
"""Validate proposed battle skill contracts without touching the running game."""
from __future__ import annotations
import argparse
from decimal import Decimal,ROUND_CEILING
import json
from pathlib import Path,PurePosixPath
ROOT=Path(__file__).resolve().parents[1]
EVENTS={'CastStarted','CastReleased','CastEnded','CastCancelled','ProjectileSpawned',
'ProjectileExpired','HitResolved','StatusApplied','StatusRemoved','UnitDied','BattleEnded'}
def require(condition,message):
if not condition:raise ValueError(message)
def index(items,label):
result={item['id']:item for item in items}
require(len(result)==len(items),f'Duplicate {label} id')
return result
def ticks(seconds,tick_ms):
return int((Decimal(str(seconds))*1000/Decimal(tick_ms)).to_integral_value(rounding=ROUND_CEILING))
def validate(plan,view,sound,gameplay,root=ROOT):
for item in (plan,view):
require(item['schemaVersion']==1 and item['status']=='design_only' and item['runtimeEnabled'] is False,
'Design examples must stay explicitly disabled until runtime integration')
require(plan['source']['path']=='Config/Gameplay/greenfield.json','Keep one gameplay value source')
require(plan['clock']['tickMs']==50 and plan['projectile']['collisionAuthority']=='simulation','Invalid simulation contract')
require(plan['scheduling']['priorities']['ability']>plan['scheduling']['priorities']['basic'],'Ability must win arbitration')
templates=index(plan['templates'],'template');profiles=index(view['profiles'],'profile');assets=index(view['assets'],'asset')
for template in templates.values():
actions=index(template['actions'],'logical action')
require(bool(actions),'A skill needs at least one logical action')
for action in actions.values():
require(action['trigger']=='delivery_hit' and action['type'] in ('damage','heal','apply_status'),'Unsupported logical action')
if action['type']=='damage':require(action.get('amount')=='attack_scaled' and action.get('scale') in ('one','ability_value'),'Damage parameters missing')
elif action['type']=='heal':require(action.get('amount')=='ability_value','Heal parameters missing')
else:
require(action.get('statusId') in {s['id'] for s in plan['statuses']},'Unresolved status')
require(action.get('value')=='ability_value' and action.get('duration')=='ability_duration','Status parameters missing')
skills=index(plan['skills'],'skill');anchors=set(view['anchors'])
require(view['anchorPolicy']['localForward']=='-Z' and view['anchorPolicy']['worldFacing']=='source_to_target',
'Local model forward must be -Z and world facing must aim at target')
expected={f"pet.{p['id']}.{slot}" for p in gameplay['pets'] for slot in ('basic','ability')}
expected|={f"enemy.{e['id']}.basic" for e in gameplay['enemies']}
require(set(skills)==expected,'Skill coverage must include every pet basic/ability and enemy basic')
for asset in assets.values():
path=PurePosixPath(asset['path'])
require(asset['path'].startswith('Assets/Bundles/') and '..' not in path.parts and '\\' not in asset['path'],'Unsafe asset path')
require(asset['type'] in ('animation','vfx','sfx') and asset['status'] in ('existing','planned'),'Invalid asset readiness/type')
if asset['status']=='existing':require((root/'AIClient'/str(path)).is_file(),'Asset marked existing is missing: '+asset['id'])
for profile in profiles.values():
require(set(profile['bindings'])<=EVENTS,'Unknown presentation event')
require(profile['seekReplaysOneShots'] is False,'Seek must not replay historical one-shots')
require(all(c.get('when')=='valid_hit' for c in profile['bindings'].get('HitResolved',[])),'Invalid hits must not play hit cues')
for cues in profile['bindings'].values():
for cue in cues:
if cue['kind']=='stop_token':require(bool(cue['token']),'Missing owned token');continue
require(cue['kind'] in ('animation','vfx','sfx'),'Unknown cue kind')
require(cue['asset'] in assets,'Unresolved presentation asset: '+cue['asset'])
require(assets[cue['asset']]['type']==cue['kind'] and cue['anchor'] in anchors,'Cue type/anchor mismatch')
if cue['kind']=='animation':require(cue['rootMotion'] is False,'Animation must not move logical actor')
for skill in skills.values():
kind=skill['actorKind'];actor_id=skill['actorId'];slot=skill['slot']
require(kind in ('pet','enemy') and slot in ('basic','ability'),'Unknown actor/slot')
actors=gameplay['pets' if kind=='pet' else 'enemies'];actor=next((a for a in actors if a['id']==actor_id),None)
require(actor is not None,'Unknown actor')
require(skill['sourcePointer']==f"/{'pets' if kind=='pet' else 'enemies'}/{actor_id}",'Source pointer must match actor')
require(skill['template'] in templates and skill['presentation'] in profiles,'Unresolved skill reference')
t=templates[skill['template']]
expected_template=('enemy_melee' if kind=='enemy' else ('basic_ranged' if actor['range']>2 else 'basic_melee')) if slot=='basic' else 'ability_'+actor['ability']['kind']
require(t['id']==expected_template,'Skill template changes actor semantics: '+skill['id'])
require(t['delivery'] in ('instant','projectile'),'Unsupported delivery')
timing=skill.get('timing',t['timing']);release=timing['releaseTicks'];recovery=timing['recoveryTicks']
require(type(release) is int and type(recovery) is int and release>=0 and recovery>0,'Invalid action timing')
cooldown=actor['cooldown'] if slot=='basic' else actor['ability']['cooldown']
require(release+recovery<=ticks(cooldown,50),'Action occupies longer than cooldown: '+skill['id'])
for cue in profiles[skill['presentation']]['bindings'].get('CastStarted',[]):
if cue['kind']=='animation':
mark=assets[cue['asset']]['releaseNormalized']
require(abs(mark-release/(release+recovery))<=1/(release+recovery),'Animation release marker is not aligned: '+skill['id'])
require(sound['status']=='planned_not_generated','SFX design manifest must not claim generated assets')
require(1<=sound['maxInflight']<=4,'TangoFlux in-flight limit exceeds guide')
require(sound['sourceFormat']==dict(container='wav',sampleRate=44100,channels=2),'TangoFlux source format mismatch')
items=index(sound['items'],'SFX')
for item in items.values():
require(item['prompt'].isascii() and 0<len(item['prompt'])<=2000,'SFX prompt must be specific English within service limit')
require(1<=item.get('duration',sound['defaults']['duration'])<=30,'TangoFlux duration must be 1..30 seconds')
require(type(item['seed']) is int,'Reproducible SFX seed required')
for asset in assets.values():
if asset['type']=='sfx':require(PurePosixPath(asset['path']).stem in items,'SFX production recipe missing: '+asset['id'])
return dict(status='design_contract_validated',runtimeIntegrated=False,generatedAudio=False,
skills=len(skills),pets=len(gameplay['pets']),enemies=len(gameplay['enemies']),stages=len(gameplay['stages']),
presentationProfiles=len(profiles),existingAssets=sum(a['status']=='existing' for a in assets.values()),
plannedAssets=sum(a['status']=='planned' for a in assets.values()),sfxRecipes=len(items),
note='Configuration and reference validation only; no combat runtime or audio generation executed')
def load(path):return json.loads(path.read_text(encoding='utf-8-sig'))
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--report',type=Path,default=Path('Tools/output/skill_system_design/validation.json'))
args=parser.parse_args()
result=validate(load(ROOT/'Config/Combat/skill_system_v1.proposal.json'),load(ROOT/'Config/Combat/skill_presentations_v1.proposal.json'),
load(ROOT/'Doc/Design/Sound/BattleSkills/manifest.json'),load(ROOT/'Config/Gameplay/greenfield.json'))
target=args.report.resolve();require(target.is_relative_to(ROOT),'Report must stay in repository')
target.parent.mkdir(parents=True,exist_ok=True);target.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps(result,ensure_ascii=False))
if __name__=='__main__':main()
+33
View File
@@ -0,0 +1,33 @@
"""Validate delivered one-shots and their TangoFlux source/recipe provenance."""
import hashlib
import json
from pathlib import Path
import numpy as np
from scipy.io import wavfile
ROOT=Path(__file__).resolve().parents[1]
DESIGN=ROOT/'Doc/Design/Sound/EnemyCombat'
def main():
generation=json.loads((DESIGN/'generation.json').read_text(encoding='utf-8'))
rows=[]
for item in generation['items']:
path=ROOT/item['output']; source=ROOT/item['source']
assert hashlib.sha256(path.read_bytes()).hexdigest()==item['outputSha256']
assert hashlib.sha256(source.read_bytes()).hexdigest()==item['sourceSha256']
assert item['job']['status']=='succeeded'
assert item['requestHash']==hashlib.sha256(json.dumps(item['request'],sort_keys=True).encode()).hexdigest()
rate,samples=wavfile.read(path);x=samples.astype(float)/32768
assert rate==44100 and x.ndim==2 and x.shape[1]==2 and samples.dtype==np.int16
assert .1<len(x)/rate<1 and np.isfinite(x).all()
assert np.max(np.abs(x))<.95 and np.max(np.abs(x[:4410]))>.05
assert np.max(np.abs(x[-10:]))<.003
rms=float(20*np.log10(np.sqrt(np.mean(x*x))))
assert rms>-36
rows.append(dict(id=item['id'],seconds=len(x)/rate,peakDb=float(20*np.log10(np.max(np.abs(x)))),rmsDb=rms,clippedSamples=int(np.sum(np.abs(x)>=.999)),hashesMatch=True))
assert len(rows)==8
report=dict(status='pass',clips=rows,checks='source/output/request hashes; succeeded job; PCM16 44.1kHz stereo; short duration; prompt onset; fade tail; finite samples; zero clipping',subjectiveListening='not_asserted_by_automated_QA',previewOrder=[item['id'] for item in generation['items']])
(DESIGN/'audio_validation.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('PASS eight TangoFlux one-shots: provenance, format, onset, level, fade and zero clipping')
if __name__=='__main__':main()