前言

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

在2021年的最后一段时间里,我决定将业余的时间和精力放到我的老朋友MOBA项目的技能系统的网络同步部分的开发和完善上来,主要工作内容是:

  • 在原本行为树的基础上再接入一套Timeline系统用于更加方便和直观的编辑技能
  • 将原本状态同步的方案转变到状态帧同步方案

这里还是简单的谈一下为什么不选择帧同步而选择状态帧同步,如果基于帧同步进行开发的话,定点数的使用会大大增加各个模块的时间和人力成本,并且稍不注意就会有毁灭性的不同步出现,再者,帧同步出于安全考虑需要服务端进行校验工作,工作量和状态同步基本上没什么区别,帧同步真正的价值在于其 “帧” 的概念,有了帧的概念,就可以去预测,去回滚,去做录像,去做观战。。。而状态帧方案就可以让我们在不受限于帧同步条条框框的基础上拥有帧同步的开发体验与项目效果,其相对于帧同步来说,最为困难的部分就是根据服务端传来的帧数据进行重新模拟+回滚,这要求状态和逻辑分离的非常纯粹,也就是说对于客户端,接收任意帧内相同的输入必须能回滚到与服务端那一帧一样的数据和状态。

状态帧同步和状态同步对比

因为之前的技能系统是基于纯状态同步的,所以任何状态的变化都要去手动同步到客户端,比如一个技能里面就会有一些状态得同步到客户端,比如一些Buff层数,一些黑板值的变化等。。。这些事情都需要做一些特殊节点去做

比如这个

image-20211219152855708

,不方便还不说,还很容易出BUG。。。,而如果采用了状态帧同步架构,就只需要每帧收集黑板脏数据发送给客户端,让客户端行为树自己去跑就ok了,完全不需要手写同步代码,当然了,意思大概是这个意思大家理解一下就好,真正实践起来还有另外一些问题,详情可参见:基于行为树的MOBA技能系统:基于状态帧的战斗,技能编辑器与录像回放系统开发手札

设计目标

开发体验

之前的战斗系统是基于状态同步的,对于一个技能中很多细碎的状态都要去手写同步代码,非常的痛苦,所以要实现的目标为:框架定好后几乎不需要手写同步代码,由技能系统托管整个同步流程

用户体验

最终目标是能像王者荣耀那样可以不联网,本地跑一个单机模式,虽然这是一个看起来蹭热度的目标,但是他有这样几个好处

  • 强制要求了客户端跑所有逻辑,包括血量计算这种强严谨的(因为我们有服务器同步数据权威,所以无所畏惧),这样在开发阶段基本上可以获得单机游戏的开发体验
  • 预测回滚的功能更加强大,因为客户端跑了所有逻辑,所以我们可以预测回滚游戏的一切
  • 用户玩起来更爽,比如在飞机上,或者很多人的大会厅(想起我大学时光在大会议厅摸鱼)这种极弱网甚至无网的环境下也能体验游戏

状态帧同步基础框架

首先明确一点,我们预测的对象只是玩家自己,不会预测别的玩家,否则会极大增加预测回滚成本,比如玩家A操控英雄A,那么就会预测英雄A的行为,对于玩家B,C,D一律只根据服务器回包来进行状态更新

DeltaData

流程图中的DeltaData是服务器上每帧的脏数据

每帧所有脏数据会汇集成一个脏数据集合发送给客户端

然后客户端就会根据这个脏数据进行那一帧的模拟,从而最终得到和服务端一致的状态,那么这所谓的脏数据,具体包含哪些种类的数据?

我把脏数据根据其产生的原因与作用归为两类:

CommonDeltaData

常规的脏数据,例如对象的Transform,血量,蓝量,速度等常规属性,虽然这些常规属性可能会被技能系统所更改,但是被技能系统更改是一个行为,不是一个结果,而我们脏数据要的是一个结果,所以我还是把他们归类于常规脏数据

SkillSystemDeltaData

技能系统产生的脏数据,因为我们的技能系统主要由三部分组成

  • NPBehave:技能行为树
  • Slate:技能Timeline
  • Buff:Buff系统

