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(); DefenseTower cold = FindTower(DefenseTowerKind.Cold); DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold); foreach (DefenseTower tower in Object.FindObjectsByType(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(); foreach (DefenseTower tower in Object.FindObjectsByType(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(); DefenseTower cold = FindTower(DefenseTowerKind.Cold); foreach (DefenseTower tower in Object.FindObjectsByType( 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( "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 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(); DefenseTower cold = FindTower(DefenseTowerKind.Cold); foreach (DefenseTower tower in Object.FindObjectsByType( 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( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); healthField.SetValue(target.Enemy, 10000f); Vector2 originalRoot = target.transform.position; SpriteRenderer targetRenderer = target.Enemy.GetComponent(); targetRenderer.flipX = false; Vector2 shadowOffset = DefenseTowerArtBounds.EnemyShadowWorldPosition( target.Enemy, originalRoot) - originalRoot; List<(float time, float damage, EnemyDamageSource source)> damageEvents = new(); System.Action 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( "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 previousEffectIds = new(); foreach (Transform existing in Object.FindObjectsByType( 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( 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(); Vector2 expectedStart = GetStaffTipWorldPosition(tower); if (tower.Kind == DefenseTowerKind.Direct) { Vector2 travelStart = (Vector2)GetSequenceField( shot.GetComponent(), "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( "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(true); List 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 impactIds = new(); HashSet hiddenShotsAtImpact = new(); Dictionary 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(), "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(); DefenseTower lightning = FindTower(DefenseTowerKind.Lightning); DefenseRouteAgent target = FindDummy(DefenseTowerKind.Lightning); foreach (DefenseTower tower in Object.FindObjectsByType(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(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(); Assert.That(controller, Is.Not.Null); Assert.That(player, Is.Not.Null); DefenseTower[] allTowers = Object.FindObjectsByType( 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 fireTargetRenderer = fireTarget.Enemy.GetComponent(); 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(), 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(); SpriteRenderer stormRenderer = storm.GetComponent(); Vector2 stormCenter = FindDummy(DefenseTowerKind.Lightning).transform.position; AssertHitSorting(stormRenderer, FindDummy(DefenseTowerKind.Lightning).Enemy.GetComponent()); 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(); DefenseSpriteSequence probeSequence = boundsProbe.AddComponent(); 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 seismicTargetRenderer = seismicTarget.Enemy.GetComponent(); 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(); 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(); 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() .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(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()); } 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()); } 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()); } } 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(); 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( 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( 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(); 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(); 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); } } }