Initial commit: Tiny Tackle Heroes Unity project

This commit is contained in:
2026-09-15 17:37:42 +09:00
commit 591fa9a826
1623 changed files with 182420 additions and 0 deletions
@@ -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));
}
}
}