前言

基于行为树的Moba技能系统系列文章总目录:https://www.lfzxb.top/nkgmoba-total

承接上篇 基于行为树的MOBA技能系统:基于状态帧的战斗,技能编辑器与录像回放系统设计 这篇文章记录一下将状态同步切换为状态帧同步所做的改动

网络同步

首先是最基础的网络同步模块

目前业界主流有两种做法

  • 使命召唤:客户端落后于服务器,服务器收到客户端消息时根据帧号回滚到那一帧进行模拟,并将得出的结果返回客户端
  • 守望先锋:客户端领先于服务器,服务器收到客户端消息以当前服务器所在帧为准,统一进行模拟

两种做法都是可以的,最大的不同在于服务器收到客户端消息时的处理方式,其他逻辑(比如预测,回滚)本质都是一样的,因为守望先锋的分享更加详尽一些,细节处理的可参考度也就更高,所以这里选择守望先锋的做法

这块内容在 基于行为树的MOBA技能系统:技能系统与网络同步 有提到,此处不再过多赘述,其中需要注意的一个概念是客户端一定是领先于服务器的,因为我们发送的网络包会在半个RTT+一个缓存帧时长才会到达服务端,所以如果服务端当前是95帧,RTT是8帧,缓冲帧时长为1帧,那么客户端就会运行在第100帧,这样才能保证客户端第100帧的输入在服务端接收输入并开始模拟时也是第100帧。所以会有下面这个平衡公式

客户端发包帧数 = 服务器当前帧数 + 半个RTT + 服务端一个缓存帧

平衡公式图例,客户端在第34帧发送的消息会在服务端第33帧收到,然后经过一个缓存帧(这里假设为1帧)被服务端正式加入模拟

那么如何实现这个超前操作呢?那就需要计算我们当前FixedUpdate的DeltaTime和目标DeltaTime的差距,然后修改即可,举例来说,客户端FixedUpdate频率是应该是稳定在30FPS,但是此时RTT变大,就需要加快FixedUpdate的频率,也就是减少每帧的Tick长度,直到再次满足我们的平衡公式,相应的,当RTT变小的时候,也需要增加FixedUpdate每帧Tick长度,直到再次满足平衡公式。这个操作会贯穿在整局游戏中,因为我们的RTT是不断变化的。

RTT变化时客户端与服务端做出的相应

下面这张GIF能直观的反应这个过程

动画

但是由于有时候RTT变化幅度过大,导致客户端没有办法在很短的时间内恢复到平衡状态,那就需要服务端这边再根据客户端的延迟情况,操作缓冲帧,做延迟补偿,将网络情况较差的客户端发送的指令往前放。(依稀记得我大学舍友对R6的延迟补偿机制破口大骂)

客户端网络同步处理流程示意

服务端网络同步处理流程示意

客户端发送指令帧数 + 1

客户端用户输入有他的特殊性,往往会在Update里收集输入,在FixedUpdate里进行指令发送,所以要放到下一帧

帧末尾发送指令,统一处理指令的必要性

体现在以下几点

  • 因为我们每一帧的计算都是确定的,就要求每一帧在准备计算的时候,所有的数据都要就绪
  • 不管在发送时还是计算时都有完整的帧数据,可以根据需求做一些指令压缩优化
  • 统一处理指令可以更加方便地管理状态,使代码的逻辑架构更加清晰

所以设计为帧起始统一执行需要执行的指令,帧末尾统一发送所有需要发送的指令

客户端插值的必要性

之所以要提出这个观点,是因为我们网络同步设计是:客户端在Update中接收服务端FixedUpdate发送的指令,然后在FixedUpdate中处理,而Update deltaTime为16.66ms,FixedUpate deltaTime为33.33ms

再加上客户端和服务端延迟不断地变化,就会有这样一种情况,对于客户端,一个指令从被接收(Update)到被处理(FixedUpdate)最多可以相隔49.99ms,最少可以相隔1~2ms(即差不多刚被Update接受完,下一个FixedUpdate立马执行)

