联网战斗同步实现

联网战斗同步实现的心得


前言

最近在做联网战斗同步这块的东西,读了不少文章、书籍,于是整理了一下。

之前也有在 团队内部技术分享 中分享过这块内容,但是有些东西受限于时间,只是大概的略过,重点放在了实现与遇到的难题解决上。

后来,在做优化调整的时候,又有不少新的收获,改进了之前的分享稿。

欢迎各位小伙伴来一起讨论,通过分享讨论来不断进步。





简介

现状

网络游戏的同步方案,大概由以下三部分搭配组成

  • 网络传输协议
  • 网络同步模型
  • 网络拓扑结构



网络传输协议(Network Transport Protocol)

  • 分类

    • UDP协议
    • TCP协议
  • 共同点

    • 在 TCP/IP 协议族中[物理层,数据链路层,网络层,传输层,应用层],位于 传输层的协议,均依赖底层 网络层中的 IP协议。
  • 区别

属性 UDP TCP
传输可靠性 不可靠 可靠
传输速度
带宽 包头小,省 包头大,费
连接速度
  • 此外,TCP还提供了 流量控制、拥塞控制等
  • 其他

    • 关于 KCP协议
      • KCP协议是一个快速可靠协议,它以浪费10%-20%带宽的代价(相较于TCP协议),换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议的收发。
      • 更详细信息可跳转最下方 参考资料2



网络同步模型(Network Model)

  • 分类

    • 状态同步(State Synchronization )
      • 本质:上传包含 游戏外部变化原因集合(玩家操作等)及 中间状态的子集(客户端计算的部分);下发包含 游戏状态的集合。
    • 帧同步(Lockstep)
      • 本质:上传下发只包含游戏外部变化原因集合(玩家操作等)。
        • 对于A客户端: [输入A] -> [过程A + 运算A] -> 输出A
        • 对于B客户端: [输入B] -> [过程B + 运算B] -> 输出B
        • 确保 [输入] 相同,再保证 [过程] 与 [运算] 一致,那么 [输出] 一定是一致的
      • 逻辑帧,游戏在逻辑层面是离散的过程,即可认为是一个逻辑帧一个逻辑帧进行逻辑运算。
      • 渲染帧,游戏在渲染层面是离散的过程,即可认为是一个渲染帧一个渲染帧进行画面呈现。
      • 游戏逻辑帧 与 渲染帧 需要互相独立。
  • 共同

    • 两种同步方案都分为 上传 和 下发 过程。
      • 上传 指客户端将信息传输给 服务器/客户端
      • 下发 指客户端从 服务器/客户端 中获取信息
  • 对比

属性 帧同步 状态同步
流量 通常较低,取决于玩家数量 通常较高,取决于该客户端可观察到的网络实体数量
预表现 难,客户端本地计算,进行回滚等 较易,客户端进行预表现,服务器进行权威演算,客户端最终和服务器下发的状态进行调节或回滚
确定性 严格确定性 不严格确定性
弱网影响 大,较难做到预表现 小,较易做到预表现
断线重连 难,需要获取所有相关帧且快播追上进度 易,根据快照迅速恢复
实时回放 难,客户端需要消耗非常大性能去从头播放到对应序列,回放完后需要快播追赶 易,根据快照进行回放,回放完再根据快照恢复
逻辑性能优化 难,客户端需要运算所有逻辑,跟客户端性能强相关 易,大部分逻辑可在服务器进行,分担客户端运算压力
外挂影响 大,客户端拥有所有信息,透视类外挂影响严重 小,服务器可做视野剔除等处理
开发特征 平时开发高效,不需要前后端联调;但是开发时要保证各模块确定性,不同步BUG出现,难以排查 平时开发效率一般,需要前后端联调,无不同步BUG
第三方库影响 大,第三方库需要确保确定性 小,第三方库不需要确保确定性



