- 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>
219 lines
17 KiB
C#
219 lines
17 KiB
C#
#if UNITY_EDITOR
|
|
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using UnityEditor;
|
|
using UnityEditor.SceneManagement;
|
|
using UnityEngine;
|
|
using Object=UnityEngine.Object;
|
|
|
|
public static class VerifyEnemyMotions
|
|
{
|
|
[Serializable] public sealed class Recipe
|
|
{
|
|
public string id,assetRoot,staticPrefab,enemyPrefab,rootBone,rightFootTip,leftFootTip,outputDir,usage,attackEvent,profile,rightWingTip,leftWingTip;
|
|
public string attackStyle,headBone,chestBone,rightPawTip,leftPawTip;
|
|
public float height,walkStride,walkLift,maxRootTilt,groundTolerance,attackImpact,attackSeconds,attackLunge;
|
|
public float attackHeadReach,attackHeadPitchDegrees,maneRigidityTolerance;
|
|
public float[] maneRegionMin,maneRegionMax;
|
|
public int maxTriangles;
|
|
}
|
|
static string Repo=>Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
|
|
static Recipe Read(string path)
|
|
{
|
|
var r=JsonUtility.FromJson<Recipe>(File.ReadAllText(Path.Combine(Repo,path)));
|
|
if(string.IsNullOrEmpty(r.attackEvent))r.attackEvent=r.usage=="companion_visual" ? "OnCompanionAttackImpact" : "OnEnemyAttackImpact";
|
|
return r;
|
|
}
|
|
static void Require(bool condition,string message) {if(!condition) throw new Exception(message);}
|
|
static Vector3[] Vertices(GameObject go)=>go.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(s=> {
|
|
var m=s.sharedMesh;var weights=m.boneWeights;var bind=m.bindposes;
|
|
var matrices=s.bones.Select((b,i)=>b.localToWorldMatrix*bind[i]).ToArray();
|
|
return m.vertices.Select((v,i)=> {var w=weights[i];return matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0
|
|
+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2
|
|
+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3;}).ToArray();
|
|
}).ToArray();
|
|
static Bounds BoundsOf(Vector3[] p) {var b=new Bounds(p[0],Vector3.zero);foreach(var v in p)b.Encapsulate(v);return b;}
|
|
static float Difference(Vector3[] a,Vector3[] b)=>a.Select((v,i)=>Vector3.Distance(v,b[i])).Max();
|
|
public static string Run(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
|
|
=>RunCore(recipePath,false);
|
|
public static string Walk(string recipePath)
|
|
=>RunCore(recipePath,true);
|
|
static string RunCore(string recipePath,bool walkOnly)
|
|
{
|
|
var r=Read(recipePath);var sb=new StringBuilder();
|
|
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>(r.assetRoot+"/"+r.id+"_Rigged.prefab");
|
|
Require(prefab!=null,"Missing rigged prefab");
|
|
var scene=EditorSceneManager.NewPreviewScene();
|
|
try
|
|
{
|
|
var go=(GameObject)PrefabUtility.InstantiatePrefab(prefab,scene);
|
|
var anim=go.GetComponentInChildren<Animator>();anim.enabled=false;
|
|
var model=anim.gameObject;var bones=go.GetComponentsInChildren<Transform>();
|
|
var root=bones.First(b=>b.name==r.rootBone);
|
|
var footR=bones.First(b=>b.name==r.rightFootTip);var footL=bones.First(b=>b.name==r.leftFootTip);
|
|
var wings=r.profile=="avian_grounded" ? new[]{bones.First(b=>b.name==r.rightWingTip),bones.First(b=>b.name==r.leftWingTip)} : new Transform[0];
|
|
bool roar=r.attackStyle=="flame_roar";
|
|
var head=roar ? bones.First(b=>b.name==r.headBone) : null;
|
|
var chest=roar ? bones.First(b=>b.name==r.chestBone) : null;
|
|
var paws=roar ? new[]{r.rightPawTip,r.leftPawTip}.Select(n=>bones.First(b=>b.name==n)).ToArray() : new Transform[0];
|
|
var bind=Vertices(go);var bb=BoundsOf(bind);
|
|
var headBind=roar ? head.rotation : Quaternion.identity;var chestBind=roar ? chest.rotation : Quaternion.identity;
|
|
var headPosition=roar ? head.position : Vector3.zero;
|
|
var mane=roar ? Enumerable.Range(0,bind.Length).Where(i=>Enumerable.Range(0,3).All(c=>bind[i][c]/r.height>=r.maneRegionMin[c] && bind[i][c]/r.height<=r.maneRegionMax[c])).ToArray() : new int[0];
|
|
var footTips=new[]{footR,footL};
|
|
var pawGeometry=roar ? Enumerable.Range(0,2).Select(f=>Enumerable.Range(0,bind.Length).Where(i=> {
|
|
var d=footTips.Select(t=>Vector2.Distance(new Vector2(bind[i].x,bind[i].z),new Vector2(t.position.x,t.position.z))).ToArray();
|
|
return bind[i].y<r.height*.18f && d[f]==d.Min() && d[f]<r.height*.3f;
|
|
}).ToArray()).ToArray() : new int[0][];
|
|
if(roar)Require(mane.Length>=20 && pawGeometry.All(p=>p.Length>=5),"Mane and both feet require actual geometry samples");
|
|
int triangles=go.GetComponentsInChildren<SkinnedMeshRenderer>().Sum(s=>s.sharedMesh.triangles.Length/3);
|
|
Require(Mathf.Abs(bb.size.y-r.height)<.002f && Mathf.Abs(bb.min.y)<.002f,"Bind height/bottom invalid");
|
|
Require(triangles<=r.maxTriangles,"Triangle budget exceeded");
|
|
Require(go.GetComponentsInChildren<SkinnedMeshRenderer>().All(s=>s.sharedMaterial.shader.name=="Test/XPbr"),"Expected approved XPbr material");
|
|
var approved=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(r.staticPrefab),scene);
|
|
float shape;
|
|
try {var points=approved.GetComponentsInChildren<MeshFilter>().SelectMany(m=>m.sharedMesh.vertices.Select(v=>m.transform.TransformPoint(v))).ToArray();shape=bind.Max(v=>points.Min(p=>Vector3.Distance(v,p)));}
|
|
finally {Object.DestroyImmediate(approved);}
|
|
Require(shape<.002f,"Approved mesh changed");
|
|
sb.AppendLine($"BIND height={bb.size.y:F6} bottom={bb.min.y:F6} tris={triangles} staticVertexError={shape:F6}");
|
|
if(roar)sb.AppendLine($"ROAR bindManeVertices={mane.Length} footVertices={string.Join("/",pawGeometry.Select(p=>p.Length))}");
|
|
var clips=new Dictionary<string,AnimationClip>();
|
|
foreach(string name in walkOnly ? new[] {"Walk"} : new[] {"Idle","Walk","Attack"})
|
|
{
|
|
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(r.assetRoot+"/Clips/"+name+".anim");
|
|
Require(clip!=null && clip.length>.2f,"Missing/empty "+name);clips[name]=clip;
|
|
foreach(var b in AnimationUtility.GetCurveBindings(clip)) Require(string.IsNullOrEmpty(b.path)||model.transform.Find(b.path)!=null,"Unbound curve "+name+" "+b.path);
|
|
Require(AnimationUtility.GetAnimationClipSettings(clip).loopTime==(name!="Attack"),"Wrong loop flag "+name);
|
|
Vector3[] start=null,end=null;float minY=float.MaxValue,maxY=float.MinValue,maxTilt=0,movement=0,minHeight=float.MaxValue;
|
|
var right=new List<float>();var left=new List<float>();var roots=new List<Vector3>();
|
|
var wingStart=new Vector3[wings.Length];var wingReach=new float[wings.Length];var rootStartRotation=Quaternion.identity;
|
|
var pawStart=new Vector3[paws.Length];var pawReach=new float[paws.Length];var pawSpread=new float[paws.Length];var lifts=new float[2];
|
|
var actualStride=new[]{new List<float>(),new List<float>()};float maneError=0,headForward=0,headStart=0,headPitch=0;int minSupport=2;
|
|
for(int f=0;f<=120;f++)
|
|
{
|
|
clip.SampleAnimation(model,clip.length*f/120f);
|
|
var p=Vertices(go);var b=BoundsOf(p);
|
|
if(f==0)start=p;if(f==120)end=p;
|
|
movement=Mathf.Max(movement,Difference(start,p));minY=Mathf.Min(minY,b.min.y);maxY=Mathf.Max(maxY,b.min.y);
|
|
minHeight=Mathf.Min(minHeight,b.size.y);maxTilt=Mathf.Max(maxTilt,Vector3.Angle(root.up,Vector3.up));
|
|
right.Add((footR.position-root.position).z);left.Add((footL.position-root.position).z);roots.Add(root.position);
|
|
if(f==0)rootStartRotation=root.rotation;
|
|
if(roar)
|
|
{
|
|
var bodyDelta=chest.rotation*Quaternion.Inverse(chestBind);var bodyInverse=Quaternion.Inverse(bodyDelta);
|
|
maxTilt=Mathf.Max(maxTilt,Vector3.Angle(bodyDelta*Vector3.up,Vector3.up));
|
|
var headInverse=Quaternion.Inverse(head.rotation*Quaternion.Inverse(headBind));
|
|
maneError=Mathf.Max(maneError,mane.Max(i=>Vector3.Distance(bind[i],headInverse*(p[i]-head.position)+headPosition)));
|
|
var relativeHead=bodyInverse*(head.position-chest.position);
|
|
if(f==0)headStart=relativeHead.z;headForward=Mathf.Max(headForward,headStart-relativeHead.z);
|
|
var facing=bodyInverse*(head.rotation*Quaternion.Inverse(headBind)*Vector3.back);
|
|
headPitch=Mathf.Max(headPitch,Mathf.Asin(Mathf.Clamp(facing.y,-1,1))*Mathf.Rad2Deg);
|
|
for(int arm=0;arm<paws.Length;arm++)
|
|
{
|
|
var relative=bodyInverse*(paws[arm].position-chest.position);
|
|
if(f==0)pawStart[arm]=relative;
|
|
pawReach[arm]=Mathf.Max(pawReach[arm],pawStart[arm].z-relative.z);
|
|
pawSpread[arm]=Mathf.Max(pawSpread[arm],Mathf.Abs(relative.x)-Mathf.Abs(pawStart[arm].x));
|
|
}
|
|
int support=0;
|
|
for(int foot=0;foot<2;foot++)
|
|
{
|
|
float bottom=pawGeometry[foot].Min(i=>p[i].y);Require(bottom>=-r.groundTolerance,"Foot penetrates floor");
|
|
if(bottom<=r.groundTolerance)support++;lifts[foot]=Mathf.Max(lifts[foot],bottom);
|
|
actualStride[foot].Add(pawGeometry[foot].Average(i=>p[i].z-root.position.z));
|
|
}
|
|
minSupport=Mathf.Min(minSupport,support);
|
|
}
|
|
for(int w=0;w<wings.Length;w++)
|
|
{
|
|
// Remove actor pitch/yaw so body lean cannot pass as a wing sweep.
|
|
var relative=Quaternion.Inverse(root.rotation*Quaternion.Inverse(rootStartRotation))*(wings[w].position-root.position);
|
|
if(f==0)wingStart[w]=relative;
|
|
wingReach[w]=Mathf.Max(wingReach[w],wingStart[w].z-relative.z);
|
|
}
|
|
}
|
|
float seam=Difference(start,end);float strideR=right.Max()-right.Min(),strideL=left.Max()-left.Min();
|
|
float drift=roots.Max(p=>Vector2.Distance(new Vector2(p.x,p.z),new Vector2(roots[0].x,roots[0].z)));
|
|
sb.AppendLine($"{name} len={clip.length:F3} loop={name!="Attack"} bottom=[{minY:F6},{maxY:F6}] minHeight={minHeight:F5} tilt={maxTilt:F3} movement={movement:F5} seam={seam:F6} rootXZ={drift:F5} feet={strideR:F5}/{strideL:F5}");
|
|
if(roar)
|
|
{
|
|
var strides=actualStride.Select(v=>v.Max()-v.Min()).ToArray();
|
|
sb.AppendLine($"ROAR {name} maneError={maneError:F6} support={minSupport} actualFootStrides={string.Join("/",strides.Select(v=>v.ToString("F5")))} lifts={string.Join("/",lifts.Select(v=>v.ToString("F5")))} headReach={headForward:F5} headPitchUp={headPitch:F3} pawReach={string.Join("/",pawReach.Select(v=>v.ToString("F5")))} pawSpread={string.Join("/",pawSpread.Select(v=>v.ToString("F5")))}");
|
|
Require(maneError<=r.maneRigidityTolerance,"Mane deformation failed\n"+sb);
|
|
if(name=="Walk")Require(minSupport>=1 && strides.All(v=>v>r.walkStride*.5f) && lifts.All(v=>v>r.walkLift*.4f),"Actual biped foot gait failed\n"+sb);
|
|
else Require(minSupport==2,"Roar and idle must brace on both feet\n"+sb);
|
|
if(name=="Attack")Require(headForward>r.attackHeadReach*.5f && headPitch>r.attackHeadPitchDegrees*.4f && pawReach.All(v=>v>.008f) && pawSpread.All(v=>v>.004f),"Head release / both forepaw spread unreadable\n"+sb);
|
|
}
|
|
Require(minY>=-r.groundTolerance && maxY<=r.groundTolerance,"Ground contact failed\n"+sb);
|
|
Require(maxTilt<=r.maxRootTilt && minHeight>r.height*.8f,"Orientation failed\n"+sb);
|
|
Require(seam<.001f,"Pose seam failed\n"+sb);
|
|
if(name=="Idle")Require(movement>.0002f && movement<.03f && drift<.001f,"Idle motion invalid\n"+sb);
|
|
if(name=="Walk")Require(strideR>r.walkStride*.5f && strideL>r.walkStride*.5f && drift<.001f,"Gait invalid\n"+sb);
|
|
if(name=="Attack")
|
|
{
|
|
Require(movement>.02f && drift<r.attackLunge*1.5f+.001f,"Attack unreadable/drifting\n"+sb);
|
|
var events=AnimationUtility.GetAnimationEvents(clip);
|
|
Require(events.Length==1 && events[0].functionName==r.attackEvent && Mathf.Abs(events[0].time-r.attackImpact*r.attackSeconds)<.001f,"Impact event invalid");
|
|
if(wings.Length>0)
|
|
{
|
|
sb.AppendLine($"WINGS bodyRelativeForwardReach={wingReach[0]:F5}/{wingReach[1]:F5}");
|
|
Require(wingReach.All(v=>v>.01f),"Both bird wings must visibly sweep forward\n"+sb);
|
|
}
|
|
}
|
|
}
|
|
if(walkOnly)
|
|
{
|
|
string walkReport=Path.Combine(Repo,r.outputDir,"walk_verification.txt");Directory.CreateDirectory(Path.GetDirectoryName(walkReport));File.WriteAllText(walkReport,sb.ToString());
|
|
return "PASS Walk\n"+sb;
|
|
}
|
|
clips["Idle"].SampleAnimation(model,0);var idlePose=Vertices(go);
|
|
clips["Attack"].SampleAnimation(model,clips["Attack"].length);
|
|
Require(Difference(idlePose,Vertices(go))<.001f,"Attack must recover to Idle reference");
|
|
anim.enabled=true;anim.Rebind();anim.Update(0);
|
|
Action<float> step=seconds=> {for(int f=0;f<Mathf.CeilToInt(seconds*60);f++){anim.Update(1f/60);Require(BoundsOf(Vertices(go)).min.y>-.01f,"Transition penetrates ground");}};
|
|
Action<string> state=name=>Require(anim.GetCurrentAnimatorStateInfo(0).IsName(name),"Expected "+name);
|
|
state("Idle");anim.SetInteger("Motion",1);step(.25f);state("Walk");
|
|
anim.SetTrigger("Attack");step(.2f);state("Attack");step(r.attackSeconds+.3f);state("Walk");step(r.attackSeconds*2);state("Walk");
|
|
anim.SetInteger("Motion",0);step(.25f);state("Idle");anim.SetTrigger("Attack");step(.2f);state("Attack");step(r.attackSeconds+.3f);state("Idle");
|
|
sb.AppendLine("CONTROLLER PASS Idle<->Walk, Idle/Walk->Attack, one trigger = one attack, recover to requested locomotion");
|
|
string report=Path.Combine(Repo,r.outputDir,"verification.txt");Directory.CreateDirectory(Path.GetDirectoryName(report));File.WriteAllText(report,sb.ToString());
|
|
return "PASS\n"+sb;
|
|
}
|
|
catch(Exception e)
|
|
{
|
|
string output=Path.Combine(Repo,r.outputDir,"last_failure.txt");Directory.CreateDirectory(Path.GetDirectoryName(output));File.WriteAllText(output,DateTime.UtcNow.ToString("O")+"\n"+e);throw;
|
|
}
|
|
finally {EditorSceneManager.ClosePreviewScene(scene);}
|
|
}
|
|
public static async Task<string> Live(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
|
|
{
|
|
Require(EditorApplication.isPlaying,"Use Play Mode");var r=Read(recipePath);
|
|
string prefix=r.usage=="companion_visual" ? "CompanionReview_"+r.id : "EnemyReview";
|
|
var unit=GameObject.Find(prefix+"_Cycle");Require(unit!=null,"Open this character's production review");
|
|
var anim=unit.GetComponentInChildren<Animator>();var seen=new HashSet<string>();float maxTilt=0;
|
|
var root=unit.GetComponentsInChildren<Transform>().First(t=>t.name==r.rootBone);
|
|
var chest=r.attackStyle=="flame_roar" ? unit.GetComponentsInChildren<Transform>().First(t=>t.name==r.chestBone) : null;
|
|
var chestBind=chest!=null ? AssetDatabase.LoadAssetAtPath<GameObject>(r.assetRoot+"/"+r.id+"_Rigged.prefab").GetComponentsInChildren<Transform>(true).First(t=>t.name==r.chestBone).rotation : Quaternion.identity;
|
|
var position=unit.transform.position;
|
|
var deadline=DateTime.UtcNow.AddSeconds(10);
|
|
while(DateTime.UtcNow<deadline)
|
|
{
|
|
var state=anim.GetCurrentAnimatorStateInfo(0);
|
|
foreach(string name in new[]{"Idle","Walk","Attack"})if(state.IsName(name))seen.Add(name);
|
|
// Production meshes stay non-readable. Real skin vertices were
|
|
// checked in Edit Mode; live QA observes animation/transform state.
|
|
maxTilt=Mathf.Max(maxTilt,Vector3.Angle(root.up,Vector3.up));
|
|
if(chest!=null)maxTilt=Mathf.Max(maxTilt,Vector3.Angle(chest.rotation*Quaternion.Inverse(chestBind)*Vector3.up,Vector3.up));
|
|
Require(Vector3.Distance(position,unit.transform.position)<.001f,"Unexpected root motion");
|
|
await Task.Delay(80);
|
|
}
|
|
Require(seen.Count==3 && maxTilt<=r.maxRootTilt,"Live playback incomplete: "+string.Join(",",seen)+" tilt="+maxTilt);
|
|
return "PASS Play Mode observed "+string.Join(",",seen)+$" maxRootTilt={maxTilt:F3} actorRootPinned=True";
|
|
}
|
|
}
|
|
#endif
|