Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/Defense25DTowerConsumerPlayModeTests.cs
T
2026-09-28 11:07:17 +09:00

509 lines
23 KiB
C#

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<Defense25DComparisonSettings>(SettingsPath));
Assert.That(settings, Is.Not.Null);
settings.Enemy = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"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<Defense25DComparison>();
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<Defense25DRewardConstants>();
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<System.ArgumentOutOfRangeException>(
() => 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<Transform>(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<Transform>(true)
.Count(transform => transform.name == "Tower Projectile");
private int CountTowerVisualRoots() => world.GetComponentsInChildren<Transform>(true)
.Count(transform => transform.name is "Tower Attack Effect" or "Lightning Attack Effect");
private Transform FindProjectileRoot() => world.GetComponentsInChildren<Transform>(true)
.FirstOrDefault(transform => transform.name == "Tower Projectile");
private bool HasVisibleProjectile()
{
foreach (Transform projectile in world.GetComponentsInChildren<Transform>(true)
.Where(transform => transform.name == "Tower Projectile"))
{
SpriteRenderer renderer = projectile.GetComponentInChildren<SpriteRenderer>(true);
if (renderer != null && renderer.enabled && renderer.sprite != null)
return true;
}
return false;
}
private bool HasVisibleTowerVisual() => world.GetComponentsInChildren<SpriteRenderer>(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<Sprite>()
.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"),
};
}
}
}