网络拓扑结构(Network Topology)

  • 分类

    • 对等结构
      • P2P结构(Peer to Peer)
    • 主从结构
      • CS结构(Client-Server)
  • 共同

    • 网络时延的评估标准
      • Ping
        • 概念:网络连接的两个端之间的信号在网络传输所花费的时间。
        • 例:从A端发出信号开始计时,到B端响应并立刻返回响应信号,A端收到响应后停止计时,该时长为Ping。
      • RTT(Round Trip Time)
        • 概念:一般可认为等于Ping,但此处 RTT = Ping + 两个端的处理信号前等待时间 + 两个端处理信号的时间,即 实际体验到的游戏时延。
        • 例:A端逻辑发出信号开始计时,在A端等待一段时间、处理一段时间;发出到B端,在B端等待一段时间、处理一段时间;处理发出响应信号;再次在B端等待一段时间、处理一段时间;发出到A端,再次等待一段时间、处理一段时间;A端逻辑收到响应信号,停止计时;该时长为RTT。
      • 标准(单位 ms)
        • 极好:<= 20
        • 优秀:21 ~ 50
        • 正常:51 ~ 100
        • 差:101 ~ 200
        • 极差:>= 201
    • 丢包率
      • 原因: 直接原因是由于 无线网络 和 拥塞控制,根本原因比较复杂。
      • 标准:
        • 优秀:<= 2%
        • 一般:2% ~ 10%
        • 差:>= 10%
  • 对比

属性 P2P结构 CS结构
样式 全连接的网状结构 星状结构
连接数 O(n^2) O(n)
流量 各客户端相等,均为 O(n^2) 服务器为 O(n),客户端为 O(1)
客户端间的时延 较小,为RTT/2 较大,为RTT




实现

广义

广义上来说,游戏采用的技术是:

  • 网络传输协议: KCP & TCP
  • 网络同步模型: 帧同步
  • 网络拓扑结构: CS结构

图例

帧同步流程



狭义

关联类

  • 同步管理类
  • 联网战斗数据缓存类
  • 联网战斗场景类


同步管理类

功能

  • 同步帧的操作的处理
    • 添加
    • 处理
    • 执行
    • 修正
  • 对玩家操作的处理
    • 收集
    • 上传
    • 吞噬


联网战斗数据缓存类

功能

  • 提供联网战斗通用数据模型
    • 解析玩家数据
  • 客户端与战斗服交互的中枢
    • 发往战斗服消息,均由此统一发送
    • 收到战斗服的消息,处理后转发给其他业务类
  • 断线重连相关处理
    • 追帧相关


联网战斗场景类

功能

  • 处理联网战斗基础场景流程、方法
    • 房间状态切换流程
    • 创建角色数据及实体
    • 传送逻辑
  • 实现本地战斗与联网战斗切换



为什么采用帧同步

  1. 游戏的核心逻辑在客户端实现,服务器只负责转发验证等
  2. 游戏类型及形式,动作类、房间为单位;更适合用帧同步
  3. 开发速度快,周期短




重点处理

如同帧同步的简介中介绍,要保证 输出的一致性,先要确保输入、过程、运算的一致性。


浮点数与定点数 [运算一致性]

浮点数的运算在不同的操作系统,甚至不同的机器上算出来的结果都是有精度差异的。

一般解决该类问题方法:

  • 使用定点数
  • 使用分数

这里主要麻烦点在于lua支持定点数,lua中的小数是double,需要把lua源码中的基础小数全部替换为定点数。

然后,物理引擎的计算,第三方库的引用(比如随机数),都需要使用定点数。



确定的 随机数机制 [运算一致性]

确定的随机数机制就是保证各个客户端一旦用到随机数,随机出来的值必须是一样的。

得益于计算机的伪随机,通过设定同样的随机种子即可实现。

但是,在客户端内,需要明确区分随机数的类型

  • 战斗类
    • 设计战斗的实体、BUFF、技能 等等
  • 非战斗类
    • 主要是显示项的随机,比如 loading期间的 tip选择

