“Unity技能系统”做到中期最容易被低估的就是Buff管理器。很多项目刚开始做战斗时技能里直接写几个if叠buff用List 硬遍历等到技能数量上了二三十个各种减速、中毒、增伤、免疫混在一起逻辑开始互相打架改一个Buff数值能带出两个无关Bug。这篇文章想把我搭一套可扩展Buff管理器的完整思路和代码细节整理出来从数据结构、Tick更新、叠加驱散、事件解耦到性能优化尽量让读到的人能直接抄走少踩一点我踩过的坑。文章不只是贴代码我会把我为什么这么设计、为什么用字典而不用List、为什么用事件而不是在技能里写死逻辑、为什么说Update里每帧遍历Buff是大忌这些“为什么”也讲清楚。适合正在做ARPG、MOBA、卡牌或者任何带战斗系统的Unity开发者参考不管你是刚写完第一个技能的新手还是正在重构战斗模块的老手这篇都应该有你能直接用上的东西。1. 为什么技能系统里必须有一个专门的Buff管理器1.1 没有管理器的技能系统会变成什么样我先描述一个场景。你拿到一个需求给玩家加一个持续5秒的减速Buff效果是移速降低30%。初版代码很容易长这样void ApplySlow(Player player, float duration, float percent) { player.speed * (1 - percent); player.StartCoroutine(WaitAndRemove(duration, player, percent)); } IEnumerator WaitAndRemove(float time, Player player, float percent) { yield return new WaitForSeconds(time); player.speed / (1 - percent); }单看这个逻辑没问题但几个Buff叠加之后就出事了。玩家同时被减速30%和加速20%你没法用一个speed字段加减法搞定更麻烦的是角色在Buff期间死亡协程还在跑复活后协程归来把速度恢复到一个不存在的旧值Buff被提前驱散你又得手动去把所有协程停掉Buff刷新比如再次中毒要重置持续时间协程版本就直接失控了。这个问题的本质是Buff是一种有时间维度的状态它不能靠零散的协程管理。它需要被统一注册、统一更新、统一移除并且要能叠加、能替换、能响应刷新。这就是Buff管理器存在的理由。1.2 Buff管理器的核心职责划分我在项目里给Buff管理器定了四条硬性职责任何一条都不允许让外部模块去碰负责Buff实例的注册、查询、移除和清空这是生命周期管理。负责所有Buff的定时更新包括持续计时、周期触发、延迟触发统一走管理器自己的Tick。负责Buff叠加策略、刷新策略和驱散回调也就是状态规则。负责对外抛出事件让战斗系统知道“谁被加了什么Buff”但自己不做伤害、不改属性。至于减速的具体数值、中毒的持续伤害、增伤的比例这些都不写在管理器里。管理器只做“调度”不做“计算”。这样设计以后每加一种新Buff我只需要新建一个Buff配置数据和一套回调逻辑管理器代码基本不用动这是“可扩展”三个字的根基。1.3 适用场景与设计目标什么样的项目需要这套东西我的判断标准很简单你的战斗里只要有超过三种可叠加的Buff并且Buff之间有交互比如“减速免疫”要抵消“减速”“灼烧”要被“寒冰”清除你就需要一个统一管理器。如果是休闲游戏里只有一个加速道具那确实没必要上这套架构直接用协程就够了。我这套管理器在设计初定了几个目标后面所有代码都围绕这些目标展开新增Buff不需要改管理器源码通过配置和注册就能完成。运行时查Buff要快战斗中频繁调用不能用List做遍历。每个Buff实例的生命周期清晰开始、结束、被刷新、被驱散都要有对应回调。与技能系统和角色属性系统解耦只通过事件和委托通信。2. Buff数据结构先把地基打牢2.1 数据与实例分离的思路写Buff管理器最容易犯的第一个错误是把“Buff定义”和“Buff实例”混在一起。比如直接在玩家身上挂一个Buff类里面既有配置数据又有剩余时间代码确实是能跑的但想要给两个敌人同时上一个Buff或者同一个Buff在不同等级下效果不同立刻就会复制出好几份重复代码。我的方案是把Buff拆成两层BuffData静态配置数据描述Buff长什么样一个Buff类型对应一份存在ScriptableObject或者配表里。BuffInstance运行时实例描述某个单位身上这个Buff当前的状态包含持续时间、叠层数、当前所属目标。这样做的第一个好处是内存占用低一百个敌人同时中毒只有一百个BuffInstance共享一份BuffData。第二个好处是方便配表驱动数值策划调参不需要找程序改代码。第三个好处是逻辑清晰面向对象里“共性和个性分离”的原则在Buff场景下就是这么落地的。2.2 BuffData 配置类定义“这个Buff是什么”这是我项目里最基本的BuffData定义用ScriptableObject实现方便在编辑器里直接配置也支持用Json配表后运行时加载[CreateAssetMenu(menuName Combat/BuffData)] public class BuffData : ScriptableObject { public string buffID; public string buffName; [Header(基础时长)] public float duration; [Header(类型标签)] public BuffType buffType; public BuffTag[] tags; [Header(叠加规则)] public BuffStackMode stackMode; // None / StackCount / RefreshDuration public int maxStackCount 1; [Header(特效)] public GameObject loopVFX; public AudioClip applySFX; }这里的关键字段有三个我只挑需要说透的讲。stackMode是叠加规则。做战斗系统的都知道同一个Buff重复施加时怎么处理非常重要。我在这里定义了三档None表示重复施法直接刷新时长StackCount表示增加叠层数每一层都有独立数值加成RefreshDuration表示刷新持续时间但叠层不变。有的游戏还要求“不同来源的同类Buff分开计时”这种情况可以在BuffInstance里记录来源是比较后期的扩展但数据结构上要提前留好位置所以我加了tags数组用于类型匹配。用ScriptableObject而不是直接写死在代码里对我这种团队协作的工作流很关键。策划不需要打开代码工程直接在编辑器里创建Buff资产填参数就行。代码里所有对Buff数据的访问都通过buffedID查表也不会出现“改了一个Buff全局都被影响”的事故。2.3 BuffInstance 运行时实例记录“这个Buff现在怎么样了”BuffData是静态蓝图BuffInstance就是运行时的动态状态。当玩家被施加一个持续5秒的减速游戏里就生成一个BuffInstance挂在管理器里每秒扣时间时间归零就移除并执行移除回调。public class BuffInstance { public BuffData data; public int stackCount; public float remainingTime; public float tickTimer; public GameObject target; public GameObject source; public float Duration data.duration; public float Progress remainingTime / data.duration; public bool IsExpired remainingTime 0f; public BuffInstance(BuffData data, GameObject target, GameObject source, int stackCount) { this.data data; this.target target; this.source source; this.stackCount stackCount; this.remainingTime data.duration; } }tickTimer是专门为周期触发准备的字段比如中毒每2秒跳一次伤害火海每0.5秒造成一次灼烧。有了这个字段管理器在Tick里就能统一处理“剩余时间递减”和“周期性触发”两类逻辑。你可能会问target为什么不直接存玩家类而要存GameObject我的经验是Buff是战斗通用模块它不应该依赖具体某个角色类。存GameObject以后伤害计算时再通过GetComponent拿到具体属性组件这样Buff管理器就和角色类解耦了。如果你项目里所有可战斗单位都有统一的接口或基类也可以把GameObject换成基类引用但原则是一样的管理器不知道具体逻辑只负责调度。3. Buff管理器核心逻辑从注册到Tick更新3.1 管理器的基础框架用字典做存储与查找Buff管理器我推荐做成普通的C#类挂在MonoBehaviour上方便走Update但核心数据结构和逻辑用纯C#写这样做有两个好处一是方便单元测试二是如果后续要做帧同步或服务端战斗纯逻辑代码可以直接迁移。管理器内部的核心存储是一个以BuffDataID为键的字典每个键对应一个BuffInstance列表。同一单位最多同时挂多少个同类Buff是有限制的所以用列表存多叠Buff很合适。public class BuffManager : MonoBehaviour { private Dictionarystring, ListBuffInstance buffDict; private void Awake() { buffDict new Dictionarystring, ListBuffInstance(); } public bool HasBuff(string buffID) { return buffDict.ContainsKey(buffID); } public int GetStackCount(string buffID) { if (buffDict.TryGetValue(buffID, out var list)) return list.Count; return 0; } }为什么用字典而不用List最核心的原因是查询频率。战斗里要频繁判断“这个单位是否免疫眩晕”“是否处于霸体状态”如果用List每次都要遍历全部Buff战斗单位一多每帧查询次数会爆炸。字典查询平均时间复杂度是O(1)这是性能上最划算的选择。还有一个细节字典的key我用的是string的buffID而没用枚举。因为配表驱动的项目里策划随时可能新增Buff用枚举就得改代码再编译一次效率太低了。字符串做ID可读性也高但要注意大小写统一我项目里强制约定buffID使用蛇形命名比如slow_down_30配表和代码里完全一致避免字符串写错导致查不到Buff这种隐蔽Bug。3.2 Tick更新管理器如何驱动所有Buff计时Buff管理器之所以要挂在MonoBehaviour上就是为了拿到Update生命周期。每次Update管理器遍历所有Buff实例做三件事刷新剩余时间、处理周期触发、判断是否到期。private void Update() { TickBuff(Time.deltaTime); } private void TickBuff(float deltaTime) { var expiredList new ListBuffInstance(); foreach (var kvp in buffDict) { var list kvp.Value; for (int i list.Count - 1; i 0; i--) { var buff list[i]; buff.remainingTime - deltaTime; // 周期触发逻辑中毒每2秒结算一次伤害 if (buff.data.triggerInterval 0f) { buff.tickTimer deltaTime; if (buff.tickTimer buff.data.triggerInterval) { buff.tickTimer 0f; buff.OnTick?.Invoke(buff); } } if (buff.IsExpired) { expiredList.Add(buff); } } } foreach (var buff in expiredList) { RemoveBuff(buff); } }这里有一个特别容易踩的坑遍历字典时不能直接修改集合。如果你在for循环里直接调用RemoveBuff会触发InvalidOperationException因为字典的迭代器检测到集合被修改了。我把要移除的实例先放进一个临时的expiredList等遍历结束再统一移除这是最简单也最稳妥的做法。还有一个关于Time.timeScale的问题。游戏暂停或者放慢动作时Buff计时需不需要跟着暂停我的做法是在管理器的Tick方法里传入deltaTime由调用方决定传Time.deltaTime还是Time.unscaledDeltaTime。大部分战斗Buff应该跟随游戏时间也就是受timeScale影响但某些UI演示Buff或者暂停菜单里还要继续计时的就要用unscaled。这个灵活度留一个参数就能搞定。3.3 叠加、刷新与驱散Buff生命周期里的三类操作Buff管理器最复杂的部分不是计时而是状态变更。我把状态变更抽象成三个方法AddBuff、RefreshBuff、RemoveBuff分别对应施加、刷新、移除/驱散。AddBuff是入口它要先查字典里是否已有同类Buff再根据BuffData的stackMode决定新叠加还是刷新。这段代码是整个管理器逻辑密度最高的地方public void AddBuff(string buffID, GameObject target, GameObject source) { var data GetBuffData(buffID); if (data null) return; if (data.stackMode BuffStackMode.StackCount) { if (buffDict.ContainsKey(buffID)) { var list buffDict[buffID]; if (list.Count data.maxStackCount) { // 达到叠层上限刷新最旧一层的时长 list[0].remainingTime data.duration; return; } else { var newBuff new BuffInstance(data, target, source, 1); list.Add(newBuff); newBuff.OnApplied?.Invoke(newBuff); return; } } else { var newList new ListBuffInstance { new BuffInstance(data, target, source, 1) }; buffDict.Add(buffID, newList); newList[0].OnApplied?.Invoke(newList[0]); return; } } else if (data.stackMode BuffStackMode.RefreshDuration) { if (buffDict.TryGetValue(buffID, out var list) list.Count 0) { foreach (var buff in list) { buff.remainingTime data.duration; } } else { var newList new ListBuffInstance { new BuffInstance(data, target, source, 1) }; buffDict.Add(buffID, newList); newList[0].OnApplied?.Invoke(newList[0]); } } }RefreshDuration模式专门用于处理“补Buff”的情况。比如中毒还剩1秒时再中一次毒常规做法是重置为完整时长。因为这种模式的叠层数永远只有1所以不需要考虑上限直接遍历所有实例把时间重置就行。RemoveBuff负责处理主动驱散和自然到期。自然到期直接移除就行主动驱散比如净化技能需要额外调用驱散回调让外部逻辑能回应“这个Buff被提前清掉了”。我在BuffInstance上挂了三个委托OnApplied、OnTick、OnRemoved由Buff的具体逻辑去注册这样可以做到管理器和技能逻辑完全解耦。3.4 解耦通信事件驱动让Buff管理器不依赖任何技能逻辑Buff管理器只负责“管理”它不关心Buff具体效果。它要做的是在发生状态变更时把消息广播出去。我的实现方式是给BuffInstance挂委托而不是用UnityEvent。委托更轻量调用代价小也不需要在Inspector里拖引用。实际使用场景举几个例子一个“攻击附带中毒”的被动技能在造成伤害时调用buffManager.AddBuff(poison, enemy, player)。一个“净化”技能调用buffManager.RemoveAllBuff(target)然后BuffInstance上的OnRemoved回调里会把中毒特效销毁、把额外伤害停止。一个“减速抵抗”被动角色属性计算模块调用buffManager.HasBuff(slow_resist)有的话就直接忽略减速效果。所有这些交互都通过委托和查询方法完成Buff管理器不需要知道“攻击”是什么“被动”是什么“净化”是什么。新增一种Buff时我只需要写一份具体的Buff逻辑类实现委托回调然后在配表里填上引用管理器代码完全不用动。关于委托注册的时机我建议在OnApplied回调里做初始化在OnRemoved里做反注册。尤其要注意Buff实例被移除时一定要把委托置空防止引用泄漏。如果Buff的OnTick里注册了伤害计算的匿名函数不清理干净长期运行会积累大量无用的委托引用内存占用会缓慢上涨。4. 实战流程完整构建一个中毒Buff4.1 从配表到实例化的完整调用链理论讲再多不如把一条完整链路走一遍。我挑一个最常见的Buff类型中毒展示它是如何从配表走到角色属性面板的。第一步创建BuffedData资产。在Unity编辑器里右键创建Combat/BuffData填上buffIDpoisonduration5持续5秒triggerInterval1每1秒跳一次伤害stackModeStackCountmaxStackCount3最多叠3层第二步写Buff具体逻辑。因为是中毒我需要一个脚本订阅Buff的Tick回调在每次周期触发时对目标造成伤害。为了方便归类我把Buff逻辑写在BuffData旁边的组件里但更像Scalable的做法是把逻辑做成一个独立类然后用BuffData的字段引用。public class PoisonBuffLogic { public static void OnBuffApplied(BuffInstance buff) { // 播放中毒特效、音效 PlayVFX(buff.target, buff.data.loopVFX); // 给目标属性组件标记中毒状态 var attr buff.target.GetComponentCombatAttribute(); attr.ApplyStatus(StatusType.Poisoned, true); } public static void OnBuffTick(BuffInstance buff) { var attr buff.target.GetComponentCombatAttribute(); int damage 10 * buff.stackCount; attr.TakeDamage(damage, buff.source); } public static void OnBuffRemoved(BuffInstance buff) { // 停止毒特效 StopVFX(buff.target); // 清除中毒状态标记 var attr buff.target.GetComponentCombatAttribute(); attr.ApplyStatus(StatusType.Poisoned, false); } }第三步把逻辑挂到BuffData上。我给BuffData加了一个字段Logic或者直接放一个回调注册方法让BuffInstance在被创建时自动订阅这三个方法。这里最常用的模式是// 在AddBuff创建BuffInstance之后 buffInstance.OnApplied PoisonBuffLogic.OnBuffApplied; buffInstance.OnTick PoisonBuffLogic.OnBuffTick; buffInstance.OnRemoved PoisonBuffLogic.OnBuffRemoved;这样中毒Buff的完整调用链就是技能命中 - 调用buffManager.AddBuff(poison, target, source)- 创建BuffInstance - 触发OnApplied - 播放特效加状态 - 管理器每帧Tick - 每隔triggerInterval触发OnTick - 计算伤害 - 持续时间结束 - 触发OnRemoved - 清理状态。4.2 伤害结算与战斗模块的联动上面这段逻辑里有两个容易被忽略的细节值得单独说。第一个是多层中毒的伤害叠加计算。我的代码里毒伤计算公式是10 * buff.stackCount这依赖于BuffInstance的stackCount。如果管理器用StackCount模式把叠层次数存在实例列表里那么GetStackCount(poison)就能直接得到层数。这样中毒伤害会随层数递增玩家就能感受到“叠满3层毒伤害暴涨”带来的战斗策略。第二种情况是OnBuffTick里伤害结算和事件派发的关系。如果战斗系统里有“收到伤害触发反击”这类逻辑伤害结算时一定要把buff.source传出去这样反击系统才能知道该反击谁。我在很多项目里看到毒伤只传target不传source结果中毒伤害无法触发受击反馈也无法被“反伤铠甲”反射这是Buff事件设计常见的坑。任何由角色造成的伤害哪怕来自间接的持续伤害也要保留施法者引用。还要考虑当目标在中毒期间死亡中毒Buff该怎么处理。我的做法是在目标角色的死亡回调里主动调用buffManager.RemoveAllBuff(target.gameObject)把中毒、灼烧、减速等所有Buff一次性清掉。这比等Buff自然到期更干净因为很多Buff的OnRemoved回调要清理特效、恢复属性如果目标死了特效还在目标尸体上播放非常出戏。4.3 实战中Buff管理器的核心查询接口设计以下是Buff管理器提供给外部调用的查询接口我做成了接口形式方便以后测试时替换mock也更符合依赖倒置的原则。public interface IBuffManager { bool HasBuff(string buffID); bool HasBuff(BuffTag tag); int GetStackCount(string buffID); float GetRemainingTime(string buffID); void AddBuff(string buffID, GameObject target, GameObject source); void RemoveBuff(string buffID, GameObject target); void RemoveBuffByTag(BuffTag tag, GameObject target); void RemoveAllBuffs(GameObject target); void Clear(); }HasBuff(BuffTag tag)特别有用。比如“霸体”不一定要做成一个Buff也可以做成一个标签体系。某些技能需要判断目标是否处于霸体状态时直接查标签就行比遍历所有Buff判断类型的效率高很多。GetRemainingTime是给UI倒计时用的。战斗UI要显示玩家身上的Buff图标和倒计时这个接口能直接返回一个指定Buff的剩余时间UI层不需要知道Buff管理器内部是怎么存储的。4.4 多单位管理对象池与字典的扩展这里要给Buff管理器增加一个维度——如果一场战斗有几十个敌人每个敌人身上都有Buff那怎么办最简单的方法是把BuffManager挂到每一个单位身上各单位只管理自己。这个方案在小规模战斗里完全够用也符合直觉。但如果你想做一个全局管理器管理所有单位的Buff那就把字典再套一层private DictionaryGameObject, Dictionarystring, ListBuffInstance allUnits;这样做的好处是方便做全屏净化和全局Buff查询坏处是性能和内存压力变大。我的建议是先不做全局统一管理等确实需要全屏技能或者跨单位查询Buff时再升级。过早设计多层嵌套会让代码可读性下降而且战斗系统永远是最容易出Bug的重灾区保持数据结构简单是降低Bug率的最有效手段。补充一个关于对象池的点。BuffInstance本身是很小的对象频繁创建和销毁在长期运行中会产生GC压力。如果你发现战斗帧率受GC影响明显可以对BuffInstance做对象池。因为BuffInstance没有复杂的引用关系池化非常容易用一个StackBuffInstanceAddBuff时从池里取RemoveBuff时把委托置空、数据清空再归还。这个优化可以放到性能优化阶段再考虑不要一开始就做容易引入新的Bug。5. 常见问题、踩坑与性能优化清单5.1 五个高频坑与对应方案我复盘过去几个项目整理了Buff管理器最容易出的五个坑每一个我都踩过先写原因再给解法。坑一用协程做Buff计时Buff被驱散后协程还在跑。协程只能停StoreCoroutine的引用一旦Buff是数组管理的你根本拿不到所有协程的引用。解法就是Buff管理器统一Tick不用协程计时。如果一定要用协程那也必须在BuffInstance上缓存Coroutine对象移除Buff时StopCoroutine否则就会出“角色复活后还在掉血”的灵异事件。坑二遍历字典时直接删除导致异常。这个我在3.2节强调过。C#的字典迭代器不允许迭代过程中修改集合强行Remove会抛异常。正确的先用expiredList收集再统一删除。这个坑在战斗系统中非常常见因为Buff的到期回调里往往会触发新的Buff逻辑递归调用AddBuff或RemoveBuff不小心就会踩中。坑三Buff结束特效不销毁。中毒特效、冰冻特效挂在角色身上Buff被驱散时如果OnRemoved里没有清理特效引用特效就会永久留在场景里。这是特别容易漏的地方。我的经验是特效挂到目标子节点时记录引用BuffInstance的OnRemoved里统一做GameObject.Destroy(vfx)。如果担心特效预加载的性能尽量用一个特效容器脚本来统一管理播放和停止。坑四属性恢复顺序导致Bug。加速Buff结束时要把速度除回来但如果玩家在加速期间被减速了除回来的结果就是减速后的值这又变了。这是数值设计上的经典陷阱。正确做法是Buff不直接改基础属性而是把Buff的加成叠加到一个系数上每次计算时用“基础值 加成值”而不是“在现有值上乘/加”。比如moveSpeed baseSpeed * (1 speedBuffPercent)这样不论多少个加速减速Buff叠加计算顺序都不会影响结果。坑五Log输出刷爆。开发调试时在Buff的Tick回调里打Debug.Log比如“中毒造成10点伤害”中毒5秒每秒一次就会出现5条日志如果100个单位同时中毒就是500条Log编辑器直接卡死。做战斗模块调试时尽量用断点或者条件编译的日志开关不要用全局Debug.Log。5.2 性能优化GC压力、字典查找与批处理Buff管理器性能问题一般出现在大规模战斗上。我遇到的实际场景是千人同屏每个单位都有2-3个Buff每帧要管理几千个Buff实例的计时这时候就必须做针对性的优化。第一个优化点是减少Update期间的堆内存分配。3.2节代码里我新建了expiredList每次都new这就是GC压力的一部分。优化方法是在管理器里预分配一个List用完Clear不要在函数内重复new。同样的道理Tick里尽量不要用LINQWhere、ToList这类操作会分配大量临时对象在战斗热路径上是性能杀手。第二个优化点是避免每帧遍历所有Buff做查询。如果Buff数量特别多可以考虑用dirty标记。比如某个属性被Buff影响时设置属性模块的脏标记只有标记变化时才重新计算属性而不是每帧把Buff全算一遍。这种“事件驱动重算”比“每帧轮询”更符合战斗系统的真实需求因为Buff只在施加、移除、叠层变化时才需要影响属性。第三个优化点是合并同类型BuffTick。如果有100个单位同时中毒每个单位每秒都要触发一次OnTick伤害计算这会产生100次伤害事件调用。有些项目会把这部分合并成批量结算比如统一收集所有中毒单位和剩余存活时间隔1秒一次性计算所有毒伤。代价是伤害结算时间会有一点点延迟但对大多数玩法的体验来说无感。第四个优化点是性能分析工具的使用。Unity自带的Profiler就够用你要重点关注的是GC.Alloc和Update里的耗时。如果发现Buff管理器占了总耗时比例过高优先看是不是有频繁的字典扩容、字符串拼接和LINQ查询。字典扩容是个隐形性能杀手AddBuff时如果没给字典预分配容量字典会在扩容时把整个哈希表重新计算一遍。在创建管理器时如果预估有多两倍的Buff种类可以先new Dictionarystring, ListBuffInstance(64)把容量一次性给足。5.3 我现在的编码习惯最后分享几个我写Buff管理器时给自己立的规矩。第一所有对外API只暴露接口不暴露具体类。这样以后不管是做断线重连后的战斗恢复还是做服务端验证都只需要替换接口实现类不用改调用方代码。第二新加Buff时先想清楚它的生命周期再动手写逻辑。Buff和技能的区别在于技能是一次性的行为Buff是一个持续性的状态。持续状态最怕的就是没有清理干净。写任何Buff逻辑前先列一张清单进入时做什么、周期时做什么、结束时做什么、被驱散时做什么全部覆盖再编码。第三多写单元测试。Buff的叠加、刷新、驱散、死亡清理这些操作极其适合写单元测试。用Unity Test Framework跑起来几分钟就能把核心逻辑覆盖完。每次重构管理器之前先跑一遍测试心里就有底了。第四Buff管理器的代码要短小精悍不要在里面堆业务逻辑。我见过有些项目的Buff管理器几百行里面塞了减速公式、毒伤公式、冰冻模型变色、击退物理效果最后完全没办法维护。管理器的职责就是调度和生命周期管理所有具体的Buff效果实现都应该拆到BuffData的子类资产里或者在外部回调里完成。这样哪怕Buff类型再翻一倍管理器仍然是这一套代码这是“可扩展”最实在的体现。