Files
AIGame/AIClient/AgentScripts/VerifyEnemyCombatFx.cs
T

174 lines
17 KiB
C#

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(bool studio = false)
{
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=studio?240: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();
if(studio)for(int s=0;s<slots.Length;s++)for(int t=0;t<5;t++)
Check(types[s][t].GetComponentsInChildren<SkinnedMeshRenderer>(true).All(r=>AssetDatabase.GetAssetPath(r.sharedMesh).Contains("/"+Actors[t]+"Studio/")),"Old enemy mesh in slot "+s+" type "+t);
set("_heroViews",heroes);set("_enemyViews",slots);set("_enemyTypes",types);
var t1=heroes.Select(h=>h.Find("Visual")).ToArray();var t2=heroes.Select(h=>h.Find("VisualT2")).ToArray();
set("_heroT1Visuals",t1);set("_heroT2Visuals",t2);
set("_heroAnimators",t1.Select(h=>h==null?null:h.GetComponentInChildren<Animator>(true)).ToArray());
set("_heroT2Animators",t2.Select(h=>h==null?null: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);
var walked=new HashSet<int>();var attacked=new HashSet<int>();
host.SetActive(true);int last=0;bool captured=false;var until=DateTime.UtcNow.AddSeconds(10);
while(!battle.Finished&&DateTime.UtcNow<until)
{
foreach(var e in battle.Enemies.Where(e=>e.Alive))
{
var anim=types[e.Index][e.Type].GetComponentInChildren<Animator>();var state=anim.GetCurrentAnimatorStateInfo(0);
if(state.IsName("Walk"))walked.Add(e.Type);if(state.IsName("Attack"))attacked.Add(e.Type);
}
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/"+(studio?"enemy_studio_arena":"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");
if(studio)Check(walked.Count==5&&attacked.Count==5,"Missing Studio Walk/Attack: "+string.Join(",",walked)+" / "+string.Join(",",attacked));
Report(studio?"studio_arena":"battle",new{status="pass",realFlow=true,isolatedInMemory=true,networkRewardRequests=false,allFiveTypes=true,studio,walked=walked.OrderBy(t=>t),attacked=attacked.OrderBy(t=>t),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);}
}
}