前言

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

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

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

状态帧同步基础框架

首先明确一点,我们预测的对象只是玩家自己,不会预测别的玩家,否则会极大增加预测回滚成本,比如玩家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的所有字段做深拷贝,这样才能剔除掉不想深拷贝的字段,那就不如直接抽离出一个状态数据了

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

录像&&回放系统

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

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

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

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