Article

C# 实战课 10:Buff/DeBuff 效果栈系统(持续效果统一治理)

系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把零散的“临时状态”收敛成标准化 Buff 框架,为后续战斗系统、AI 决策、数值平衡打基础。

学习目标

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

  1. 区分瞬时效果与持续效果,并选择正确建模方式。
  2. 实现 Buff 生命周期:应用、刷新、周期触发、过期、移除。
  3. 支持叠层策略(叠加层数/刷新时长/互斥覆盖)。
  4. 把 Buff 系统与技能系统、状态机、事件总线打通。

背景问题

没有 Buff 框架时,项目通常会出现这种代码:

if (poisoned)
{
    poisonTimer -= dt;
    if (poisonTick <= 0f)
    {
        hp -= 8;
        poisonTick = 1f;
    }
}

if (slowTimer > 0f)
{
    slowTimer -= dt;
    moveSpeed = baseSpeed * 0.7f;
}
else
{
    moveSpeed = baseSpeed;
}

问题非常典型:

  1. 状态变量散落,逻辑重复。
  2. 同类效果冲突规则不一致(先加速再减速?先减速再加速?)。
  3. 驱散和净化无法统一处理。
  4. 调试时无法回答“角色此刻到底挂了哪些效果”。

解决思路:把效果抽象成可管理对象,角色只维护一个 Effect 容器。

核心模型

1. 效果配置

public enum EffectType
{
    StatModifier = 0,
    Dot = 1,
    Hot = 2,
    Shield = 3,
    Control = 4
}

public enum StackPolicy
{
    None = 0,
    StackCount = 1,
    RefreshDuration = 2,
    ReplaceByPriority = 3
}

public sealed class EffectConfig
{
    public int Id;
    public string Name;
    public EffectType Type;

    public float DurationSeconds;
    public float TickIntervalSeconds;
    public int MaxStacks;
    public StackPolicy StackPolicy;

    public float Value;
    public int Priority;
}

2. 运行时实例

public sealed class EffectInstance
{
    public readonly EffectConfig Config;
    public readonly int SourceEntityId;

    public float RemainSeconds;
    public float TickRemainSeconds;
    public int StackCount;

    public EffectInstance(EffectConfig config, int sourceEntityId)
    {
        Config = config;
        SourceEntityId = sourceEntityId;
        RemainSeconds = config.DurationSeconds;
        TickRemainSeconds = config.TickIntervalSeconds;
        StackCount = 1;
    }
}

项目驱动:构建统一 EffectContainer

目标:每个 Actor 拥有一个 EffectContainer,技能命中后只调用 ApplyEffect

1. 容器主体

public sealed class EffectContainer
{
    private readonly Dictionary<int, EffectInstance> _effects = new Dictionary<int, EffectInstance>(16);
    private readonly Actor _owner;
    private readonly EventBus _eventBus;

    public EffectContainer(Actor owner, EventBus eventBus)
    {
        _owner = owner;
        _eventBus = eventBus;
    }

    public bool HasEffect(int effectId)
    {
        return _effects.ContainsKey(effectId);
    }

    public void Apply(EffectConfig config, int sourceEntityId)
    {
        EffectInstance inst;
        if (_effects.TryGetValue(config.Id, out inst))
        {
            MergeStack(inst, config);
            FoundationLog.Info("Effect", "Refresh id=" + config.Id + " stack=" + inst.StackCount);
            _eventBus.Publish("EffectRefresh", config.Id);
            return;
        }

        inst = new EffectInstance(config, sourceEntityId);
        _effects.Add(config.Id, inst);

        OnEnter(inst);
        FoundationLog.Info("Effect", "Apply id=" + config.Id + " source=" + sourceEntityId);
        _eventBus.Publish("EffectApply", config.Id);
    }

    public void Remove(int effectId, string reason)
    {
        EffectInstance inst;
        if (!_effects.TryGetValue(effectId, out inst))
        {
            return;
        }

        OnExit(inst);
        _effects.Remove(effectId);

        FoundationLog.Info("Effect", "Remove id=" + effectId + " reason=" + reason);
        _eventBus.Publish("EffectRemove", effectId);
    }

    public void Tick(float dt)
    {
        if (_effects.Count == 0)
        {
            return;
        }

        var expired = ListPool<int>.Get();

        foreach (var kv in _effects)
        {
            var inst = kv.Value;

            inst.RemainSeconds -= dt;

            if (inst.Config.TickIntervalSeconds > 0f)
            {
                inst.TickRemainSeconds -= dt;
                while (inst.TickRemainSeconds <= 0f)
                {
                    OnTick(inst);
                    inst.TickRemainSeconds += inst.Config.TickIntervalSeconds;
                }
            }

            if (inst.RemainSeconds <= 0f)
            {
                expired.Add(inst.Config.Id);
            }
        }

        for (var i = 0; i < expired.Count; i++)
        {
            Remove(expired[i], "expired");
        }

        ListPool<int>.Release(expired);
    }

