Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user