为什么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 : AEvent<long, int> { public override void Run(long targetId, int amount) { // 只负责「被打了」的表现,不关心是哪个技能打来的 var bar = Game.Scene.GetComponent<HpBarComponent>(); 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写进add,+20%写进pct,「最终再+50%」写进finalPct。任何一个因子变化,公式重算并抛出数值变化事件。所谓「叠加冲突」,在这里根本不成立。
护盾Buff就是按这个思路取「因子位」,25%减伤是一个负百分比因子:
[EntitySystem] private static void Awake(this AbsorbShieldBuff self, int configId) { var numeric = self.GetOwner().GetComponent<NumericComponent>(); // 负百分比即减伤,公式负责生效,业务侧不用写判断分支 numeric[NumericType.DamageTakenPct] += -25; } [EntitySystem] private static void Destroy(this AbsorbShieldBuff self) { // 还原的是因子而不是最终值,否则Buff移除后 // 会把其他Buff的贡献一并清零——开头那个Bug的根因 self.GetOwner().GetComponent<NumericComponent>() [NumericType.DamageTakenPct] -= -25; }三个时间戳决定一个Buff的全部生命周期
一个Buff的一生只有三个时间点:创建即生效、间隔触发(tick)、到期销毁。前两个不用你操心——ET框架的组件系统会在实体创建时自动调用Awake、删除时自动调用Destroy——需要自己管理的只有「到期」这个时间戳。
别在每帧Update里检查是否过期,那是最浪费的做法,交给周期定时器:
public class BurnDotBuff : Entity, IAwake<int>, 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.GetSingleton<TimeInfo>().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 : ATimer<BurnDotBuff> { protected override void Run(BurnDotBuff self) { var unit = self.Parent.GetParent<Unit>(); var numeric = unit.GetComponent<NumericComponent>(); 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.GetBuff<BurnDotBuff>(configId); if (existing != null) { // 层数上限3:Math.Min保证第4次命中不会突破 existing.Stack = Math.Min(existing.Stack + 1, 3); // 刷新的是持续时间而不是层数:最后一下决定烧多久 existing.ExpireTime = fiber.GetSingleton<TimeInfo>().ServerNow() + 9000; return; } // 首次施加才创建实例并注册 unit.AddComponent<BurnDotBuff>(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),仅供参考