- 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>
254 lines
16 KiB
Python
254 lines
16 KiB
Python
"""Recipe-driven character art production: same rig -> three clips -> visual prefab -> QA.
|
|
|
|
Default is local cached production. --generate explicitly requests DGXA inference.
|
|
Unity-managed assets are imported/authored only through Unity Pipeline.
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
REPO=Path(__file__).resolve().parents[1]
|
|
PROJECT=REPO/'AIClient'
|
|
|
|
def sha(path):
|
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
|
|
def resolve(path):
|
|
result=(REPO/path).resolve()
|
|
if not result.is_relative_to(REPO):
|
|
raise ValueError(f'Path leaves repository: {path}')
|
|
return result
|
|
|
|
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'] 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','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'):
|
|
if not r[key].startswith('Assets/Bundles/') or '..' in Path(r[key]).parts:
|
|
raise ValueError(f'Invalid Unity asset path: {key}')
|
|
for key in ('approvedMesh','rigSource','outputDir'):
|
|
resolve(r[key])
|
|
if not (.05<r['height']<20 and 0<r['walkStride']<r['height'] and 0<r['walkLift']<r['height']*.2):
|
|
raise ValueError('Invalid dimensions/stride')
|
|
if not 0<r['attackWindup']<r['attackImpact']<r['attackRecover']<1:
|
|
raise ValueError('Invalid attack phase order')
|
|
if not 0<=r.get('attackReach',0)<r['height']*.25:
|
|
raise ValueError('Invalid attack reach')
|
|
if not 0<r.get('walkSourceWeight',1)<=1:
|
|
raise ValueError('Invalid Walk source weight')
|
|
if {m['name'] for m in r['motions']}!={'Idle','Walk','Attack'} or len(r['motions'])!=3:
|
|
raise ValueError('Exactly Idle, Walk and Attack required')
|
|
for m in r['motions']:
|
|
resolve(m['sourceDir'])
|
|
if not m['prompt'].isascii() or ('jump' in m['prompt'].lower() and m['name']=='Walk'):
|
|
raise ValueError('Use explicit English action captions; do not mix walk and jump')
|
|
return r
|
|
|
|
def unity(command,**kwargs):
|
|
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=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)
|
|
result=data.get('data',{}).get('result')
|
|
if not data.get('success') or isinstance(result,dict) and result.get('success') is False:
|
|
raise RuntimeError(json.dumps(data,ensure_ascii=False))
|
|
return result
|
|
|
|
def script(file,entry,args):
|
|
result=unity('run_script',file='AgentScripts/'+file+'.cs',entry=entry,args=args)
|
|
print(result.get('result',result),flush=True)
|
|
return result
|
|
|
|
def wait_editor(play_mode):
|
|
deadline=time.monotonic()+60
|
|
while time.monotonic()<deadline:
|
|
try:
|
|
state=unity('editor_status')
|
|
if state['playMode']==play_mode and not state['compiling'] and not state.get('domainReloadInProgress'):
|
|
return
|
|
except RuntimeError:
|
|
pass # Pipeline reconnects during the expected domain reload.
|
|
time.sleep(.25)
|
|
raise TimeoutError('Editor did not reach '+play_mode)
|
|
|
|
def cached_sources(r):
|
|
rig_hash=sha(resolve(r['rigSource']))
|
|
receipts={}
|
|
for m in r['motions']:
|
|
folder=resolve(m['sourceDir']);receipt=folder/'motion_request.json'
|
|
if not receipt.is_file() and (folder/'walk_request.json').is_file():
|
|
receipt=folder/'walk_request.json'
|
|
if not (folder/'animated.fbx').is_file() and m.get('cachedFbx'):
|
|
# Only accept a cache whose accompanying source rig is byte-identical.
|
|
if sha(resolve(m['cachedRig']))!=rig_hash:
|
|
raise ValueError('Cached action was generated from another rig: '+m['name'])
|
|
folder.mkdir(parents=True,exist_ok=True)
|
|
shutil.copy2(resolve(m['cachedFbx']),folder/'animated.fbx')
|
|
receipt=folder/'motion_request.json'
|
|
receipt.write_text(json.dumps(dict(rig_sha256=rig_hash,motion=m['name'],
|
|
prompt=m['cachedPrompt'],cache_original=m['cachedFbx'],cache_rig=m['cachedRig'],
|
|
note='Existing same-rig generated seed. Offline authoring defines final action semantics.'),indent=2),encoding='utf-8')
|
|
if not receipt.is_file() or not (folder/'animated.fbx').is_file():
|
|
raise FileNotFoundError('Missing source motion/provenance: '+m['name'])
|
|
record=json.loads(receipt.read_text(encoding='utf-8-sig'))
|
|
if record['rig_sha256'].lower()!=rig_hash:
|
|
raise ValueError('Motion rig hash mismatch: '+m['name'])
|
|
if record.get('source_fbx_sha256') and record['source_fbx_sha256']!=sha(folder/'animated.fbx'):
|
|
raise ValueError('Motion source FBX hash mismatch: '+m['name'])
|
|
receipts[m['name']]=dict(source=str(folder/'animated.fbx'),sha256=sha(folder/'animated.fbx'),request=record)
|
|
return receipts
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--recipe',type=Path,required=True)
|
|
parser.add_argument('--generate',nargs='+',choices=('Idle','Walk','Attack'))
|
|
parser.add_argument('--generation-device',choices=('cuda','cpu'),default='cuda')
|
|
parser.add_argument('--build-review',action='store_true')
|
|
parser.add_argument('--play-check',action='store_true',help='With --build-review, verify all states in actual Play Mode and return to Edit Mode')
|
|
parser.add_argument('--validate-recipe',action='store_true')
|
|
args=parser.parse_args()
|
|
recipe_path=args.recipe.resolve();r=recipe(recipe_path)
|
|
if args.validate_recipe:
|
|
print('PASS recipe',r['id']);return
|
|
if args.play_check and not args.build_review:
|
|
parser.error('--play-check requires --build-review')
|
|
unity('set_autotick',enable=True)
|
|
status=unity('editor_status')
|
|
if status['playMode']!='stopped':raise RuntimeError('Use Edit Mode')
|
|
if args.generate:
|
|
subprocess.run([sys.executable,str(REPO/'Tools/generate_enemy_motions.py'),'--recipe',str(recipe_path),'--device',args.generation_device,'--motions',*args.generate],cwd=REPO,check=True)
|
|
sources=cached_sources(r)
|
|
output=resolve(r['outputDir']);output.mkdir(parents=True,exist_ok=True)
|
|
(output/'production_manifest.json').write_text(json.dumps(dict(schemaVersion=1,status='building',id=r['id'])),encoding='utf-8')
|
|
blender=os.environ.get('BLENDER_EXE',r'D:\Tools\Blender\blender-5.0.0-windows-x64\blender.exe')
|
|
rigged=output/(r['id']+'_Rigged.fbx')
|
|
subprocess.run([blender,'-b','--factory-startup','--python',str(REPO/'Tools/model_tools/bl_transfer_skin.py'),'--',
|
|
str(resolve(r['rigSource'])),str(resolve(r['approvedMesh'])),str(rigged),'--low-up',r['lowUp'],'--align-bounds',
|
|
'--weight-overrides',str(recipe_path)],cwd=REPO,check=True)
|
|
unity('import_asset',source=str(rigged),path=r['assetRoot']+'/Rigged/'+r['id']+'_Rigged.fbx',confirm=True)
|
|
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])
|
|
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])
|
|
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])
|
|
script('BuildEnemyReview','BuildEnemyReview.Capture',[relative])
|
|
live_result=None
|
|
if args.play_check:
|
|
unity('editor_play')
|
|
try:
|
|
wait_editor('playing')
|
|
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,
|
|
save_path='Assets/Screenshots/'+role+'_'+r['id'].lower()+'_play.png')
|
|
finally:
|
|
unity('editor_stop')
|
|
wait_editor('stopped')
|
|
(output/'play_verification.txt').write_text(live_result,encoding='utf-8')
|
|
manifest=dict(schemaVersion=1,status='validated' if live_result else 'edit_validated',id=r['id'],usage=r['usage'],recipe=relative,
|
|
approved_mesh_sha256=sha(resolve(r['approvedMesh'])),rig_sha256=sha(resolve(r['rigSource'])),
|
|
sources=sources,rigged_fbx=str(rigged),visual_prefab=r['visualPrefab'],
|
|
motions=['Idle','Walk','Attack'],controller=dict(Motion='0=Idle,1=Walk',Attack='Trigger, one shot'),
|
|
attack_impact_seconds=r['attackSeconds']*r['attackImpact'],
|
|
qa=dict(edit='passed',play=live_result or 'not_run'),
|
|
report=str(output/'verification.txt'),generated_at_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()))
|
|
if r['usage']=='enemy_visual':manifest['enemy_prefab']=r['visualPrefab']
|
|
(output/'production_manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding='utf-8')
|
|
print('PASS character production',r['visualPrefab'],flush=True)
|
|
|
|
if __name__=='__main__':
|
|
main()
|