那么就会有这样一种情况,服务端发送的两帧消息,会被客户端同一个FixedUpdate执行,也就会导致数据的跳变,当然结果本身是没问题的,只是表现会出现肉眼可查的抖动(毕竟相当于从30FPS变成了15FPS)

所以就需要我们表现层做好插值处理,消除这个抖动

玩家输入处理

玩家的所有输入都需要由一个组件进行托管,然后由这个组件分发到所有需要玩家输入驱动的模块

此外,玩家的每次输入都需要放入一个玩家输入缓冲区中,因为客户端是超前于服务器的,所以难免会有预测失败的情况出现,那么我们就需要从预测失败的那一帧追帧到当前帧,在这个追帧的过程中我们就需要重新读取缓冲区的输入进行预测模拟

最后需要注意的是对于玩家的输入,发送间隔可以定帧发送,比如1s可以发送15次操作,但是对于玩家输入有效性的检测,则必须跑满帧率去检测,例如玩家会点击地面进行寻路,会判断玩家是否按下按键,然后发射射线到地面,得出目标点进行寻路,如果这个过程不跑满帧率的话就会产生漏检测的后果。

移动模块

对于基于寻路的移动模块来说,本地一般都是需要跑一个寻路系统的,因为我们的寻路指令要经过一整个RTT + 一个服务器缓存帧时长才能经过服务器将寻路结果传回来,例如ping是60ms,一帧为30ms,那么就需要60 + 60 + 30 = 150ms = 5帧的时间才能收到服务器的寻路结果,这还只是60ms延迟,更高的话效果更不理想。

此外,对于移动模块来说指令主要包含两个主要信息

  • 开始寻路,因为本地领先于服务器,所以可能本地认为玩家可以移动,实际上服务器判定已经被眩晕了,所以需要这个开始寻路指令做触发并返回结果让客户端做修正,另外如果没有这个开始寻路指令,也没有办法去判断玩家是否开始了寻路,也就没法做一些与其他模块的互动,例如攻击时如果进行了寻路操作,就会取消此次攻击
  • 每帧Transform信息同步,由于浮点精度问题需要每帧都下发Transform信息,可以避免各个客户端的不一致

预测

如果本地跑一个寻路库的话完全可以本地立即进行寻路而不用等服务器回包,再加上我们状态帧的特性:每帧发送变化快照到客户端,不需要定点数版本的寻路库(这一步很节省工程时间,毕竟谁愿意花费九牛二虎之力改造一个定点数版本的寻路库呢),因为每帧都在纠正玩家的位置,基本上不会有误差

回滚

移动的回滚条件检测主要是检测某一帧位置与朝向是否与服务器一致(由于浮点数的原因所以有一个容差值,一般是0.0001f)。

一旦发现位置信息同服务器不一致,就需要用当前服务器的输入进行修正,只修正还不行,还需要重新进行寻路模拟,毕竟位置发生了变化如果不重新模拟寻路就会导致寻路点对不上。

插值的必要性

对于插值的必要性,主要体现在表现的顺滑度:我们数据同步的帧率为30帧,而视图层的帧率往往为60帧甚至更高,如果不进行插值就会有很明显的停顿感

战斗模块

战斗模块是网络同步中最棘手的模块,因为它是逻辑分支和数据流转最为复杂的模块,而越复杂的模块,剥离同步数据的难度就越大,这就要求我们要尽量把战斗系统模块化,说来我也是感觉自己非常的幸运,因为从一开始选择的的纯行为树方案,到现在的行为树 + Timeline配合的方案,都在潜移默化的做着模块化这件事情,这让我省了很多重构的功夫

在开始阅读这块内容之前,我想请读者将这两句话作为下文的前提条件:

  • 战斗系统由各个组件组成,每个组件各自负责自己的网络同步,而使用这些组件的系统是不需要关心其内部的同步细节的,直接使用即可

  • 行为树负责负责逻辑切换,Timeline负责线性逻辑推进和表现,二者结合完成技能的制作

组件化的重要性