这里,为了更明确区分,在客户端做了一层封装:

  • AEUtil:GRandom,战斗类的随机数方法
  • AEUtil:UIRandom,非战斗类的随机数方法

做好区分,也便于相关日志的打印。

使用战斗类随机数模块:

  • AI
  • 行为树
  • 相机
  • 技能、BUFF、特殊能力
  • 实体相关
  • 地图相关

使用 非战斗类 随机数模块:

  • UI界面
  • 外挂检测
  • 数据收集
  • 音乐音效

当然,也不是绝对的,比如实体相关的有些可以不用战斗类随机数,比如NPC弹出个对话,也是纯显示性的。这里是为了好区分,方便开发,一刀切了。



确定的 容器及算法 [过程一致性]

  • 对于lua语言,不要用 pairs 方式遍历,要用 ipairs,也相应就要求容器必须是数组
  • 所有用到的算法,必须是 稳定 的算法



隔离与封装 逻辑层 [过程一致性]

所有模块都可以分为 draw 与 update 两部分

  • draw 进行绘制,走本地绘制帧更新
  • update 进行逻辑计算,走逻辑帧更新,可被帧同步接管

实现帧同步尤其需要对 逻辑层的数据进行封装与隔离

以位移组件为例:

  • 位移组件有两套坐标
    • 逻辑坐标
    • 渲染坐标
  • 人物的行走都是通过逻辑坐标计算,渲染坐标是在渲染帧的时候,将当前渲染坐标与逻辑坐标进行比较,再用差值平滑过渡

同理的还有:

  • 碰撞框的计算
  • 各组件
  • 各实体

做好分离,也便于之后做快照相关的优化。



支持本地战斗

创建联网战斗场景基类继承自单人战斗场景基类,用来统一控制联网相关的特殊操作,如 传送,协议交互 等。

然后,设置本地战斗变量,用来进行控制,若是本地战斗,交由基类处理。



同步模式 及 处理帧策略 [过程一致性]

同步模式

  • 服务器: 固定推帧 30帧/秒
  • 客户端:
    1. 30帧/秒,每次执行一次处理帧
    2. 60帧/秒,每次执行一次处理帧
    3. 30帧/秒,每次执行一次处理帧,绘制帧到来时,若有帧积压,再执行一次处理帧


处理帧策略

每次执行一次处理帧操作,具体释放帧数量

  1. 释放1帧
  2. 逐步释放帧
    • 累计帧数 < 2帧,释放1帧
    • 累计帧数 < 5帧,释放2帧
    • 累计帧数 < 10帧,释放3帧
    • 累计帧数 >= 10帧,释放所有帧
  3. 可变释放帧
    • 释放帧数量由 PlayFrameScale 变量控制,可 加速/减速 播放(一般用于处理回放)
  4. 释放全部帧



断线重连

断线重连,主要由 联网战斗数据缓存类负责。

  1. 从服务器中获取重连过程中的战斗帧
  2. 进入 追帧模式进行追帧,在追帧模式中,服务器发来的推送帧会被缓存起来
  3. 追帧完毕后,退出追帧模式;并将追帧期间的 服务器推送帧压入 同步管理器中

断线重连



同步校验

验证多个客户端是否同步,主要依赖于随机数及调用随机数的位置。

在联网战斗运行时,会将使用的随机数都打印出来,由于我们随机种子一致,所调用的随机数序列也应该是一致的,辅助以调用随机数的位置信息,战斗结束后对不同客户端的随机种子文件日志比对,可以校验同步。

我处理这块的方式是使用两个日志文件,

  • 一个用来做同步校验:大部分内容是 使用随机数的模块 + 随机数范围 + 最终生成的随机数,还有一些必然一致的过程日志。
  • 另一个用来做同步排查:包含更详细的日志信息

两场战斗结束后,用对比工具比较日志,一旦有差异,用更详细的日志信息,进行排查。




优化项

联网战斗同步向来不是一个做完就行的东西,而且也没有一套东西,在各个类型游戏通吃的情况。

所以,在实现完基础的同步架构后,还有很长的路要走。

目前只是搭建了一个基础的框架,要真正投入还有下面这些优化项可以做。

下面这些东西,有些已经做了,有些正在做,有些是一些设想,即将做的;欢迎各位伙伴一起来讨论。


快照的支持

在帧同步基础上,进行优化;就是 帧同步+快照 的模式。

其实已经不属于帧同步了,偏向状态同步。

快照作用就是将整个现场备份,缺点是数据量过大。

但是,我们以房间为单位的战斗,尤其适合 帧同步+快照;因为有明确的划分单位;并且房间初始,很多东西都是不需要存储的。

  • 房间内的快照,所有实体的状态(怪物、NPC、传送门 等等),HP、EP、受损状态 等等
  • 房间间的快照,实体都是初始创建,且实体的创建是不通过帧的,可以本地处理

这三者区别,

  • 帧同步 => 没有进度条的播放器;想要看到第6分30秒的内容,必须从头开始看

  • 状态同步 => 有进度条的播放器;知道时间,就可以直接切到相应时间开始播放

  • 帧同步+快照 => 有进度条,但单位是5分钟;要看 6分30秒的内容,不需要从头看,但是也要从第5分钟开始播放,直到6分30秒



安全性

帧同步的安全性也是一个重大的问题,可以分为几大部分。

  1. 客户端的安全模块,游戏的核心战斗逻辑演算都在客户端进行,所以对于数据的加密,防篡改等都是由安全模块统一处理。

  2. 网络模块,对于网络层的外挂,由底层网络模块的加密等处理。

  3. 联网战斗系统的防外挂模块

    基础的几个方案

    • 每隔一段时间,进行玩家信息收集并上传(如血量、技能使用、buff使用),出现结算不一致,由服务器裁决,可以解决部分外挂
    • 服务器新开一个“客户端”,在那个客户端上跑所有的帧,作为评判依据。
    • 等等

防外挂这个东西,就是魔高一尺,道高一丈,不断优化,不断调整的过程,有些东西也不好讲太细,只能说个大概。



不同步的处理

解决不同步问题,也是帧同步方案的一大痛点。

对于不同步的处理,可以分为三个部分:发现 -> 重现 -> 解决

作为开发,应该深有感触,如果方便重现,那解决问题就很简单了。

下面的处理方式都是针对传统的不同步处理各个步骤,进行优化设想。

一般出现不同步: 发现不同步 -> 打开日志开关 -> 使用同样的数据源 -> 复现问题 -> 解决问题

发现

发现不同步,最简单粗暴的方式,肯定是人力跑,没有技术成本,纯跑…

但是,缺点很多:

  • 人力不足
  • 时间不足
  • 不够全面
  • 不方便收集日志
  • 不能体现技术实力
  • 等等等等

所以,需要一种自动化的测试工具,来进行大量全面的测试。

目前打算是使用 python + jenkins 来部署自动化测试流水线,等测试完,再单独来说一说。


重现

重现不同步,也是很重要的一个步骤,能完美重现,那距离解决就不远了。

这里预期采用的方案是,固定数据源 + 回放机制。

  • 固定数据源

    需要和服务器配合,服务器需要存储参战玩家信息及帧内容,便于回放。

    前期可以全部存储,但是这样服务器压力会比较大;后期可以将本地战斗产生的同步文件形成MD5,发给服务器;服务器判断各客户端MD5不同,采取缓存录像。

  • 回放机制

    需要客户端实现一套根据帧内容回放机制,理论上来讲帧同步的回放还是比较好实现的。

    毕竟 确定的输入,确定的运算,确定的过程,都与时间无关联,可以得到确定的输出。

    但是,我们需要的是日志文件,所以绘制帧内容可以忽略掉,尽量做到逻辑帧的播放,这样在时间上也会更快。


