Files
AIGame/AIClient/AgentScripts/VerifyQuadrupedMotions.cs
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

164 lines
15 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;
/// <summary>Actual four-paw support, body orientation and rigid shell/armor/horn QA.</summary>
public static class VerifyQuadrupedMotions
{
[Serializable] public sealed class Recipe
{
public string id,assetRoot,staticPrefab,rootBone,shellBone,chestBone,headBone,outputDir,attackEvent,profile;
public string frontRightFootTip,frontLeftFootTip,hindRightFootTip,hindLeftFootTip;
public float height,walkStride,walkLift,maxRootTilt,groundTolerance,shellRigidityTolerance,attackHeadReach,attackImpact,attackSeconds;
public float[] shellRegionMin,shellRegionMax;
public float[] armorRegionMin,armorRegionMax,hornRegionMin,hornRegionMax;
public float armorRigidityTolerance,hornRigidityTolerance,attackLunge,attackHeadPitchDegrees;
public int maxTriangles;
}
static string Repo=>Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
static Recipe Read(string path)=>JsonUtility.FromJson<Recipe>(File.ReadAllText(Path.Combine(Repo,path)));
static void Require(bool ok,string message){if(!ok)throw new Exception(message);}
static Vector3[] Vertices(GameObject go)=>go.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(s=> {
var m=s.sharedMesh;var w=m.boneWeights;var bind=m.bindposes;
var matrices=s.bones.Select((b,i)=>b.localToWorldMatrix*bind[i]).ToArray();
return m.vertices.Select((v,i)=>matrices[w[i].boneIndex0].MultiplyPoint3x4(v)*w[i].weight0+
matrices[w[i].boneIndex1].MultiplyPoint3x4(v)*w[i].weight1+
matrices[w[i].boneIndex2].MultiplyPoint3x4(v)*w[i].weight2+
matrices[w[i].boneIndex3].MultiplyPoint3x4(v)*w[i].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();
static bool Inside(Vector3 p,float[] lo,float[] hi)=>Enumerable.Range(0,3).All(c=>p[c]>=lo[c] && p[c]<=hi[c]);
public static string Walk(string path)=>RunCore(path,true);
public static string Run(string path)=>RunCore(path,false);
static string RunCore(string path,bool walkOnly)
{
var r=Read(path);var sb=new StringBuilder();var scene=EditorSceneManager.NewPreviewScene();
try
{
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>(r.assetRoot+"/"+r.id+"_Rigged.prefab");
Require(prefab!=null,"Missing quadruped prefab");
var go=(GameObject)PrefabUtility.InstantiatePrefab(prefab,scene);var anim=go.GetComponentInChildren<Animator>();anim.enabled=false;
var model=anim.gameObject;var bones=go.GetComponentsInChildren<Transform>();
bool heavy=r.profile=="quadruped_heavy";
var root=bones.First(b=>b.name==r.rootBone);var shell=bones.First(b=>b.name==(heavy ? r.chestBone : r.shellBone));var head=bones.First(b=>b.name==r.headBone);
var feet=new[]{r.frontRightFootTip,r.frontLeftFootTip,r.hindRightFootTip,r.hindLeftFootTip}.Select(n=>bones.First(b=>b.name==n)).ToArray();
Require(feet.Distinct().Count()==4,"Four distinct paw tips required");
Require(head.position.z<shell.position.z && (feet[0].position.z+feet[1].position.z)<(feet[2].position.z+feet[3].position.z),
"Quadruped head and front paws must face game -Z; verify handedness before authoring");
var bind=Vertices(go);var bb=BoundsOf(bind);var shellBind=shell.rotation;var shellPosition=shell.position;var headBind=head.rotation;var headPosition=head.position;
int tris=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 && tris<=r.maxTriangles,"Bind dimensions/triangle budget invalid");
Require(go.GetComponentsInChildren<SkinnedMeshRenderer>().All(s=>s.sharedMaterial.shader.name=="Test/XPbr"),"XPbr required");
var approved=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(r.staticPrefab),scene);
var staticPoints=approved.GetComponentsInChildren<MeshFilter>().SelectMany(m=>m.sharedMesh.vertices.Select(v=>m.transform.TransformPoint(v))).ToArray();
float shape=bind.Max(v=>staticPoints.Min(p=>Vector3.Distance(v,p)));Object.DestroyImmediate(approved);
Require(shape<.002f,"Approved quadruped geometry changed");
var shellIndices=Enumerable.Range(0,bind.Length).Where(i=>Inside(bind[i]/r.height,heavy ? r.armorRegionMin : r.shellRegionMin,heavy ? r.armorRegionMax : r.shellRegionMax)).ToArray();
var hornIndices=heavy ? Enumerable.Range(0,bind.Length).Where(i=>Inside(bind[i]/r.height,r.hornRegionMin,r.hornRegionMax)).ToArray() : new int[0];
Require(shellIndices.Length>=20,"Shell/armor audit region too small");
if(heavy)Require(hornIndices.Length>=5,"Horn audit region too small");
var footIndices=Enumerable.Range(0,4).Select(f=>Enumerable.Range(0,bind.Length).Where(i=> {
var distances=feet.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*.24f && distances[f]==distances.Min() && distances[f]<r.height*.28f;
}).ToArray()).ToArray();
Require(footIndices.All(p=>p.Length>=5),"Each paw needs its own sampled geometry");
sb.AppendLine($"BIND height={bb.size.y:F6} bottom={bb.min.y:F6} tris={tris} staticVertexError={shape:F6} bodyVertices={shellIndices.Length} hornVertices={hornIndices.Length} pawVertices={string.Join("/",footIndices.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,"Missing "+name);clips[name]=clip;
foreach(var b in AnimationUtility.GetCurveBindings(clip))Require(string.IsNullOrEmpty(b.path)||model.transform.Find(b.path)!=null,"Unbound "+b.path);
Require(AnimationUtility.GetAnimationClipSettings(clip).loopTime==(name!="Attack"),"Wrong loop setting");
var traces=Enumerable.Range(0,4).Select(_=>new List<float>()).ToArray();var lifts=new float[4];
Vector3[] start=null,end=null;Vector3 rootStart=Vector3.zero;float headStart=0,bodyStart=0,bodyForward=0,headPitch=0,hornLowering=0,forward=0,retract=0,minY=1e9f,maxY=-1e9f,movement=0,tilt=0,rigidity=0,hornError=0,drift=0,minHeight=1e9f;int minSupport=4;
for(int f=0;f<=120;f++)
{
clip.SampleAnimation(model,clip.length*f/120f);var p=Vertices(go);var bounds=BoundsOf(p);
if(f==0){start=p;rootStart=root.position;headStart=(head.position-root.position).z;bodyStart=(shell.position-root.position).z;}if(f==120)end=p;
minY=Mathf.Min(minY,bounds.min.y);maxY=Mathf.Max(maxY,bounds.min.y);minHeight=Mathf.Min(minHeight,bounds.size.y);movement=Mathf.Max(movement,Difference(start,p));
// Measure the visible body, not only the deliberately pinned root bone.
tilt=Mathf.Max(tilt,Vector3.Angle(shell.rotation*Quaternion.Inverse(shellBind)*Vector3.up,Vector3.up));
drift=Mathf.Max(drift,Vector2.Distance(new Vector2(root.position.x,root.position.z),new Vector2(rootStart.x,rootStart.z)));
var inverse=Quaternion.Inverse(shell.rotation*Quaternion.Inverse(shellBind));
rigidity=Mathf.Max(rigidity,shellIndices.Max(i=>Vector3.Distance(bind[i],inverse*(p[i]-shell.position)+shellPosition)));
if(heavy)
{
var inverseHead=Quaternion.Inverse(head.rotation*Quaternion.Inverse(headBind));
hornError=Mathf.Max(hornError,hornIndices.Max(i=>Vector3.Distance(bind[i],inverseHead*(p[i]-head.position)+headPosition)));
bodyForward=Mathf.Max(bodyForward,bodyStart-(shell.position-root.position).z);
headPitch=Mathf.Max(headPitch,Quaternion.Angle(headBind,head.rotation));
// Lowering is measured from visible horn geometry relative to
// the head pivot, so an unrelated yaw cannot pass as a nod.
hornLowering=Mathf.Max(hornLowering,hornIndices.Average(i=>bind[i].y-headPosition.y)-hornIndices.Average(i=>p[i].y-head.position.y));
}
int support=0;
for(int foot=0;foot<4;foot++)
{
float bottom=footIndices[foot].Min(i=>p[i].y);Require(bottom>=-r.groundTolerance,"Paw penetrates floor");
if(bottom<=r.groundTolerance)support++;lifts[foot]=Mathf.Max(lifts[foot],bottom);
traces[foot].Add(footIndices[foot].Average(i=>p[i].z-root.position.z));
}
minSupport=Mathf.Min(minSupport,support);
float headZ=(head.position-root.position).z;forward=Mathf.Max(forward,headStart-headZ);retract=Mathf.Max(retract,headZ-headStart);
}
float seam=Difference(start,end);var strides=traces.Select(v=>v.Max()-v.Min()).ToArray();
sb.AppendLine($"{name} len={clip.length:F3} bottom=[{minY:F6},{maxY:F6}] minHeight={minHeight:F5} bindUpTilt={tilt:F3} movement={movement:F5} seam={seam:F6} rootXZ={drift:F5} bodyError={rigidity:F6} hornError={hornError:F6} support={minSupport} strides={string.Join("/",strides.Select(v=>v.ToString("F5")))} lifts={string.Join("/",lifts.Select(v=>v.ToString("F5")))} headForward={forward:F5} retract={retract:F5} shoulderForward={bodyForward:F5} headPitch={headPitch:F3} hornLowering={hornLowering:F5}");
Require(minY>=-r.groundTolerance && maxY<=r.groundTolerance && tilt<=r.maxRootTilt && minHeight>.8f*r.height,"Orientation/ground failed\n"+sb);
Require(seam<.001f && drift<.001f && rigidity<=(heavy ? r.armorRigidityTolerance : r.shellRigidityTolerance) && (!heavy || hornError<=r.hornRigidityTolerance),"Seam/root/rigid geometry failed\n"+sb);
if(name=="Walk")Require(strides.All(v=>v>r.walkStride*.5f) && lifts.All(v=>v>r.walkLift*.4f) && minSupport>=3,"Four-paw gait failed\n"+sb);
else Require(minSupport==4,"Standing/attack must brace on four paws\n"+sb);
if(name=="Idle")Require(movement>.0002f && movement<.03f,"Idle motion invalid\n"+sb);
if(name=="Attack")
{
Require(movement>.02f && forward>r.attackHeadReach*.5f && retract>r.attackHeadReach*.1f,"Head retract/release unreadable\n"+sb);
if(heavy)Require(bodyForward>r.attackLunge*.5f && headPitch>r.attackHeadPitchDegrees*.5f && hornLowering>r.height*.005f,"Heavy bash must drive shoulder and lower head\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,"Release event invalid");
}
}
if(!walkOnly)
{
clips["Idle"].SampleAnimation(model,0);var idle=Vertices(go);clips["Attack"].SampleAnimation(model,clips["Attack"].length);Require(Difference(idle,Vertices(go))<.001f,"Attack recovery mismatch");
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=n=>Require(anim.GetCurrentAnimatorStateInfo(0).IsName(n),"Expected "+n);
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 four-legged Idle<->Walk, single Attack and requested-state recovery");
}
string output=Path.Combine(Repo,r.outputDir,walkOnly ? "walk_verification.txt" : "verification.txt");Directory.CreateDirectory(Path.GetDirectoryName(output));File.WriteAllText(output,sb.ToString());return "PASS quadruped\n"+sb;
}
catch(Exception e)
{
// Keep the actual QA failure even when Pipeline loses the exception
// response during its main-thread dispatch timeout.
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 path)
{
Require(EditorApplication.isPlaying,"Use Play Mode");var r=Read(path);var go=GameObject.Find("CompanionReview_"+r.id+"_Cycle");Require(go!=null,"Missing review cycle");
string body=r.profile=="quadruped_heavy" ? r.chestBone : r.shellBone;
var anim=go.GetComponentInChildren<Animator>();var shell=go.GetComponentsInChildren<Transform>().First(t=>t.name==body);
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>(r.assetRoot+"/"+r.id+"_Rigged.prefab");var bind=prefab.GetComponentsInChildren<Transform>(true).First(t=>t.name==body).rotation;
var seen=new HashSet<string>();var origin=go.transform.position;float tilt=0;var deadline=DateTime.UtcNow.AddSeconds(10);
while(DateTime.UtcNow<deadline)
{
foreach(var name in new[]{"Idle","Walk","Attack"})if(anim.GetCurrentAnimatorStateInfo(0).IsName(name))seen.Add(name);
tilt=Mathf.Max(tilt,Vector3.Angle(shell.rotation*Quaternion.Inverse(bind)*Vector3.up,Vector3.up));Require(Vector3.Distance(origin,go.transform.position)<.001f,"Actor root drift");await Task.Delay(80);
}
Require(seen.Count==3 && tilt<=r.maxRootTilt,"Incomplete quadruped playback");return "PASS Play Mode observed "+string.Join(",",seen)+$" bindUpTilt={tilt:F3} actorRootPinned=True";
}
}
#endif