我们战斗是由许多模块组成的,比如Buff组件,状态组件,数值组件,伤害组件,治疗组件。。。而且很多情况下这些组件会被其他系统所使用

比如我们的普通攻击其实就会和伤害组件和数值组件互动(因为涉及到伤害计算和英雄属性修改),就完全可以让伤害组件与数值组件去负责伤害的网络同步,而不是依赖于普通攻击这个行为去做

再比如状态组件,Buff组件会被行为树使用,也会被碰撞系统所使用,如果把这两个组件的同步工作交给行为树和碰撞系统去做,可不是一件美逝

综上,其实我们战斗系统的同步工作其实分摊给各个组件各自统一进行网络同步工作,这样不管这个组件被多少系统使用,这些使用它的系统都不需要关系其内部的网络同步细节,只管使用即可

基于事件驱动的行为树

我们使用的行为树是事件驱动的行为树,这种类型的行为树有以下几个很卓越的优势:

  • 行为树依据黑板中的键值数据运行,当黑板数据没有任何数据更新时,整颗行为树是可以没有任何性能消耗的
  • 黑板中键值更新后,下一帧行为树才会响应,这保证了行为树状态每帧的独立性和完整性(试想下黑板键值A改变了,如果当前帧B节点立即响应,又将A节点改变回去,那么在帧末尾的时候我们岂不是无法得知这一帧里发生的任何事情?)
  • 纯数据驱动,完美分离了数据和逻辑,我们只需要同步黑板数据即可完成整颗行为树的同步

对于行为树来说,想要将其适用在帧同步里,有三个核心点:

  1. 对于任意一帧而言,一致的黑板内容,都将会得到一致的行为树状态
  2. 涉及 时间 这一概念的内容全部改成帧的概念
  3. 节点本身是否需要回滚

相同输入下的状态一致性

对于第一点,因为我们使用的NPBehave是事件驱动的行为树,所以我们只需要将所有的运行时数据都放在黑板中,就可以自然而然地达成目的

延时节点的帧概念

对于第二点,会重写相当大部分的的行为树底层代码,但这是必要的,考虑这样一个情形:玩家在第100帧按下技能键,客户端立即开始响应,释放技能,播放动画,播放特效。。。但是,由于是客户端先行的,所以不知道当前帧其他玩家的状态,是默认其他玩家什么操作都不做就站在那挨打的,得要等到服务器回包之后(此时假设服务端才跑在95帧)才知道其他玩家做了什么,那么如果其他玩家在第100帧对本地玩家添加了一个异常状态的Buff(眩晕,禁锢,沉默),那么本地玩家这个技能在服务端判定后其实是会释放失败的,但是本地玩家客户端已经跑了一部分行为树逻辑了,所以要涉及到行为树整体状态的回滚,如果这个行为树没有延时节点,那么根据事件驱动行为树特性,我们只需要回滚行为树的黑板数据即可,行为树会自动恢复到正确的状态,但如果这个行为树中恰巧还有一些延时节点,比如释放此技能的第一段后等待1s才能释放第二段,那么这个延时行为,该怎么回滚呢,不管怎么做,这种计时器性质的节点都不好处理,或者说处理起来会搞得很脏,究其原因是它把这个计时状态放在了这个节点的内部,导致不好回滚,这还是节点完全回滚的情况,如果不完全回滚呢?比如一个节点是等待5s,过了3s之后在一个Buff的作用下,CD清零,已经不需要继续等待了,处理起来更麻烦。最终答案也很简单,延时节点的真实时间改成帧的概念,比如这个延时节点是1s,我们Tick FPS为33.33ms,也就是30帧,并且在这个延时节点开始执行的时候,就把它的目标触发帧数存在黑板中,随后每帧都去取这个黑板值与外部传进来的当前帧帧数做对比即可,这样一来,延时节点的问题就迎刃而解了

节点本身是否需要回滚

答案是不需要,而且要做的话也很会很难做

首先明确一点,我们行为树的多帧节点只有一个,那就是延时节点

