using System.Collections; using System.Linq; using BumpCombat.Constants; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using BumpCombat.Enemies; using NUnit.Framework; using UnityEditor; using UnityEngine; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace BumpCombat.PlayModeTests { public sealed class Defense25DTowerConsumerPlayModeTests { private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; private Defense25DComparisonSettings settings; private Defense25DComparison world; private Defense25DRewardConstants rewardConstants; private Defense25DTowerModifiers modifiers; private DefenseTowerKind[] previousPortalImmunities; private EnemyDefinition sourceEnemy; private float previousTimeScale; [UnitySetUp] public IEnumerator SetUp() { previousTimeScale = Time.timeScale; Time.timeScale = 1f; DefenseConstants tuning = GameplayConstants.Current.Defense; previousPortalImmunities = tuning.PortalImmuneTowerKinds; tuning.PortalImmuneTowerKinds = Enumerable.Repeat( DefenseTowerKind.Empowering, previousPortalImmunities != null && previousPortalImmunities.Length > 0 ? previousPortalImmunities.Length : 6).ToArray(); settings = Object.Instantiate( AssetDatabase.LoadAssetAtPath(SettingsPath)); Assert.That(settings, Is.Not.Null); settings.Enemy = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Enemies/Slime.asset"); settings.EnemyArt = ReadCharacter("Slime", "Slime", "Attack"); settings.EnemyRadius = .24f; settings.EnemyColliderOffset = new Vector2(0f, -.28f); settings.EnemyVisualScale = 1f; settings.EnemyFootOffset = -.4f; settings.EnemyBodyCenterCorrectionPixels = 0f; world = new GameObject("Tower consumer regression world") .AddComponent(); world.Initialize(settings); world.AutoSpawn = false; world.ReadInput = false; world.Player.ResetAt(Vector2.zero); sourceEnemy = settings.Enemy; settings.Enemy = Object.Instantiate(sourceEnemy); SerializedObject serializedEnemy = new(settings.Enemy); serializedEnemy.FindProperty("maxHealth").floatValue = 1000f; serializedEnemy.ApplyModifiedPropertiesWithoutUndo(); rewardConstants = ScriptableObject.CreateInstance(); modifiers = new Defense25DTowerModifiers(rewardConstants); world.TowerManager.SetRunModifiers(modifiers); yield return new WaitForFixedUpdate(); } [UnityTearDown] public IEnumerator TearDown() { if (world != null) Object.Destroy(world.gameObject); if (settings != null && settings.Enemy != sourceEnemy) Object.Destroy(settings.Enemy); if (settings != null) Object.Destroy(settings); if (rewardConstants != null) Object.Destroy(rewardConstants); if (previousPortalImmunities != null) GameplayConstants.Current.Defense.PortalImmuneTowerKinds = previousPortalImmunities; Time.timeScale = previousTimeScale; yield return null; } [UnityTest] public IEnumerator LegacyComparisonKeepsSixConfiguredPads() { Assert.That(world.TowerManager.PadCount, Is.EqualTo(6)); Assert.That(world.TowerManager.GetPadPosition(5), Is.EqualTo(GameplayConstants.Current.Defense.GetBuildPadPosition(5))); Assert.Throws( () => world.TowerManager.GetPadPosition(6)); yield return null; } [UnityTest] public IEnumerator DirectModifiersChangeRealDamageTargetCountAndProjectileVisuals() { AssertApply("fire.attack.common"); AssertApply("fire.targets.epic"); Defense25DTower tower = BuildTower(DefenseTowerKind.Direct); Defense25DEnemy first = SpawnTarget(tower.Position + Vector2.right * .8f); Defense25DEnemy second = SpawnTarget(tower.Position + Vector2.right * 1.6f); Physics.SyncTransforms(); tower.StepParityAttacks(.01f); Assert.That(CountProjectileRoots(), Is.EqualTo(2)); Assert.That(HasVisibleProjectile(), Is.True); tower.StepParityAttacks(.2f); Assert.That(first.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(second.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(1000f - first.Actor.CurrentHealth, Is.EqualTo(GameplayConstants.Current.Defense.DirectTowerDamage * 1.1f * tower.SightEfficiency).Within(.01f)); yield return null; } [UnityTest] public IEnumerator NewlyBuiltUpgradedTowerAppliesRunDamageBeforeOneOutsideSightCorrection() { AssertApply("fire.attack.common"); Defense25DTower tower = BuildTower(DefenseTowerKind.Direct); Assert.That(tower.TryUpgrade(DefenseTowerBranch.None), Is.True); world.Player.ResetAt(tower.Position + Vector2.right * (GameplayConstants.Current.Defense.PlayerSightRadius + 2f)); Assert.That(tower.SightEfficiency, Is.EqualTo(GameplayConstants.Current.Defense.OutsideSightDamageEfficiency) .Within(.001f)); Defense25DEnemy target = SpawnTarget(tower.Position + Vector2.right * .8f); Physics.SyncTransforms(); tower.StepParityAttacks(.01f); tower.StepParityAttacks(.2f); float tierScale = 1f + GameplayConstants.Current.Defense.TowerDamageBonusPerTier; float expectedDamage = GameplayConstants.Current.Defense.DirectTowerDamage * tierScale * 1.1f * GameplayConstants.Current.Defense.OutsideSightDamageEfficiency; Assert.That(target.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(1000f - target.Actor.CurrentHealth, Is.EqualTo(BumpCombat.Combat.DamageCalculator.FinalizeDamage(expectedDamage)), "A newly built, upgraded tower applies run damage and the 70% sight rule once each."); yield return null; } [UnityTest] public IEnumerator PlayerDeathPausesAnInFlightTowerAttackUntilRevival() { Defense25DTower tower = BuildTower(DefenseTowerKind.Direct); Defense25DEnemy target = SpawnTarget(tower.Position + Vector2.right * .8f); Physics.SyncTransforms(); tower.StepParityAttacks(.01f); Assert.That(CountProjectileRoots(), Is.EqualTo(1)); Assert.That(world.Player.TakeDamageWithResult( 100000f, Vector2.left, 0f, BumpCombat.Enemies.EnemyDamageSource.None, ordinaryBump: false, out _), Is.True); Assert.That(world.TowersOperational, Is.False); tower.StepParityAttacks(1f); Assert.That(target.Actor.DamageEvents, Is.Zero, "A tower flight must not deal damage while the player is dead."); world.Player.ResetAt(tower.Position); tower.StepParityAttacks(.2f); Assert.That(target.Actor.DamageEvents, Is.EqualTo(1), "The paused flight resumes once after the player is revived."); yield return null; } [UnityTest] public IEnumerator DirectTierFourMeteorUsesRunDamageModifierOnce() { AssertApply("fire.attack.common"); Defense25DTower tower = BuildTower(DefenseTowerKind.Direct); Assert.That(tower.TryUpgrade(DefenseTowerBranch.None), Is.True); Assert.That(tower.TryUpgrade(DefenseTowerBranch.None), Is.True); Assert.That(tower.TryUpgrade(DefenseTowerBranch.First), Is.True); Defense25DEnemy target = SpawnTarget(tower.Position + Vector2.right * .8f); Physics.SyncTransforms(); tower.StepParityAttacks(.01f); tower.StepParityAttacks(.32f); float tierScale = 1f + GameplayConstants.Current.Defense.TowerDamageBonusPerTier * 3f; float expectedDamage = GameplayConstants.Current.Defense.DirectTowerDamage * tierScale * 1.6f * 1.1f * tower.SightEfficiency; Assert.That(target.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(1000f - target.Actor.CurrentHealth, Is.GreaterThan(expectedDamage - 2f), "The run modifier applies after tier/meteor scaling without being multiplied twice."); Assert.That(1000f - target.Actor.CurrentHealth, Is.LessThan(expectedDamage + 1f)); Assert.That(HasVisibleTowerVisual(), Is.True); yield return null; } [UnityTest] public IEnumerator PlayerDeathStopsAStartedSummonAttack() { Defense25DTower tower = BuildTower(DefenseTowerKind.Summon); Defense25DEnemy target = SpawnTarget(tower.Position + Vector2.right * .2f); tower.StepSummonPack(.01f); Defense25DSummon[] pack = world.Summons .Where(candidate => candidate.Owner == tower) .OrderBy(candidate => candidate.SlotIndex) .ToArray(); Assert.That(pack, Has.Length.EqualTo(GameplayConstants.Current.Defense.SummonUnitCount)); Defense25DSummon summon = pack[0]; summon.Step(.01f); Assert.That(summon.IsAttacking, Is.True); Assert.That(world.Player.TakeDamageWithResult( 100000f, Vector2.left, 0f, BumpCombat.Enemies.EnemyDamageSource.None, ordinaryBump: false, out _), Is.True); summon.Step(.1f); Assert.That(summon.IsAttacking, Is.False); Assert.That(target.Actor.DamageEvents, Is.Zero, "A summon cannot continue its attack after player death."); yield return null; } [UnityTest] public IEnumerator ColdAdditionalProjectileHitsDistinctTargetAndRendersBoth() { AssertApply("cold.projectile.epic"); Defense25DTower tower = BuildTower(DefenseTowerKind.Cold); Defense25DEnemy first = SpawnTarget(tower.Position + Vector2.right * .8f); Defense25DEnemy second = SpawnTarget(tower.Position + Vector2.right * 1.6f); Physics.SyncTransforms(); tower.StepParityAttacks(.01f); Assert.That(CountProjectileRoots(), Is.EqualTo(2)); Assert.That(HasVisibleProjectile(), Is.True); tower.StepParityAttacks(.2f); tower.StepParityAttacks(.2f); Assert.That(first.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(second.Actor.DamageEvents, Is.EqualTo(1)); yield return null; } [UnityTest] public IEnumerator LightningChainModifierHitsAndRendersAnExtraTarget() { AssertApply("lightning.chain.rare"); Assert.That(modifiers.GetEffectiveStats(new Defense25DTowerStatInput( DefenseTowerKind.Lightning, 0f, 0f, supportsChainTargets: true)).ChainCount, Is.EqualTo(1)); Defense25DTower tower = BuildTower(DefenseTowerKind.Lightning); Defense25DEnemy first = SpawnTarget(tower.Position + Vector2.right * .35f); Defense25DEnemy alreadyInArea = SpawnTarget(tower.Position + Vector2.right * .65f); float outsideBaseRadius = GameplayConstants.Current.Defense.LightningRadius + .35f; Defense25DEnemy chained = SpawnTarget( first.Actor.Position + Vector2.right * outsideBaseRadius); Assert.That(world.IsTowerImmuneToEnemy(DefenseTowerKind.Lightning, chained), Is.False); Physics.SyncTransforms(); tower.StepParityAttacks(.01f); Assert.That(first.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(alreadyInArea.Actor.DamageEvents, Is.EqualTo(1), "The chain must skip targets already hit by the initial area query."); Assert.That(chained.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(CountTowerVisualRoots(), Is.GreaterThanOrEqualTo(2)); Assert.That(HasVisibleTowerVisual(), Is.True); yield return null; } [UnityTest] public IEnumerator SeismicAreaModifierExpandsActualHitAndVisibleMarker() { AssertApply("seismic.area.common"); Defense25DTower tower = BuildTower(DefenseTowerKind.Seismic); float baseRadius = GameplayConstants.Current.Defense.SeismicRadius; Defense25DEnemy first = SpawnTarget(tower.Position + Vector2.right * .2f); Defense25DEnemy edge = SpawnTarget(tower.Position + Vector2.right * (baseRadius * 1.05f)); Physics.SyncTransforms(); tower.StepParityAttacks(.01f); Assert.That(first.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(edge.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(HasVisibleTowerVisual(), Is.True); yield return null; } [UnityTest] public IEnumerator ColdSplitHitsDistinctTargetsWithoutRecursiveSplitting() { AssertApply("cold.split.epic"); AssertApply("cold.return.epic"); Defense25DTower tower = BuildTower(DefenseTowerKind.Cold); Defense25DEnemy originalTarget = SpawnTarget( tower.Position + Vector2.right * .8f); tower.StepParityAttacks(.01f); Vector2 projectileStart = Defense25DActor.Ground( FindProjectileRoot().position); Vector2 incomingDirection = ( originalTarget.Actor.Position - projectileStart).normalized; Defense25DEnemy offAngleTarget = SpawnTarget( originalTarget.Actor.Position + incomingDirection * .7f); Defense25DEnemy[] splitTargets = { SpawnTarget(originalTarget.Actor.Position + Rotate(incomingDirection, 25f)), SpawnTarget(originalTarget.Actor.Position + Rotate(incomingDirection, -25f)), }; Physics.SyncTransforms(); tower.StepParityAttacks(.2f); Assert.That(CountProjectileRoots(), Is.EqualTo(3), "The original flight and its two fixed-angle child flights should exist."); Assert.That(HasVisibleProjectile(), Is.True); tower.StepParityAttacks(.02f); float[] splitAngles = world.GetComponentsInChildren(true) .Where(transform => transform.name == "Tower Projectile") .Select(transform => Defense25DActor.Ground(transform.position) - originalTarget.Actor.Position) .Where(offset => offset.sqrMagnitude > .01f) .Select(offset => Vector2.SignedAngle(incomingDirection, offset.normalized)) .Where(angle => Mathf.Abs(angle) < 90f) .OrderBy(angle => angle) .ToArray(); Assert.That(splitAngles, Has.Length.EqualTo(2)); Assert.That(splitAngles[0], Is.EqualTo(-25f).Within(.5f)); Assert.That(splitAngles[1], Is.EqualTo(25f).Within(.5f)); tower.StepParityAttacks(.2f); float originalDamage = GameplayConstants.Current.Defense.ColdDamage * tower.SightEfficiency; float expectedFullDamage = BumpCombat.Combat.DamageCalculator .FinalizeDamage(originalDamage); float expectedHalfDamage = BumpCombat.Combat.DamageCalculator .FinalizeDamage(originalDamage * .5f); Assert.That(originalTarget.Actor.DamageEvents, Is.EqualTo(2), "The split source hits the original target outbound and once on its return."); Assert.That(1000f - originalTarget.Actor.CurrentHealth, Is.EqualTo(expectedFullDamage + expectedHalfDamage).Within(.001f)); Assert.That(offAngleTarget.Actor.DamageEvents, Is.Zero, "Split children travel along the specified ±25° lanes, not toward the nearest off-angle target."); foreach (Defense25DEnemy splitTarget in splitTargets) { Assert.That(splitTarget.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(1000f - splitTarget.Actor.CurrentHealth, Is.EqualTo(expectedHalfDamage).Within(.001f)); } yield return null; Assert.That(CountProjectileRoots(), Is.Zero, "Split children neither split again nor inherit the original flight's return effect."); yield return null; } [UnityTest] public IEnumerator ColdReturnDamagesAnInterveningTargetAndMovesVisualBackToOrigin() { AssertApply("cold.return.epic"); Defense25DTower tower = BuildTower(DefenseTowerKind.Cold); Defense25DEnemy outboundTarget = SpawnTarget( tower.Position + Vector2.right * 1.8f); Physics.SyncTransforms(); tower.StepParityAttacks(.01f); Defense25DEnemy returnTarget = SpawnTarget( tower.Position + Vector2.right * 1.25f); Physics.SyncTransforms(); tower.StepParityAttacks(.2f); Assert.That(outboundTarget.Actor.DamageEvents, Is.EqualTo(1)); Transform projectile = FindProjectileRoot(); Assert.That(projectile, Is.Not.Null); float beforeReturnStep = Vector2.Distance( Defense25DActor.Ground(projectile.position), tower.Position); float expectedFullDamage = BumpCombat.Combat.DamageCalculator .FinalizeDamage(GameplayConstants.Current.Defense.ColdDamage * tower.SightEfficiency); float expectedReturnDamage = BumpCombat.Combat.DamageCalculator .FinalizeDamage(GameplayConstants.Current.Defense.ColdDamage * tower.SightEfficiency * .5f); for (int i = 0; i < 4; i++) tower.StepParityAttacks(.03f); Assert.That(Vector2.Distance(Defense25DActor.Ground(projectile.position), tower.Position), Is.LessThan(beforeReturnStep)); Assert.That(outboundTarget.Actor.DamageEvents, Is.EqualTo(2), "Each target may be hit once outbound and once during the single return leg."); Assert.That(1000f - outboundTarget.Actor.CurrentHealth, Is.EqualTo(expectedFullDamage + expectedReturnDamage).Within(.001f)); Assert.That(returnTarget.Actor.DamageEvents, Is.EqualTo(1)); Assert.That(1000f - returnTarget.Actor.CurrentHealth, Is.EqualTo(expectedReturnDamage).Within(.001f), "The return path deals half damage and does not repeatedly hit a target across segments."); Assert.That(HasVisibleProjectile(), Is.True); for (int i = 0; i < 5; i++) tower.StepParityAttacks(.02f); Assert.That(outboundTarget.Actor.DamageEvents, Is.EqualTo(2)); Assert.That(returnTarget.Actor.DamageEvents, Is.EqualTo(1)); yield return null; } private static Vector2 Rotate(Vector2 direction, float angleDegrees) { float angle = angleDegrees * Mathf.Deg2Rad; float cosine = Mathf.Cos(angle); float sine = Mathf.Sin(angle); return new Vector2( direction.x * cosine - direction.y * sine, direction.x * sine + direction.y * cosine).normalized; } private Defense25DTower BuildTower(DefenseTowerKind kind) { Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0); world.Player.ResetAt(pad); Assert.That(world.TowerManager.TryBuildTowerAtPad(0, kind), Is.True); return world.TowerManager.GetTowerAtPad(0); } private Defense25DEnemy SpawnTarget(Vector2 position) { Defense25DEnemy enemy = world.SpawnNextEnemy(); enemy.Actor.ResetAt(position); return enemy; } private void AssertApply(string definitionId) { Defense25DTowerModifierDefinition definition = rewardConstants.TowerModifierCatalog.Single(candidate => candidate.DefinitionId == definitionId); Assert.That(modifiers.Apply( Defense25DRewardChoice.CreateTowerModifier(definition)), Is.True); } private int CountProjectileRoots() => world.GetComponentsInChildren(true) .Count(transform => transform.name == "Tower Projectile"); private int CountTowerVisualRoots() => world.GetComponentsInChildren(true) .Count(transform => transform.name is "Tower Attack Effect" or "Lightning Attack Effect"); private Transform FindProjectileRoot() => world.GetComponentsInChildren(true) .FirstOrDefault(transform => transform.name == "Tower Projectile"); private bool HasVisibleProjectile() { foreach (Transform projectile in world.GetComponentsInChildren(true) .Where(transform => transform.name == "Tower Projectile")) { SpriteRenderer renderer = projectile.GetComponentInChildren(true); if (renderer != null && renderer.enabled && renderer.sprite != null) return true; } return false; } private bool HasVisibleTowerVisual() => world.GetComponentsInChildren(true) .Any(renderer => renderer.enabled && renderer.sprite != null && renderer.transform.parent != null && renderer.transform.parent.name == "Tower Attack Effect"); private static ComparisonSpriteFrames ReadCharacter( string folder, string prefix, string attack) { Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath( $"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png") .OfType() .OrderBy(sprite => int.TryParse( sprite.name[(sprite.name.LastIndexOf('_') + 1)..], out int frame) ? frame : 0) .ToArray(); return new ComparisonSpriteFrames { Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack), Hurt = Read("Hurt"), Death = Read("Death"), }; } } }