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)); } } }