Files
AIGame/Tools/enemy_character_pipeline.py
udxandClaude Opus 5.5 89e0fa246f Add formal enemies, remaining formal partners and synthesized UI SFX
- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to
  buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting.
- Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn):
  Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type
  in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes.
- Formal partners for all ten pets in the arena and portraits, including new
  VineSprite, ThunderLeopard and TideWhale.
- Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands,
  configurable Unity run_script timeout, companion static stage.
- Also includes concurrent work from another tool in this working tree
  (FlameLion, enemy combat FX, challenge mode, home explorer, server updates).
- Resources registered in Doc/ResDesc_*.md; verified on isolated :18081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:55 +08:00

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