    private void MergeStack(EffectInstance inst, EffectConfig cfg)
    {
        if (cfg.StackPolicy == StackPolicy.StackCount)
        {
            inst.StackCount += 1;
            if (inst.StackCount > cfg.MaxStacks)
            {
                inst.StackCount = cfg.MaxStacks;
            }
            inst.RemainSeconds = cfg.DurationSeconds;
            return;
        }

        if (cfg.StackPolicy == StackPolicy.RefreshDuration)
        {
            inst.RemainSeconds = cfg.DurationSeconds;
            return;
        }

        if (cfg.StackPolicy == StackPolicy.ReplaceByPriority)
        {
            if (cfg.Priority >= inst.Config.Priority)
            {
                inst.RemainSeconds = cfg.DurationSeconds;
            }
        }
    }

    private void OnEnter(EffectInstance inst)
    {
        if (inst.Config.Type == EffectType.StatModifier)
        {
            _owner.MoveSpeedMultiplier *= (1f + inst.Config.Value);
        }
        else if (inst.Config.Type == EffectType.Shield)
        {
            _owner.ShieldValue += (int)inst.Config.Value;
        }
    }

    private void OnTick(EffectInstance inst)
    {
        if (inst.Config.Type == EffectType.Dot)
        {
            var damage = (int)(inst.Config.Value * inst.StackCount);
            _owner.ApplyDamage(damage);
            FoundationLog.Debug("Effect", "DOT id=" + inst.Config.Id + " damage=" + damage);
        }
        else if (inst.Config.Type == EffectType.Hot)
        {
            var heal = (int)(inst.Config.Value * inst.StackCount);
            _owner.Heal(heal);
            FoundationLog.Debug("Effect", "HOT id=" + inst.Config.Id + " heal=" + heal);
        }
    }

    private void OnExit(EffectInstance inst)
    {
        if (inst.Config.Type == EffectType.StatModifier)
        {
            _owner.MoveSpeedMultiplier /= (1f + inst.Config.Value);
        }
        else if (inst.Config.Type == EffectType.Shield)
        {
            _owner.ShieldValue -= (int)inst.Config.Value;
            if (_owner.ShieldValue < 0)
            {
                _owner.ShieldValue = 0;
            }
        }
    }
}

注意:上面的 foreach + Remove 通过 expired 列表规避了迭代期修改容器的问题。

2. ListPool(减少临时分配)

public static class ListPool<T>
{
    private static readonly Stack<List<T>> Pool = new Stack<List<T>>(16);

    public static List<T> Get()
    {
        if (Pool.Count > 0)
        {
            return Pool.Pop();
        }

        return new List<T>(8);
    }

    public static void Release(List<T> list)
    {
        if (list == null)
        {
            return;
        }

        list.Clear();
        Pool.Push(list);
    }
}

与技能系统联动

技能生效时,不再直接改属性,而是挂效果:

public sealed class EffectSkillHandler
{
    private readonly EffectRepository _effectRepo;

    public EffectSkillHandler(EffectRepository effectRepo)
    {
        _effectRepo = effectRepo;
    }

    public void ApplySkillEffect(Actor caster, Actor target, int effectId)
    {
        var cfg = _effectRepo.Get(effectId);
        if (cfg == null || target == null)
        {
            return;
        }

        target.Effects.Apply(cfg, caster.Id);
    }
}

这样做后,技能引擎只负责“触发什么效果”,效果如何演进由 Buff 系统统一管理。

与 FSM 联动

典型规则:

  1. 进入 DeadState 时清空可移除效果。
  2. 进入 HitState 时若有“霸体 Buff”则忽略硬直。
  3. 进入 AttackState 时检查“沉默 Debuff”,有则禁止施法。

用状态机去定义行为边界,用 Effect 去定义规则变化,职责清晰。

验收标准

必须通过以下回归:

  1. 同一 DOT 可叠加至上限,伤害按层数增长。
  2. 减速与加速可按优先级共存或覆盖,行为符合配置规则。
  3. 效果过期后属性恢复到正确值(无残留)。
  4. 驱散指定类型效果后,日志和事件完整输出。

常见坑

坑 1:退出效果时恢复逻辑写错

例如加速 x1.2,退出时直接减 0.2,会累积误差。建议按进入时的逆操作恢复。

坑 2:叠层与刷新混在一起

很多 Bug 来自“既加层又重置全部状态”。把策略固定在 StackPolicy

坑 3:效果和 UI 强耦合

不要在容器里直接改 UI。用事件总线通知展示层更新。

本月作业

实现“净化技能 + 驱散规则”:

  1. 支持按类型驱散(仅负面、仅控制、全部可驱散)。
  2. 支持按优先级驱散前 N 个效果。
  3. 打印驱散前后效果快照(id、层数、剩余时间)。

下一章进入“伤害结算管线(命中、暴击、减伤、护盾吸收)”,把战斗核心公式体系化。