系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把零散的“临时状态”收敛成标准化 Buff 框架,为后续战斗系统、AI 决策、数值平衡打基础。
学习目标
完成本章后,你应该能做到:
- 区分瞬时效果与持续效果,并选择正确建模方式。
- 实现 Buff 生命周期:应用、刷新、周期触发、过期、移除。
- 支持叠层策略(叠加层数/刷新时长/互斥覆盖)。
- 把 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;
}
问题非常典型:
- 状态变量散落,逻辑重复。
- 同类效果冲突规则不一致(先加速再减速?先减速再加速?)。
- 驱散和净化无法统一处理。
- 调试时无法回答“角色此刻到底挂了哪些效果”。
解决思路:把效果抽象成可管理对象,角色只维护一个 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 联动
典型规则:
- 进入
DeadState时清空可移除效果。 - 进入
HitState时若有“霸体 Buff”则忽略硬直。 - 进入
AttackState时检查“沉默 Debuff”,有则禁止施法。
用状态机去定义行为边界,用 Effect 去定义规则变化,职责清晰。
验收标准
必须通过以下回归:
- 同一 DOT 可叠加至上限,伤害按层数增长。
- 减速与加速可按优先级共存或覆盖,行为符合配置规则。
- 效果过期后属性恢复到正确值(无残留)。
- 驱散指定类型效果后,日志和事件完整输出。
常见坑
坑 1:退出效果时恢复逻辑写错
例如加速 x1.2,退出时直接减 0.2,会累积误差。建议按进入时的逆操作恢复。
坑 2:叠层与刷新混在一起
很多 Bug 来自“既加层又重置全部状态”。把策略固定在 StackPolicy。
坑 3:效果和 UI 强耦合
不要在容器里直接改 UI。用事件总线通知展示层更新。
本月作业
实现“净化技能 + 驱散规则”:
- 支持按类型驱散(仅负面、仅控制、全部可驱散)。
- 支持按优先级驱散前 N 个效果。
- 打印驱散前后效果快照(id、层数、剩余时间)。
下一章进入“伤害结算管线(命中、暴击、减伤、护盾吸收)”,把战斗核心公式体系化。