Buff 系统设计
上一篇讲行为组件的时候,减速组件干了件"甩锅"的事——它自己不管减速持续多久、什么时候消失,而是给目标挂了个 buff,然后就撒手不管了。
ctx.target.addBuff(slow);// 挂上去,剩下的交给buff系统那接锅的 buff 系统到底怎么运作?这一篇就把这口锅接住。你会发现,中毒、灼烧、加攻、护盾、眩晕、狂暴……游戏里几乎所有"持续一段时间的状态",全靠这一套系统撑着。
先想清楚:buff 和普通效果有啥不一样
上一篇的伤害、治疗,都是一锤子买卖——打完这一下,事就结束了,组件执行完就退场。
但 buff 不一样,它的核心特征是**“持续”**:
- 中毒:接下来 5 秒,每秒掉 10 血
- 加攻:接下来 10 秒,攻击力 +20%
- 眩晕:接下来 2 秒,不能动
这就带来一堆一锤子买卖不需要操心的问题:
- 它得记住自己还剩多久,时间到了要自己消失
- 它得在持续期间反复干活(比如中毒每秒都要掉血)
- 它上身和消失时,可能要改变角色的属性(加攻上身时攻击力涨,消失时得还原)
- 同一个 buff 反复叠加怎么办?中毒叠三层是什么效果?
所以 buff 系统的本质,是一套"管理持续状态"的机制。它要管这些状态的生老病死。
一个 buff 长什么样
先把 buff 这个数据结构定出来。它比普通效果多了"时间"和"生命周期"的概念:
classBuff{Stringid;// 唯一标识,比如 "poison"Stringname;// 显示名,"中毒"Entitycaster;// 谁给我挂的(结算伤害归属要用)Entityowner;// 我挂在谁身上floatduration;// 总共持续多久floatremaining;// 还剩多久(每帧递减)intstack;// 当前叠了几层intmaxStack;// 最多能叠几层floattickInterval;// 每隔多久触发一次(中毒每1秒)floattickTimer;// 距离下次触发还有多久}对比一下上一篇的EffectConfig,多出来的全是跟"时间"和"叠加"有关的字段。这就是 buff 的特殊之处。
buff 也用组件化:三个生命周期钩子
还记得上一篇的思路吗——每种效果做成一个统一接口的组件。buff 也照搬这套,只不过 buff 的接口要复杂一点,因为它有生命周期。
一个 buff 在它的一生中,有三个关键时刻:
上身 → 持续期间反复触发 → 消失 onApply onTick onRemove所以 buff 组件的接口设计成三个方法:
interfaceBuffBehavior{voidonApply(Buffbuff);// 刚挂上时执行一次voidonTick(Buffbuff);// 持续期间,每隔 tickInterval 执行一次voidonRemove(Buffbuff);// 消失时执行一次}不是每个 buff 三个方法都要用。看具体是哪种 buff:
中毒——只关心 tick(每秒掉血),上身和消失时啥都不干:
classPoisonBuffimplementsBuffBehavior{publicvoidonApply(Buffbuff){}// 不用管publicvoidonTick(Buffbuff){floatdamage=10*buff.stack;// 每层10点,叠加变强buff.owner.hp-=damage;showDamageNumber(buff.owner,damage);}publicvoidonRemove(Buffbuff){}// 不用管}加攻——只关心上身和消失(上身加攻击力,消失还原),中间不用反复干活:
classAttackUpBuffimplementsBuffBehavior{publicvoidonApply(Buffbuff){buff.owner.attack*=1.2f;// 上身,攻击力涨20%}publicvoidonTick(Buffbuff){}// 不用管publicvoidonRemove(Buffbuff){buff.owner.attack/=1.2f;// 消失,还原}}看出规律了:一锤子的属性变化用 onApply/onRemove 成对处理,周期性的效果用 onTick 处理。这两对钩子基本能覆盖所有 buff。
核心:让 buff 自己"活"起来
buff 挂上去只是开始,得有个东西驱动它——每一帧去更新所有 buff 的时间,该触发触发,该消失消失。这个活儿,交给挂在每个角色身上的"buff 容器"来干。
classBuffContainer{privateList<Buff>buffs=newArrayList<>();privateBuffRegistryregistry;// buff类型 → 组件 的对照表// 每一帧都调用,deltaTime 是这一帧过了多少秒voidupdate(floatdeltaTime){Iterator<Buff>it=buffs.iterator();while(it.hasNext()){Buffbuff=it.next();BuffBehaviorbehavior=registry.get(buff.id);// 1. 处理周期性触发buff.tickTimer-=deltaTime;if(buff.tickTimer<=0){behavior.onTick(buff);// 时间到,触发一次buff.tickTimer+=buff.tickInterval;// 重置计时}// 2. 处理总时长倒计时buff.remaining-=deltaTime;if(buff.remaining<=0){behavior.onRemove(buff);// 时间耗尽,执行消失逻辑it.remove();// 从容器移除}}}}这段是整个系统的发动机。它每帧做两件事:给周期计时器倒数,到点就 tick;给总时长倒数,耗尽就 remove。中毒的"每秒掉血持续5秒"、加攻的"持续10秒后还原",全靠这几行自动跑起来。
老大难问题:buff 叠加怎么处理
同一个 buff 再次挂上来,该怎么办?这是 buff 系统最容易出乱子的地方。现实里有好几种不同的规则,得分清楚:
规则一:叠层数。中毒再中一次,叠成两层,掉血翻倍。
规则二:刷新时间。已经中毒了,再中一次不叠层,但持续时间重新计满(俗称"续毒")。
规则三:又叠层又刷新。最常见,叠一层同时把时间刷新。
规则四:独立共存。两个中毒互不干扰,各自倒计时(少见,但有些游戏这么设计)。
处理叠加的逻辑,放在"添加 buff"的入口统一判断:
voidaddBuff(BuffnewBuff){Buffexisting=findBuff(newBuff.id);// 身上已经有同款吗?if(existing==null){// 没有,直接挂上buffs.add(newBuff);registry.get(newBuff.id).onApply(newBuff);return;}// 已经有了,按叠加规则处理switch(newBuff.stackRule){caseSTACK:// 只叠层existing.stack=Math.min(existing.stack+1,existing.maxStack);break;caseREFRESH:// 只刷新时间existing.remaining=existing.duration;break;caseSTACK_REFRESH://又叠层又刷新 existing.stack=Math.min(existing.stack+1,existing.maxStack);existing.remaining=existing.duration;break;caseINDEPENDENT:// 独立共存buffs.add(newBuff);registry.get(newBuff.id).onApply(newBuff);break;}}叠加规则一定要写进配置,让策划自己定。这个 buff 到底是叠层还是刷新,是策划的设计决策,不该由程序员拍脑袋。
一个隐藏的大坑:叠层时属性怎么算
上面加攻的例子,我写的是onApply里attack *= 1.2。但如果这个 buff 能叠 3 层,问题就来了——叠层的时候只是stack++,并没有再次调用onApply,那多出来的两层攻击力加成怎么生效?
这是 buff 系统最阴险的坑:属性类 buff 遇到叠层,简单的"上身加、消失减"就不够用了。
有个更干净的做法:buff 不直接改属性,而是角色属性每次都"重新算一遍"。
// 角色的最终攻击力,是实时算出来的,不是被buff改来改去的floatgetFinalAttack(){floatattack=baseAttack;// 基础攻击// 遍历所有buff,把加成累加上去for(Buffbuff:buffs){if(buff.id.equals("attack_up")){attack*=(1+0.2f*buff.stack);// 按层数算}}returnattack;}这样一来,buff 只管记录"我有几层",攻击力多少永远是现算的。叠层、掉层、消失,属性自动就对了,根本不用在 onApply/onRemove 里手动加加减减,也就不会出现"加了没减回去"这种经典 bug。
代价是每次读属性都要遍历 buff,有性能开销。数据不常变、读取频繁的话,可以加个缓存,buff 变动时才重算。怎么权衡看项目。
和技能系统怎么接上
绕了一圈,回到最初的问题——上一篇的减速组件是怎么把锅甩给 buff 系统的?现在能看清全貌了:
// 技能行为组件里classSlowEffectimplementsSkillEffect{publicvoidapply(EffectContextctx){Buffslow=newBuff();slow.id="slow";slow.duration=ctx.config.duration;slow.remaining=ctx.config.duration;slow.value=ctx.config.value;slow.stackRule=StackRule.REFRESH;// 减速一般是刷新,不叠加ctx.target.buffContainer.addBuff(slow);// 甩锅给buff系统}}整条链路就串起来了:
配置里写 { "type": "slow", "duration": 3 } ↓ 技能引擎读到,找到 SlowEffect 组件 ↓ SlowEffect 造一个 buff,扔进目标的 BuffContainer ↓ BuffContainer 每帧更新,到点自动移除 ↓ 角色算移速时,实时读取身上的减速buff技能系统负责"产生"buff,buff 系统负责"管理"buff,各管一段,接口清清爽爽。
几个还要注意的点
免疫和驱散。真实游戏里,有"净化"能移除减益 buff,有"免疫"能挡住某些 buff。所以 buff 最好带上分类标签(增益/减益、可驱散/不可驱散、控制类/属性类),方便这些机制批量筛选处理。
控制类 buff 要能互相打断和抵抗。眩晕、冰冻、击飞这些控制 buff,往往有"递减抗性"(连续被控,后续控制时间缩短),还涉及互相覆盖的优先级。这块规则复杂,得单独设计,别硬塞进通用逻辑。
buff 消失的原因不止"时间到"。还有被驱散、角色死亡、被更高优先级 buff 顶掉等等。这些情况都要正确触发 onRemove,否则属性还原不了,角色属性就乱套了(比如加攻 buff 没触发 onRemove,攻击力永久涨着)。
别让 buff 数量失控。有些场景下 buff 会疯狂叠加(比如每帧都挂一个),要设上限,否则容器越来越大,性能崩了。
一句话收尾
Buff 系统这一篇的核心:
buff 就是"带时间的持续状态",用 onApply / onTick / onRemove 三个钩子描述它的一生,用一个每帧更新的容器驱动它自动运转。叠加规则交给配置,属性计算尽量用"实时重算"而不是"手动加减",从根上避免加了忘减的坑。
到这里,技能系统的骨架就比较完整了:数据逻辑分离打地基,数据结构组织数据,行为组件执行瞬时效果,buff 系统管理持续状态。四块拼起来,一套能扛住策划各种花活的技能框架就成型了。
再往下,就是把它们联动起来的事件系统——“击杀后回血”“受击时反弹”"每third次攻击暴击"这类触发式设计,全靠事件机制串联。那是下一个话题了。
