Files
obsidian-notes/1Project/战斗编辑器/参考文章/写代码的诗人/战斗系统:属性管理.md
2025-06-20 09:51:50 +08:00

13 KiB
Raw Blame History

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

阅读提醒:

  1. 本篇尚不涉及属性修改器的管理

  2. 关于战斗系统的框架设计,可阅读《战斗系统:框架设计


属性管理的范围

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

    那么,哪些属性需要存储在属性组件上呢?

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

public class AttrComponent {

    这样做有什么好处?

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

  1. 减少战斗系统关注的组件

  2. 方便属性测试更支持Mask快速测试角色属性

  3. 方便属性修改器实现

  4. 方便数据同步

  5. 方便属性变化监听


等级

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

    可以!是不是有点反直觉?但确实可以。我在《角色框架:场景内外玩法数据分离》一篇中提到:角色在场景中的等级和名字等需要和养成系统分离,这样才可以支持复杂的变身玩法。

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

坐标

    我见过将角色的坐标拆分为 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整数型的浮点数其实是可以被压缩的可阅读《序列化:数字的各种压缩方式

    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避免负数。

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

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