
为什么ET框架的Buff系统让多个技能叠加也不会打架答案藏在事件驱动和数值组件里【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET上周成就模块的人来找我「生命值大于5000存活」的成就突然不触发了。排查后发现是新上的护盾Buff绕过正常通道、直接改了数值字段导致的。今天就借ET框架的事件机制和键值对数值组件把Buff系统的设计从里到外讲清楚——读完你能独立写出一套完整的DoT型DeBuff多个Buff叠在一起也互不干扰。成就没触发的那晚一个典型的「三处修改」Bug护盾Buff作者的初衷很简单上Buff时给单位加500点上限到期时还原。问题出在他直接写了数值类的「最终值」字段。运行一周后三个症状同时出现吸血技能也在改同一个字段两边互相覆盖数值漂移无法复现到期「还原值」时用的是一刻被别的Buff改过后的值还原错了成就的Watcher订阅的是数值变化事件而直接改字段抛出的事件里最终值不是完整公式的计算结果条件永远差一点。三个问题根源只有一个Buff动了最终值响应模块之间又直接耦合。护盾根本不需要知道成就模块存在更不需要知道吸血存在。所以修正方案只有两条原则Buff只增删「因子」最终值交给公式统一计算数值变化用事件广播血条、音效、成就各自订阅。ET框架Buff系统配图游戏战士与战场场景让Buff只管「改数值」事件驱动的解耦方式ET框架的事件机制允许任何模块「订阅」任意事件。以「受到伤害」为例传统直接调用写法下结算函数得自己去找血条、调音效模块、调成就模块——每多一个响应模块结算代码就要多改一处。事件驱动的写法里结算只改数值、抛事件不关心谁在听// 先存旧值再赋值事件才能携带完整变化量 // 订阅方自己判断这次是「涨了」还是「跌了」 int oldHp numeric[NumericType.Hp]; numeric[NumericType.Hp] oldHp - damage; Game.EventSystem.Run(DamageDealt, unit.Id, damage);血条不再被任何人调用它自己「听」[Event(DamageDealt)] public class DamageDealt_ShakeHpBar : AEventlong, int { public override void Run(long targetId, int amount) { // 只负责「被打了」的表现不关心是哪个技能打来的 var bar Game.Scene.GetComponentHpBarComponent(); bar.Shake(amount); } }音效、成就、飘字模块照着同样的模式各写一个订阅类即可。两种做法的代价对比维度直接调用事件订阅新增一个响应模块修改结算函数零只加一个订阅类耦合度结算代码必须知道所有下游只认事件名故障隔离某模块崩溃波及整个结算流程只影响该订阅者代价即时同步派发订阅方逻辑要克制一个属性五个因子键值对数值组件的算法假设三个Buff同时改攻击速度一个给10一个给20%一个要求最终结果再50%——最终值听谁的谁问谁都说「会冲突」。ET框架的数值组件换了个思路它不存「攻击速度」而是用Key-Value形式存五个键最终值由一条统一公式算出public enum NumericType { Max 10000, Hp 1001, HpBase Hp * 10 1, MaxHp 1002, MaxHpBase MaxHp * 10 1, MaxHpPct MaxHp * 10 3, // 新增受到伤害修正负百分比即减伤 DamageTaken 1003, DamageTakenPct DamageTaken * 10 3, }计算公式固定为final (((base add) * (100 pct) / 100) finalAdd) * (100 finalPct) / 100Buff之间不需要知道彼此10写进add20%写进pct「最终再50%」写进finalPct。任何一个因子变化公式重算并抛出数值变化事件。所谓「叠加冲突」在这里根本不成立。护盾Buff就是按这个思路取「因子位」25%减伤是一个负百分比因子[EntitySystem] private static void Awake(this AbsorbShieldBuff self, int configId) { var numeric self.GetOwner().GetComponentNumericComponent(); // 负百分比即减伤公式负责生效业务侧不用写判断分支 numeric[NumericType.DamageTakenPct] -25; } [EntitySystem] private static void Destroy(this AbsorbShieldBuff self) { // 还原的是因子而不是最终值否则Buff移除后 // 会把其他Buff的贡献一并清零——开头那个Bug的根因 self.GetOwner().GetComponentNumericComponent() [NumericType.DamageTakenPct] - -25; }三个时间戳决定一个Buff的全部生命周期一个Buff的一生只有三个时间点创建即生效、间隔触发tick、到期销毁。前两个不用你操心——ET框架的组件系统会在实体创建时自动调用Awake、删除时自动调用Destroy——需要自己管理的只有「到期」这个时间戳。别在每帧Update里检查是否过期那是最浪费的做法交给周期定时器public class BurnDotBuff : Entity, IAwakeint, IDestroy { public int ConfigId; public long ExpireTime; // 到期时间戳判断「当前时间它」即可 public int Stack 1; // 当前层数 public long TickTimer; // 间隔定时器ID销毁时必须记得摘除 }[EntitySystemOf(typeof(BurnDotBuff))] public static partial class BurnDotBuffSystem { // 周期定时器比每帧轮询便宜只在该结算的时刻被唤醒 [EntitySystem] private static void Awake(this BurnDotBuff self, int configId) { self.ConfigId configId; self.ExpireTime self.GetSingletonTimeInfo().ServerNow() 9000; var timer self.Root().TimerComponent; self.TickTimer timer.NewRepeatedTimer(1500, TimerInvokeType.BurnDotTick, self); self.Tick(); // 上Buff立刻结算一次不等第一个1.5秒 } [EntitySystem] private static void Destroy(this BurnDotBuff self) { // Destroy是唯一出口在这里统一摘除定时器 // 保证Buff无论因何被删定时器都不会变成孤儿 self.Root().TimerComponent.Remove(ref self.TickTimer); } }闭环一个燃烧DoT从注册到销毁的完整代码拼起来燃烧DoT每1.5秒结算15点伤害持续9秒。间隔效果由定时器驱动整个闭环如下tick逻辑写在定时器回调里改数值的手法与前面一致——先改数值再抛事件[Invoke(TimerInvokeType.BurnDotTick)] public class BurnDotTickTimer : ATimerBurnDotBuff { protected override void Run(BurnDotBuff self) { var unit self.Parent.GetParentUnit(); var numeric unit.GetComponentNumericComponent(); int oldHp numeric[NumericType.Hp]; numeric[NumericType.Hp] oldHp - self.GetConfig().DamagePerTick * self.Stack; // 订阅方只关心「打了多少」不关心「是哪个Buff打的」 Game.EventSystem.Run(BurnTick, unit.Id, oldHp - numeric[NumericType.Hp]); } }到期侧不需要在tick里写「检查过期」注册时补一个一次性定时器闭环即完整// 注册时挂一个到期的一次性定时器触发时销毁Buff // Dispose会自动触发Destroy系统把间隔定时器一并摘掉 timer.NewOnceTimer(self.ExpireTime, TimerInvokeType.BurnDotExpire, self);成就统计、飘字、命中音效全部按DamageDealt的订阅模式各自加类Buff代码一行不用动。同一个Buff第二次命中层数上限与效果刷新策略如果玩家在9秒内被第二次点燃这时你有三种策略可选手感各不相同策略行为适用场景刷新叠层并延长持续时间DoT类层数越多痛感越强覆盖新Buff替换旧的只保留一份眩晕等控制类避免重复计时共存各自独立实例荆棘反伤等独立多源效果燃烧取「刷新」。要点是先找已有实例找到就改它找不到才新建public static void Apply(this Fiber fiber, Unit unit, int configId) { var existing unit.GetBuffBurnDotBuff(configId); if (existing ! null) { // 层数上限3Math.Min保证第4次命中不会突破 existing.Stack Math.Min(existing.Stack 1, 3); // 刷新的是持续时间而不是层数最后一下决定烧多久 existing.ExpireTime fiber.GetSingletonTimeInfo().ServerNow() 9000; return; } // 首次施加才创建实例并注册 unit.AddComponentBurnDotBuff(configId); }特别注意重复施加时绝不能新建一个BurnDotBuff否则同一单位上会跑两套tick定时器伤害直接翻倍。三条值得留意的取舍⚠️事件是同步派发的。ET框架的所有逻辑跑在单线程Fiber里事件不会排队到别的线程——好处是没有锁竞争、调试器能单步走完全链路代价是某个订阅方慢了会卡住整帧。所以订阅方的Run要轻改数据、刷UI可以加载资源、发网络请求请挪进协程。数值组件自带一层过滤。真实源码里键值对的setter在「新旧值相等」时直接return、不抛事件所以不必担心「同一个值反复设置」引发事件风暴——前提是你别刻意写振荡值。因子别过度设计。不是每个属性都要占满五个键位一个永远不会被百分比影响的属性只留base final就够了。硬凑五键只会让配置表更难读按需加键才是键值结构的本意。延伸阅读事件机制文档从AwakeSystem到MessageHandler的九类事件数值组件设计文档键值存储与五级因子公式的完整推导技能系统示例代码仓库中真实的Buff tick、定时器与数值订阅实现合上文章就可以动手挑你项目里一行硬编码改属性的地方比如护盾里直接改血量那行换成「因子增删 事件广播」的写法然后数一数下游少了几处if (模块)的调用——那个数字就是解耦的账。【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考