然后解释一下为什么说行为树做回滚不好做:因为行为树本身并没有一个时间轴的概念,本质上是条件达成就去做某事,这个所谓的条件达成包含时间条件,逻辑条件两种。如果只有时间条件,那就是Timeline,但是行为树出色的逻辑组织能力就是得力于这个逻辑条件,而这个逻辑条件是没有办法做回滚的。考虑这样一个情况,一个单帧节点A在条件B达成的时候时候会执行,然后给玩家添加一个Buff C,如果此时条件B被回滚了,那么这个Buff C也当被回滚(从玩家身上移除),到目前为止还在可以接受的范畴内,那么关键问题来了,我们怎么知道条件B回滚导致的节点A回滚是回滚,而不是正常的逻辑执行呢?正常逻辑也完全有可能重设条件B,也就是说,我们没办法知道条件B回滚会导致哪些节点进行回滚,唯一能做的就是把回滚的责任给到对应的模块去做,比如新添加/移除的Buff需要回滚,那就交给BuffComponent进行回滚,如果新生成/移除的碰撞体需要进行回滚,就交给ColliderManagerComponent进行回滚。。。这样行为树本身只需要根据黑板值的改变做出响应,而不必分心于细碎的回滚需求

总结一下行为树回滚相关解决方案如下:

  • 利用Timeline分担一部分回滚工作,如果是纯行为树方案,对于一个播放动画节点来说,就必须处理这个节点的回滚,但是我们的技能系统是将线性的表现逻辑(播放动画,动画混合,播放特效,播放音效)放在了Timeline中,也就是让Timeline去托管这些行为的回滚,这些对于行为树来说很棘手的回滚问题对于Timeline来说却是再容易不过了(想象一下我们平时用Timeline的时候,来回随意拖动时间轴,每一帧的表现都是正常的,并不会有动画,特效,音效的穿帮问题)。
  • 利用战斗各个模块各自分担回滚工作

实例演示

由于每帧服务端都会发送最新的黑板脏数据到客户端,而我们的行为树是事件驱动的行为树,也就是黑板值的改变会通知行为树的黑板条件节点然后执行相应的逻辑,所以自然而然地可以想到把客户端/服务端会运行的节点通过一个个黑板条件节点做划分,这样同时也就实现了表现和逻辑分离,因为我们的策略是客户端只做表现不做逻辑,可能听起来有点抽象,看下面这个例子:

image-20211225122553106

假设客户端当前在100帧,服务器在95帧,此时玩家按下Q键,右侧的子行为树会直接运行,并且根据玩家当前状态做一些检测,检测是否能够释放技能,如果可以就直接播放动画,播放特效,播放音效,这个玩家输入指令将会在100帧被服务端处理

如果服务端判定玩家此次技能释放有效,就进行逻辑层处理,也就是会把左边的Server_PlayerInput同步给客户端,然后客户端的左侧行为树也进行处理(之所以这样做也是因为一些表现行为也是在一些逻辑判定之后才会出现的,比如击中音效,特效,添加Buff等),当然了,既然在服务端做好了碰撞检测等逻辑判断工作,客户端这里就不会再去跑创建碰撞体这个节点的逻辑了,客户端需要处理的就是一些表现上的工作。其实整个技能释放过程对于客户端来说也就分为了两部分,第一部分是完全可以让客户端先行的部分,第二部分是要依靠服务端判定之后结果的部分,需要等到服务器下发黑板脏数据再进行处理,这样就保证了我们整个技能 预测有度

但如果服务端判定玩家此次技能释放无效(可能被眩晕了,沉默了,死亡了等),那么我们上面这个模型就解决不了这个回滚需求了,因为服务器只会同步黑板脏数据不关心具体的黑板运行状态,所以我们要保证能根据服务端发回的黑板脏数据让客户端得到正确的表现。在这个例子中,我们要让客户端的玩家不再处于释放技能的状态,所以多设计一个黑板条件节点,专门用于处理这个技能释放状态(也就是下图中的中间那颗子树),当服务器判定技能无法进行时就不会让玩家进入技能释放状态,即IsInExcutingSkill这个黑板键值不会被改变,所以客户端这边会因为对比服务器黑板值与本地预测不一致而做黑板值的重设,从而达到让玩家退出技能释放状态的目的

