引入属性、伤害管线

This commit is contained in:
2026-09-18 21:43:22 +08:00
parent 92e1ea0d55
commit ba25ec1926
108 changed files with 2920 additions and 720 deletions
@@ -0,0 +1,173 @@
using System;
using NUnit.Framework;
using SepCore.Battle;
using SepCore.Definition;
namespace VampireLike.Tests.Gameplay.EditMode
{
[TestFixture]
public class AttributePipelineTests
{
[Test]
public void FormulaOrder_CalculatesFlatPercentAddAndPercentMultCorrectly()
{
var stat = new CharacterStat(StatType.MeleeDamage, baseValue: 10f);
// Formula: ((Base + Sum(Flat)) * (1 + Sum(PercentAdd))) * Prod(PercentMult)
// Flat: +5, +3 -> Sum = +8 -> Base + Flat = 18
stat.AddModifier(new StatModifier(5f, ModifierType.Flat));
stat.AddModifier(new StatModifier(3f, ModifierType.Flat));
// PercentAdd: +20% (0.2), +30% (0.3) -> Sum = +50% -> 1 + 0.5 = 1.5
stat.AddModifier(new StatModifier(0.2f, ModifierType.PercentAdd));
stat.AddModifier(new StatModifier(0.3f, ModifierType.PercentAdd));
// PercentMult: 1.2x, 1.5x -> Prod = 1.8
stat.AddModifier(new StatModifier(1.2f, ModifierType.PercentMult));
stat.AddModifier(new StatModifier(1.5f, ModifierType.PercentMult));
// Expected: 18 * 1.5 * 1.8 = 48.6
Assert.AreEqual(48.6f, stat.Value, 1e-4f);
}
[Test]
public void DirtyFlag_CachesValueUntilModified()
{
var stat = new CharacterStat(StatType.MaxHp, baseValue: 100f);
var mod = new StatModifier(20f, ModifierType.Flat);
stat.AddModifier(mod);
float firstRead = stat.Value;
Assert.AreEqual(120f, firstRead, 1e-4f);
// 读取多次,验证缓存值一致
float secondRead = stat.Value;
Assert.AreEqual(120f, secondRead, 1e-4f);
// 移除修饰器后脏标记生效,值重新计算
stat.RemoveModifier(mod);
Assert.AreEqual(100f, stat.Value, 1e-4f);
// 修改 BaseValue 后脏标记生效
stat.BaseValue = 150f;
Assert.AreEqual(150f, stat.Value, 1e-4f);
}
[Test]
public void RemoveAllModifiersFromSource_RemovesOnlyMatchingSource()
{
var pipeline = new AttributePipeline();
var sourceA = new object();
var sourceB = new object();
pipeline.SetBaseValue(StatType.MeleeDamage, 10f);
pipeline.SetBaseValue(StatType.MoveSpeed, 5f);
// sourceA 附加加成到近战伤害与移速
pipeline.AddModifier(StatType.MeleeDamage, new StatModifier(10f, ModifierType.Flat, sourceA));
pipeline.AddModifier(StatType.MoveSpeed, new StatModifier(0.2f, ModifierType.PercentAdd, sourceA));
// sourceB 附加加成到近战伤害
pipeline.AddModifier(StatType.MeleeDamage, new StatModifier(5f, ModifierType.Flat, sourceB));
Assert.AreEqual(25f, pipeline.GetValue(StatType.MeleeDamage), 1e-4f);
Assert.AreEqual(6f, pipeline.GetValue(StatType.MoveSpeed), 1e-4f);
// 清理 sourceA
int removedCount = pipeline.RemoveAllModifiersFromSource(sourceA);
Assert.AreEqual(2, removedCount);
// 验证 sourceA 被清除,sourceB 依然生效
Assert.AreEqual(15f, pipeline.GetValue(StatType.MeleeDamage), 1e-4f);
Assert.AreEqual(5f, pipeline.GetValue(StatType.MoveSpeed), 1e-4f);
}
[Test]
public void StatConfig_ClampsMinAndMaxCorrectly()
{
// 闪避率硬上限 80% (0.8f) 来自配置表
var dodgeConfig = new StatConfig(StatType.Dodge, hasMin: true, minValue: 0f, hasMax: true, maxValue: 0.80f);
var dodgeStat = new CharacterStat(StatType.Dodge, baseValue: 0.50f, dodgeConfig);
dodgeStat.AddModifier(new StatModifier(0.40f, ModifierType.Flat));
Assert.AreEqual(0.80f, dodgeStat.Value, 1e-4f);
// 移速保底 0.5f 来自配置表
var speedConfig = new StatConfig(StatType.MoveSpeed, hasMin: true, minValue: 0.5f);
var speedStat = new CharacterStat(StatType.MoveSpeed, baseValue: 3f, speedConfig);
speedStat.AddModifier(new StatModifier(-10f, ModifierType.Flat));
Assert.AreEqual(0.5f, speedStat.Value, 1e-4f);
// 暴击率硬上限 90% (0.9f) 来自配置表
var critConfig = new StatConfig(StatType.CritChance, hasMin: true, minValue: 0f, hasMax: true, maxValue: 0.90f);
var critStat = new CharacterStat(StatType.CritChance, baseValue: 0.50f, critConfig);
critStat.AddModifier(new StatModifier(0.60f, ModifierType.Flat));
Assert.AreEqual(0.90f, critStat.Value, 1e-4f);
}
[Test]
public void FromLuban_ConvertsFlatAndPercentModifiersCorrectly()
{
// 1. 固定值 (Flat): 比如近战伤害 +15
var flatConfig = new StatModifierConfig(StatType.MeleeDamage, 15f, ModifierType.Flat);
var flatStatMod = StatModifier.FromLuban(flatConfig);
Assert.AreEqual(ModifierType.Flat, flatStatMod.Type);
Assert.AreEqual(15f, flatStatMod.Value, 1e-4f);
// 2. 百分比累加 (PercentAdd): 比如伤害 +20% (0.2f)
var percentConfig = new StatModifierConfig(StatType.DamagePercent, 0.2f, ModifierType.PercentAdd);
var percentStatMod = StatModifier.FromLuban(percentConfig);
Assert.AreEqual(ModifierType.PercentAdd, percentStatMod.Type);
Assert.AreEqual(0.2f, percentStatMod.Value, 1e-4f);
// 3. 独立乘区 (PercentMult): 比如伤害 1.5 倍
var multConfig = new StatModifierConfig(StatType.DamagePercent, 1.5f, ModifierType.PercentMult);
var multStatMod = StatModifier.FromLuban(multConfig);
Assert.AreEqual(ModifierType.PercentMult, multStatMod.Type);
Assert.AreEqual(1.5f, multStatMod.Value, 1e-4f);
}
[Test]
public void OnStatChanged_FiresOnModifications()
{
var pipeline = new AttributePipeline();
StatType changedStat = StatType.None;
float changedValue = 0f;
int eventCount = 0;
pipeline.OnStatChanged += (type, val) =>
{
changedStat = type;
changedValue = val;
eventCount++;
};
// 设置基础值
pipeline.SetBaseValue(StatType.Armor, 10f);
Assert.AreEqual(StatType.Armor, changedStat);
Assert.AreEqual(10f, changedValue, 1e-4f);
Assert.AreEqual(1, eventCount);
// 添加修饰器
var mod = new StatModifier(5f, ModifierType.Flat);
pipeline.AddModifier(StatType.Armor, mod);
Assert.AreEqual(15f, changedValue, 1e-4f);
Assert.AreEqual(2, eventCount);
// 移除修饰器
pipeline.RemoveModifier(StatType.Armor, mod);
Assert.AreEqual(10f, changedValue, 1e-4f);
Assert.AreEqual(3, eventCount);
}
[Test]
public void DynamicConfig_MarksDirtyAndUpdatesClamp()
{
var stat = new CharacterStat(StatType.Dodge, baseValue: 0.90f);
Assert.AreEqual(0.90f, stat.Value, 1e-4f); // 未注入配置时不钳制
// 动态注入配置,硬上限 80%
stat.Config = new StatConfig(StatType.Dodge, hasMax: true, maxValue: 0.80f);
Assert.AreEqual(0.80f, stat.Value, 1e-4f); // 自动标记脏并重新钳制
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d4e6bd8e676b316468d43ad23e20e917
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,316 @@
using NUnit.Framework;
using SepCore.Battle;
using SepCore.Definition;
namespace VampireLike.Tests.Gameplay.EditMode
{
[TestFixture]
public class DamagePipelineTests
{
[Test]
public void DamageScaling_AddsMeleeDamageBeforePercentMultiplier()
{
var attackerPipeline = new AttributePipeline();
attackerPipeline.SetBaseValue(StatType.MeleeDamage, 20f);
attackerPipeline.SetBaseValue(StatType.DamagePercent, 0.50f); // +50%
var context = new DamageContext
{
BaseDamage = 100,
DamageScalingStat = StatType.MeleeDamage,
ScalingRatio = 1.0f,
BaseCritChance = 0f,
CritMultiplier = 1.5f,
AttackerPipeline = attackerPipeline,
DefenderPipeline = null,
FallbackDefenderArmor = 0f,
FallbackDefenderDodge = 0f
};
// Raw = 100 + 20 = 120
// Amplified = 120 * (1 + 0.5) = 180
// Armor = 0 -> Mitigation = 1.0 -> 180
DamageResult result = DamagePipeline.Calculate(in context);
Assert.AreEqual(180, result.Damage);
Assert.IsFalse(result.IsDodge);
Assert.IsFalse(result.IsCrit);
}
[Test]
public void DamageScaling_RangedAndElemental_ApplyToRespectiveTypes()
{
var attackerPipeline = new AttributePipeline();
attackerPipeline.SetBaseValue(StatType.RangedDamage, 30f);
attackerPipeline.SetBaseValue(StatType.ElementalDamage, 50f);
// 1. 远程伤害加成
var rangedContext = new DamageContext
{
BaseDamage = 50,
DamageScalingStat = StatType.RangedDamage,
ScalingRatio = 1.0f,
AttackerPipeline = attackerPipeline
};
DamageResult rangedResult = DamagePipeline.Calculate(in rangedContext);
Assert.AreEqual(80, rangedResult.Damage); // 50 + 30 = 80
// 2. 元素伤害加成(带 0.8 加成系数)
var elementalContext = new DamageContext
{
BaseDamage = 50,
DamageScalingStat = StatType.ElementalDamage,
ScalingRatio = 0.8f,
AttackerPipeline = attackerPipeline
};
DamageResult elementalResult = DamagePipeline.Calculate(in elementalContext);
Assert.AreEqual(90, elementalResult.Damage); // 50 + 50 * 0.8 = 90
}
[Test]
public void ArmorMitigation_PositiveArmor_AppliesHyperbolicReduction()
{
// 基础伤害 100,测试双曲公式 1 / (1 + Armor / 15)
// 1. Armor = 15 -> 减伤 50% -> 50
var context15 = new DamageContext
{
BaseDamage = 100,
FallbackDefenderArmor = 15f
};
DamageResult result15 = DamagePipeline.Calculate(in context15);
Assert.AreEqual(50, result15.Damage);
// 2. Armor = 30 -> 减伤 66.7% -> 100 / 3 = 33.33 -> 向上取整 34
var context30 = new DamageContext
{
BaseDamage = 100,
FallbackDefenderArmor = 30f
};
DamageResult result30 = DamagePipeline.Calculate(in context30);
Assert.AreEqual(34, result30.Damage);
}
[Test]
public void ArmorMitigation_NegativeArmor_IncreasesDamageTaken()
{
// 基础伤害 100,负护甲增伤公式: 2 - 1 / (1 - Armor / 15)
// Armor = -15 -> 2 - 1 / (1 - (-1)) = 2 - 0.5 = 1.5 -> 150
var context = new DamageContext
{
BaseDamage = 100,
FallbackDefenderArmor = -15f
};
DamageResult result = DamagePipeline.Calculate(in context);
Assert.AreEqual(150, result.Damage);
}
[Test]
public void Crit_WhenGuaranteed_MultipliesDamageCorrectly()
{
var context = new DamageContext
{
BaseDamage = 100,
BaseCritChance = 1.0f, // 100% 暴击率(触发 Clamp 90% 上限,但若直接传入高暴击率测试倍率)
CritMultiplier = 2.0f,
FallbackDefenderArmor = 0f
};
// 测试 100 次计算,由于暴击率最高 90%,我们设置 BaseCritChance = 0.9f 并循环验证暴击时的数值
var guaranteedContext = new DamageContext
{
BaseDamage = 100,
BaseCritChance = 0.9f,
CritMultiplier = 2.0f
};
bool sawCrit = false;
for (int i = 0; i < 50; i++)
{
DamageResult result = DamagePipeline.Calculate(in guaranteedContext);
if (result.IsCrit)
{
Assert.AreEqual(200, result.Damage);
sawCrit = true;
break;
}
}
Assert.IsTrue(sawCrit, "At 90% crit chance, at least one crit should occur across 50 trials.");
}
[Test]
public void Dodge_WhenGuaranteed_ReturnsZeroDamageAndIsDodgeTrue()
{
var defenderPipeline = new AttributePipeline();
defenderPipeline.SetBaseValue(StatType.Dodge, 0.80f); // 80% 闪避率上限
var context = new DamageContext
{
BaseDamage = 100,
DefenderPipeline = defenderPipeline
};
bool sawDodge = false;
for (int i = 0; i < 50; i++)
{
DamageResult result = DamagePipeline.Calculate(in context);
if (result.IsDodge)
{
Assert.AreEqual(0, result.Damage);
sawDodge = true;
break;
}
}
Assert.IsTrue(sawDodge, "At 80% dodge chance, at least one dodge should occur across 50 trials.");
}
[Test]
public void MinDamage_EnforcesFloor_WhenMitigationReducesDamageTooLow()
{
var context = new DamageContext
{
BaseDamage = 1,
FallbackDefenderArmor = 9999f
};
DamageResult result = DamagePipeline.Calculate(in context);
Assert.AreEqual(1, result.Damage);
}
[Test]
public void LifeSteal_TriggersWhenDamageDealtAndRateSucceeds()
{
var attackerPipeline = new AttributePipeline();
attackerPipeline.SetBaseValue(StatType.LifeSteal, 0.99f); // 99% 吸血概率
var context = new DamageContext
{
BaseDamage = 50,
AttackerPipeline = attackerPipeline
};
bool sawLifeSteal = false;
for (int i = 0; i < 20; i++)
{
DamageResult result = DamagePipeline.Calculate(in context);
if (result.IsLifeSteal)
{
Assert.AreEqual(1, result.LifeStealAmount);
sawLifeSteal = true;
break;
}
}
Assert.IsTrue(sawLifeSteal, "At 99% life steal rate, life steal should trigger.");
}
[Test]
public void DamageTypeExtensions_MapsCorrectlyToStatType()
{
Assert.AreEqual(StatType.MeleeDamage, DamageType.Melee.ToScalingStat());
Assert.AreEqual(StatType.RangedDamage, DamageType.Ranged.ToScalingStat());
Assert.AreEqual(StatType.ElementalDamage, DamageType.Element.ToScalingStat());
Assert.AreEqual(StatType.None, DamageType.None.ToScalingStat());
}
[Test]
public void AttackSpeed_ScalesCooldownCorrectly()
{
float baseCooldown = 1.0f;
// 1. +50% 攻速 -> 1.0 / (1 + 0.5) = 0.6667s
float speedBonus1 = 0.5f;
float factor1 = UnityEngine.Mathf.Max(0.2f, 1f + speedBonus1);
float cd1 = UnityEngine.Mathf.Max(0.02f, baseCooldown / factor1);
Assert.AreEqual(1f / 1.5f, cd1, 1e-4f);
// 2. -50% 攻速 -> 1.0 / (1 - 0.5) = 2.0s
float speedBonus2 = -0.5f;
float factor2 = UnityEngine.Mathf.Max(0.2f, 1f + speedBonus2);
float cd2 = UnityEngine.Mathf.Max(0.02f, baseCooldown / factor2);
Assert.AreEqual(2.0f, cd2, 1e-4f);
// 3. -80% 攻速(配置表下限)-> 1.0 / (1 - 0.8) = 5.0s
float speedBonus3 = -0.8f;
float factor3 = UnityEngine.Mathf.Max(0.2f, 1f + speedBonus3);
float cd3 = UnityEngine.Mathf.Max(0.02f, baseCooldown / factor3);
Assert.AreEqual(5.0f, cd3, 1e-4f);
// 4. -95% 攻速(受 0.2 保护下限钳制)-> 1.0 / 0.2 = 5.0s
float speedBonus4 = -0.95f;
float factor4 = UnityEngine.Mathf.Max(0.2f, 1f + speedBonus4);
float cd4 = UnityEngine.Mathf.Max(0.02f, baseCooldown / factor4);
Assert.AreEqual(5.0f, cd4, 1e-4f);
}
[Test]
public void RangeBonus_CalculatesAttackRangeCorrectly()
{
float baseRange = 5.0f; // 5m
// 1. 增加 2m (direct units)
float bonus1 = 2.0f;
float eff1 = bonus1 >= 50f ? bonus1 / 1000f : bonus1;
float range1 = UnityEngine.Mathf.Max(0.5f, baseRange + eff1);
Assert.AreEqual(7.0f, range1, 1e-4f);
// 2. 增加 1500 (millimeters)
float bonus2 = 1500f;
float eff2 = bonus2 >= 50f ? bonus2 / 1000f : bonus2;
float range2 = UnityEngine.Mathf.Max(0.5f, baseRange + eff2);
Assert.AreEqual(6.5f, range2, 1e-4f);
}
[Test]
public void LifeStealICD_ThrottlesRepeatedTriggersWithin100ms()
{
var go = new UnityEngine.GameObject("TestHealthEntity");
var health = go.AddComponent<SepCore.Components.HealthComponent>();
health.OnInit(100);
health.CurrentHealth = 50;
// 第一次吸血成功 (t = 1.00s)
bool first = health.TryTriggerLifeSteal(1, customTime: 1.00f);
Assert.IsTrue(first);
Assert.AreEqual(51, health.CurrentHealth);
// 50ms 后再次触发,小于 0.1s ICD,应被节流拦截
bool second = health.TryTriggerLifeSteal(1, customTime: 1.05f);
Assert.IsFalse(second);
Assert.AreEqual(51, health.CurrentHealth);
// 110ms 后触发 (t = 1.11s),超过 0.1s ICD,应成功
bool third = health.TryTriggerLifeSteal(1, customTime: 1.11f);
Assert.IsTrue(third);
Assert.AreEqual(52, health.CurrentHealth);
UnityEngine.Object.DestroyImmediate(go);
}
[Test]
public void MovementAndHealthComponents_EvaluateViaAttributePipeline()
{
var go = new UnityEngine.GameObject("TestEntity");
var statComp = go.AddComponent<SepCore.Components.StatComponent>();
var moveComp = go.AddComponent<SepCore.Components.MovementComponent>();
var healthComp = go.AddComponent<SepCore.Components.HealthComponent>();
// 初始化:基础速度 5m/s,基础生命 100
moveComp.OnInit(5.0f, go.transform, statComp, false, 0.5f, 4);
healthComp.OnInit(100, statComp);
Assert.AreEqual(5.0f, moveComp.Speed, 1e-4f);
Assert.AreEqual(100, healthComp.MaxHealth);
// 添加 +20% 移速修饰器
statComp.AddModifier(StatType.MoveSpeed, new StatModifier(0.20f, ModifierType.PercentAdd));
Assert.AreEqual(6.0f, moveComp.Speed, 1e-4f); // 5 * 1.2 = 6.0
// 添加 +50 生命上限修饰器
statComp.AddModifier(StatType.MaxHp, new StatModifier(50f, ModifierType.Flat));
Assert.AreEqual(150, healthComp.MaxHealth);
UnityEngine.Object.DestroyImmediate(go);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6f8492019a4b419c8d3091728490bb15
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,27 @@
{
"name": "Gameplay.EditModeTests",
"rootNamespace": "",
"references": [
"UnityGameFramework.Runtime",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"SepCore.Runtime",
"SepCore.Base"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll",
"GameFramework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 98acb13808fbbff4c83e548d62c5119d
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: