13 KiB
说完技能组件,本篇讲讲战斗系统的另一个重要组件:属性组件。
阅读提醒:
-
本篇尚不涉及属性修改器的管理
-
关于战斗系统的框架设计,可阅读《战斗系统:框架设计》
属性管理的范围
按照我们在《战斗系统:框架设计》中的设计,属性即角色身上的数据,包括:攻防血、坐标朝向、模型、等级、名字...
那么,哪些属性需要存储在属性组件上呢?
所有可表达为数值(number)的属性,如果放在独立的模块没有变得更优,都可以存储在属性组件。
public class AttrComponent {
这样做有什么好处?
我们可以将属性组件看做角色的数值中心,这有以下好处:
-
减少战斗系统关注的组件
-
方便属性测试,更支持Mask快速测试角色属性
-
方便属性修改器实现
-
方便数据同步
-
方便属性变化监听
等级
角色的等级可以存储在属性组件吗?
可以!是不是有点反直觉?但确实可以。我在《角色框架:场景内外玩法数据分离》一篇中提到:角色在场景中的等级和名字等需要和养成系统分离,这样才可以支持复杂的变身玩法。
在数据分离之后,场景中角色的等级便不再有特殊业务,就是简单的数值而已;因此存储在属性组件中并无坏处,反而可以享受集中存储带来的各种好处。
坐标
我见过将角色的坐标拆分为 XYZ 三个值独立存储在角色身上的,这么做也许有一些好处:比如通过属性组件自动同步,但我认为这并不是个好的设计。
从存储的角度讲,角色的坐标和朝向,确实可以拆分为XYZ存储在属性组件,但这有一些问题:
-
表达力不足:坐标拆散存储,没有Vector3的可读性好。
-
性能问题:坐标和朝向的读写频率极高,使用值类型的Vector3会更高效。
-
职责问题:存储在属性组件的属性,应避免为属性组件增加特殊的职责,而坐标放入属性组件会增加特殊逻辑。
-
扩展性问题:如果项目需要实现子空间,将坐标和朝向封装到独立的模块是更好的选择。
为角色坐标和朝向建立属性视图
如果项目已经将角色坐标和朝向存储在了属性组件,那么建议为坐标和朝向封装属性视图,屏蔽底层的存储方式。
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,使用小数配置概率,得出的总概率不一定等于期望的总概率,会有误差。
属性变化事件
角色的属性变化事件,有以下特殊性:
-
无论一次修改多少个属性,都只产生一次事件 —— 原子性
-
监听器可能监听多个属性类型,不论一次其中几个属性发生变化,只应该接收到一次事件
-
属性变化事件派发期间,如果有监听器又修改了角色属性,其它的监听器就可能产生Bug
-
属性变化事件,需要告知监听器旧属性值
第一个点要求我们不能在修改属性时自动触发事件,甚至自动同步给客户端也是不行的,必须在修改数据完成之后才派发事件。
第二个点要为管理监听器时,不能简单的分散注册,而是需要在派发事件时测试属性类型集合的相交性。
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,避免负数。
另外,哪些属性需要同步给客户端,可以在代码中配置,也可以通过表格配置,通过表格会更灵活一点。
最后,不仅仅是战斗属性,属性组件里的所有属性都需要配置在该表格,包括前面提到的角色等级这些。