解决

解决不同步问题,那就相对简单很多了。

实现了上面的发现 与 重现,可以无数次反复执行不同步数据源,验证是否解决也很便捷。


运行过程中的日志收集

这应该属于发现不同步的部分。

在实际项目中,日志的实现都是比较粗暴的,一般来说线上运行的模块,都不会开启日志文件。因为一般日志文件都会比较大,尤其是查同步问题的日志文件,涉及模块繁多,产生文件体积大。

所以,线上出不同步问题,往往也很难复现并解决,就是无法固定数据源。(不产生校验文件,就不能上传MD5,不能传MD5,服务器无法判断是否不同步,就不会缓存)

如果有一套性能损耗小一些的日志收集系统,会对同步问题的解决有很大的帮助,

正好最近看到了 《腾讯游戏开发精粹》- 第六部分 - 第14章 - 一种高效的帧同步全过程日志输出方案 。

上面的方案也对我有一些启发,之后可以去实验一下。



延迟处理

在实际测验中,会有玩家反馈卡顿情况。

延迟、卡顿的玩家体验,一般可以分为:

  • 延迟高
  • 波动大

而且,不同游戏类型对延迟的敏感度也不同,现在实现的这种偏格斗类型的游戏,对延迟敏感度还是比较高的。

再者,传统帧同步的处理,逻辑上就是比本地操作要慢一帧:

A帧操作 -> B帧上传 -> C帧执行

B ≥ A,C ≥ B+1

最终,还是要用数据来验证延迟的具体位置,可以按照下流程打时间戳,再收集各个数据,来分析并解决延迟与卡顿:

同步帧数据收集流程

这里列出几个方向:

  • 玩家的位置
  • 玩家的机型

  • 战斗的时间

  • 玩家的运营商

数据收集的选项:

  • 最小值
  • 最大值
  • 平均值
  • 波动值

这里还要注意设置阈值,防止某个异常操作,导致数据不准确,拉高或拉低平均值。

甚至可以设置一些字段,来做筛选剔除异常数据。




感悟

不信推送,相信帧

推送缺点

  • 每个客户端处理的时机不同
    • 收到的时机:服务器 推送A,再推送B;对于客户端肯定是先收到A,再收到B
    • 处理的时机:由于各客户端阻塞状态等,每个客户端处理推送时机是不一致的
  • 推送可能丢失
  • 推送内容,不支持在回放中处理

比如:

  • 玩家复活
  • 玩家掉线,删除角色



逻辑帧的更新流程是确定的

游戏进行过程中,所有的相关模块:

  • 实体管理器

  • 场景管理器

  • 碰撞管理器

  • 摄像机管理器

  • 等等

这些模块的更新,都是固定顺序执行,所有参数都是确定的,所用随机都是指定随机方法

需要客户端同步的东西,必须通过帧来驱动。



同步的实质

同步,就像一个管理器,它的策略项设计项不难,难点主要在于管理的各个模块的内部实现。因为一场战斗涉及的模块很多,只要有一个模块实现有不同步的地方,整场战斗就不同步了。

在到后期查找不同步原因,也往往是去排查下属模块的实现,可能就是在于遍历方式,随机数的使用,逻辑帧绘制帧等。

主要还是要求:

  • 实现同步下属模块的责任人,有联网战斗的意识,尽量的不使用本地数据,能区分出哪些代码可以使用绘制帧的更新,哪些坚决不允许使用绘制帧的更新。
  • 做同步的责任人,对各个下属模块的涉猎广泛,不能只做完同步就可以了,还需要辅助下属模块进行不同步的排查。

解决这个问题的方向:

  • 让所有实现模块的人都有联网战斗的意识,对整个逻辑帧绘制帧等更新都有概念。(难度很大)
  • 实现自动化同步测试,在发现不同步问题,辅助去定位解决。





参考资料: