做战斗系统做久了,你会发现 buff 这个东西很难绕开。不管是动作游戏里的增益减益,还是卡牌战斗里的状态变化,本质都是一套“给角色挂上某种配置、按时间结算、到期移除”的流程。我在自己的项目里用 Luban 统一管理 buff 配置,然后用一层薄的运行时框架去消费这些配置,整体做下来非常顺。今天把这套方案拆开聊聊:为什么选 Luban,表格怎么设计,Unity 侧怎么写,以及实际踩过的坑。
Luban 的核心价值不是“生成一堆 C# 类”,而是它让策划和程序在面对同一份配置时,不需要再靠口头约定同步字段。以前项目里最常见的情况是:策划在 Excel 里加了一个“是否可驱散”的列,程序那边的解析逻辑没跟上,或者字段类型写错,一上线 buff 数据全错。用 Luban 把表结构约束住之后,这类低级问题直接少了一大半。在此基础上,buff 系统只要把“数据模型”和“运行时逻辑”拆干净,后面加新 buff、调整叠层规则、接 UI 表现都会轻松很多。
1. 为什么用 Luban 来做 buff 配置
1.1 传统 buff 配置的痛点
早些年我写 buff 系统,第一版非常朴素:直接在代码里定义一个BuffConfig类,加一堆字段,然后在启动阶段硬编码一批实例,或者从 JSON 文件读。策划想要调一个数值,得提流程,程序改代码,再出包。这种方式的病根在于“描述一个 buff 的数据结构”和“系统如何读取这份数据”是绑死的。
后来项目引入了 ScriptableObject,策划可以直接在编辑器里创建 Buff 资产,填比较方便了,但新问题也来了:Buff 资产的字段规则靠程序写在 Inspector 上,策划随便拖,改错了运行时才报错。更麻烦的是,不同模块之间要共用这套数据时,加载方式各写各的,表现层拿不到配置,战斗逻辑又拿同一份数据做二次加工,维护成本不低。
1.2 Luban 解决了什么
Lubban 这类工具做的事情很直接:把 Excel / JSON 当作唯一数据源,通过定义表结构,直接生成强类型代码和运行时数据。buff 表里有哪些列、字段类型是 int 还是 float、是枚举还是列表,在生成的代码里全部固定。策划改配置是改数据,不改结构;程序这边就算换字段名,编译器直接报错,不会拖到运行时才炸。
另一个我很喜欢的能力是它支持枚举、bean、嵌套列表,比如一条 buff 可以携带多个效果,每个效果又可以有自己的参数。这个表达能力刚好是 buff 系统需要的。要是只有“数值一、数值二”这种平铺字段,加一个 buff 效果类型就要加两列,表会越来越胖。
1.3 框架层面的目标
配置层解决完之后,运行时框架的目标就很明确了:
- buff 的所有描述信息都能从配置类拿到,不散落在业务代码里。
- 同一个 buff 可以重复添加、叠层、刷新持续时间,规则由配置字段控制。
- 表现层、战斗逻辑层通过回调感知 buff 的增删和层数变化。
- 新增 buff 不需要改核心逻辑,大部分只需要填表,偶尔加一种新的效果类型才动代码。
这个目标听起来不复杂,但能把四件事都做干净的方案,在战斗系统里并不多见。下面先讲数据表怎么设计,再讲 Unity 侧怎么写。
2. buff 数据表设计:字段、枚举、效果选择
2.1 基础枚举先定好
我在表设计之前,会先把几个核心枚举定下来。不是为了炫方法论,而是因为这些枚举直接决定了后续字段能不能配置得清楚。
BuffType:增益还是减益,或者中立状态。这个字段影响 UI 颜色、技能驱散规则。 BuffDurationType:瞬时、有限时间、无限时间。比如“造成一次伤害”是瞬时;“移动速度提升 5 秒”是有限时间;“直到被驱散”是无限时间。 RefreshPolicy:同类 buff 再次施加时怎么处理,是忽略、刷新持续时间、叠层、还是重置层数。 BuffTriggerType:这个 buff 在什么时机触发效果,比如添加时、移除时、周期性 tick 时、受到伤害时、造成伤害时。
我会在 Luban 的 defines 里把这些枚举定义好,然后在 buff 主表里直接用枚举列。不要用字符串存,手写错一个字母整个功能就没了。
2.2 buff 主表字段
在我的方案里,一张浓缩的 buff 主表大致长这样:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | buff 唯一 ID |
| name | string | 配置名,方便策划看 |
| description | string | 客户端显示文本 |
| buff_type | BuffType | 增益/减益/中立 |
| duration_type | BuffDurationType | 瞬时/有限/无限 |
| duration | float | 持续时间,单位为秒 |
| max_layer | int | 最大层数 |
| refresh_policy | RefreshPolicy | 重复施加时的策略 |
| priority | int | 同优先级覆盖规则时使用 |
| icon | string | UI 图标 |
| effects | list<BuffEffectBean> | 效果列表 |
| triggers | list<BuffTriggerType> | 触发时机列表 |
其中BuffEffectBean是一个自定义 bean,里面至少包含:
| 字段名 | 类型 | 说明 |
|---|---|---|
| effect_type | BuffEffectType | 效果类型 |
| value | float | 数值参数 |
| param | string | 扩展参数,比如 buffId、属性名 |
举个例子,一条“攻击力提升 10%,持续 8 秒,最多叠 3 层”的 buff:
| id | name | buff_type | duration_type | duration | max_layer | refresh_policy | effects | triggers |
|---|---|---|---|---|---|---|---|---|
| 1001 | 攻击增益 | BuffType.Buff | Finite | 8 | 3 | AddLayer | [{"effectType":"AttackAddPercent","value":0.1}] | ["OnAdd"] |
“战斗狂热”这种每次受击叠一层减伤 buff 也是一样的行:
| id | name | duration_type | duration | max_layer | refresh_policy | effects | triggers |
|---|---|---|---|---|---|---|---|
| 1002 | 战意 | Finite | 12 | 5 | RefreshDuration | [{"effectType":"ReduceDamagePercent","value":0.02}] | ["OnAdd","OnTakeDamage"] |
表结构确定后,我会把这些行交给 Luban 生成 C# 代码。生成的BuffCfg类大概长这样(简化版):
public sealed partial class BuffCfg { public int Id; public string Name; public BuffType Type; public BuffDurationType DurationType; public float Duration; public int MaxLayer; public RefreshPolicy RefreshPolicy; public string Icon; public System.Collections.Generic.List<BuffEffectBean> Effects; public System.Collections.Generic.List<BuffTriggerType> Triggers; }这里有一个非常关键的优点:Effects不再是某个“魔法数字数组”,而是强类型的List<BuffEffectBean>,代码里判断effect.EffectType == BuffEffectType.AttackAddPercent时,编译器能帮我查错。漏字段、类型错误在出数据的时候就会报出来。
2.3 effects 和 triggers 怎么填
实际项目里,我推荐在 Excel 单元格里写 JSON 数组,而不是搞一堆effect1_type、effect1_value、effect2_type、effect2_value。后者表会变得极宽,策划填起来也容易错位。用 JSON 数组,一行就能表达“伤害 + 减速 + 标记”这种组合效果。
我自己的填写约定是:
- 效果顺序就是执行优先级,先写核心效果,再写辅助效果。
- 周期性 buff 的 tick 间隔不要单独加字段,放在
BuffEffectBean.param里,比如{"interval":1.0},或者干脆在 effect 里加一个interval字段,更清楚。 - triggers 与 effects 不强行绑定,同一个效果可以挂在多个 trigger 上。比如“回血”既可能挂在 OnAdd 上,也可能挂在 OnTick 上。
策划写完表后,我会跑一遍 Luban 的任务,如果某个 JSON 解析失败、枚举写错,工具会直接报错,不会带着坏数据进游戏。这一步节省的时间,比系统本身写的代码还多。
2.4 用代码消费生成的配置类
拿到Tables对象之后,查 buff 配置非常简单:
BuffCfg cfg = Tables.Instance.BuffTbl.Get(1001); if (cfg == null) { Debug.LogError($"找不到 buff 配置:{buffId}"); return; }一段几百行的 buff 系统,真正“配置读取”的代码可能只有这四五行。后面所有逻辑都在操作BuffInstance,而不是频繁查表。初始化时把配置引用保存下来,运行时只访问内存中的对象,性能上也说得过去。
3. Unity 运行时 buff 系统实现
3.1 核心类怎么划分
运行时我不会把逻辑全塞进一个BuffSystem,那样类会膨胀到没法维护。我分成三个角色:
- BuffSystem:管理所有 buff 实例,负责添加、移除、刷新、tick 分发。
- BuffInstance:单个运行中的 buff,保存配置引用、施法者、目标、当前层数、剩余时间、tick 计时器。
- BuffEffectExecutor:根据配置里的 effect 类型,对目标实体做实际数值修改,比如加属性、回血、施加控制。
单独拆一个执行器,是为了新增效果类型时不用动 BuffSystem 的主流程。加一种新的效果,只改执行器里的 switch 分支就够了。
先看 BuffInstance 的基础形态:
using System; using System.Collections.Generic; public sealed class BuffInstance { public BuffCfg Cfg { get; } public Entity Caster { get; } public Entity Target { get; } public int Layer { get; internal set; } public float RemainingTime { get; internal set; } public float TickAccumulator { get; set; } public bool IsPaused { get; set; } private readonly List<IBuffEventListener> _listeners = new(); public BuffInstance(BuffCfg cfg, Entity caster, Entity target) { Cfg = cfg; Caster = caster; Target = target; Layer = 1; RemainingTime = cfg.DurationType == BuffDurationType.Finite ? cfg.Duration : float.MaxValue; } public void AddListener(IBuffEventListener listener) => _listeners.Add(listener); public void RemoveListener(IBuffEventListener listener) => _listeners.Remove(listener); public void NotifyAdded() { for (int i = 0; i < _listeners.Count; i++) _listeners[i].OnBuffAdded(this); } }Entity是我项目里的通用角色类,换成你的角色组件也行。这里的关键是把施法者和目标都保存在运行实例里,同一个 buff 来自不同人施放时,数据才不会被搞混。
3.2 BuffSystem 的添加与刷新策略
添加 buff 的核心在于“重复施加时的处理”。很多人写死成“再次添加就叠层”,但策划真正想要的规则往往是复杂的:有时候续时间,有时候叠层,有时候完全忽略。我把规则交给配置字段RefreshPolicy,主流程只做策略分发:
public BuffInstance AddBuff(int buffId, Entity caster, Entity target) { if (_buffMap.TryGetValue(buffId, out BuffInstance old)) { switch (old.Cfg.RefreshPolicy) { case RefreshPolicy.Ignore: return old; case RefreshPolicy.RefreshDuration: old.RemainingTime = old.Cfg.Duration; old.NotifyDurationRefreshed(); return old; case RefreshPolicy.AddLayer: if (old.Layer < old.Cfg.MaxLayer) { int beforeLayer = old.Layer; old.Layer++; old.NotifyLayerChanged(beforeLayer, old.Layer); } else { old.RemainingTime = old.Cfg.Duration; } return old; case RefreshPolicy.ResetLayer: old.Layer = 1; old.RemainingTime = old.Cfg.Duration; old.NotifyReset(); return old; } } BuffCfg cfg = _table.Get(buffId); var inst = new BuffInstance(cfg, caster, target); _buffMap.Add(buffId, inst); _buffList.Add(inst); inst.NotifyAdded(); return inst; }这里有个很微妙的地方要提醒大家:叠层满之后,新的同类 buff 再施加,到底是忽略、刷新持续时间,还是继续叠在最大层并刷新时间?这个策划分歧很大。我的默认做法是“层数满之后刷新剩余时间”,因为玩家反馈更友好,不会出现叠满三层后继续放技能毫无收益的感觉。
不过这种事不能拍脑袋,我会把几种策略都做成可配置项,让策划在表里自己选。代码层面只要保证执行结果可预期、层数回调正确,就算基本功扎实。
3.3 Tick 与周期结算
Buff 系统的主循环一般跟着战斗系统或 MonoBehaviour 的 Update 走。每一帧调用 BuffSystem.Tick,内部处理周期效果和剩余时间。
public void Tick(float dt) { for (int i = 0; i < _buffList.Count; i++) { BuffInstance buff = _buffList[i]; if (buff.IsPaused) continue; if (buff.Cfg.Triggers.Contains(BuffTriggerType.OnTick)) { buff.TickAccumulator += dt; float interval = buff.Cfg.TickInterval; while (buff.TickAccumulator >= interval) { buff.TickAccumulator -= interval; _effectExecutor.ExecuteOnTick(buff); } } if (buff.Cfg.DurationType == BuffDurationType.Finite) { buff.RemainingTime -= dt; if (buff.RemainingTime <= 0f) { RemoveBuff(buff.Id); } } } }注意while而不是if:如果游戏帧率突然掉到很低,一帧可能越过多个 tick 周期,用 while 能把缺失的周期补上,避免出现“定身 1 秒一跳”变成“2 秒一跳”的问题。这在长时间挂机、后台恢复的场景里尤其重要。
周期效果我统一走 execute 接口,不回在 Tick 里写具体业务。比如眩晕这种控制效果,它其实不适合走 OnTick,更适合进入控制组件统一管理,避免和移动系统冲突。
3.4 效果执行和属性修改器的绑定
效果执行器是 buff 系统里最容易写乱的地方。如果所有 effect 都在 OnAdd 时硬算一遍,叠层、移除时就会漏。我的做法是:数值类效果不直接在目标属性上“加减”,而是生成一个带来源信息的修改器,挂在目标的属性组件上。
public void ExecuteOnAdd(BuffInstance buff) { Entity target = buff.Target; foreach (BuffEffectBean effect in buff.Cfg.Effects) { switch (effect.EffectType) { case BuffEffectType.AttackAddPercent: var attr = target.GetComponent<AttributeComponent>(); attr.AddModifier(AttributeType.Attack, new Modifier(ModifierOperator.PercentAdd, effect.Value, buff)); break; case BuffEffectType.Heal: if (buff.Cfg.Triggers.Contains(BuffTriggerType.OnAdd)) target.GetComponent<HealthComponent>().Heal(effect.Value); break; case BuffEffectType.Stun: target.GetComponent<ControlComponent>().AddControl(ControlType.Stun, buff); break; } } }基于来源buff挂修改器有一个明显的好处:移除 buff 时,可以精确地把属于它的修改器全部回收。否则你移除“攻击力 +10%”后,万一中间又叠了一层,数值怎么减都可能算错。我见过太多 buff 移除后属性残留的 bug,根源就是没有做来源绑定。
为了彻底处理移除清理,BuffInstance 会在被移除前执行一次 OnRemove 回调,执行器根据配置里的 OnRemove 效果做反向处理:
public void ExecuteOnRemove(BuffInstance buff, bool isExpired) { foreach (BuffEffectBean effect in buff.Cfg.Effects) { switch (effect.EffectType) { case BuffEffectType.AttackAddPercent: var attr = buff.Target.GetComponent<AttributeComponent>(); attr.RemoveModifiersFromSource(buff); break; case BuffEffectType.Stun: buff.Target.GetComponent<ControlComponent>().RemoveControl(ControlType.Stun, buff); break; } } }这样一条链就闭环了:添加时挂来源、叠层时更新数值、移除时按来源回收。
4. 常见问题、难点与排查方法
4.1 叠层和刷新时机搞混
这是 buff 系统里最典型的问题。策划配置的“叠层 3 次”和“持续 10 秒”,如果刷新策略没写明白,会出现两种完全相反的体验。
比如一个“10 秒内攻击力提升,每次施放叠一层”的 buff。玩家第 5 秒再次施放,是应该变成“层数 2、剩余时间从 10 秒重新开始”,还是“层数 2、剩余时间只刷新一半”?两种都有人要过。我的经验是:把 refresh_policy 做成显式枚举,并且默认值用“叠层并刷新持续时间”,因为这个最符合玩家直觉。如果策划要“失败”,让他主动选 Ignore。
排查这类问题时,先看表里的 refresh_policy 是什么,再看 AddBuff 分支有没有命中。大多数“叠层不对”都不是代码 bug,而是表配置和理解不一致。
4.2 遍历集合时删除 Buff
Tick 过程中,一个周期触发可能造成连锁反应:中毒 tick 导致目标死亡,目标身上的 buff 被清空。如果此时 Tick 还在用 foreach 遍历_buffList,直接崩给你看,或者跳过一堆 buff。
我处理这类问题的方式是“延迟移除”:Tick 里发现 buff 到期,只把 buffId 放进待删除集合,等遍历完成后再统一清理。
public void RemoveBuff(int buffId) { if (_buffMap.TryGetValue(buffId, out var buff)) { _pendingRemove.Add(buff.Id); } } public void CommitRemove() { if (_pendingRemove.Count == 0) return; for (int i = _buffList.Count - 1; i >= 0; i--) { if (_pendingRemove.Contains(_buffList[i].Id)) { BuffInstance buff = _buffList[i]; _buffMap.Remove(buff.Id); buff.NotifyRemoved(); _buffList.RemoveAt(i); } } _pendingRemove.Clear(); }注意_buffMap.Remove不要在_buffList的 foreach 里做。Dictionary 的迭代器同样不能容忍结构变化,统一丢到 CommitRemove 里处理最安全。
4.3 目标死亡后 buff 没清理
角色死亡时,身上的 buff 如果不清理,复活后还会带着一堆“死前效果”。尤其是 Dot 类 buff,可能继续 tick 造成负伤害、负治疗,或者显示一个没有效果的图标。
我一般会在实体死亡接口里调用ClearAllBuffs(),并且让 BuffSystem 的 Tick 在目标不可用或已销毁时直接标记移除。还有一个容易被忽略的点:BuffInstance 里的 Target 引用了 Entity,如果实体被对象池回收,引用不会自动置空,必须显式解除监听从列表移除。
4.4 排查清单
如果运行时 buff 表现不对,我会按这个顺序排查:
- 配置表是否最新:重新跑一遍 Luban 生成,确认 BuffCfg 里的字段和 Excel 一致。
- 枚举名是否匹配:effect_type 写成 attackAddPercent,代码里却是 AttackAddPercent,生成期可能不报错但在运行时匹配不上。
- AddBuff 是否真的被调用:看战斗技能事件有没有正确打点。
- 层数叠加是否调用了 NotifyLayerChanged:UI 层数为啥没变,多半是这个回调没实现。
- 移除路径是否兜底:到期移除、主动移除、死亡清除、驱散移除,四类路径都要各自测试。
这个顺序比对着代码一行行找快得多。先把配置层排除掉,再看调用链路,一般 10 分钟内能定位大部分问题。
5. 可扩展的设计补充
5.1 Buff 互斥与分组
有的 buff 天生不能共存,比如“石化”和“加速”,再比如“冰冻”和“燃烧”。我会在配置里增加group_id字段,战斗代码里加一个规则:添加新 buff 时,检查目标身上同组 buff,按优先级覆盖。
最简单的实现是加一批“同组互斥”配置:
public bool CanAddBuff(BuffCfg newCfg, Entity target) { foreach (BuffInstance existing in target.Buffs.GetAll()) { if (existing.Cfg.GroupId == newCfg.GroupId && existing.Id != newCfg.Id) { if (newCfg.Priority > existing.Cfg.Priority) { target.Buffs.RemoveBuff(existing.Id); } else { return false; } } } return true; }注意千万别做成“遍历所有 buff 直接全清”。搞互斥时保留优先级高的那个,而不是简单后添加的覆盖先添加的,会更可控。不然后放一个低优先级 buff 会把强力 buff 顶掉,策划会骂人。
5.2 多来源同 ID buff
同一个 buffId 如果不同角色施加,在竞技场里尤其要小心。两个法师同时对怪物施放灼烧,如果 BuffSystem 用一个 buffId 当键,那就只会有一个 buff,第二个法师的灼烧被覆盖,输出算谁都搞不清。
我的办法是为 BuffInstance 增加一个来源键。当配置里merge_source为 false 时,用(buffId, casterId)作为唯一键;为 true 时才合并成同一个实例。这样既能处理多来源共享层数的情况,也能处理多来源独立结算的情况。
这个字段要在设计表时就想好,等上线再改键规则,旧的存档数据和服务器间的 buff 同步会很难兼容。
5.3 UI 表现层的同步
很多 buff 系统代码把 UI 更新写在业务逻辑里,buff 一加就把图标塞进 UI 列表,一移除又移除。短平快,但问题是这样 UI 和逻辑耦合太近,一旦布局逻辑变化,要改动的地方很多。
我推荐 BuffSystem 维护一个简单的版本号或 dirty 标记,每帧或每隔 0.1 秒让 UI 去拉取当前 buff 列表。UI 不关心谁加的、什么时候加的,它只根据当前 buff 实例的Id、Layer、RemainingTime渲染图标和倒计时。这样当 buff 被死亡清除、被驱散、被互斥顶掉时,UI 都能保持一致。
图标层的复杂场景,比如“同类 buff 只显示最高优先级那个”,也需要基于配置字段show_on_ui、priority来做。每一层都去改 UI 反而更容易错。
5.4 和战斗事件流的衔接
buff 系统的触发时机如果只靠 OnAdd/OnTick/OnRemove 是不够的。真正战斗里还要监听“受击时”“造成伤害时”“死亡时”“治疗时”这些事件。
我的做法是在 BuffSystem 外挂一个简易事件总线,或者直接使用战斗核心里的行动事件。BuffInstance 的 NotifyTrigger 会读取配置里的 triggers,如果是 OnTakeDamage,就由战斗核心在伤害结算前调用它,而不是让 BuffSystem 每帧去轮询碰撞结果。
这样既能做“受到攻击后获得一层护盾”这种即时反应 buff,又能避免为了一个触发把战斗核心和 buff 系统强行耦合。数据表里 triggers 写什么,战斗核心就在对应节点通知什么。把事件名定成字符串容易敲错,更安全的做法还是用枚举或者常量名。
如果让我重新搭一遍 buff 系统,我不会再急着写那些花哨的“多态 buff 子类”。第一件事一定是把表结构定干净,把 refresh_policy 和 effect 列表设计出来,然后让 Luban 帮忙生成强类型代码。剩下的事情顺理成章:BuffSystem 不需要知道具体 buff 是什么,BuffEffectExecutor 只对效果类型负责,UI 只跟系统同步状态,所有业务规则都往配置里放。这种方案最大的收益不是代码量少,而是以后加新玩法时,策划和程序不用再互相等待。