991 lines
54 KiB
C#
991 lines
54 KiB
C#
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using BumpCombat.Constants;
|
|
using BumpCombat.Core;
|
|
using BumpCombat.Defense;
|
|
using BumpCombat.Enemies;
|
|
using BumpCombat.Progression;
|
|
using BumpCombat.Player;
|
|
using BumpCombat.UI;
|
|
using NUnit.Framework;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
using UnityEngine.TestTools;
|
|
|
|
namespace BumpCombat.PlayModeTests
|
|
{
|
|
public sealed class DefenseTowerVisualPlayModeTests
|
|
{
|
|
private const string SceneName = "CombatPrototype";
|
|
private const string StartupSelectionPanelName = "Artifact Reward Selection";
|
|
private const float ReadyTimeoutSeconds = 8f;
|
|
private float originalTimeScale;
|
|
private Camera captureCamera;
|
|
private ArenaCameraFollow captureCameraFollow;
|
|
private Vector3 originalCameraPosition;
|
|
private float originalCameraOrthographicSize;
|
|
private bool originalCameraFollowEnabled;
|
|
private bool cameraStateSaved;
|
|
#if UNITY_EDITOR
|
|
private bool originalGrantCatalogForTests;
|
|
private bool originalForceProductionModeForTests;
|
|
#endif
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
originalTimeScale = Time.timeScale;
|
|
Time.timeScale = 1f;
|
|
DefensePrototypeController.CancelTowerHitTestRun();
|
|
#if UNITY_EDITOR
|
|
originalGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests;
|
|
originalForceProductionModeForTests = RunManager.ForceProductionModeForTests;
|
|
ArtifactRewardController.GrantCatalogForTests = false;
|
|
RunManager.ForceProductionModeForTests = false;
|
|
#endif
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
DefensePrototypeController.CancelTowerHitTestRun();
|
|
RunManager runManager = RunManager.Instance;
|
|
if (runManager != null) runManager.ClosePause();
|
|
GameObject selectionPanel = GameObject.Find(StartupSelectionPanelName);
|
|
if (selectionPanel != null) selectionPanel.SetActive(false);
|
|
#if UNITY_EDITOR
|
|
ArtifactRewardController.GrantCatalogForTests = originalGrantCatalogForTests;
|
|
RunManager.ForceProductionModeForTests = originalForceProductionModeForTests;
|
|
#endif
|
|
if (cameraStateSaved && captureCamera != null)
|
|
{
|
|
captureCamera.transform.position = originalCameraPosition;
|
|
captureCamera.orthographicSize = originalCameraOrthographicSize;
|
|
if (captureCameraFollow != null)
|
|
captureCameraFollow.enabled = originalCameraFollowEnabled;
|
|
}
|
|
Time.timeScale = originalTimeScale;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator ColdTower_TiersFireSequentialSingleProjectilesWithPerShotImpacts()
|
|
{
|
|
yield return StartTowerHitTestRun();
|
|
DefensePrototypeController controller = DefensePrototypeController.Instance;
|
|
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
|
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
|
|
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
|
|
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
|
|
if (tower != cold) tower.enabled = false;
|
|
Assert.That(target.Enemy.TryReposition(cold.transform.position), Is.True);
|
|
Physics2D.SyncTransforms();
|
|
Sprite[] volleyFrames = DefenseTowerArt.Load("Effects/Ice/T1-Volley");
|
|
Sprite[] freezeboltFrames = DefenseTowerArt.Load("Effects/Ice/Freezebolt");
|
|
Assert.That(controller.Tuning.ColdProjectileBurstInterval, Is.EqualTo(0.12f));
|
|
|
|
yield return AssertColdVolley(cold, target, volleyFrames, 1,
|
|
controller.Tuning.ColdProjectileBurstInterval, faceLeft: true);
|
|
for (int tier = 2; tier <= 3; tier++)
|
|
{
|
|
Assert.That(player.TryReposition(controller.Tuning.GetBuildPadPosition(cold.PadIndex)), Is.True);
|
|
Assert.That(controller.TryUpgradeTowerAtPad(cold.PadIndex, DefenseTowerBranch.None), Is.True);
|
|
yield return AssertColdVolley(cold, target, volleyFrames, tier,
|
|
controller.Tuning.ColdProjectileBurstInterval, faceLeft: tier == 2);
|
|
}
|
|
Assert.That(player.TryReposition(controller.Tuning.GetBuildPadPosition(cold.PadIndex)), Is.True);
|
|
Assert.That(controller.TryUpgradeTowerAtPad(cold.PadIndex, DefenseTowerBranch.First), Is.True);
|
|
Assert.That(controller.Tuning.FreezeboltProjectileCount, Is.EqualTo(5));
|
|
yield return AssertColdVolley(cold, target, freezeboltFrames,
|
|
5, controller.Tuning.ColdProjectileBurstInterval, faceLeft: true);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator FireAndLightningProjectiles_StartAtStaffTipWhenFacingEitherDirection()
|
|
{
|
|
yield return StartTowerHitTestRun();
|
|
DefenseTower fire = FindTower(DefenseTowerKind.Direct);
|
|
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
|
|
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
|
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
|
|
if (tower != fire && tower != lightning) tower.enabled = false;
|
|
SetField(fire, "attackCooldown", 60f);
|
|
SetField(lightning, "attackCooldown", 60f);
|
|
|
|
yield return AssertStaffTipOriginsBothSides(fire, FindDummy(fire.Kind));
|
|
yield return AssertStaffTipOriginsBothSides(lightning, FindDummy(lightning.Kind));
|
|
|
|
Assert.That(player.TryReposition(
|
|
DefensePrototypeController.Instance.Tuning.GetBuildPadPosition(lightning.PadIndex)),
|
|
Is.True);
|
|
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
|
|
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
|
|
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
|
|
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
|
|
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
|
|
lightning.PadIndex, DefenseTowerBranch.First), Is.True);
|
|
yield return AssertStaffTipOriginsBothSides(lightning, FindDummy(lightning.Kind));
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator Blizzard_AutomaticallyHitsTheUnmovedColdDummy()
|
|
{
|
|
yield return StartTowerHitTestRun();
|
|
DefensePrototypeController controller = DefensePrototypeController.Instance;
|
|
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
|
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
|
|
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(
|
|
FindObjectsSortMode.None))
|
|
tower.enabled = false;
|
|
SetField(cold, "attackCooldown", 60f);
|
|
yield return new WaitForSeconds(1f);
|
|
UpgradeToSecondBranch(controller, player, cold);
|
|
SetField(cold, "attackCooldown", -0.1f);
|
|
cold.enabled = true;
|
|
|
|
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
|
|
FieldInfo healthField = typeof(EnemyController).GetField(
|
|
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
healthField.SetValue(target.Enemy, 10000f);
|
|
Vector2 originalRoot = target.transform.position;
|
|
Vector2 originalShadow = DefenseTowerArtBounds.EnemyShadowWorldPosition(
|
|
target.Enemy, originalRoot);
|
|
List<(float damage, EnemyDamageSource source)> damageEvents = new();
|
|
System.Action<float, float> observer = (_, damage) =>
|
|
damageEvents.Add((damage, target.Enemy.LastDamageSource));
|
|
target.Enemy.OnDamageApplied += observer;
|
|
|
|
yield return new WaitForSeconds(0.9f);
|
|
|
|
float nearestEllipseDistance = float.PositiveInfinity;
|
|
for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++)
|
|
{
|
|
Vector2 landing = originalRoot
|
|
+ DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f);
|
|
Vector2 delta = originalShadow - landing;
|
|
float ellipseDistance = delta.x * delta.x
|
|
/ (DefenseTowerArtBounds.BlizzardWaveRadiusX
|
|
* DefenseTowerArtBounds.BlizzardWaveRadiusX)
|
|
+ delta.y * delta.y
|
|
/ (DefenseTowerArtBounds.BlizzardWaveRadiusY
|
|
* DefenseTowerArtBounds.BlizzardWaveRadiusY);
|
|
nearestEllipseDistance = Mathf.Min(nearestEllipseDistance, ellipseDistance);
|
|
}
|
|
|
|
Assert.That(damageEvents.Count, Is.GreaterThan(0),
|
|
$"Automatic Blizzard should hit its selected unmoved target. root={originalRoot}, "
|
|
+ $"shadow={originalShadow}, castCenter={originalRoot}, "
|
|
+ $"nearestEllipseDistance={nearestEllipseDistance:F4}.");
|
|
Assert.That(damageEvents[0].source, Is.EqualTo(EnemyDamageSource.Tower));
|
|
Assert.That(Vector2.Distance(target.transform.position, originalRoot),
|
|
Is.LessThan(0.001f),
|
|
"The dummy remains at its original test-scene position.");
|
|
target.Enemy.OnDamageApplied -= observer;
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator Blizzard_DamagesEachLandingEllipseAndHitsOverlapsSeparately()
|
|
{
|
|
yield return StartTowerHitTestRun();
|
|
DefensePrototypeController controller = DefensePrototypeController.Instance;
|
|
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
|
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
|
|
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(
|
|
FindObjectsSortMode.None))
|
|
tower.enabled = false;
|
|
SetField(cold, "attackCooldown", 60f);
|
|
yield return new WaitForSeconds(1f);
|
|
UpgradeToSecondBranch(controller, player, cold);
|
|
SetField(cold, "attackCooldown", 60f);
|
|
cold.enabled = true;
|
|
|
|
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
|
|
FieldInfo healthField = typeof(EnemyController).GetField(
|
|
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
healthField.SetValue(target.Enemy, 10000f);
|
|
Vector2 originalRoot = target.transform.position;
|
|
SpriteRenderer targetRenderer = target.Enemy.GetComponent<SpriteRenderer>();
|
|
targetRenderer.flipX = false;
|
|
Vector2 shadowOffset = DefenseTowerArtBounds.EnemyShadowWorldPosition(
|
|
target.Enemy, originalRoot) - originalRoot;
|
|
List<(float time, float damage, EnemyDamageSource source)> damageEvents = new();
|
|
System.Action<float, float> damageObserver = (_, appliedDamage) =>
|
|
damageEvents.Add((Time.time, appliedDamage, target.Enemy.LastDamageSource));
|
|
target.Enemy.OnDamageApplied += damageObserver;
|
|
|
|
float expectedDamage = Mathf.Floor(controller.Tuning.ColdDamage
|
|
* (1f + controller.Tuning.TowerDamageBonusPerTier * (cold.Tier - 1)) * 0.5f);
|
|
Vector2 singleAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
|
|
target.Enemy, target.transform.position);
|
|
Vector2 singleWaveGround = singleAttackCenter
|
|
+ DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f);
|
|
float singleStartHealth = target.Enemy.CurrentHealth;
|
|
float singleStartedAt = Time.time;
|
|
Assert.That(cold.TryPulse(), Is.True);
|
|
Vector2 singleRoot = singleWaveGround - shadowOffset;
|
|
Assert.That(target.Enemy.TryReposition(singleRoot), Is.True);
|
|
SetField(target, "stationaryTowerTestDummyAnchor", singleRoot);
|
|
Physics2D.SyncTransforms();
|
|
float singleHitDeadline = Time.realtimeSinceStartup + 0.8f;
|
|
while (damageEvents.Count == 0 && Time.realtimeSinceStartup < singleHitDeadline)
|
|
yield return null;
|
|
Assert.That(damageEvents.Count, Is.EqualTo(1),
|
|
"A target at one landing center is hit once by that wave only.");
|
|
Assert.That(damageEvents[0].source, Is.EqualTo(EnemyDamageSource.Tower));
|
|
Assert.That(damageEvents[0].damage, Is.EqualTo(expectedDamage).Within(0.001f));
|
|
Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f));
|
|
Assert.That(target.Enemy.IsStunned, Is.True);
|
|
float singleRemaining = DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f
|
|
- (Time.time - singleStartedAt);
|
|
if (singleRemaining > 0f) yield return new WaitForSeconds(singleRemaining);
|
|
Assert.That(damageEvents.Count, Is.EqualTo(1),
|
|
"The wave never repeats its hit while its expansion animation plays.");
|
|
Assert.That(singleStartHealth - target.Enemy.CurrentHealth,
|
|
Is.EqualTo(expectedDamage).Within(0.001f));
|
|
|
|
damageEvents.Clear();
|
|
healthField.SetValue(target.Enemy, 10000f);
|
|
SetField(cold, "attackCooldown", 60f);
|
|
Vector2 overlapAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
|
|
target.Enemy, target.transform.position);
|
|
Vector2 first = DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f);
|
|
Vector2 second = DefenseTowerArtBounds.BlizzardLandingOffset(1, 1.65f);
|
|
Vector2 overlapGround = overlapAttackCenter + (first + second) * 0.5f;
|
|
float overlapStartedAt = Time.time;
|
|
Assert.That(cold.TryPulse(), Is.True);
|
|
Vector2 overlapRoot = overlapGround - shadowOffset;
|
|
Assert.That(target.Enemy.TryReposition(overlapRoot), Is.True);
|
|
SetField(target, "stationaryTowerTestDummyAnchor", overlapRoot);
|
|
Physics2D.SyncTransforms();
|
|
float overlapDeadline = Time.realtimeSinceStartup + 0.8f;
|
|
while (damageEvents.Count < 2 && Time.realtimeSinceStartup < overlapDeadline)
|
|
yield return null;
|
|
Assert.That(damageEvents.Count, Is.EqualTo(2),
|
|
"Only the two overlapping landing ellipses hit this target.");
|
|
for (int i = 0; i < damageEvents.Count; i++)
|
|
{
|
|
Assert.That(damageEvents[i].source, Is.EqualTo(EnemyDamageSource.Tower));
|
|
Assert.That(damageEvents[i].damage, Is.EqualTo(expectedDamage).Within(0.001f),
|
|
$"Wave event {i} applies the configured per-wave damage.");
|
|
}
|
|
Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f));
|
|
Assert.That(target.Enemy.IsStunned, Is.True);
|
|
float overlapRemaining = DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f
|
|
- (Time.time - overlapStartedAt);
|
|
if (overlapRemaining > 0f) yield return new WaitForSeconds(overlapRemaining);
|
|
Assert.That(damageEvents.Count, Is.EqualTo(2),
|
|
"Neither overlapping wave repeats during its animated lifetime.");
|
|
Assert.That(10000f - target.Enemy.CurrentHealth,
|
|
Is.EqualTo(expectedDamage * 2f).Within(0.001f));
|
|
|
|
damageEvents.Clear();
|
|
healthField.SetValue(target.Enemy, 10000f);
|
|
SetField(cold, "attackCooldown", 60f);
|
|
Vector2 outerAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
|
|
target.Enemy, target.transform.position);
|
|
Assert.That(cold.TryPulse(), Is.True);
|
|
Vector2 outerRoot = outerAttackCenter + new Vector2(1.5f, 0f) - shadowOffset;
|
|
Assert.That(target.Enemy.TryReposition(outerRoot), Is.True);
|
|
SetField(target, "stationaryTowerTestDummyAnchor", outerRoot);
|
|
Physics2D.SyncTransforms();
|
|
yield return new WaitForSeconds(DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f);
|
|
Assert.That(damageEvents.Count, Is.Zero,
|
|
"A target outside every wave ellipse but inside the cast radius is not hit.");
|
|
Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(10000f).Within(0.001f));
|
|
target.Enemy.OnDamageApplied -= damageObserver;
|
|
}
|
|
|
|
private static IEnumerator AssertStaffTipOriginsBothSides(
|
|
DefenseTower tower, DefenseRouteAgent target)
|
|
{
|
|
FieldInfo healthField = typeof(EnemyController).GetField(
|
|
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
foreach (bool faceLeft in new[] { true, false })
|
|
{
|
|
healthField.SetValue(target.Enemy, 10000f);
|
|
Vector2 targetPosition = (Vector2)tower.transform.position
|
|
+ (faceLeft ? Vector2.left : Vector2.right) * 1.25f;
|
|
Assert.That(target.Enemy.TryReposition(targetPosition), Is.True);
|
|
Physics2D.SyncTransforms();
|
|
SetField(tower, "attackCooldown", 60f);
|
|
string effectName = tower.Kind == DefenseTowerKind.Direct
|
|
? "Direct Attack Effect" : "Lightning Attack Effect";
|
|
HashSet<int> previousEffectIds = new();
|
|
foreach (Transform existing in Object.FindObjectsByType<Transform>(
|
|
FindObjectsInactive.Include, FindObjectsSortMode.None))
|
|
if (existing.name == effectName)
|
|
previousEffectIds.Add(existing.gameObject.GetInstanceID());
|
|
|
|
Assert.That(tower.TryPulse(), Is.True);
|
|
GameObject effect = null;
|
|
foreach (Transform candidate in Object.FindObjectsByType<Transform>(
|
|
FindObjectsInactive.Include, FindObjectsSortMode.None))
|
|
{
|
|
if (candidate.name == effectName
|
|
&& !previousEffectIds.Contains(candidate.gameObject.GetInstanceID()))
|
|
{
|
|
effect = candidate.gameObject;
|
|
break;
|
|
}
|
|
}
|
|
Assert.That(effect, Is.Not.Null);
|
|
Transform shot = effect.transform.Find("Shot Sprite");
|
|
Assert.That(shot, Is.Not.Null);
|
|
SpriteRenderer renderer = shot.GetComponent<SpriteRenderer>();
|
|
Vector2 expectedStart = GetStaffTipWorldPosition(tower);
|
|
if (tower.Kind == DefenseTowerKind.Direct)
|
|
{
|
|
Vector2 travelStart = (Vector2)GetSequenceField(
|
|
shot.GetComponent<DefenseSpriteSequence>(), "travelStart");
|
|
Assert.That(Vector2.Distance(travelStart, expectedStart),
|
|
Is.LessThan(0.0001f),
|
|
$"Fire T{tower.Tier}, flipX={faceLeft}, travel start must match the attack-frame staff tip.");
|
|
Assert.That(Vector2.Distance(shot.position, expectedStart),
|
|
Is.LessThan(0.0001f), "Fireball renderer starts at the staff tip.");
|
|
}
|
|
else
|
|
{
|
|
string key = tower.Tier == 4
|
|
? "Effects/Lightning/Arc" : "Effects/Lightning/T1-Bolt";
|
|
int frame = FindFrameIndex(DefenseTowerArt.Load(key), renderer.sprite);
|
|
Vector2Int bounds = DefenseTowerArtBounds.LightningBeamXBounds(frame);
|
|
Vector3 localStart = new(
|
|
(bounds.x - renderer.sprite.pivot.x) / renderer.sprite.pixelsPerUnit,
|
|
0f, 0f);
|
|
Assert.That(Vector2.Distance(
|
|
renderer.transform.TransformPoint(localStart), expectedStart),
|
|
Is.LessThan(0.0001f),
|
|
$"Lightning T{tower.Tier} renderer starts at the staff tip.");
|
|
}
|
|
|
|
yield return null;
|
|
}
|
|
}
|
|
|
|
private static IEnumerator AssertColdVolley(
|
|
DefenseTower tower, DefenseRouteAgent target, Sprite[] expectedFrames,
|
|
int expectedCount, float burstInterval, bool faceLeft = false)
|
|
{
|
|
FieldInfo healthField = typeof(EnemyController).GetField(
|
|
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.That(healthField, Is.Not.Null);
|
|
healthField.SetValue(target.Enemy, 10000f);
|
|
Vector2 targetPosition = (Vector2)tower.transform.position
|
|
+ (faceLeft ? Vector2.left : Vector2.right) * 1.25f;
|
|
Assert.That(target.Enemy.TryReposition(targetPosition), Is.True);
|
|
Physics2D.SyncTransforms();
|
|
float healthBefore = target.Enemy.CurrentHealth;
|
|
SetField(tower, "attackCooldown", 60f);
|
|
Assert.That(tower.TryPulse(), Is.True);
|
|
GameObject effect = GameObject.Find("Cold Attack Effect");
|
|
Assert.That(effect, Is.Not.Null);
|
|
SpriteRenderer[] renderers = effect.GetComponentsInChildren<SpriteRenderer>(true);
|
|
List<SpriteRenderer> shots = new();
|
|
foreach (SpriteRenderer renderer in renderers)
|
|
{
|
|
if (!renderer.name.StartsWith("Shot Sprite ")) continue;
|
|
shots.Add(renderer);
|
|
Assert.That(renderer.sprite, Is.SameAs(expectedFrames[0]),
|
|
"Every projectile must reuse one existing projectile sheet.");
|
|
}
|
|
Assert.That(shots.Count, Is.EqualTo(expectedCount));
|
|
AssertStaffTipStart(tower, shots[0]);
|
|
for (int i = 0; i < shots.Count; i++)
|
|
Assert.That(shots[i].enabled, Is.EqualTo(i == 0),
|
|
"Only the first projectile is visible at launch.");
|
|
|
|
HashSet<int> impactIds = new();
|
|
HashSet<int> hiddenShotsAtImpact = new();
|
|
Dictionary<int, float> launchTimes = new();
|
|
bool flightRendererHiddenAtImpact = false;
|
|
float previousHealth = healthBefore;
|
|
int damageEvents = 0;
|
|
float deadline = Time.realtimeSinceStartup + 1.5f;
|
|
while (Time.realtimeSinceStartup < deadline && effect != null)
|
|
{
|
|
float now = Time.realtimeSinceStartup;
|
|
for (int i = 0; i < shots.Count; i++)
|
|
if (shots[i] != null && shots[i].enabled && !launchTimes.ContainsKey(i))
|
|
{
|
|
launchTimes.Add(i, now);
|
|
AssertStaffTipStart(tower, shots[i]);
|
|
}
|
|
foreach (Transform child in effect.transform)
|
|
if (child.name == "Impact Sprite")
|
|
impactIds.Add(child.gameObject.GetInstanceID());
|
|
if (impactIds.Count > 0 && (shots[0] == null || !shots[0].enabled))
|
|
flightRendererHiddenAtImpact = true;
|
|
for (int i = 0; i < shots.Count; i++)
|
|
if (impactIds.Count > i && (shots[i] == null || !shots[i].enabled))
|
|
hiddenShotsAtImpact.Add(i);
|
|
float currentHealth = target.Enemy.CurrentHealth;
|
|
if (currentHealth < previousHealth)
|
|
{
|
|
damageEvents++;
|
|
previousHealth = currentHealth;
|
|
}
|
|
yield return null;
|
|
}
|
|
Assert.That(launchTimes.Count, Is.EqualTo(expectedCount),
|
|
"Each projectile renderer must become visible in sequence.");
|
|
for (int i = 1; i < expectedCount; i++)
|
|
Assert.That(launchTimes[i] - launchTimes[i - 1], Is.EqualTo(burstInterval).Within(0.035f));
|
|
Assert.That(impactIds.Count, Is.EqualTo(expectedCount),
|
|
"Each projectile must create a distinct impact before shared cleanup.");
|
|
Assert.That(flightRendererHiddenAtImpact, Is.True,
|
|
"The flight renderer must be hidden on arrival while its impact still animates.");
|
|
Assert.That(hiddenShotsAtImpact.Count, Is.EqualTo(expectedCount),
|
|
"Every flight renderer must be hidden on its own arrival.");
|
|
Assert.That(damageEvents, Is.EqualTo(expectedCount),
|
|
"Every projectile must apply its existing damage on arrival.");
|
|
Assert.That(target.Enemy.CurrentHealth, Is.LessThan(healthBefore));
|
|
}
|
|
|
|
private static void AssertStaffTipStart(DefenseTower tower, SpriteRenderer shot)
|
|
{
|
|
Vector2 travelStart = (Vector2)GetSequenceField(
|
|
shot.GetComponent<DefenseSpriteSequence>(), "travelStart");
|
|
Assert.That(IsStaffTipAnchor(tower, travelStart), Is.True,
|
|
"Each Cold projectile, including delayed shots, starts at a measured staff-tip frame anchor.");
|
|
}
|
|
|
|
private static bool IsStaffTipAnchor(DefenseTower tower, Vector2 position)
|
|
{
|
|
SpriteRenderer body = (SpriteRenderer)typeof(DefenseTower).GetField(
|
|
"bodyRenderer", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(tower);
|
|
Sprite sprite = body.sprite;
|
|
for (int frame = 0; frame < 6; frame++)
|
|
{
|
|
Vector2 tipPixels = DefenseTowerArtBounds.StaffTipPixels(true, frame);
|
|
Vector2 localPixels = tipPixels - sprite.pivot;
|
|
localPixels.y = sprite.rect.height - tipPixels.y - sprite.pivot.y;
|
|
if (body.flipX) localPixels.x = -localPixels.x;
|
|
Vector2 anchor = body.transform.TransformPoint(
|
|
localPixels / sprite.pixelsPerUnit);
|
|
if (Vector2.Distance(anchor, position) < 0.0001f) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static Vector2 GetStaffTipWorldPosition(DefenseTower tower) =>
|
|
(Vector2)typeof(DefenseTower).GetMethod("GetStaffTipWorldPosition",
|
|
BindingFlags.Instance | BindingFlags.NonPublic).Invoke(tower, null);
|
|
|
|
private static object GetSequenceField(DefenseSpriteSequence sequence, string name) =>
|
|
typeof(DefenseSpriteSequence).GetField(name,
|
|
BindingFlags.Instance | BindingFlags.NonPublic).GetValue(sequence);
|
|
[UnityTest]
|
|
public IEnumerator LightningTiersOneToThreeDoNotCreateImpactWaves()
|
|
{
|
|
yield return StartTowerHitTestRun();
|
|
DefensePrototypeController controller = DefensePrototypeController.Instance;
|
|
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
|
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
|
|
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Lightning);
|
|
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
|
|
if (tower != lightning) tower.enabled = false;
|
|
Assert.That(target.Enemy.TryReposition(lightning.transform.position), Is.True);
|
|
Physics2D.SyncTransforms();
|
|
|
|
for (int tier = 1; tier <= 3; tier++)
|
|
{
|
|
if (tier > 1)
|
|
{
|
|
Assert.That(player.TryReposition(
|
|
controller.Tuning.GetBuildPadPosition(lightning.PadIndex)), Is.True);
|
|
Assert.That(controller.TryUpgradeTowerAtPad(
|
|
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
|
|
}
|
|
|
|
SetField(lightning, "attackCooldown", 60f);
|
|
Assert.That(lightning.TryPulse(), Is.True, $"T{tier} lightning should attack.");
|
|
GameObject effect = GameObject.Find("Lightning Attack Effect");
|
|
Assert.That(effect, Is.Not.Null);
|
|
foreach (SpriteRenderer renderer in effect.GetComponentsInChildren<SpriteRenderer>(true))
|
|
Assert.That(renderer.name, Is.Not.EqualTo("Impact Sprite"),
|
|
$"T{tier} lightning must not create an impact wave.");
|
|
Object.DestroyImmediate(effect);
|
|
}
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator TowerVisuals_AlignHitsFitStormShrinkStunAndStaggerBlizzardLanding()
|
|
{
|
|
yield return StartTowerHitTestRun();
|
|
DefensePrototypeController controller = DefensePrototypeController.Instance;
|
|
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
|
|
Assert.That(controller, Is.Not.Null);
|
|
Assert.That(player, Is.Not.Null);
|
|
|
|
DefenseTower[] allTowers = Object.FindObjectsByType<DefenseTower>(
|
|
FindObjectsSortMode.None);
|
|
foreach (DefenseTower tower in allTowers) tower.enabled = false;
|
|
|
|
// Fire impact uses the enemy body position and target-relative sorting.
|
|
DefenseTower fire = FindTower(DefenseTowerKind.Direct);
|
|
DefenseRouteAgent fireTarget = FindDummy(DefenseTowerKind.Direct);
|
|
fire.enabled = true;
|
|
SetField(fire, "attackCooldown", 60f);
|
|
float fireHealth = fireTarget.Enemy.CurrentHealth;
|
|
Assert.That(fire.TryPulse(), Is.True);
|
|
float fireDeadline = Time.realtimeSinceStartup + 1f;
|
|
while (Time.realtimeSinceStartup < fireDeadline
|
|
&& fireTarget.Enemy.CurrentHealth >= fireHealth)
|
|
yield return null;
|
|
Assert.That(fireTarget.Enemy.CurrentHealth, Is.LessThan(fireHealth));
|
|
GameObject fireEffect = GameObject.Find("Direct Attack Effect");
|
|
Assert.That(fireEffect, Is.Not.Null);
|
|
Transform fireImpact = fireEffect.transform.Find("Impact Sprite");
|
|
Assert.That(fireImpact, Is.Not.Null);
|
|
SpriteRenderer fireRenderer = fireImpact.GetComponent<SpriteRenderer>();
|
|
SpriteRenderer fireTargetRenderer = fireTarget.Enemy.GetComponent<SpriteRenderer>();
|
|
Vector2 expectedFireCenter = fireTarget.Enemy.transform.position;
|
|
Assert.That(Vector2.Distance(fireImpact.position, expectedFireCenter),
|
|
Is.LessThan(0.01f));
|
|
AssertHitSorting(fireRenderer, fireTargetRenderer);
|
|
GameObject sharedFireImpact = GameObject.Find("Tower Impact Direct");
|
|
Assert.That(sharedFireImpact, Is.Not.Null);
|
|
AssertHitSorting(sharedFireImpact.GetComponent<SpriteRenderer>(), fireTargetRenderer);
|
|
CaptureRender("tower-visual-01-fire-impact.png");
|
|
|
|
// LightningStorm's measured ground center and furthest ring pixel fit radius 2.4.
|
|
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
|
|
UpgradeToSecondBranch(controller, player, lightning);
|
|
lightning.enabled = true;
|
|
SetField(lightning, "attackCooldown", 60f);
|
|
Assert.That(DefenseTower.GetBranchName(
|
|
DefenseTowerKind.Lightning, DefenseTowerBranch.Second),
|
|
Is.EqualTo("LIGHTNING STORM"));
|
|
Assert.That(lightning.TryPulse(), Is.True);
|
|
CaptureRender("tower-visual-02-lightning-storm.png");
|
|
GameObject lightningEffect = GameObject.Find("Lightning Attack Effect");
|
|
Assert.That(lightningEffect, Is.Not.Null);
|
|
Transform storm = lightningEffect.transform.Find("Shot Sprite");
|
|
Assert.That(storm, Is.Not.Null);
|
|
DefenseSpriteSequence stormSequence =
|
|
storm.GetComponent<DefenseSpriteSequence>();
|
|
SpriteRenderer stormRenderer = storm.GetComponent<SpriteRenderer>();
|
|
Vector2 stormCenter = FindDummy(DefenseTowerKind.Lightning).transform.position;
|
|
AssertHitSorting(stormRenderer,
|
|
FindDummy(DefenseTowerKind.Lightning).Enemy.GetComponent<SpriteRenderer>());
|
|
const string stormKey = "Effects/Lightning/LightningStorm";
|
|
Sprite[] stormSprites = DefenseTowerArt.Load(stormKey);
|
|
Assert.That(stormSprites, Has.Length.EqualTo(16));
|
|
GameObject boundsProbe = new("Lightning Storm Bounds Probe");
|
|
SpriteRenderer probeRenderer = boundsProbe.AddComponent<SpriteRenderer>();
|
|
DefenseSpriteSequence probeSequence =
|
|
boundsProbe.AddComponent<DefenseSpriteSequence>();
|
|
probeSequence.Play(probeRenderer, stormKey, stormSprites, false);
|
|
for (int i = 0; i < stormSprites.Length; i++)
|
|
{
|
|
Sprite frame = stormSprites[i];
|
|
probeRenderer.sprite = frame;
|
|
probeSequence.FitLightningStormGround(stormCenter, 2.4f);
|
|
Vector2 localCenter =
|
|
(DefenseTowerArtBounds.LightningStormGroundCenterPixels - frame.pivot)
|
|
/ frame.pixelsPerUnit;
|
|
Assert.That(Vector2.Distance(
|
|
probeRenderer.transform.TransformPoint(localCenter), stormCenter),
|
|
Is.LessThan(0.01f), $"Ground center frame {i}.");
|
|
float fittedRadius = probeRenderer.transform.lossyScale.x
|
|
* DefenseTowerArtBounds.LightningStormGroundRadiusPixels
|
|
/ frame.pixelsPerUnit;
|
|
Assert.That(fittedRadius, Is.LessThanOrEqualTo(2.4001f),
|
|
$"Measured ground footprint frame {i}.");
|
|
}
|
|
Object.Destroy(boundsProbe);
|
|
|
|
float ringFrameDeadline = Time.realtimeSinceStartup + 1f;
|
|
int visibleStormFrame = FindFrameIndex(stormSprites, stormRenderer.sprite);
|
|
while ((visibleStormFrame < 4 || visibleStormFrame > 10)
|
|
&& Time.realtimeSinceStartup < ringFrameDeadline)
|
|
{
|
|
yield return null;
|
|
visibleStormFrame = stormRenderer != null
|
|
? FindFrameIndex(stormSprites, stormRenderer.sprite) : -1;
|
|
}
|
|
Assert.That(visibleStormFrame, Is.InRange(4, 10),
|
|
"The second Storm capture must include visible ground-ring frames.");
|
|
CaptureRender("tower-visual-02b-lightning-groundring.png");
|
|
|
|
// Seismic shows a small shadow-centered marker while keeping tower range guidance.
|
|
DefenseTower seismic = FindTower(DefenseTowerKind.Seismic);
|
|
DefenseRouteAgent seismicTarget = FindDummy(DefenseTowerKind.Seismic);
|
|
seismic.enabled = true;
|
|
SetField(seismic, "attackCooldown", 60f);
|
|
Assert.That(seismic.TryPulse(), Is.True);
|
|
GameObject seismicEffect = GameObject.Find("Seismic Attack Effect");
|
|
Assert.That(seismicEffect, Is.Not.Null);
|
|
SpriteRenderer stunArt = seismicEffect.transform.Find("Impact Sprite")
|
|
.GetComponent<SpriteRenderer>();
|
|
SpriteRenderer seismicTargetRenderer = seismicTarget.Enemy.GetComponent<SpriteRenderer>();
|
|
const int seismicMaximumVisiblePixels = 32;
|
|
float expectedMarkerScale = DefenseTowerArtBounds.SeismicGroundMarkerWidthPixels
|
|
* Mathf.Abs(seismicTargetRenderer.transform.lossyScale.x)
|
|
* stunArt.sprite.pixelsPerUnit
|
|
/ (seismicTargetRenderer.sprite.pixelsPerUnit
|
|
* seismicMaximumVisiblePixels
|
|
* Mathf.Abs(seismicEffect.transform.lossyScale.x));
|
|
Assert.That(stunArt.transform.localScale.x,
|
|
Is.EqualTo(expectedMarkerScale).Within(0.01f),
|
|
"The constant effect scale must fit the measured maximum visible frame width.");
|
|
Assert.That((Vector2)stunArt.transform.position,
|
|
Is.EqualTo(DefenseTowerArtBounds.EnemyShadowWorldPosition(
|
|
seismicTarget.Enemy, seismicTarget.transform.position)));
|
|
Assert.That(stunArt.sortingLayerID, Is.EqualTo(seismicTargetRenderer.sortingLayerID));
|
|
Assert.That(stunArt.sortingOrder, Is.EqualTo(seismicTargetRenderer.sortingOrder - 1));
|
|
Assert.That(seismicEffect.transform.Find("Impact Radius"), Is.Null,
|
|
"The large transient Seismic radius ring is omitted from the ground marker.");
|
|
LineRenderer seismicRange = seismic.transform.Find("Effect Range")
|
|
.GetComponent<LineRenderer>();
|
|
Assert.That(seismicRange, Is.Not.Null,
|
|
"The tower's persistent attack-range display remains available.");
|
|
Assert.That(seismicRange.GetPosition(0).magnitude, Is.GreaterThan(0f));
|
|
CaptureRender("tower-visual-03-seismic-stun.png");
|
|
|
|
// Blizzard staggers shards and applies damage at each landing ellipse.
|
|
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
|
|
SetField(cold, "attackCooldown", 60f);
|
|
cold.enabled = false;
|
|
yield return new WaitForSeconds(1f);
|
|
UpgradeToSecondBranch(controller, player, cold);
|
|
cold.enabled = true;
|
|
SetField(cold, "attackCooldown", 60f);
|
|
DefenseRouteAgent coldTarget = FindDummy(DefenseTowerKind.Cold);
|
|
float coldHealth = coldTarget.Enemy.CurrentHealth;
|
|
float blizzardStartedAt = Time.time;
|
|
Assert.That(DefenseTower.GetBranchName(
|
|
DefenseTowerKind.Cold, DefenseTowerBranch.Second),
|
|
Is.EqualTo("BLIZZARD"));
|
|
Assert.That(cold.TryPulse(), Is.True);
|
|
GameObject coldEffect = GameObject.Find("Cold Attack Effect");
|
|
Assert.That(coldEffect, Is.Not.Null);
|
|
Vector2 coldShadowCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
|
|
coldTarget.Enemy, coldTarget.transform.position);
|
|
Assert.That(Vector2.Distance(coldEffect.transform.position,
|
|
coldShadowCenter), Is.LessThan(0.01f));
|
|
LineRenderer radiusLine = coldEffect.transform.Find("Impact Radius")
|
|
.GetComponent<LineRenderer>();
|
|
Assert.That(radiusLine, Is.Not.Null);
|
|
Assert.That(radiusLine.startColor.a,
|
|
Is.EqualTo(DefenseTowerArtBounds.BlizzardImpactRadiusAlpha).Within(0.001f),
|
|
"Only Blizzard softens the per-cast radius outline.");
|
|
Assert.That(cold.transform.Find("Effect Range").GetComponent<LineRenderer>()
|
|
.startColor.a, Is.EqualTo(DefenseTowerArtBounds.BlizzardRangeAlpha).Within(0.001f),
|
|
"Only Blizzard uses the quieter live range outline.");
|
|
float radius = 0f;
|
|
for (int i = 0; i < radiusLine.positionCount; i++)
|
|
radius = Mathf.Max(radius, radiusLine.GetPosition(i).magnitude);
|
|
Assert.That(radius, Is.EqualTo(1.65f).Within(0.01f));
|
|
Assert.That(coldTarget.Enemy.CurrentHealth, Is.EqualTo(coldHealth),
|
|
"Blizzard damage waits for the first shard landing.");
|
|
|
|
SpriteRenderer[] blizzardSprites =
|
|
coldEffect.GetComponentsInChildren<SpriteRenderer>(true);
|
|
SpriteRenderer[] shards = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
|
|
SpriteRenderer[] trails = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
|
|
SpriteRenderer[] impacts = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
|
|
SpriteRenderer[] waves = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
|
|
int shardCount = 0;
|
|
int trailCount = 0;
|
|
int impactCount = 0;
|
|
int waveCount = 0;
|
|
foreach (SpriteRenderer renderer in blizzardSprites)
|
|
{
|
|
if (renderer.name.StartsWith("Blizzard Shard "))
|
|
{
|
|
int index = renderer.name["Blizzard Shard ".Length] - '0';
|
|
shards[index] = renderer;
|
|
shardCount++;
|
|
Assert.That(renderer.transform.localScale.x,
|
|
Is.EqualTo(DefenseTowerArtBounds.BlizzardShardScale).Within(0.001f));
|
|
Assert.That(renderer.sprite,
|
|
Is.SameAs(DefenseTowerArt.Load("Effects/Ice/Shard-Blizzard-v1")[0]));
|
|
Assert.That(renderer.enabled, Is.EqualTo(index == 0),
|
|
"Delayed shards stay hidden until their staggered launch.");
|
|
AssertHitSorting(renderer,
|
|
coldTarget.Enemy.GetComponent<SpriteRenderer>());
|
|
}
|
|
else if (renderer.name.StartsWith("Blizzard Trail "))
|
|
{
|
|
int index = renderer.name["Blizzard Trail ".Length] - '0';
|
|
trails[index] = renderer;
|
|
trailCount++;
|
|
Assert.That(renderer.transform.localScale.x,
|
|
Is.EqualTo(DefenseTowerArtBounds.BlizzardTrailScale).Within(0.001f));
|
|
Assert.That(renderer.color.a,
|
|
Is.EqualTo(DefenseTowerArtBounds.BlizzardTrailAlpha).Within(0.001f));
|
|
Assert.That(renderer.enabled, Is.EqualTo(index == 0),
|
|
"The brief shard trail follows its staggered projectile.");
|
|
}
|
|
else if (renderer.name.StartsWith("Blizzard Impact "))
|
|
{
|
|
int index = renderer.name["Blizzard Impact ".Length] - '0';
|
|
impacts[index] = renderer;
|
|
impactCount++;
|
|
Assert.That(renderer.transform.localScale.x,
|
|
Is.EqualTo(DefenseTowerArtBounds.BlizzardImpactScale).Within(0.001f));
|
|
Assert.That(renderer.enabled, Is.False,
|
|
"Each impact stays hidden until its shard lands.");
|
|
AssertHitSorting(renderer,
|
|
coldTarget.Enemy.GetComponent<SpriteRenderer>());
|
|
}
|
|
else if (renderer.name.StartsWith("Blizzard Wave "))
|
|
{
|
|
int index = renderer.name["Blizzard Wave ".Length] - '0';
|
|
waves[index] = renderer;
|
|
waveCount++;
|
|
Assert.That(renderer.transform.localScale.x,
|
|
Is.EqualTo(DefenseTowerArtBounds.BlizzardWaveScale).Within(0.001f));
|
|
Assert.That(renderer.enabled, Is.False,
|
|
"Each ground wave waits for its matching shard landing.");
|
|
Vector2 expectedCenter = (Vector2)coldEffect.transform.position
|
|
+ DefenseTowerArtBounds.BlizzardLandingOffset(index, radius);
|
|
Vector2 localCenter = (DefenseTowerArtBounds.ImpactWaveCenterPixels(
|
|
0, renderer.sprite) - renderer.sprite.pivot)
|
|
/ renderer.sprite.pixelsPerUnit;
|
|
Assert.That(Vector2.Distance(
|
|
renderer.transform.TransformPoint(localCenter), expectedCenter),
|
|
Is.LessThan(0.01f), $"Wave {index} stays centered on its landing.");
|
|
float outerRadius = 0f;
|
|
foreach (Vector2 vertex in renderer.sprite.vertices)
|
|
{
|
|
outerRadius = Mathf.Max(outerRadius, Vector2.Distance(
|
|
renderer.transform.TransformPoint(vertex), expectedCenter));
|
|
}
|
|
Assert.That(Vector2.Distance(expectedCenter, coldEffect.transform.position)
|
|
+ outerRadius, Is.LessThanOrEqualTo(radius + 0.01f),
|
|
$"Wave {index} stays within the Blizzard attack radius.");
|
|
AssertHitSorting(renderer,
|
|
coldTarget.Enemy.GetComponent<SpriteRenderer>());
|
|
}
|
|
}
|
|
Assert.That(shardCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
|
|
Assert.That(trailCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
|
|
Assert.That(impactCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
|
|
Assert.That(waveCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
|
|
|
|
float firstLandingDeadline = Time.realtimeSinceStartup + 0.6f;
|
|
while (!impacts[0].enabled && Time.realtimeSinceStartup < firstLandingDeadline)
|
|
yield return null;
|
|
Assert.That(impacts[0].enabled, Is.True,
|
|
"The first impact appears when the first shard lands.");
|
|
Assert.That(waves[0].enabled, Is.True,
|
|
"The first ground wave appears when the first shard lands.");
|
|
Assert.That(Time.time - blizzardStartedAt,
|
|
Is.EqualTo(DefenseTowerArtBounds.BlizzardLandingSeconds).Within(0.05f));
|
|
|
|
yield return new WaitForSeconds(0.21f);
|
|
|
|
float landingDeadline = Time.realtimeSinceStartup + 0.25f;
|
|
while (Time.realtimeSinceStartup < landingDeadline
|
|
&& !impacts[DefenseTowerArtBounds.BlizzardShardCount - 1].enabled)
|
|
yield return null;
|
|
Assert.That(impacts[DefenseTowerArtBounds.BlizzardShardCount - 1].enabled, Is.True,
|
|
"The final wave lands at its staggered time.");
|
|
Assert.That(Time.time - blizzardStartedAt,
|
|
Is.EqualTo(DefenseTowerArtBounds.BlizzardLastLandingSeconds).Within(0.05f));
|
|
int lastImpact = DefenseTowerArtBounds.BlizzardShardCount - 1;
|
|
yield return null;
|
|
Assert.That(impacts[lastImpact].enabled, Is.True,
|
|
"The final impact appears on the frame after its shard lands.");
|
|
Assert.That(waves[lastImpact].enabled, Is.True,
|
|
"The final wave appears on the frame after its shard lands.");
|
|
float captureTimeScale = Time.timeScale;
|
|
Time.timeScale = 0f;
|
|
CaptureRender("tower-visual-04-blizzard-last-landing.png");
|
|
Time.timeScale = captureTimeScale;
|
|
yield return new WaitForSeconds(0.2f);
|
|
Assert.That(coldEffect, Is.Not.Null,
|
|
"The attack root remains alive while the final wave animation is playing.");
|
|
float cleanupDeadline = Time.realtimeSinceStartup + 0.6f;
|
|
while (coldEffect != null && Time.realtimeSinceStartup < cleanupDeadline)
|
|
yield return null;
|
|
Assert.That(coldEffect == null, Is.True,
|
|
"The attack root is destroyed after every shard, impact, trail, and wave sequence finishes.");
|
|
}
|
|
|
|
private void CaptureRender(string fileName)
|
|
{
|
|
if (!cameraStateSaved)
|
|
{
|
|
captureCameraFollow = Object.FindAnyObjectByType<ArenaCameraFollow>();
|
|
captureCamera = captureCameraFollow != null
|
|
? captureCameraFollow.WorldCamera : Camera.main;
|
|
Assert.That(captureCamera, Is.Not.Null);
|
|
originalCameraPosition = captureCamera.transform.position;
|
|
originalCameraOrthographicSize = captureCamera.orthographicSize;
|
|
originalCameraFollowEnabled = captureCameraFollow != null
|
|
&& captureCameraFollow.enabled;
|
|
cameraStateSaved = true;
|
|
}
|
|
|
|
if (captureCameraFollow != null) captureCameraFollow.enabled = false;
|
|
captureCamera.transform.position = new Vector3(
|
|
0f, 0f, captureCamera.transform.position.z);
|
|
captureCamera.orthographicSize = 9f;
|
|
|
|
const int width = 1920;
|
|
const int height = 1080;
|
|
RenderTexture renderTarget = new(width, height, 24);
|
|
Assert.That(renderTarget.Create(), Is.True);
|
|
Texture2D image = new(width, height, TextureFormat.RGB24, false);
|
|
RenderTexture previousTarget = captureCamera.targetTexture;
|
|
RenderTexture previousActive = RenderTexture.active;
|
|
try
|
|
{
|
|
captureCamera.targetTexture = renderTarget;
|
|
captureCamera.Render();
|
|
RenderTexture.active = renderTarget;
|
|
image.ReadPixels(new Rect(0, 0, width, height), 0, 0);
|
|
image.Apply();
|
|
Directory.CreateDirectory(
|
|
@"D:\project\game\output\tower-hit-test-v1");
|
|
File.WriteAllBytes(
|
|
Path.Combine(@"D:\project\game\output\tower-hit-test-v1", fileName),
|
|
image.EncodeToPNG());
|
|
}
|
|
finally
|
|
{
|
|
captureCamera.targetTexture = previousTarget;
|
|
RenderTexture.active = previousActive;
|
|
Object.Destroy(image);
|
|
renderTarget.Release();
|
|
Object.Destroy(renderTarget);
|
|
}
|
|
}
|
|
|
|
private static int FindFrameIndex(Sprite[] frames, Sprite current)
|
|
{
|
|
for (int i = 0; i < frames.Length; i++)
|
|
if (frames[i] == current) return i;
|
|
return -1;
|
|
}
|
|
|
|
private static void AssertHitSorting(Renderer effect, SpriteRenderer target)
|
|
{
|
|
Assert.That(effect.sortingLayerID, Is.EqualTo(target.sortingLayerID));
|
|
Assert.That(effect.sortingOrder,
|
|
Is.EqualTo(target.sortingOrder + DefenseTowerArtBounds.TowerHitSortingOffset));
|
|
}
|
|
|
|
private static void UpgradeToSecondBranch(
|
|
DefensePrototypeController controller, PlayerController player,
|
|
DefenseTower tower)
|
|
{
|
|
Assert.That(player.TryReposition(
|
|
controller.Tuning.GetBuildPadPosition(tower.PadIndex)), Is.True);
|
|
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
|
|
DefenseTowerBranch.None), Is.True);
|
|
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
|
|
DefenseTowerBranch.None), Is.True);
|
|
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
|
|
DefenseTowerBranch.Second), Is.True);
|
|
}
|
|
|
|
private static DefenseTower FindTower(DefenseTowerKind kind)
|
|
{
|
|
int padIndex = System.Array.IndexOf(
|
|
DefenseTowerHitTestLayout.KindsByPad, kind);
|
|
Assert.That(padIndex, Is.GreaterThanOrEqualTo(0),
|
|
$"Tower kind {kind} is absent from the hit-test layout.");
|
|
DefenseTower[] towers = Object.FindObjectsByType<DefenseTower>(
|
|
FindObjectsSortMode.None);
|
|
foreach (DefenseTower tower in towers)
|
|
if (tower.PadIndex == padIndex && tower.Kind == kind) return tower;
|
|
Assert.Fail($"Missing tower {kind} at pad {padIndex}.");
|
|
return null;
|
|
}
|
|
|
|
private static DefenseRouteAgent FindDummy(DefenseTowerKind kind)
|
|
{
|
|
DefenseRouteAgent[] agents = Object.FindObjectsByType<DefenseRouteAgent>(
|
|
FindObjectsSortMode.None);
|
|
string expectedName = $"Tower Test Dummy {kind}";
|
|
foreach (DefenseRouteAgent agent in agents)
|
|
if (agent.Enemy != null && agent.Enemy.name == expectedName)
|
|
return agent;
|
|
Assert.Fail($"Missing fixed target {expectedName}.");
|
|
return null;
|
|
}
|
|
|
|
private static void SetField(object instance, string name, object value)
|
|
{
|
|
FieldInfo field = instance.GetType().GetField(
|
|
name, BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.That(field, Is.Not.Null, $"Missing field {name}.");
|
|
field.SetValue(instance, value);
|
|
}
|
|
|
|
private static IEnumerator StartTowerHitTestRun()
|
|
{
|
|
AsyncOperation load = SceneManager.LoadSceneAsync(SceneName);
|
|
while (!load.isDone) yield return null;
|
|
yield return null;
|
|
yield return null;
|
|
RunManager runManager = RunManager.Instance;
|
|
Assert.That(runManager, Is.Not.Null);
|
|
DefensePrototypeController.PrepareTowerHitTestRun();
|
|
Assert.That(runManager.BeginRun(RunMode.DefensePrototype), Is.True);
|
|
|
|
float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
|
|
while (Time.realtimeSinceStartup < deadline
|
|
&& (DefensePrototypeController.Instance == null
|
|
|| !DefensePrototypeController.Instance.IsActive))
|
|
yield return null;
|
|
Assert.That(DefensePrototypeController.Instance, Is.Not.Null);
|
|
Assert.That(DefensePrototypeController.Instance.IsActive, Is.True);
|
|
yield return CloseStartupSelection(runManager);
|
|
}
|
|
|
|
private static IEnumerator CloseStartupSelection(RunManager runManager)
|
|
{
|
|
float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
|
|
while (Time.realtimeSinceStartup < deadline && !runManager.IsSelectionOpen)
|
|
yield return null;
|
|
Assert.That(runManager.IsSelectionOpen, Is.True);
|
|
#if UNITY_EDITOR
|
|
ArtifactRewardController rewardController =
|
|
Object.FindAnyObjectByType<ArtifactRewardController>();
|
|
Assert.That(rewardController, Is.Not.Null);
|
|
int count = runManager.StartupArtifactSelectionCount;
|
|
Assert.That(count, Is.GreaterThan(0));
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
Assert.That(rewardController.DebugChooseOption(0), Is.True);
|
|
yield return null;
|
|
}
|
|
#else
|
|
Assert.Ignore("The tower visual fixture requires Unity Editor test APIs.");
|
|
#endif
|
|
yield return null;
|
|
#if UNITY_EDITOR
|
|
RunTutorialController tutorial =
|
|
Object.FindAnyObjectByType<RunTutorialController>();
|
|
Assert.That(tutorial, Is.Not.Null);
|
|
bool observed = false;
|
|
deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
|
|
while (Time.realtimeSinceStartup < deadline)
|
|
{
|
|
if (tutorial.DebugIsVisible)
|
|
{
|
|
observed = true;
|
|
Assert.That(tutorial.DebugContinue(), Is.True);
|
|
yield return null;
|
|
continue;
|
|
}
|
|
if (!runManager.IsSelectionOpen && tutorial.DebugPendingCount == 0)
|
|
break;
|
|
yield return null;
|
|
}
|
|
Assert.That(observed, Is.True);
|
|
Assert.That(tutorial.DebugIsVisible, Is.False);
|
|
#else
|
|
Assert.Ignore("The tower visual fixture requires Unity Editor test APIs.");
|
|
#endif
|
|
Assert.That(runManager.IsSelectionOpen, Is.False);
|
|
}
|
|
}
|
|
}
|