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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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++)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);}
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user