联网战斗同步实现的心得
前言
最近在做联网战斗同步这块的东西,读了不少文章、书籍,于是整理了一下。
之前也有在 团队内部技术分享 中分享过这块内容,但是有些东西受限于时间,只是大概的略过,重点放在了实现与遇到的难题解决上。
后来,在做优化调整的时候,又有不少新的收获,改进了之前的分享稿。
欢迎各位小伙伴来一起讨论,通过分享讨论来不断进步。
简介
现状
网络游戏的同步方案,大概由以下三部分搭配组成
- 网络传输协议
- 网络同步模型
- 网络拓扑结构
网络传输协议(Network Transport Protocol)
分类
- UDP协议
- TCP协议
共同点
- 在 TCP/IP 协议族中[物理层,数据链路层,网络层,传输层,应用层],位于 传输层的协议,均依赖底层 网络层中的 IP协议。
区别
属性 | UDP | TCP |
---|---|---|
传输可靠性 | 不可靠 | 可靠 |
传输速度 | 快 | 慢 |
带宽 | 包头小,省 | 包头大,费 |
连接速度 | 快 | 慢 |
- 此外,TCP还提供了 流量控制、拥塞控制等
其他
- 关于 KCP协议
- KCP协议是一个快速可靠协议,它以浪费10%-20%带宽的代价(相较于TCP协议),换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议的收发。
- 更详细信息可跳转最下方 参考资料2
- 关于 KCP协议
网络同步模型(Network Model)
分类
- 状态同步(State Synchronization )
- 本质:上传包含 游戏外部变化原因集合(玩家操作等)及 中间状态的子集(客户端计算的部分);下发包含 游戏状态的集合。
- 帧同步(Lockstep)
- 本质:上传下发只包含游戏外部变化原因集合(玩家操作等)。
- 对于A客户端: [输入A] -> [过程A + 运算A] -> 输出A
- 对于B客户端: [输入B] -> [过程B + 运算B] -> 输出B
- 确保 [输入] 相同,再保证 [过程] 与 [运算] 一致,那么 [输出] 一定是一致的
- 逻辑帧,游戏在逻辑层面是离散的过程,即可认为是一个逻辑帧一个逻辑帧进行逻辑运算。
- 渲染帧,游戏在渲染层面是离散的过程,即可认为是一个渲染帧一个渲染帧进行画面呈现。
- 游戏逻辑帧 与 渲染帧 需要互相独立。
- 本质:上传下发只包含游戏外部变化原因集合(玩家操作等)。
- 状态同步(State Synchronization )
共同
- 两种同步方案都分为 上传 和 下发 过程。
- 上传 指客户端将信息传输给 服务器/客户端
- 下发 指客户端从 服务器/客户端 中获取信息
- 两种同步方案都分为 上传 和 下发 过程。
对比
属性 | 帧同步 | 状态同步 |
---|---|---|
流量 | 通常较低,取决于玩家数量 | 通常较高,取决于该客户端可观察到的网络实体数量 |
预表现 | 难,客户端本地计算,进行回滚等 | 较易,客户端进行预表现,服务器进行权威演算,客户端最终和服务器下发的状态进行调节或回滚 |
确定性 | 严格确定性 | 不严格确定性 |
弱网影响 | 大,较难做到预表现 | 小,较易做到预表现 |
断线重连 | 难,需要获取所有相关帧且快播追上进度 | 易,根据快照迅速恢复 |
实时回放 | 难,客户端需要消耗非常大性能去从头播放到对应序列,回放完后需要快播追赶 | 易,根据快照进行回放,回放完再根据快照恢复 |
逻辑性能优化 | 难,客户端需要运算所有逻辑,跟客户端性能强相关 | 易,大部分逻辑可在服务器进行,分担客户端运算压力 |
外挂影响 | 大,客户端拥有所有信息,透视类外挂影响严重 | 小,服务器可做视野剔除等处理 |
开发特征 | 平时开发高效,不需要前后端联调;但是开发时要保证各模块确定性,不同步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
- Ping
- 丢包率
- 原因: 直接原因是由于 无线网络 和 拥塞控制,根本原因比较复杂。
- 标准:
- 优秀:<= 2%
- 一般:2% ~ 10%
- 差:>= 10%
- 网络时延的评估标准
对比
属性 | P2P结构 | CS结构 |
---|---|---|
样式 | 全连接的网状结构 | 星状结构 |
连接数 | O(n^2) | O(n) |
流量 | 各客户端相等,均为 O(n^2) | 服务器为 O(n),客户端为 O(1) |
客户端间的时延 | 较小,为RTT/2 | 较大,为RTT |
实现
广义
广义上来说,游戏采用的技术是:
- 网络传输协议: KCP & TCP
- 网络同步模型: 帧同步
- 网络拓扑结构: CS结构
图例
狭义
关联类
- 同步管理类
- 联网战斗数据缓存类
- 联网战斗场景类
同步管理类
功能
- 同步帧的操作的处理
- 添加
- 处理
- 执行
- 修正
- 对玩家操作的处理
- 收集
- 上传
- 吞噬
联网战斗数据缓存类
功能
- 提供联网战斗通用数据模型
- 解析玩家数据
- 客户端与战斗服交互的中枢
- 发往战斗服消息,均由此统一发送
- 收到战斗服的消息,处理后转发给其他业务类
- 断线重连相关处理
- 追帧相关
联网战斗场景类
功能
- 处理联网战斗基础场景流程、方法
- 房间状态切换流程
- 创建角色数据及实体
- 传送逻辑
- 实现本地战斗与联网战斗切换
为什么采用帧同步
- 游戏的核心逻辑在客户端实现,服务器只负责转发验证等
- 游戏类型及形式,动作类、房间为单位;更适合用帧同步
- 开发速度快,周期短
重点处理
如同帧同步的简介中介绍,要保证 输出的一致性,先要确保输入、过程、运算的一致性。
浮点数与定点数 [运算一致性]
浮点数的运算在不同的操作系统,甚至不同的机器上算出来的结果都是有精度差异的。
一般解决该类问题方法:
- 使用定点数
- 使用分数
这里主要麻烦点在于lua支持定点数,lua中的小数是double,需要把lua源码中的基础小数全部替换为定点数。
然后,物理引擎的计算,第三方库的引用(比如随机数),都需要使用定点数。
确定的 随机数机制 [运算一致性]
确定的随机数机制就是保证各个客户端一旦用到随机数,随机出来的值必须是一样的。
得益于计算机的伪随机,通过设定同样的随机种子即可实现。
但是,在客户端内,需要明确区分随机数的类型
- 战斗类
- 设计战斗的实体、BUFF、技能 等等
- 非战斗类
- 主要是显示项的随机,比如 loading期间的 tip选择
这里,为了更明确区分,在客户端做了一层封装:
- AEUtil:GRandom,战斗类的随机数方法
- AEUtil:UIRandom,非战斗类的随机数方法
做好区分,也便于相关日志的打印。
使用战斗类随机数模块:
- AI
- 行为树
- 相机
- 技能、BUFF、特殊能力
- 实体相关
- 地图相关
使用 非战斗类 随机数模块:
- UI界面
- 外挂检测
- 数据收集
- 音乐音效
当然,也不是绝对的,比如实体相关的有些可以不用战斗类随机数,比如NPC弹出个对话,也是纯显示性的。这里是为了好区分,方便开发,一刀切了。
确定的 容器及算法 [过程一致性]
- 对于lua语言,不要用 pairs 方式遍历,要用 ipairs,也相应就要求容器必须是数组
- 所有用到的算法,必须是 稳定 的算法
隔离与封装 逻辑层 [过程一致性]
所有模块都可以分为 draw 与 update 两部分
- draw 进行绘制,走本地绘制帧更新
- update 进行逻辑计算,走逻辑帧更新,可被帧同步接管
实现帧同步尤其需要对 逻辑层的数据进行封装与隔离
以位移组件为例:
- 位移组件有两套坐标
- 逻辑坐标
- 渲染坐标
- 人物的行走都是通过逻辑坐标计算,渲染坐标是在渲染帧的时候,将当前渲染坐标与逻辑坐标进行比较,再用差值平滑过渡
同理的还有:
- 碰撞框的计算
- 各组件
- 各实体
做好分离,也便于之后做快照相关的优化。
支持本地战斗
创建联网战斗场景基类继承自单人战斗场景基类,用来统一控制联网相关的特殊操作,如 传送,协议交互 等。
然后,设置本地战斗变量,用来进行控制,若是本地战斗,交由基类处理。
同步模式 及 处理帧策略 [过程一致性]
同步模式
- 服务器: 固定推帧 30帧/秒
- 客户端:
- 30帧/秒,每次执行一次处理帧
- 60帧/秒,每次执行一次处理帧
- 30帧/秒,每次执行一次处理帧,绘制帧到来时,若有帧积压,再执行一次处理帧
处理帧策略
每次执行一次处理帧操作,具体释放帧数量
- 释放1帧
- 逐步释放帧
- 累计帧数 < 2帧,释放1帧
- 累计帧数 < 5帧,释放2帧
- 累计帧数 < 10帧,释放3帧
- 累计帧数 >= 10帧,释放所有帧
- 可变释放帧
- 释放帧数量由 PlayFrameScale 变量控制,可 加速/减速 播放(一般用于处理回放)
- 释放全部帧
断线重连
断线重连,主要由 联网战斗数据缓存类负责。
- 从服务器中获取重连过程中的战斗帧
- 进入 追帧模式进行追帧,在追帧模式中,服务器发来的推送帧会被缓存起来
- 追帧完毕后,退出追帧模式;并将追帧期间的 服务器推送帧压入 同步管理器中
同步校验
验证多个客户端是否同步,主要依赖于随机数及调用随机数的位置。
在联网战斗运行时,会将使用的随机数都打印出来,由于我们随机种子一致,所调用的随机数序列也应该是一致的,辅助以调用随机数的位置信息,战斗结束后对不同客户端的随机种子文件日志比对,可以校验同步。
我处理这块的方式是使用两个日志文件,
- 一个用来做同步校验:大部分内容是 使用随机数的模块 + 随机数范围 + 最终生成的随机数,还有一些必然一致的过程日志。
- 另一个用来做同步排查:包含更详细的日志信息
两场战斗结束后,用对比工具比较日志,一旦有差异,用更详细的日志信息,进行排查。
优化项
联网战斗同步向来不是一个做完就行的东西,而且也没有一套东西,在各个类型游戏通吃的情况。
所以,在实现完基础的同步架构后,还有很长的路要走。
目前只是搭建了一个基础的框架,要真正投入还有下面这些优化项可以做。
下面这些东西,有些已经做了,有些正在做,有些是一些设想,即将做的;欢迎各位伙伴一起来讨论。
快照的支持
在帧同步基础上,进行优化;就是 帧同步+快照 的模式。
其实已经不属于帧同步了,偏向状态同步。
快照作用就是将整个现场备份,缺点是数据量过大。
但是,我们以房间为单位的战斗,尤其适合 帧同步+快照;因为有明确的划分单位;并且房间初始,很多东西都是不需要存储的。
- 房间内的快照,所有实体的状态(怪物、NPC、传送门 等等),HP、EP、受损状态 等等
- 房间间的快照,实体都是初始创建,且实体的创建是不通过帧的,可以本地处理
这三者区别,
帧同步 => 没有进度条的播放器;想要看到第6分30秒的内容,必须从头开始看
状态同步 => 有进度条的播放器;知道时间,就可以直接切到相应时间开始播放
帧同步+快照 => 有进度条,但单位是5分钟;要看 6分30秒的内容,不需要从头看,但是也要从第5分钟开始播放,直到6分30秒
安全性
帧同步的安全性也是一个重大的问题,可以分为几大部分。
客户端的安全模块,游戏的核心战斗逻辑演算都在客户端进行,所以对于数据的加密,防篡改等都是由安全模块统一处理。
网络模块,对于网络层的外挂,由底层网络模块的加密等处理。
联网战斗系统的防外挂模块
基础的几个方案
- 每隔一段时间,进行玩家信息收集并上传(如血量、技能使用、buff使用),出现结算不一致,由服务器裁决,可以解决部分外挂
- 服务器新开一个“客户端”,在那个客户端上跑所有的帧,作为评判依据。
- 等等
防外挂这个东西,就是魔高一尺,道高一丈,不断优化,不断调整的过程,有些东西也不好讲太细,只能说个大概。
不同步的处理
解决不同步问题,也是帧同步方案的一大痛点。
对于不同步的处理,可以分为三个部分:发现 -> 重现 -> 解决
作为开发,应该深有感触,如果方便重现,那解决问题就很简单了。
下面的处理方式都是针对传统的不同步处理各个步骤,进行优化设想。
一般出现不同步: 发现不同步 -> 打开日志开关 -> 使用同样的数据源 -> 复现问题 -> 解决问题
发现
发现不同步,最简单粗暴的方式,肯定是人力跑,没有技术成本,纯跑…
但是,缺点很多:
- 人力不足
- 时间不足
- 不够全面
- 不方便收集日志
- 不能体现技术实力
- 等等等等
所以,需要一种自动化的测试工具,来进行大量全面的测试。
目前打算是使用 python + jenkins 来部署自动化测试流水线,等测试完,再单独来说一说。
重现
重现不同步,也是很重要的一个步骤,能完美重现,那距离解决就不远了。
这里预期采用的方案是,固定数据源 + 回放机制。
固定数据源
需要和服务器配合,服务器需要存储参战玩家信息及帧内容,便于回放。
前期可以全部存储,但是这样服务器压力会比较大;后期可以将本地战斗产生的同步文件形成MD5,发给服务器;服务器判断各客户端MD5不同,采取缓存录像。
回放机制
需要客户端实现一套根据帧内容回放机制,理论上来讲帧同步的回放还是比较好实现的。
毕竟 确定的输入,确定的运算,确定的过程,都与时间无关联,可以得到确定的输出。
但是,我们需要的是日志文件,所以绘制帧内容可以忽略掉,尽量做到逻辑帧的播放,这样在时间上也会更快。
解决
解决不同步问题,那就相对简单很多了。
实现了上面的发现 与 重现,可以无数次反复执行不同步数据源,验证是否解决也很便捷。
运行过程中的日志收集
这应该属于发现不同步的部分。
在实际项目中,日志的实现都是比较粗暴的,一般来说线上运行的模块,都不会开启日志文件。因为一般日志文件都会比较大,尤其是查同步问题的日志文件,涉及模块繁多,产生文件体积大。
所以,线上出不同步问题,往往也很难复现并解决,就是无法固定数据源。(不产生校验文件,就不能上传MD5,不能传MD5,服务器无法判断是否不同步,就不会缓存)
如果有一套性能损耗小一些的日志收集系统,会对同步问题的解决有很大的帮助,
正好最近看到了 《腾讯游戏开发精粹》- 第六部分 - 第14章 - 一种高效的帧同步全过程日志输出方案 。
上面的方案也对我有一些启发,之后可以去实验一下。
延迟处理
在实际测验中,会有玩家反馈卡顿情况。
延迟、卡顿的玩家体验,一般可以分为:
- 延迟高
- 波动大
而且,不同游戏类型对延迟的敏感度也不同,现在实现的这种偏格斗类型的游戏,对延迟敏感度还是比较高的。
再者,传统帧同步的处理,逻辑上就是比本地操作要慢一帧:
A帧操作 -> B帧上传 -> C帧执行
B ≥ A,C ≥ B+1
最终,还是要用数据来验证延迟的具体位置,可以按照下流程打时间戳,再收集各个数据,来分析并解决延迟与卡顿:
这里列出几个方向:
- 玩家的位置
玩家的机型
战斗的时间
- 玩家的运营商
数据收集的选项:
- 最小值
- 最大值
- 平均值
- 波动值
这里还要注意设置阈值,防止某个异常操作,导致数据不准确,拉高或拉低平均值。
甚至可以设置一些字段,来做筛选剔除异常数据。
感悟
不信推送,相信帧
推送缺点
- 每个客户端处理的时机不同
- 收到的时机:服务器 推送A,再推送B;对于客户端肯定是先收到A,再收到B
- 处理的时机:由于各客户端阻塞状态等,每个客户端处理推送时机是不一致的
- 推送可能丢失
- 推送内容,不支持在回放中处理
比如:
- 玩家复活
- 玩家掉线,删除角色
逻辑帧的更新流程是确定的
游戏进行过程中,所有的相关模块:
实体管理器
场景管理器
碰撞管理器
摄像机管理器
等等
这些模块的更新,都是固定顺序执行,所有参数都是确定的,所用随机都是指定随机方法
需要客户端同步的东西,必须通过帧来驱动。
同步的实质
同步,就像一个管理器,它的策略项设计项不难,难点主要在于管理的各个模块的内部实现。因为一场战斗涉及的模块很多,只要有一个模块实现有不同步的地方,整场战斗就不同步了。
在到后期查找不同步原因,也往往是去排查下属模块的实现,可能就是在于遍历方式,随机数的使用,逻辑帧绘制帧等。
主要还是要求:
- 实现同步下属模块的责任人,有联网战斗的意识,尽量的不使用本地数据,能区分出哪些代码可以使用绘制帧的更新,哪些坚决不允许使用绘制帧的更新。
- 做同步的责任人,对各个下属模块的涉猎广泛,不能只做完同步就可以了,还需要辅助下属模块进行不同步的排查。
解决这个问题的方向:
- 让所有实现模块的人都有联网战斗的意识,对整个逻辑帧绘制帧等更新都有概念。(难度很大)
- 实现自动化同步测试,在发现不同步问题,辅助去定位解决。
参考资料: