ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

RPG战斗框架:Unity C# Buff系统设计与生命周期实践指南

RPG战斗框架:Unity C# Buff系统设计与生命周期实践指南 RPG框架中的Buff系统表面上看只是给战斗单位挂一个状态图标实际它处在战斗逻辑的交叉位置技能命中后要触发它伤害公式要读取它单位属性面板要响应它单位死亡后还要正确清理它。做战斗系统时Buff系统如果只靠临时塞字段和到处 if 判断通常在加法术效果时看不出问题一旦接入多个Buff叠加、刷新、持续伤害、死亡还原数据结构上的缺口会被成倍放大。这一篇作为战斗系统RPG框架分享系列的第3期目标不是贴一个“万能模板”而是把设计思路拆开讲清楚先明确Buff要表达什么再设计生命周期接着用 Unity 和 C# 搭一个最小可运行的示例框架最后把常见坑、排查路径和生产级扩展方向整理成可以直接使用的清单。战斗系统学习到一定阶段一定会遇到“同一个攻击Buff有时生效、有时不生效”“Buff消失后属性没有还原”“持续伤害最后一段不跳字”这类问题。大多数情况不是因为某个逻辑不懂而是因为在动手前没有把Buff的边界想清楚。Buff不只是“给角色加一个数值”它是战斗单位在一段时间内被外部效果修改的总入口。理解了这一点新增Buff类型、叠加规则、驱散、死亡清理、网络同步时才能知道新逻辑应该放在数据层、容器层还是技能触发层。1. 先按战斗场景梳理Buff系统的职责边界1.1 同一个Buff往往要同时影响数值、单位和表现RPG中的Buff种类很多增加攻击力、减少受到的伤害、每秒掉血、眩晕、冰冻、护盾、免伤、怒气回复加成。表面看它们没有任何共同点但从战斗系统维护的角度看它们都要回答同样几个问题这个Buff会不会过期如果重复加同一个Buff应该刷新时间、延长时间还是叠加层数Buff生效期间对单位属性做了什么修改单位死亡后这个Buff要不要清掉Buff被移除时有没有需要还原的状态。把Buff做成统一体系最大的收益是让战斗单位存在一种“可统一遍历的状态集合”。战斗循环里计算伤害时不需要从技能列表里逐个找“谁有没有给自己加过Buff”只需要问单位的Buff容器“当前有没有减伤Buff减伤是多少”所有Buff逻辑都收敛到同一个列表中后续排查也只需要关注一条明确链路技能触发添加、容器持有并结算、到期移除移除后还原。常见误区是给每个Buff都单独写一段逻辑比如“火焰Buff每秒扣10血”直接写在技能函数里导致单位无法统一查询“是否免疫火焰”新Buff也只能复制粘贴。正确思路是把Buff分成数据和行为两部分数据描述它是什么行为描述它做什么。数值型Buff可以直接注册修改器持续伤害型Buff由单位更新循环触发控制型Buff则影响动作状态机。这样设计后战斗系统、AI、技能系统都不需要知道具体Buff实现只需要和容器打交道。1.2 把Buff生命周期拆成多个阶段很多混乱可以避免一个Buff从被释放到彻底消失至少经过四个阶段添加阶段技能命中或状态触发判定是否成功添加包括免疫、同类替换、层数上限。生效阶段每帧检查剩余时间和触发间隔执行周期效果比如DoT扣血、回蓝。移除阶段到期、被驱散、被替换、单位死亡或战斗结束时移除。清理阶段还原属性修改、移除伤害减免标记、恢复行动限制、销毁图标。如果Buff只有数据和效果没有明确的生命周期接口最常见的问题是属性Buff。比如一个加30%攻击力的Buff添加时把攻击力改了到期后如果Buff没有正确走到“还原”逻辑攻击力就会永久保留加成下一场战斗数值直接出错。可以用一个阶段表辅助设计每个Buff都要问自己在这个阶段做了什么生命周期阶段需要处理的逻辑漏处理的后果添加免疫判定、同类合并、添加Buff实例、注册属性修改效果重复或属性膨胀生效周期结算、计时递减、状态刷新DoT不跳字、持续时间不准移除打断效果、执行结束回调、刷新单位属性属性没还原、控制状态卡死清理容器列表删除、UI图标移除、引用置空内存泄漏、Buff残留在最初设计接口时就应该把这几个阶段对应的回调方法预留出来而不是只写一个“Apply”方法。很多框架最终膨胀就是因为后来发现需要还原、需要打断、需要叠加层数不得不回头给已有的Buff补状态。1.3 谁负责驱动Buff更新需要尽早定下来Buff的计时和周期结算需要一个驱动者。常见做法有两种一种是每个战斗单位在自己的Update中驱动自身Buff容器另一种是战场或战斗管理器统一驱动所有单位的Buff列表。单体逻辑用单位自身Update即可问题少、定位直观回合制或需要精确时间同步的玩法更推荐用统一的战斗时间步去驱动避免因为单位对象的生命周期差异导致Buff计时不一致。不管选哪种方式都要考虑暂停。Unity项目通常会用Time.timeScale 0实现暂停但很多Buff周期逻辑写在Update里如果游戏暂停时战斗单位仍然被Update驱动就会出现“打开暂停菜单角色还在掉血”的严重问题。推荐把Buff时间推进和对局时间缩放绑定暂停时不要推进Buff计时也不要执行OnTick结算。2. Buff数据结构和运行时实例要分开建模2.1 Buff配置字段决定它能支持多少玩法设计Buff数据结构时最容易犯的错误是把字段加得又多又早或者反过来一开始只放一个名字后面不断加参数。一个合理的BuffData至少需要表达标识、展示、时长、触发间隔、叠加、清理策略和效果参数。可以先把最基础的配置模型定义出来[System.Serializable] public class BuffData { public int BuffId; public string BuffName; public BuffType Type; public float Duration; public float TickInterval; public int MaxStack 1; public StackMode StackMode; public bool IsDebuff; public bool RemoveOnDeath true; public bool CanDispel true; public bool RefreshResetTimer true; public float Value1; public float Value2; }这里的字段含义并不需要照搬但它覆盖了Buff系统最容易出问题的部分BuffId是逻辑标识用于判断同类Buff。BuffName只用于展示和日志不参与逻辑匹配。Duration表示存活时间0 可以约定为无限持续或只存在到战斗结束需要在项目内统一。TickInterval表示多久触发一次周期效果0 表示没有周期结算。MaxStack和StackMode决定重复添加同一个Buff时的合并行为。RemoveOnDeath决定单位死亡后是否清理。Value1、Value2是按具体Buff类型解释的效果参数比如DoT伤害系数、属性增加量。Type 最好用枚举或配置表中的类型码区分处理方式public enum BuffType { AttributeModify, DamageOverTime, HealOverTime, Control, Shield, Custom }如果类型猜不全可以预留Custom或者使用可扩展的字符串类型方便后续接入脚本化效果。不要每个效果都新增一种Type否则Buff系统会变成效果系统。2.2 同一个BuffId会在多个单位上存在多个不同状态在设计时要把Buff配置和Buff运行时实例区分清楚。BuffData描述“这个Buff配置了多久、多少数值”是静态配置真正挂在战斗单位身上的对象是运行时实例它需要记录“剩余时间、当前层数、已经累计的Tick计时、所属单位、施法者”。一个常见的错误用法每个BuffId只存在一个全局实例A单位加Buff时直接修改这个全局实例B单位再添加时把A的剩余时间改掉了最后两个单位共用一个计时器表现和数值全乱。推荐把每一个“单位加了一个Buff”都理解为一次实例化。运行时实例建议这样设计public class BuffRuntimeInstance { public BuffData Data; public BuffBehaviour Behaviour; public Creature Owner; public Creature Caster; public float RemainTime; public float TickTimer; public int StackCount; public bool IsExpired Data.Duration 0f RemainTime 0f; }RemainTime和StackCount必须是实例字段不能放在BuffData里。否则同一个Buff在一次团战中同时加给10个敌人时任何一个单位的计时都会污染其他单位。BuffData作为共享配置字段只允许被读取不允许在战斗中被修改。2.3 用行为类承载逻辑避免在容器里写满 if当Buff种类增多时BuffContainer里如果到处是if (buffData.Type BuffType.DamageOverTime)代码会迅速膨胀。可以用一个行为基类或者接口把Buff逻辑外置public abstract class BuffBehaviour { public virtual void OnAdd(BuffRuntimeInstance instance) { } public virtual void OnTick(BuffRuntimeInstance instance) { } public virtual void OnRefresh(BuffRuntimeInstance instance) { } public virtual void OnRemove(BuffRuntimeInstance instance) { } }然后在工厂方法中根据BufferType创建对应逻辑比如public static class BuffBehaviourFactory { public static BuffBehaviour Create(BuffType type) { switch (type) { case BuffType.AttributeModify: return new AttributeModifyBuff(); case BuffType.DamageOverTime: return new DotBuff(); case BuffType.Shield: return new ShieldBuff(); default: return new CustomBuff(); } } }这样BuffContainer只需要调度生命周期方法不需要知道“攻击力Buff内部怎么改属性”。后续增加新Buff时优先增加一个行为类而不是修改已有调用链。3. Buff容器与生命周期Add、Tick、Remove、清理3.1 BuffContainer是战斗单位的Buff管家每个战斗单位身上应该有一个BuffContainer负责持有当前所有Buff实例、处理添加时的合并、推进计时、触发周期效果、到期移除和死亡清理。下面是最小容器模型先不引入复杂的事件队列public class BuffContainer { private readonly Creature _owner; private readonly ListBuffRuntimeInstance _activeBuffs new ListBuffRuntimeInstance(); public BuffContainer(Creature owner) { _owner owner; } public void AddBuff(BuffData data, Creature caster) { if (_owner.IsDead data.RemoveOnDeath) { return; } if (data.MaxStack 1) { for (int i _activeBuffs.Count - 1; i 0; i--) { if (_activeBuffs[i].Data.BuffId ! data.BuffId) { continue; } HandleSingleStackAdd(_activeBuffs[i], data); return; } } var instance new BuffRuntimeInstance { Data data, Owner _owner, Caster caster, RemainTime data.Duration, TickTimer 0f, StackCount 1 }; instance.Behaviour BuffBehaviourFactory.Create(data.Type); instance.Behaviour.OnAdd(instance); _activeBuffs.Add(instance); _owner.RefreshAttributes(); BuffLog.Info(BuffAdded, _owner.UnitId, data.BuffId, data.Duration); } public void Tick(float deltaTime) { for (int i _activeBuffs.Count - 1; i 0; i--) { var buff _activeBuffs[i]; if (buff.Data.Duration 0f) { buff.RemainTime - deltaTime; if (buff.RemainTime 0f) { RemoveAt(i); continue; } } if (buff.Data.TickInterval 0f) { continue; } buff.TickTimer deltaTime; while (buff.TickTimer buff.Data.TickInterval) { buff.TickTimer - buff.Data.TickInterval; buff.Behaviour.OnTick(buff); if (buff.RemainTime 0f || _owner.IsDead) { break; } } } } public void RemoveBuff(int buffId) { for (int i _activeBuffs.Count - 1; i 0; i--) { if (_activeBuffs[i].Data.BuffId buffId) { RemoveAt(i); } } _owner.RefreshAttributes(); } private void RemoveAt(int index) { var buff _activeBuffs[index]; buff.Behaviour.OnRemove(buff); _activeBuffs.RemoveAt(index); _owner.RefreshAttributes(); } }这个容器有几个关键决策AddBuff在最终加入到列表前先执行了OnAdd。如果OnAdd中有属性注入要确保此时Owner已经知道这个Buff否则可能出现“Buff还没注册属性先改了”的问题。更严谨的做法是先创建实例、加入列表、再触发OnAdd。Tick使用倒序遍历是为了在定时到期的移除操作中避免索引错乱。OnTick里可能调用TakeDamage甚至可能再次触发单位死亡清理所以要在循环后判断Owner是否死亡并中断。RemoveAt显式调用OnRemove而不是直接删列表这是属性还原正确性的关键。实际项目中不要在OnTick回调里直接修改正在遍历的Buff列表安全做法是把“新增Buff”和“移除Buff”放到待处理队列在当前帧Tick结束后统一刷新。上面的示例为了展示主流程使用了直接修改List真实战斗中事件嵌套会比示例复杂得多。3.2 叠加规则是Buff系统最容易理解错的环节先规划三类叠加需求场景期望行为StackMode加攻Buff不能叠再次添加直接忽略None治疗Buff再次刷新时长恢复15秒加血Refresh持续同类型护盾叠加刷新护盾值或保留较高值RefreshBuff结束太快需要延长剩余时间加上新时长Extend多段标记类Buff同层数叠加单独掉血MultiplyLayer要注意叠加规则不能只靠一个Mode字段完成。还需要区分三种刷新时机是否重置剩余时间Refresh类型通常是重置为完整时长。是否重置Tick累计器如果不重置刷新后的第一段伤害可能马上就来如果重置则玩家重新获得完整等待时间。是否保留层数和属性加成属性类Buff叠3层后死亡移除时是一次移除一层还是全部移除。很多项目把“同名Buff重复添加”和“同类Buff添加”混为一谈。比如“减速30%”和“减速50%”是两个BuffId它们之间应该采用优先级覆盖而不是StackMode合并。StackMode只处理同一个BuffId叠加BuffId冲突规则由另一套逻辑决定。设计时如果没把这两者分开就会出现“同一个Buff刷新会把另一种减速顶掉”的诡异问题。3.3 死亡和战斗结束时的清理不能每项目写一套战斗单位死亡时会触发Buff系统最复杂的清理问题。如果一个Buff只是每秒掉血死亡时清理很直接如果Buff是“增加最大生命值上限”死亡清理时需要把MaxHp改回来但此时单位的Hp已经在0附近随意改MaxHp会产生负数或直接复活。推荐用字段从配置层面区分清理策略Buff在单位死亡后是否保留复活后再持续剩余时间。Buff是否只在本次战斗有效战斗结束统一清空。Buff是否随施法者死亡而消失召唤师加给友军的Buff施法者倒下后是否仍然存在。属性还原要放在OnRemove里而不是等死亡清理时手动扣除。死亡清理只负责“把当前单位身上的Buff按规则循环移除”真正还原数值的职责要落在每一个Buff实例的OnRemove方法上。否则清理方法会越长越乱。常见坑是单位死亡回调里调用了BuffContainer.RemoveAllRemoveAll遍历移除Buff每个Buff的OnRemove里又调用了伤害公式或属性刷新属性刷新又触发死亡判定的重新检查形成递归。建议RemoveBuff、RemoveAll和单位死亡判定之间保持单向依赖死亡回调可以通知BuffContainer清BuffBuff的OnRemove不应反向触发一次完整的死亡流程。4. 用 Unity 和 C# 搭一个最小可运行的Buff框架4.1 战斗单位挂载点先有一个Creature类示例中的战斗单位可以很简单重点是把Buff容器挂上去public class Creature : MonoBehaviour { public string UnitName; public float MaxHp 100; public float BaseAtk 10; public bool IsDead { get; private set; } public float CurrentHp { get; private set; } public BuffContainer Buffs { get; private set; } private readonly ListBuffModifier _modifiers new ListBuffModifier(); private void Awake() { CurrentHp MaxHp; Buffs new BuffContainer(this); } private void Update() { if (IsDead) { return; } Buffs.Tick(Time.deltaTime); } public void AddBuff(BuffData data, Creature caster) { Buffs.AddBuff(data, caster); } public void TakeDamage(float damage) { if (IsDead) { return; } float absorbed 0f; var shield Buffs.GetShieldAmount(); if (shield 0f) { absorbed Mathf.Min(shield, damage); damage - absorbed; Buffs.ConsumeShield(absorbed); } CurrentHp - Mathf.Max(0f, damage); if (CurrentHp 0f) { CurrentHp 0f; IsDead true; } } public void AddModifier(BuffModifier modifier) { _modifiers.Add(modifier); RefreshAttributes(); } public void RemoveModifierByBuffId(int buffId) { _modifiers.RemoveAll(m m.BuffId buffId); RefreshAttributes(); } public float FinalAtk (BaseAtk SumAdditiveModifier(0)) * (1f SumPercentModifier(0)); private float SumAdditiveModifier(int attributeId) { ... } private float SumPercentModifier(int attributeId) { ... } public void RefreshAttributes() { // 按当前Buff修改器重新计算最终属性 } }战斗单位字段可以换成更独立的属性组件不要在Creature里堆攻击、防御、暴击、移速几十个字段。示例里只保留攻击力思路不变。Creature.Update驱动Buff容器学习环境够用但正式项目建议用全局战斗服务统一驱动避免单位在场景中未激活时Timer停滞也方便同步对局暂停。4.2 属性修改型BuffOnAdd注册OnRemove还原属性修改型Buff是多数RPG战斗系统的地基比如增加攻击力、增加防御、减少移速。实现思路是Buff在OnAdd时把修改量注册到单位属性模块在OnRemove时注销。public class BuffModifier { public int BuffId; public int AttributeId; public float Add; public float Percent; } public class AttributeModifyBuff : BuffBehaviour { public override void OnAdd(BuffRuntimeInstance instance) { int buffId instance.Data.BuffId; float add instance.Data.Value1; float percent instance.Data.Value2; var modifier new BuffModifier { BuffId buffId, AttributeId 0, Add add, Percent percent }; instance.Owner.AddModifier(modifier); } public override void OnRemove(BuffRuntimeInstance instance) { instance.Owner.RemoveModifierByBuffId(instance.Data.BuffId); } }这里有一个容易踩的坑如果同一个Buff可叠加多层并且每层都注册了一个BuffModifierRemove时不能简单地“按BuffId移除所有Modifier”就把它们全部删光。要根据层数原子化处理。更稳妥的方式是让BuffRuntimeInstance自己保存本实例创建的Modifier引用移除时只删除该实例对应的Modifier。4.3 持续伤害型Buff用TickInterval驱动周期结算DoT类Buff不改变属性面板而是每隔TickInterval触发一次伤害。它的Data配置大致为Duration等于总持续时间TickInterval等于每跳间隔Value1等于每次伤害系数或固定伤害值。public class DotBuff : BuffBehaviour { public override void OnTick(BuffRuntimeInstance instance) { if (instance.Owner.IsDead) { return; } float damage; if (instance.Data.Value1 1f) { damage instance.Data.Value1; } else { damage instance.Caster.FinalAtk * instance.Data.Value1; } instance.Owner.TakeDamage(damage); BuffLog.Info(DotTick, instance.Owner.UnitName, instance.Data.BuffId, damage, damage, remainTime, instance.RemainTime); } }持续治疗Buff可以按同样的模式实现只是把伤害改成回复。要注意HoT不应该让治疗在单位死亡后继续执行所以OnTick第一行判断单位状态是通用习惯。如果把伤害公式放到Buff的OnTick里需要在数值设计上约定Value1到底代表“固定数值”还是“百分比系数”。建议不要用一个判断 1 的临时规则而是在BuffData中加入ValueType比如固定值和百分比二选一。否则策划配置600数值时会被误解成600倍攻击。5. 常用Buff类型拆分属性、DoT、控制、护盾5.1 属性百分比要和固定值分开计算RPG属性公式常见的错误是“固定值加上百分比全部混在float里直接加”。正确公式应该是最终属性 (基础值 固定加成总和) * (1 百分比加成总和)固定加成和百分比加成需要分开累加。BuffModifier里分别有Add和Percent字段正是为了这个目的。如果混加一个“增加10%攻击”的Buff在加攻击Buff前后会被重复计算同一个Buff叠加后收益超出设计。优先级问题也会出现。比如增伤Buff、易伤Debuff、最终伤害加成可能通过不同百分比字段表达。如果在同一套属性公式中把它们全部算在一起伤害容易爆炸。建议抽象成一个多级伤害修饰链public enum ModifierStage { BeforeDefense, AfterDefense, FinalDamage }每个Buff可以指定自己在哪个阶段生效。这样Buff系统才塞得进远程减伤、近战增伤、暴击伤害修正、最终结果百分比等复杂规则。不要把这些规则全部做成“攻击力增加百分之几”。5.2 控制Buff和普通状态之间的交互要单独设计控制Buff如眩晕、冰冻、定身、沉默和属性Buff有一个明显区别它们不改变属性而是改变单位的行动能力。如果RPG框架采用通用状态机控制Buff应该写成“状态限制器”。比如眩晕表示禁止移动、禁止释放技能沉默禁止释放技能但允许移动。控制Buff的移除顺序很关键。一个单位身上同时有“眩晕”和“禁止移动”如果只按BuffId遍历移除可能把“禁止移动”移除后单位还处于眩晕状态却能移动了。常见解决方式是状态机每次根据当前控制类Buff重新计算单位可行动作而不是由单个控制Buff直接翻转状态值。public void RefreshActionState() { bool canMove !Buffs.HasControlType(ControlType.Stun) !Buffs.HasControlType(ControlType.Freeze); bool canCast !Buffs.HasControlType(ControlType.Stun) !Buffs.HasControlType(ControlType.Silence); MovementCtrl.enabled canMove; SkillCaster.SetBlocked(!canCast); }这种“每次重新聚合状态”比“Buff添加时改一个变量Buff移除时再改回来”稳定得多。控制效果通常不是简单的布尔叠加集中计算能避免乱序恢复。5.3 护盾作为受到伤害前的吸收层护盾和血量看起来相似但逻辑上最好不要混在Hp里扣。护盾只需要关心单位受到伤害时优先被谁扣减。Creature.TakeDamage里先查护盾总量先吸伤再扣血就是最简单直接的结构。护盾Buff需要单独记录剩余护盾值。在BuffRuntimeInstance上可以直接用字段存也可以在ShieldBuff内部维护。移除时要注意如果护盾被消耗了一部分Buff到期不能把剩余护盾值全部保留要随着OnRemove一起清零。不要等到Buff移除后才在单位身上扣一次护盾那样会造成“护盾值消失但单位还显示有盾”。6. 常见问题排查从日志反推是数据错还是生命周期错6.1 Buff没生效先按输入链排查现象给单位添加了加攻Buff伤害数字却没有改变。这时不要先怀疑伤害公式要按“是否真正添加成功 - OnAdd是否触发 - 属性是否刷新 - 伤害是否读取最新属性”这条链路排查。常见原因包括添加时单位处于死亡状态AddBuff被判定直接return。同一个BuffId已经存在且StackMode为None二次添加被忽略。BuffType和工厂方法没有匹配工厂返回了空实现。OnAdd执行了但属性组件没有调用RefreshAttributes。战斗单位UI或网络端没有收到同步消息表现层看起来没生效。排查时可以给Buff关键节点输出日志至少覆盖AddBuff进入、合并判定、实例创建、OnAdd、RefreshAttributes这几个节点。很快就能定位断点在哪。6.2 Buff结束后属性不还原重点看移除路径属性未还原的常见原因是Buff到期时直接从列表移除了实例却没有执行OnRemove。比如把到期逻辑写成_activeBuffs.Remove(buff); // 错误没有调用 behaviour.OnRemove这会导致所有在OnRemove里做还原的逻辑全部失效。检查方法是在BuffData里临时设置一个长Duration手动调用RemoveBuff观察属性是否还原。如果手动Remove能还原、到期不能那就是Tick里的移除路径没有走统一RemoveAt方法。另一些情况与叠加有关Buff有3层层数减少时没有把对应的修改量一并减少。比如3层Buff直接生成3个Modifier移除第1层时却按BuffId把全部删光结果属性从加成3份直接变回0。需要让层数记录和Modifier记录一一对应。6.3 DoT跳字次数和配置不符检查Tick计时器的边界设置一个3秒Duration、每1秒跳一次的DoT通常理解为3次伤害。如果Tick逻辑是if (buff.TickTimer buff.Data.TickInterval) { buff.TickTimer 0f; // 错误没用 while扣掉间隔后的余量被丢弃 TickDamage(); }逻辑可能只在“刚好满1秒”的时候执行一次当帧率不高时每帧deltaTime可能超过1秒1秒内只会跳一次而不是按余量补跳。推荐使用while循环扣减TickTimer而不是直接把Timer清零。还要注意第一跳时机。如果BuffDuration包含第0秒立即跳一次总伤害次数可能是1 Duration / TickInterval。这必须在数值文档中约定清楚否则策划写1秒DoT时不知道会不会“添加瞬间就跳一次”。用一张问题表可以快速定位问题现象优先检查点可能原因加攻Buff没生效是否进入AddBuff、是否触发OnAdd工厂未匹配、属性未刷新Buff消失后属性没有还原RemoveAt是否调用OnRemove移除时跳过行为回调叠加Buff伤害异常放大Modifier和层数是否一一对应层数减少没同步减少ModifierDoT跳字比预期少TickTimer是否用while扣减帧间隔大于TickInterval时余量丢失死亡时卡死或重复伤害死亡回调与RemoveAll是否互相触发OnRemove反向触发死亡判定6.4 日志节点要落在生命周期回调上建议每个Buff生命周期方法里输出统一日志格式包含时间、单位Id、BuffId、阶段和关键状态[DamageLog][12.4s][UnitId1001][BuffId3001][OnAdd] atkModify10%, duration8s [DamageLog][14.6s][UnitId1001][BuffId3001][OnTick] damage205 [DamageLog][20.4s][UnitId1001][BuffId3001][OnRemove] remainTime0日志不要打太多字段打出阶段、BuffId、单位Id和持续时间足够。新增Buff时先强制输出这一行之后排查任何Buff问题都有基线数据。生产环境可以关闭这些日志或使用Buffger级别控制但开发期不要省。7. 从Demo走向生产扩展方向与可复用清单7.1 用数据驱动配置替换硬编码Demo里每个BuffEffect写一个类BuffType负责映射。项目进入内容量产阶段后Buff数量会快速膨胀不能再只靠枚举和工厂里一个case一个case地手写。建议把Buff基础配置导出为ScriptableObject或JSON让策划能够填表维护。一套完整配置至少包含基础信息BuffId、显示名、图标、描述文本。类型信息BuffType、关联的SkillEffectId。生命周期Duration、TickInterval、是否可驱散、是否死亡清除。叠加策略MaxStack、StackMode、RefreshResetTimer。数值模块固定值、百分比、阶段、目标属性。表现模块特效ID、音效ID、Buff图标角标。不要把表现字段直接硬编码到Buff逻辑里。Buff特效可以绑定到单位骨骼Buff图标与剩余时间绑定UI。缺少表现层字段会导致逻辑写完后UI又要补一套映射代码改动很散。7.2 投入生产前要补的机制Demo框架能跑通单局战斗离生产还有一段距离。至少要补齐以下几个模块缓存和对象池DoT和护盾这类Buff如果每帧都要产生新对象GC压力会很大。BuffRuntimeInstance和BuffModifier建议用小型对象池管理或者至少保证战斗循环中不频繁 new 临时对象。Buff事件通知Buff添加、移除、刷新后要通知伤害飘字、音效、特效、UI图标。不要让Buff逻辑直接持有UI引用统一用IBuffEventListener或C#事件派发。战斗同步如果是网络游戏Buff由服务器发起添加、计时和移除客户端主要负责表现。战斗单位身上的属性以服务器为准客户端Buff只是预测表现。这里最容易出的Bug是客户端自行计算DoT伤害再用结果回滚服务器导致伤害一跳一跳地跳动最终数值不一致。存档与跨局持久化战斗结束后只把“是否将某种状态带入下一场战斗”的少数Buff保留其余全部清空。若战斗外的被动技能和战斗内的Buff共用一套RuntimeInstance下场战斗要把上次剩余时间清零否则上一次战斗残留的Buff会带进新战斗。7.3 开发新Buff前的检查清单每次新增Buff不要急着写OnTick逻辑先按清单过一遍检查项确认内容它是属性类还是状态类决定使用AttributeModify还是Control/DoT同类再添加怎么办StackMode是否明确Duration和TickInterval边界单位死亡后是否停止结算添加和移除是否对称OnAdd注册的数据是否在OnRemove里还原是否需要即时触发一段效果比如加血Buff要先加一跳再持续UI图标刷新规则剩余时间、层数由哪个字段驱动表现与逻辑是否可分离特效回调是否在生命周期事件里是否影响AI决策眩晕不应只是禁用移动还要停止寻路清单里最重要的是“添加和移除是否对称”。很多BuffBug都源自注册了A后来却只还原了B。7.4 一句总结给新手的练习路径Buff系统是战斗系统里最合适的扩展练习对象。新手可以先从两个Buff练起一个增加攻击力一个每秒掉血把它们放到同一个战斗单位上手工验证叠加、到期、死亡清理三件事。这个练习做完再接触抗性、护盾、减速、击飞、嘲讽、技能冷却缩短时都会发现它们都能被抽象成不同生命周期行为的组合。真正吃透Buff系统后再看技能系统、战斗事件系统、天赋加成系统会更容易理解它们之间的分层关系。下一期如果继续分享战斗系统可以围绕技能触发框架和Buff如何被技能效果调用展开。
返回列表