Files
AIGame/AIClient/AgentScripts/VerifyEnemyMotions.cs
T

143 lines
10 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;
public float height,walkStride,maxRootTilt,groundTolerance,attackImpact,attackSeconds,attackLunge;
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 bind=Vertices(go);var bb=BoundsOf(bind);
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}");
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>();
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);
}
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}");
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(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;
}
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 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));
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