Article

C# 实战课 11:伤害结算管线(命中、暴击、减伤、护盾吸收一条链打通)

系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把散落在技能与武器脚本里的伤害公式,收敛成标准化结算管线,保证数值可验证、回放可复现、线上可排障。

学习目标

完成本章后,你应该能做到:

  1. 把一次伤害拆成清晰阶段:请求、判定、修正、吸收、落地。
  2. 设计可扩展的数据结构,支持后续元素伤害、穿透、反伤等机制。
  3. 将 Buff、技能、状态机规则统一注入同一结算链。
  4. 输出结构化日志,支持战斗回放和异常追踪。

为什么需要伤害管线

常见早期写法:

var damage = skill.BaseDamage;
if (Random.value < critRate) damage *= 2;
damage -= target.Defense;
if (damage < 1) damage = 1;
target.Hp -= damage;

看似够用,实际上会遇到四类问题:

  1. 规则顺序混乱:先减伤还是先暴击,不同脚本不一致。
  2. 机制冲突:护盾、减伤 Buff、无敌帧互相覆盖时结果不可预测。
  3. 回放不一致:随机与规则散落,难以复现线上战斗。
  4. 扩展困难:每加一种机制都要改 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;
}

标准结算阶段

固定顺序如下:

  1. 前置判定:目标是否可受伤(死亡/无敌/免伤标记)。
  2. 命中判定:命中率、闪避率、必中/必闪修饰。
  3. 暴击判定:按固定随机源与概率计算。
  4. 攻击侧修正:增伤、易伤、技能倍率。
  5. 防御侧修正:防御值、减伤率、护甲穿透。
  6. 护盾吸收:先扣护盾,再扣生命。
  7. 落地与事件:写入 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);
    }
}

与前面章节的连接

  1. 命令系统CastSkillCommand 执行后触发伤害请求。
  2. 技能系统:技能只提交请求,不直接扣血。
  3. Buff 系统:易伤、减伤、护盾、无敌全部作为修正来源。
  4. FSM:攻击状态只负责“何时发起伤害”,不管公式细节。
  5. 回放系统:通过固定 RandomSeed 保证同请求同结果。

关键测试用例

至少覆盖以下 8 条:

  1. 命中成功、非暴击、无护盾。
  2. 命中成功、暴击、无护盾。
  3. 命中成功、防御高于基础伤害(最小伤害保护)。
  4. 命中成功、护盾足量吸收(最终 0 伤害)。
  5. 命中成功、护盾不足吸收(部分入血)。
  6. 目标无敌,直接拒绝。
  7. 命中失败(miss),不改变目标生命。
  8. 相同 seed + 相同输入,结果完全一致。

常见坑

坑 1:浮点误差导致回放不一致

关键阶段尽量收敛到整数(伤害落地前转 int),并固定舍入策略。

坑 2:护盾吸收顺序和减伤顺序写反

必须明确“先减伤再护盾”还是“先护盾再减伤”,并全项目统一。本章采用先减伤再护盾。

坑 3:日志字段缺失

只打“造成了 123 伤害”没有意义。必须记录每一阶段的中间值,才能做数值审计。

本月作业

做一版“元素伤害扩展”并保持主干不改:

  1. 新增伤害类型:物理、火焰、冰霜。
  2. 在防御阶段引入按元素独立抗性。
  3. 给出 3 个技能在不同抗性目标上的结算对比日志。

下一章进入“目标选择与仇恨系统”,把 AI 攻击决策和伤害链路联通。