Initial commit: Tiny Tackle Heroes Unity project

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