446 lines
15 KiB
C#
446 lines
15 KiB
C#
using BumpCombat.Combat;
|
||
using BumpCombat.Enemies;
|
||
using NUnit.Framework;
|
||
using UnityEngine;
|
||
|
||
namespace BumpCombat.Tests
|
||
{
|
||
public sealed class BumpCombatMathTests
|
||
{
|
||
[Test]
|
||
public void ClassifySide_WithinFrontNinetyDegrees_ReturnsFront()
|
||
{
|
||
Vector2 enemyFacing = Vector2.right;
|
||
Vector2 enemyToPlayer = DirectionAtDegrees(44f);
|
||
|
||
Assert.That(BumpCombatMath.ClassifySide(enemyFacing, enemyToPlayer),
|
||
Is.EqualTo(HitSide.Front));
|
||
}
|
||
|
||
[Test]
|
||
public void ClassifySide_OutsideFrontNinetyDegrees_ReturnsSide()
|
||
{
|
||
Vector2 enemyFacing = Vector2.right;
|
||
Vector2 enemyToPlayer = DirectionAtDegrees(46f);
|
||
|
||
Assert.That(BumpCombatMath.ClassifySide(enemyFacing, enemyToPlayer),
|
||
Is.EqualTo(HitSide.Side));
|
||
}
|
||
|
||
[Test]
|
||
public void ClassifySide_WithinBackOneHundredTwentyDegrees_ReturnsBack()
|
||
{
|
||
Vector2 enemyFacing = Vector2.right;
|
||
Vector2 enemyToPlayer = DirectionAtDegrees(121f);
|
||
|
||
Assert.That(BumpCombatMath.ClassifySide(enemyFacing, enemyToPlayer),
|
||
Is.EqualTo(HitSide.Back));
|
||
}
|
||
|
||
[Test]
|
||
public void ClassifySide_OutsideBackOneHundredTwentyDegrees_ReturnsSide()
|
||
{
|
||
Vector2 enemyFacing = Vector2.right;
|
||
Vector2 enemyToPlayer = DirectionAtDegrees(119f);
|
||
|
||
Assert.That(BumpCombatMath.ClassifySide(enemyFacing, enemyToPlayer),
|
||
Is.EqualTo(HitSide.Side));
|
||
}
|
||
|
||
[Test]
|
||
public void ApplySideMultiplier_BackHit_IsExactlyOnePointFiveTimesDamage()
|
||
{
|
||
Assert.That(BumpCombatMath.ApplySideMultiplier(10f, HitSide.Back), Is.EqualTo(15f));
|
||
}
|
||
|
||
[Test]
|
||
public void ApplySideMultiplier_SideHit_IsExactlyOnePointTwoTimesDamage()
|
||
{
|
||
Assert.That(BumpCombatMath.ApplySideMultiplier(10f, HitSide.Side), Is.EqualTo(12f));
|
||
}
|
||
|
||
[Test]
|
||
public void ApplySideMultiplier_OffsetFrontHit_IsExactlyOnePointTwoTimesDamage()
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.ApplySideMultiplier(10f, HitSide.Front, true),
|
||
Is.EqualTo(12f));
|
||
}
|
||
|
||
[Test]
|
||
public void ApplySideMultiplier_CenteredFrontHit_DoesNotChangeDamage()
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.ApplySideMultiplier(10.5f, HitSide.Front),
|
||
Is.EqualTo(10.5f));
|
||
}
|
||
|
||
[TestCase(HitSide.Back, 16.5f)]
|
||
[TestCase(HitSide.Side, 13.2f)]
|
||
public void ApplySideMultiplier_PreservesFractionalPositionBonus(
|
||
HitSide side,
|
||
float expectedDamage)
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.ApplySideMultiplier(11f, side),
|
||
Is.EqualTo(expectedDamage).Within(0.001f));
|
||
}
|
||
|
||
[Test]
|
||
public void ApplyRepeatedMultiplier_PreservesEveryFractionalChainDepth()
|
||
{
|
||
Assert.That(
|
||
DamageCalculator.ApplyRepeatedMultiplier(10f, 1.2f, 0),
|
||
Is.EqualTo(10f));
|
||
Assert.That(
|
||
DamageCalculator.ApplyRepeatedMultiplier(10f, 1.2f, 1),
|
||
Is.EqualTo(12f));
|
||
Assert.That(
|
||
DamageCalculator.ApplyRepeatedMultiplier(10f, 1.2f, 2),
|
||
Is.EqualTo(14.4f).Within(0.001f));
|
||
float depthThreeDamage = DamageCalculator.ApplyRepeatedMultiplier(10f, 1.2f, 3);
|
||
Assert.That(
|
||
depthThreeDamage,
|
||
Is.EqualTo(17.28f).Within(0.001f),
|
||
"Each link applies ×1.2 to the previous fractional damage value.");
|
||
Assert.That(DamageCalculator.FinalizeDamage(depthThreeDamage), Is.EqualTo(17));
|
||
}
|
||
|
||
[Test]
|
||
public void ApplyDamageTakenEffects_FinalizesAfterShockAndVulnerability()
|
||
{
|
||
float damage = DamageCalculator.ApplyDamageTakenEffects(
|
||
13f,
|
||
0.2f,
|
||
true,
|
||
0.25f,
|
||
true);
|
||
|
||
Assert.That(damage, Is.EqualTo(19f), "13 × 1.2 × 1.25 = 19.5, then final floor.");
|
||
}
|
||
|
||
[Test]
|
||
public void FinalizeDamage_DoesNotDropIntermediateDamageBelowOne()
|
||
{
|
||
float intermediateDamage = DamageCalculator.ApplyMultiplier(0.9f, 2f);
|
||
|
||
Assert.That(intermediateDamage, Is.EqualTo(1.8f).Within(0.001f));
|
||
Assert.That(DamageCalculator.FinalizeDamage(intermediateDamage), Is.EqualTo(1f));
|
||
}
|
||
|
||
[Test]
|
||
public void FormatDamage_FloorsOnlyTheDisplayedDamage()
|
||
{
|
||
Assert.That(DamageCalculator.FormatDamage(14.5f), Is.EqualTo("14"));
|
||
}
|
||
|
||
[TestCase(HitSide.Front)]
|
||
[TestCase(HitSide.Side)]
|
||
public void PlayerImpactRecoil_NonBackHit_IsApplied(HitSide side)
|
||
{
|
||
Assert.That(BumpCombatMath.AppliesPlayerImpactRecoil(side), Is.True);
|
||
}
|
||
|
||
[Test]
|
||
public void PlayerImpactRecoil_BackHit_IsNotApplied()
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.AppliesPlayerImpactRecoil(HitSide.Back),
|
||
Is.False);
|
||
}
|
||
|
||
[Test]
|
||
public void PlayerImpactRecoil_EventEnemyBackHit_IsApplied()
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.AppliesPlayerImpactRecoil(HitSide.Back, true),
|
||
Is.True);
|
||
}
|
||
|
||
[TestCase(1.2f, 0.625f, false, false)]
|
||
[TestCase(0.625f, 0.625f, false, true)]
|
||
[TestCase(0f, 0f, false, true)]
|
||
[TestCase(0.4f, 0.625f, false, true)]
|
||
[TestCase(0.1f, 0.625f, true, false)]
|
||
public void AttackAnimationLead_StartsOnceAtLeadTime(
|
||
float warningTimeRemaining,
|
||
float leadTime,
|
||
bool animationStarted,
|
||
bool expected)
|
||
{
|
||
Assert.That(
|
||
EnemyController.ShouldStartAttackAnimation(
|
||
warningTimeRemaining,
|
||
leadTime,
|
||
animationStarted),
|
||
Is.EqualTo(expected));
|
||
}
|
||
|
||
[Test]
|
||
public void HorizontalRootDirection_UsesOnlyTheLockedLeftRightSign()
|
||
{
|
||
Assert.That(
|
||
EnemyAttack.ResolveAttackDirection(
|
||
EnemyAttackDirectionMode.HorizontalRoot,
|
||
new Vector2(1f, 1f),
|
||
Vector2.left),
|
||
Is.EqualTo(Vector2.right));
|
||
Assert.That(
|
||
EnemyAttack.ResolveAttackDirection(
|
||
EnemyAttackDirectionMode.HorizontalRoot,
|
||
new Vector2(-1f, 1f),
|
||
Vector2.right),
|
||
Is.EqualTo(Vector2.left));
|
||
Assert.That(
|
||
EnemyAttack.ResolveAttackDirection(
|
||
EnemyAttackDirectionMode.LockedDirection,
|
||
new Vector2(1f, 1f),
|
||
Vector2.left),
|
||
Is.EqualTo(new Vector2(1f, 1f).normalized));
|
||
}
|
||
|
||
[Test]
|
||
public void HorizontalRootOrigin_UsesWorldUpForLocalVerticalOffset()
|
||
{
|
||
Vector2 rightOrigin = EnemyAttack.GetAttackOrigin(
|
||
Vector2.zero,
|
||
Vector2.right,
|
||
new Vector2(0.05f, -0.05f),
|
||
EnemyAttackDirectionMode.HorizontalRoot);
|
||
Vector2 leftOrigin = EnemyAttack.GetAttackOrigin(
|
||
Vector2.zero,
|
||
Vector2.left,
|
||
new Vector2(0.05f, -0.05f),
|
||
EnemyAttackDirectionMode.HorizontalRoot);
|
||
|
||
Assert.That(rightOrigin, Is.EqualTo(new Vector2(0.05f, -0.05f)));
|
||
Assert.That(leftOrigin, Is.EqualTo(new Vector2(-0.05f, -0.05f)));
|
||
}
|
||
|
||
[Test]
|
||
public void ContactWindow_UsesHalfOpenActiveInterval()
|
||
{
|
||
EnemyAttackPattern pattern = EnemyAttackPattern.Create(
|
||
"Attack",
|
||
EnemyAttackShape.Box,
|
||
1f,
|
||
0.25f,
|
||
1f,
|
||
1f,
|
||
1f,
|
||
1f,
|
||
0f);
|
||
|
||
Assert.That(pattern.IsContactWindowActive(0f, 0.25f), Is.True);
|
||
Assert.That(pattern.IsContactWindowActive(0.24999f, 0.25f), Is.True);
|
||
Assert.That(pattern.IsContactWindowActive(0.25f, 0.25f), Is.False);
|
||
Assert.That(pattern.IsContactWindowActive(0.27f, 0.25f), Is.False);
|
||
Assert.That(pattern.IsContactWindowActive(-0.01f, 0.25f), Is.False);
|
||
}
|
||
|
||
[Test]
|
||
public void CalculateLateralOffset_CenteredApproach_ReturnsZero()
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.CalculateLateralOffset(
|
||
Vector2.right,
|
||
Vector2.zero,
|
||
Vector2.right),
|
||
Is.EqualTo(0f).Within(0.0001f));
|
||
}
|
||
|
||
[Test]
|
||
public void IsOffsetHit_FrontApproachPastThreshold_ReturnsTrue()
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.IsOffsetHit(
|
||
HitSide.Front,
|
||
Vector2.right,
|
||
Vector2.zero,
|
||
new Vector2(0.4f, 0.17f),
|
||
0.16f),
|
||
Is.True);
|
||
}
|
||
|
||
[Test]
|
||
public void IsOffsetHit_FrontApproachBelowThreshold_ReturnsFalse()
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.IsOffsetHit(
|
||
HitSide.Front,
|
||
Vector2.right,
|
||
Vector2.zero,
|
||
new Vector2(0.4f, 0.15f),
|
||
0.16f),
|
||
Is.False);
|
||
}
|
||
|
||
[TestCase(HitSide.Side)]
|
||
[TestCase(HitSide.Back)]
|
||
public void IsOffsetHit_NonFrontSide_ReturnsFalse(HitSide side)
|
||
{
|
||
Assert.That(
|
||
BumpCombatMath.IsOffsetHit(
|
||
side,
|
||
Vector2.right,
|
||
Vector2.zero,
|
||
new Vector2(0.4f, 0.2f),
|
||
0.16f),
|
||
Is.False);
|
||
}
|
||
|
||
[Test]
|
||
public void WeaponEnemy_AttackStates_DoNotAllowStateMovement()
|
||
{
|
||
Assert.That(
|
||
EnemyController.AllowsStateMovement(EnemyState.Warning, EnemyAttackShape.Box),
|
||
Is.False);
|
||
Assert.That(
|
||
EnemyController.AllowsStateMovement(EnemyState.Active, EnemyAttackShape.Box),
|
||
Is.False);
|
||
Assert.That(
|
||
EnemyController.AllowsStateMovement(EnemyState.Recovery, EnemyAttackShape.Box),
|
||
Is.False);
|
||
}
|
||
|
||
[Test]
|
||
public void Slime_ActiveState_AllowsLockedDirectionDash()
|
||
{
|
||
Assert.That(
|
||
EnemyController.AllowsStateMovement(EnemyState.Active, EnemyAttackShape.Body),
|
||
Is.True);
|
||
}
|
||
|
||
[TestCase(EnemyState.Chase)]
|
||
[TestCase(EnemyState.Warning)]
|
||
[TestCase(EnemyState.Recovery)]
|
||
public void Slime_NonActiveState_DoesNotDealContactDamage(EnemyState state)
|
||
{
|
||
Assert.That(
|
||
EnemyController.AllowsContactDamage(state, EnemyAttackShape.Body),
|
||
Is.False);
|
||
}
|
||
|
||
[Test]
|
||
public void Slime_ActiveState_DealsContactDamage()
|
||
{
|
||
Assert.That(
|
||
EnemyController.AllowsContactDamage(
|
||
EnemyState.Active,
|
||
EnemyAttackShape.Body),
|
||
Is.True);
|
||
}
|
||
|
||
[TestCase(EnemyState.Warning)]
|
||
[TestCase(EnemyState.Active)]
|
||
public void BackHit_AttemptedAttackState_IsInterruptible(EnemyState state)
|
||
{
|
||
Assert.That(EnemyController.CanBackHitInterrupt(state), Is.True);
|
||
}
|
||
|
||
[TestCase(EnemyState.Spawn)]
|
||
[TestCase(EnemyState.Chase)]
|
||
[TestCase(EnemyState.Recovery)]
|
||
[TestCase(EnemyState.Dead)]
|
||
public void BackHit_NonAttackState_IsNotInterruptible(EnemyState state)
|
||
{
|
||
Assert.That(EnemyController.CanBackHitInterrupt(state), Is.False);
|
||
}
|
||
|
||
[TestCase(EnemyAttackShape.Box)]
|
||
[TestCase(EnemyAttackShape.Cone)]
|
||
[TestCase(EnemyAttackShape.TargetCircle)]
|
||
public void WeaponAttackShapes_NeverDealBodyContactDamage(
|
||
EnemyAttackShape attackShape)
|
||
{
|
||
Assert.That(
|
||
EnemyController.AllowsContactDamage(
|
||
EnemyState.Active,
|
||
attackShape),
|
||
Is.False);
|
||
}
|
||
|
||
[Test]
|
||
public void HeavyEnemies_HaveMorePlayerKnockbackThanSlime()
|
||
{
|
||
float slimeDistance =
|
||
EnemyController.GetPlayerKnockbackDistance(EnemyKind.Slime);
|
||
|
||
Assert.That(
|
||
EnemyController.GetPlayerKnockbackDistance(EnemyKind.Lancer),
|
||
Is.GreaterThan(slimeDistance));
|
||
Assert.That(
|
||
EnemyController.GetPlayerKnockbackDistance(
|
||
EnemyKind.GreatswordSkeleton),
|
||
Is.GreaterThan(slimeDistance));
|
||
}
|
||
|
||
[Test]
|
||
public void AllEnemies_ChaseState_AllowsMovement()
|
||
{
|
||
Assert.That(
|
||
EnemyController.AllowsStateMovement(EnemyState.Chase, EnemyAttackShape.Box),
|
||
Is.True);
|
||
}
|
||
|
||
private static Vector2 DirectionAtDegrees(float degrees)
|
||
{
|
||
float radians = degrees * Mathf.Deg2Rad;
|
||
return new Vector2(Mathf.Cos(radians), Mathf.Sin(radians));
|
||
}
|
||
}
|
||
|
||
public sealed class LancerAttackMotionTests
|
||
{
|
||
[Test]
|
||
public void WarningReach_RetractsFromSixtyFiveToThirtyFivePercent()
|
||
{
|
||
Assert.That(
|
||
LancerAttackMotion.EvaluateWarningReachFraction(0f),
|
||
Is.EqualTo(0.65f).Within(0.0001f));
|
||
Assert.That(
|
||
LancerAttackMotion.EvaluateWarningReachFraction(1f),
|
||
Is.EqualTo(0.35f).Within(0.0001f));
|
||
}
|
||
|
||
[Test]
|
||
public void ActiveReach_ExtendsThenReturnsDuringNonDamagingTail()
|
||
{
|
||
Assert.That(
|
||
LancerAttackMotion.EvaluateActiveReachFraction(0f),
|
||
Is.EqualTo(0.35f).Within(0.0001f));
|
||
Assert.That(
|
||
LancerAttackMotion.EvaluateActiveReachFraction(0.65f),
|
||
Is.EqualTo(1f).Within(0.0001f));
|
||
Assert.That(
|
||
LancerAttackMotion.EvaluateActiveReachFraction(1f),
|
||
Is.EqualTo(0.35f).Within(0.0001f));
|
||
Assert.That(
|
||
LancerAttackMotion.IsDamageWindow(0.65f),
|
||
Is.True);
|
||
Assert.That(
|
||
LancerAttackMotion.IsDamageWindow(0.651f),
|
||
Is.False);
|
||
}
|
||
|
||
[TestCase(1f, 0f)]
|
||
[TestCase(-1f, 0f)]
|
||
[TestCase(0f, 1f)]
|
||
[TestCase(1f, 1f)]
|
||
public void FullReachTip_ProjectsToAttackLengthInLockedDirection(
|
||
float directionX,
|
||
float directionY)
|
||
{
|
||
Vector2 direction = new(directionX, directionY);
|
||
Vector2 tip = LancerAttackMotion.GetTipPosition(
|
||
Vector2.zero,
|
||
direction,
|
||
1.8f,
|
||
1f);
|
||
|
||
Assert.That(Vector2.Dot(tip, direction.normalized), Is.EqualTo(1.8f).Within(0.0001f));
|
||
}
|
||
|
||
}
|
||
}
|