Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/Defense25DComparisonShowcasePlayModeTests.cs
T
2026-09-27 21:08:05 +09:00

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);
}
}
}