如何设计角色属性组件

目标 & 背景

本篇文章是对 ET[^1] 中 NumericComponent 的介绍和补充,会围绕实际开发过程中可能会碰到的一些问题,给一个解题思路,并且会结合 Luban[^2] 给出一个策划和程序都开心的方案

猫大曾经对 NumericComponent 做出过如下评论

单 NumericComponent 就可以完成 80% Moba 类游戏的设计了

属性组件作为游戏战斗中最底层的设计,拥有非常高的设计优先级,一个好的设计可以让你后续节省非常多的时间

下面我们一点点对此组件的设计进行解析,这里要注意,在不同 ET 版本中细节可能稍有不一致,但是整体设计思路都是通的,不一致的部分需要自行鉴别

NumericComponent 内部设计

数据结构

数据结构非常简单,就是一个 Map,key 是当前属性的槽位(NumericType),value 存放当前槽位的具体值

Dictionary<int, Long> dic;

NumericType

这个类型也就是上面说到的槽位,新版的 ET 将 NumericType 改为了 const 实现,这里要注意区分,我们以攻击力为例,最基础的槽位划分如下:

Attack = 1000,
AttackBase = Attack * 10 + 1,
AttackBaseAdd = Attack * 10 + 2,
AttackBasePct = Attack * 10 + 3,
AttackFinalAdd = Attack * 10 + 4,
AttackFinalPct = Attack * 10 + 5,

这里注意区分各个槽位值分配的规律,Attack 这个槽位是只读的,通常我们外部获取当前角色到底有多少攻击力,就是取 dic[1000] 中存放的值

如果你没有接触过这个设计,最开始理解时可能会有一定的困难和费解

而其他的槽位共同组成了 Attack,这里的组成方式需要根据自己项目策划具体的数值规划做相应的调整,假设此时我们希望更新 Attack 的值,其计算过程如下:

final = ((base + baseadd) * (1000 + basepct) / 1000 + finaladd)
* (1000 + finalpct) / 1000

理解这个计算过程非常重要,基于这个计算过程,我们将一个真实属性的值,分散在各个槽位上,简化了后续属性投放,接下来我们模拟一下真实场景,帮助理解这个过程

槽位来源介绍
AttackBase100角色基础属性 100
AttackBaseAdd20队友加成 20
AttackBasePct500自己的 Buff 增加 50% 的攻击力
AttackFinalAdd100战场统一加成 100
AttackFinalPct-500敌方减少 50% 攻击力 Buff

那么此时角色 最终攻击力 = ((100 + 20) * 1.5 + 100) * 0.5 也就是 140 点,此时我们假设,敌方减少 50% 攻击力的 Buff 到期了,只需要将 AttackFinalPct 回滚 500 即可,再次计算攻击力,我们可以得出结果为 280

基于这种设计,我们可以将属性投放简化至仅有加减法,极大降低了属性投放的设计成本,最终值 Attack 槽位与其他的都是 10 倍关系,所以可以应用到所有属性上,这样我们就几乎完成了所有属性的统一设计

实际开发中可能会遇到的问题

当前血量

当前血量这个槽位很特殊,他并没有 BasePct FinalPct 等槽位,ET 中也给了相应的设计,这里我们单独拎出来解释一下

  • Hp = 1000
  • HpBase = Hp * 10 + 1

此时当前血量仅有两个定义,HpHpBase,第一个槽位 Hp 就是当前血量的最终值,而 HpBase 因为没有定义其他槽位,所以可以粗暴的理解为也是最终值,但是所有属性的计算过程都是一致的,在我们更新 Hp 时,依然会获取 10001~10005 的所有值,只不过除了 HpBase 以外,其他的都是 0

限位

一般在属性投放上,策划会给不同槽位限制上下限,这里分两种情况

  • CD 被限制在 [0, 300] 之间
  • 当前血量 Hp,最小值为 0,但是最大值不能大于 MaxHp

这里我们就需要考虑设计两个 Attribute

[AttributeUsage(AttributeTargets.Field)]
public class MinMaxAttribute : Attribute
{
    public long min;
    public long max;

    public MinMaxAttribute(long min, long max)
    {
        this.min = min;
        this.max = max;
    }
}

[AttributeUsage(AttributeTargets.Field)]
internal class MinNTMaxAttribute : Attribute
{
    public long        min;
    public NumericType max;

    public MinNTMaxAttribute(long min, NumericType max)
    {
        this.min = min;
        this.max = max;
    }
}

此时,针对上面两种情况我们在 NumericType 中追加 Attribute 定义即可

[MinNTMax(0, MaxHp)]
Hp = 1000,
HpBase = Hp * 10 + 1,

[MinMax(0, long.MaxValue)]
MaxHp = 1001,
MaxHpBase = MaxHp * 10 + 1,
MaxHpBaseAdd = MaxHp * 10 + 1
// ...

[MinMax(0, 300)]
CD = 1002,
CDBase = CD * 10 + 1,
CDBaseAdd = CD * 10 + 2,
// ...

这样我们只需要在游戏初始化时,对 NumericType 枚举进行遍历,并存放所有枚举的 attr 定义,并插入到 final 值计算过程中,对其进行裁剪即可

内存加密

如果想要对属性组件进行内存加密,非常简单,只需要引入 值类型内存加密[^3] 这个库,并对数据结构修改一下存放值即可

#if SERVER
Dictionary<int, Long> dic;
#else
Dictionary<int, EncryptLong> dic;
#endif

这里要注意的是,我们仅需要在客户端中对内存进行加密,而服务端并不需要,得益于 C# 的 operator 我们其余的代码可以保持不变

但是基于性能考虑,对加密值的修改最好还是使用 xxx.Set() 这个在 README 中有介绍,这里就不做展开了

Luban 配表设计

假设一件装备可以增加 血量、攻击力、防御力,一个天赋可以增加血量百分比,策划在实际设计属性投放时面对的情况是非常灵活的,我们并不希望策划改了一下属性投放,程序就需要跟着做调整,最终要实现不管是什么系统内的属性投放,开发都不需要关心策划到底投放了那些属性

此时我们需要引入一个新的对象 NumericKV,在 Beans.xlsx 文件中的定义参考如下:

full_namecommentnametypecomment
NumericKV数值键值对keys(list#sep=| ),NumericType属性槽
values(list#sep=| ),long属性值

注意看这里的定义,keys 和 values 时两个对应的 list,这样对于程序增加属性的过程会很简单,一个 for 循环就能搞定所有属性的投放,但是!对于策划来说,填写的过程就比较痛苦了

此时我们就需要借助辅助列,通过 Excel 中的公式,找到策划和程序都开心的解决方案,这里我们需要使用 TEXTJOIN 这个函数

values 使用同一行的值,而 key 固定使用蓝色属性槽中的定义,这样策划只需要在辅助列中去做属性投放,而程序真正关心的只有 kv 这个对象

最后

ET 中 NumericComponent 的设计是非常优秀的,在实际项目中解决了非常多的问题,整体设计非常统一,做了上文的一些补充后,我们项目的属性组件几乎没有再改动过,即使面对非常复杂的属性修改稳定性也非常高

但是这个设计有一定的理解成本,初次接触可能会有些懵,但请一定耐心尝试去理解,同时对策划也要耐心讲解,很可能最开始策划对这个设计非常抵触,但当一切理顺之后,投入产出比是非常可观的

参考

[^1]: ET: https://github.com/egametang/ET [^2]: Luban: https://github.com/focus-creative-games/luban [^3]: 内存加密:https://github.com/LiuOcean/ValueTypes_Memory_Encrypt