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>
This commit is contained in:
udx
2026-10-21 12:23:55 +08:00
co-authored by Claude Opus 5.5
parent 6c64210331
commit 89e0fa246f
780 changed files with 7117051 additions and 15906 deletions
+16 -2
View File
@@ -43,6 +43,17 @@ public static class AuditCharacterVisual
var camera=new GameObject("AuditCamera").AddComponent<Camera>();SceneManager.MoveGameObjectToScene(camera.gameObject,scene);camera.scene=scene;
camera.clearFlags=CameraClearFlags.SolidColor;camera.backgroundColor=new Color(.56f,.64f,.7f);
camera.orthographic=true;camera.orthographicSize=height*.68f;camera.nearClipPlane=.01f;camera.farClipPlane=10;
var visualPoints=unit.GetComponentsInChildren<MeshFilter>().SelectMany(m=>m.sharedMesh.vertices.Select(v=>m.transform.TransformPoint(v))).ToList();
foreach(var skin in unit.GetComponentsInChildren<SkinnedMeshRenderer>())
{
var mesh=skin.sharedMesh;var weights=mesh.boneWeights;
var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*mesh.bindposes[i]).ToArray();
visualPoints.AddRange(mesh.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;
}));
}
var bounds=new Bounds(visualPoints[0],Vector3.zero);foreach(var p in visualPoints)bounds.Encapsulate(p);
var rt=new RenderTexture(640,640,24);var tex=new Texture2D(640,640,TextureFormat.RGB24,false);var previous=RenderTexture.active;
try
{
@@ -50,8 +61,11 @@ public static class AuditCharacterVisual
string[] names={"front","side","back","perspective"};
for(int i=0;i<directions.Length;i++)
{
var center=Vector3.up*height*.5f;camera.transform.position=center+directions[i]*height*3;
camera.transform.LookAt(center);camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;
var center=bounds.center;camera.transform.position=center+directions[i]*Mathf.Max(height,bounds.size.magnitude)*3;
camera.transform.LookAt(center);camera.targetTexture=rt;
// Broad quadrupeds must fit horizontally as well as vertically.
camera.orthographicSize=1.15f*Mathf.Max(visualPoints.Max(p=>Mathf.Abs(Vector3.Dot(p-center,camera.transform.right))),visualPoints.Max(p=>Mathf.Abs(Vector3.Dot(p-center,camera.transform.up))));
camera.Render();RenderTexture.active=rt;
tex.ReadPixels(new Rect(0,0,640,640),0,0);tex.Apply();File.WriteAllBytes(Path.Combine(dir,names[i]+".png"),tex.EncodeToPNG());
}
}
+17
View File
@@ -0,0 +1,17 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
public static class BuildChallenge
{
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build the challenge page in Edit Mode");
string repo = Directory.GetParent(UnityEngine.Application.dataPath).Parent.FullName;
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo, "Doc/UIPrefabCreater/UI_PrototypeChallenges.json")))) throw new Exception("Challenge prefab failed");
SetupUiBundleCollector.Setup();
AssetDatabase.SaveAssets();
return "Built the element challenge page";
}
}
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.IO;
using System.Linq;
using HotUpdate.Gameplay;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>Persistent combat assets, imported sound settings and an isolated review. Run through Pipeline.</summary>
public static class BuildEnemyCombatFx
{
public const string Folder = "Assets/Bundles/Effects/EnemyCombat";
public const string Bank = Folder + "/EnemyCombatPresentation.prefab";
const string Arena = "Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab";
static readonly string[] Names = { "HornletAttack", "DashletAttack", "ShellhornAttack", "HornCaptainAttack", "GreatHornAttack", "HitSoft", "HitArmor", "HitShield" };
static readonly Color[] Colors = { new Color(1,.66f,.23f), new Color(.65f,.9f,1), new Color(1,.7f,.35f), new Color(1,.38f,.14f), new Color(1,.3f,.12f), new Color(1,.94f,.63f), new Color(1,.7f,.28f), new Color(.3f,.85f,1) };
static void Folders(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
Folders(Path.GetDirectoryName(path).Replace('\\','/')); AssetDatabase.CreateFolder(Path.GetDirectoryName(path).Replace('\\','/'), Path.GetFileName(path));
}
static Material Material()
{
string png = Folder + "/CombatSoft.png";
var tex = new Texture2D(64,64,TextureFormat.RGBA32,false);
for (int y=0;y<64;y++) for (int x=0;x<64;x++)
{ float r = Vector2.Distance(new Vector2(x,y),new Vector2(31.5f,31.5f))/31.5f; tex.SetPixel(x,y,new Color(1,1,1,Mathf.Pow(Mathf.Clamp01(1-r),2))); }
tex.Apply(); File.WriteAllBytes(png,tex.EncodeToPNG()); UnityEngine.Object.DestroyImmediate(tex);
AssetDatabase.ImportAsset(png, ImportAssetOptions.ForceUpdate);
var imp=(TextureImporter)AssetImporter.GetAtPath(png); imp.alphaIsTransparency=true; imp.wrapMode=TextureWrapMode.Clamp; imp.mipmapEnabled=false; imp.SaveAndReimport();
string path=Folder+"/CombatGlow.mat";
var mat=AssetDatabase.LoadAssetAtPath<Material>(path);
if(mat==null) { mat=new Material(Shader.Find("AIGame/EnemyCombatParticle") ?? throw new Exception("Particle shader missing")); AssetDatabase.CreateAsset(mat,path); }
mat.mainTexture=AssetDatabase.LoadAssetAtPath<Texture2D>(png); EditorUtility.SetDirty(mat); return mat;
}
static Mesh Arc(string name, float degrees)
{
const int count=40; var verts=new Vector3[(count+1)*2]; var uv=new Vector2[verts.Length]; var tri=new int[count*6];
for(int i=0;i<=count;i++)
{
float a=(i/(float)count*degrees-degrees/2)*Mathf.Deg2Rad;
float width=.065f*Mathf.Sin(Mathf.PI*i/count)+.006f;
for(int j=0;j<2;j++) { float r=.5f+(j==0?-width:width); verts[i*2+j]=new Vector3(Mathf.Sin(a)*r,Mathf.Cos(a)*r,0); uv[i*2+j]=new Vector2(.5f,j==0?.35f:.65f); }
if(i<count) { int k=i*6,v=i*2; tri[k]=v;tri[k+1]=v+1;tri[k+2]=v+2;tri[k+3]=v+1;tri[k+4]=v+3;tri[k+5]=v+2; }
}
string path=Folder+"/"+name+".asset"; var mesh=AssetDatabase.LoadAssetAtPath<Mesh>(path);
if(mesh==null) { mesh=new Mesh();AssetDatabase.CreateAsset(mesh,path); } else mesh.Clear();
mesh.vertices=verts; mesh.uv=uv; mesh.triangles=tri;mesh.RecalculateBounds();EditorUtility.SetDirty(mesh);return mesh;
}
static ParticleSystem Part(Transform parent,string name,Material mat,Color color,int count,float life,float size,float speed,Mesh mesh=null)
{
var go=new GameObject(name);go.layer=30;if(parent!=null)go.transform.SetParent(parent,false);
var ps=go.AddComponent<ParticleSystem>();var main=ps.main; main.playOnAwake=false;main.loop=false;main.duration=.8f;main.startLifetime=new ParticleSystem.MinMaxCurve(life*.7f,life);main.startSpeed=new ParticleSystem.MinMaxCurve(speed*.5f,speed);main.startSize=new ParticleSystem.MinMaxCurve(size*.65f,size);main.startColor=color;main.maxParticles=48;main.simulationSpace=ParticleSystemSimulationSpace.Local;
var em=ps.emission;em.rateOverTime=0;em.SetBursts(new[]{new ParticleSystem.Burst(0,(short)count)});
var shape=ps.shape;shape.shapeType=ParticleSystemShapeType.Sphere;shape.radius=.055f;
var col=ps.colorOverLifetime;col.enabled=true; var grad=new Gradient();grad.SetKeys(new[]{new GradientColorKey(color,0),new GradientColorKey(color,1)},new[]{new GradientAlphaKey(.95f,0),new GradientAlphaKey(.65f,.25f),new GradientAlphaKey(0,1)});col.color=grad;
var sz=ps.sizeOverLifetime;sz.enabled=true;sz.size=new ParticleSystem.MinMaxCurve(1,AnimationCurve.Linear(0,1,1,.08f));
var rend=ps.GetComponent<ParticleSystemRenderer>();rend.sharedMaterial=mat;
if(mesh!=null) {rend.renderMode=ParticleSystemRenderMode.Mesh;rend.mesh=mesh; main.startRotation3D=true;main.startRotationZ=0;}
else {rend.renderMode=ParticleSystemRenderMode.Stretch;rend.velocityScale=.08f;rend.lengthScale=1.3f;}
return ps;
}
public static string Build()
{
if(EditorApplication.isPlaying)throw new Exception("Author in Edit Mode");
Folders(Folder);var mat=Material();var arc=Arc("CombatArc",200);var ring=Arc("CombatRing",360);
var original=SceneManager.GetActiveScene();var scene=EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,NewSceneMode.Additive);
try
{
SceneManager.SetActiveScene(scene);var assets=new GameObject[8];
for(int i=0;i<8;i++)
{
var root=Part(null,"Vfx_Enemy_"+Names[i],mat,Colors[i], i<5?14:10,i==4?.55f:.32f,i==4?.18f:.10f,i==4?2.8f:1.7f);
var glow=Part(root.transform,"ContactFlash",mat,Colors[i],1,.15f,i==4?1.25f:.65f,0);glow.GetComponent<ParticleSystemRenderer>().renderMode=ParticleSystemRenderMode.Billboard;
var sweep=Part(root.transform,i<5?"StrikeArc":"ImpactRing",mat,Colors[i],1,i==4?.46f:.22f,i==4?2.5f:i<5?1.6f:.85f,0,i<5&&i!=2?arc:ring);
var sz=sweep.sizeOverLifetime;sz.size=new ParticleSystem.MinMaxCurve(1,AnimationCurve.Linear(0,.65f,1,1.3f));
if(i==1) {sweep.transform.localScale=new Vector3(.45f,1.5f,1);sweep.transform.localRotation=Quaternion.Euler(0,0,65);}
if(i==3) {var second=Part(root.transform,"CaptainCrossStrike",mat,Colors[i],1,.26f,1.6f,0,arc);second.transform.localRotation=Quaternion.Euler(0,0,95);}
if(i==4) { var shock=Part(root.transform,"GroundShock",mat,Colors[i],1,.55f,3.5f,0,ring);shock.transform.localPosition=Vector3.down*.75f;shock.transform.localRotation=Quaternion.Euler(90,0,0);var s=shock.sizeOverLifetime;s.size=new ParticleSystem.MinMaxCurve(1,AnimationCurve.Linear(0,.1f,1,1.2f)); }
assets[i]=PrefabUtility.SaveAsPrefabAsset(root.gameObject,Folder+"/Vfx_Enemy_"+Names[i]+".prefab");UnityEngine.Object.DestroyImmediate(root.gameObject);
}
var bank=new GameObject("EnemyCombatPresentation");bank.layer=30;var player=bank.AddComponent<EnemyCombatPresentation>();var so=new SerializedObject(player);
var pools=so.FindProperty("_effects");pools.arraySize=8;var clips=so.FindProperty("_clips");clips.arraySize=8;
for(int i=0;i<8;i++)
{
var list=pools.GetArrayElementAtIndex(i).FindPropertyRelative("instances");list.arraySize=4;
for(int n=0;n<4;n++) { var go=(GameObject)PrefabUtility.InstantiatePrefab(assets[i],bank.transform);go.name=Names[i]+"_"+n;list.GetArrayElementAtIndex(n).objectReferenceValue=go.GetComponent<ParticleSystem>(); }
string path="Assets/Bundles/Sound/SFX/EnemyCombat/Enemy_"+Names[i]+".wav";AssetDatabase.ImportAsset(path,ImportAssetOptions.ForceUpdate);
var imp=(AudioImporter)AssetImporter.GetAtPath(path);if(imp==null)throw new Exception("Missing "+path);
var st=imp.defaultSampleSettings;st.loadType=AudioClipLoadType.DecompressOnLoad;st.compressionFormat=AudioCompressionFormat.PCM;st.sampleRateSetting=AudioSampleRateSetting.PreserveSampleRate;st.preloadAudioData=true;imp.defaultSampleSettings=st;imp.forceToMono=false;imp.SaveAndReimport();
clips.GetArrayElementAtIndex(i).objectReferenceValue=AssetDatabase.LoadAssetAtPath<AudioClip>(path);
}
var voices=so.FindProperty("_voices");voices.arraySize=8;
for(int n=0;n<8;n++) {var go=new GameObject("Voice"+n);go.transform.SetParent(bank.transform,false);var source=go.AddComponent<AudioSource>();source.playOnAwake=false;source.spatialBlend=0;source.volume=.40f;source.priority=100;voices.GetArrayElementAtIndex(n).objectReferenceValue=source;}
so.ApplyModifiedPropertiesWithoutUndo();PrefabUtility.SaveAsPrefabAsset(bank,Bank);UnityEngine.Object.DestroyImmediate(bank);
}
finally {SceneManager.SetActiveScene(original);EditorSceneManager.CloseScene(scene,true);}
var arena=PrefabUtility.LoadPrefabContents(Arena);
try {var old=arena.transform.Find("EnemyCombatPresentation");if(old!=null)UnityEngine.Object.DestroyImmediate(old.gameObject);PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Bank),arena.transform);PrefabUtility.SaveAsPrefabAsset(arena,Arena);}
finally {PrefabUtility.UnloadPrefabContents(arena);}
AssetDatabase.SaveAssets();return Verify();
}
public static string Verify()
{
foreach(var name in new[]{"review","battle"})
if(File.Exists("Assets/Screenshots/enemy_combat_fx_"+name+".png"))AssetDatabase.ImportAsset("Assets/Screenshots/enemy_combat_fx_"+name+".png",ImportAssetOptions.ForceUpdate);
var bank=AssetDatabase.LoadAssetAtPath<GameObject>(Bank);var p=bank.GetComponent<EnemyCombatPresentation>();
if(ShaderUtil.ShaderHasError(Shader.Find("AIGame/EnemyCombatParticle")))throw new Exception("Combat shader compilation failed");
if(p.EffectCount!=8||p.VoiceCount!=8)throw new Exception("Missing bank slots");
var clips=Enumerable.Range(0,8).Select(i=> {var c=p.Clip(i);if(c==null||c.length>1||c.frequency!=44100||c.channels!=2)throw new Exception("Invalid clip "+i);var imp=(AudioImporter)AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(c));if(imp.defaultSampleSettings.compressionFormat!=AudioCompressionFormat.PCM||imp.defaultSampleSettings.loadType!=AudioClipLoadType.DecompressOnLoad)throw new Exception("Unexpected importer for "+c.name);return new {c.name,c.length,c.frequency,c.channels};}).ToArray();
if(bank.GetComponentsInChildren<ParticleSystem>().Any(ps=>ps.main.playOnAwake||ps.main.loop))throw new Exception("Loop/autoplay in one shot");
if(AssetDatabase.LoadAssetAtPath<GameObject>(Arena).GetComponentInChildren<EnemyCombatPresentation>(true)==null)throw new Exception("Arena missing bank");
var report=JsonConvert.SerializeObject(new {status="pass",bank=Bank,effects=8,poolInstances=32,voices=8,clips},Formatting.Indented);
string path="../Doc/Design/Sound/EnemyCombat/unity_assets.json";Directory.CreateDirectory(Path.GetDirectoryName(path));File.WriteAllText(path,report);return report;
}
}
+28 -19
View File
@@ -2,6 +2,7 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
@@ -98,37 +99,45 @@ public static class BuildEnemyReview
var view=SceneView.lastActiveSceneView;if(view!=null){view.LookAt(new Vector3(r.reviewOriginX,.35f,0),Quaternion.Euler(12,-12,0),3.6f);view.Repaint();}
return "Built additive review "+r.reviewScene+"; other scenes preserved";
}
public static string Capture(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
public static async Task<string> Capture(string recipePath="Config/Art/EnemyCharacters/RockBall.json")
{
var r=Read(recipePath);var camera=GameObject.Find(Prefix(r)+"Camera").GetComponent<Camera>();
var units=new[]{"Idle","Walk","Attack","Cycle"}.Select(n=>GameObject.Find(Prefix(r)+"_"+n)).ToArray();
var cycles=units.Select(g=>g.GetComponent<CharacterReviewAnimCycle>()).ToArray();
var labels=units.Select(g=>g.transform.Find("NameLabel").GetComponent<TextMesh>()).ToArray();
var labelTexts=labels.Select(t=>t.text).ToArray();
var animators=units.Select(g=>g.GetComponentInChildren<Animator>()).ToArray();
var transforms=units.SelectMany(g=>g.GetComponentsInChildren<Transform>()).ToArray();
var p=transforms.Select(t=>t.localPosition).ToArray();var q=transforms.Select(t=>t.localRotation).ToArray();var s=transforms.Select(t=>t.localScale).ToArray();
var r=Read(recipePath);var sourceCamera=GameObject.Find(Prefix(r)+"Camera").GetComponent<Camera>();
var names=new[]{"Static","Idle","Walk","Attack","Cycle"};
var scene=EditorSceneManager.NewPreviewScene();
var rt=new RenderTexture(1900,760,24);var tex=new Texture2D(1900,760,TextureFormat.RGB24,false);var active=RenderTexture.active;
try
{
for(int i=0;i<units.Length;i++)
// Fresh prefab instances have no review-cycle state or cached Animator
// pose. The live review and its labels are never paused or modified.
var container=new GameObject("IsolatedReviewCapture");SceneManager.MoveGameObjectToScene(container,scene);
var camera=Object.Instantiate(sourceCamera,container.transform);camera.scene=scene;camera.enabled=false;
foreach(var light in sourceCamera.transform.parent.GetComponentsInChildren<Light>())Object.Instantiate(light,container.transform);
var floor=sourceCamera.transform.parent.Find("Ground_Y0");if(floor!=null)Object.Instantiate(floor,container.transform,false);
for(int i=0;i<names.Length;i++)
{
cycles[i].enabled=false;animators[i].enabled=false;
string name=i==0 ? "Idle" : i==1 ? "Walk" : "Attack";
labels[i].text=i==3 ? "Auto cycle" : name+(i==2 ? " (impact)" : " (loop)");
var source=GameObject.Find(Prefix(r)+"_"+names[i]);
var asset=AssetDatabase.LoadAssetAtPath<GameObject>(i==0 ? r.staticPrefab : r.visualPrefab);
var go=(GameObject)PrefabUtility.InstantiatePrefab(asset,scene);go.name="Capture_"+names[i];
go.transform.position=source.transform.position;go.transform.rotation=source.transform.rotation;go.transform.localScale=source.transform.localScale;
var label=Object.Instantiate(source.transform.Find("NameLabel"),go.transform,false).GetComponent<TextMesh>();
label.text=i==0 ? "Static" : i==4 ? "Auto cycle" : names[i]+(i==3 ? " (impact)" : " (loop)");
var marker=source.transform.Find("ForwardMinusZ");if(marker!=null)Object.Instantiate(marker,go.transform,false);
foreach(var t in go.GetComponentsInChildren<Transform>(true))t.gameObject.layer=source.layer;
if(i==0)continue;
var animator=go.GetComponentInChildren<Animator>();animator.enabled=false;
foreach(var skin in go.GetComponentsInChildren<SkinnedMeshRenderer>())skin.updateWhenOffscreen=true;
string name=i==1 ? "Idle" : i==2 ? "Walk" : "Attack";
var clip=AssetDatabase.LoadAssetAtPath<AnimationClip>(r.assetRoot+"/Clips/"+name+".anim");
clip.SampleAnimation(animators[i].gameObject,clip.length*(i==0 ? .5f : i==1 ? .25f : r.attackImpact));
clip.SampleAnimation(animator.gameObject,clip.length*(i==1 ? .5f : i==2 ? .25f : r.attackImpact));
}
await Task.Delay(100);
camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;tex.ReadPixels(new Rect(0,0,1900,760),0,0);tex.Apply();
string path="Assets/Screenshots/"+(Companion(r) ? "companion_" : "enemy_")+r.id.ToLowerInvariant()+"_review.png";
File.WriteAllBytes(Path.Combine(Application.dataPath,"../",path),tex.EncodeToPNG());return path;
File.WriteAllBytes(Path.Combine(Application.dataPath,"../",path),tex.EncodeToPNG());AssetDatabase.Refresh();return path;
}
finally
{
RenderTexture.active=active;camera.targetTexture=null;rt.Release();Object.DestroyImmediate(rt);Object.DestroyImmediate(tex);
for(int i=0;i<transforms.Length;i++){transforms[i].localPosition=p[i];transforms[i].localRotation=q[i];transforms[i].localScale=s[i];}
for(int i=0;i<units.Length;i++){labels[i].text=labelTexts[i];animators[i].enabled=true;cycles[i].enabled=true;}
AssetDatabase.Refresh();
RenderTexture.active=active;rt.Release();Object.DestroyImmediate(rt);Object.DestroyImmediate(tex);EditorSceneManager.ClosePreviewScene(scene);
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEngine;
/// <summary>Installs the empty camp painting and authors the placeholder explorer.</summary>
public static class BuildHomeExplorer
{
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
const string Folder = "Assets/Bundles/Characters/Explorer";
const string Generated = @"C:\Users\Administrator\.cursor\projects\d-UD-AI-AIGame\assets\home_camp_clear.jpg";
public static string Install()
{
if (EditorApplication.isPlaying) throw new Exception("Install the camp background in Edit Mode");
string design = Path.Combine(Repo, "Doc/Design/UI/Home");
string source = Path.Combine(design, "home_camp_source.png");
string cast = Path.Combine(design, "home_camp_with_cast.png");
if (!File.Exists(cast)) File.Copy(source, cast, false);
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false);
try
{
if (!tex.LoadImage(File.ReadAllBytes(Generated))) throw new Exception("Camp image failed to decode");
byte[] png = tex.EncodeToPNG();
File.WriteAllBytes(source, png);
Directory.CreateDirectory(Path.Combine(Application.dataPath, "Bundles/UI/Texture/Home"));
File.WriteAllBytes(Path.Combine(Application.dataPath, "Bundles/UI/Texture/Home/home_camp.png"), png);
}
finally { UnityEngine.Object.DestroyImmediate(tex); }
AssetDatabase.ImportAsset("Assets/Bundles/UI/Texture/Home/home_camp.png");
var importer = (TextureImporter)AssetImporter.GetAtPath("Assets/Bundles/UI/Texture/Home/home_camp.png");
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single;
importer.mipmapEnabled = false;
importer.alphaIsTransparency = true;
importer.textureCompression = TextureImporterCompression.Uncompressed;
importer.filterMode = FilterMode.Bilinear;
importer.wrapMode = TextureWrapMode.Clamp;
var settings = new TextureImporterSettings();
importer.ReadTextureSettings(settings);
settings.spriteMeshType = SpriteMeshType.FullRect;
importer.SetTextureSettings(settings);
importer.SaveAndReimport();
return "Installed empty camp painting " + importer.spriteImportMode;
}
public static string Prefab()
{
if (EditorApplication.isPlaying) throw new Exception("Author the explorer in Edit Mode");
Directory.CreateDirectory(Path.Combine(Application.dataPath, "Bundles/Characters/Explorer"));
var root = new GameObject("ExplorerSimple");
try
{
root.AddComponent<HotUpdate.Gameplay.ExplorerPuppet>();
Part(root.transform, "Body", PrimitiveType.Capsule, new Vector3(0f, 0.78f, 0f), new Vector3(0.5f, 0.42f, 0.36f), "#C4895A");
Part(root.transform, "Head", PrimitiveType.Sphere, new Vector3(0f, 1.28f, -0.02f), Vector3.one * 0.4f, "#F3D2B3");
Part(root.transform, "Hair", PrimitiveType.Sphere, new Vector3(0f, 1.46f, 0.04f), new Vector3(0.38f, 0.22f, 0.36f), "#6B3E2A");
Part(root.transform, "Goggles", PrimitiveType.Cube, new Vector3(0f, 1.32f, -0.16f), new Vector3(0.34f, 0.1f, 0.08f), "#7EC8C3");
Limb(root.transform, "LegL", new Vector3(-0.12f, 0.48f, 0f));
Limb(root.transform, "LegR", new Vector3(0.12f, 0.48f, 0f));
string path = Folder + "/ExplorerSimple.prefab";
PrefabUtility.SaveAsPrefabAsset(root, path);
}
finally { UnityEngine.Object.DestroyImmediate(root); }
return "Authored ExplorerSimple placeholder prefab";
}
public static string Hud()
{
if (EditorApplication.isPlaying) throw new Exception("Build the HUD in Edit Mode");
string json = Path.Combine(Repo, "Doc/UIPrefabCreater/UI_MainHUD.json");
var validation = JsonUIPrefabBuilder.Validate(json);
if (!validation.Ok) throw new Exception(string.Join("\n", validation.Errors));
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(json))) throw new Exception("HUD build failed");
return "Rebuilt UI_MainHUD with the walking explorer";
}
static void Limb(Transform parent, string name, Vector3 hip)
{
var pivot = new GameObject(name);
pivot.layer = 31;
pivot.transform.SetParent(parent, false);
pivot.transform.localPosition = hip;
Part(pivot.transform, "Mesh", PrimitiveType.Capsule, new Vector3(0f, -0.24f, 0f), new Vector3(0.16f, 0.24f, 0.16f), "#3E6EA8");
Part(pivot.transform, "Boot", PrimitiveType.Sphere, new Vector3(0f, -0.46f, -0.04f), new Vector3(0.18f, 0.12f, 0.24f), "#5C4033");
}
static void Part(Transform parent, string name, PrimitiveType type, Vector3 position, Vector3 scale, string color)
{
var go = GameObject.CreatePrimitive(type);
go.name = name;
go.layer = 31;
go.transform.SetParent(parent, false);
go.transform.localPosition = position;
go.transform.localScale = scale;
UnityEngine.Object.DestroyImmediate(go.GetComponent<Collider>());
string matPath = Folder + "/" + name + ".mat";
var material = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (material == null)
{
material = new Material(Shader.Find("Universal Render Pipeline/Lit"));
AssetDatabase.CreateAsset(material, matPath);
}
ColorUtility.TryParseHtmlString(color, out var parsed);
material.SetColor("_BaseColor", parsed);
material.SetFloat("_Smoothness", 0.25f);
go.GetComponent<Renderer>().sharedMaterial = material;
EditorUtility.SetDirty(material);
}
}
+3 -1
View File
@@ -44,7 +44,9 @@ public static class BuildLanes
{
Shape("Skill" + i, effects, PrimitiveType.Cylinder, Vector3.zero, new Vector3(1.5f, .04f, 1.5f), colors[i]).gameObject.SetActive(false);
Shape("Shield" + i, effects, PrimitiveType.Cylinder, Vector3.zero, new Vector3(1.9f, .06f, 1.9f), "ProtoCyan").gameObject.SetActive(false);
var crest = Shape("T2Crest", root.transform.Find("Heroes/Hero" + i), PrimitiveType.Cube, new Vector3(0, 2.0f, 0), Vector3.one * .24f, "ProtoYellow");
var hero = root.transform.Find("Heroes/Hero" + i);
// Formal partners (nested "Visual") are 1.5 units tall; primitive stand-ins reach ~1.8.
var crest = Shape("T2Crest", hero, PrimitiveType.Cube, new Vector3(0, hero.Find("Visual") != null ? 1.8f : 2.0f, 0), Vector3.one * .24f, "ProtoYellow");
crest.localRotation = Quaternion.Euler(0, 45, 45); crest.gameObject.SetActive(false);
}
for (int i = 0; i < 9; i++)
+123 -5
View File
@@ -16,6 +16,22 @@ public static class BuildPrototype
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
public static readonly string[] Screens = { "Stages", "Pinball", "Capture", "Pets", "Formation", "Battle" };
const int PetCount = 10;
const string Animal = "Assets/Bundles/Characters/Animal/";
/// <summary>Validated formal partner art by pet id; other ids keep primitive stand-ins until their art passes review.</summary>
static readonly System.Collections.Generic.Dictionary<int, string> FormalPets = new System.Collections.Generic.Dictionary<int, string>
{
[0] = Animal + "RockBall/RockBall_Rigged.prefab", [1] = Animal + "FireCat/FireCat_Companion.prefab", [2] = Animal + "BudBud/BudBud_Companion.prefab",
[3] = Animal + "SparkBird/SparkBird_Companion.prefab", [4] = Animal + "BubbleTurtle/BubbleTurtle_Companion.prefab",
[5] = Animal + "RockRhino/RockRhino_Companion.prefab", [6] = Animal + "FlameLion/FlameLion_Companion.prefab",
[7] = Animal + "VineSprite/VineSprite_Companion.prefab", [8] = Animal + "ThunderLeopard/ThunderLeopard_Companion.prefab", [9] = Animal + "TideWhale/TideWhale_Companion.prefab",
};
/// <summary>Battle height in arena units; the authored models are ~0.55m and the lanes are 3 units apart.</summary>
public const float FormalHeight = 1.5f;
const string Enemy = "Assets/Bundles/Characters/Enemy/";
/// <summary>Formal enemy art by enemy type id (greenfield.json enemies 0..4), nested under every Enemies/EnemyN slot as Type{t}.</summary>
static readonly string[] FormalEnemies = { Enemy + "Hornlet/Hornlet_Enemy.prefab", Enemy + "Dashlet/Dashlet_Enemy.prefab", Enemy + "Shellhorn/Shellhorn_Enemy.prefab", Enemy + "HornCaptain/HornCaptain_Enemy.prefab", Enemy + "GreatHorn/GreatHorn_Enemy.prefab" };
/// <summary>Base enemy height in arena units; the runtime still scales captains x1.2 and bosses x1.55.</summary>
public const float EnemyHeight = 1.2f;
static Material _cream, _rock, _orange, _leaf, _dark, _white, _cyan, _grass, _path, _monster, _yellow, _water, _ember, _violet;
static Material Material(string name, string color)
@@ -47,9 +63,56 @@ public static class BuildPrototype
Shape("Pupil", root, PrimitiveType.Sphere, new Vector3(x, y, z - .1f), Vector3.one * .1f, _dark);
}
}
static void SetLayer(Transform t)
{
t.gameObject.layer = 30; foreach (Transform child in t) SetLayer(child);
}
/// <summary>Top of the bind-pose vertices in <paramref name="space"/>; skinned Renderer.bounds is padded and unreliable in Edit Mode.</summary>
public static float MeshTop(Transform visual, Transform space)
{
float top = 0; var mesh = new Mesh();
try
{
foreach (var skin in visual.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
skin.BakeMesh(mesh, true);
foreach (var v in mesh.vertices) top = Mathf.Max(top, space.InverseTransformPoint(skin.transform.TransformPoint(v)).y);
}
}
finally { UnityEngine.Object.DestroyImmediate(mesh); }
return top;
}
/// <summary>Nests the formal partner prefab under a "Visual" node scaled to the battle height; returns false when none exists.</summary>
static bool FormalVisual(int id, Transform root)
{
if (!FormalPets.TryGetValue(id, out var path)) return false;
var asset = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (asset == null) throw new Exception("Missing formal partner " + path);
var go = (GameObject)PrefabUtility.InstantiatePrefab(asset, root.gameObject.scene);
go.name = "Visual"; go.transform.SetParent(root, false);
go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity; go.transform.localScale = Vector3.one;
go.transform.localScale = Vector3.one * (FormalHeight / Mathf.Max(.1f, MeshTop(go.transform, root)));
SetLayer(go.transform); return true;
}
/// <summary>Nests all formal enemy types under one enemy slot, inactive; the runtime shows the one matching the spawned type. False until all five exist.</summary>
static bool FormalEnemyVisuals(Transform slot)
{
var assets = FormalEnemies.Select(AssetDatabase.LoadAssetAtPath<GameObject>).ToArray();
if (assets.Any(a => a == null)) return false;
for (int t = 0; t < assets.Length; t++)
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(assets[t], slot.gameObject.scene);
go.name = "Type" + t; go.transform.SetParent(slot, false);
go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity; go.transform.localScale = Vector3.one;
go.transform.localScale = Vector3.one * (EnemyHeight / Mathf.Max(.1f, MeshTop(go.transform, slot)));
SetLayer(go.transform); go.SetActive(false);
}
return true;
}
static Transform Pet(int id, Transform parent, string name)
{
var root = Group(name, parent);
if (FormalVisual(id, root)) return root;
if (id == 0)
{
Shape("Body", root, PrimitiveType.Sphere, new Vector3(0, .7f, 0), new Vector3(1.3f, 1.3f, 1), _rock);
@@ -215,9 +278,13 @@ public static class BuildPrototype
for (int i = 0; i < 9; i++)
{
var enemy = Group("Enemy" + i, enemies);
Shape("Body", enemy, PrimitiveType.Sphere, new Vector3(0, .48f, 0), new Vector3(.85f, .95f, .85f), _monster);
Shape("Horn", enemy, PrimitiveType.Cube, new Vector3(0, 1.02f, 0), new Vector3(.2f, .35f, .2f), _cream).localRotation = Quaternion.Euler(0, 0, 20);
Face(enemy, .65f, -.38f, .17f); enemy.gameObject.SetActive(false);
if (!FormalEnemyVisuals(enemy))
{
Shape("Body", enemy, PrimitiveType.Sphere, new Vector3(0, .48f, 0), new Vector3(.85f, .95f, .85f), _monster);
Shape("Horn", enemy, PrimitiveType.Cube, new Vector3(0, 1.02f, 0), new Vector3(.2f, .35f, .2f), _cream).localRotation = Quaternion.Euler(0, 0, 20);
Face(enemy, .65f, -.38f, .17f);
}
enemy.gameObject.SetActive(false);
}
var projectiles = Group("Projectiles", root.transform);
for (int i = 0; i < PetCount; i++)
@@ -228,6 +295,17 @@ public static class BuildPrototype
camera.cullingMask = 1 << 30; camera.transform.position = new Vector3(0, 16, -11); camera.transform.LookAt(new Vector3(0, 0, 4));
var light = Group("BattleLight", root.transform).gameObject.AddComponent<Light>();
light.type = LightType.Directional; light.intensity = 1; light.cullingMask = 1 << 30; light.transform.rotation = Quaternion.Euler(45, -30, 0);
// A full rebuild must keep the authored battlefield (same result as AttachGreenfield.Build); BuildLanes runs after this.
var battlefield = AssetDatabase.LoadAssetAtPath<GameObject>(World + "/Battlefield/Battlefield_Greenfield.prefab");
if (battlefield != null)
{
var greenfield = (GameObject)PrefabUtility.InstantiatePrefab(battlefield, scene); greenfield.name = "Greenfield";
greenfield.transform.SetParent(root.transform, false); greenfield.transform.SetSiblingIndex(0); SetLayer(greenfield.transform);
foreach (Transform child in root.transform)
if (child.name == "Ground" || child.name == "Lane" || child.name == "LeftBorder" || child.name == "RightBorder" || child.name == "BattleLight") child.gameObject.SetActive(false);
}
var enemyFeedback = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Effects/EnemyCombat/EnemyCombatPresentation.prefab");
if (enemyFeedback != null) PrefabUtility.InstantiatePrefab(enemyFeedback, root.transform);
PrefabUtility.SaveAsPrefabAsset(root, World + "/PrototypeArena.prefab");
}
finally { UnityEngine.Object.DestroyImmediate(root); EditorSceneManager.ClosePreviewScene(scene); }
@@ -242,8 +320,9 @@ public static class BuildPrototype
Pet(id, root.transform, "PortraitPet");
var camera = Group("PortraitCamera", root.transform).gameObject.AddComponent<Camera>(); camera.scene = scene;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.clear; camera.cullingMask = 1 << 30;
camera.orthographic = true; camera.orthographicSize = id == 3 ? 1.22f : id >= 9 ? 1.34f : 1.08f; camera.allowHDR = false;
camera.transform.position = new Vector3(1.6f, 1.55f, -4); camera.transform.LookAt(new Vector3(0, id == 3 ? .94f : id >= 5 ? .86f : .82f, 0)); camera.targetTexture = rt;
bool primitiveBird = id == 3 && !FormalPets.ContainsKey(id);
camera.orthographic = true; camera.orthographicSize = primitiveBird ? 1.22f : id >= 9 ? 1.34f : 1.08f; camera.allowHDR = false;
camera.transform.position = new Vector3(1.6f, 1.55f, -4); camera.transform.LookAt(new Vector3(0, primitiveBird ? .94f : id >= 5 ? .86f : .82f, 0)); camera.targetTexture = rt;
var light = Group("PortraitLight", root.transform).gameObject.AddComponent<Light>(); light.type = LightType.Directional;
light.intensity = 1.15f; light.cullingMask = 1 << 30; light.transform.rotation = Quaternion.Euler(35, -35, 0);
camera.Render(); RenderTexture.active = rt;
@@ -280,6 +359,45 @@ public static class BuildPrototype
package.CheckConfigError(); BundleCollectorSettingData.SaveFile();
return "Built authored arena with ten pets, ten portraits and six JSON UI prefabs; updated Art collectors.";
}
/// <summary>Swaps formal partners into the existing arena in place (keeps Lanes/Greenfield edits) and re-renders their portraits.</summary>
public static string ApplyFormalHeroes()
{
if (EditorApplication.isPlaying) throw new Exception("Apply formal heroes in Edit Mode");
SetupMaterials();
var root = PrefabUtility.LoadPrefabContents(World + "/PrototypeArena.prefab");
try
{
foreach (int id in FormalPets.Keys)
{
var hero = root.transform.Find("Heroes/Hero" + id) ?? throw new Exception("Arena missing Hero" + id);
foreach (var child in hero.Cast<Transform>().Where(c => c.name != "T2Crest").ToArray()) UnityEngine.Object.DestroyImmediate(child.gameObject);
FormalVisual(id, hero);
var crest = hero.Find("T2Crest"); if (crest != null) crest.localPosition = new Vector3(0, FormalHeight + .3f, 0);
}
PrefabUtility.SaveAsPrefabAsset(root, World + "/PrototypeArena.prefab");
}
finally { PrefabUtility.UnloadPrefabContents(root); }
foreach (int id in FormalPets.Keys) Portrait(id);
return "Arena Hero" + string.Join("/", FormalPets.Keys) + " now nest formal partner prefabs; portraits re-rendered.";
}
/// <summary>Swaps the enemy stand-ins in the existing arena for the five formal enemy types, in place (keeps SlowMark and other edits).</summary>
public static string ApplyFormalEnemies()
{
if (EditorApplication.isPlaying) throw new Exception("Apply formal enemies in Edit Mode");
var root = PrefabUtility.LoadPrefabContents(World + "/PrototypeArena.prefab");
try
{
var enemies = root.transform.Find("Enemies") ?? throw new Exception("Arena missing Enemies");
foreach (Transform slot in enemies)
{
foreach (var child in slot.Cast<Transform>().Where(c => c.name != "SlowMark").ToArray()) UnityEngine.Object.DestroyImmediate(child.gameObject);
if (!FormalEnemyVisuals(slot)) throw new Exception("Formal enemy prefabs incomplete: " + string.Join(", ", FormalEnemies.Where(p => AssetDatabase.LoadAssetAtPath<GameObject>(p) == null)));
}
PrefabUtility.SaveAsPrefabAsset(root, World + "/PrototypeArena.prefab");
return "Arena " + enemies.childCount + " enemy slots now nest formal Type0-" + (FormalEnemies.Length - 1) + " visuals.";
}
finally { PrefabUtility.UnloadPrefabContents(root); }
}
public static string RefreshNewPortraits()
{
if (EditorApplication.isPlaying) throw new Exception("Render portraits in Edit Mode");
+17
View File
@@ -0,0 +1,17 @@
using System;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
public static class BuildRank
{
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build the rank page in Edit Mode");
string repo = Directory.GetParent(UnityEngine.Application.dataPath).Parent.FullName;
if (string.IsNullOrEmpty(JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(repo, "Doc/UIPrefabCreater/UI_PrototypeRank.json")))) throw new Exception("Rank prefab failed");
SetupUiBundleCollector.Setup();
AssetDatabase.SaveAssets();
return "Built the rank page";
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.IO;
using System.Linq;
using HotUpdate.Gameplay;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>Pipeline authoring of the UiSfx prefab: one AudioSource plus the v3 synthesized UI clips in UiSound order.</summary>
public static class BuildUiSfx
{
public const string PrefabPath = "Assets/Bundles/Sound/Prefab/UiSfx.prefab";
const string ClipFolder = "Assets/Bundles/Sound/SFX/UI/";
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Build UI SFX in Edit Mode");
var names = Enum.GetNames(typeof(UiSound));
var clips = names.Select(n =>
{
string path = ClipFolder + "Ui_" + n + ".wav";
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
return AssetDatabase.LoadAssetAtPath<AudioClip>(path) ?? throw new Exception("Missing " + path);
}).ToArray();
var longest = clips.OrderByDescending(c => c.length).First();
if (longest.length > 1f) throw new Exception(longest.name + " is still " + longest.length + "s; import the one-shots first");
var original = SceneManager.GetActiveScene();
var temporary = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
try
{
SceneManager.SetActiveScene(temporary);
var root = new GameObject(UiSfx.PrefabName);
var source = root.AddComponent<AudioSource>();
source.playOnAwake = false; source.loop = false; source.spatialBlend = 0; source.volume = .8f;
var player = root.AddComponent<UiSfx>();
var serialized = new SerializedObject(player);
serialized.FindProperty("_source").objectReferenceValue = source;
var list = serialized.FindProperty("_clips"); list.arraySize = clips.Length;
for (int i = 0; i < clips.Length; i++) list.GetArrayElementAtIndex(i).objectReferenceValue = clips[i];
serialized.ApplyModifiedPropertiesWithoutUndo();
PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
}
finally
{
SceneManager.SetActiveScene(original);
EditorSceneManager.CloseScene(temporary, true);
}
return Verify();
}
public static string Verify()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath) ?? throw new Exception("Missing " + PrefabPath);
var player = prefab.GetComponent<UiSfx>(); var audio = prefab.GetComponent<AudioSource>();
if (player == null || player.Source != audio || audio.playOnAwake || audio.loop || audio.spatialBlend != 0) throw new Exception("UiSfx prefab configuration invalid");
var rows = Enum.GetValues(typeof(UiSound)).Cast<UiSound>().Select(s =>
{
var clip = player.Clip(s);
if (clip == null || clip.name != "Ui_" + s || clip.length > 1f || clip.frequency != 44100) throw new Exception("Bad clip for " + s + ": " + clip?.name);
return new { sound = s.ToString(), clip = AssetDatabase.GetAssetPath(clip), seconds = Math.Round(clip.length, 3), clip.channels, clip.frequency };
}).ToArray();
string report = JsonConvert.SerializeObject(new
{
batch = "ui_sfx_v3", prefab = PrefabPath, address = UiSfx.PrefabName, package = "Art", group = "Sound",
volume = audio.volume, sfxToggle = "PrototypeClientPrefs.Sfx", clips = rows, bundleBuilt = false, verifiedUtc = DateTime.UtcNow.ToString("o")
}, Formatting.Indented);
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
File.WriteAllText(Path.Combine(repo, "Doc/Design/Sound/UiSfx/playback_assets.json"), report);
return report;
}
}
+34 -2
View File
@@ -9,6 +9,17 @@ using Object = UnityEngine.Object;
/// <summary>Bake an upright, grounded, periodic in-place gait onto this exact bind.</summary>
public static class FinalizeAnimalWalk
{
[Serializable] public sealed class QuadrupedRecipe
{
public string assetRoot,id,rootBone,frontRightFoot,hindLeftFoot,frontLeftFoot,hindRightFoot,outputDir;
public float height,walkStride,walkLift,walkSeconds,walkSourceWeight,walkSwingFraction;
}
public static string RunQuadruped(string recipePath)
{
var r=JsonUtility.FromJson<QuadrupedRecipe>(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../..",recipePath))));
if(!(r.walkSwingFraction>0 && r.walkSwingFraction<=.25f))throw new ArgumentException("Four-beat swing must be at most a quarter cycle");
return RunCore(r.assetRoot,r.id,r.height,r.rootBone,new[]{r.frontRightFoot,r.hindLeftFoot,r.frontLeftFoot,r.hindRightFoot},r.walkStride,r.walkLift,r.walkSeconds,r.outputDir,true,r.walkSourceWeight,true,r.walkSwingFraction);
}
const string Root = "Assets/Bundles/Characters/Animal/RockBall";
sealed class Pose
{
@@ -48,6 +59,10 @@ public static class FinalizeAnimalWalk
public static string RunFor(string assetRoot,string actor,float height,string rootBone,string rightFoot,string leftFoot,
float strideMeters,float liftMeters,float cycleSeconds,string reportDirectory,bool calibrateBind=false,float sourceWeight=1f)
=>RunCore(assetRoot,actor,height,rootBone,new[]{rightFoot,leftFoot},strideMeters,liftMeters,cycleSeconds,reportDirectory,calibrateBind,sourceWeight,false,.2f);
static string RunCore(string assetRoot,string actor,float height,string rootBone,string[] footNames,
float strideMeters,float liftMeters,float cycleSeconds,string reportDirectory,bool calibrateBind,float sourceWeight,bool fourBeat,float swingFraction)
{
var asset=AssetDatabase.LoadAssetAtPath<GameObject>(assetRoot+"/Rigged/"+actor+"_Rigged.fbx");
var model=Object.Instantiate(asset);
@@ -57,7 +72,9 @@ public static class FinalizeAnimalWalk
if(animator!=null) animator.enabled=false;
var bones=model.GetComponentsInChildren<Transform>().Where(t=>t!=model.transform && (t.name=="Armature" || t.name.StartsWith("bone_"))).ToArray();
var root=bones.First(t=>t.name==rootBone);
var feet=new[] { bones.First(t=>t.name==rightFoot),bones.First(t=>t.name==leftFoot) };
var feet=footNames.Select(name=>bones.First(t=>t.name==name)).ToArray();
if(feet.Distinct().Count()!=feet.Length)throw new ArgumentException("Foot mappings must be distinct");
var footPositions=feet.Select(f=>f.position).ToArray();var footRotations=feet.Select(f=>f.rotation).ToArray();
var bindPose=Read(bones);
var bindRotation=root.rotation;
var bindPosition=root.position;
@@ -88,6 +105,7 @@ public static class FinalizeAnimalWalk
}
}
else root.rotation=bindRotation*Quaternion.Inverse(reference)*root.rotation;
if(fourBeat)root.rotation=bindRotation;
root.position=new Vector3(bindPosition.x,root.position.y,bindPosition.z);
root.position+=Vector3.up*(bindBottom-Bottom(model));
poses[f]=Read(bones);
@@ -117,10 +135,24 @@ public static class FinalizeAnimalWalk
p.q[i]=a.q[i]*Quaternion.Slerp(Quaternion.identity,Quaternion.Inverse(end.q[i])*begin.q[i],blend);
}
Write(bones,p);
if(fourBeat)root.rotation=bindRotation;
// Author an explicit alternating short step in the OFFLINE clip bake.
// Bone mapping and dimensions belong to this character's recipe.
for(int foot=0;foot<feet.Length;foot++)
{
if(fourBeat)
{
// Four separate swing windows leave the other three paws supporting.
float u=Mathf.Repeat(t-foot*.25f,1);
bool swinging=u<swingFraction;
float advance=swinging ? Mathf.SmoothStep(-.5f,.5f,u/swingFraction) : .5f-(u-swingFraction)/(1-swingFraction);
float lift=swinging ? Mathf.Pow(Mathf.Sin(Mathf.PI*u/swingFraction),2) : 0;
for(int b=0;b<bones.Length;b++)if(bones[b]!=feet[foot] && bones[b].IsChildOf(feet[foot]))
{bones[b].localPosition=bindPose.p[b];bones[b].localRotation=bindPose.q[b];bones[b].localScale=bindPose.s[b];}
feet[foot].position=footPositions[foot]+(Vector3.back*(advance*strideMeters)+Vector3.up*(lift*liftMeters))*metersToBind;
feet[foot].rotation=footRotations[foot];
continue;
}
float phase=2*Mathf.PI*t+foot*Mathf.PI;
float swing=Mathf.Max(0,Mathf.Cos(phase));
feet[foot].position+=Vector3.back*(Mathf.Sin(phase)*strideMeters*.5f*metersToBind)
@@ -162,7 +194,7 @@ public static class FinalizeAnimalWalk
AnimationUtility.SetAnimationClipSettings(clip,settings);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
string report=$"frames={first}..{last} length={clip.length:F4} phaseRms={error:F3} sourceInitialRootTilt={referenceTilt:F3}; allBoneBindCalibration={calibrateBind} sourceWeight={sourceWeight:F3}; root bind calibration + in-place XZ + per-frame grounded skin + periodic closure; authored short-foot stride={strideMeters:F3}m lift={liftMeters:F3}m cycle={cycleSeconds:F3}s";
string report=$"frames={first}..{last} length={clip.length:F4} phaseRms={error:F3} sourceInitialRootTilt={referenceTilt:F3}; allBoneBindCalibration={calibrateBind} sourceWeight={sourceWeight:F3}; root bind calibration + in-place XZ + per-frame grounded skin + periodic closure; feet={feet.Length} fourBeat={fourBeat} swingFraction={swingFraction:F3}; authored short-foot stride={strideMeters:F3}m lift={liftMeters:F3}m cycle={cycleSeconds:F3}s";
string outPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../",reportDirectory,"walk_processing.txt"));
Directory.CreateDirectory(Path.GetDirectoryName(outPath));
File.WriteAllText(outPath,report);
+67 -10
View File
@@ -13,8 +13,10 @@ public static class FinalizeEnemyMotions
[Serializable] public sealed class Recipe
{
public string id,assetRoot,rootBone,chestBone,rightArm,leftArm,outputDir,profile,attackStyle,attackEvent,usage;
public string headBone,neckBone,frontRightFoot,frontLeftFoot,hindRightFoot,hindLeftFoot,rightFoot,leftFoot;
public float height,idleSeconds,idleBreath,attackSeconds,attackWindup,attackImpact,attackRecover;
public float attackLeanDegrees,attackArmDegrees,attackLunge,attackReach,idleSourceWeight,attackSourceWeight;
public float attackLeanDegrees,attackArmDegrees,attackLunge,attackReach,idleSourceWeight,attackSourceWeight,wingSpreadDegrees;
public float attackHeadReach,attackHeadPitchDegrees,attackSpreadDegrees;
}
sealed class Pose
{
@@ -79,7 +81,18 @@ public static class FinalizeEnemyMotions
var r=JsonUtility.FromJson<Recipe>(File.ReadAllText(Path.Combine(Repo,recipePath)));
if(string.IsNullOrEmpty(r.attackStyle))r.attackStyle="body_slam";
if(string.IsNullOrEmpty(r.attackEvent))r.attackEvent=r.usage=="companion_visual" ? "OnCompanionAttackImpact" : "OnEnemyAttackImpact";
if(r.attackStyle!="body_slam" && r.attackStyle!="ranged_cast")throw new ArgumentException("Unsupported attack style");
bool bird=r.profile=="avian_grounded";
bool turtle=r.profile=="quadruped_turtle";
bool heavy=r.profile=="quadruped_heavy";
bool quadruped=turtle || heavy;
bool roar=r.attackStyle=="flame_roar";
if(r.profile!="biped_short" && !bird && !quadruped)throw new ArgumentException("Unsupported anatomy");
if(r.attackStyle!="body_slam" && r.attackStyle!="ranged_cast" && r.attackStyle!="wing_cast" && r.attackStyle!="turtle_bubble" && r.attackStyle!="braced_bash" && !roar)throw new ArgumentException("Unsupported attack style");
if(roar && r.profile!="biped_short")throw new ArgumentException("Flame roar requires an upright biped");
if(bird!=(r.attackStyle=="wing_cast") || (bird && !(r.wingSpreadDegrees>0 && r.wingSpreadDegrees<=60)))
throw new ArgumentException("Invalid grounded-bird wing cast");
if(turtle!=(r.attackStyle=="turtle_bubble"))throw new ArgumentException("Invalid turtle bubble cast");
if(heavy!=(r.attackStyle=="braced_bash"))throw new ArgumentException("Invalid heavy quadruped bash");
if(r.height<=0 || !(0<r.attackWindup && r.attackWindup<r.attackImpact && r.attackImpact<r.attackRecover && r.attackRecover<1))
throw new ArgumentException("Invalid dimensions / attack phase order");
var sb=new StringBuilder();
@@ -91,10 +104,18 @@ public static class FinalizeEnemyMotions
var anim=model.GetComponent<Animator>();if(anim!=null) anim.enabled=false;
var bones=model.GetComponentsInChildren<Transform>().Where(t=>t!=model.transform && (t.name=="Armature" || t.name.StartsWith("bone_"))).ToArray();
var root=bones.First(t=>t.name==r.rootBone);
var head=quadruped || roar ? bones.First(t=>t.name==r.headBone) : null;
var neck=quadruped || roar ? bones.First(t=>t.name==r.neckBone) : null;
var feet=(quadruped ? new[]{r.frontRightFoot,r.frontLeftFoot,r.hindRightFoot,r.hindLeftFoot} : roar ? new[]{r.rightFoot,r.leftFoot} : new string[0]).Select(n=>bones.First(t=>t.name==n)).ToArray();
var footPositions=feet.Select(t=>t.position).ToArray();var footRotations=feet.Select(t=>t.rotation).ToArray();
var chest=bones.FirstOrDefault(t=>t.name==r.chestBone);
var arms=new[] {bones.FirstOrDefault(t=>t.name==r.rightArm),bones.FirstOrDefault(t=>t.name==r.leftArm)};
if(r.profile=="biped_short" && (chest==null || arms.Any(t=>t==null))) throw new Exception("Missing profile bones");
if(!quadruped && (chest==null || arms.Any(t=>t==null) || arms[0]==arms[1])) throw new Exception("Missing/distinct profile bones required");
if((heavy || roar) && (chest==null || chest==head))throw new Exception("Independent chest and head required");
var bind=Pose.Read(bones);var bindRootPosition=root.position;float bindBottom=Bottom(model);
var bindRootRotation=root.rotation;
var wingSides=bird || roar ? arms.Select(a=>Mathf.Sign(a.position.x-root.position.x)).ToArray() : new float[0];
if((bird || roar) && wingSides[0]==wingSides[1])throw new Exception("Arm/wing roots must be on opposite sides");
var skins=model.GetComponentsInChildren<SkinnedMeshRenderer>();
float unitScale=(skins.SelectMany(Vertices).Max(v=>v.y)-bindBottom)/r.height;
var source=AssetDatabase.LoadAllAssetsAtPath(r.assetRoot+"/Anims/"+r.id+"_"+name+".fbx").OfType<AnimationClip>().First(c=>!c.name.StartsWith("__preview__"));
@@ -114,28 +135,64 @@ public static class FinalizeEnemyMotions
bones[b].localScale=bind.s[b];
}
root.position=new Vector3(bindRootPosition.x,root.position.y,bindRootPosition.z);
if(r.profile=="biped_short")
if(roar)root.rotation=bindRootRotation;
if(quadruped)
{
root.rotation=bindRootRotation;
if(idle)head.position+=Vector3.up*(r.idleBreath*Mathf.Pow(Mathf.Sin(Mathf.PI*t),2)*unitScale);
else
{
float strike=Strike(r,t);
// Heavy animals drive the shoulder and head together; a turtle
// only extends its neck. Paws are planted in either case.
if(heavy)chest.position+=Vector3.back*(r.attackLunge*strike*unitScale);
neck.position+=Vector3.back*(r.attackHeadReach*strike*unitScale);
head.rotation=Quaternion.AngleAxis(-r.attackHeadPitchDegrees*strike,Vector3.right)*head.rotation;
}
}
if(r.profile=="biped_short" || bird)
{
if(idle)
{
float breath=Mathf.Pow(Mathf.Sin(Mathf.PI*t),2);
chest.localScale=Vector3.Scale(chest.localScale,new Vector3(1+.007f*breath,1+r.idleBreath/r.height*breath,1+.007f*breath));
if(roar)head.position+=Vector3.up*(r.idleBreath*breath*unitScale);
else chest.localScale=Vector3.Scale(chest.localScale,new Vector3(1+.007f*breath,1+r.idleBreath/r.height*breath,1+.007f*breath));
}
else
{
float strike=Strike(r,t);
root.rotation=Quaternion.AngleAxis(-r.attackLeanDegrees*strike,Vector3.right)*root.rotation;
root.position+=Vector3.back*(r.attackLunge*strike*unitScale);
foreach(var arm in arms)
if(!roar)
{
float armSign=r.attackStyle=="ranged_cast" ? 1 : -1;
root.rotation=Quaternion.AngleAxis(-r.attackLeanDegrees*strike,Vector3.right)*root.rotation;
root.position+=Vector3.back*(r.attackLunge*strike*unitScale);
}
for(int side=0;side<arms.Length;side++)
{
var arm=arms[side];
float armSign=r.attackStyle=="ranged_cast" || roar ? 1 : -1;
arm.rotation=Quaternion.AngleAxis(armSign*r.attackArmDegrees*strike,Vector3.right)*arm.rotation;
// Standing bird wings start outward/upward: negative X sweeps
// them toward -Z. Opposite Z rotations open each wing upward.
if(bird)arm.rotation=Quaternion.AngleAxis(wingSides[side]*r.wingSpreadDegrees*strike,Vector3.forward)*arm.rotation;
if(roar)arm.rotation=Quaternion.AngleAxis(wingSides[side]*r.attackSpreadDegrees*strike,Vector3.forward)*arm.rotation;
// A ranged cast presents both paws in front, without moving the actor.
// Small offline bone translation supplies reach for very short chibi arms.
if(r.attackStyle=="ranged_cast")arm.position+=Vector3.back*(r.attackReach*strike*unitScale);
if(r.attackStyle=="ranged_cast" || bird || roar)arm.position+=Vector3.back*(r.attackReach*strike*unitScale);
}
if(roar)
{
neck.position+=Vector3.back*(r.attackHeadReach*strike*unitScale);
head.rotation=Quaternion.AngleAxis(r.attackHeadPitchDegrees*strike,Vector3.right)*head.rotation;
}
}
}
// Planted feet are baked offline for the standing braced actions.
for(int foot=0;foot<feet.Length;foot++)
{
for(int b=0;b<bones.Length;b++)if(bones[b]!=feet[foot] && bones[b].IsChildOf(feet[foot]))
{bones[b].localPosition=bind.p[b];bones[b].localRotation=bind.q[b];bones[b].localScale=bind.s[b];}
feet[foot].position=footPositions[foot];feet[foot].rotation=footRotations[foot];
}
root.position+=Vector3.up*(bindBottom-Bottom(model));
poses[f]=Pose.Read(bones);
}
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
using YooAsset.Editor;
// Pipeline: import UI SFX WAVs under Bundles/Sound/SFX/UI and register Art/Sound collector.
public static class ImportUiSfx
{
const string Folder = "Assets/Bundles/Sound/SFX/UI";
public static string ConfigureAndVerify()
{
if (EditorApplication.isPlaying)
throw new InvalidOperationException("Import UI SFX in Edit Mode.");
string repo = Directory.GetParent(Application.dataPath).Parent.FullName;
string designDir = Path.Combine(repo, "Doc/Design/Sound/UiSfx");
Directory.CreateDirectory(designDir);
string[] guids = AssetDatabase.FindAssets("t:AudioClip", new[] { Folder });
if (guids.Length == 0)
throw new Exception("No AudioClips under " + Folder);
var items = new List<object>();
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var importer = AssetImporter.GetAtPath(path) as AudioImporter;
if (importer == null)
throw new Exception("AudioImporter missing: " + path);
var sample = importer.defaultSampleSettings;
sample.loadType = AudioClipLoadType.DecompressOnLoad;
sample.compressionFormat = AudioCompressionFormat.PCM;
sample.sampleRateSetting = AudioSampleRateSetting.PreserveSampleRate;
sample.preloadAudioData = true;
importer.defaultSampleSettings = sample;
importer.forceToMono = false;
importer.loadInBackground = false;
importer.SaveAndReimport();
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(path);
if (clip == null || clip.channels < 1 || clip.frequency != 44100)
throw new Exception("Unexpected AudioClip: " + path);
items.Add(new {
asset = path,
guid,
address = Path.GetFileNameWithoutExtension(path),
clip.length,
clip.samples,
clip.channels,
clip.frequency,
loadType = importer.defaultSampleSettings.loadType.ToString(),
compression = importer.defaultSampleSettings.compressionFormat.ToString()
});
}
var package = BundleCollectorSettingData.Setting.Packages.Find(p => p.PackageName == "Art");
if (package == null)
throw new Exception("Existing Art package missing.");
var group = package.Groups.Find(g => g.GroupName == "Sound");
if (group == null)
group = BundleCollectorSettingData.CreateGroup(package, "Sound");
group.ActiveRuleName = nameof(EnableGroup);
group.AssetTags = "art";
BundleCollectorSettingData.ModifyGroup(package, group);
var collector = group.Collectors.Find(c => c.CollectPath == Folder);
if (collector == null)
{
collector = new BundleCollector {
CollectPath = Folder,
CollectorGUID = AssetDatabase.AssetPathToGUID(Folder),
CollectorType = ECollectorType.MainAssetCollector,
AddressRuleName = nameof(AddressByFileName),
PackRuleName = nameof(PackSeparately),
FilterRuleName = nameof(CollectAll),
AssetTags = "art"
};
BundleCollectorSettingData.CreateCollector(group, collector);
}
package.CheckConfigError();
BundleCollectorSettingData.SaveFile();
var report = new {
folder = Folder,
count = items.Count,
items,
package = "Art",
group = "Sound",
packRule = collector.PackRuleName,
runtimePlaybackWired = false,
bundleBuilt = false,
verifiedUtc = DateTime.UtcNow.ToString("o")
};
string json = JsonConvert.SerializeObject(report, Formatting.Indented);
File.WriteAllText(Path.Combine(designDir, "unity_validation.json"), json);
return json;
}
}
@@ -0,0 +1,53 @@
#if UNITY_EDITOR
using System;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
public static class PrepareBudBudFormal
{
public static string InspectScenes()
{
return string.Join("\n",Enumerable.Range(0,SceneManager.sceneCount).Select(i=> {
var scene=SceneManager.GetSceneAt(i);
return $"name={scene.name} path={scene.path} dirty={scene.isDirty} roots={scene.rootCount}: "+string.Join(",",scene.GetRootGameObjects().Select(g=>g.name));
}));
}
public static string SaveEmptyBatchStartup()
{
if(!Application.isBatchMode || EditorApplication.isPlaying || SceneManager.sceneCount!=1)
throw new Exception("Only the empty batch startup scene may be saved here");
var scene=SceneManager.GetSceneAt(0);
if(!string.IsNullOrEmpty(scene.path) || scene.rootCount!=0)
throw new Exception("Existing scene content must be preserved untouched");
const string path="Assets/Temp/BudBud_BatchStartup_20261020.unity";
if(AssetDatabase.LoadAssetAtPath<SceneAsset>(path)!=null)throw new Exception("Startup archive already exists");
if(!AssetDatabase.IsValidFolder("Assets/Temp"))AssetDatabase.CreateFolder("Assets","Temp");
if(!EditorSceneManager.SaveScene(scene,path))throw new Exception("Cannot preserve empty startup scene");
return "Preserved empty batch startup: "+path;
}
public static string FinishBatch()
{
if(!Application.isBatchMode || EditorApplication.isPlaying)throw new Exception("Only finish this task's batch editor in Edit Mode");
foreach(var scene in Enumerable.Range(0,SceneManager.sceneCount).Select(SceneManager.GetSceneAt))
if(scene.isDirty || (scene.path!="Assets/Temp/BudBud_BatchStartup_20261020.unity" && scene.path!="Assets/Bundles/Scenes/Review/Companion_BudBud_Review.unity"))
throw new Exception("Preserve existing scene state before closing");
EditorApplication.delayCall+=()=>EditorApplication.Exit(0);
return "Closing this task's saved batch editor; other project editors untouched";
}
public static string ArchiveDraft()
{
if(EditorApplication.isPlaying)throw new Exception("Archive in Edit Mode");
const string source="Assets/Bundles/Characters/Animal/BudBud";
const string target="Assets/Temp/BudBud_OfflineDraft_20261020";
if(AssetDatabase.IsValidFolder(target))return "Archive already exists: "+target;
if(!AssetDatabase.IsValidFolder(source))throw new Exception("Missing existing BudBud draft");
if(!AssetDatabase.IsValidFolder("Assets/Temp"))AssetDatabase.CreateFolder("Assets","Temp");
if(!AssetDatabase.CopyAsset(source,target))throw new Exception("Cannot preserve BudBud draft");
AssetDatabase.SaveAssets();AssetDatabase.Refresh();
return "Preserved draft with new archive GUIDs outside Bundles: "+target;
}
}
#endif
@@ -0,0 +1,161 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Object = UnityEngine.Object;
public static class VerifyEnemyCombatFx
{
const string Folder="Assets/Bundles/Effects/EnemyCombat/";
const string Review="Assets/Bundles/Scenes/Review/EnemyCombatFx_Review.unity";
static readonly string[] Names={"HornletAttack","DashletAttack","ShellhornAttack","HornCaptainAttack","GreatHornAttack","HitSoft","HitArmor","HitShield"};
static readonly string[] Actors={"Hornlet","Dashlet","Shellhorn","HornCaptain","GreatHorn"};
static string Repo=>Directory.GetParent(Application.dataPath).Parent.FullName;
static void Check(bool value,string message) {if(!value)throw new Exception(message);}
static void Report(string name,object data) {string dir=Path.Combine(Repo,"Doc/Design/Combat/EnemyCombatFx");Directory.CreateDirectory(dir);File.WriteAllText(Path.Combine(dir,name+".json"),JsonConvert.SerializeObject(data,Formatting.Indented));}
static PrototypeBattle Battle(int type,bool shield=false,bool lethal=false,int rules=2)
{
var c=JsonUtility.FromJson<PrototypeConfig>(File.ReadAllText(Path.Combine(Repo,"Config/Gameplay/greenfield.json")));
c.battleRules=rules;var stage=c.stages[0];stage.lanes=1;stage.enemyIds=Enumerable.Repeat(type,9).ToArray();stage.enemyLanes=new int[9];stage.waveGap=2;stage.spawnGap=.1f;stage.timeout=20;stage.elementsEnabled=false;stage.hpScale=stage.attackScale=1;
c.pets[0].hp=100000;c.pets[0].attack=lethal?100000:1;c.pets[0].range=2;c.pets[0].cooldown=.5f;
c.pets[0].ability.kind="shield";c.pets[0].ability.t1=shield?100000:0;c.pets[0].ability.duration=30;c.pets[0].ability.cooldown=30;
c.enemies[type].hp=100;c.enemies[type].hpStep=0;c.enemies[type].speed=20;c.enemies[type].attack=1;c.enemies[type].cooldown=.5f;
var save=PrototypeSave.BattleSnapshot(1,Enumerable.Repeat(1,10).ToArray(),new[]{0,-1,-1,-1,-1,-1});
return new PrototypeBattle("enemy-fx-qa-"+type,new PrototypeGame(save),c);
}
public static string Simulation()
{
var rows=new List<object>();
for(int rules=1;rules<=2;rules++)for(int type=0;type<5;type++)
{
var a=Battle(type,false,false,rules);var b=Battle(type,false,false,rules);int strikes=0,hits=0;
for(int tick=0;tick<400;tick++)
{
a.Tick();b.Tick();while(a.TryDequeueCue(out var cue)) {if(cue.Kind==PrototypeBattle.CueKind.EnemyAttack)strikes++;else hits++;Check(cue.Type==type,"Wrong type cue");}
}
Check(strikes==a.Enemies.Sum(e=>e.Strikes)&&strikes>0&&hits>0,"Dropped/duplicated cue for "+rules+"/"+type);
Check(a.Heroes[0].Hp==b.Heroes[0].Hp&&a.Enemies.Sum(e=>e.Hp)==b.Enemies.Sum(e=>e.Hp)&&a.Won==b.Won&&a.Durability==b.Durability,"Consuming presentation changed battle");
rows.Add(new{rules,type,strikes,hits});
}
var shield=Battle(0,true);int blocked=0;for(int i=0;i<80;i++){shield.Tick();while(shield.TryDequeueCue(out var cue))if(cue.Shield)blocked++;}
Check(blocked>0&&shield.Heroes[0].Hp==shield.Heroes[0].MaxHp,"Shield feedback wrong");
var lethal=Battle(0,false,true);int impacts=0;for(int i=0;i<400;i++){lethal.Tick();while(lethal.TryDequeueCue(out var cue))if(cue.Kind==PrototypeBattle.CueKind.EnemyHit)impacts++;}
Check(impacts==9&&lethal.Defeated==9,"Lethal impact missing/duplicated");
Report("simulation",new{status="pass",rows,blocked,lethalImpacts=impacts,presentationConsumptionDoesNotChangeResults=true});return "PASS classic+lanes x five types, exact strike counts, shield absorption, nine lethal impacts, presentation-independent results";
}
public static string BuildReview()
{
Check(!EditorApplication.isPlaying,"Build review in Edit Mode");var original=SceneManager.GetActiveScene();var scene=EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,NewSceneMode.Additive);
try
{
SceneManager.SetActiveScene(scene);var root=new GameObject("EnemyCombatFxReview");
var camera=new GameObject("EnemyCombatFxCamera").AddComponent<Camera>();camera.transform.SetParent(root.transform,false);camera.orthographic=true;camera.orthographicSize=3.7f;camera.transform.position=new Vector3(0,6,-12);camera.transform.LookAt(new Vector3(0,.4f,0));camera.cullingMask=1<<30;camera.backgroundColor=new Color(.045f,.075f,.105f);camera.clearFlags=CameraClearFlags.SolidColor;camera.depth=70;
var light=new GameObject("ReviewLight").AddComponent<Light>();light.transform.SetParent(root.transform);light.type=LightType.Directional;light.transform.rotation=Quaternion.Euler(40,-25,0);light.intensity=1.2f;light.cullingMask=1<<30;
var bank=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Folder+"EnemyCombatPresentation.prefab"),root.transform);bank.SetActive(false);
for(int i=0;i<8;i++)
{
float x=i<5?(i-2)*2.6f:(i-6)*3.6f;float z=i<5?1.4f:-2;
var slot=new GameObject("Sample"+i);slot.layer=30;slot.transform.SetParent(root.transform);slot.transform.position=new Vector3(x,0,z);
string actor=Actors[Mathf.Min(i,4)];var model=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Characters/Enemy/"+actor+"/"+actor+"_Enemy.prefab"),slot.transform);
float height=i<5?1.2f:.85f;var mesh=new Mesh();float top=0;
foreach(var skin in model.GetComponentsInChildren<SkinnedMeshRenderer>()) {skin.BakeMesh(mesh,true);foreach(var v in mesh.vertices)top=Mathf.Max(top,model.transform.InverseTransformPoint(skin.transform.TransformPoint(v)).y);}Object.DestroyImmediate(mesh);model.transform.localScale=Vector3.one*(height/Mathf.Max(top,.1f));
var fx=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Folder+"Vfx_Enemy_"+Names[i]+".prefab"),slot.transform);fx.name="Effect";fx.transform.localPosition=new Vector3(0,i<5?.65f:.5f,-.45f);
var label=new GameObject("Label").AddComponent<TextMesh>();label.transform.SetParent(slot.transform,false);label.transform.localPosition=new Vector3(0,-.4f,-.6f);label.transform.rotation=camera.transform.rotation;label.text=Names[i];label.fontSize=48;label.characterSize=.045f;label.anchor=TextAnchor.MiddleCenter;label.color=new Color(.85f,.93f,1);
}
foreach(var t in root.GetComponentsInChildren<Transform>(true))t.gameObject.layer=30;
EditorSceneManager.SaveScene(scene,Review);
}
finally{SceneManager.SetActiveScene(original);EditorSceneManager.CloseScene(scene,true);}
return "Saved isolated "+Review;
}
public static async Task<string> Capture()
{
Check(!EditorApplication.isPlaying,"Capture review in Edit Mode");var scene=EditorSceneManager.OpenScene(Review,OpenSceneMode.Additive);var root=scene.GetRootGameObjects().Single();var camera=root.GetComponentInChildren<Camera>();
var rt=new RenderTexture(1800,900,24);var tex=new Texture2D(1800,900,TextureFormat.RGB24,false);var previous=RenderTexture.active;
try
{
camera.enabled=false;
foreach(var slot in root.GetComponentsInChildren<Transform>().Where(t=>t.name.StartsWith("Sample")))
{
int index=int.Parse(slot.name.Substring(6));var anim=slot.GetComponentInChildren<Animator>();var clip=anim.runtimeAnimatorController.animationClips.First(c=>c.name.Contains(index<5?"Attack":"Idle"));clip.SampleAnimation(anim.gameObject,clip.length*(index<5?.46f:.25f));
var ps=slot.Find("Effect").GetComponent<ParticleSystem>();ps.Simulate(.09f,true,true,true);
}
await Task.Delay(100);camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;tex.ReadPixels(new Rect(0,0,1800,900),0,0);tex.Apply();
string path=Path.Combine(Repo,"AIClient/Assets/Screenshots/enemy_combat_fx_review.png");File.WriteAllBytes(path,tex.EncodeToPNG());AssetDatabase.ImportAsset("Assets/Screenshots/enemy_combat_fx_review.png");return path;
}
finally {RenderTexture.active=previous;camera.targetTexture=null;rt.Release();Object.DestroyImmediate(rt);Object.DestroyImmediate(tex);EditorSceneManager.CloseScene(scene,true);}
}
public static async Task<string> Play()
{
Check(EditorApplication.isPlaying,"Enter Play Mode first");bool sfx=PrototypeClientPrefs.Sfx;
var bank=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(Folder+"EnemyCombatPresentation.prefab"));var p=bank.GetComponent<EnemyCombatPresentation>();
try
{
PrototypeClientPrefs.Sfx=true;
for(int i=0;i<5;i++){p.Play(new PrototypeBattle.CombatCue(PrototypeBattle.CueKind.EnemyAttack,i,Vector3.zero,Vector3.back,i==4));await Task.Delay(100);p.Play(new PrototypeBattle.CombatCue(PrototypeBattle.CueKind.EnemyHit,i,Vector3.zero,Vector3.forward));}
Check(p.PlayedEffects==15&&p.PlayedSounds>=10,"Missing attack/hit playback");
p.SetPlayback(true,1);var particles=bank.GetComponentsInChildren<ParticleSystem>().Where(ps=>ps.particleCount>0).ToArray();Check(particles.Length>0,"No visible particle emitted");var times=particles.Select(ps=>ps.time).ToArray();await Task.Delay(150);Check(particles.Select((ps,i)=>Mathf.Abs(ps.time-times[i])<.001f).All(x=>x),"Pause failed");
p.SetPlayback(false,2);Check(particles.All(ps=>ps.main.simulationSpeed==2),"2x particle clock failed");
PrototypeClientPrefs.Sfx=false;Check(bank.GetComponentsInChildren<AudioSource>().All(v=>!v.isPlaying),"SFX toggle left combat audio playing");
int before=p.PlayedSounds;p.Play(new PrototypeBattle.CombatCue(PrototypeBattle.CueKind.EnemyHit,0,Vector3.zero,Vector3.forward));Check(before==p.PlayedSounds,"Muted cue played sound");
for(int n=0;n<100;n++)p.Play(new PrototypeBattle.CombatCue(PrototypeBattle.CueKind.EnemyAttack,n%5,Vector3.zero,Vector3.back));Check(bank.GetComponentsInChildren<AudioSource>().Length==8&&bank.GetComponentsInChildren<ParticleSystem>().Length<140,"Unbounded pools");
PrototypeClientPrefs.Sfx=true;await Task.Delay(100);for(int i=0;i<5;i++)p.Play(new PrototypeBattle.CombatCue(PrototypeBattle.CueKind.EnemyAttack,i,Vector3.zero,Vector3.back));Check(bank.GetComponentsInChildren<AudioSource>().Where(v=>v.isPlaying).Sum(v=>v.volume)<=.701f,"Combat mix budget exceeded");
bank.SetActive(false);Check(bank.GetComponentsInChildren<AudioSource>(true).All(v=>!v.isPlaying),"Exit left audio playing");
Report("play",new{status="pass",allFiveTypes=true,muting=true,pause=true,doubleSpeed=true,boundedPool=true,mixGainBudget=.7f,exitCleanup=true});return "PASS Play: all five attacks/hits, shield, actual particle/audio playback, pause, 2x, mute, bounded pool and exit cleanup";
}
finally{PrototypeClientPrefs.Sfx=sfx;Object.Destroy(bank);}
}
/// <summary>Uses the real PrototypeFlow.Update/RefreshBattle with an isolated in-memory battle. No login or reward request.</summary>
public static async Task<string> PlayBattle()
{
Check(EditorApplication.isPlaying,"Enter Play Mode first");
var battle=Battle(0);var config=battle.Config;
config.stages[0].enemyIds=new[]{0,1,2,3,4,0,1,2,4};config.stages[0].waveGap=.5f;
foreach(var enemy in config.enemies){enemy.hp=100;enemy.hpStep=0;enemy.speed=20;enemy.attack=1;enemy.cooldown=.5f;}
config.pets[0].cooldown=.15f;battle.Heroes[0].Attack=60;
var arena=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab"));
var ui=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/UI/Prefab/UI_PrototypeBattle.prefab"));ui.name="UI_PrototypeBattle";ui.SetActive(false);
var host=new GameObject("IsolatedEnemyCombatFlowQA");host.SetActive(false);var flow=host.AddComponent<PrototypeFlow>();
var ft=typeof(PrototypeFlow);Action<string,object> set=(name,value)=>ft.GetField(name,BindingFlags.Instance|BindingFlags.NonPublic).SetValue(flow,value);
try
{
set("_profile",new Func<long>(()=>1));set("_boundProfile",1L);set("_game",new PrototypeGame(PrototypeSave.BattleSnapshot(1,Enumerable.Repeat(1,10).ToArray(),new[]{0,-1,-1,-1,-1,-1})));set("_config",config);set("_battle",battle);set("_arena",arena);set("_settled",true);set("_speed",1);
set("_page",Activator.CreateInstance(ft.GetNestedType("Panel",BindingFlags.NonPublic),new object[]{ui}));
int pets=config.pets.Length;
var heroes=Enumerable.Range(0,pets).Select(i=>arena.transform.Find("Heroes/Hero"+i)).ToArray();
var slots=Enumerable.Range(0,9).Select(i=>arena.transform.Find("Enemies/Enemy"+i)).ToArray();
var types=slots.Select(s=>Enumerable.Range(0,5).Select(i=>s.Find("Type"+i)).ToArray()).ToArray();
set("_heroViews",heroes);set("_enemyViews",slots);set("_enemyTypes",types);
set("_heroAnimators",heroes.Select(h=>h.GetComponentInChildren<Animator>(true)).ToArray());set("_heroPulse",new float[pets]);set("_heroSkillPulse",new float[pets]);
set("_enemyAnimators",types.Select(ts=>ts.Select(t=>t.GetComponentInChildren<Animator>(true)).ToArray()).ToArray());set("_enemyModels",new PrototypeBattle.Enemy[9]);set("_enemyStrikes",new int[9]);
foreach(var name in new[]{"_projectiles","_skillViews","_shieldViews"})set(name,Enumerable.Range(0,pets).Select(i=>arena.transform.Find((name=="_projectiles"?"Projectiles/Shot":name=="_skillViews"?"SkillEffects/Skill":"SkillEffects/Shield")+i)).ToArray());
var feedback=arena.GetComponentInChildren<EnemyCombatPresentation>();Check(feedback!=null,"Arena feedback missing");set("_enemyFeedback",feedback);
host.SetActive(true);int last=0;bool captured=false;var until=DateTime.UtcNow.AddSeconds(8);
while(!battle.Finished&&DateTime.UtcNow<until)
{
if(!captured&&battle.Enemies.Any(e=>e.Type==4&&e.Strikes>0)&&feedback.PlayedEffects>last)
{
captured=true;set("_paused",true);await Task.Delay(35);
var camera=arena.GetComponentsInChildren<Camera>().First();var rt=new RenderTexture(900,1200,24);var tex=new Texture2D(900,1200,TextureFormat.RGB24,false);var previous=RenderTexture.active;
try{camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;tex.ReadPixels(new Rect(0,0,900,1200),0,0);tex.Apply();File.WriteAllBytes(Path.Combine(Repo,"AIClient/Assets/Screenshots/enemy_combat_fx_battle.png"),tex.EncodeToPNG());}
finally{camera.targetTexture=null;RenderTexture.active=previous;rt.Release();Object.Destroy(rt);Object.Destroy(tex);}
set("_paused",false);
}
last=feedback.PlayedEffects;await Task.Delay(20);
}
Check(captured&&feedback.PlayedEffects>0&&battle.Enemies.Select(e=>e.Type).Distinct().Count()==5,"Real flow did not play every type");
Check(battle.Enemies.GroupBy(e=>e.Type).All(g=>g.Sum(e=>e.Strikes)>0),"Some type never attacked");
Report("battle",new{status="pass",realFlow=true,isolatedInMemory=true,networkRewardRequests=false,allFiveTypes=true,effects=feedback.PlayedEffects,sounds=feedback.PlayedSounds,captured,battle.Defeated});
return "PASS real PrototypeFlow battle: all five types attack, hit cues consumed, screenshot enemy_combat_fx_battle.png; no account writes";
}
finally{Object.Destroy(host);Object.Destroy(ui);Object.Destroy(arena);}
}
}
+78 -2
View File
@@ -14,8 +14,11 @@ 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 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,"../.."));
@@ -52,7 +55,21 @@ public static class VerifyEnemyMotions
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");
@@ -63,6 +80,7 @@ public static class VerifyEnemyMotions
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"})
{
@@ -72,6 +90,9 @@ public static class VerifyEnemyMotions
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);
@@ -80,10 +101,53 @@ public static class VerifyEnemyMotions
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);
@@ -94,6 +158,11 @@ public static class VerifyEnemyMotions
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)
@@ -114,6 +183,10 @@ public static class VerifyEnemyMotions
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")
@@ -123,6 +196,8 @@ public static class VerifyEnemyMotions
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)
@@ -132,6 +207,7 @@ public static class VerifyEnemyMotions
// 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);
}
@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>Checks that the five formal enemy types replaced the arena stand-ins and animate in a real isolated battle.</summary>
public static class VerifyFormalEnemies
{
const string Endpoint = "http://127.0.0.1:18081";
const string Arena = "Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab";
const int Types = 5;
/// <summary>Matches BuildPrototype.EnemyHeight (run_script compiles each file on its own).</summary>
const float EnemyHeight = 1.2f;
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Flow, value);
static async Task Until(Func<bool> condition, int seconds = 25)
{
var end = DateTime.UtcNow.AddSeconds(seconds);
while (!condition()) { Check(DateTime.UtcNow < end, "Formal enemies timed out: " + Flow?.Page); await Task.Delay(100); }
}
static void Click(string name)
{
var b = UnityEngine.Object.FindObjectsOfType<Button>().Single(x => x.name == "Btn_" + name && x.gameObject.activeInHierarchy);
Check(b.interactable, "Disabled " + name); Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(200);
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t => t != null);
type.GetMethod("CaptureGameView", BindingFlags.Public | BindingFlags.Static).Invoke(null, new object[] { 1080, 1920, null, "Screenshots/formal_enemies_" + name + ".png", false, 0, "screen" });
}
public static string Assets()
{
var arena = AssetDatabase.LoadAssetAtPath<GameObject>(Arena);
var slots = arena.transform.Find("Enemies").Cast<Transform>().ToArray();
Check(slots.Length == PrototypeBattle.TotalEnemies, "Arena has " + slots.Length + " enemy slots");
var heights = new string[Types];
var instance = (GameObject)PrefabUtility.InstantiatePrefab(arena);
try
{
foreach (var slot in instance.transform.Find("Enemies").Cast<Transform>())
{
Check(slot.Cast<Transform>().All(c => c.name == "SlowMark" || c.name.StartsWith("Type")), slot.name + " still has primitive parts");
for (int t = 0; t < Types; t++)
{
var visual = slot.Find("Type" + t);
Check(visual != null && PrefabUtility.IsAnyPrefabInstanceRoot(visual.gameObject) && !visual.gameObject.activeSelf, slot.name + " Type" + t + " missing or active");
Check(visual.GetComponentsInChildren<Transform>(true).All(x => x.gameObject.layer == 30), slot.name + " Type" + t + " not on battle layer 30");
var animator = visual.GetComponentInChildren<Animator>(true);
Check(animator != null && animator.runtimeAnimatorController != null && !animator.applyRootMotion, slot.name + " Type" + t + " animator missing");
if (slot.name != "Enemy0") continue;
slot.gameObject.SetActive(true); visual.gameObject.SetActive(true);
float min = float.MaxValue, max = float.MinValue; var mesh = new Mesh();
foreach (var skin in visual.GetComponentsInChildren<SkinnedMeshRenderer>())
{
skin.BakeMesh(mesh, true);
foreach (var v in mesh.vertices) { float y = slot.InverseTransformPoint(skin.transform.TransformPoint(v)).y; min = Mathf.Min(min, y); max = Mathf.Max(max, y); }
}
UnityEngine.Object.DestroyImmediate(mesh); visual.gameObject.SetActive(false);
Check(Mathf.Abs(max - EnemyHeight) < .05f && Mathf.Abs(min) < .05f, "Type" + t + " height/ground wrong " + min + ".." + max);
heights[t] = "Type" + t + "=" + Path.GetFileNameWithoutExtension(PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(visual.gameObject)) + " y=" + min.ToString("F2") + ".." + max.ToString("F2");
}
}
}
finally { UnityEngine.Object.DestroyImmediate(instance); }
return "PASS " + slots.Length + " slots x " + Types + " types; " + string.Join("; ", heights);
}
/// <summary>Logs into an isolated :18081 account seeded to reach <paramref name="stage"/>, fights it at 2x and checks each spawned type walks and strikes.</summary>
public static async Task<string> PlaySeeded(string seededUser, int stage)
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
// Re-entry after an interrupted run: already on the isolated account, so keep the saved real-account snapshot.
if (Get<string>("_httpBase") == Endpoint && Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null) return await Observe(stage);
SessionState.SetString("AIGame.FormalEnemies.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.FormalEnemies.originalEndpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false;
Flow.StartCoroutine(AuthApi.Login(Endpoint, seededUser, "isolated-formal-heroes-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated auth failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = seededUser };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => session));
Set("_profile", new Func<long>(() => session.Pid));
Set("_httpBase", Endpoint);
Set("_boundProfile", 0L);
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
Click("Adventure"); await Until(() => Flow.Page == "UI_PrototypeStages");
Click("Stage" + stage); await Until(() => Flow.Page == "UI_PrototypeFormation");
Click("StartBattle"); await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null);
return await Observe(stage);
}
static async Task<string> Observe(int stage)
{
// The battle panel is briefly covered while it opens; retry the 2x toggle instead of failing on the first frame.
for (int attempt = 0; ; attempt++)
{
try { if (Get<int>("_speed") < 2) Click("Speed"); break; }
catch (Exception) when (attempt < 30) { await Task.Delay(100); }
}
var arena = Get<GameObject>("_arena");
var slots = Enumerable.Range(0, PrototypeBattle.TotalEnemies).Select(i => arena.transform.Find("Enemies/Enemy" + i)).ToArray();
var walked = new HashSet<int>(); var struck = new HashSet<int>(); var shown = new HashSet<int>();
bool walkShot = false, attackShot = false; var end = DateTime.UtcNow.AddSeconds(75);
while (DateTime.UtcNow < end && !Flow.Battle.Finished && (struck.Count < shown.Count || shown.Count < Flow.Battle.Enemies.Select(e => e.Type).Distinct().Count() || walked.Count < shown.Count || shown.Count == 0))
{
foreach (var e in Flow.Battle.Enemies.Where(e => e.Alive))
{
var slot = slots[e.Index]; if (slot == null || !slot.gameObject.activeInHierarchy) continue;
var actives = slot.Cast<Transform>().Where(c => c.name.StartsWith("Type") && c.gameObject.activeSelf).ToArray();
Check(actives.Length == 1 && actives[0].name == "Type" + e.Type, "Enemy" + e.Index + " shows " + string.Join(",", actives.Select(a => a.name)) + " for type " + e.Type);
shown.Add(e.Type);
var state = actives[0].GetComponentInChildren<Animator>().GetCurrentAnimatorStateInfo(0);
if (state.IsName("Walk")) walked.Add(e.Type);
if (state.IsName("Attack")) struck.Add(e.Type);
}
if (!walkShot && shown.Count >= 2) { walkShot = true; await Capture("walk"); }
if (!attackShot && struck.Count >= 1) { attackShot = true; await Capture("attack"); }
await Task.Delay(50);
}
Check(shown.Count > 0 && walked.IsSupersetOf(shown), "Walk never played for types " + string.Join(",", shown.Except(walked)));
return "PASS stage " + stage + ": types shown " + string.Join(",", shown.OrderBy(t => t)) + ", walked " + string.Join(",", walked.OrderBy(t => t)) +
", struck " + string.Join(",", struck.OrderBy(t => t)) + (Flow.Battle.Finished ? " (battle finished, won=" + Flow.Battle.Won + ")" : "") + "; screenshots formal_enemies_walk/attack.png";
}
public static async Task<string> Cleanup()
{
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => net.Session));
Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0));
Set("_httpBase", SessionState.GetString("AIGame.FormalEnemies.originalEndpoint", "http://127.0.0.1:8080"));
Set("_boundProfile", 0L);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.FormalEnemies.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.level == original.level, "Real account was changed by enemy test");
return "PASS cleanup: real account restored; battle check used isolated :18081 only.";
}
}
+214
View File
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>Checks that formal partner art replaced the battle stand-ins and animates in a real isolated battle.</summary>
public static class VerifyFormalHeroes
{
const string Endpoint = "http://127.0.0.1:18081";
const string Arena = "Assets/Bundles/Scenes/Prototype/PrototypeArena.prefab";
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Flow, value);
static async Task Until(Func<bool> condition, int seconds = 25)
{
var end = DateTime.UtcNow.AddSeconds(seconds);
while (!condition()) { Check(DateTime.UtcNow < end, "Formal heroes timed out: " + Flow?.Page); await Task.Delay(100); }
}
static void Click(string name)
{
var b = UnityEngine.Object.FindObjectsOfType<Button>().Single(x => x.name == "Btn_" + name && x.gameObject.activeInHierarchy);
Check(b.interactable, "Disabled " + name); Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
static async Task Capture(string name)
{
await Task.Delay(200);
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t => t != null);
type.GetMethod("CaptureGameView", BindingFlags.Public | BindingFlags.Static).Invoke(null, new object[] { 1080, 1920, null, "Screenshots/formal_heroes_" + name + ".png", false, 0, "screen" });
}
/// <summary>Pet ids whose arena hero nests formal art (BuildPrototype.FormalPets).</summary>
static readonly int[] Formal = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
public static string Assets()
{
var arena = AssetDatabase.LoadAssetAtPath<GameObject>(Arena);
var lines = new List<string>();
foreach (int id in Formal)
{
var hero = arena.transform.Find("Heroes/Hero" + id);
var visual = hero.Find("Visual");
Check(visual != null && PrefabUtility.IsAnyPrefabInstanceRoot(visual.gameObject), "Hero" + id + " lacks nested formal prefab");
Check(hero.Cast<Transform>().All(c => c.name == "Visual" || c.name == "T2Crest"), "Hero" + id + " still has primitive parts");
Check(visual.GetComponentsInChildren<Transform>(true).All(t => t.gameObject.layer == 30), "Hero" + id + " visual not on battle layer 30");
var animator = visual.GetComponentInChildren<Animator>(true);
Check(animator != null && animator.runtimeAnimatorController != null && !animator.applyRootMotion, "Hero" + id + " animator missing");
var instance = (GameObject)PrefabUtility.InstantiatePrefab(arena);
try
{
var h = instance.transform.Find("Heroes/Hero" + id); h.gameObject.SetActive(true);
float min = float.MaxValue, max = float.MinValue; var mesh = new Mesh();
foreach (var skin in h.Find("Visual").GetComponentsInChildren<SkinnedMeshRenderer>())
{
skin.BakeMesh(mesh, true);
foreach (var v in mesh.vertices) { float y = h.InverseTransformPoint(skin.transform.TransformPoint(v)).y; min = Mathf.Min(min, y); max = Mathf.Max(max, y); }
}
UnityEngine.Object.DestroyImmediate(mesh);
Check(Mathf.Abs(max - 1.5f) < .05f && Mathf.Abs(min) < .05f, "Hero" + id + " height/ground wrong " + min + ".." + max);
lines.Add("Hero" + id + "=" + PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(visual.gameObject) + " y=" + min.ToString("F2") + ".." + max.ToString("F2"));
}
finally { UnityEngine.Object.DestroyImmediate(instance); }
}
foreach (int id in Enumerable.Range(0, 10).Except(Formal)) Check(arena.transform.Find("Heroes/Hero" + id + "/Visual") == null, "Hero" + id + " unexpectedly formal");
return "PASS " + string.Join("; ", lines);
}
public static Task<string> Play() => PlaySeeded(null, 1);
/// <summary><paramref name="seededUser"/>: an existing isolated :18081 account (password below) whose save was seeded to field later partners.</summary>
public static async Task<string> PlaySeeded(string seededUser, int stage)
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.FormalHeroes.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.FormalHeroes.originalEndpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false;
string user = seededUser ?? "formalheroes_" + Guid.NewGuid().ToString("N");
Directory.CreateDirectory(Path.Combine(Application.dataPath, "../../Tools/output/formal_heroes"));
File.WriteAllText(Path.Combine(Application.dataPath, "../../Tools/output/formal_heroes/account.txt"), user);
Action<AuthResponse, string> onAuth = (r, e) => { account = r; error = e; done = true; };
Flow.StartCoroutine(seededUser != null ? AuthApi.Login(Endpoint, user, "isolated-formal-heroes-password", onAuth) : AuthApi.Register(Endpoint, user, "isolated-formal-heroes-password", onAuth));
await Until(() => done);
Check(account != null, "Isolated auth failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => session));
Set("_profile", new Func<long>(() => session.Pid));
Set("_httpBase", Endpoint);
Set("_boundProfile", 0L);
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
Click("Adventure"); await Until(() => Flow.Page == "UI_PrototypeStages");
Click("Stage" + stage); await Until(() => Flow.Page == "UI_PrototypeFormation");
Click("StartBattle"); await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null);
var arena = Get<GameObject>("_arena");
var fielded = Flow.Battle.Heroes.Select(h => h.Pet).Where(p => Formal.Contains(p)).Distinct().ToArray();
Check(fielded.Length > 0, "No formal partner fielded in 1-" + stage);
var animators = fielded.ToDictionary(p => p, p => arena.transform.Find("Heroes/Hero" + p + "/Visual").GetComponentInChildren<Animator>());
var attacked = new HashSet<int>(); var end = DateTime.UtcNow.AddSeconds(stage > 1 ? 45 : 20);
string shot = seededUser != null ? "seeded_" : "";
await Capture(shot + "idle"); bool attackShot = false;
while (attacked.Count < fielded.Length && DateTime.UtcNow < end && !Flow.Battle.Finished)
{
foreach (var kv in animators) if (kv.Value.isActiveAndEnabled && kv.Value.GetCurrentAnimatorStateInfo(0).IsName("Attack")) attacked.Add(kv.Key);
if (attacked.Count >= 1 && !attackShot) { attackShot = true; await Capture(shot + "attack"); }
await Task.Delay(50);
}
Check(attacked.Count == fielded.Length, "Attack never played for " + string.Join(",", fielded.Except(attacked)));
Click("Speed");
await Until(() => animators.Values.All(a => !a.isActiveAndEnabled || Mathf.Approximately(a.speed, 2)), 5);
return "PASS formal partners " + string.Join(",", fielded) + " fielded in 1-" + stage + ", played Attack and follow 2x speed; screenshots formal_heroes_" + shot + "idle/attack.png";
}
public static async Task<string> OpenSeedAccount()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.FormalHeroes.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.FormalHeroes.originalEndpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false;
string user = "formalheroes_" + Guid.NewGuid().ToString("N");
Directory.CreateDirectory(Path.Combine(Application.dataPath, "../../Tools/output/formal_heroes"));
File.WriteAllText(Path.Combine(Application.dataPath, "../../Tools/output/formal_heroes/account.txt"), user);
Flow.StartCoroutine(AuthApi.Register(Endpoint, user, "isolated-formal-heroes-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated register failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => session));
Set("_profile", new Func<long>(() => session.Pid));
Set("_httpBase", Endpoint);
Set("_boundProfile", 0L);
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy && Flow.Game.State.schema >= 9);
return user;
}
/// <summary>Reload the isolated account after its save was seeded, then field SparkBird and BubbleTurtle in 1-1.</summary>
public static async Task<string> BattleBirdTurtle()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_boundProfile", 0L);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy && Flow.Game.State.stages[3] == 1 && Flow.Game.State.stages[4] == 1 && Flow.Game.State.formation[0] == 4 && Flow.Game.State.formation[1] == 3);
Click("Adventure"); await Until(() => Flow.Page == "UI_PrototypeStages");
Click("Stage1"); await Until(() => Flow.Page == "UI_PrototypeFormation");
Check(Flow.Game.State.formation[0] == 4 && Flow.Game.State.formation[1] == 3, "Seeded formation was replaced");
Click("StartBattle"); await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null, 40);
var arena = Get<GameObject>("_arena");
var fielded = Flow.Battle.Heroes.Select(h => h.Pet).OrderBy(p => p).ToArray();
Check(fielded.SequenceEqual(new[] { 3, 4 }), "1-1 fielded " + string.Join(",", fielded));
Check(Flow.Battle.Heroes.Single(h => h.Pet == 4).Slot == 0 && Flow.Battle.Heroes.Single(h => h.Pet == 3).Slot == 1, "Turtle is not in front of the bird");
var animators = fielded.ToDictionary(p => p, p => arena.transform.Find("Heroes/Hero" + p + "/Visual").GetComponentInChildren<Animator>());
foreach (var kv in animators) Check(kv.Value != null && arena.transform.Find("Heroes/Hero" + kv.Key).gameObject.activeInHierarchy, "Hero" + kv.Key + " inactive");
var attacked = new HashSet<int>(); var end = DateTime.UtcNow.AddSeconds(25);
await Capture("bird_turtle_idle"); bool attackShot = false;
while (attacked.Count < fielded.Length && DateTime.UtcNow < end && !Flow.Battle.Finished)
{
foreach (var kv in animators) if (kv.Value.isActiveAndEnabled && kv.Value.GetCurrentAnimatorStateInfo(0).IsName("Attack")) attacked.Add(kv.Key);
if (attacked.Count >= 1 && !attackShot) { attackShot = true; await Capture("bird_turtle_attack"); }
await Task.Delay(50);
}
Check(attacked.Count == fielded.Length, "Attack never played for " + string.Join(",", fielded.Except(attacked)));
Click("Speed");
await Until(() => animators.Values.All(a => !a.isActiveAndEnabled || Mathf.Approximately(a.speed, 2)), 5);
return "PASS SparkBird and BubbleTurtle fielded in 1-1, feet on the lane, Attack played, 2x speed followed.";
}
public static async Task<string> Abandon()
{
if (Flow?.Page == "UI_PrototypeBattle" && Flow.Battle != null && !Flow.Battle.Finished)
{
Click("Abandon");
await Task.Delay(200);
Click("Ok");
await Until(() => Flow.Battle == null || Flow.Battle.Finished && !Flow.IsBusy);
}
return "Battle settled";
}
public static async Task<string> Cleanup()
{
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => net.Session));
Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0));
Set("_httpBase", SessionState.GetString("AIGame.FormalHeroes.originalEndpoint", "http://127.0.0.1:8080"));
Set("_boundProfile", 0L);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.FormalHeroes.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level, "Real account was changed by formal heroes test");
return "PASS cleanup: real account restored; battle check used isolated :18081 only.";
}
}
+297
View File
@@ -0,0 +1,297 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using Newtonsoft.Json;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>G3 guide copy and an editor-only battle frame sample. Not a WeChat device pass.</summary>
public static class VerifyG3
{
const string Endpoint = "http://127.0.0.1:18081";
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Flow, value);
static async Task Until(Func<bool> condition, int seconds = 30)
{
var end = DateTime.UtcNow.AddSeconds(seconds);
while (!condition())
{
Check(DateTime.UtcNow < end, "G3 timed out: " + Flow?.Page);
await Task.Delay(100);
}
}
static Button Button(string name) => UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_" + name && b.gameObject.activeInHierarchy);
static void Click(string name)
{
var b = Button(name);
Check(b.interactable, "Disabled " + name);
Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>();
EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name + " by " + (hits.Count == 0 ? "nothing" : hits[0].gameObject.name));
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
public static string Domain()
{
var save = new PrototypeSave();
Check(MainHudView.GuideTask(save) == "发射第一枚弹珠", "Fresh guide");
save.shots = 1; save.tickets = 1;
Check(MainHudView.GuideTask(save) == "用捕捉券获得芽芽", "Capture guide");
save.captureTutorialDone = true;
Check(MainHudView.GuideTask(save) == "用糖果做一次共享训练", "Train guide");
save.level = 2;
Check(MainHudView.GuideTask(save) == "布阵并完成教学关 1-1", "Battle guide");
save.cleared = true;
Check(MainHudView.GuideTask(save) == "领取首通目标奖励", "Claim guide");
save.goalClaimed = true;
Check(MainHudView.GuideTask(save) == "教学目标已完成", "Done guide");
return "PASS first-day guide order.";
}
public static async Task<string> Play()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.G3.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.G3.originalEndpoint", Get<string>("_httpBase"));
int quality = PrototypeClientPrefs.Quality;
SessionState.SetInt("AIGame.G3.quality", quality);
PrototypeClientPrefs.Quality = 0;
AuthResponse account = null; string error = null; bool done = false;
string user = "g3_" + Guid.NewGuid().ToString("N");
string dir = Path.Combine(Application.dataPath, "../../Tools/output/g3");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "account.txt"), user);
Flow.StartCoroutine(AuthApi.Register(Endpoint, user, "isolated-g3-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated register failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => session));
Set("_profile", new Func<long>(() => session.Pid));
Set("_httpBase", Endpoint);
Set("_boundProfile", 0L);
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy && Flow.Page == "Home");
var task = UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().First(t => t.name == "Txt_Task" && t.gameObject.activeInHierarchy);
Check(task.text == "发射第一枚弹珠", "Home guide was " + task.text);
Click("Adventure");
await Until(() => Flow.Page == "UI_PrototypeStages");
Click("Stage1");
await Until(() => Flow.Page == "UI_PrototypeFormation");
Click("StartBattle");
await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null, 40);
var samples = new List<float>();
int heroes = 0, enemies = 0;
var arena = Get<GameObject>("_arena");
Flow.StartCoroutine(Sample(samples, () =>
{
heroes = Math.Max(heroes, ActiveChildren(arena, "Heroes"));
enemies = Math.Max(enemies, ActiveChildren(arena, "Enemies"));
}));
await Until(() => samples.Count >= 90 || Flow.Battle.Finished, 20);
Check(samples.Count >= 30, "Too few battle frames");
Check(heroes >= 1 && heroes <= 6, "Hero count " + heroes);
Check(enemies <= 40, "Enemy count " + enemies);
Click("Abandon");
await Task.Delay(200);
Click("Ok");
await Until(() => UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Any(t => t.name == "Txt_ResultInfo" && t.gameObject.activeInHierarchy && t.text.Contains("建议:")));
string advice = UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().First(t => t.name == "Txt_ResultInfo" && t.gameObject.activeInHierarchy).text;
float sum = samples.Sum();
var report = new
{
device = "Unity Editor " + SystemInfo.operatingSystem + " / " + SystemInfo.graphicsDeviceName,
quality = "流畅",
note = "Editor sample only. Does not satisfy the WeChat 30 FPS target.",
frames = samples.Count,
averageFps = Math.Round(samples.Count / sum, 1),
minFrameMs = Math.Round(samples.Min() * 1000, 2),
maxFrameMs = Math.Round(samples.Max() * 1000, 2),
heroes,
enemies,
advice
};
File.WriteAllText(Path.Combine(Application.dataPath, "../../Doc/Design/G3/editor_frame_sample.json"), JsonConvert.SerializeObject(report, Formatting.Indented));
PrototypeClientPrefs.Quality = quality;
return "PASS guide, abandon advice, heroes " + heroes + ", enemies " + enemies + ", editor avg fps " + report.averageFps;
}
static IEnumerator Sample(List<float> samples, Action observe)
{
while (samples.Count < 90 && Flow.Battle != null && !Flow.Battle.Finished)
{
yield return null;
samples.Add(Time.unscaledDeltaTime);
observe();
}
}
static int ActiveChildren(GameObject arena, string name)
{
var root = arena.transform.Find(name);
if (root == null) return 0;
int count = 0;
for (int i = 0; i < root.childCount; i++)
if (root.GetChild(i).gameObject.activeInHierarchy) count++;
return count;
}
static async Task Shot()
{
int shots = Flow.Game.State.shots;
Click("Fire");
await Until(() => !Flow.IsBusy && Flow.Game.State.shots == shots + 1 && string.IsNullOrEmpty(Flow.Game.State.pendingShot), 20);
}
static string HomeTask() => UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().First(t => t.name == "Txt_Task" && t.gameObject.activeInHierarchy).text;
static async Task Home(string guide)
{
if (Flow.Page != "Home") { Click("Back"); await Until(() => Flow.Page == "Home" && !Flow.IsBusy); }
Check(MainHudView.GuideTask(Flow.Game.State) == guide, "Guide state was " + MainHudView.GuideTask(Flow.Game.State));
Check(HomeTask() == guide, "Home guide was " + HomeTask());
await Task.Delay(200);
}
public static async Task<string> Loop()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy);
SessionState.SetString("AIGame.G3.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.G3.originalEndpoint", Get<string>("_httpBase"));
AuthResponse account = null; string error = null; bool done = false;
string user = "g3loop_" + Guid.NewGuid().ToString("N");
string dir = Path.Combine(Application.dataPath, "../../Tools/output/g3");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "account.txt"), user);
Flow.StartCoroutine(AuthApi.Register(Endpoint, user, "isolated-g3-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated register failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => session));
Set("_profile", new Func<long>(() => session.Pid));
Set("_httpBase", Endpoint);
Set("_boundProfile", 0L);
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy && Flow.Page == "Home");
var fresh = Flow.Game.State;
Check(fresh.balls == 20 && fresh.candy == 0 && fresh.coins == 0 && fresh.tickets == 0 && fresh.level == 1 && fresh.shots == 0, "Fresh teaching wallet wrong");
await Home("发射第一枚弹珠");
Click("NavPinball");
await Until(() => Flow.Page == "UI_PrototypePinball");
await Shot();
Check(Flow.Game.State.tickets == 1, "First shot did not grant a ticket");
int extra = 0;
while (Flow.Game.State.candy < 30)
{
extra++;
Check(extra <= 6, "Could not reach training candy");
await Shot();
}
int shotCandy = Flow.Game.State.candy, shotCoins = Flow.Game.State.coins, shots = Flow.Game.State.shots;
await Home("用捕捉券获得芽芽");
Click("NavPinball");
await Until(() => Flow.Page == "UI_PrototypePinball");
Click("Capture");
await Until(() => Flow.Page == "UI_PrototypeCapture");
Click("Capture");
await Until(() => !Flow.IsBusy && Flow.Game.State.captureTutorialDone && Flow.Game.State.stages[2] == 1 && Flow.Game.State.tickets == 0);
Click("Ok");
await Until(() => Flow.Page == "UI_PrototypePets" && !Flow.IsBusy);
Click("Train");
await Until(() => !Flow.IsBusy && Flow.Game.State.level == 2);
Check(Flow.Game.State.candy == shotCandy - 30, "Training did not spend 30 candy");
await Home("布阵并完成教学关 1-1");
int coins = Flow.Game.State.coins, candy = Flow.Game.State.candy, balls = Flow.Game.State.balls;
Click("Adventure");
await Until(() => Flow.Page == "UI_PrototypeStages");
Click("Stage1");
await Until(() => Flow.Page == "UI_PrototypeFormation");
Check(Flow.Game.State.formation[0] == 0 && Flow.Game.State.formation[1] == 1, "Teaching formation changed");
Click("StartBattle");
await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null, 40);
Click("Speed");
await Until(() => Flow.Battle.Finished && !Flow.IsBusy && Flow.Game.State.cleared, 90);
Check(Flow.Battle.Won, "Stage 1-1 was not won");
Check(Flow.Game.State.coins == coins + 80 && Flow.Game.State.candy == candy + 40 && Flow.Game.State.balls == balls + 3, "First-clear reward mismatch");
Click("Home");
await Home("领取首通目标奖励");
Click("Tasks");
await Until(() => Flow.Page == "UI_PrototypeTasks");
Click("ClaimGoal");
await Until(() => !Flow.IsBusy && Flow.Game.State.goalClaimed);
Check(Flow.Game.State.balls == balls + 8, "Teaching goal was not +5");
Click("Ok");
Click("Back");
await Until(() => Flow.Page == "Home" && !Flow.IsBusy);
Check(MainHudView.GuideTask(Flow.Game.State) == "教学目标已完成", "Goal flag did not finish the guide");
int ready = 0;
if (Flow.Game.State.dailyProgress[0] >= 3) ready++;
if (Flow.Game.State.dailyProgress[1] >= 1) ready++;
Check(HomeTask() == ready + "项奖励可领取", "Home did not switch to daily tasks: " + HomeTask());
Check(Flow.Game.State.coins == shotCoins + 80 && Flow.Game.State.candy == shotCandy + 10 && Flow.Game.State.tickets == 0 && Flow.Game.State.balls == 20 - shots + 8, "Teaching wallet identity failed");
var report = new
{
account = user,
note = "Teaching loop only. Mail, sign-in, daily claims, shop and stages 1-2 through 1-5 are not included, so this is not the 79-ball day ceiling.",
shots,
extraShotsAfterTicket = extra,
shotCandy,
shotCoins,
balls = Flow.Game.State.balls,
candy = Flow.Game.State.candy,
coins = Flow.Game.State.coins,
tickets = Flow.Game.State.tickets,
level = Flow.Game.State.level,
budBudOwned = Flow.Game.State.stages[2] == 1,
cleared = Flow.Game.State.cleared,
goalClaimed = Flow.Game.State.goalClaimed,
dailyReady = ready,
homeAfterGoal = HomeTask()
};
File.WriteAllText(Path.Combine(Application.dataPath, "../../Doc/Design/G3/first_day_loop.json"), JsonConvert.SerializeObject(report, Formatting.Indented));
return "PASS teaching loop: " + shots + " shots, capture, train Lv.2, 1-1 clear, goal +5. Wallet balls " + report.balls + ", candy " + report.candy + ", coins " + report.coins + ".";
}
public static async Task<string> Cleanup()
{
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => net.Session));
Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0));
Set("_httpBase", SessionState.GetString("AIGame.G3.originalEndpoint", "http://127.0.0.1:8080"));
Set("_boundProfile", 0L);
PrototypeClientPrefs.Quality = SessionState.GetInt("AIGame.G3.quality", 1);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.G3.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level, "Real account was changed by G3 test");
return "PASS cleanup: real account restored; G3 check used isolated :18081 only.";
}
}
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>Confirms the camp painting has a walking placeholder and speech only after a tap.</summary>
public static class VerifyHomeExplorer
{
static async Task Until(Func<bool> condition, int seconds = 20)
{
var end = DateTime.UtcNow.AddSeconds(seconds);
while (!condition())
{
if (DateTime.UtcNow >= end) throw new Exception("Home explorer timed out");
await Task.Delay(100);
}
}
public static async Task<string> Play()
{
if (!EditorApplication.isPlaying) throw new Exception("Enter Play Mode first");
HomeExplorerPresenter presenter = null;
await Until(() => (presenter = UnityEngine.Object.FindObjectOfType<HomeExplorerPresenter>()) != null && presenter.Ready);
if (presenter.Talking) throw new Exception("Speech was visible before a tap: " + presenter.Line);
var greeting = presenter.transform.parent.Find("Layout_Sides/Panel_Greeting");
if (greeting != null && greeting.gameObject.activeSelf) throw new Exception("Static greeting is still visible");
float x = presenter.transform.parent.Find("Btn_Explorer").position.x;
await Task.Delay(900);
float moved = presenter.transform.parent.Find("Btn_Explorer").position.x;
if (Mathf.Abs(moved - x) < 1f) throw new Exception("Explorer did not move: " + x + " -> " + moved);
await Capture("walk");
var button = GameObject.Find("Btn_Explorer").GetComponent<Button>();
button.onClick.Invoke();
await Task.Delay(200);
if (!presenter.Talking) throw new Exception("Tap did not open speech");
string line = presenter.Line;
if (string.IsNullOrEmpty(line)) throw new Exception("Speech was empty");
await Capture("talk");
button.onClick.Invoke();
await Task.Delay(100);
if (presenter.Line == line) throw new Exception("Second tap repeated the same line");
return "PASS explorer walks and speaks only after a tap: " + line;
}
static async Task Capture(string name)
{
await Task.Delay(150);
var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("Unity.Pipeline.Editor.Commands.Capture.CaptureCommands")).First(t => t != null);
type.GetMethod("CaptureGameView", BindingFlags.Public | BindingFlags.Static).Invoke(null, new object[] { 1080, 1920, null, "Screenshots/home_explorer_" + name + ".png", false, 0, "screen" });
}
}
@@ -0,0 +1,163 @@
#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
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HotUpdate.Gameplay;
using HotUpdate.Net;
using HotUpdate.UI;
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>Play Mode check that real clicks and server results trigger the expected UI one-shots, and the SFX toggle mutes them.</summary>
public static class VerifyUiSfx
{
const string Endpoint = "http://127.0.0.1:18081";
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
static UiSfx Player => UnityEngine.Object.FindObjectOfType<UiSfx>();
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Flow);
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Flow, value);
static T Sfx<T>(string name) => (T)typeof(UiSfx).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Player);
static async Task Until(Func<bool> condition, int seconds = 25)
{
var end = DateTime.UtcNow.AddSeconds(seconds);
while (!condition()) { Check(DateTime.UtcNow < end, "UI SFX timed out: " + Flow?.Page); await Task.Delay(100); }
}
static void Click(string name)
{
var b = UnityEngine.Object.FindObjectsOfType<Button>().Single(x => x.name == "Btn_" + name && x.gameObject.activeInHierarchy);
Check(b.interactable, "Disabled " + name); Canvas.ForceUpdateCanvases();
var rect = b.GetComponent<RectTransform>();
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(pointer, hits);
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name);
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
}
/// <summary>Clicks a button and returns the last UiSound it caused (null when nothing played).</summary>
static async Task<UiSound?> Sound(string button, Func<bool> settled)
{
float before = Sfx<float>("_lastAt");
Click(button); await Until(settled); await Task.Delay(250);
return Mathf.Approximately(Sfx<float>("_lastAt"), before) ? (UiSound?)null : Sfx<UiSound>("_last");
}
public static async Task<string> Play()
{
Check(EditorApplication.isPlaying, "Enter Play Mode first");
await Until(() => Flow?.Game != null && !Flow.IsBusy && Player != null);
Check(Player.Source != null && Enum.GetValues(typeof(UiSound)).Cast<UiSound>().All(s => Player.Clip(s) != null), "UiSfx prefab not loaded with clips");
SessionState.SetString("AIGame.UiSfx.original", JsonUtility.ToJson(Flow.Game.State));
SessionState.SetString("AIGame.UiSfx.originalEndpoint", Get<string>("_httpBase"));
SessionState.SetBool("AIGame.UiSfx.sfx", PrototypeClientPrefs.Sfx);
PrototypeClientPrefs.Sfx = true;
AuthResponse account = null; string error = null; bool done = false;
string user = "uisfx_" + Guid.NewGuid().ToString("N");
Directory.CreateDirectory(Path.Combine(Application.dataPath, "../../Tools/output/ui_sfx_v2"));
File.WriteAllText(Path.Combine(Application.dataPath, "../../Tools/output/ui_sfx_v2/account.txt"), user);
Flow.StartCoroutine(AuthApi.Register(Endpoint, user, "isolated-ui-sfx-password", (r, e) => { account = r; error = e; done = true; }));
await Until(() => done);
Check(account != null, "Isolated register failed " + error);
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Set("_session", new Func<SessionData>(() => session));
Set("_profile", new Func<long>(() => session.Pid));
Set("_httpBase", Endpoint);
Set("_boundProfile", 0L);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
var log = new List<string>();
void Expect(string step, UiSound? actual, UiSound? expected) { log.Add(step + "=" + (actual?.ToString() ?? "none")); Check(actual == expected, step + " played " + (actual?.ToString() ?? "nothing") + ", expected " + (expected?.ToString() ?? "nothing")); }
Expect("hud.settings", await Sound("Settings", () => Flow.Page == "UI_PrototypeSettings"), UiSound.Open);
Expect("settings.quality", await Sound("CycleQuality", () => true), UiSound.Toggle);
Click("CycleQuality"); Click("CycleQuality"); await Task.Delay(100);
Expect("settings.sfxOff", await Sound("ToggleSfx", () => !PrototypeClientPrefs.Sfx), UiSound.Toggle);
Expect("muted.close", await Sound("Close", () => Flow.Page == "Home"), null);
Expect("muted.settings", await Sound("Settings", () => Flow.Page == "UI_PrototypeSettings"), null);
Click("ToggleSfx"); await Until(() => PrototypeClientPrefs.Sfx);
Expect("settings.close", await Sound("Close", () => Flow.Page == "Home"), UiSound.Close);
Expect("hud.rewards", await Sound("Rewards", () => Flow.Page == "UI_PrototypeChest"), UiSound.Open);
Expect("chest.claimDaily", await Sound("ClaimDailyChest", () => !Flow.IsBusy && Flow.Game.State.chestDailyClaimed == 1), UiSound.Claim);
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
Expect("chest.back", await Sound("Back", () => Flow.Page == "Home"), UiSound.Close);
return "PASS " + string.Join(", ", log);
}
public static async Task<string> Cleanup()
{
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
PrototypeClientPrefs.Sfx = SessionState.GetBool("AIGame.UiSfx.sfx", true);
Set("_session", new Func<SessionData>(() => net.Session));
Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0));
Set("_httpBase", SessionState.GetString("AIGame.UiSfx.originalEndpoint", "http://127.0.0.1:8080"));
Set("_boundProfile", 0L);
Flow.Handle("Home");
await Until(() => Flow.Game != null && !Flow.IsBusy);
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.UiSfx.original", ""));
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level, "Real account was changed by UI SFX test");
return "PASS cleanup: real account and SFX preference restored; checks used isolated :18081 only.";
}
}