系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把散落在技能与武器脚本里的伤害公式,收敛成标准化结算管线,保证数值可验证、回放可复现、线上可排障。
学习目标
完成本章后,你应该能做到:
- 把一次伤害拆成清晰阶段:请求、判定、修正、吸收、落地。
- 设计可扩展的数据结构,支持后续元素伤害、穿透、反伤等机制。
- 将 Buff、技能、状态机规则统一注入同一结算链。
- 输出结构化日志,支持战斗回放和异常追踪。
为什么需要伤害管线
常见早期写法:
var damage = skill.BaseDamage;
if (Random.value < critRate) damage *= 2;
damage -= target.Defense;
if (damage < 1) damage = 1;
target.Hp -= damage;
看似够用,实际上会遇到四类问题:
- 规则顺序混乱:先减伤还是先暴击,不同脚本不一致。
- 机制冲突:护盾、减伤 Buff、无敌帧互相覆盖时结果不可预测。
- 回放不一致:随机与规则散落,难以复现线上战斗。
- 扩展困难:每加一种机制都要改 N 处旧代码。
解决方案是:固定结算流程 + 可插拔修正节点。
结算数据模型
1. 请求与上下文
public struct DamageRequest
{
public int SourceId;
public int TargetId;
public int SkillId;
public int BaseDamage;
public float CritRate;
public float CritMultiplier;
public bool CanCrit;
public int RandomSeed;
public float Time;
}
public sealed class DamageContext
{
public Actor Source;
public Actor Target;
public SkillConfig Skill;
public EffectContainer SourceEffects;
public EffectContainer TargetEffects;
}
2. 结算结果
public struct DamageResult
{
public bool IsHit;
public bool IsCritical;
public int RawDamage;
public int AfterAttackBonus;
public int AfterDefense;
public int AbsorbedByShield;
public int FinalDamage;
public string RejectReason;
}
标准结算阶段
固定顺序如下:
- 前置判定:目标是否可受伤(死亡/无敌/免伤标记)。
- 命中判定:命中率、闪避率、必中/必闪修饰。
- 暴击判定:按固定随机源与概率计算。
- 攻击侧修正:增伤、易伤、技能倍率。
- 防御侧修正:防御值、减伤率、护甲穿透。
- 护盾吸收:先扣护盾,再扣生命。
- 落地与事件:写入 HP,发布事件,记录日志。
项目驱动:实现可上线 DamagePipeline
1. 随机一致性(回放关键)
public sealed class DeterministicRandom
{
private uint _state;
public DeterministicRandom(int seed)
{
_state = (uint)seed;
if (_state == 0u)
{
_state = 2463534242u;
}
}
public float Next01()
{
_state ^= _state << 13;
_state ^= _state >> 17;
_state ^= _state << 5;
var value = _state & 0x00FFFFFFu;
return value / 16777215f;
}
}
2. 主管线
public sealed class DamagePipeline
{
private readonly EventBus _eventBus;
public DamagePipeline(EventBus eventBus)
{
_eventBus = eventBus;
}
public DamageResult Execute(DamageRequest req, DamageContext ctx)
{
var result = new DamageResult();
// Stage 1: 前置判定
if (ctx.Target == null || ctx.Target.Hp <= 0f)
{
result.IsHit = false;
result.RejectReason = "target_invalid";
return result;
}
if (ctx.TargetEffects.HasTag("Invincible"))
{
result.IsHit = false;
result.RejectReason = "target_invincible";
return result;
}
// Stage 2: 命中判定
var rng = new DeterministicRandom(req.RandomSeed);
var hitChance = ctx.Source.GetHitChance() - ctx.Target.GetDodgeChance();
if (hitChance < 0.05f) hitChance = 0.05f;
if (hitChance > 0.99f) hitChance = 0.99f;
if (rng.Next01() > hitChance)
{
result.IsHit = false;
result.RejectReason = "miss";
Publish(req, result, ctx);
return result;
}
result.IsHit = true;
// Stage 3: 暴击判定
var damage = req.BaseDamage;
result.RawDamage = damage;
if (req.CanCrit && rng.Next01() < req.CritRate)
{
result.IsCritical = true;
damage = (int)(damage * req.CritMultiplier);
}
// Stage 4: 攻击侧修正
var attackBonus = ctx.Source.GetAttackBonus(); // 例如 0.25 表示 +25%
var vulnerable = ctx.Target.GetVulnerableBonus(); // 例如 0.10 表示易伤 +10%
damage = (int)(damage * (1f + attackBonus + vulnerable));
result.AfterAttackBonus = damage;
// Stage 5: 防御侧修正
var armor = ctx.Target.Defense;
var penetration = ctx.Source.GetArmorPenetration();
var effectiveArmor = armor - penetration;
if (effectiveArmor < 0) effectiveArmor = 0;
damage -= effectiveArmor;
var damageReduction = ctx.Target.GetDamageReduction();
if (damageReduction < 0f) damageReduction = 0f;
if (damageReduction > 0.8f) damageReduction = 0.8f;
damage = (int)(damage * (1f - damageReduction));
if (damage < 1) damage = 1;
result.AfterDefense = damage;
// Stage 6: 护盾吸收
var absorbed = 0;
if (ctx.Target.ShieldValue > 0)
{
absorbed = damage;
if (absorbed > ctx.Target.ShieldValue)
{
absorbed = ctx.Target.ShieldValue;
}
ctx.Target.ShieldValue -= absorbed;
damage -= absorbed;
}
result.AbsorbedByShield = absorbed;
// Stage 7: 落地
if (damage > 0)
{
ctx.Target.ApplyDamage(damage);
}
result.FinalDamage = damage;
Publish(req, result, ctx);
return result;
}
private void Publish(DamageRequest req, DamageResult result, DamageContext ctx)
{
FoundationLog.Info(
"Damage",
"src=" + req.SourceId +
" tgt=" + req.TargetId +
" hit=" + result.IsHit +
" crit=" + result.IsCritical +
" raw=" + result.RawDamage +
" atk=" + result.AfterAttackBonus +
" def=" + result.AfterDefense +
" shield=" + result.AbsorbedByShield +
" final=" + result.FinalDamage +
" reject=" + result.RejectReason
);
_eventBus.Publish("DamageResolved", result.FinalDamage);
}
}
与前面章节的连接
- 命令系统:
CastSkillCommand执行后触发伤害请求。 - 技能系统:技能只提交请求,不直接扣血。
- Buff 系统:易伤、减伤、护盾、无敌全部作为修正来源。
- FSM:攻击状态只负责“何时发起伤害”,不管公式细节。
- 回放系统:通过固定
RandomSeed保证同请求同结果。
关键测试用例
至少覆盖以下 8 条:
- 命中成功、非暴击、无护盾。
- 命中成功、暴击、无护盾。
- 命中成功、防御高于基础伤害(最小伤害保护)。
- 命中成功、护盾足量吸收(最终 0 伤害)。
- 命中成功、护盾不足吸收(部分入血)。
- 目标无敌,直接拒绝。
- 命中失败(miss),不改变目标生命。
- 相同 seed + 相同输入,结果完全一致。
常见坑
坑 1:浮点误差导致回放不一致
关键阶段尽量收敛到整数(伤害落地前转 int),并固定舍入策略。
坑 2:护盾吸收顺序和减伤顺序写反
必须明确“先减伤再护盾”还是“先护盾再减伤”,并全项目统一。本章采用先减伤再护盾。
坑 3:日志字段缺失
只打“造成了 123 伤害”没有意义。必须记录每一阶段的中间值,才能做数值审计。
本月作业
做一版“元素伤害扩展”并保持主干不改:
- 新增伤害类型:物理、火焰、冰霜。
- 在防御阶段引入按元素独立抗性。
- 给出 3 个技能在不同抗性目标上的结算对比日志。
下一章进入“目标选择与仇恨系统”,把 AI 攻击决策和伤害链路联通。