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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0fa9e8fcdcfe4d94b961e82b71d876ae
@@ -0,0 +1,355 @@
using BumpCombat.Core;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Enemies;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class ArtifactBalanceStatusRegressionTests
{
private static readonly BindingFlags NonPublicInstance =
BindingFlags.Instance | BindingFlags.NonPublic;
private GameObject owner;
[SetUp]
public void SetUp()
{
owner = new GameObject("Artifact Balance Status Tests");
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(owner);
}
[Test]
public void Ignite_UsesHalfSecondFirstTickAndIncludesThreeSecondBoundary()
{
EnemyController enemy = CreateEnemy("Ignite Boundary Target");
SetAutoProperty(enemy, "CurrentHealth", 100f);
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 3f), Is.True);
MethodInfo updateTimedEffects = GetUpdateTimedEffects();
for (int tick = 1; tick <= 6; tick++)
{
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
Assert.That(
enemy.CurrentHealth,
Is.EqualTo(100f - tick * 3f).Within(0.001f),
$"Expected ignite tick {tick} at the {tick * 0.5f:0.0}s boundary.");
}
Assert.That(enemy.IsIgnited, Is.False);
Assert.That(enemy.IgniteRemaining, Is.Zero);
}
[Test]
public void Ignite_ReapplicationKeepsStrongValueAndDoesNotDelayNextTick()
{
EnemyController enemy = CreateEnemy("Ignite Reapply Target");
SetAutoProperty(enemy, "CurrentHealth", 100f);
MethodInfo updateTimedEffects = GetUpdateTimedEffects();
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 5f), Is.True);
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
Assert.That(enemy.CurrentHealth, Is.EqualTo(95f).Within(0.001f));
Assert.That(enemy.ApplyIgnite(1f, 0.5f, 2f), Is.True);
Assert.That(enemy.IgniteRemaining, Is.EqualTo(2.5f).Within(0.001f));
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
Assert.That(
enemy.CurrentHealth,
Is.EqualTo(90f).Within(0.001f),
"Reapplying ignite must not move the next scheduled tick.");
}
[Test]
public void Ignite_ShockAmplificationFloorsTheFinalTickDamage()
{
EnemyController enemy = CreateEnemy("Shocked Ignite Target");
SetAutoProperty(enemy, "CurrentHealth", 100f);
Assert.That(enemy.ApplyShock(1f, 0.2f), Is.True);
Assert.That(enemy.ApplyIgnite(0.5f, 0.5f, 11f), Is.True);
GetUpdateTimedEffects().Invoke(enemy, new object[] { 0.5f });
Assert.That(
enemy.CurrentHealth,
Is.EqualTo(87f),
"An 11-damage tick amplified by shock is floored from 13.2 to 13 at application.");
}
[Test]
public void DamageTakenEffects_StackByMultiplicationAndOnlyNormalBumpsUseVulnerability()
{
EnemyController enemy = CreateEnemy("Damage Taken Effects Target");
Assert.That(enemy.ApplyShock(4f, 0.2f), Is.True);
Assert.That(enemy.ApplyBumpVulnerability(3f, 0.25f), Is.True);
SetAutoProperty(enemy, "CurrentHealth", 100f);
Assert.That(
enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, false),
Is.True);
Assert.That(enemy.CurrentHealth, Is.EqualTo(88f).Within(0.001f));
SetAutoProperty(enemy, "CurrentHealth", 100f);
Assert.That(
enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, true),
Is.True);
Assert.That(enemy.CurrentHealth, Is.EqualTo(85f).Within(0.001f));
}
[Test]
public void DamageTakenEffects_FloorAfterShockAndVulnerability()
{
EnemyController enemy = CreateEnemy("Truncated Status Target");
Assert.That(enemy.ApplyShock(4f, 0.2f), Is.True);
Assert.That(enemy.ApplyBumpVulnerability(3f, 0.25f), Is.True);
SetAutoProperty(enemy, "CurrentHealth", 100f);
Assert.That(
enemy.TryTakeDamage(13f, Vector2.zero, 0f, false, true),
Is.True);
Assert.That(
enemy.LastReportedDamage,
Is.EqualTo(19f),
"13 × 1.2 × 1.25 = 19.5, with a single final floor to 19.");
Assert.That(enemy.CurrentHealth, Is.EqualTo(81f));
}
[Test]
public void ChainLightningDirectHit_UsesZeroAndOneShockProbabilityPaths()
{
GameObject playerObject = new("Chain Lightning Direct Hit Source");
playerObject.transform.SetParent(owner.transform);
playerObject.AddComponent<PlayerStats>();
playerObject.AddComponent<Rigidbody2D>();
ActiveArtifactController artifacts =
playerObject.AddComponent<ActiveArtifactController>();
typeof(ActiveArtifactController)
.GetMethod("Awake", NonPublicInstance)
?.Invoke(artifacts, null);
MethodInfo applyArtifactHit = typeof(ActiveArtifactController).GetMethod(
"ApplyArtifactHit",
NonPublicInstance);
Assert.That(applyArtifactHit, Is.Not.Null);
ActiveArtifactDefinition noShock = CreateChainDefinition(
"chain-no-shock",
0f);
EnemyController noShockTarget = CreateEnemy("No Shock Target");
SetAutoProperty(noShockTarget, "CurrentHealth", 100f);
Assert.That(
applyArtifactHit.Invoke(
artifacts,
new object[]
{
noShockTarget,
noShock,
1f,
Vector2.right,
0f,
false,
false,
0f,
1,
}),
Is.True);
Assert.That(noShockTarget.IsShocked, Is.False);
ActiveArtifactDefinition guaranteedShock = CreateChainDefinition(
"chain-guaranteed-shock",
1f);
EnemyController guaranteedTarget = CreateEnemy("Guaranteed Shock Target");
SetAutoProperty(guaranteedTarget, "CurrentHealth", 100f);
Assert.That(
applyArtifactHit.Invoke(
artifacts,
new object[]
{
guaranteedTarget,
guaranteedShock,
11f,
Vector2.right,
0f,
false,
false,
0f,
2,
}),
Is.True);
Assert.That(guaranteedTarget.IsShocked, Is.True);
Assert.That(guaranteedTarget.CurrentHealth, Is.EqualTo(89f).Within(0.001f));
Assert.That(
applyArtifactHit.Invoke(
artifacts,
new object[]
{
guaranteedTarget,
guaranteedShock,
11f,
Vector2.right,
0f,
false,
false,
0f,
3,
}),
Is.True);
Assert.That(
guaranteedTarget.CurrentHealth,
Is.EqualTo(76f).Within(0.001f),
"A shock applied by a direct hit amplifies the next hit from 11 to 13 damage.");
Object.DestroyImmediate(noShock);
Object.DestroyImmediate(guaranteedShock);
}
[Test]
public void TimedStatusReapplication_KeepsStrongestValuesAndCoexistsWithoutDuplicates()
{
EnemyController enemy = CreateEnemy("Status Reapplication Target");
Assert.That(enemy.ApplyShock(4f, 0.3f), Is.True);
Assert.That(enemy.ApplyShock(1f, 0.1f), Is.True);
Assert.That(enemy.ShockRemaining, Is.EqualTo(4f).Within(0.001f));
Assert.That(enemy.ShockIncrease, Is.EqualTo(0.3f).Within(0.001f));
Assert.That(enemy.ApplyBumpVulnerability(3f, 0.4f), Is.True);
Assert.That(enemy.ApplyBumpVulnerability(1f, 0.25f), Is.True);
Assert.That(
enemy.BumpVulnerabilityRemaining,
Is.EqualTo(3f).Within(0.001f));
Assert.That(
enemy.BumpVulnerabilityIncrease,
Is.EqualTo(0.4f).Within(0.001f));
Assert.That(enemy.IsShocked, Is.True);
Assert.That(enemy.IsBumpVulnerable, Is.True);
SetAutoProperty(enemy, "CurrentHealth", 100f);
Assert.That(
enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, true),
Is.True);
Assert.That(enemy.LastReportedDamage, Is.EqualTo(18f));
Assert.That(enemy.CurrentHealth, Is.EqualTo(82f));
}
[Test]
public void Ignite_UsesFractionalDamageSnapshotAndFloorsAfterShockAtTick()
{
EnemyController enemy = CreateEnemy("Fractional Ignite Snapshot Target");
SetAutoProperty(enemy, "CurrentHealth", 100f);
GameObject playerObject = new("Fractional Ignite Snapshot Source");
playerObject.transform.SetParent(owner.transform);
PlayerStats stats = playerObject.AddComponent<PlayerStats>();
stats.AddModifier(new StatModifier(
"collision-increased",
CharacterStat.CollisionDamage,
ModifierOperation.Increased,
0.5f));
float snapshotDamage = stats.CalculateDamage(3f, DamageTag.Collision);
Assert.That(snapshotDamage, Is.EqualTo(4.5f).Within(0.001f));
Assert.That(enemy.ApplyShock(2f, 0.2f), Is.True);
Assert.That(enemy.ApplyIgnite(0.5f, 0.5f, snapshotDamage), Is.True);
GetUpdateTimedEffects().Invoke(enemy, new object[] { 0.5f });
Assert.That(enemy.LastReportedDamage, Is.EqualTo(5f));
Assert.That(enemy.CurrentHealth, Is.EqualTo(95f));
}
[Test]
public void Ignite_ConsumesZeroDamageTickWithoutReplayingItAfterShock()
{
EnemyController enemy = CreateEnemy("Zero Ignite Tick Target");
SetAutoProperty(enemy, "CurrentHealth", 100f);
MethodInfo updateTimedEffects = GetUpdateTimedEffects();
Assert.That(enemy.ApplyIgnite(2f, 1f, 0.9f), Is.True);
updateTimedEffects.Invoke(enemy, new object[] { 1f });
Assert.That(enemy.CurrentHealth, Is.EqualTo(100f));
Assert.That(enemy.ApplyShock(2f, 1f), Is.True);
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
Assert.That(enemy.CurrentHealth, Is.EqualTo(100f), "The first zero-damage tick must not be replayed.");
updateTimedEffects.Invoke(enemy, new object[] { 0.5f });
Assert.That(enemy.CurrentHealth, Is.EqualTo(99f), "Only the next scheduled tick is amplified and applied.");
}
private EnemyController CreateEnemy(string name)
{
GameObject enemyObject = new(name);
enemyObject.transform.SetParent(owner.transform);
enemyObject.AddComponent<Rigidbody2D>();
enemyObject.AddComponent<CircleCollider2D>();
enemyObject.AddComponent<SpriteRenderer>();
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
EnemyAttack enemyAttack = enemyObject.GetComponent<EnemyAttack>();
typeof(EnemyAttack)
.GetMethod("Awake", NonPublicInstance)
?.Invoke(enemyAttack, null);
typeof(EnemyController)
.GetMethod("Awake", NonPublicInstance)
?.Invoke(enemy, null);
return enemy;
}
private static MethodInfo GetUpdateTimedEffects()
{
return typeof(EnemyController).GetMethod(
"UpdateTimedEffects",
NonPublicInstance);
}
private static ActiveArtifactDefinition CreateChainDefinition(
string id,
float shockChance)
{
ActiveArtifactDefinition definition =
ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
definition.Configure(
id,
id,
"AR",
ActiveArtifactEffect.ChainLightning,
DamageTag.Collision,
Color.white,
Color.white,
30f,
30f,
0.8f,
1f,
1f,
2f,
2f,
0f,
0f,
normalElectrocuteChance: shockChance,
normalElectrocuteIncrease: 0.2f,
normalElectrocuteDuration: 3f);
return definition;
}
private static void SetAutoProperty(
object target,
string propertyName,
object value)
{
target.GetType()
.GetField(
$"<{propertyName}>k__BackingField",
NonPublicInstance)
?.SetValue(target, value);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8e6c3a9bf54a4f3ca1d27b90e5f6c812
@@ -0,0 +1,77 @@
using System;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class ArtifactChargeVisualTests
{
private static readonly ActiveArtifactEffect[] Effects =
{
ActiveArtifactEffect.Dash,
ActiveArtifactEffect.Pulse,
ActiveArtifactEffect.Phoenix,
ActiveArtifactEffect.Cyclone,
ActiveArtifactEffect.ThunderCrash,
ActiveArtifactEffect.ChainLightning,
};
[Test]
public void ChargeResources_UseSix64PixelCellsAndPointImport()
{
foreach (ActiveArtifactEffect effect in Effects)
{
foreach (string phase in new[]
{
"Aura",
"ReadyAura",
"Flash",
"Release",
})
{
string path =
$"Assets/_Project/Resources/Artifacts/Charge/"
+ $"{effect}-{phase}-v1.png";
Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
Assert.That(texture, Is.Not.Null, path);
Assert.That(
texture.width,
Is.EqualTo(phase == "Flash" ? 256 : 384),
path);
Assert.That(texture.height, Is.EqualTo(64), path);
Assert.That(texture.filterMode, Is.EqualTo(FilterMode.Point), path);
Assert.That(texture.mipmapCount, Is.EqualTo(1), path);
}
}
}
[Test]
public void ControllerAwake_AutoAddsChargeVisualComponent()
{
GameObject owner = new("Artifact Charge Visual Harness");
try
{
owner.AddComponent<PlayerStats>();
owner.AddComponent<Rigidbody2D>();
ActiveArtifactController controller =
owner.AddComponent<ActiveArtifactController>();
typeof(ActiveArtifactController)
.GetMethod("Awake", BindingFlags.Instance | BindingFlags.NonPublic)
?.Invoke(controller, null);
Assert.That(
owner.GetComponent<ArtifactChargeVisual>(),
Is.Not.Null);
Assert.That(controller, Is.Not.Null);
}
finally
{
UnityEngine.Object.DestroyImmediate(owner);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7f7a11df6edc46a49ddf5677cc3de3b1
@@ -0,0 +1,468 @@
using System.Collections.Generic;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class ArtifactColorShieldEditModeRegressionTests
{
private static readonly BindingFlags NonPublicInstance =
BindingFlags.Instance | BindingFlags.NonPublic;
private readonly List<Object> createdObjects = new();
private GameObject owner;
[SetUp]
public void SetUp()
{
owner = new GameObject("Artifact Color Shield Tests");
}
[TearDown]
public void TearDown()
{
if (owner != null)
{
Object.DestroyImmediate(owner);
}
for (int i = createdObjects.Count - 1; i >= 0; i--)
{
if (createdObjects[i] != null)
{
Object.DestroyImmediate(createdObjects[i]);
}
}
createdObjects.Clear();
}
[Test]
public void ArtifactKindsMapToTheThreeShieldColors()
{
Assert.That(
ActiveArtifactDefinition.GetArtifactColor(
ActiveArtifactEffect.Dash),
Is.EqualTo(ArtifactColor.Green));
Assert.That(
ActiveArtifactDefinition.GetArtifactColor(
ActiveArtifactEffect.Cyclone),
Is.EqualTo(ArtifactColor.Green));
Assert.That(
ActiveArtifactDefinition.GetArtifactColor(
ActiveArtifactEffect.Pulse),
Is.EqualTo(ArtifactColor.Red));
Assert.That(
ActiveArtifactDefinition.GetArtifactColor(
ActiveArtifactEffect.Phoenix),
Is.EqualTo(ArtifactColor.Red));
Assert.That(
ActiveArtifactDefinition.GetArtifactColor(
ActiveArtifactEffect.ThunderCrash),
Is.EqualTo(ArtifactColor.Blue));
Assert.That(
ActiveArtifactDefinition.GetArtifactColor(
ActiveArtifactEffect.ChainLightning),
Is.EqualTo(ArtifactColor.Blue));
Assert.That(
ActiveArtifactDefinition.GetArtifactPaletteColor(ArtifactColor.Green),
Is.EqualTo(new Color32(103, 231, 178, 255)));
Assert.That(
ActiveArtifactDefinition.GetArtifactPaletteColor(ArtifactColor.Red),
Is.EqualTo(new Color32(255, 100, 100, 255)));
Assert.That(
ActiveArtifactDefinition.GetArtifactPaletteColor(ArtifactColor.Blue),
Is.EqualTo(new Color32(104, 179, 255, 255)));
}
[Test]
public void EliteShieldWithoutOwnedArtifactsFallsBackToGreen()
{
CreateArtifactOwner();
EnemyController enemy = CreateConfiguredEventEnemy(
"Green Elite Shield",
EnemyKind.ArmoredSkeleton);
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green));
Assert.That(enemy.ShieldHitRequirement, Is.EqualTo(1));
Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1));
Assert.That(
enemy.RegisterArtifactContact(ArtifactColor.Red, 1),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(1));
Assert.That(
enemy.RegisterArtifactContact(ArtifactColor.Green, 2),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(enemy.IsGroggy, Is.True);
Assert.That(enemy.ShieldHitsRemaining, Is.Zero);
}
[Test]
public void EliteShieldStartsWithTheOnlyOwnedRedColor()
{
CreateArtifactOwner(ActiveArtifactEffect.Pulse);
EnemyController enemy = CreateConfiguredEventEnemy(
"Red Elite Shield",
EnemyKind.ArmoredSkeleton);
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Red));
}
[Test]
public void EliteShieldStartsWithTheOnlyOwnedBlueColor()
{
CreateArtifactOwner(ActiveArtifactEffect.ThunderCrash);
EnemyController enemy = CreateConfiguredEventEnemy(
"Blue Elite Shield",
EnemyKind.ArmoredSkeleton);
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Blue));
}
[Test]
public void EliteShieldInitialColorIsRestrictedToDistinctOwnedColors()
{
CreateArtifactOwner(
ActiveArtifactEffect.Cyclone,
ActiveArtifactEffect.Pulse);
EnemyController enemy = CreateConfiguredEventEnemy(
"Green Red Elite Shield",
EnemyKind.ArmoredSkeleton);
Assert.That(
enemy.ShieldColor,
Is.EqualTo(ArtifactColor.Green)
.Or.EqualTo(ArtifactColor.Red));
}
[Test]
public void DuplicateOwnedColorsDoNotChangeTheInitialColorChoice()
{
ActiveArtifactController artifacts = CreateArtifactOwner();
ActiveArtifactDefinition green = CreateArtifact(
ActiveArtifactEffect.Cyclone);
ActiveArtifactDefinition red = CreateArtifact(
ActiveArtifactEffect.Pulse);
SetOwnedArtifacts(artifacts, green, red, green);
UnityEngine.Random.State previousState = UnityEngine.Random.state;
try
{
UnityEngine.Random.InitState(7319);
EnemyController withDuplicate = CreateConfiguredEventEnemy(
"Duplicate Green Red Shield",
EnemyKind.ArmoredSkeleton);
ArtifactColor duplicateChoice = withDuplicate.ShieldColor;
SetOwnedArtifacts(artifacts, green, red);
UnityEngine.Random.InitState(7319);
EnemyController withoutDuplicate = CreateConfiguredEventEnemy(
"Distinct Green Red Shield",
EnemyKind.ArmoredSkeleton);
Assert.That(
withoutDuplicate.ShieldColor,
Is.EqualTo(duplicateChoice));
}
finally
{
UnityEngine.Random.state = previousState;
}
}
[Test]
public void InitialShieldColorRemainsStableWhenEventEnemyIsReenabled()
{
CreateArtifactOwner(
ActiveArtifactEffect.Pulse,
ActiveArtifactEffect.ThunderCrash);
EnemyController enemy = CreateConfiguredEventEnemy(
"Stable Event Shield",
EnemyKind.ArmoredSkeleton);
ArtifactColor initialColor = enemy.ShieldColor;
enemy.gameObject.SetActive(false);
enemy.gameObject.SetActive(true);
Assert.That(enemy.ShieldColor, Is.EqualTo(initialColor));
}
[Test]
public void LaterShieldCyclesAdvanceFromTheSampledInitialColor()
{
CreateArtifactOwner(ActiveArtifactEffect.Pulse);
EnemyController enemy = CreateConfiguredEventEnemy(
"Red Cycle Shield",
EnemyKind.ArmoredSkeleton);
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Red));
OpenEliteGroggy(enemy, ArtifactColor.Red, 33);
enemy.EndGroggyForProtection();
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Blue));
}
[Test]
public void MidBossAndFinalBossUseTwoAndFourHitsOfCurrentColor()
{
EnemyController midBoss = CreateEnemy(
"Red Mid Boss Shield",
EnemyKind.NecroGolem);
SetPrivateField(midBoss, "shieldColor", ArtifactColor.Red);
Assert.That(midBoss.ShieldHitRequirement, Is.EqualTo(2));
Assert.That(
midBoss.RegisterArtifactContact(ArtifactColor.Green, 10),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(midBoss.GroggyQualifyingContactCount, Is.Zero);
Assert.That(
midBoss.RegisterArtifactContact(ArtifactColor.Red, 11),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(
midBoss.RegisterArtifactContact(ArtifactColor.Red, 12),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(midBoss.IsGroggy, Is.True);
EnemyController finalBoss = CreateEnemy(
"Blue Final Boss Shield",
EnemyKind.Necromancer);
SetPrivateField(finalBoss, "shieldColor", ArtifactColor.Blue);
Assert.That(finalBoss.ShieldHitRequirement, Is.EqualTo(4));
for (int i = 0; i < 3; i++)
{
Assert.That(
finalBoss.RegisterArtifactContact(ArtifactColor.Blue, 20 + i),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(finalBoss.IsGroggy, Is.False);
}
Assert.That(
finalBoss.RegisterArtifactContact(ArtifactColor.Blue, 23),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(finalBoss.IsGroggy, Is.True);
}
[Test]
public void ShieldColorCyclesGreenRedBlueAfterEachGroggyEnd()
{
EnemyController enemy = CreateEnemy(
"Cycling Elite Shield",
EnemyKind.Werebear);
OpenEliteGroggy(enemy, ArtifactColor.Green, 30);
enemy.EndGroggyForProtection();
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Red));
OpenEliteGroggy(enemy, ArtifactColor.Red, 31);
enemy.EndGroggyForProtection();
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Blue));
OpenEliteGroggy(enemy, ArtifactColor.Blue, 32);
enemy.EndGroggyForProtection();
Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green));
}
[Test]
public void ArtifactOwnershipAllowsOneOfTwoKindsPerColorAndThreeTotal()
{
GameObject player = new("Artifact Ownership");
createdObjects.Add(player);
player.AddComponent<PlayerStats>();
player.AddComponent<Rigidbody2D>();
ActiveArtifactController artifacts =
player.AddComponent<ActiveArtifactController>();
artifacts.GetType()
.GetMethod("Awake", NonPublicInstance)
?.Invoke(artifacts, null);
ActiveArtifactDefinition greenA = CreateArtifact(
ActiveArtifactEffect.Dash);
ActiveArtifactDefinition greenB = CreateArtifact(
ActiveArtifactEffect.Cyclone);
ActiveArtifactDefinition red = CreateArtifact(
ActiveArtifactEffect.Pulse);
ActiveArtifactDefinition blue = CreateArtifact(
ActiveArtifactEffect.ThunderCrash);
artifacts.Configure(new[] { greenA, greenB, red, blue });
Assert.That(artifacts.MaxOwnedArtifacts, Is.EqualTo(3));
Assert.That(artifacts.MaxArtifactsPerColor, Is.EqualTo(1));
Assert.That(artifacts.TryAddArtifact(greenA), Is.True);
Assert.That(artifacts.TryAddArtifact(greenB), Is.False);
Assert.That(artifacts.TryAddArtifact(red), Is.True);
Assert.That(artifacts.TryAddArtifact(blue), Is.True);
Assert.That(artifacts.OwnedArtifactCount, Is.EqualTo(3));
}
private void OpenEliteGroggy(
EnemyController enemy,
ArtifactColor color,
int castIdentity)
{
Assert.That(
enemy.RegisterArtifactContact(color, castIdentity),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(enemy.IsGroggy, Is.True);
}
private EnemyController CreateEnemy(string name, EnemyKind kind)
{
EnemyDefinition definition =
ScriptableObject.CreateInstance<EnemyDefinition>();
definition.Configure(
kind,
EnemyAttackShape.Body,
100f,
0f,
0f,
0f,
0f,
0f,
0f,
0f,
0f,
0f,
0f,
0);
createdObjects.Add(definition);
GameObject enemyObject = new(name);
enemyObject.transform.SetParent(owner.transform);
enemyObject.AddComponent<Rigidbody2D>();
enemyObject.AddComponent<CircleCollider2D>();
enemyObject.AddComponent<SpriteRenderer>();
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
enemy.Configure(definition, null);
enemyObject.GetComponent<EnemyAttack>()
.GetType()
.GetMethod("Awake", NonPublicInstance)
?.Invoke(enemyObject.GetComponent<EnemyAttack>(), null);
enemy.GetType()
.GetMethod("Awake", NonPublicInstance)
?.Invoke(enemy, null);
SetAutoProperty(enemy, "CurrentHealth", 100f);
return enemy;
}
private EnemyController CreateConfiguredEventEnemy(
string name,
EnemyKind kind)
{
EnemyController enemy = CreateEnemy(name, kind);
enemy.ConfigureRunEventEnemy(
RunTimedEvent.Elite,
RunEventEnemyTuning.Create(
kind,
1f,
0,
1f,
Color.white,
1f,
1f,
1f,
1f,
1f,
1f,
1f,
1f,
1f,
1,
1f,
1f,
1,
1f,
true));
return enemy;
}
private ActiveArtifactController CreateArtifactOwner(
params ActiveArtifactEffect[] effects)
{
GameObject player = new("Shield Color Artifact Owner");
player.transform.SetParent(owner.transform);
player.AddComponent<PlayerStats>();
player.AddComponent<Rigidbody2D>();
ActiveArtifactController artifacts =
player.AddComponent<ActiveArtifactController>();
artifacts.GetType()
.GetMethod("Awake", NonPublicInstance)
?.Invoke(artifacts, null);
ActiveArtifactDefinition[] definitions =
new ActiveArtifactDefinition[effects.Length];
for (int i = 0; i < effects.Length; i++)
{
definitions[i] = CreateArtifact(effects[i]);
}
artifacts.Configure(definitions);
for (int i = 0; i < definitions.Length; i++)
{
Assert.That(artifacts.TryAddArtifact(definitions[i]), Is.True);
}
return artifacts;
}
private static void SetOwnedArtifacts(
ActiveArtifactController artifacts,
params ActiveArtifactDefinition[] definitions)
{
FieldInfo field = typeof(ActiveArtifactController).GetField(
"ownedArtifacts",
NonPublicInstance);
Assert.That(field, Is.Not.Null);
List<ActiveArtifactDefinition> owned =
(List<ActiveArtifactDefinition>)field.GetValue(artifacts);
owned.Clear();
owned.AddRange(definitions);
}
private ActiveArtifactDefinition CreateArtifact(
ActiveArtifactEffect effect)
{
ActiveArtifactDefinition definition =
ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
FieldInfo field = typeof(ActiveArtifactDefinition).GetField(
"effect",
NonPublicInstance);
field.SetValue(definition, effect);
FieldInfo idField = typeof(ActiveArtifactDefinition).GetField(
"artifactId",
NonPublicInstance);
idField.SetValue(
definition,
$"color-test-{effect}-{createdObjects.Count}");
createdObjects.Add(definition);
return definition;
}
private static void SetPrivateField(
object target,
string fieldName,
object value)
{
FieldInfo field = target.GetType().GetField(
fieldName,
NonPublicInstance);
Assert.That(field, Is.Not.Null, $"Missing {fieldName} field.");
field.SetValue(target, value);
}
private static void SetAutoProperty(
object target,
string propertyName,
object value)
{
SetPrivateField(target, $"<{propertyName}>k__BackingField", value);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9f17ae9b2e223dc4f8a9dc40a2659d5f
@@ -0,0 +1,367 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class ArtifactGroggyEditModeRegressionTests
{
private static readonly BindingFlags NonPublicInstance =
BindingFlags.Instance | BindingFlags.NonPublic;
private readonly List<UnityEngine.Object> createdObjects = new();
private GameObject owner;
[SetUp]
public void SetUp()
{
owner = new GameObject("Artifact Groggy EditMode Tests");
}
[TearDown]
public void TearDown()
{
if (owner != null)
{
UnityEngine.Object.DestroyImmediate(owner);
}
for (int i = createdObjects.Count - 1; i >= 0; i--)
{
if (createdObjects[i] != null)
{
UnityEngine.Object.DestroyImmediate(createdObjects[i]);
}
}
createdObjects.Clear();
}
[Test]
public void EnemyKindInfersGroggyTierAndContactRequirements()
{
EnemyController elite = CreateEnemy("Elite", EnemyKind.ArmoredSkeleton);
EnemyController midBoss = CreateEnemy(
"Mid Boss",
EnemyKind.GreatswordSkeleton);
EnemyController finalBoss = CreateEnemy(
"Final Boss",
EnemyKind.Necromancer);
Assert.That(elite.GroggyTier, Is.EqualTo(RunTimedEvent.Elite));
Assert.That(elite.ShieldHitRequirement, Is.EqualTo(1));
Assert.That(elite.GroggyRequiredContactCount, Is.EqualTo(1));
Assert.That(midBoss.GroggyTier, Is.EqualTo(RunTimedEvent.MidBoss));
Assert.That(midBoss.ShieldHitRequirement, Is.EqualTo(2));
Assert.That(midBoss.GroggyRequiredContactCount, Is.EqualTo(2));
Assert.That(finalBoss.GroggyTier, Is.EqualTo(RunTimedEvent.FinalBoss));
Assert.That(finalBoss.ShieldHitRequirement, Is.EqualTo(4));
Assert.That(finalBoss.GroggyRequiredContactCount, Is.EqualTo(4));
}
[Test]
public void EliteGroggyRequirement_ReadsChangedEnemySettings()
{
EnemyConstants constants = GameplayConstants.Current.Enemies;
int originalRequirement = constants.EliteGroggyRequiredMatchingHits;
try
{
constants.EliteGroggyRequiredMatchingHits = 3;
EnemyController enemy = CreateEnemy(
"Elite Changed Threshold",
EnemyKind.ArmoredSkeleton);
Assert.That(enemy.ShieldHitRequirement, Is.EqualTo(3));
Assert.That(enemy.GroggyRequiredContactCount, Is.EqualTo(3));
}
finally
{
constants.EliteGroggyRequiredMatchingHits = originalRequirement;
}
}
[Test]
public void NormalEnemyRemainsDamageableWithoutGroggyShield()
{
EnemyController enemy = CreateEnemy("Normal", EnemyKind.Skeleton);
float healthBefore = enemy.CurrentHealth;
Assert.That(enemy.IsGroggyTierGated, Is.False);
Assert.That(enemy.IsDamageInvulnerable, Is.False);
Assert.That(
enemy.TryTakeDamage(5f, Vector2.right, 0f, false),
Is.True);
Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore - 5f));
Assert.That(
enemy.TryTakeArtifactHit(
7f,
Vector2.right,
0f,
ArtifactColor.Blue,
1,
false),
Is.EqualTo(EnemyArtifactHitResult.DamageApplied));
}
[Test]
public void GroggyShieldBlocksNormalArtifactAndDotDamageBeforeOpening()
{
EnemyController enemy = CreateEnemy("Elite Gate", EnemyKind.Werewolf);
float healthBefore = enemy.CurrentHealth;
Assert.That(enemy.IsDamageInvulnerable, Is.True);
Assert.That(
enemy.TryTakeDamage(5f, Vector2.right, 0f, false),
Is.False);
Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore));
Assert.That(enemy.ApplyIgnite(1f, 0.5f, 4f), Is.True);
InvokeTimedEffects(enemy, 0.5f);
Assert.That(
enemy.CurrentHealth,
Is.EqualTo(healthBefore),
"A DOT tick must not bypass the groggy shield.");
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
Assert.That(
enemy.TryTakeArtifactHit(
9f,
Vector2.right,
0f,
ArtifactColor.Red,
1,
false),
Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(enemy.LastAppliedDamage, Is.Zero);
Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore));
Assert.That(enemy.IsGroggy, Is.False);
Assert.That(
enemy.TryTakeArtifactHit(
9f,
Vector2.right,
0f,
ArtifactColor.Green,
2,
false),
Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(enemy.IsGroggy, Is.True);
Assert.That(enemy.IsDamageInvulnerable, Is.False);
Assert.That(
enemy.TryTakeDamage(5f, Vector2.right, 0f, false),
Is.True);
Assert.That(
enemy.TryTakeArtifactHit(
7f,
Vector2.right,
0f,
ArtifactColor.Blue,
3,
false),
Is.EqualTo(EnemyArtifactHitResult.DamageApplied));
float healthAfterDirectHits = enemy.CurrentHealth;
InvokeTimedEffects(enemy, 0.5f);
Assert.That(
enemy.CurrentHealth,
Is.LessThan(healthAfterDirectHits),
"The same DOT API is allowed to tick after groggy opens.");
}
[Test]
public void MidBossNeedsTwoMatchingColorContacts()
{
EnemyController enemy = CreateEnemy(
"Mid Boss Threshold",
EnemyKind.NecroGolem);
Assert.That(Contact(enemy, ArtifactColor.Green, 11), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(enemy.IsGroggy, Is.False);
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1));
Assert.That(
Contact(enemy, ArtifactColor.Green, 12),
Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(enemy.IsGroggy, Is.True);
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(2));
Assert.That(enemy.IsDamageInvulnerable, Is.False);
}
[Test]
public void FinalBossRequiresFourMatchingColorContacts()
{
EnemyController enemy = CreateEnemy(
"Final Boss Four Singles",
EnemyKind.Necromancer);
Assert.That(Contact(enemy, ArtifactColor.Red, 21), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(Contact(enemy, ArtifactColor.Blue, 22), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(Contact(enemy, ArtifactColor.Blue, 23), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(Contact(enemy, ArtifactColor.Red, 24), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(enemy.IsGroggy, Is.False);
Assert.That(enemy.IsDamageInvulnerable, Is.True);
Assert.That(enemy.ShieldHitsRemaining, Is.EqualTo(4));
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
Assert.That(Contact(enemy, ArtifactColor.Green, 25), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(Contact(enemy, ArtifactColor.Green, 26), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(Contact(enemy, ArtifactColor.Green, 27), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(Contact(enemy, ArtifactColor.Green, 28), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(enemy.IsGroggy, Is.True);
}
[Test]
public void FinalBossFourCastsOfOneColorOpenGroggy()
{
EnemyController enemy = CreateEnemy(
"Final Boss Four Green Activations",
EnemyKind.Necromancer);
Assert.That(Contact(enemy, ArtifactColor.Green, 31), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(Contact(enemy, ArtifactColor.Green, 32), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(Contact(enemy, ArtifactColor.Green, 33), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(
Contact(enemy, ArtifactColor.Green, 34),
Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(enemy.IsGroggy, Is.True);
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(4));
}
[Test]
public void SameCastMultiTickIsDeduplicatedPerEnemy()
{
EnemyController enemy = CreateEnemy(
"Mid Boss Dedup",
EnemyKind.GreatswordSkeleton);
Assert.That(Contact(enemy, ArtifactColor.Green, 41), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(
Contact(enemy, ArtifactColor.Green, 41),
Is.EqualTo(EnemyArtifactHitResult.Rejected));
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1));
Assert.That(enemy.IsGroggy, Is.False);
Assert.That(Contact(enemy, ArtifactColor.Green, 42), Is.EqualTo(EnemyArtifactHitResult.Shielded));
Assert.That(enemy.IsGroggy, Is.True);
}
[Test]
public void GroggyHitsDoNotExtendOrPrechargeTheNextCycle()
{
EnemyController enemy = CreateEnemy("Groggy Rearm", EnemyKind.Werebear);
Assert.That(Contact(enemy, ArtifactColor.Green, 51), Is.EqualTo(EnemyArtifactHitResult.Shielded));
float remaining = enemy.GroggyTimeRemaining;
Assert.That(
Contact(enemy, ArtifactColor.Blue, 52),
Is.EqualTo(EnemyArtifactHitResult.DamageApplied));
Assert.That(enemy.GroggyTimeRemaining, Is.EqualTo(remaining).Within(0.001f));
Assert.That(enemy.ApplyStun(2f), Is.False);
Assert.That(enemy.IsStunned, Is.False);
enemy.EndGroggyForProtection();
Assert.That(enemy.IsGroggy, Is.False);
Assert.That(enemy.IsDamageInvulnerable, Is.True);
Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero);
Assert.That(
enemy.RegisterArtifactContact(ArtifactColor.Blue, 52),
Is.EqualTo(EnemyArtifactContactResult.Ignored),
"A cast that first contacted during groggy must not precharge the next shield.");
Assert.That(
enemy.RegisterArtifactContact(ArtifactColor.Red, 53),
Is.EqualTo(EnemyArtifactContactResult.Shielded));
Assert.That(enemy.IsGroggy, Is.True);
Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1));
}
private EnemyController CreateEnemy(string name, EnemyKind kind)
{
EnemyDefinition definition =
ScriptableObject.CreateInstance<EnemyDefinition>();
definition.Configure(
kind,
EnemyAttackShape.Body,
100f,
0f,
0f,
0f,
0f,
0f,
0f,
0f,
0f,
0f,
0f,
0);
createdObjects.Add(definition);
GameObject enemyObject = new(name);
enemyObject.transform.SetParent(owner.transform);
enemyObject.AddComponent<Rigidbody2D>();
enemyObject.AddComponent<CircleCollider2D>();
enemyObject.AddComponent<SpriteRenderer>();
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
enemy.Configure(definition, null);
EnemyAttack enemyAttack = enemyObject.GetComponent<EnemyAttack>();
enemyAttack.GetType()
.GetMethod("Awake", NonPublicInstance)
?.Invoke(enemyAttack, null);
enemy.GetType()
.GetMethod("Awake", NonPublicInstance)
?.Invoke(enemy, null);
SetAutoProperty(enemy, "CurrentHealth", 100f);
return enemy;
}
private static EnemyArtifactHitResult Contact(
EnemyController enemy,
ArtifactColor artifactColor,
int castIdentity)
{
return enemy.TryTakeArtifactHit(
10f,
Vector2.right,
0f,
artifactColor,
castIdentity,
false);
}
private static void InvokeTimedEffects(
EnemyController enemy,
float deltaTime)
{
MethodInfo method = typeof(EnemyController).GetMethod(
"UpdateTimedEffects",
NonPublicInstance);
Assert.That(method, Is.Not.Null);
method.Invoke(enemy, new object[] { deltaTime });
}
private static void SetAutoProperty(
object target,
string propertyName,
object value)
{
FieldInfo field = target.GetType().GetField(
$"<{propertyName}>k__BackingField",
NonPublicInstance);
Assert.That(field, Is.Not.Null, $"Missing {propertyName} backing field.");
field.SetValue(target, value);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b6bbf5d18ad4d4cb2f869734fa2e8b1
@@ -0,0 +1,14 @@
{
"name": "BumpCombat.Tests",
"rootNamespace": "BumpCombat.Tests",
"references": [
"BumpCombat.Runtime"
],
"includePlatforms": [
"Editor"
],
"optionalUnityReferences": [
"TestAssemblies"
],
"autoReferenced": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: d730b0dbebc910d4db14efbbd2217c51
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54d8919b5c8bbb34d8716c88211a8887
@@ -0,0 +1,46 @@
using BumpCombat.Combat;
using BumpCombat.Enemies;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class CombatFeedbackV2EditModeTests
{
[Test]
public void CycloneDefinition_PreservesNormalAndChargedRuntimeTickInputs()
{
ActiveArtifactDefinition definition = AssetDatabase.LoadAssetAtPath<
ActiveArtifactDefinition>(
"Assets/_Project/Constants/Artifacts/CycloneArtifact.asset");
Assert.That(definition, Is.Not.Null);
Assert.That(definition.Effect, Is.EqualTo(ActiveArtifactEffect.Cyclone));
Assert.That(definition.NormalDamage, Is.EqualTo(6f));
Assert.That(definition.ChargedDamage, Is.EqualTo(8f));
Assert.That(definition.NormalDuration, Is.EqualTo(0.36f).Within(0.0001f));
Assert.That(definition.ChargedDuration, Is.EqualTo(0.65f).Within(0.0001f));
Assert.That(definition.HitInterval, Is.EqualTo(0.14f).Within(0.0001f));
Assert.That(definition.NormalRange, Is.EqualTo(0.85f).Within(0.0001f));
Assert.That(definition.ChargedRange, Is.EqualTo(1.25f).Within(0.0001f));
}
[Test]
public void NecroGolemPrefab_UsesTheLargePhysicalBodyContract()
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
"Assets/_Project/Prefabs/Enemies/NecroGolem.prefab");
Assert.That(prefab, Is.Not.Null);
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1.25f));
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1.25f));
CircleCollider2D body = prefab.GetComponent<CircleCollider2D>();
Assert.That(body, Is.Not.Null);
Assert.That(body.radius, Is.EqualTo(0.45f).Within(0.0001f));
Assert.That(body.offset.y, Is.EqualTo(-0.28f).Within(0.0001f));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f4f02fb53ab4b2a9c7e1d6a0e5f8b31
@@ -0,0 +1,143 @@
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class EnemyModelTests
{
private GameObject owner;
private EnemyDefinition definition;
[SetUp]
public void SetUp()
{
owner = new GameObject("Enemy Model Tests");
definition = ScriptableObject.CreateInstance<EnemyDefinition>();
definition.Configure(
EnemyKind.Slime,
EnemyAttackShape.Body,
100f,
4f,
12f,
1f,
0.2f,
1f,
1f,
1f,
1f,
1f,
1f,
10);
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(owner);
Object.DestroyImmediate(definition);
}
[Test]
public void EnemyModel_UsesSharedModifiersForHealthSpeedAttackAndIncomingDamage()
{
EnemyModel model = owner.AddComponent<EnemyModel>();
model.ConfigureDefinition(definition);
model.ConfigureEventMultipliers(1.1f, 1.2f, 1.3f);
CharacterModel character = model;
character.AddModifier(new StatModifier(
"max-health",
CharacterStat.MaxHealth,
ModifierOperation.Increased,
0.2f));
character.AddModifier(new StatModifier(
"move-speed-flat",
CharacterStat.MoveSpeed,
ModifierOperation.Flat,
0.5f));
character.AddModifier(new StatModifier(
"move-speed-increased",
CharacterStat.MoveSpeed,
ModifierOperation.Increased,
0.25f));
character.AddModifier(new StatModifier(
"attack-increased",
CharacterStat.AttackDamage,
ModifierOperation.Increased,
0.2f));
character.AddModifier(new StatModifier(
"incoming-flat",
CharacterStat.IncomingDamage,
ModifierOperation.Flat,
2f));
character.AddModifier(new StatModifier(
"incoming-increased",
CharacterStat.IncomingDamage,
ModifierOperation.Increased,
0.25f));
character.AddModifier(new StatModifier(
"incoming-more",
CharacterStat.IncomingDamage,
ModifierOperation.More,
0.1f));
Assert.That(character.MaxHealth, Is.EqualTo(132f).Within(0.001f));
Assert.That(character.MoveSpeed, Is.EqualTo(6.625f).Within(0.001f));
Assert.That(model.AttackDamage, Is.EqualTo(18.72f).Within(0.001f));
Assert.That(
model.CalculatePatternDamage(1.5f, true),
Is.EqualTo(28.08f).Within(0.001f));
float modifiedIncomingDamage = character.CalculateIncomingDamage(10f);
Assert.That(modifiedIncomingDamage, Is.EqualTo(16.5f).Within(0.001f));
Assert.That(
DamageCalculator.ApplyDamageTakenEffects(
modifiedIncomingDamage,
0f,
false,
0.25f,
true),
Is.EqualTo(20f));
}
[Test]
public void UnprotectedSummon_KeepsHealthMultiplierWithoutEventSpeedOrDamageTuning()
{
owner.AddComponent<Rigidbody2D>();
owner.AddComponent<BoxCollider2D>();
owner.AddComponent<SpriteRenderer>();
EnemyController enemy = owner.AddComponent<EnemyController>();
enemy.Configure(definition, null);
RunEventEnemyTuning tuning = RunEventEnemyTuning.Create(
EnemyKind.Slime,
1.5f,
10,
1f,
Color.white,
2f,
2f,
1f,
1f,
1f,
1f,
1f,
1f,
1f,
1,
1f,
1f,
1,
1f,
false);
enemy.ConfigureSummonedEnemy(null, tuning, false);
Assert.That(enemy.MaximumHealth, Is.EqualTo(150f).Within(0.001f));
Assert.That(enemy.MoveSpeed, Is.EqualTo(4f).Within(0.001f));
Assert.That(enemy.AttackDamage, Is.EqualTo(12f).Within(0.001f));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: af0f3b0082644d57a9304cbd8b671d0b
@@ -0,0 +1,54 @@
using BumpCombat.Core;
using System.Reflection;
using BumpCombat.Player;
using BumpCombat.Progression;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class ExperienceSystemTests
{
[TestCase(1, 10)]
[TestCase(2, 15)]
[TestCase(3, 20)]
[TestCase(10, 55)]
public void RequiredExperienceForLevel_UsesWorkOrderFormula(int level, int expected)
{
Assert.That(ExperienceSystem.RequiredExperienceForLevel(level), Is.EqualTo(expected));
}
[Test]
public void AddExperience_UsesPlayerStatsExperienceGain()
{
GameObject owner = new("ExperienceSystem Tests");
try
{
PlayerStats stats = owner.AddComponent<PlayerStats>();
ExperienceSystem experience = owner.AddComponent<ExperienceSystem>();
typeof(ExperienceSystem)
.GetMethod(
"Awake",
BindingFlags.Instance | BindingFlags.NonPublic)
?.Invoke(experience, null);
stats.AddModifier(new StatModifier(
"experience-increased",
CharacterStat.ExperienceGain,
ModifierOperation.Increased,
0.1f));
for (int i = 0; i < 10; i++)
{
experience.AddExperience(1);
}
Assert.That(experience.Level, Is.EqualTo(2));
Assert.That(experience.CurrentExperience, Is.EqualTo(1));
}
finally
{
Object.DestroyImmediate(owner);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: edfca9956c00a0d46af3be57412d711a
@@ -0,0 +1,273 @@
using System.Reflection;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Spawning;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class FiniteArenaGeometryTests
{
[TearDown]
public void Cleanup()
{
ArenaBounds[] bounds = Object.FindObjectsByType<ArenaBounds>(
FindObjectsSortMode.None);
for (int i = 0; i < bounds.Length; i++)
{
Object.DestroyImmediate(bounds[i].gameObject);
}
SpawnDirector[] directors = Object.FindObjectsByType<SpawnDirector>(
FindObjectsSortMode.None);
for (int i = 0; i < directors.Length; i++)
{
if (directors[i] != null)
{
Object.DestroyImmediate(directors[i].gameObject);
}
}
ArenaBounds.Instance?.gameObject.SetActive(false);
}
[Test]
public void DefaultGeometry_UsesFullFloorWithSmallSharedActorClearance()
{
GameObject root = CreateBounds();
ArenaBounds bounds = root.GetComponent<ArenaBounds>();
Assert.That(bounds.BackgroundHalfExtents.x, Is.EqualTo(15f).Within(0.0001f));
Assert.That(bounds.BackgroundHalfExtents.y, Is.EqualTo(8.4375f).Within(0.0001f));
Assert.That(bounds.PlayableHalfExtents, Is.EqualTo(bounds.BackgroundHalfExtents));
Assert.That(bounds.PlayerPadding, Is.EqualTo(new Vector2(0.5f, 0.625f)));
Assert.That(bounds.PlayerClampHalfExtents.x, Is.EqualTo(14.5f).Within(0.0001f));
Assert.That(bounds.PlayerClampHalfExtents.y, Is.EqualTo(7.8125f).Within(0.0001f));
Assert.That(bounds.SharedActorClampHalfExtents, Is.EqualTo(bounds.PlayerClampHalfExtents));
Assert.That(bounds.CameraCenterHalfExtents.x, Is.EqualTo(5f).Within(0.0001f));
Assert.That(bounds.CameraCenterHalfExtents.y, Is.EqualTo(2.8125f).Within(0.0001f));
}
[Test]
public void ClampsPlayerEnemyAndCameraInsideFiniteMap()
{
GameObject root = CreateBounds();
ArenaBounds bounds = root.GetComponent<ArenaBounds>();
Vector2 playerPosition = bounds.ClampPlayerPosition(new Vector2(99f, -99f));
Assert.That(playerPosition.x, Is.EqualTo(14.5f).Within(0.0001f));
Assert.That(playerPosition.y, Is.EqualTo(-7.8125f).Within(0.0001f));
Vector2 enemyPosition = bounds.ClampEnemyPosition(new Vector2(-99f, 99f));
Assert.That(enemyPosition.x, Is.EqualTo(-14.5f).Within(0.0001f));
Assert.That(enemyPosition.y, Is.EqualTo(7.8125f).Within(0.0001f));
Assert.That(
bounds.ClampEnemyPosition(new Vector2(99f, -99f)),
Is.EqualTo(bounds.ClampPlayerPosition(new Vector2(99f, -99f))));
Vector2 cameraPosition = bounds.ClampCameraCenter(new Vector2(99f, -99f));
Assert.That(cameraPosition.x, Is.EqualTo(5f).Within(0.0001f));
Assert.That(cameraPosition.y, Is.EqualTo(-2.8125f).Within(0.0001f));
GameObject cameraObject = new("Arena Bounds Camera Test");
Camera camera = cameraObject.AddComponent<Camera>();
camera.orthographic = true;
camera.orthographicSize = 5.625f;
camera.aspect = 16f / 9f;
Vector2 cameraCenter = bounds.ClampCameraCenter(
new Vector2(99f, 99f),
bounds.GetCameraVisibleHalfExtents(camera));
camera.transform.position = cameraCenter;
Bounds visibleBounds = bounds.GetCameraVisibleBounds(camera);
Assert.That(visibleBounds.min.x, Is.GreaterThanOrEqualTo(-15f));
Assert.That(visibleBounds.max.x, Is.EqualTo(15f).Within(0.0001f));
Assert.That(visibleBounds.min.y, Is.GreaterThanOrEqualTo(-8.4375f));
Assert.That(visibleBounds.max.y, Is.EqualTo(8.4375f).Within(0.0001f));
cameraCenter = bounds.ClampCameraCenter(
new Vector2(-99f, -99f),
bounds.GetCameraVisibleHalfExtents(camera));
camera.transform.position = cameraCenter;
visibleBounds = bounds.GetCameraVisibleBounds(camera);
Assert.That(visibleBounds.min.x, Is.EqualTo(-15f).Within(0.0001f));
Assert.That(visibleBounds.max.x, Is.LessThanOrEqualTo(15f));
Assert.That(visibleBounds.min.y, Is.EqualTo(-8.4375f).Within(0.0001f));
Assert.That(visibleBounds.max.y, Is.LessThanOrEqualTo(8.4375f));
Object.DestroyImmediate(cameraObject);
}
[Test]
public void DeadZone_HoldsUntilTargetCrossesEachAxis()
{
Assert.That(
ArenaCameraFollow.GetDeadZoneTargetCenter(
Vector2.zero,
new Vector2(0.5f, 0.3f),
new Vector2(0.5f, 0.3f)),
Is.EqualTo(Vector2.zero));
Vector2 movedCenter = ArenaCameraFollow.GetDeadZoneTargetCenter(
Vector2.zero,
new Vector2(0.7f, -0.5f),
new Vector2(0.5f, 0.3f));
Assert.That(movedCenter.x, Is.EqualTo(0.2f).Within(0.0001f));
Assert.That(movedCenter.y, Is.EqualTo(-0.2f).Within(0.0001f));
}
[Test]
public void SpawnPositionsStayReachableAndClearOfPlayer()
{
GameObject boundsObject = CreateBounds();
ArenaBounds bounds = boundsObject.GetComponent<ArenaBounds>();
GameObject directorObject = new("Spawn Director Test");
SpawnDirector director = directorObject.AddComponent<SpawnDirector>();
for (int i = 0; i < 64; i++)
{
Vector2 position = director.GetSpawnPositionForTests();
Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True);
Assert.That(position.magnitude, Is.GreaterThanOrEqualTo(4f));
}
}
[Test]
public void EnemyGroundAnchors_StayOnSharedPlayerBoundaryAfterEdgeAndCornerKnockback()
{
GameObject boundsObject = CreateBounds();
ArenaBounds bounds = boundsObject.GetComponent<ArenaBounds>();
EnemyController normalEnemy = CreateEnemy(
new Vector2(0.72f, 0.72f),
new Vector2(0.1f, -0.28f),
1f);
EnemyController eventEnemy = CreateEnemy(
new Vector2(0.72f, 0.72f),
new Vector2(-0.18f, -0.28f),
1.25f);
try
{
MethodInfo clampMethod = typeof(EnemyController).GetMethod(
"ClampEnemyPosition",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(clampMethod, Is.Not.Null);
EnemyController[] enemies = { normalEnemy, eventEnemy };
for (int enemyIndex = 0; enemyIndex < enemies.Length; enemyIndex++)
{
EnemyController enemy = enemies[enemyIndex];
Collider2D collider = enemy.GetComponent<Collider2D>();
Rigidbody2D body = enemy.GetComponent<Rigidbody2D>();
Vector2[] knockbackDestinations =
{
new(100f, 100f),
new(-100f, 100f),
new(100f, -100f),
new(-100f, -100f),
};
for (int i = 0; i < knockbackDestinations.Length; i++)
{
Vector2 clamped = (Vector2)clampMethod.Invoke(
enemy,
new object[] { knockbackDestinations[i] });
body.position = clamped;
Physics2D.SyncTransforms();
Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents;
Assert.That(body.position.x, Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.0001f));
Assert.That(body.position.x, Is.LessThanOrEqualTo(safeHalfExtents.x + 0.0001f));
Assert.That(body.position.y, Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.0001f));
Assert.That(body.position.y, Is.LessThanOrEqualTo(safeHalfExtents.y + 0.0001f));
Bounds colliderBounds = collider.bounds;
// The collider can overhang the ground-anchor edge by
// its art footprint; only the anchor defines movement.
Assert.That(colliderBounds.size.x, Is.GreaterThan(0f));
Assert.That(colliderBounds.size.y, Is.GreaterThan(0f));
}
}
}
finally
{
Object.DestroyImmediate(normalEnemy.gameObject);
Object.DestroyImmediate(eventEnemy.gameObject);
}
}
[Test]
public void EnemyGroundAnchor_PostPhysicsCorrectionRestoresSharedBoundary()
{
CreateBounds();
EnemyController enemy = CreateEnemy(
new Vector2(0.72f, 0.72f),
new Vector2(-0.18f, -0.28f),
1.25f);
try
{
Rigidbody2D body = enemy.GetComponent<Rigidbody2D>();
Collider2D collider = enemy.GetComponent<Collider2D>();
body.position = new Vector2(100f, -100f);
Physics2D.SyncTransforms();
MethodInfo correctionMethod = typeof(EnemyController).GetMethod(
"KeepBodyInsideArena",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(correctionMethod, Is.Not.Null);
correctionMethod.Invoke(enemy, null);
Physics2D.SyncTransforms();
Bounds colliderBounds = collider.bounds;
ArenaBounds bounds = ArenaBounds.Resolve();
Vector2 safeHalfExtents = bounds.SharedActorClampHalfExtents;
Assert.That(body.position.x, Is.GreaterThanOrEqualTo(-safeHalfExtents.x - 0.0001f));
Assert.That(body.position.x, Is.LessThanOrEqualTo(safeHalfExtents.x + 0.0001f));
Assert.That(body.position.y, Is.GreaterThanOrEqualTo(-safeHalfExtents.y - 0.0001f));
Assert.That(body.position.y, Is.LessThanOrEqualTo(safeHalfExtents.y + 0.0001f));
Assert.That(colliderBounds.size.x, Is.GreaterThan(0f));
}
finally
{
Object.DestroyImmediate(enemy.gameObject);
}
}
private static EnemyController CreateEnemy(
Vector2 colliderSize,
Vector2 colliderOffset,
float rootScale)
{
GameObject enemyObject = new("Boundary Test Enemy");
Rigidbody2D body = enemyObject.AddComponent<Rigidbody2D>();
body.bodyType = RigidbodyType2D.Kinematic;
body.gravityScale = 0f;
BoxCollider2D collider = enemyObject.AddComponent<BoxCollider2D>();
collider.size = colliderSize;
collider.offset = colliderOffset;
enemyObject.AddComponent<SpriteRenderer>();
enemyObject.transform.localScale = Vector3.one * rootScale;
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
// Unity does not invoke runtime Awake in EditMode. Populate the
// same private references that Awake would cache so this test
// exercises the controller's real clamp path.
SetPrivateField(enemy, "body", body);
SetPrivateField(enemy, "bodyCollider", collider);
Physics2D.SyncTransforms();
return enemy;
}
private static void SetPrivateField<T>(
object target,
string fieldName,
T value)
{
typeof(EnemyController)
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(target, value);
}
private static GameObject CreateBounds()
{
GameObject root = new("Arena Bounds Test");
root.AddComponent<ArenaBounds>();
return root;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c5f151c42dbb4b2d8bb0df41ec6ad77e
@@ -0,0 +1,173 @@
using BumpCombat.Constants;
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.Spawning;
using NUnit.Framework;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class GameplayConstantsConsumptionTests
{
private static readonly BindingFlags NonPublicInstance =
BindingFlags.Instance | BindingFlags.NonPublic;
[Test]
public void ExperienceAndMissionRules_ReadChangedLevelUpSettings()
{
LevelUpConstants constants = GameplayConstants.Current.LevelUp;
int originalBaseExperience = constants.BaseExperienceToNextLevel;
int originalAdditionalExperience = constants.AdditionalExperiencePerLevel;
float originalRampageWindow = constants.RampageWindowSeconds;
MissionTuning[] originalMissions = constants.Missions;
try
{
constants.BaseExperienceToNextLevel = 19;
constants.AdditionalExperiencePerLevel = 8;
MissionTuning[] changedMissions =
(MissionTuning[])originalMissions.Clone();
changedMissions[0].Target = 7;
changedMissions[0].ConditionFormat = "Defeat {0} units";
changedMissions[0].ExperienceReward = 23;
changedMissions[10].Target = 9;
changedMissions[10].ConditionFormat = "Within {1}s defeat {0}";
constants.Missions = changedMissions;
constants.RampageWindowSeconds = 4f;
Assert.That(
ExperienceSystem.RequiredExperienceForLevel(3),
Is.EqualTo(35));
RunMissionDefinition mission = RunMissionTracker.GetDefinition(0);
Assert.That(mission.Target, Is.EqualTo(7));
Assert.That(mission.ExperienceReward, Is.EqualTo(23));
Assert.That(mission.Condition, Is.EqualTo("Defeat 7 units"));
Assert.That(RunMissionTracker.RampageKillTarget, Is.EqualTo(9));
Assert.That(
RunMissionTracker.GetDefinition(10).Condition,
Is.EqualTo("Within 4s defeat 9"));
}
finally
{
constants.BaseExperienceToNextLevel = originalBaseExperience;
constants.AdditionalExperiencePerLevel = originalAdditionalExperience;
constants.Missions = originalMissions;
constants.RampageWindowSeconds = originalRampageWindow;
}
}
[Test]
public void SpawnDensityRules_ReadChangedRunSettings()
{
RunConstants constants = GameplayConstants.Current.Run;
SpawnDensityPhase[] originalPhases = constants.SpawnDensity;
try
{
SpawnDensityPhase[] changedPhases =
(SpawnDensityPhase[])originalPhases.Clone();
changedPhases[0] = new SpawnDensityPhase(60f, 9, 8);
constants.SpawnDensity = changedPhases;
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(0f), Is.EqualTo(9));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(0f), Is.EqualTo(8));
}
finally
{
constants.SpawnDensity = originalPhases;
}
}
[Test]
public void PlayerAndBumpRules_ReadChangedSettings()
{
PlayerConstants player = GameplayConstants.Current.Player;
CombatConstants combat = GameplayConstants.Current.Combat;
float originalSpeed = player.BaseMoveSpeed;
float originalPositionMultiplier = combat.PositionDamageMultiplier;
GameObject owner = new("Gameplay Constants Player Consumer Test");
try
{
PlayerStats stats = owner.AddComponent<PlayerStats>();
player.BaseMoveSpeed = 6.25f;
combat.PositionDamageMultiplier = 1.7f;
Assert.That(stats.MoveSpeed, Is.EqualTo(6.25f));
Assert.That(
BumpCombatMath.ApplySideMultiplier(10f, HitSide.Side),
Is.EqualTo(17f).Within(0.0001f));
}
finally
{
player.BaseMoveSpeed = originalSpeed;
combat.PositionDamageMultiplier = originalPositionMultiplier;
Object.DestroyImmediate(owner);
}
}
[Test]
public void ArtifactGaugeRule_ReadsChangedArtifactSettings()
{
ArtifactConstants artifacts = GameplayConstants.Current.Artifacts;
float originalGaugeGain = artifacts.NormalBumpGaugeGain;
GameObject playerObject = new("Gameplay Constants Artifact Consumer Test");
try
{
playerObject.AddComponent<PlayerStats>();
playerObject.AddComponent<Rigidbody2D>();
ActiveArtifactController controller =
playerObject.AddComponent<ActiveArtifactController>();
InvokePrivate(controller, "Awake");
artifacts.NormalBumpGaugeGain = 9f;
controller.AddNormalHitCharge();
Assert.That(controller.CurrentGauge, Is.EqualTo(9f));
}
finally
{
artifacts.NormalBumpGaugeGain = originalGaugeGain;
Object.DestroyImmediate(playerObject);
}
}
[Test]
public void ImportedCatalog_ReferencesEveryCategoryAndPreservesRunArrays()
{
GameplayConstants catalog = AssetDatabase.LoadAssetAtPath<GameplayConstants>(
"Assets/_Project/Constants/Resources/GameplayConstants.asset");
Assert.That(catalog, Is.Not.Null);
Assert.That(catalog.Player, Is.SameAs(AssetDatabase.LoadAssetAtPath<PlayerConstants>(
"Assets/_Project/Constants/Player/PlayerConstants.asset")));
Assert.That(catalog.Combat, Is.SameAs(AssetDatabase.LoadAssetAtPath<CombatConstants>(
"Assets/_Project/Constants/Combat/CombatConstants.asset")));
Assert.That(catalog.Enemies, Is.SameAs(AssetDatabase.LoadAssetAtPath<EnemyConstants>(
"Assets/_Project/Constants/Enemies/EnemyConstants.asset")));
Assert.That(catalog.Items, Is.SameAs(AssetDatabase.LoadAssetAtPath<ItemConstants>(
"Assets/_Project/Constants/Items/ItemConstants.asset")));
Assert.That(catalog.LevelUp, Is.SameAs(AssetDatabase.LoadAssetAtPath<LevelUpConstants>(
"Assets/_Project/Constants/LevelUp/LevelUpConstants.asset")));
Assert.That(catalog.Artifacts, Is.SameAs(AssetDatabase.LoadAssetAtPath<ArtifactConstants>(
"Assets/_Project/Constants/Artifacts/ArtifactConstants.asset")));
Assert.That(catalog.Run, Is.SameAs(AssetDatabase.LoadAssetAtPath<RunConstants>(
"Assets/_Project/Constants/Run/RunConstants.asset")));
Assert.That(catalog.CombatFeedback, Is.SameAs(AssetDatabase.LoadAssetAtPath<CombatFeedbackSettings>(
"Assets/_Project/Constants/Combat/CombatFeedback.asset")));
Assert.That(catalog.Run.MidBossKinds, Is.EqualTo(new[] {
EnemyKind.GreatswordSkeleton,
EnemyKind.NecroGolem,
}));
Assert.That(catalog.Run.ProductionEliteAttacksPerSequence, Is.EqualTo(new[] { 1, 2, 3, 2 }));
Assert.That(catalog.Run.DevelopmentEliteEventTimes, Is.EqualTo(new[] { 36f, 72f, 108f, 144f, 180f, 216f }));
Assert.That(catalog.Run.ProductionEliteEventTimes, Is.EqualTo(new[] { 180f, 360f, 540f, 720f }));
}
private static void InvokePrivate(object target, string methodName)
{
MethodInfo method = target.GetType().GetMethod(methodName, NonPublicInstance);
Assert.That(method, Is.Not.Null, methodName);
method.Invoke(target, null);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4699af2b037e4eb78345dca6281b73cd
@@ -0,0 +1,333 @@
using BumpCombat.Combat;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace BumpCombat.Tests.EditMode
{
public sealed class InteractionFeedbackTests
{
[TestCase("Slime/Slime_Idle", 46)]
[TestCase("Bat/Bat_Idle-shadow-v2", 40)]
[TestCase("ArmoredSkeleton/ArmoredSkeleton_Idle-shadow-v2", 39)]
[TestCase("NecroGolem/NecroGolem_Idle-shadow-v2", 29)]
public void CrowdControlHeadAnchor_IgnoresHiddenRendererAndTracksScaledHead(string asset, int top)
{
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>("Assets/_Project/Art/Characters/" + asset + ".png");
Assert.That(texture, Is.Not.Null);
var sprite = Sprite.Create(texture, new Rect(0, 0, 100, 100), new Vector2(.5f, .5f), 32f);
var target = new GameObject("CC Head Test");
var renderer = target.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.enabled = false;
foreach (float scale in new[] { 1f, 1.25f, 2f })
{
target.transform.position = new Vector3(3f, -2f, 0f);
target.transform.localScale = Vector3.one * scale;
Vector3 actual = target.transform.TransformPoint(CombatFeedback.CalculateStunMarkerLocalPosition(target.transform, renderer));
Assert.That(actual.x, Is.EqualTo(3f).Within(.0001f));
Assert.That(actual.y, Is.EqualTo(-2f + (50f - top) / 32f * scale + (4f + 5f * scale) / 32f).Within(.0001f));
}
Object.DestroyImmediate(target);
Object.DestroyImmediate(sprite);
}
[Test]
public void CrowdControlSpiral_UsesDistinctPalettesAndLoopsEightFrames()
{
Sprite first = CrowdControlMarkerArt.Frame(0f, false);
Assert.That(first, Is.Not.Null);
Assert.That(first.rect.size, Is.EqualTo(new Vector2(32f, 24f)));
Assert.That(first.texture.filterMode, Is.EqualTo(FilterMode.Point));
Assert.That(CrowdControlMarkerArt.Frame(.07f, false).rect.x, Is.EqualTo(32f));
Assert.That(CrowdControlMarkerArt.Frame(.481f, false), Is.SameAs(first));
Assert.That(CrowdControlMarkerArt.Frame(0f, true).texture, Is.Not.SameAs(first.texture));
}
[Test]
public void StrongDashLaunchResult_IsDistinctFromStrongDashIntent()
{
CombatHitResult resisted = new(
null,
null,
true,
HitSide.Front,
1f,
Vector2.right,
1f,
Vector2.zero,
false,
true,
DamageTag.Collision,
"Dash",
true,
true,
false);
CombatHitResult launched = new(
null,
null,
true,
HitSide.Front,
1f,
Vector2.right,
1f,
Vector2.zero,
false,
true,
DamageTag.Collision,
"Dash",
true,
true,
true);
Assert.That(resisted.IsStrongDash, Is.True);
Assert.That(resisted.LaunchSucceeded, Is.False);
Assert.That(launched.IsStrongDash, Is.True);
Assert.That(launched.LaunchSucceeded, Is.True);
}
[TestCase(0.09f, false)]
[TestCase(0.1f, true)]
[TestCase(0.5f, true)]
public void PulseGatherDistance_UsesMinimumStreakThreshold(float distance, bool expectedStreak)
{
Assert.That(CombatFeedback.ShouldShowPulseStreak(distance), Is.EqualTo(expectedStreak));
}
[TestCase(0.49f, false)]
[TestCase(0.5f, true)]
public void KnockbackDistance_UsesMinimumDustThreshold(float distance, bool expectedDust)
{
Assert.That(CombatFeedback.ShouldShowKnockbackDust(distance), Is.EqualTo(expectedDust));
}
[TestCase(true, false, true)]
[TestCase(true, true, false)]
[TestCase(false, false, false)]
public void LaunchResult_SeparatesResistFromDeath(
bool canBeLaunched,
bool isDead,
bool expectedLaunch)
{
Assert.That(
BumpCombatResolver.CanLaunchAfterHit(canBeLaunched, isDead),
Is.EqualTo(expectedLaunch));
Assert.That(
BumpCombatResolver.ShouldShowLaunchResist(canBeLaunched),
Is.EqualTo(!canBeLaunched));
}
[TestCase(false, true, false, true)]
[TestCase(true, true, false, false)]
[TestCase(false, false, false, false)]
[TestCase(false, true, true, false)]
public void LineTransientRecycle_RejectsPendingOrDuplicateEntries(
bool destroyPending,
bool activeRegistrationRemoved,
bool alreadyPooled,
bool expected)
{
Assert.That(
CombatFeedback.CanRecycleLineTransient(
destroyPending,
activeRegistrationRemoved,
alreadyPooled),
Is.EqualTo(expected));
Assert.That(
CombatFeedback.MaxConcurrentHeavyEffects,
Is.EqualTo(12));
}
[Test]
public void WorldStreakRenderer_ResetsRingLoopState()
{
GameObject host = new("Line Renderer State Test");
LineRenderer line = host.AddComponent<LineRenderer>();
line.loop = true;
line.useWorldSpace = false;
CombatFeedback.ConfigureWorldStreakRenderer(
line,
null,
Color.white,
0.04f,
Vector2.zero,
Vector2.right);
Assert.That(line.loop, Is.False);
Assert.That(line.useWorldSpace, Is.True);
Assert.That(line.positionCount, Is.EqualTo(2));
Object.DestroyImmediate(host);
}
[TestCase(false, false, true, true)]
[TestCase(false, true, true, false)]
[TestCase(false, false, false, false)]
[TestCase(true, false, true, false)]
public void LineTransientAcquire_RejectsPendingOrInvalidEntries(
bool unityNull,
bool destroyPending,
bool hasLineRenderer,
bool expected)
{
Assert.That(
CombatFeedback.CanAcquireLineTransient(
unityNull,
destroyPending,
hasLineRenderer),
Is.EqualTo(expected));
}
[Test]
public void StunMarkerAnchor_FallbackKeepsClearanceAndScalesWithCharacter()
{
GameObject target = new("Stun Marker Anchor Test");
SpriteRenderer targetRenderer = target.AddComponent<SpriteRenderer>();
Sprite sprite = Sprite.Create(
Texture2D.whiteTexture,
new Rect(0f, 0f, 1f, 1f),
new Vector2(0.5f, 0.5f),
32f);
targetRenderer.sprite = sprite;
Vector3 runeLocalScale = new(0.11f, 0.035f, 1f);
foreach (float targetScale in new[] { 0.8f, 1f, 1.25f, 1.6f, 2f, 2.5f, 3.5f })
{
Vector3 expectedWorldScale = Vector3.Scale(new Vector3(.5f * targetScale, .5f * targetScale, 1f), runeLocalScale);
target.transform.localScale = Vector3.one * targetScale;
Vector3 localAnchor = CombatFeedback.CalculateStunMarkerLocalPosition(
target.transform,
targetRenderer);
Vector3 worldAnchor = target.transform.TransformPoint(localAnchor);
Assert.That(worldAnchor.x, Is.EqualTo(target.transform.position.x).Within(0.0001f));
Assert.That(
worldAnchor.y,
Is.EqualTo(targetRenderer.bounds.max.y + (4f + 5f * targetScale) / 32f).Within(0.0001f));
Vector3 baseLocalScale = CombatFeedback.CalculateStunMarkerBaseLocalScale(
target.transform.lossyScale);
Vector3 worldScale = Vector3.Scale(
target.transform.lossyScale,
Vector3.Scale(baseLocalScale, runeLocalScale));
Assert.That(worldScale.x, Is.EqualTo(expectedWorldScale.x).Within(0.0001f));
Assert.That(worldScale.y, Is.EqualTo(expectedWorldScale.y).Within(0.0001f));
}
Object.DestroyImmediate(sprite);
Object.DestroyImmediate(target);
}
[TestCase(485)]
[TestCase(1000)]
[TestCase(1515)]
public void StunMarkerSortingOrder_StaysAheadOfTarget(int targetSortingOrder)
{
Assert.That(
CombatFeedback.CalculateStunMarkerSortingOrder(targetSortingOrder),
Is.GreaterThan(targetSortingOrder));
}
[Test]
public void BumpImpactGrades_UseHitSideAndFixedFrameTiming()
{
Assert.That(
CombatFeedback.GetBumpImpactResourcePath(HitSide.Front),
Is.EqualTo("Combat/BumpImpact-Weak-v1"));
Assert.That(
CombatFeedback.GetBumpImpactResourcePath(HitSide.Side),
Is.EqualTo("Combat/BumpImpact-Medium-v1"));
Assert.That(
CombatFeedback.GetBumpImpactResourcePath(HitSide.Back),
Is.EqualTo("Combat/BumpImpact-Strong-v1"));
Assert.That(
CombatFeedback.GetBumpImpactFrameDuration(0),
Is.EqualTo(0.03f));
Assert.That(
CombatFeedback.GetBumpImpactFrameDuration(1),
Is.EqualTo(0.04f));
Assert.That(
CombatFeedback.GetBumpImpactFrameDuration(2),
Is.EqualTo(0.04f));
Assert.That(
CombatFeedback.GetBumpImpactFrameDuration(3),
Is.EqualTo(0.03f));
}
[Test]
public void BumpImpactResources_ContainFourCenteredWhitePixelFrames()
{
foreach (HitSide side in System.Enum.GetValues(typeof(HitSide)))
{
Sprite[] frames = Resources.LoadAll<Sprite>(
CombatFeedback.GetBumpImpactResourcePath(side));
System.Array.Sort(
frames,
(left, right) => System.String.CompareOrdinal(left.name, right.name));
Assert.That(frames, Has.Length.EqualTo(CombatFeedback.BumpImpactFrameCount));
for (int i = 0; i < frames.Length; i++)
{
Assert.That(frames[i].name, Does.EndWith($"_{i}"));
Assert.That(frames[i].rect.width, Is.EqualTo(64f));
Assert.That(frames[i].rect.height, Is.EqualTo(64f));
Assert.That(frames[i].pixelsPerUnit, Is.EqualTo(32f));
Assert.That(frames[i].pivot.x, Is.EqualTo(32f));
Assert.That(frames[i].pivot.y, Is.EqualTo(32f));
Assert.That(frames[i].texture, Is.Not.Null);
}
}
}
[Test]
public void SwordsmanController_WiresCombatStatesToExpectedClipLengths()
{
const string controllerPath =
"Assets/_Project/Art/Characters/Swordsman/Animations/Swordsman.controller";
AnimatorController controller =
AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
Assert.That(controller, Is.Not.Null);
AnimatorState bumpAttack = FindState(controller, "BumpAttack");
AnimatorState artifactUse = FindState(controller, "ArtifactUse");
Assert.That(bumpAttack, Is.Not.Null);
Assert.That(artifactUse, Is.Not.Null);
Assert.That((bumpAttack.motion as AnimationClip).length, Is.EqualTo(0.24f).Within(0.0001f));
Assert.That((artifactUse.motion as AnimationClip).length, Is.EqualTo(0.4f).Within(0.0001f));
Assert.That(bumpAttack.transitions, Has.Length.EqualTo(2));
Assert.That(artifactUse.transitions, Has.Length.EqualTo(2));
foreach (AnimatorStateTransition transition in bumpAttack.transitions)
{
Assert.That(transition.hasExitTime, Is.True);
Assert.That(transition.exitTime, Is.EqualTo(1f));
}
foreach (AnimatorStateTransition transition in artifactUse.transitions)
{
Assert.That(transition.hasExitTime, Is.True);
Assert.That(transition.exitTime, Is.EqualTo(1f));
}
foreach (AnimatorControllerParameter parameter in controller.parameters)
{
Assert.That(parameter.name, Is.Not.EqualTo("BumpAttack"));
Assert.That(parameter.name, Is.Not.EqualTo("ArtifactUse"));
}
}
private static AnimatorState FindState(
AnimatorController controller,
string stateName)
{
foreach (ChildAnimatorState child in controller.layers[0].stateMachine.states)
{
if (child.state.name == stateName)
{
return child.state;
}
}
return null;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7f5ce9a33a0c4d53b0b6d5a0f8a41d2e
timeCreated: 1788100000
@@ -0,0 +1,267 @@
using BumpCombat.Core;
using BumpCombat.Combat;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class PlayerStatsTests
{
private GameObject owner;
private PlayerStats stats;
[SetUp]
public void SetUp()
{
owner = new GameObject("PlayerStats Tests");
stats = owner.AddComponent<PlayerStats>();
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(owner);
}
[Test]
public void Evaluate_AppliesFlatThenSummedIncreasedThenEachMore()
{
stats.AddModifier(new StatModifier(
"flat",
CharacterStat.CollisionDamage,
ModifierOperation.Flat,
10f));
stats.AddModifier(new StatModifier(
"increased-a",
CharacterStat.CollisionDamage,
ModifierOperation.Increased,
0.1f));
stats.AddModifier(new StatModifier(
"increased-b",
CharacterStat.CollisionDamage,
ModifierOperation.Increased,
0.2f));
stats.AddModifier(new StatModifier(
"more-a",
CharacterStat.CollisionDamage,
ModifierOperation.More,
0.1f));
stats.AddModifier(new StatModifier(
"more-b",
CharacterStat.CollisionDamage,
ModifierOperation.More,
0.2f));
Assert.That(
stats.Evaluate(CharacterStat.CollisionDamage, 100f),
Is.EqualTo(188.76f).Within(0.001f));
}
[Test]
public void CalculateDamage_CollisionModifierDoesNotAffectSpell()
{
stats.AddModifier(new StatModifier(
"collision-increased",
CharacterStat.CollisionDamage,
ModifierOperation.Increased,
0.5f));
Assert.That(
stats.CalculateDamage(10f, DamageTag.Collision),
Is.EqualTo(15f).Within(0.001f));
Assert.That(
stats.CalculateDamage(10f, DamageTag.Spell),
Is.EqualTo(10f).Within(0.001f));
}
[Test]
public void CalculateDamage_AppliesSharedAttackDamageBeforeCollisionDamage()
{
stats.AddModifier(new StatModifier(
"shared-attack-increased",
CharacterStat.AttackDamage,
ModifierOperation.Increased,
0.1f));
stats.AddModifier(new StatModifier(
"collision-increased",
CharacterStat.CollisionDamage,
ModifierOperation.Increased,
0.5f));
Assert.That(
stats.CalculateDamage(10f, DamageTag.Collision),
Is.EqualTo(16.5f).Within(0.001f));
Assert.That(
stats.CalculateDamage(10f, DamageTag.Spell),
Is.EqualTo(11f).Within(0.001f));
}
[Test]
public void CalculateDamage_PreservesFractionsUntilTheFinalHit()
{
stats.AddModifier(new StatModifier(
"damage-flat",
CharacterStat.CollisionDamage,
ModifierOperation.Flat,
0.8f));
stats.AddModifier(new StatModifier(
"damage-increased",
CharacterStat.CollisionDamage,
ModifierOperation.Increased,
0.25f));
stats.AddModifier(new StatModifier(
"damage-more",
CharacterStat.CollisionDamage,
ModifierOperation.More,
0.2f));
float calculatedDamage = stats.CalculateDamage(10f, DamageTag.Collision);
Assert.That(calculatedDamage, Is.EqualTo(16.2f).Within(0.001f));
Assert.That(
stats.Evaluate(CharacterStat.CollisionDamage, 10f),
Is.EqualTo(calculatedDamage).Within(0.001f),
"Damage stat evaluation and the public damage API must share the same calculation.");
Assert.That(DamageCalculator.FinalizeDamage(calculatedDamage), Is.EqualTo(16f));
Assert.That(
stats.CalculateDamage(10.9f, DamageTag.Spell),
Is.EqualTo(10.9f).Within(0.001f),
"Spell damage has no collision modifiers and keeps its intermediate fraction.");
}
[Test]
public void CalculateIncomingDamage_PreservesFractionsUntilThePlayerHit()
{
stats.AddModifier(new StatModifier(
"incoming-flat",
CharacterStat.IncomingDamage,
ModifierOperation.Flat,
0.8f));
stats.AddModifier(new StatModifier(
"incoming-increased",
CharacterStat.IncomingDamage,
ModifierOperation.Increased,
0.25f));
stats.AddModifier(new StatModifier(
"incoming-more",
CharacterStat.IncomingDamage,
ModifierOperation.More,
0.2f));
float incomingDamage = stats.CalculateIncomingDamage(10f);
Assert.That(incomingDamage, Is.EqualTo(16.2f).Within(0.001f));
Assert.That(
stats.Evaluate(CharacterStat.IncomingDamage, 10f),
Is.EqualTo(incomingDamage).Within(0.001f));
Assert.That(DamageCalculator.FinalizeDamage(incomingDamage), Is.EqualTo(16f));
}
[Test]
public void CalculateDamage_PreservesFractionsAcrossMoreStacks()
{
StatModifier more = new(
"damage-more-stacked",
CharacterStat.CollisionDamage,
ModifierOperation.More,
0.2f,
3);
stats.AddModifier(more);
stats.AddModifier(more);
stats.AddModifier(more);
float calculatedDamage = stats.CalculateDamage(10f, DamageTag.Collision);
Assert.That(calculatedDamage, Is.EqualTo(17.28f).Within(0.001f));
Assert.That(
DamageCalculator.FinalizeDamage(calculatedDamage),
Is.EqualTo(17),
"10 × 1.2 × 1.2 × 1.2 stays fractional until the final hit.");
}
[Test]
public void Evaluate_IncomingDamageMoreModifierReducesReceivedDamage()
{
stats.AddModifier(new StatModifier(
"artifact.pulse.damage-reduction",
CharacterStat.IncomingDamage,
ModifierOperation.More,
-0.35f));
Assert.That(
stats.Evaluate(CharacterStat.IncomingDamage, 20f),
Is.EqualTo(13f).Within(0.001f));
}
[Test]
public void AddModifier_StopsAtMaxStacksAndReportsCurrentStacks()
{
StatModifier modifier = new(
"level-up.move-speed",
CharacterStat.MoveSpeed,
ModifierOperation.Increased,
0.05f,
2);
Assert.That(stats.AddModifier(modifier), Is.True);
Assert.That(stats.AddModifier(modifier), Is.True);
Assert.That(stats.AddModifier(modifier), Is.False);
Assert.That(
stats.GetStackCount("level-up.move-speed", CharacterStat.MoveSpeed),
Is.EqualTo(2));
AppliedStatModifier applied = stats.GetAppliedModifiers()[0];
Assert.That(applied.CurrentStacks, Is.EqualTo(2));
Assert.That(applied.MaxStacks, Is.EqualTo(2));
}
[Test]
public void RemoveModifiersFromSource_RemovesEveryStackFromThatSource()
{
StatModifier modifier = new(
"temporary-buff",
CharacterStat.ArtifactGaugeGain,
ModifierOperation.Increased,
0.1f,
3);
stats.AddModifier(modifier);
stats.AddModifier(modifier);
Assert.That(
stats.RemoveModifiersFromSource("temporary-buff"),
Is.EqualTo(2));
Assert.That(
stats.GetStackCount(
"temporary-buff",
CharacterStat.ArtifactGaugeGain),
Is.Zero);
Assert.That(
stats.Evaluate(CharacterStat.ArtifactGaugeGain, 5f),
Is.EqualTo(5f).Within(0.001f));
}
[Test]
public void BaseProperties_UseConfiguredDefaultsAndModifiers()
{
stats.AddModifier(new StatModifier(
"level-up.max-health",
CharacterStat.MaxHealth,
ModifierOperation.Flat,
10f));
stats.AddModifier(new StatModifier(
"level-up.move-speed",
CharacterStat.MoveSpeed,
ModifierOperation.Increased,
0.05f));
Assert.That(stats.MaxHealth, Is.EqualTo(110f).Within(0.001f));
Assert.That(stats.MoveSpeed, Is.EqualTo(3.15f).Within(0.001f));
}
[Test]
public void CharacterStat_KeepsExistingSerializedValuesAndAppendsAttackDamage()
{
Assert.That((int)CharacterStat.CollisionDamage, Is.EqualTo(0));
Assert.That((int)CharacterStat.IncomingDamage, Is.EqualTo(8));
Assert.That((int)CharacterStat.AttackDamage, Is.EqualTo(9));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1c70dd0752514ca2938de65fa7ba2953
@@ -0,0 +1,558 @@
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Spawning;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class StageRosterDefinitionTests
{
private static readonly RosterSpec[] AmbientRoster =
{
new("Bat", EnemyKind.Bat, EnemyRole.Crowd),
new("Slime", EnemyKind.Slime, EnemyRole.Crowd),
new("Skeleton", EnemyKind.Skeleton, EnemyRole.Normal),
new("Necrofire", EnemyKind.Necrofire, EnemyRole.Normal),
new("SkeletonArcher", EnemyKind.SkeletonArcher, EnemyRole.Normal),
};
private static readonly RosterSpec[] EventRoster =
{
new("ArmoredSkeleton", EnemyKind.ArmoredSkeleton, EnemyRole.Normal),
new("Werewolf", EnemyKind.Werewolf, EnemyRole.Normal),
new("Werebear", EnemyKind.Werebear, EnemyRole.Normal),
new("GreatswordSkeleton", EnemyKind.GreatswordSkeleton, EnemyRole.Normal),
new("NecroGolem", EnemyKind.NecroGolem, EnemyRole.Normal),
new("Necromancer", EnemyKind.Necromancer, EnemyRole.Normal),
};
[Test]
public void RequestedStageDefinitions_UseTheFiveAmbientRolesAndEventRoster()
{
foreach (RosterSpec spec in AmbientRoster)
{
EnemyDefinition definition = LoadDefinition(spec.Name);
Assert.That(definition.Kind, Is.EqualTo(spec.Kind), spec.Name);
Assert.That(definition.Role, Is.EqualTo(spec.Role), spec.Name);
Assert.That(definition.MaxHealth, Is.GreaterThan(0f), spec.Name);
Assert.That(definition.MoveSpeed, Is.GreaterThan(0f), spec.Name);
Assert.That(definition.ExperienceValue, Is.GreaterThanOrEqualTo(0), spec.Name);
}
foreach (RosterSpec spec in EventRoster)
{
EnemyDefinition definition = LoadDefinition(spec.Name);
Assert.That(definition.Kind, Is.EqualTo(spec.Kind), spec.Name);
Assert.That(definition.Role, Is.EqualTo(spec.Role), spec.Name);
Assert.That(definition.MaxHealth, Is.GreaterThan(0f), spec.Name);
Assert.That(definition.MoveSpeed, Is.GreaterThan(0f), spec.Name);
Assert.That(definition.AttackPatternCount, Is.GreaterThan(0), spec.Name);
}
Assert.That(
LoadDefinition("Necrofire").IsRanged,
Is.True,
"Necrofire must remain a normal ranged enemy.");
Assert.That(
LoadDefinition("SkeletonArcher").IsRanged,
Is.True,
"Skeleton Archer must remain a normal ranged enemy.");
Assert.That(
LoadDefinition("Necromancer").IsRanged,
Is.True,
"Necromancer must use ranged attack behavior.");
}
[Test]
public void StagePrefabs_UseRequestedCollisionRolesAndKeepLegacyLancer()
{
foreach (RosterSpec spec in AmbientRoster)
{
GameObject prefab = LoadPrefab(spec.Name);
AssertVisualMaterial(prefab, spec.Name);
Collider2D collider = prefab.GetComponent<Collider2D>();
Assert.That(collider, Is.Not.Null, spec.Name);
Assert.That(collider.isTrigger, Is.EqualTo(spec.Role == EnemyRole.Crowd), spec.Name);
Assert.That(prefab.transform.localScale.x, Is.EqualTo(spec.Role == EnemyRole.Crowd ? 1f : 1.25f).Within(0.0001f), spec.Name);
Assert.That(prefab.transform.localScale.y, Is.EqualTo(spec.Role == EnemyRole.Crowd ? 1f : 1.25f).Within(0.0001f), spec.Name);
}
foreach (RosterSpec spec in EventRoster)
{
GameObject prefab = LoadPrefab(spec.Name);
AssertVisualMaterial(prefab, spec.Name);
Assert.That(prefab.GetComponent<Collider2D>(), Is.Not.Null, spec.Name);
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1.25f).Within(0.0001f), spec.Name);
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1.25f).Within(0.0001f), spec.Name);
}
Assert.That(
LoadDefinition("Lancer"),
Is.Not.Null,
"The legacy Lancer definition must remain available for its motion regressions.");
Assert.That(
AssetDatabase.LoadAssetAtPath<GameObject>(
"Assets/_Project/Prefabs/Enemies/Lancer.prefab"),
Is.Not.Null,
"The legacy Lancer prefab must remain available for its motion regressions.");
Assert.That(
AssetDatabase.LoadAssetAtPath<GameObject>(
"Assets/_Project/Prefabs/Enemies/Warlock.prefab"),
Is.Not.Null,
"The legacy Warlock prefab must remain available for status regressions.");
}
[Test]
public void PlayerPrefab_UsesTheSameEnlargedVisualAndColliderScale()
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
"Assets/_Project/Prefabs/Player/Player.prefab");
Assert.That(prefab, Is.Not.Null);
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1.25f).Within(0.0001f));
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1.25f).Within(0.0001f));
Assert.That(prefab.transform.localScale.z, Is.EqualTo(1f).Within(0.0001f));
CircleCollider2D collider = prefab.GetComponent<CircleCollider2D>();
Assert.That(collider, Is.Not.Null);
Assert.That(
collider.radius * prefab.transform.localScale.x,
Is.EqualTo(0.275f).Within(0.0001f));
}
[Test]
public void SkeletonAttack_UsesVisibleSwordArcAndLocalDirectionalGeometry()
{
EnemyDefinition definition = LoadDefinition("Skeleton");
EnemyAttackPattern pattern = definition.GetAttackPattern(0);
Assert.That(pattern.WarningDuration, Is.EqualTo(0.7f).Within(0.0001f));
Assert.That(pattern.ActiveDuration, Is.EqualTo(0.15f).Within(0.0001f));
Assert.That(pattern.AnimationLeadTime, Is.EqualTo(0.375f).Within(0.0001f));
Assert.That(pattern.DirectionMode, Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot));
Assert.That(pattern.GeometryScaleMode, Is.EqualTo(EnemyAttackGeometryScaleMode.RootScale));
Assert.That(pattern.AttackRange, Is.EqualTo(0.85f).Within(0.0001f));
Assert.That(pattern.AttackLength, Is.EqualTo(0.8f).Within(0.0001f));
Assert.That(pattern.AttackWidth, Is.EqualTo(0.6f).Within(0.0001f));
}
[Test]
public void RootWeaponPatterns_UseLocalScaleAndSourceContactWindows()
{
AssertHorizontalRootPattern("Skeleton", 0, 0.15f, 0.375f);
AssertHorizontalRootPattern("ArmoredSkeleton", 0, 0.25f, 0.5f);
AssertHorizontalRootPattern("ArmoredSkeleton", 1, 0.35f, 0.5f);
AssertHorizontalRootPattern("GreatswordSkeleton", 0, 0.22f, 0.625f);
AssertHorizontalRootPattern("GreatswordSkeleton", 1, 0.35f, 0.75f);
AssertHorizontalRootPattern("GreatswordSkeleton", 2, 0.25f, 0.5f);
AssertHorizontalRootPattern("Werewolf", 0, 0.25f, 0.625f);
AssertHorizontalRootPattern("Werewolf", 1, 0.625f, 0.875f);
AssertHorizontalRootPattern("Werebear", 0, 0.25f, 0.625f);
AssertHorizontalRootPattern("Werebear", 1, 0.875f, 0.5f);
AssertHorizontalRootPattern("Werebear", 2, 0.375f, 0.625f);
AssertHorizontalRootPattern("Necromancer", 0, 0.375f, 0.625f);
EnemyAttackPattern bearSecond =
LoadDefinition("Werebear").GetAttackPattern(1);
Assert.That(bearSecond.ContactPhaseCount, Is.EqualTo(2));
Assert.That(bearSecond.IsContactWindowActive(0f, 0.875f), Is.True);
Assert.That(bearSecond.IsContactWindowActive(0.3f, 0.875f), Is.False);
Assert.That(bearSecond.IsContactWindowActive(0.625f, 0.875f), Is.True);
Assert.That(bearSecond.IsContactWindowActive(0.7f, 0.875f), Is.True);
Assert.That(bearSecond.IsContactWindowActive(0.875f, 0.875f), Is.False);
}
[Test]
public void NecroGolemRootGroundContracts_StayInsideMeasuredContactFootprint()
{
EnemyDefinition definition = LoadDefinition("NecroGolem");
EnemyAttackPattern first = definition.GetAttackPattern(0);
EnemyAttackPattern spikes = definition.GetAttackPattern(1);
EnemyAttackPattern flame = definition.GetAttackPattern(2);
AssertRootGroundPattern(first, 0.75f, 0.6f, new Vector2(0f, -0.15f), 0.75f);
AssertRootGroundPattern(spikes, 1.1f, 0.6f, new Vector2(0f, -0.2f), 0.75f);
AssertRootGroundPattern(flame, 1.05f, 0.55f, new Vector2(0.05f, -0.05f), 0.625f);
Assert.That(first.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f));
Assert.That(spikes.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f));
Assert.That(flame.ActiveDuration, Is.EqualTo(0.25f).Within(0.0001f));
Vector2 midOrigin = EnemyAttack.GetAttackOrigin(
Vector2.zero,
Vector2.right,
flame.AttackOriginOffset * 2f,
flame.DirectionMode);
Assert.That(midOrigin, Is.EqualTo(new Vector2(0.1f, -0.1f)));
Assert.That(flame.AttackLength * 2f, Is.EqualTo(2.1f).Within(0.0001f));
Assert.That(flame.AttackWidth * 2f, Is.EqualTo(1.1f).Within(0.0001f));
Assert.That(flame.IsContactWindowActive(0.25f, 0.25f), Is.False);
}
[Test]
public void EventAndSummonedTuning_MultipliesTheEnlargedPrefabScale()
{
RunEventEnemyTuning tuning = RunEventEnemyTuning.Create(
EnemyKind.Skeleton,
1f,
0,
1.6f,
Color.white,
1f,
1f,
1f,
1f,
1f,
1f,
1f,
1f,
1f,
1,
1f,
1f,
1,
1f,
true);
GameObject eventObject = Object.Instantiate(LoadPrefab("Skeleton"));
GameObject summonObject = Object.Instantiate(LoadPrefab("Skeleton"));
try
{
eventObject.GetComponent<EnemyController>().ConfigureRunEventEnemy(
RunTimedEvent.Elite,
tuning);
summonObject.GetComponent<EnemyController>().ConfigureSummonedEnemy(
null,
tuning,
true);
Assert.That(eventObject.transform.localScale.x, Is.EqualTo(2f).Within(0.0001f));
Assert.That(summonObject.transform.localScale.x, Is.EqualTo(2f).Within(0.0001f));
}
finally
{
Object.DestroyImmediate(eventObject);
Object.DestroyImmediate(summonObject);
}
}
[Test]
public void StagePrefabs_ImportWithFrozenRootRotationAndExpectedPhysicsDefaults()
{
string[] prefabNames =
{
"Bat",
"Slime",
"Skeleton",
"Necrofire",
"SkeletonArcher",
"ArmoredSkeleton",
"Werewolf",
"Werebear",
"GreatswordSkeleton",
"NecroGolem",
"Necromancer",
};
foreach (string name in prefabNames)
{
Rigidbody2D body = LoadPrefab(name).GetComponent<Rigidbody2D>();
Assert.That(body, Is.Not.Null, name);
Assert.That(
body.constraints & RigidbodyConstraints2D.FreezeRotation,
Is.EqualTo(RigidbodyConstraints2D.FreezeRotation),
$"{name} must keep its root upright after prefab import.");
Assert.That(body.bodyType, Is.EqualTo(RigidbodyType2D.Dynamic), name);
Assert.That(body.simulated, Is.True, name);
Assert.That(body.gravityScale, Is.EqualTo(0f), name);
}
}
[Test]
public void MidBossDefinitions_ExposeThreeDistinctAttack01To03Patterns()
{
AssertMidBossPatterns("GreatswordSkeleton");
AssertMidBossPatterns("NecroGolem");
}
[Test]
public void NecromancerDefinition_ExposesThreeCombatPatternsAndTwoSummonTimers()
{
EnemyDefinition definition = LoadDefinition("Necromancer");
Assert.That(definition.AttackPatternCount, Is.EqualTo(3));
HashSet<string> animationStates = new();
int targetCirclePatterns = 0;
for (int i = 0; i < definition.AttackPatternCount; i++)
{
EnemyAttackPattern pattern = definition.GetAttackPattern(i);
Assert.That(pattern.AnimationState, Is.Not.Null.And.Not.Empty, $"pattern {i}");
animationStates.Add(pattern.AnimationState);
Assert.That(pattern.WarningDuration, Is.GreaterThan(0f), $"pattern {i}");
Assert.That(pattern.ActiveDuration, Is.GreaterThan(0f), $"pattern {i}");
Assert.That(pattern.RecoveryDuration, Is.GreaterThan(0f), $"pattern {i}");
if (pattern.AttackShape == EnemyAttackShape.TargetCircle)
{
targetCirclePatterns++;
}
}
Assert.That(
targetCirclePatterns,
Is.GreaterThanOrEqualTo(2),
"The single and multi AOE entries must both be represented.");
Assert.That(
animationStates.Count,
Is.GreaterThanOrEqualTo(2),
"The melee and AOE behaviors must expose at least two animation states; the two AOE variants may share Attack02.");
NecromancerBossController boss =
LoadPrefab("Necromancer").GetComponent<NecromancerBossController>();
Assert.That(boss, Is.Not.Null);
EnemyConstants enemyTuning = GameplayConstants.Current.Enemies;
Assert.That(
enemyTuning.GeneralSummonCooldown,
Is.GreaterThanOrEqualTo(0.5f));
Assert.That(
enemyTuning.CrowdSummonCooldown,
Is.GreaterThanOrEqualTo(0.5f));
}
[Test]
public void NecrofireDefinition_UsesLongWarningAndShortFixedRayWindow()
{
EnemyDefinition definition = LoadDefinition("Necrofire");
EnemyAttackPattern pattern = definition.GetAttackPattern(0);
Assert.That(definition.WarningDuration, Is.EqualTo(1.5f).Within(0.0001f));
Assert.That(definition.ActiveDuration, Is.EqualTo(0.375f).Within(0.0001f));
Assert.That(definition.RecoveryDuration, Is.EqualTo(1f).Within(0.0001f));
Assert.That(definition.AttackAnimationLeadTime, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(definition.ProjectileSpeed, Is.Zero);
Assert.That(definition.ProjectileRadius, Is.Zero);
Assert.That(pattern.WarningDuration, Is.EqualTo(1.5f).Within(0.0001f));
Assert.That(pattern.ActiveDuration, Is.EqualTo(0.375f).Within(0.0001f));
Assert.That(pattern.RecoveryDuration, Is.EqualTo(1f).Within(0.0001f));
Assert.That(pattern.AnimationLeadTime, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(pattern.AttackShape, Is.EqualTo(EnemyAttackShape.Box));
Assert.That(pattern.AttackLength, Is.EqualTo(4f).Within(0.0001f));
Assert.That(pattern.AttackWidth, Is.EqualTo(0.5f).Within(0.0001f));
}
[Test]
public void EliteTuning_RotatesRequestedKindsAcrossSixTimedEvents()
{
GameObject owner = new("Stage Roster Tuning Test");
try
{
SpawnDirector director = owner.AddComponent<SpawnDirector>();
EnemyKind[] expected =
{
EnemyKind.ArmoredSkeleton,
EnemyKind.Werewolf,
EnemyKind.Werebear,
EnemyKind.ArmoredSkeleton,
EnemyKind.Werewolf,
EnemyKind.Werebear,
};
for (int i = 0; i < expected.Length; i++)
{
Assert.That(
director.GetEventEnemyTuning(RunTimedEvent.Elite, i).PrefabKind,
Is.EqualTo(expected[i]),
$"elite ordinal {i}");
}
EnemyKind midBossKind =
director.GetEventEnemyTuning(RunTimedEvent.MidBoss).PrefabKind;
Assert.That(
midBossKind == EnemyKind.GreatswordSkeleton
|| midBossKind == EnemyKind.NecroGolem,
Is.True,
"The 10 minute mid-boss must be selected from the two requested kinds.");
Assert.That(
director.GetEventEnemyTuning(RunTimedEvent.FinalBoss).PrefabKind,
Is.EqualTo(EnemyKind.Necromancer));
}
finally
{
Object.DestroyImmediate(owner);
}
}
[Test]
public void StageSchedule_UsesFormalAndFiveTimesDebugTimes()
{
Assert.That(
RunManager.GetEliteEventTimes(false),
Is.EqualTo(new[] { 180f, 360f, 540f, 720f, 900f, 1080f }));
Assert.That(
RunManager.GetEliteEventTimes(true),
Is.EqualTo(new[] { 36f, 72f, 108f, 144f, 180f, 216f }));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.MidBoss, false),
Is.EqualTo(600f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.MidBoss, true),
Is.EqualTo(120f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.FinalBoss, false),
Is.EqualTo(1200f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.FinalBoss, true),
Is.EqualTo(240f));
Assert.That(
RunManager.GetEliteEventTimes(RunMode.Production),
Is.EqualTo(new[] { 180f, 360f, 540f, 720f }));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.MidBoss, RunMode.Production),
Is.EqualTo(600f));
Assert.That(
RunManager.GetEventTime(RunTimedEvent.FinalBoss, RunMode.Production),
Is.EqualTo(900f));
Assert.That(
RunManager.GetProgressionTime(600f, useDebugTimes: false),
Is.EqualTo(600f));
}
[Test]
public void AmbientTargets_PreserveFiveMinuteBandsAndSeparateRoles()
{
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(0f), Is.EqualTo(3));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(0f), Is.EqualTo(3));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(60f), Is.EqualTo(6));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(60f), Is.EqualTo(4));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(180f), Is.EqualTo(12));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(180f), Is.EqualTo(4));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(300f), Is.EqualTo(19));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(300f), Is.EqualTo(5));
Assert.That(SpawnDirector.TargetCrowdAliveCountAt(600f), Is.EqualTo(27));
Assert.That(SpawnDirector.TargetNormalAliveCountAt(600f), Is.EqualTo(5));
Assert.That(SpawnDirector.TargetAliveCountAt(600f), Is.EqualTo(32));
}
private static EnemyDefinition LoadDefinition(string name)
{
EnemyDefinition definition = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
$"Assets/_Project/Constants/Enemies/{name}.asset");
Assert.That(definition, Is.Not.Null, name);
return definition;
}
private static void AssertHorizontalRootPattern(
string name,
int index,
float activeDuration,
float animationLeadTime)
{
EnemyAttackPattern pattern = LoadDefinition(name).GetAttackPattern(index);
Assert.That(
pattern.DirectionMode,
Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot),
$"{name} pattern {index} direction");
Assert.That(
pattern.GeometryScaleMode,
Is.EqualTo(EnemyAttackGeometryScaleMode.RootScale),
$"{name} pattern {index} scale");
Assert.That(
pattern.ActiveDuration,
Is.EqualTo(activeDuration).Within(0.0001f),
$"{name} pattern {index} active");
Assert.That(
pattern.AnimationLeadTime,
Is.EqualTo(animationLeadTime).Within(0.0001f),
$"{name} pattern {index} lead");
Assert.That(
pattern.AttackRange,
Is.GreaterThan(0f),
$"{name} pattern {index} range");
if (pattern.AttackShape == EnemyAttackShape.Box)
{
Assert.That(pattern.AttackLength, Is.GreaterThan(0f), name);
}
else
{
Assert.That(pattern.AttackRadius, Is.GreaterThan(0f), name);
Assert.That(
pattern.AttackWidth,
Is.GreaterThan(0f),
$"{name} pattern {index} ground band");
}
}
private static void AssertRootGroundPattern(
EnemyAttackPattern pattern,
float length,
float width,
Vector2 offset,
float animationLeadTime)
{
Assert.That(pattern.AttackShape, Is.EqualTo(EnemyAttackShape.Box));
Assert.That(
pattern.DirectionMode,
Is.EqualTo(EnemyAttackDirectionMode.HorizontalRoot));
Assert.That(
pattern.GeometryScaleMode,
Is.EqualTo(EnemyAttackGeometryScaleMode.RootScale));
Assert.That(pattern.AttackLength, Is.EqualTo(length).Within(0.0001f));
Assert.That(pattern.AttackWidth, Is.EqualTo(width).Within(0.0001f));
Assert.That(pattern.AttackOriginOffset, Is.EqualTo(offset));
Assert.That(
pattern.AnimationLeadTime,
Is.EqualTo(animationLeadTime).Within(0.0001f));
}
private static GameObject LoadPrefab(string name)
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
$"Assets/_Project/Prefabs/Enemies/{name}.prefab");
Assert.That(prefab, Is.Not.Null, name);
return prefab;
}
private static void AssertVisualMaterial(GameObject prefab, string name)
{
SpriteRenderer renderer = prefab.GetComponent<SpriteRenderer>();
Assert.That(renderer, Is.Not.Null, name);
Assert.That(renderer.sprite, Is.Not.Null, name);
Assert.That(renderer.sharedMaterial, Is.Not.Null, name);
Assert.That(renderer.sharedMaterial.shader, Is.Not.Null, name);
Assert.That(renderer.sharedMaterial.shader.isSupported, Is.True, name);
}
private static void AssertMidBossPatterns(string name)
{
EnemyDefinition definition = LoadDefinition(name);
Assert.That(definition.AttackPatternCount, Is.EqualTo(3), name);
HashSet<string> animationStates = new();
for (int i = 0; i < 3; i++)
{
EnemyAttackPattern pattern = definition.GetAttackPattern(i);
Assert.That(pattern.AnimationState, Is.Not.Null.And.Not.Empty, $"{name} pattern {i}");
Assert.That(animationStates.Add(pattern.AnimationState), Is.True, $"{name} pattern {i}");
Assert.That(pattern.WarningDuration, Is.GreaterThan(0f), $"{name} pattern {i}");
Assert.That(pattern.ActiveDuration, Is.GreaterThan(0f), $"{name} pattern {i}");
Assert.That(pattern.RecoveryDuration, Is.GreaterThan(0f), $"{name} pattern {i}");
}
}
private readonly struct RosterSpec
{
public RosterSpec(string name, EnemyKind kind, EnemyRole role)
{
Name = name;
Kind = kind;
Role = role;
}
public string Name { get; }
public EnemyKind Kind { get; }
public EnemyRole Role { get; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4aaf0c1d1f954b7f8bf4e2f4c7c94e61
@@ -0,0 +1,131 @@
using System.Linq;
using System.Reflection;
using BumpCombat.Enemies;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class SummonAnimationContractTests
{
[TestCase(false, 1.25f)]
[TestCase(true, 1.875f)]
public void SummonCircle_FollowsShadowCenterAcrossFramesAndMovement(bool flip, float scale)
{
var sprites = AssetDatabase.LoadAllAssetsAtPath(
"Assets/_Project/Art/Characters/Necromancer/Necromancer_Summon-shadow-v2.png")
.OfType<Sprite>().ToArray();
Assert.That(sprites.Length, Is.EqualTo(10));
var owner = new GameObject("Summon anchor test");
StageEnemyEffectVisual effect = null;
try
{
var body = owner.AddComponent<SpriteRenderer>();
body.sprite = sprites[0];
body.flipX = flip;
owner.transform.localScale = new Vector3(scale, scale, 1f);
effect = StageEnemyEffectVisual.PlaySummon(owner, owner.transform.position, 1.25f);
var lateUpdate = typeof(StageEnemyEffectVisual).GetMethod("LateUpdate",
BindingFlags.NonPublic | BindingFlags.Instance);
for (int i = 0; i < sprites.Length; i++)
{
body.sprite = sprites[i];
owner.transform.position = new Vector3(i * .3f, i * -.2f, 0f);
lateUpdate.Invoke(effect, null);
// Measured source shadow center, relative to the imported 50px pivot.
Vector3 expected = owner.transform.position
+ new Vector3((flip ? -.5f : .5f) / 32f, -7.5f / 32f) * scale;
Assert.That(Vector3.Distance(effect.transform.position, expected), Is.LessThan(.0001f));
Assert.That(effect.transform.localScale, Is.EqualTo(Vector3.one));
}
}
finally
{
if (effect != null) Object.DestroyImmediate(effect.gameObject);
Object.DestroyImmediate(owner);
}
}
[TestCase("Skeleton", 5)]
[TestCase("GreatswordSkeleton", 5)]
[TestCase("ArmoredSkeleton", 5)]
[TestCase("SkeletonArcher", 5)]
[TestCase("Necromancer", 10)]
[TestCase("Bat", 4)]
[TestCase("Slime", 4)]
[TestCase("Necrofire", 6)]
[TestCase("Werewolf", 4)]
[TestCase("Werebear", 4)]
[TestCase("NecroGolem", 6)]
public void Summon_PreservesEveryAuthoredFrameAndCannotExitEarly(string name, int frames)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
$"Assets/_Project/Prefabs/Enemies/{name}.prefab");
var controller = prefab.GetComponent<Animator>().runtimeAnimatorController as AnimatorController;
Assert.That(controller, Is.Not.Null);
var summon = controller.layers[0].stateMachine.states
.Select(child => child.state).Single(state => state.name == "Summon");
var clip = summon.motion as AnimationClip;
Assert.That(clip, Is.Not.Null);
Assert.That(clip.frameRate, Is.EqualTo(8f));
Assert.That(clip.length, Is.EqualTo(frames / 8f).Within(.0001f));
Assert.That(clip.isLooping, Is.False);
var binding = AnimationUtility.GetObjectReferenceCurveBindings(clip)
.Single(b => b.type == typeof(SpriteRenderer) && b.propertyName == "m_Sprite");
var keys = AnimationUtility.GetObjectReferenceCurve(clip, binding);
Assert.That(keys.Length, Is.EqualTo(frames));
for (int i = 0; i < keys.Length; i++)
{
Assert.That(keys[i].value, Is.Not.Null);
Assert.That(keys[i].time, Is.EqualTo(i / 8f).Within(.0001f));
var texturePath = AssetDatabase.GetAssetPath(keys[i].value);
var importer = AssetImporter.GetAtPath(texturePath) as TextureImporter;
Assert.That(importer.filterMode, Is.EqualTo(FilterMode.Point));
Assert.That(importer.spritePixelsPerUnit, Is.EqualTo(32f));
if (name == "GreatswordSkeleton" || name == "ArmoredSkeleton")
{
var sprite = (Sprite)keys[i].value;
var masks = Resources.LoadAll<Sprite>(
"Enemies/Protection-v2/" + sprite.texture.name + "-outline");
Assert.That(masks.Any(mask => mask.rect == sprite.rect), Is.True,
"Protected summons must retain their outline during every rise frame.");
}
}
Assert.That(summon.transitions, Is.Not.Empty);
foreach (var transition in summon.transitions)
{
Assert.That(transition.hasExitTime, Is.True);
Assert.That(transition.exitTime, Is.EqualTo(1f));
Assert.That(transition.duration, Is.Zero);
// Attack exits are deliberately left independent of the summon clock.
foreach (var child in controller.layers[0].stateMachine.states)
if (child.state != summon)
Assert.That(child.state.transitions.Contains(transition), Is.False);
}
}
[TestCase("Bat")]
[TestCase("Slime")]
[TestCase("Necrofire")]
[TestCase("Werewolf")]
[TestCase("Werebear")]
[TestCase("NecroGolem")]
public void DerivedRise_ReusesOriginalDeathSpritesInReverse(string name)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
$"Assets/_Project/Prefabs/Enemies/{name}.prefab");
var controller = prefab.GetComponent<Animator>().runtimeAnimatorController as AnimatorController;
var states = controller.layers[0].stateMachine.states;
var death = (AnimationClip)states.Single(s => s.state.name == "Death").state.motion;
var rise = (AnimationClip)states.Single(s => s.state.name == "Summon").state.motion;
var deathBinding = AnimationUtility.GetObjectReferenceCurveBindings(death).Single();
var riseBinding = AnimationUtility.GetObjectReferenceCurveBindings(rise).Single();
var deathFrames = AnimationUtility.GetObjectReferenceCurve(death, deathBinding);
var riseFrames = AnimationUtility.GetObjectReferenceCurve(rise, riseBinding);
Assert.That(riseFrames.Select(f => f.value),
Is.EqualTo(deathFrames.Reverse().Select(f => f.value)));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 949061bed93945da8db4eb3563b5185b
@@ -0,0 +1,128 @@
using System;
using System.IO;
using BumpCombat.Progression;
using NUnit.Framework;
namespace BumpCombat.Tests
{
public sealed class UserProfileStoreTests
{
private string testDirectory;
private string profilePath;
[SetUp]
public void SetUp()
{
testDirectory = Path.Combine(
Path.GetTempPath(),
"BumpCombat-profile-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(testDirectory);
profilePath = Path.Combine(testDirectory, UserProfileStore.DefaultFileName);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(testDirectory))
{
Directory.Delete(testDirectory, true);
}
}
[Test]
public void MissionTotalsAndSkinFields_RoundTripWithStableSteamIntMappings()
{
UserProfileStore store = new(profilePath);
Assert.That(store.CanSave, Is.True);
store.Profile.unlockedSkinIds.Add("skin.future.test");
store.Profile.selectedSkinId = "skin.future.test";
Assert.That(store.IncrementMissionCompletion(MissionIds.FirstSweep), Is.True);
UserProfileStore loaded = new(profilePath);
Assert.That(loaded.Profile.schemaVersion, Is.EqualTo(1));
Assert.That(loaded.GetMissionCompletions(MissionIds.FirstSweep), Is.EqualTo(1));
Assert.That(loaded.Profile.unlockedSkinIds, Does.Contain("skin.future.test"));
Assert.That(loaded.Profile.selectedSkinId, Is.EqualTo("skin.future.test"));
SteamIntegerStatSnapshot[] stats = loaded.CreateSteamIntegerStatSnapshot();
Assert.That(stats.Length, Is.EqualTo(11));
Assert.That(stats[0].ApiName, Is.EqualTo("bc_mission_first_sweep_completions"));
Assert.That(stats[0].Value, Is.EqualTo(1));
Assert.That(stats[^1].ApiName, Is.EqualTo("bc_mission_rampage_completions"));
Assert.That(loaded.UnlockAchievement("achievement.fixture"), Is.True);
Assert.That(loaded.UnlockAchievement("achievement.fixture"), Is.True);
Assert.That(loaded.Profile.achievements.Count, Is.EqualTo(1));
string[] unlocked = loaded.CreateSteamUnlockedAchievementApiNameSnapshot(
new[]
{
new SteamAchievementDefinition(
"achievement.fixture",
"BC_FIXTURE_ACHIEVEMENT"),
new SteamAchievementDefinition(
"achievement.unlocked.but.unmapped",
"BC_UNMAPPED_ACHIEVEMENT"),
});
Assert.That(unlocked, Is.EqualTo(new[] { "BC_FIXTURE_ACHIEVEMENT" }));
Assert.That(
loaded.CreateSteamUnlockedAchievementApiNameSnapshot(
Array.Empty<SteamAchievementDefinition>()),
Is.Empty);
}
[TestCase("{}")]
[TestCase("{ \"missionTotals\": [] }")]
[TestCase("{")]
public void MissingOrMalformedSchema_PreservesOriginalFile(string json)
{
File.WriteAllText(profilePath, json);
UserProfileStore store = new(profilePath);
Assert.That(store.CanSave, Is.False);
Assert.That(store.LoadIssue, Is.EqualTo("malformed"));
Assert.That(store.IncrementMissionCompletion(MissionIds.Rampage), Is.False);
Assert.That(File.ReadAllText(profilePath), Is.EqualTo(json));
}
[Test]
public void NewerSchema_PreservesOriginalFileAndSaturatesSteamIntAtSignedMaximum()
{
const string newerJson = "{\"schemaVersion\":2,\"missionTotals\":[]}";
File.WriteAllText(profilePath, newerJson);
UserProfileStore future = new(profilePath);
Assert.That(future.CanSave, Is.False);
Assert.That(future.LoadIssue, Is.EqualTo("newer-schema"));
Assert.That(future.IncrementMissionCompletion(MissionIds.Rampage), Is.False);
Assert.That(File.ReadAllText(profilePath), Is.EqualTo(newerJson));
const string fullJson = "{\"schemaVersion\":1,\"missionTotals\":["
+ "{\"missionId\":\"mission.rampage\",\"completions\":2147483647}]}";
File.WriteAllText(profilePath, fullJson);
UserProfileStore full = new(profilePath);
Assert.That(full.IncrementMissionCompletion(MissionIds.Rampage), Is.True);
Assert.That(full.GetMissionCompletions(MissionIds.Rampage), Is.EqualTo(int.MaxValue));
SteamIntegerStatSnapshot rampage = full.CreateSteamIntegerStatSnapshot()[^1];
Assert.That(rampage.Value, Is.EqualTo(int.MaxValue));
}
[Test]
public void RepeatedAchievementUnlock_RetriesAfterTransientSaveFailure()
{
string blockingFile = Path.Combine(testDirectory, "blocking-file");
File.WriteAllText(blockingFile, "not a directory");
string blockedProfilePath = Path.Combine(blockingFile, "profile.json");
UserProfileStore store = new(blockedProfilePath);
Assert.That(store.UnlockAchievement("achievement.retry"), Is.False);
Assert.That(store.LoadIssue, Is.EqualTo("save-failed"));
Assert.That(store.UnlockAchievement("achievement.retry"), Is.False,
"A repeated call must retry persistence while the path is still blocked.");
File.Delete(blockingFile);
Directory.CreateDirectory(blockingFile);
Assert.That(store.UnlockAchievement("achievement.retry"), Is.True);
Assert.That(store.LoadIssue, Is.Null);
Assert.That(File.Exists(blockedProfilePath), Is.True);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1bc168e553084176b88e9e35be42cc7c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: