前两期我把技能释放和角色数值池的处理思路讲完了,这一期专门拆 Buff 系统。在 RPG 框架里,Buff 系统属于那种“一开始不觉得难,功能越加越痛苦”的模块。战斗里常见的增伤减伤、中毒灼烧、护盾免疫、眩晕嘲讽、变身变形,都会被做成 Buff。如果 Buff 模块结构选得不好,后面每加一个新技能或新状态,都要去改伤害计算、移动速度处理、动画表现和 UI 图标,Bug 也会跟着越来越多。
这篇文章会把 Buff 系统的搭建思路按实际落地顺序拆一遍:先讲清楚 Buff 本质上管理哪些事,再给一套配置、实例、效果三层的代码结构,然后看生命周期和战斗结算怎么接入,最后补叠加规则、常见排错和验收标准。内容偏工程框架,不绑定具体引擎,但代码示例用的是 C# 风格,放到 Unity、Godot、自研引擎里都可以做类似映射。
1. Buff 系统为什么会变成“改一个加一个”
1.1 最容易犯的错:在伤害函数里堆满 if
很多项目一开始是没有独立 Buff 模块的。实现“火伤提高 30%”的时候,直接在伤害公式里写:
if (hasFireBuff) { damage = damage * 1.3f; }实现“受到物理伤害降低 10%”的时候,又在受伤函数里写:
if (hasPhysicalShield) { damage = damage * 0.9f; }这种写法单看一次没问题,所有状态叠加起来就开始失控。等到技能系统、装备系统、Buff 系统几个模块同时给玩家添加状态时,你根本不知道一个属性在某个时间点到底被多少系统改过。
更麻烦的是顺序问题。如果一个角色同时有“受到伤害降低 10%”和“受到火属性伤害提高 20%”,这两个修正谁先谁后,会让最终数值出现不同结果。伤害逻辑里靠 if 顺序决定结果,意味着这个序列非常脆,改动一次伤害公式,Buff 效果就可能悄悄变了。
1.2 Buff 本质不是状态标记,是三件事
我在做第 1 期和第 2 期框架的时候,把技能和属性模块的接口定得比较清晰,到了第 3 期才发现,Buff 要稳定,需要把它拆成以下三件事处理:
- 立刻生效并持续存在的事件,例如“每秒恢复 2% 生命”。
- 被动修正数值的事件,例如“攻击力提高 15%”。
- 改变战斗流程判定的事件,例如“免疫一次致命伤害”或“眩晕期间不能行动”。
这三件事如果都靠技能伤害函数里打标记控制,Buff 自然到处蔓延。如果换一种思路,让 Buff 系统只负责“产生一个实例、维护这个实例的周期、在正确时机触发事件”,战斗系统只负责“读取当前属性或注册事件”,两边就有明确的边界。
所以我建议所有做战斗系统的人,先不要急着封装一堆 Buff 对象的 API。先确认你的项目里有哪些类型的状态,它们影响的是数值、是行为还是结算结果。大多数 Buff 系统的混乱不是缺少代码,而是“影响点”不明确。
2. 落地数据模型:配置、实例、效果三件套
2.1 静态 Buff 定义和运行时状态分开
设计 Buff 系统第一个要遵守的原则是:Buff 模板和 Buff 实例是两回事。
模板表示“这种 Buff 长什么样”,实例表示“当前这个角色身上带着的那一份状态”。同一个中毒 Buff,可以被多个角色携带,每个角色的剩余时间、层数、来源技能都不同。如果只用一份对象来表示,就会出现角色 A 的毒伤结束,角色 B 的毒伤也跟着没了的诡异现象。
示例配置:
public class BuffConfig { public int id; public string buffName; public float duration; public int maxStack; public bool canRefresh; public bool killClean; public List<IBuffEffect> effects; }示例实例:
public class BuffInstance { public int instId; public int configId; public int currentStack; public float remainTime; public int tickCount; public BuffState state; public BattleEntity source; public BattleEntity owner; }这样配置表可以策划填,实例由战斗运行时产生。添加同一个 Buff 时,先去找角色当前身上有没有同 configId 的 Buff,如果有就进入叠加或刷新逻辑,如果没有就创建新实例。
2.2 伤害、治疗、护盾、属性修正不要全塞一个数组里
刚开始写 Buff 效果时,很容易用一个枚举加一个数值数组来表达:“类型是伤害,值是 50,间隔是 2 秒”。这种简化在少量 Buff 时很好用,但后续会遇到几个问题:
- 每个效果的参数数量不一样。DOT 需要间隔、每次伤害、伤害类型;修改攻击力需要修正百分比或固定值;护盾需要吸收上限;控制效果需要处理免疫优先级。
- 如果所有效果都放在同一个数组里,每种效果只能通过大 switch 来区分,新增效果类型时,所有读取入口都要加一段分支。
- 不好做配置序列化。策划写配置时可能漏字段,代码一边读一边判断,容易把问题留到运行时才暴露。
我建议使用接口方式组织效果,同一效果类型是一个独立类。核心思路是:Buff 实例自己不直接去计算伤害或修改属性,而是把一项工作委托给 effect 对象。例如:
public interface IBuffEffect { void OnAdded(BuffContext ctx); void OnRemoved(BuffContext ctx); void OnTick(BuffContext ctx); }然后不同效果实现自己的逻辑。这样做最大的好处是:以后加“吸血 Buff”“反射伤害 Buff”,只是在 effects 列表里新增一个类,不需要去改 Buff 主流程。主流程只负责遍历、调用生命周期回调。
2.3 运行时实例要记录“来源体”和“累计参数”
建立 BuffInstance 时,除了记录目标角色、Buff 配置之外,至少还要存两个信息:
- source:是谁施加了这个 Buff。
- currentStack:当前层数带来的累计强度。
source 很重要。很多 Buff 的生命周期和施法者绑定。比如“召唤火焰结界,施法者死亡后结界消失”,如果不记录 source,Buff 就无法在施法者死亡时正确清理。还有一些判定要追溯来源,比如“击杀者获得回血”,如果没有 source,就很难判断击杀归属。
currentStack 不只是数量的叠加,还可能改变效果值。比如“每层减速 10%,最多 5 层”,这些效果值在计算时要根据 currentStack 去动态求值。如果做成“添加 Buff 时一次性把数值算死”,后续层数变化、持续时间刷新都会很难处理。
建议:不要把 Buff 的效果值在添加时一次性写死,除非你的战斗里不存在层数变化。
正确的做法是在每次结算时从当前层数和来源属性去算。这样即使玩家中途换装备、升级属性,Buff 的伤害或增益也能跟随当前状态,避免“Buff 添加时很强,几秒后玩家角色变弱了但 Buff 伤害还保持高数值”的奇怪表现。
3. 生命周期状态机:先让 Buff 能正常挂上去再谈扩展
3.1 一个 Buff 从添加到结束要经过哪些阶段
有些项目只做两个状态:存在和不存在。Buff 添加时触发一次;结束时移除。但实战里会漏掉很多关键时机:
- 添加后效果还没生效,UI 应不应该显示?
- 角色处于免疫状态时,这个 Buff 是添加失败还是等待免疫结束再添加?
- Buff 移除时,身上的属性移除和 UI 清理顺序谁先谁后?
- 战斗结束时正在倒计时的 Buff 要不要清除?
为了避免这些遗漏,我建议把状态控制在四个以内:
public enum BuffState { Pending, Active, Removing, Removed }Pending 表示已加入目标列表,但还未触发生效逻辑;Active 是正式生效;Removing 是已经开始移除,但不能重复移除;Removed 表示已完全销毁。状态并不是越多越好,而是保证“同一个 Buff 不会在一次生命周期里被重复触发”。
添加 Buff 的标准顺序是:先创建实例,加入拥有者的 Buff 容器,然后进入 Active,触发 OnAdded。移除 Buff 的推荐顺序是:先标记为 Removing,调用 OnRemoved,再把实例从容器中删除。最好不要在遍历 Buff 列表的过程中直接修改列表,否则容易出现迭代跳项或访问空引用。
3.2 Tick 驱动方式优先于“在 Update 里每帧检查”
Buff 系统里有大量周期性效果,常见如“每 2 秒扣一次血”“每 5 秒恢复一次蓝”。许多初学者会在 Update 里对每个 Buff 都做一帧检查,然后判断有没有到时间点。如果 Buff 数量少,这没问题。可一场战斗如果有几十个怪物,每个怪物身上又有多个周期 Buff,帧循环里做频繁遍历就会增加无意义的开销。
更好的做法是让每个 Buff 自带剩余 tick 间隔,在统一的 BuffUpdate 方法里只做两件事:
- 更新剩余持续时间。
- 到达 tick 节点时调用对应 effect。
一个足够简单的框架示例:
public void OnUpdate(float delta) { for (int i = buffList.Count - 1; i >= 0; i--) { var buff = buffList[i]; buff.remainTime -= delta; if (buff.tickWaitTime > 0) { buff.tickWaitTime -= delta; if (buff.tickWaitTime <= 0) { DoBuffTick(buff); } } if (buff.remainTime <= 0f) { RemoveBuff(buff, BuffRemoveReason.TimeOut); } } }从后往前遍历是因为移除一个 Buff 后,当前位置后面的元素会向前移动,倒序遍历可以避免漏处理。
3.3 角色死亡、战斗结束、清状态时到底怎么处理
没有一套 Buff 框架能覆盖所有游戏,但有几个点一定要提前约定。
第一,角色死亡时,身上哪些 Buff 保留,哪些清除?一般受击类控制效果和持续伤害会在死亡后移除,但有些“死后复活”专用 Buff 需要保留。最简单的方法是给 Buff 配置一个 killClean 标记,或者 buffCategory 分类,避免在角色死亡函数里写死清除所有。
第二,战斗结束和副本结算时,Buff 容器要支持一次性清空。此时需要给每个 Buff 发一个 RemoveReason 参数,让“战斗结束移除”“效果时间结束移除”“被驱散移除”可以区分。否则你写日志时只能看到一行 Remove,根本不知道是逻辑主动删除还是自然消失。
第三,竞技场或野外挂机场景中,如果你把角色切到后台后再回来,要自己检查分钟级或小时级的时间差。很多基于 Update 的框架在页面切到后台时,delta 可能被暂停或累计很大,这时要专门处理长间隔补算,不能直接把一个大 delta 塞进每个 Buff 里,否则有些 Buff 可能被瞬间结算很多次。
4. Buff 进入战斗结算:减伤、免疫、伤害吸收和护盾怎么排
4.1 结算拦截点是 Buff 和伤害系统耦合的地方
Buff 真正影响战斗体验的,不只是“攻击力增加了多少”,更重要的是它在伤害流程中出现在哪个环节。同一个减伤技能,如果写在“计算攻击力”之后,就和写在“最终伤害”之前效果不同,对玩家的观感也不一样。
为此,伤害系统应当预留一套统一的事前和事后处理节点,让 Buff 可以在这些节点中修改伤害上下文:
public class DamageContext { public BattleEntity source; public BattleEntity target; public float finalValue; public float rawValue; public bool isImmune; }Buff 做一个“受伤修正器”时,不应该直接回调伤害函数外部的一堆逻辑,而是把 Buff 注册到目标实体的事件列表里,当实体即将受伤时遍历这些修正器。修正器可以修改 finalValue、设置 isImmune、把直接伤害改成吸收伤害等。
这套设计说难不难,关键只是你不要把结算逻辑散得满项目都是。推荐把流程固定在:
- 造成伤害前触发:主要处理免疫、格挡、分摊、闪避。
- 计算修正阶段:处理百分比减伤、属性减伤、护盾吸收。
- 最终伤害生成后:处理反击、吸血、伤害回蓝、记录伤害事件。
Buff 系统只要能够在这些节点上插入自己的处理函数,就可以实现绝大多数战斗效果。
4.2 同优先级到底谁先谁后
实际项目中经常出现几个减伤 Buff 同时存在,例如“受到物理伤害降低 20%”和“受到伤害降低 10%”。这类通常属于乘法叠加还算直观:最终伤害 = 原伤害 × (1 - 20%) × (1 - 10%)。
可如果出现“减少敌方攻击造成的伤害”和“把所有伤害转移到护盾”这种偏机制型效果,就不适合靠乘法公式调节了。我们要先定义 Buff 的执行优先级。低优先级修正先执行,高优先级后执行,或者反过来,关键是同一个项目里只采用一套约定。
我在框架里通常会给每种 Buff 效果配置一个 order 字段,或者按 buffType 分大类固定优先级。例如控制类、格挡类、无敌类、护盾类、属性类,它们的默认执行顺序是固定的:
- 无敌和闪避类:直接让这次伤害无效。
- 免疫控制类:不进入后续控制结算。
- 护盾吸收类:优先扣护盾值。
- 减伤修正类:修改最终伤害。
- 记录伤害类:统计和触发反击。
注意,顺序本身没有绝对正确,但必须有统一实现,并且要能在配置表表达。不要在没有明确优先级的情况下靠代码顺序解决,因为代码顺序很难给策划做灵活配置。
4.3 控制类 Buff 的效果怎么做更不容易出错
控制效果和普通属性 Buff 不同,它会直接影响行动系统。比如眩晕后角色不能放技能、不能移动;冰冻后可能也不能被击退;嘲讽后普通攻击只能打特定目标。
如果控制只是给角色打个“不能再动”的标记,那么角色在移动、转向、AI 选目标这些地方都要做条件判断。一个更可控的方式是让整个行动系统拥有“可行动能力”和“当前主动状态”两个概念。控制 Buff 负责给行动系统注入受控状态。行动系统统一在“准备执行任何动作”之前检查控制状态,而不是在技能按钮响应函数里判断。
这能避免一个典型 Bug:玩家被眩晕时还能触发被动闪避反击。被动反击也是一种行为,如果被动技能逻辑没有检查控制状态,就会造成“眩晕状态下还能反击”的奇怪问题。所以控制类 Buff 应该影响更底层的行动能力入口,而不是靠每个技能自己处理。
5. 同类型 Buff 叠加、刷新、移除时的常见坑
5.1 同 ID 的 Buff 是叠层还是刷新
不同游戏有不同的算法选择。
- 很多 MOBA 和动作游戏,再次获得同类 Buff 时会刷新持续时间,不叠加层数。
- MMORPG 里,同一 DOT 的来源不同,可能允许各挂一份,各自跳伤害。
- 回合制游戏里,同类减益经常允许层数上限,例如最多 3 层“脆弱”,每层减少一定比例防御。
设计数据模型时,最好在 BuffConfig 中明确表达这个规则,而不是在 AddBuff 函数里硬写:
public class StackRule { public bool allowMultiSource; public bool refreshOnAdd; public int maxStack; public float maxDurationAfterRefresh; }addBuff 的逻辑可以概括为:
- 如果角色身上已有同 configId 的 Buff,并且 allowMultiSource 为 false,那么走刷新或叠层逻辑。
- 如果 allowMultiSource 为 true,并且同来源 Buff 已存在,再决定刷新还是另开一个。
- 如果 maxStack 已满,新的添加可以选择“刷新最长时间”或“拒绝添加”,具体由玩法决定。
这里最容易错的是:同来源和多来源混在一起时,玩家视角看到的是“我放了两层毒”,但数据层可能已经出现多个实例,UI 层也可能重复显示图标。建议至少在 BuffComponent 里维护一个便捷方法:GetBuffByConfigId、GetBuffStackCountByConfigId。
5.2 刷新时数据和表现要一起刷新
当 Buff 被刷新时,不只是剩余时间发生变化。如果 Buff 有层数,刷新后要重算新的 tick 间隔、剩余 tick 等待时间、下次伤害值。如果技能等级更高,刷新后可能还要替换效果强度。
表现层往往比数据层慢。所以只改 remainingTime 而不通知 UI,会出现读秒结束但图标还在的情况;只更新图标不重算数值,又会出现“显示 3 层,实际只按 1 层计算”的问题。我建议在 BuffComponent 内部提供一个统一接口,比如RefreshBuff(instId)。它负责修改实例数据,同时发出一个 buffChanged 事件。这个事件是 UI、伤害编辑器、战斗日志共同订阅的同一个入口,不会漏。
5.3 移除时最容易出两件事:清不干净和多清一次
Buff 移除时有两个高发 Bug。
一是 Clea 不干净。属性加成类 Buff 在效果结束时,没有把原来加成的属性减回去。例如加 20 点攻击力,移除时只移除 15 点,角色属性就会越来越膨胀。解决方法是:所有属性修改都走一个统一的属性附加系统,Buff 模块只做“提交修改”,属性系统记录完整修改来源。移除 Buff 时,从修改来源列表里删除对应项,然后重新计算角色基础属性。不推荐在 Buff 内部自行把属性减回去,这样很容易因为加算顺序错乱产生误差。
二是多清一次。Buff 状态机如果缺少 Removing/Removed 判断,一次移除事件可能同时由“时间到”和“角色被净化”触发。第二次遍历到这个 Buff 时可能已经是空引用。所以移除入口要检查状态,已经在 Removing 的 Buff 不能重复执行 OnRemoved。
6. 排错思路和最小测试用例
6.1 先复现,再改逻辑
Buff 相关的 Bug 通常很恶心,因为错误不会马上暴露。比如“某个 Buff 在叠加到第二层时伤害少了”,你不一定立刻发现。如果你打算给战斗系统写 Buff 框架,我强烈建议准备一个小型战斗演示场景,场景里只做一个木桩和一个施法者。通过控制台命令直接给木桩添加任意 id、层数和时长的 Buff。
这个演示场景不需要美术资源,白盒碰撞体都行。核心是能稳定地重复一套操作,比如:
- 添加 Buff A,等 3 秒,看生效次数。
- 添加 Buff A 两次,检查层数和剩余时间。
- 在 Buff A 存在时添加 Buff B,观察两者是否有意外的属性覆盖。
- 移除 Buff A,再查看木桩属性是否完全还原。
Bug 能不能快速解决,往往取决于你能不能快速复现。像 Buff 这种受持续时间、事件顺序影响很大的系统,纯靠脑内推演很难定位。
6.2 日志要带上实例 ID 和来源 ID
给 Buff 系统加日志时,不要只输出一行“Add Buff”。实例 ID、配置 ID、目标、来源、层数、剩余时间都值得记录。尤其是多个角色同时战斗时,没有实例 ID 根本看不出是哪一份 Buff 出了问题。
推荐在关键节点输出类似这样的文本:
[Buff] Add instId=1024 cfgId=105 stack=1 source=1002 owner=1001 remain=5.0 [Buff] Tick instId=1024 cfgId=105 stack=2 damage=45 remain=3.0 [Buff] Remove instId=1024 reason=TimeOut stack=2 owner=1001如果以后出现数值不对,直接拉日志对比操作输入和输出,很多问题都能看到。
6.3 一定要跑过的几个边界用例
我没必要列一份特别宽泛的测试清单,但有几类问题每次重构都容易踩:
- Buff 自身时间结束和被外部 Clean 同时发生。
- 角色在 Buff 持续中死亡,复活后属性没有恢复到正确状态。
- 拥有 Buff 的敌人从场景中移除时,Buff 容器没有释放,可能造成泄漏。
- 施加 Buff 的施法者在 Buff 持续中死亡,Buff 是否需要消失。
- Buff 效果执行期间,重复添加高层级 Buff,会不会触发两次同样的即时治疗或护盾。
这五类都不要只测一次,最好每次调整 Buff 状态机后都回归测一遍。因为这些问题和数据状态顺序强相关,改一次执行顺序可能就会把某个隐式依赖打碎。
7. 本阶段打磨到什么程度算合格
7.1 从功能角度验收
这一期 Buff 系统做到底,不是看写了多少个类和接口,而是看能不能稳定表达常见战斗状态。我在自己的 RPG 框架里,会拿几个典型效果做验收:
- 物理易伤和魔法易伤叠加。
- 持续伤害每 2 秒触发一次,同时目标身上有护盾。
- 玩家施放加速 Buff 后,又被减速,速度最终按预期公式计算。
- 一个 Buff 被移除后,角色属性面板立即还原。
- 控制效果触发时,目标不能执行主动技能。
如果这几个场景都能跑通,并能通过一批测试用例反复回归,说明基础框架是可用的。如果只是代码能编译,却没有自己的状态机描述,后续还是很容易失控。
7.2 从扩展性角度验收
判断一个 Buff 框架到底合不合格,有一个非常简单的标准:给你一个新的 Buff 需求时,你要改几个文件?
如果你是“改一个 if”就能实现,说明结构可能已经被局部逻辑腐蚀;如果改一个配置数据,建议通过处理通用配置覆盖,通过新增 effect 对象覆盖更多场景,这种效果更好。框架不是越复杂越好。好的框架应该让你 80% 的新增 Buff 都只需要填写配置数据或新增一个 effect 类,而不是每次都要动伤害计算、移动逻辑和 UI 表现。
我在实际使用中还发现一个点:Buff 系统不要太早追求“支持一切”。只要它支持了伤害、治疗、护盾、属性修正、控制、周期触发这些核心效果,并留有干净的事件接口,后面加入“击退”“嘲讽”“变形”就只是扩展问题,而不是推翻重来的问题。
7.3 下一步要处理的事
第 3 期做完 Buff 的数据和生命周期,下一步我通常会补三块:
- Buff 的 UI 表现层。图标挂载、倒计时刷新、层数显示、来源技能说明。
- 配置导出和排错工作台。让策划可以在不写代码的情况下配置 Buff,并在编辑器里预览剩余时间和触发节点。
- Buff 与网络同步的规则。如果要做联机,服务器给客户端下发哪些 Buff 状态,哪些表现本地预测,都需要单独约束。
这期先把框架本身的地基打牢,后面再接表现和联机时会轻松很多。做战斗系统最忌讳的就是急着把效果铺满,却让 Buff 状态变得无据可查。希望这篇梳理能帮你在自己的 RPG 框架里少踩一些 Buff 相关的坑。