326 lines
18 KiB
C#
326 lines
18 KiB
C#
using System.Collections;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using BumpCombat.Combat;
|
|
using BumpCombat.Constants;
|
|
using BumpCombat.Defense;
|
|
using BumpCombat.Defense.Comparison;
|
|
using BumpCombat.Enemies;
|
|
using NUnit.Framework;
|
|
using UnityEditor.SceneManagement;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
using UnityEngine.TestTools;
|
|
using Object = UnityEngine.Object;
|
|
|
|
namespace BumpCombat.Tests
|
|
{
|
|
public sealed class Defense25DComparisonShowcasePlayModeTests
|
|
{
|
|
private const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity";
|
|
private Defense25DComparison world;
|
|
private float previousTimeScale;
|
|
|
|
[UnitySetUp]
|
|
public IEnumerator SetUp()
|
|
{
|
|
previousTimeScale = Time.timeScale;
|
|
Time.timeScale = 1f;
|
|
yield return EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath,
|
|
new LoadSceneParameters(LoadSceneMode.Single));
|
|
world = Object.FindAnyObjectByType<Defense25DComparison>();
|
|
Assert.That(world, Is.Not.Null);
|
|
yield return null;
|
|
world.AutoSpawn = false;
|
|
world.ReadInput = false;
|
|
CompleteStartupArtifactSelection(world);
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTearDown]
|
|
public IEnumerator TearDown()
|
|
{
|
|
Time.timeScale = previousTimeScale;
|
|
yield return null;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator ShowcaseUsesSixRealTowerFamiliesAndStationarySkeletonsThenRestoresTheRun()
|
|
{
|
|
Defense25DTowerManager runManager = world.TowerManager;
|
|
runManager.AddCurrency(1000);
|
|
DefenseConstants tuning = GameplayConstants.Current.Defense;
|
|
Vector2 directPad = tuning.GetBuildPadPosition(0);
|
|
world.Player.ResetAt(directPad);
|
|
Physics.SyncTransforms();
|
|
Assert.That(runManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
|
|
Defense25DTower runDirect = runManager.GetTowerAtPad(0);
|
|
Vector2 summonPad = tuning.GetBuildPadPosition(1);
|
|
world.Player.ResetAt(summonPad);
|
|
Physics.SyncTransforms();
|
|
Assert.That(runManager.TryBuildTowerAtPad(1, DefenseTowerKind.Summon), Is.True);
|
|
Defense25DTower runSummonTower = runManager.GetTowerAtPad(1);
|
|
Defense25DSummon runSummon = world.Summons.First(summon => summon.Owner == runSummonTower);
|
|
Assert.That(runSummon, Is.Not.Null);
|
|
runSummon.Actor.ResetAt(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Summon));
|
|
|
|
Defense25DEnemy runEnemy = null;
|
|
for (int attempt = 0; attempt < GameplayConstants.Current.Defense.PortalEnemyCount * 2; attempt++)
|
|
{
|
|
Defense25DEnemy candidate = world.SpawnNextEnemy();
|
|
if (candidate == null) break;
|
|
if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Direct, candidate))
|
|
{
|
|
runEnemy = candidate;
|
|
break;
|
|
}
|
|
}
|
|
Assert.That(runEnemy, Is.Not.Null, "A non-immune lane must be available within the configured two-lane supply.");
|
|
runEnemy.Actor.ResetAt(runDirect.Position + Vector2.right * .7f);
|
|
Physics.SyncTransforms();
|
|
runDirect.StepParityAttacks(0f);
|
|
Transform runProjectile = world.transform.Find("Tower Projectile");
|
|
Assert.That(runProjectile, Is.Not.Null, "The existing run must have a live tower projectile before showcase entry.");
|
|
DefenseSpriteSequence projectileSequence = runProjectile.GetComponentInChildren<DefenseSpriteSequence>(true);
|
|
Assert.That(projectileSequence, Is.Not.Null);
|
|
Assert.That(projectileSequence.IsFinished, Is.False);
|
|
|
|
Assert.That(world.Settings.Enemy.name, Is.EqualTo("Skeleton"));
|
|
Assert.That(world.Settings.EnemyAttackClip, Is.Not.Null);
|
|
Assert.That(world.Settings.EnemyArt.Attack.Length, Is.GreaterThan(0));
|
|
Assert.That(world.Settings.EnemyArt.Attack[0].name, Does.StartWith("Skeleton_Attack"));
|
|
int runCurrency = runManager.ConstructionCurrency;
|
|
int supplied = world.Ledger.SuppliedCount;
|
|
int resolved = world.Ledger.ResolvedCount;
|
|
int kills = world.KillCurrency;
|
|
float baseHealth = world.BaseHealth;
|
|
float gameTime = world.GameTime;
|
|
Vector2 playerPosition = new(-4.5f, 0f);
|
|
world.Player.ResetAt(playerPosition);
|
|
Physics.SyncTransforms();
|
|
DefenseTowerKind[] kinds = DefenseTowerHitTestLayout.KindsByPad;
|
|
|
|
world.BindValidationShowcase(world.EnterTowerShowcase, world.ExitTowerShowcase);
|
|
Assert.That(world.EnterValidationShowcase(), Is.True);
|
|
Assert.That(world.IsTowerShowcaseSimulationActive, Is.True);
|
|
Assert.That(world.TowerManager, Is.Not.SameAs(runManager));
|
|
Assert.That(runManager.IsSuspended, Is.True);
|
|
Assert.That(runDirect.GetComponentInChildren<SpriteRenderer>(true).enabled, Is.False,
|
|
"The parked run tower must not render over the showcase tower on the shared pad.");
|
|
Assert.That(runSummon.gameObject.activeSelf, Is.False,
|
|
"Parked summon colliders and movement must not participate in the showcase.");
|
|
Assert.That(runProjectile.gameObject.activeInHierarchy, Is.False,
|
|
"A parked flight and its sprite sequence must not advance or expire during showcase time.");
|
|
Assert.That(runEnemy.gameObject.activeSelf, Is.False,
|
|
"Normal route enemies must be suspended while the isolated showcase runs.");
|
|
Assert.That(world.TowerShowcaseDummies.Count, Is.EqualTo(kinds.Length));
|
|
Assert.That(world.TowerManager.Towers.Count, Is.EqualTo(kinds.Length));
|
|
for (int i = 0; i < kinds.Length; i++)
|
|
{
|
|
Assert.That(world.TowerManager.GetTowerAtPad(i).Kind, Is.EqualTo(kinds[i]));
|
|
Assert.That(world.TowerManager.GetTowerAtPad(i).Tier, Is.EqualTo(1));
|
|
Defense25DEnemy dummy = world.GetTowerShowcaseDummy(kinds[i]);
|
|
Assert.That(dummy, Is.Not.Null);
|
|
Assert.That(dummy.IsShowcaseDummy, Is.True);
|
|
Assert.That(dummy.IsAvailable, Is.True);
|
|
Assert.That(dummy.Actor.Visual.Renderer.sprite.name, Does.StartWith("Skeleton_Idle"));
|
|
Assert.That(world.IsTowerShowcaseTarget(kinds[i], dummy), Is.True);
|
|
Assert.That(world.IsTowerShowcaseTarget(kinds[(i + 1) % kinds.Length], dummy), Is.False);
|
|
Assert.That(world.GetTowerTargets(kinds[i]).Where(target => target.IsShowcaseDummy),
|
|
Is.EquivalentTo(new[] { dummy }), "Each family may query only its assigned showcase target.");
|
|
}
|
|
|
|
var dummyPositions = kinds.ToDictionary(kind => kind,
|
|
kind => world.GetTowerShowcaseDummy(kind).Actor.Position);
|
|
|
|
float evidenceDeadline = Time.realtimeSinceStartup + 20f;
|
|
while (Time.realtimeSinceStartup < evidenceDeadline
|
|
&& kinds.Where(kind => kind != DefenseTowerKind.Empowering)
|
|
.Any(kind => world.GetTowerShowcaseHitCount(kind) == 0))
|
|
yield return new WaitForFixedUpdate();
|
|
|
|
for (int i = 0; i < 150; i++) yield return new WaitForFixedUpdate();
|
|
|
|
Camera camera = world.View.Camera;
|
|
camera.Render();
|
|
Color32[] rendered = ReadPixels(world.View.Texture);
|
|
foreach (DefenseTowerKind kind in kinds)
|
|
{
|
|
Defense25DTower tower = world.TowerManager.TowersInConstructionOrder
|
|
.First(item => item.Kind == kind);
|
|
SpriteRenderer towerBody = tower.transform.Find("Tower billboard").GetComponent<SpriteRenderer>();
|
|
Assert.That(towerBody.sprite, Is.Not.Null, $"{kind} must render its authored tower body sprite.");
|
|
Assert.That(RendererContribution(camera, rendered, towerBody), Is.GreaterThan(0),
|
|
$"{kind} tower body must contribute pixels through the saved scene's renderer.");
|
|
|
|
Defense25DEnemy dummy = world.GetTowerShowcaseDummy(kind);
|
|
Assert.That(RendererContribution(camera, rendered, dummy.Actor.Visual.Renderer), Is.GreaterThan(0),
|
|
$"The {kind} assigned Skeleton must contribute full-body sprite pixels.");
|
|
}
|
|
Assert.That(RendererContribution(camera, rendered, world.Player.Visual.Renderer), Is.GreaterThan(0),
|
|
"The saved-scene player sprite must remain visible in the showcase capture.");
|
|
|
|
CaptureTowerShowcase();
|
|
|
|
foreach (DefenseTowerKind kind in kinds.Where(kind => kind != DefenseTowerKind.Empowering))
|
|
{
|
|
Defense25DTower tower = world.TowerManager.TowersInConstructionOrder
|
|
.First(item => item.Kind == kind);
|
|
string summonState = kind == DefenseTowerKind.Summon
|
|
? string.Join("; ", world.Summons.Where(item => item.Owner == tower).Select(item =>
|
|
$"slot={item.SlotIndex} pos={item.Actor.Position} alive={item.Actor.IsAlive} " +
|
|
$"attack={item.IsAttacking} attacks={item.AttackEvents} moving={item.Actor.LastMove} " +
|
|
$"knockback={item.Actor.HasKnockback} elapsed={GetPrivateFloat(item, "attackElapsed"):0.000} " +
|
|
$"next={GetPrivateFloat(item, "nextAttack"):0.000} targetGap=" +
|
|
(tower.CommandTarget == null ? "none" : item.Actor.GapTo(tower.CommandTarget.Actor).ToString("0.000"))))
|
|
: string.Empty;
|
|
Assert.That(world.GetTowerShowcaseHitCount(kind), Is.GreaterThan(0),
|
|
$"No actual attack evidence for {kind}. command={tower.CommandTarget?.name ?? "none"}; " +
|
|
$"targets={world.GetTowerTargets(kind).Count()} gameTime={world.GameTime:0.000} " +
|
|
$"running={world.IsRunning} paused={world.Paused} operational={world.TowersOperational} " +
|
|
$"towerEnabled={tower.isActiveAndEnabled} summonCount={tower.SummonCount}/{tower.TargetSummonCount} {summonState}");
|
|
}
|
|
Assert.That(world.GetTowerShowcaseSupportBonus(DefenseTowerKind.Direct), Is.GreaterThan(0f),
|
|
"The pad-4 Empowering tower must support the pad-0 Direct tower within the authored radius.");
|
|
Assert.That(world.GetTowerShowcaseSupportBonus(DefenseTowerKind.Summon), Is.EqualTo(0f),
|
|
"The remote pad-1 Summon tower is outside the authored support radius.");
|
|
foreach (DefenseTowerKind kind in kinds)
|
|
Assert.That(world.GetTowerShowcaseDummy(kind).Actor.Position,
|
|
Is.EqualTo(dummyPositions[kind]), $"The {kind} Skeleton target must remain stationary.");
|
|
|
|
Defense25DEnemy respawnTarget = world.GetTowerShowcaseDummy(DefenseTowerKind.Direct);
|
|
float aliveDeadline = Time.realtimeSinceStartup + 8f;
|
|
while (!respawnTarget.IsAvailable && Time.realtimeSinceStartup < aliveDeadline)
|
|
yield return new WaitForFixedUpdate();
|
|
Assert.That(respawnTarget.IsAvailable, Is.True);
|
|
Defense25DTower directTower = world.TowerManager.GetTowerAtPad(0);
|
|
MethodInfo applyDamage = typeof(Defense25DTower).GetMethod("ApplyDamage",
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.That(applyDamage, Is.Not.Null);
|
|
int popupCount = world.ActiveTowerDamagePopupCount;
|
|
Assert.That(applyDamage.Invoke(directTower,
|
|
new object[] { respawnTarget, respawnTarget.Actor.MaxHealth + 1f }), Is.EqualTo(true));
|
|
Assert.That(respawnTarget.Actor.CurrentHealth, Is.EqualTo(respawnTarget.Actor.MaxHealth),
|
|
"A showcase dummy respawns synchronously at full health after a lethal tower hit.");
|
|
Assert.That(world.ActiveTowerDamagePopupCount, Is.GreaterThan(popupCount),
|
|
"The lethal hit's actual damage popup must be recorded before immediate dummy respawn.");
|
|
Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(supplied));
|
|
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved));
|
|
Assert.That(world.KillCurrency, Is.EqualTo(kills));
|
|
Assert.That(world.TowerManager.ConstructionCurrency,
|
|
Is.EqualTo(GameplayConstants.Current.Defense.InitialConstructionCurrency));
|
|
|
|
Assert.That(respawnTarget.IsAvailable, Is.True,
|
|
"A dead tower-test dummy must be replaced immediately, not after the summon-unit timer.");
|
|
Assert.That(respawnTarget.Actor.Position,
|
|
Is.EqualTo(DefenseTowerHitTestLayout.GetDummyPosition(DefenseTowerKind.Direct)));
|
|
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved));
|
|
Assert.That(world.KillCurrency, Is.EqualTo(kills));
|
|
|
|
Assert.That(world.ExitValidationShowcase(), Is.True);
|
|
Assert.That(world.IsTowerShowcaseSimulationActive, Is.False);
|
|
Assert.That(world.TowerManager, Is.SameAs(runManager));
|
|
Assert.That(runManager.IsSuspended, Is.False);
|
|
Assert.That(runDirect.GetComponentInChildren<SpriteRenderer>(true).enabled, Is.True);
|
|
Assert.That(runSummon.gameObject.activeSelf, Is.True);
|
|
Assert.That(runSummon.Actor.BodyCollider.enabled, Is.True);
|
|
Assert.That(runSummon.Actor.Visual.Renderer.enabled, Is.True);
|
|
Assert.That(runProjectile.gameObject.activeInHierarchy, Is.True);
|
|
Assert.That(projectileSequence.IsFinished, Is.False,
|
|
"The existing projectile must resume with its remaining flight after showcase exit.");
|
|
Assert.That(runEnemy.gameObject.activeSelf, Is.True,
|
|
"The previously active route Skeleton must resume after leaving the showcase.");
|
|
Assert.That(runEnemy.Actor.BodyCollider.enabled, Is.True,
|
|
"The resumed route Skeleton restores its collision footprint.");
|
|
Assert.That(runEnemy.Actor.Visual.Renderer.enabled, Is.True,
|
|
"The resumed route Skeleton restores its billboard.");
|
|
Assert.That(runManager.ConstructionCurrency, Is.EqualTo(runCurrency));
|
|
Assert.That(world.Player.Position, Is.EqualTo(playerPosition));
|
|
Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(supplied));
|
|
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolved));
|
|
Assert.That(world.KillCurrency, Is.EqualTo(kills));
|
|
Assert.That(world.BaseHealth, Is.EqualTo(baseHealth));
|
|
Assert.That(world.GameTime, Is.EqualTo(gameTime).Within(.001f));
|
|
Assert.That(world.IsRunning, Is.True);
|
|
}
|
|
|
|
private static float GetPrivateFloat(Defense25DSummon summon, string field) =>
|
|
(float)typeof(Defense25DSummon).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)
|
|
.GetValue(summon);
|
|
|
|
private static int RendererContribution(Camera camera, Color32[] visible, Renderer renderer)
|
|
{
|
|
Vector3 screen = camera.WorldToScreenPoint(renderer.bounds.center);
|
|
Assert.That(screen.z, Is.GreaterThan(camera.nearClipPlane));
|
|
bool wasEnabled = renderer.enabled;
|
|
try
|
|
{
|
|
renderer.enabled = false;
|
|
camera.Render();
|
|
Color32[] hidden = ReadPixels(camera.targetTexture as RenderTexture);
|
|
return DifferentPixelsNear(visible, hidden, screen, 28);
|
|
}
|
|
finally
|
|
{
|
|
renderer.enabled = wasEnabled;
|
|
camera.Render();
|
|
}
|
|
}
|
|
|
|
private static int DifferentPixelsNear(Color32[] a, Color32[] b, Vector3 center, int radius)
|
|
{
|
|
int count = 0, cx = Mathf.RoundToInt(center.x), cy = Mathf.RoundToInt(center.y);
|
|
for (int y = -radius; y <= radius; y++)
|
|
for (int x = -radius; x <= radius; x++)
|
|
{
|
|
int px = cx + x, py = cy + y;
|
|
if (px < 0 || px >= Defense25DView.Width || py < 0 || py >= Defense25DView.Height) continue;
|
|
if (!a[py * Defense25DView.Width + px].Equals(b[py * Defense25DView.Width + px])) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private static Color32[] ReadPixels(RenderTexture texture)
|
|
{
|
|
RenderTexture previous = RenderTexture.active;
|
|
RenderTexture.active = texture;
|
|
Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false);
|
|
image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0);
|
|
image.Apply();
|
|
Color32[] pixels = image.GetPixels32();
|
|
RenderTexture.active = previous;
|
|
Object.DestroyImmediate(image);
|
|
return pixels;
|
|
}
|
|
|
|
private static void CompleteStartupArtifactSelection(Defense25DComparison comparison)
|
|
{
|
|
var choose = typeof(Defense25DComparison).GetMethod("ChooseStartupArtifact",
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.That(choose, Is.Not.Null);
|
|
for (int i = 0; comparison.Paused && i < 3; i++)
|
|
Assert.That(choose.Invoke(comparison, new object[] { 0 }), Is.EqualTo(true));
|
|
Assert.That(comparison.Paused, Is.False,
|
|
"The showcase fixture must complete the authored startup artifact choices before entering the run.");
|
|
}
|
|
|
|
private void CaptureTowerShowcase()
|
|
{
|
|
string directory = System.Environment.GetEnvironmentVariable("DEFENSE25D_OUTPUT")
|
|
?? Path.Combine(Application.temporaryCachePath, "Defense25DComparison");
|
|
Directory.CreateDirectory(directory);
|
|
world.View.Camera.Render();
|
|
RenderTexture previous = RenderTexture.active;
|
|
RenderTexture.active = world.View.Texture;
|
|
Texture2D image = new(Defense25DView.Width, Defense25DView.Height, TextureFormat.RGB24, false);
|
|
image.ReadPixels(new Rect(0, 0, Defense25DView.Width, Defense25DView.Height), 0, 0);
|
|
image.Apply();
|
|
RenderTexture.active = previous;
|
|
File.WriteAllBytes(Path.Combine(directory, "20-tower-showcase-six-families-640x360.png"),
|
|
image.EncodeToPNG());
|
|
Object.Destroy(image);
|
|
}
|
|
}
|
|
}
|