所以继续细分的话也可以细分出三种脏数据类型

  • 技能行为树产生的黑板脏数据:因为我们使用的是基于事件的行为树,依赖于黑板值变化做事件驱动,所以只需要记录脏黑板值然后传送给客户端即可在客户端得到与服务端一样的行为树状态
  • Slate产生的关键帧脏数据:Slate就更简单些,直接同步当前Timeline中相对关键帧与状态到客户端即可,由于服务端不存在回滚这一行为,也就不会有Track中的多个Action重复执行的问题,所以一直发送即可,直到被外层的技能行为树打断或者其生命周期结束,倒是客户端这边会有回滚的操作,但Timeline的特性就是定位到任意帧表现依旧正常,所以也不用担心
  • Buff系统脏数据:主要包括Buff的添加和删除

技能编辑器设计

我们技能编辑器由两部分组成

  • 基于事件驱动的NPBehave行为树
  • Timeline形式的Slate

更详细的,对于客户端和服务端,使用的是同一份技能配置实例,而不是客户端一份技能配置,服务端一份技能配置,这种做法有以下好处:

  • 便于节点程序开发和技能的配置,因为不用单独配置客户端和服务器技能,两者是一个整体,也就不会涉及手写网络同步代码,由FrameHandler来收集DeltaData来进行网络同步,也会一定程度上减少技能配置人员脑力负担
  • 便于可视化调试,如果采用客户端与服务端分离的技能配置,那么服务端的技能Debug就会很困难,因为没有可视化的支持,而将客户端与服务器技能配置配置在一起就可以使用客户端已有的节点编辑器做可视化Debug

利用Timeline去托管技能的线性逻辑部分正好可以规避掉在回滚时计时器状态问题,因为Timeline可以定位到任意帧确保逻辑表现一致

预测&&回滚

预测回滚是帧同步亘古不变的核心,预测即直接进行Tick,然后记录自身的状态数据,即快照数据,回滚则是对比服务器与客户端同一帧快照是否一致,不一致则进行回滚

可以不通过深拷贝Component的方式做快照,因为任何Component都可以抽象出一个专属的状态数据,比如

  • 直接:MoveComponent就是Pos,Rot,Speed,IsStopped
  • 间接:BuffComponent就是BuffContentIdList(BuffId,剩余帧数,层数)

更合理的是,服务器每帧会发送deltaData到客户端,而这个deltaData就是我们专门为每个组件抽象出的数据,这样可以节省很多快照空间,单一个buffcomponent来说,可能一次序列化能达到8kb~10kb,那么10帧就是100kb,100帧就是1MB,如果在直接暴力深拷贝的基础上做优化,就需要自己做递归处理,比如BuffComponent你就需要递归到所有Buff的所有字段做深拷贝,这样才能剔除掉不想深拷贝的字段,那就不如直接抽离出一个状态数据了

可以看到我把状态数据分为两类,一类是直接,一类是间接,所谓直接数据是因为他们比较简单(例如MoveComponent只有位置,朝向和速度这几个变量,是恒定不变的),可以直接将他们的所有数据都当作脏数据进行处理,所谓间接数据是因为他们比较复杂,而且数据结构往往是一直在变化的,直接全量发送浪费带宽,所以需要自己处理他们的变化了的数据作为脏数据

不管是直接还是间接,本质都是缓存脏数据进行对比得到一致性的结果。比如行为树里的黑板数据,因为数据量往往会比较大,所以如果涉及到了新增/删除/修改黑板键值,那么脏数据只会发来这个新增/删除/修改的黑板键值,这种情况其实和MoveComponent脏数据本质是一样的,只是MoveComponent脏数据表现得更加直观一些(直接将全部数据都当作脏数据进行处理),那么对于行为树的脏数据,我们就需要手动处理下:每帧客户端对比记录自身产生的脏数据,然后与服务器发送的脏数据进行对比即可

有这套结构之后,什么录像,观战,死亡回放(死亡回放就像OW说的会更麻烦点,要重建一个World,不能影响当前World)都ok了,因为我们可以根据这个抽象的状态数据驱动游戏的每一帧

录像&&回放系统

帧同步的一大特点就是只需要转发玩家输入的指令就能得到最终的结果

状态帧则更复杂一些,因为我们本地没有确定性结果(没有使用定点数),所以需要将服务端计算后的结果回传给客户端,客户端才能根据这个正确的结果进行正确的表现

我们的流程是客户端发送指令给服务端,然后服务端计算后将结果传回客户端,然后客户端根据结果做表现,也就是说客户端的表现完全取决于服务端每帧回包的指令

所以对于录像,回放来说只需要不断将服务端每帧传给客户端的指令持久化下来,然后进行分发即可