ET框架 Buff 系统深度拆解:事件驱动消除技能耦合
2026/9/17 21:48:30 网站建设 项目流程

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 只填三个空。
  • NumericComponent:Key-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.GetParent<Unit>().GetComponent<NumericComponent>(); } 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.GetParent<Unit>(); // 只声明状态 ⚠️ 不直接碰任何 UI Game.EventSystem.Run("PlayEffect", "Burn", unit.Id); } protected override void OnTick() { var unit = this.GetParent<Unit>(); var numeric = unit.GetComponent<NumericComponent>(); int oldHp = numeric[NumericType.Hp]; numeric[NumericType.Hp] -= burnDps; // 广播伤害事实 ⚠️ 飘字、统计、死亡判定都从这订阅 Game.EventSystem.Run("BurnTick", unit.Id, burnDps, oldHp); } protected override void OnRemove() { var unit = this.GetParent<Unit>(); Game.EventSystem.Run("StopEffect", "Burn", unit.Id); } }

关键点:OnTick 里只做两件事——改数值、发事件。到期销毁由基类 Update 里的 Duration 检查自动触发,Debuff 类里一行时间代码都没有。

订阅事件并输出 UI 反馈

UI 侧只写一个订阅类,与伤害来源完全无关:

[Event("BurnTick")] public class BurnTick_FloatingText: AEvent<long, int, int> { public override void Run(long unitId, int damage, int oldHp) { var unit = Game.Scene.GetComponent<UnitManager>().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.GetParent<Unit>().GetComponent<BuffComponent>() .GetBuff<StackableStrengthBuff>(); 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.GetParent<Unit>().GetComponent<NumericComponent>(); numeric[NumericType.AttackAdd] = this.stack * 5; } }

关键点:覆盖与刷新必须在"写入"之前完成决策,如果先写再判断,两段时间内会出现双倍效果,这类 bug 在战斗回放里极难复现。优先级冲突(如两个减速各 20%,期望上限 30%)则建议在 NumericComponent 读取端用 clamp 收口,而不是让 Buff 互相感知对方存在。

落地建议与下一步 ✅

把上面这套结构搬进现有项目时,建议按以下顺序推进:

  1. 先冻结"数值写入点":规定所有属性修改必须经过 NumericComponent,用代码评审拦住散落各处的Hp -= x
  2. 事件名统一加模块前缀(BurnTickHpChange),并集中登记在常量类里,缓解字符串路由的拼写风险。
  3. 用"镜像恢复"作为硬性评审标准:每个 OnApply 必须有可对应的 OnRemove,否则不予合并。
  4. 到期判定、层数合并这类容易写错的逻辑放进基类或 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询