ET框架 Buff 系统深度拆解事件驱动消除技能耦合【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET在基于ET框架的项目里技能系统最容易失控的不是某个具体效果而是 Buff 的创建、tick 与移除过程。一个灼烧命中后UI 要飘字、特效要点火、统计系统要记伤害、死亡判定要抢跑若这些逻辑全部堆进扣血函数改一处就得回归测试整个战斗模块。本文用一个端到端的灼烧Debuff讲清楚 ET 事件机制与数值组件如何支撑起一套低耦合的 Buff 系统。让 ET 事件机制成为技能系统解耦的默认选择先讲一个取舍。Buff 本质上是一种状态变更它改变的是实体身上的数值而不是直接产出某个画面。凡是一次变更、多方响应的场景直接调用都会退化成蜘蛛网——扣血函数里同时调用血条、飘字、音效、统计任何一处需求变化都要动这个函数。ET 的事件总线可以类比成一栋写字楼的广播室数值组件只管对着话筒说1001 号角色掉了 8 点血说完就挂电话UI 组、特效组、成就组在各自工位上听到广播后按自己的规矩办事广播室完全不认识它们。反过来新增一个伤害统计面板时你只需要写一个新的订阅类扣血逻辑一行都不用改。这个方案的代价也有两点要提前说清楚。第一事件名是字符串路由拼错不会编译报错只能靠命名规范和集成测试兜底第二事件是发出去就不管的如果你需要 A 逻辑执行完才能跑 B 逻辑的强顺序事件驱动反而会增加排查成本。事件驱动适合一源多汇的状态广播不适合替代函数调用链本身这也是后文把修改数值和消费数值拆成两层的原因。让 Buff 管理器与数值组件协作完成低耦合技能设计架构上只需要三个角色各自的边界非常清晰BuffComponent挂在实体上的效果口袋负责创建、查询、销毁实体身上所有 Buff 实例本身不关心任何 Buff 的玩法。ABuff基类定义生命周期契约——何时应用效果、何时间隔触发、何时反悔恢复。每个具体 Buff 只填三个空。NumericComponentKey-Value 结构的属性仓库Buff 唯一的合法写入点。数值被改动后由它抛出变化事件下游模块只消费事件。三者协作流程如下ET 框架自带的锁步示例工程里就有这种技能命中后多状态并存的典型场景加载界面的角色立绘背景能直观感受到这类项目对状态层的要求用 Awake/Update/Dispose 钩子管理 Buff 生命周期 ET 的组件生命周期和 Buff 的三段式流程几乎是天然对齐的Awake 对应创建即生效Update 对应间隔触发与到期检查Dispose 对应销毁即反悔。把时间判断收进基类后子类作者永远不需要自己写下一帧该不该 tick。下面这段代码搭出 Buff 基类骨架时间驱动逻辑全部内置public abstract class ABuff: Component { public long Duration; // 总时长0 表示永久 public long Interval; // 间隔触发周期 public long NextTriggerAt; // 下次触发时间点 public long BornAt; // 创建时间戳 public override void Awake() { // 从 Luban 读参数 ⚠️ 统一走配置表禁止子类硬编码 var cfg GameConfig.TableBuff.Get(this.GetType().Name); this.Duration cfg.Duration; this.Interval cfg.Interval; this.BornAt TimeHelper.Now(); this.NextTriggerAt this.BornAt this.Interval; OnApply(); // 落地立即生效 } public override void Update() { // 间隔触发 if(TimeHelper.Now() this.NextTriggerAt) { OnTick(); this.NextTriggerAt this.Interval; } // 到期自检 ⚠️ Dispose 会走 OnRemove 反向恢复 if(this.Duration 0 TimeHelper.Now() - this.BornAt this.Duration) { this.Dispose(); } } public override void Dispose() { OnRemove(); base.Dispose(); } protected abstract void OnApply(); // 创建时应用 protected abstract void OnTick(); // 间隔效果 protected abstract void OnRemove(); // 反向恢复 }关键点三个抽象钩子是应用 / 间隔 / 恢复而恢复不是把 Buff 删掉就完事必须与 OnApply 的写入做镜像对冲否则 Buff 到期后数值会残留。拿一个纯加成的急速Buff 看最小实现它没有间隔效果只关心进出场public class HasteBuff: ABuff { private const int speedPct 10; // 10% 移速 private NumericComponent Numeric() { return this.GetParentUnit().GetComponentNumericComponent(); } protected override void OnApply() { this.Numeric()[NumericType.SpeedPct] speedPct; } protected override void OnTick() { // 纯瞬时加成无间隔效果 } protected override void OnRemove() { // ⚠️ 与 OnApply 严格镜像负号对冲 this.Numeric()[NumericType.SpeedPct] - speedPct; } }关键点写入一律走 NumericComponent 的键值索引Buff 自己不持有、不缓存任何属性值这是数值不残留的前提。让灼烧 Debuff 自动触发并到期销毁 完整示例按定义数值类型 → 编写 Debuff 类 → 订阅事件 → UI 反馈四步走。伤害设定每 1.5 秒 8 点持续 9 秒共 48 点。定义灼烧相关数值类型ET 的数值组件按类型号 × 10 级别组织多级计算因子先补上灼烧与移速两套定义public enum NumericType { // 移速五级因子 Speed 1000, SpeedBase Speed * 10 1, SpeedAdd Speed * 10 2, SpeedPct Speed * 10 3, SpeedFinalAdd Speed * 10 4, SpeedFinalPct Speed * 10 5, // 灼烧专用 BurnDps 2000, // 单次 tick 伤害 BurnInterval 2001, // tick 间隔 }最终值的合成顺序是// 五级因子按固定顺序合成 ⚠️ 顺序错了结果不可解释 final (((base add) * (100 pct) / 100) finalAdd) * (100 finalPct) / 100;关键点Buff 只需往某一格例如 SpeedPct写增量其余因子由组件统一合成多个 Buff 写同一格时自动线性叠加不存在谁覆盖谁的隐式规则。编写 BurnDebuff 类这段代码是灼烧 Debuff 的完整实现时间参数全部交给基类调度public class BurnDebuff: ABuff { private const int burnDps 8; // 每 tick 8 点伤害 protected override void OnApply() { var unit this.GetParentUnit(); // 只声明状态 ⚠️ 不直接碰任何 UI Game.EventSystem.Run(PlayEffect, Burn, unit.Id); } protected override void OnTick() { var unit this.GetParentUnit(); var numeric unit.GetComponentNumericComponent(); int oldHp numeric[NumericType.Hp]; numeric[NumericType.Hp] - burnDps; // 广播伤害事实 ⚠️ 飘字、统计、死亡判定都从这订阅 Game.EventSystem.Run(BurnTick, unit.Id, burnDps, oldHp); } protected override void OnRemove() { var unit this.GetParentUnit(); Game.EventSystem.Run(StopEffect, Burn, unit.Id); } }关键点OnTick 里只做两件事——改数值、发事件。到期销毁由基类 Update 里的 Duration 检查自动触发Debuff 类里一行时间代码都没有。订阅事件并输出 UI 反馈UI 侧只写一个订阅类与伤害来源完全无关[Event(BurnTick)] public class BurnTick_FloatingText: AEventlong, int, int { public override void Run(long unitId, int damage, int oldHp) { var unit Game.Scene.GetComponentUnitManager().Get(unitId); FloatingText.Show(unit.Position, $-{damage}, Color.orange); // 死亡判定由 HpDeath 事件另行订阅 ⚠️ 此处不抢逻辑 } }关键点订阅类接收的是事实掉了多少血、掉之前多少血而不是这是灼烧。飘字模块不需要知道伤害来自灼烧还是平砍这正是事件机制把语义留在发送端、把展示交给订阅端的分工。项目内 PVP 对局界面所用的框架素材展示了这类战斗 UI 在实战中的呈现密度用明确策略解决游戏 Buff 叠加、覆盖与到期三大冲突同一个 Buff 被第二次命中时怎么办实战中基本只有三种模式区别在于第二次写入时做什么决策冲突模式触发场景决策点到期/移除时的恢复方式叠加Stack攻击增益类每层 5 攻击新实例发现同名旧实例合并层数并刷新效果逐层回退全部耗尽才真正 Dispose覆盖Overwrite等级相同的减速 Debuff新实例生效前主动移除旧实例只移除当前实例写入的部分刷新Renew同等级灼烧/中毒保留旧实例仅重置 Duration 与 NextTriggerAt按刷新后的时长自然到期叠加模式的核心代码集中在 Awake 里做一次查重public class StackableStrengthBuff: ABuff { private const int MaxStack 5; private int stack 1; public override void Awake() { base.Awake(); // 查同名实例 ⚠️ 叠加 Buff 禁止创建第二个组件 var exist this.GetParentUnit().GetComponentBuffComponent() .GetBuffStackableStrengthBuff(); if(exist ! null) { exist.AddStack(); this.Dispose(); // 合并进旧实例自我销毁 return; } } public void AddStack() { this.stack Math.Min(this.stack 1, MaxStack); // 重算总增量先对冲旧值再写入新值 this.RefreshEffect(); } private void RefreshEffect() { var numeric this.GetParentUnit().GetComponentNumericComponent(); numeric[NumericType.AttackAdd] this.stack * 5; } }关键点覆盖与刷新必须在写入之前完成决策如果先写再判断两段时间内会出现双倍效果这类 bug 在战斗回放里极难复现。优先级冲突如两个减速各 20%期望上限 30%则建议在 NumericComponent 读取端用 clamp 收口而不是让 Buff 互相感知对方存在。落地建议与下一步 ✅把上面这套结构搬进现有项目时建议按以下顺序推进先冻结数值写入点规定所有属性修改必须经过 NumericComponent用代码评审拦住散落各处的Hp - x。事件名统一加模块前缀BurnTick、HpChange并集中登记在常量类里缓解字符串路由的拼写风险。用镜像恢复作为硬性评审标准每个 OnApply 必须有可对应的 OnRemove否则不予合并。到期判定、层数合并这类容易写错的逻辑放进基类或 BuffComponent让业务 Buff 作者只写效果本身。下一步值得投入的方向是把 Buff 配置全面 Luban 化时长、间隔、层数上限进表以及为高频事件做对象池化以减少 GC。延伸阅读事件机制文档Book/3.4事件机制EventSystem.md数值组件设计Book/5.6数值组件设计.md组件式设计背景Book/4.1组件式设计.md【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考