    说完技能组件，本篇讲讲战斗系统的另一个重要组件：属性组件。

阅读提醒：

1. 本篇尚不涉及属性修改器的管理
    
2. 关于战斗系统的框架设计，可阅读《[战斗系统：框架设计](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247484186&idx=1&sn=fde958e5c26b7e7ba572eee0b64211c8&scene=21#wechat_redirect)》
    

---

属性管理的范围

    按照我们在《战斗系统：框架设计》中的设计，属性即角色身上的数据，包括：攻防血、坐标朝向、模型、等级、名字...

    那么，哪些属性需要存储在属性组件上呢？

    所有可表达为数值（number）的属性，如果放在独立的模块没有变得更优，都可以存储在属性组件。

```
public class AttrComponent {
```

    这样做有什么好处？

    我们可以将属性组件看做角色的数值中心，这有以下好处：

1. 减少战斗系统关注的组件
    
2. 方便属性测试，更支持Mask快速测试角色属性
    
3. 方便属性修改器实现
    
4. 方便数据同步
    
5. 方便属性变化监听
    

---

等级

    角色的等级可以存储在属性组件吗？

    可以！是不是有点反直觉？但确实可以。我在《[角色框架：场景内外玩法数据分离](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247484162&idx=1&sn=2dc7289b8675e9b71748a47997d5e844&scene=21#wechat_redirect)》一篇中提到：角色在场景中的等级和名字等需要和养成系统分离，这样才可以支持复杂的变身玩法。

    在数据分离之后，场景中角色的等级便不再有特殊业务，就是简单的数值而已；因此存储在属性组件中并无坏处，反而可以享受集中存储带来的各种好处。

坐标

    我见过将角色的坐标拆分为 XYZ 三个值独立存储在角色身上的，这么做也许有一些好处：比如通过属性组件自动同步，但我认为这并不是个好的设计。

    从存储的角度讲，角色的坐标和朝向，确实可以拆分为XYZ存储在属性组件，但这有一些问题：

1. 表达力不足：坐标拆散存储，没有Vector3的可读性好。
    
2. 性能问题：坐标和朝向的读写频率极高，使用值类型的Vector3会更高效。
    
3. 职责问题：存储在属性组件的属性，应避免为属性组件增加特殊的职责，而坐标放入属性组件会增加特殊逻辑。
    
4. 扩展性问题：如果项目需要实现子空间，将坐标和朝向封装到独立的模块是更好的选择。
    

  

为角色坐标和朝向建立属性视图

    如果项目已经将角色坐标和朝向存储在了属性组件，那么建议为坐标和朝向封装属性视图，屏蔽底层的存储方式。

```
public class AttrComponent {
```

死亡

    在传统框架下，死亡通常是角色的一个属性；有些项目是直接定义在了GameObject上，另一些项目则可能存储在属性组件上。

    而在作者的“一切皆状态”架构下，死亡不是角色身上的属性；判断角色是否死亡，是查询角色身上是否包含State实现的。

```
// 传统架构
```

运行时 ≠ 配置时

    虽然在运行时，我们会将角色的等级，怪物的强度等属性注入到属性组件；但策划在配置Npc时，这些特殊的属性通常还是分开配置的；即特殊的属性会定义为单独列（或编辑器项）进行配置，而不会和纯粹的战斗属性（攻防血这些）混合在一起。

---

属性的分类

    属性，按照每一个数值是否具有特殊的含义，可以划分为：数值属性（number）、功能属性（bool）和枚举属性（enum）。

数值属性（number）

    数值属性是指属性的不同数值的含义相同，比如大家熟悉的攻防血。

    数值属性还需要根据是否可能为小数，分为整数属性和浮点数属性，像攻防血就是整数属性，而移动速度就是浮点数属性。

只要变化就同步和派发事件

    简单数值属性，只要数值发生变化，就进行网络同步和派发事件。

```
public void Set(int type, double value) {
```

PS：哪些属性需要同步和派发事件是需要规划的，这里后面讨论。

---

  

功能属性（bool）

    功能属性是指仅区分0和非0类型的属性，比如：禁止移动、禁止施展技能、AI不可见。

    功能属性可以看做角色支持的功能开关，因为它们负责的是功能的开启和禁用。

    功能属性通常由状态（State）添加，StateCfg中就包含了获得状态时需要添加给角色的功能属性。

```
public class StateCfg {
```

PS：功能属性（bool属性）是枚举属性的特殊情况。

避免负数

    功能属性应当尽可能避免产生负数，这会带来奇怪的语义。

0和非0突变时同步和派发事件

    功能属性，只有在值由0变为非0（通常指大于0），和非0变为0时才需要同步和派发事件。

```
public void Set(int type, double value) {
```

MASK缓存

    属性组件上的mask缓存是为功能属性设计的。业务经常需要查询角色身上的功能禁用信息，通过数组测试相交固然可行，但不够高效。

```
// 通过迭代数组测试相交
```

BitSet仅128位？

    128个功能属性对于绝大多数的项目来说都是足够的，而且128位的BitSet实现简单且效率极好；因此默认情况下，我们使用的都是128位的BitSet。（必要时扩展到256位也不麻烦）

    那是将属性类型中的低128种，还是高128种规划为功能属性呢？

    高128种。功能属性只有在0和非0突变时才需要同步，因此功能属性进行同步的频次是较低的，因此可以将低位让给同步频率更高的数值属性，可以减少网络同步开销。

```
public void Set(int type, double value) {
```

UE的GamePlayTag

    我看见部分UE的开发者在使用GAS时，通过GamePlayTag来实现不可移动、禁止施法等效果，我认为不是一个好选择。

    TagTree表达力和扩展性都很好，但低效。用Tags表达对象的类型信息是OK的，但用来实现功能开关（功能属性），则是低效的。

    UE它提供的是通用型框架，因此不能对属性组件做特殊的约定，因此它只能通过TagTree来实现一些功能；但我们在具体的项目中，没有这些约束，因此应当选择更高效的实现方式。

---

枚举属性

    枚举属性是指属性的每一个值都有特殊的意义，比如：怪物的强度（无效，普通，精英，Boss）。

```
public enum MonsterStrength {
```

    同数值属性一样，枚举属性的值只要发生变化，就需要同步和派发事件。

类型枚举

    我们在定义最终的枚举时，可以将数值属性下的浮点数属性和整数属性提到最上层来，这可以简化逻辑。

```
public enum AttrValueType {
```

---

long和double的选择

    在前面的示例代码中，我们采用的都是double数组，那么是否还有别的选择呢？

    还可以存储为long类型。作者的第二个项目，底层便是long类型；浮点数类型属性，统一存储为万分比。

```
// 浮点数采用万分比存储
```

    那么long和double，哪种方式更好呢？

    各有缺陷，但更推荐double。

long类型的优缺点

    long类型的主要优点是利于网络同步，整数值是容易压缩的。long的另一个优点是高效，角色属性中大多数属性都是整数类型，使用long可以避免大量的额外运算。

    long类型最直接的缺点就是不直接不直观。策划在配置表和技能编辑器中，也需要将属性配置为万分比；我认为这不是个好现象，策划的配置数据最好是简单且直接的。

    long类型的另一个缺点就是设值函有副作用 —— double转为long的时候，会丢失精度。就是说，取出的值可能和写入的值不同，看下面的代码：

```
public void Func1() {
```

    这个问题可大可小，但如果你想让自己编程更轻松，Bug更少；那么不要去分析有没有问题，直接选择double。

double类型的优缺点

    double类型的主要优点就是直接直观。

    double类型的主要缺点是网络同步的开销大；浮点数通常不能被压缩，因此多数通信协议并未对float和double进行传输优化。这个问题我们可以在业务层来优化，当我们进行同步时，可将整数型属性写为int64类型，浮点数属性才写为double类型。

```
message AttrItem {
```

PS：整数型的浮点数其实是可以被压缩的，可阅读《[序列化：数字的各种压缩方式](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247484027&idx=1&sn=c22fbc5f250a2ae40fb2b755dd7943f9&scene=21#wechat_redirect)》

  

    double的另一个问题是，整数型属性通常远多于浮点数类型属性，使用double会带来较多的额外运算；此外，要保证非浮点数（含Bool和Enum）属性被正确存储为整数，也要额外的开销。

  

什么情况下使用long胜过double？

    配置概率的时候。10个0.1的和不是1，使用小数配置概率，得出的总概率不一定等于期望的总概率，会有误差。

---

属性变化事件

    角色的属性变化事件，有以下特殊性：

1. 无论一次修改多少个属性，都只产生一次事件 —— 原子性
    
2. 监听器可能监听多个属性类型，不论一次其中几个属性发生变化，只应该接收到一次事件
    
3. 属性变化事件派发期间，如果有监听器又修改了角色属性，其它的监听器就可能产生Bug
    
4. 属性变化事件，需要告知监听器旧属性值
    

    第一个点要求我们不能在修改属性时自动触发事件，甚至自动同步给客户端也是不行的，必须在修改数据完成之后才派发事件。

    第二个点要为管理监听器时，不能简单的分散注册，而是需要在派发事件时测试属性类型集合的相交性。

```
public struct ListenerInfo {
```

    第三个问题，在项目中也是比较容易出现的。如果有监听器在处理属性变化事件的时候，又修改了本次事件中包含的属性；那么，不论新的属性变化事件是立即广播，还是延迟广播，都会影响其它监听器。

    如果新事件立即广播，那么其它监听器就会先看见新值，再看见旧值，这明显是不对的；如果新事件延迟广播，那么其它监听器在处理旧事件的时候，从角色身上取出来的当前属性是新的属性值，这也是有问题的。

    这个问题不好解决，所以我们只能要求属性监听器不能立即产生属性修改，必须延迟修改 -- 通常是延迟到下一帧。

    与这个问题类似的，是背包系统的新获得物品事件。

  

    第四点则要求我们在修改属性时，需要自动记录属性的旧值；在修改完成后，清空变化的属性信息并同步和派发事件。

```
public void FireEvent(GameObject gobj) {
```

公共事件系统 OR 定制事件系统

    现在有个小问题，那就是我们的属性变化事件，是通过公共的事件系统派发，还是在属性组件派发？

    通过属性组件派发，即我们在属性组件中维护属性变化事件的监听器，派发时直接迭代监听器列表即可，这相当于提前进行了分流，因此性能上会更好一些。

```
public class AttrComponent {
```

    但提前分流也会带来一些问题，那就是我们的战斗系统需要关注多个事件系统。如果没有明显的性能问题，我建议还是使用公共的事件系统。

---

属性同步

    属性同步和事件管理是类似的，都依赖属性组件记录的ChangedValues，外部在完成数据修改后，将变化的属性同步给客户端。

哪些属性需要服务器同步给客户端？

    所有涉及到客户端表现和预演的，比如：当前血量和最大血量，都需要同步给客户端。

高频同步的数据放前方

    通信协议通常对int32、int64类型进行了压缩，最出名的便是Protobuf中的VarInt算法；在VarInt算法中，数值越小的Int，使用的字节数越少；因此，越频繁同步的属性类型，其类型Int应当越小。

```
public enum AttrType {
```

属性配置

```
public class AttrTypeCfg {
```

    首先，我们需要在表格中配置属性的极大极小值，对于Bool类型和Enum类型，我们应该限制极小值为0，避免负数。

    另外，哪些属性需要同步给客户端，可以在代码中配置，也可以通过表格配置，通过表格会更灵活一点。

    最后，不仅仅是战斗属性，属性组件里的所有属性都需要配置在该表格，包括前面提到的角色等级这些。