当然在这里多放中间那个黑板条件节点可能显得有些多余,我们似乎完全可以直接修改Client_PlayerInput键值来强行让客户端玩家退出技能释放状态,但是请考虑这种情况,技能已经释放了成功了,处于吟唱阶段,可以被打断,那么这个打断行为其实也可以算作打断“技能释放状态”,这样在异常状态打断技能施法时只需要传递修改IsInExcutingSkill这个黑板键值,行为树就能做出正确的响应,而不必考虑修改玩家输入

image-20211225140828797

最后,可能有的读者已经注意到了,我们这种方案,其实每次释放技能一定会涉及到行为树的回滚的,因为服务端回包永远落后于客户端帧数,这样会不会有卡顿和拉扯感呢?其实这个担心是多余的,还是那句话,我们是事件驱动的行为树,依据黑板键值变化来推动逻辑的执行。在这个例子中,假设客户端在105帧收到了服务端100帧的消息,它里面的脏数据为

1
2
3
Client_PlayerInput : Q
Server_PlayerInput : Q
IsInExcutingSkill : true

但是由于我们客户端本地先行的时候已经将Client_PlayerInput,IsInExcutingSkill设置为Q和true了,所以其实我们在回滚到100帧的时候,只会执行Server_PlayerInput里的内容(里面可能是一些重要状态和Buff的添加),而不会重复执行中间和右边的行为树

跨行为树实例的黑板赋值

考虑这样一种情况,目前客户端和服务端同时运行有行为树A,B。A的某个分支条件触发后,会修改B的黑板值。

假设客户端处于30帧,服务端处于28帧

服务端的变化过程

  1. 28帧:在服务端的A行为树黑板值已经发生变化,所以会往客户端发送命令
  2. 29帧,正式执行A目标分支的逻辑,修改B的黑板值,B的黑板值改变,回望客户端发送消息

客户端的变化过程

  1. 32帧,收到服务端28帧行为树A黑板值改变事件,回滚到28帧进行处理,设置行为树A的黑板值,然后进行追帧,运行到29帧时,正式执行A的分支逻辑,设置B的黑板值,运行到30帧时,正式执行B的分支逻辑,31,32帧都正常追帧逻辑
  2. 33帧,收到服务端29帧行为树B黑板值改变事件,正式执行A目标分支的逻辑,修改B的黑板值 TODO

Timeline

在之前的一篇 ParadoxNotion-Slate学习笔记与拓展计划 已经提到过了所有Timeline相关注意点,直接移步查看即可

Buff系统

实现思路

其实思路还是比较简单直接的:直接每帧扫描BuffManager中发生变化的Buff(判断依据包括但不限于Buff持续时间,Buff层数,Buff新增,移除等),然后抽象序列化成网络数据同步给客户端,客户端进行处理

下面是每个Buff变化的抽象数据类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[ProtoContract]
public class BuffSnapInfo : IReference, IEquatable<BuffSnapInfo>
{
public enum BuffOperationType
{
NONE,
ADD,
REMOVE,
CHANGE
}
/// <summary>
/// Buff归属的NPSupportId
/// </summary>
[ProtoMember(1)] public long NP_SupportId;
/// <summary>
/// BuffDataNode的Id
/// </summary>
[ProtoMember(2)] public long BuffNodeId;
/// <summary>
/// BuffData的Id
/// </summary>
[ProtoMember(9)] public long BuffId;
/// <summary>
/// Buff的层数
/// </summary>
[ProtoMember(3)] public int BuffLayer;
/// <summary>
/// Buff来源UnitId
/// </summary>
[ProtoMember(4)] public long FromUnitId;
/// <summary>
/// Buff归属UnitId
/// </summary>
[ProtoMember(5)] public long BelongtoUnitId;
/// <summary>
/// Buff归属NP_RuntimeTreeId
/// </summary>
[ProtoMember(6)] public long BelongtoNP_RuntimeTreeId;
/// <summary>
/// Buff会被移除的目标帧
/// </summary>
[ProtoMember(7)] public uint BuffMaxLimitFrame;
[ProtoMember(8)] public BuffOperationType OperationType;
}

