ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ET框架数值组件设计:用Key-Value与五段式公式支撑MOBA级技能系统

ET框架数值组件设计:用Key-Value与五段式公式支撑MOBA级技能系统 ET框架数值组件设计用Key-Value与五段式公式支撑MOBA级技能系统【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET导读本文深入解析 ET 框架Unity3D Client And C# Server Framework中 cn.etetet.numeric 数值组件的设计思想与实现细节。该组件以 Key-Value 存储配合统一的五段式计算公式解决了魔兽世界、MOBA 类游戏中几十种属性相互影响、Buff 增减益叠加、属性变化监听等复杂需求让技能系统的数值部分从一场灾难变成可配置、可扩展、可热更新的基础能力。读完本文你将掌握 ET 数值组件的数据结构、计算公式、事件通知机制与配置驱动生成流程并能在自己的角色/技能模块中直接落地使用。一、为什么复杂技能系统必须有一套灵活的数值结构类似魔兽世界、MOBA 这种技能极其复杂、灵活性要求极高的游戏数值结构的设计直接决定了技能系统的实现难度。一个人物往往拥有几十种属性移动速度、力量、怒气、能量、集中值、魔法值、血量、最大血量、物理攻击、物理防御、法术攻击、法术防御……而且属性与属性之间相互影响如力量加成物理攻击某个 Buff 给属性增加绝对值某个 Buff 给属性增加百分比甚至还有 Buff 在所有增加值算完之后再在最终值上整体翻倍。如果数值结构设计得当技能系统会非常简单反之则是一场灾难。ET 框架给出的答案是用 Key-Value 保存数值属性 统一公式 事件化监听这也是 5.6数值组件设计 这篇文档的核心内容。二、传统写法的困境面向对象的数值类撑不起复杂技能2.1 每个职业一套字段继承关系爆炸最常见的做法是直接写一个数值类把属性平铺成字段class Numeric { public int Hp; public int MaxHp; public int Speed; // 能量 public int Energy; public int MaxEnergy; // 魔法 public int Mp; public int MaxMp; // ... }问题立刻出现盗贼用的是能量为什么要有Mp字段法师用魔法为什么要有Energy字段当作没看见显然不合理于是有人尝试用继承// 法师数值 class MageNumeric : Numeric { public int Mp; public int MaxMp; } // 盗贼数值 class RougeNumeric : Numeric { public int Energy; public int MaxEnergy; }但游戏里往往有 10 个种族、每个种族 78 种英雄加上法师/战士/盗贼/猎人/牧师……数值类的继承关系立刻失控。面向对象难以适应这种灵活多变的复杂需求——这是文档给出的第一个关键结论。2.2 一个属性要拆成多个二级字段类迅速膨胀就算忽略职业差异单一属性也不能只设计一个字段。比如有 Buff 给 Speed 加 10 点还有 Buff 给 Speed 加 50%那么至少需要三个字段class Numeric { public int Speed; // 速度最终值 public int SpeedInit; // 速度初始值 public int SpeedAdd; // 速度增加值 public int SpeedPct; // 速度增加百分比值 }SpeedAdd与SpeedPct改变后执行一次计算得到最终速度Speed (SpeedInit SpeedAdd) * (100 SpeedPct) / 100每种属性都可能有多种间接影响值照此设计几十种属性就要平铺出上百个字段。更麻烦的是计算公式基本一样却无法统一成一个函数。以MaxHp为例class Numeric { public int Speed; public int SpeedInit; public int SpeedAdd; public int SpeedPct; public int MaxHp; public int MaxHpInit; public int MaxHpAdd; public int MaxHpPct; }还得再写一遍几乎相同的公式MaxHp (MaxHpInit MaxHpAdd) * (100 MaxHpPct) / 100几十种属性就要写几十遍公式并且每个二级属性改变时都要正确调用对应的公式——非常麻烦。2.3 配置表无法简单索引字段另一个致命问题Buff 配置表很难填写对应的属性字段。例如疾跑 Buff增加速度 50%在 Buff 表中怎么配置程序才能简单找到并操作SpeedPct字段面向对象方案下这几乎只能靠硬编码与字符串反射可维护性极差。三、ET 的解法Key-Value 形式保存数值属性3.1 NumericType用整型编码枚举出所有数值类型ET 抛弃了每个属性一个字段的写法改为Key-Valuekey 是数值类型由NumericType枚举定义value 是整数。当前仓库中 NumericType.cs 是自动生成的枚举编码规则清晰可见public enum NumericType { Speed 1000, SpeedBase 10001, SpeedAdd 10002, SpeedPct 10003, SpeedFinalAdd 10004, SpeedFinalPct 10005, HP 1001, MaxHP 1002, MaxHPBase 10021, MaxHPAdd 10022, MaxHPPct 10023, MaxHPFinalAdd 10024, MaxHPFinalPct 10025, MP 1003, MaxMP 1004, // ... }编码规则每个最终属性占用一个整数位如Speed 1000、MaxHP 1002其五个影响分量通过最终值 * 10 1..5推导分量后缀含义X * 10 1Base基础值初始值X * 10 2Add绝对增加值X * 10 3Pct百分比相对 BaseAddX * 10 4FinalAdd最终绝对值加成X * 10 5FinalPct最终百分比加成仓库中还提供了 NumericTypeEnum.cs运行时把枚举值映射为字符串方便日志与调试输出。3.2 NumericComponent一个 Entity 上的数值容器当前实现中NumericComponent是一个挂在Unit上的实体组件NumericComponent.cs[DisableGetComponent] [ComponentOf(typeof(Unit))] public class NumericComponent : Entity, IAwake, ITransfer, IDeserialize { [BsonDictionaryOptions(DictionaryRepresentation.ArrayOfArrays)] public DictionaryNumericType, long NumericDic new(); }数值统一存放在DictionaryNumericType, long中long保证了大数值与精度ComponentOf(typeof(Unit))限定其只能挂在 Unit 上通过unit.NumericComponent访问Unit的扩展属性定义在同一文件中ITransfer、IDeserialize表明该组件可随实体跨场景转移、可反序列化BsonDictionaryOptions(ArrayOfArrays)保证字典能被 MongoDB Bson 正确序列化配合 3.2强大的MongoBson库 中的序列化方案。这种设计的灵活性收益立竿见影法师没有能量属性初始化法师对象时不添加 Energy 的 Key-Value 即可盗贼没有法力值、没有法术伤害初始化时不加即可。新增属性只需在枚举中加一个条目无需改动类结构这正是文档强调的Key-Value 保存属性会变得非常灵活。3.3 读取与写入 API组件的读写逻辑在 NumericComponentSystem.cs 中全部以扩展方法实现EntitySystem 风格// 读取 self.Get(NumericType.Speed); // 返回 int self.GetAsFloat(NumericType.Speed); // 返回 float内部除以 1000 还原小数 self.GetAsLong(NumericType.Speed); // 返回原始 long // 写入 self.Set(NumericType.Speed, 100); // int 重载 self.Set(NumericType.Speed, 12.5f); // float 重载内部转 long 保存值得注意当前仓库实现以 1000 作为浮点精度系数GetAsFloat返回GetByKey / 1000fSet(float)写入value * 1000与文档示例中的 10000 略有差异。实际使用时以仓库代码为准即保留 3 位小数。所有Set最终都进入核心方法Insertpublic static void Insert(this NumericComponent self, NumericType numericType, long value, bool isPublicEvent true) { long oldValue self.GetByKey(numericType); if (oldValue value) return; // 值没变直接返回 self.NumericDic[numericType] value; int numericTypeValue (int)numericType; if (numericTypeValue Max) // 是二级属性如 SpeedAdd触发重算 { self.Update(numericType, isPublicEvent); return; } // ... 最大值钳制、影响联动、事件发布 }其中Max 10000凡是 key 小于 10000 的都是最终属性写入时直接走配置检查与事件发布凡是 key 大于等于 10000 的都是二级属性Base/Add/Pct/FinalAdd/FinalPct写入后立即触发Update重算最终值。四、五段式数值公式base / add / pct / finalAdd / finalPct4.1 一条公式统一所有属性魔兽世界中一个数值最多由 5 个值影响ET 将其统一为一条公式final (((base add) * (100 pct) / 100) finalAdd) * (100 finalPct) / 100仓库中的 Update 实现 与文档公式完全一致且增加了百分比下限保护public static void Update(this NumericComponent self, NumericType numericType, bool isPublicEvent) { int numericTypeValue (int)numericType; int final numericTypeValue / 10; // 推导最终属性 key int bas final * 10 1; int add final * 10 2; int pct final * 10 3; int finalAdd final * 10 4; int finalPct final * 10 5; int pctValue self.GetAsInt(pct); if (pctValue -100) pctValue -100; // 防止出现负血/负速等非法值 int finalPctValue self.GetAsInt(finalPct); if (finalPctValue -100) finalPctValue -100; long result (long)(((self.GetByKey(bas) self.GetByKey(add)) * (100 pctValue) / 100f self.GetByKey(finalAdd)) * (100 finalPctValue) / 100f); self.Insert(final, result, isPublicEvent); // 写回最终属性 }关键点通过numericTypeValue / 10反推最终属性 key再以final * 10 1..5定位五个分量——任何属性的重算逻辑完全相同彻底消灭了几十遍重复公式百分比被钳制在 -100避免减少 150%导致最终值为负数的非法状态结果写回最终属性时再次走Insert从而把最终值变化作为一次普通数值变化发布事件。4.2 Buff 生命周期中的增减操作公式统一之后Buff 只需要操作对应的二级属性即可。以速度为例对应枚举SpeedBase/SpeedAdd/SpeedPct/SpeedFinalAdd/SpeedFinalPct初始值SpeedBase 300Buff110 点绝对速度创建时SpeedAdd 10删除时SpeedAdd - 10Buff220% 速度创建时SpeedPct 20删除时SpeedPct - 20Buff3最终值再 100%创建时SpeedFinalPct 100删除时SpeedFinalPct - 100。由于每个二级属性写入都会自动触发Update重算最终值Buff 无需关心最终值是如何算出来的只增删自己的影响分量即可。最终速度 ((300 10) * (100 20) / 100 0) * (100 100) / 100 744。4.3 Buff 配置表的字段映射因为二级属性都有规整的枚举名SpeedAdd、SpeedPct……Buff 配置表中只需填上相应的NumericType枚举值程序即可通过self.Set(NumericType.SpeedPct, ...)精确操作对应数值彻底解决了文档中Buff 表怎么配置才能让程序简单找到并操作 SpeedPct 字段的难题。五、数值变化事件与监视器属性变化监听5.1 事件发布链路数值变化的监听是技能、成就、UI 等模块的基础能力。当前仓库的发布链路如下Insert写入数值后若isPublicEvent true构造NumbericChange结构体并通过EventSystem.Instance.Publish发布NumericComponentSystem.csEventSystem.Instance.Publish(self.Scene(), new NumbericChange() { Unit unit, New value, Old oldValue, NumericType numericType });NumbericChange携带Unit、NumericType、Old、New四个字段监听方不仅知道变了还知道从多少变成多少。事件处理器 NumericChangeEvent_NotifyWatcher.cs 接收后转交NumericWatcherComponent[Event(SceneType.All)] // 服务端 Map 需要分发, 客户端 CurrentScene 也要分发 public class NumericChangeEvent_NotifyWatcher : AEventScene, NumbericChange { protected override async ETTask Run(Scene scene, NumbericChange args) { NumericWatcherComponent.Instance.Run(args.Unit, args); await ETTask.CompletedTask; } }NumericWatcherComponentNumericWatcherComponent.cs在Awake时通过CodeTypes.Instance.GetTypes(typeof(NumericWatcherAttribute))反射收集所有标有[NumericWatcher]的类按NumericType分组缓存Run时按unit.IScene.SceneType过滤只调用属于当前场景类型的监视器。5.2 监视器声明与场景过滤监视器接口与属性定义在 INumericWatcher.cs 与 NumericWatcherAttribute.cspublic interface INumericWatcher { void Run(Unit unit, NumbericChange args); } [AttributeUsage(AttributeTargets.Class)] public class NumericWatcherAttribute : BaseAttribute { public int SceneType { get; } public NumericType NumericType { get; } // 构造时同时传入 SceneType 与 NumericType }NumericWatcherAttribute携带两个关键信息监听哪个数值NumericType与在哪个场景生效SceneType。同一个数值可以在服务端 Map 场景与客户端 CurrentScene 分别注册不同的监视器互不干扰。5.3 实战示例成就长寿大师文档给出了经典示例——监听 HP 变化生命值超过 1000 时触发成就/// 监视 hp 数值变化 [NumericWatcher(NumericType.HP)] public class NumericWatcher_Hp : INumericWatcher { public void Run(Unit unit, NumbericChange args) { if (args.New 1000) { // 获得成就长寿大师 } } }借助NumbericChange.Old/New还可以轻松实现金币单次变化超过 10000 记异常日志掉血百分比触发受击表现能量回满自动释放大招等跨模块联动。有了这套事件机制写一个属性变化监视器变得非常简单。六、配置驱动的进阶能力源码补充在文档基础上当前仓库的数值组件还演进出了两个配置驱动的能力均来自 NumericComponentSystem.cs 的Insert逻辑6.1 最大值钳制MaxNumericTypeNumericTypeConfig numericTypeConfig self.Fiber().GetSingletonNumericTypeConfigCategory().Get(numericTypeValue); if (numericTypeConfig.MaxNumericType 0) { long max self.GetByKey(numericTypeConfig.MaxNumericType); if (value max) value max; }例如给HP配置MaxNumericType MaxHP则所有 HP 写入都会被自动钳制到当前MaxHP治疗、回血、吸血等操作无需手动判断上限。该配置来自 Luban 表NumericType。6.2 属性联动AffectNumericforeach (var kv in numericTypeConfig.AffectNumeric) { if (kv.Key Max) { Log.Error(...); continue; } // 被影响的值必须 Max NumericType affectNumericType (NumericType)kv.Key; long affectOldValue self.GetByKey(affectNumericType); self.Insert(affectNumericType, affectOldValue kv.Value * (value - oldValue), isPublicEvent); }例如力量增加时按比例联动物理攻击配置Strength.Add - Attack 2则力量每变化 1 点攻击自动变化 2 点。AffectNumeric的联动是基于value - oldValue的增量传播因此多次联动也不会累积误差。源码注释特别提示被影响的值必须 Max(10000)即联动目标是二级属性这样下次写入时才能通过Update正确恢复重算。6.3 静默写入 SetNoEvent某些初始化场景不希望触发事件如从数据库读取角色属性时可用SetNoEvent内部调用Insert(..., isPublicEvent: false)只改值不发事件避免初始化阶段产生大量无效监听调用。6.4 数值枚举的配置生成闭环NumericType枚举不是手写的而是由 Excel 配置生成。仓库中的 ExcelHandler_NumericType.cs 实现了完整闭环遍历Packages下所有cn.etetet.*包的Luban/Config/Datas/NumericType.xlsx读取第 6 行起的表项列依次为序号、ID、名称、注释、GenSecondAttr标记做全局 ID 与名称去重防止跨包重复对标记了GenSecondAttr 1的条目自动追加Base/Add/Pct/FinalAdd/FinalPct五个二级属性按 ID 排序后写出 NumericType.xml再由 Luban 生成NumericType.cs枚举与NumericTypeConfig配置类。这意味着策划在 Excel 中加一行属性运行时代码即可直接使用新枚举属性字段的增删改完全配置化无需修改组件代码。七、总结ET 数值组件设计的三板斧可以归纳为Key-Value 存储用NumericType枚举作 key、long作 value摆脱面向对象字段爆炸属性随用随加、按需初始化五段式统一公式final (((base add) * (100 pct) / 100) finalAdd) * (100 finalPct) / 100任何属性的任何二级分量变化都走同一个UpdateBuff 只增删自己的分量即可事件化监听Insert - NumbericChange 事件 - NumericWatcherComponent 分发成就、日志、UI 等模块通过[NumericWatcher]声明式订阅按场景类型隔离。在此基础上当前仓库还叠加了最大值钳制、属性增量联动、静默写入与 Excel 配置生成枚举等能力构成一个配置驱动、源码兜底、事件解耦的完整数值系统。正如文档所言有了这个数值组件一个 Moba 技能系统可以说已经完成了一半。如果你正在设计自己的角色属性或技能框架这套枚举 Key-Value 统一公式 事件监听的范式非常值得参考与复用。【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表