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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user