#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(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().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(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();anim.enabled=false; var model=anim.gameObject;var bones=go.GetComponentsInChildren(); 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=20 && pawGeometry.All(p=>p.Length>=5),"Mane and both feet require actual geometry samples"); int triangles=go.GetComponentsInChildren().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().All(s=>s.sharedMaterial.shader.name=="Test/XPbr"),"Expected approved XPbr material"); var approved=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(r.staticPrefab),scene); float shape; try {var points=approved.GetComponentsInChildren().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(); foreach(string name in walkOnly ? new[] {"Walk"} : new[] {"Idle","Walk","Attack"}) { var clip=AssetDatabase.LoadAssetAtPath(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();var left=new List();var roots=new List(); 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(),new List()};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;armp[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;wVector2.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 && drift0) { 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 step=seconds=> {for(int f=0;f-.01f,"Transition penetrates ground");}}; Action 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 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();var seen=new HashSet();float maxTilt=0; var root=unit.GetComponentsInChildren().First(t=>t.name==r.rootBone); var chest=r.attackStyle=="flame_roar" ? unit.GetComponentsInChildren().First(t=>t.name==r.chestBone) : null; var chestBind=chest!=null ? AssetDatabase.LoadAssetAtPath(r.assetRoot+"/"+r.id+"_Rigged.prefab").GetComponentsInChildren(true).First(t=>t.name==r.chestBone).rotation : Quaternion.identity; var position=unit.transform.position; var deadline=DateTime.UtcNow.AddSeconds(10); while(DateTime.UtcNow