按照这种的同步方案来说其实问题已经不大了,毕竟每帧变化的Buff数量不会太大,网络和性能都能顶得住,但是仔细思考之后我发现了一个意外惊喜

意外惊喜:网络同步优化

这部分会涉及到Buff系统的可视化模块,大家可以先去这篇文章了解下:https://www.lfzxb.top/nkgmoba-visualnodeeditor-skill/

其中有一段比较重要,我单独摘抄出来:

其中比较有意思的地方是我利用连线来表述Buff之间的关系,比如一个BindStateBuff节点 A后面连了一个FlashDamageBuff节点 B和另一个BindStateBuff节点 C,这个BindStateBuff节点 C后面连了另一个FlashDamageBuff节点 D,这样,在导出数据的时候,点一下Canvas上的“自动配置所有结点数据”,就会自动将相关Buff的Id注册。在运行时添加A Buff的时候,就会连锁添加B,C,D这三个Buff。这样就实现了直观,有序,可控的Buff组合。

简而言之就是每一条Buff链都可以当成一个函数调用,那么我们每次同步Buff系统的时候其实就只需要同步Buff头节点即可,下面是个例子

image-20220216220539483

两个红框里的Buff头节点只要正确的被同步了状态,那么这整张图里的Buff节点行为就都是正常的,这样就将同步量从10个变成了2个,大大提高了效率和性能。

禁用基于真实时间的计时器

因为我们战斗帧数据的收集是基于帧的,如果战斗逻辑中使用了基于真实时间的计时器就会导致数据收集时间点发生不同步

举个例子,逻辑帧率为30fps,当前帧数为80帧,我们攻击前摇是0.5s(即耗时15帧多一点)(即发起攻击指令后0.5s才会有伤害),如果基于真实的时间计时器来处理这个延时,那么这个伤害数据就位于85帧结束后,86帧开始之前,那么这种情况,这个伤害数据对于客户端来说就丢失了。

录像回放&&观战系统

有了之前那些内容的铺垫,这部分已经非常简单了,我们要做的就是序列化所有的帧数据,然后去Tick整个状态帧战斗系统即可

预测还是不预测,不仅仅是个技术问题,还是个设计问题

到了文章的结尾部分,我想抛出这样一个问题:通过前面的内容,我们可以做到预测战斗中所有的行为,但是真的有必要吗,或者说,预测所有行为是好的吗?

我的答案是否定的,理由有以下几点:

  • 在我前些年玩英魂之刃这款MOBA的时候,经常会有这种情况,我按下了大招,大招释放了,伤害出来了,但是过了一瞬间,大招又可以按了,我又按了一次大招,伤害又出来了,其实前一次是客户端本地预测的结果,但是很明显预测失败了,后一次才是预测成功的结果(没有回滚),但是这对我造成了相当大的困扰,明明伤害都出来了,为什么给我回滚了,这太不公平了。但是如果我们只预测播放技能动画,没有伤害判定,那么玩家就会知道,哦,这次技能释放可能由于我网络原因有可能成功,也有可能失败,我已经做好心理准备了,来吧。
  • 就像上面说的,如果预测所有的内容,那么就会导致程序开发难度大大增加,因为我们要预测回滚很多模块:碰撞系统,Buff系统,UI系统。。。其次,这些模块对于玩家来说是最直观的模块,如果进行预测回滚,会造成相当大的困扰

常见问题

uint数据类型溢出

因为帧数使用的数据类型为uint,而一些处理,例如PING值转帧数,预测回滚导致的追帧都涉及到较为复杂的uint运算,稍不留神就会得到负数,也就是uint数据溢出到上限,从而可能导致整个程序卡住(因为他实在太大了)