在本系列的前几篇文章(文章末尾的所有链接)中,我们研究了新的快节奏射击游戏,研究了基于ECS的游戏逻辑主要架构的机制,以及在客户端上与射击游戏合作的功能,特别是
用于预测本地玩家行为以提高游戏响应性的
系统的实现。 。 这次,我们将更详细地介绍在移动网络连接不良的情况下客户端与服务器之间的交互问题以及为最终用户提高游戏质量的方法。 我还将简要描述游戏服务器的体系结构。

在为移动设备开发新的同步PvP的过程中,我们遇到了该类型的典型问题:
- 移动客户端的连接质量差。 这是200-250毫秒范围内的相对较高的平均ping,并且考虑到接入点的变化,ping的时间分布不稳定(尽管与普遍的看法相反,3G +移动网络中的数据包丢失百分比非常低-约为1%)。
- 现有的技术解决方案是令人讨厌的框架,这些框架将开发人员推向严格的框架。
我们在UNet制作了第一个原型,尽管它对可伸缩性施加了限制,对网络组件进行了控制,并增加了对主客户端反复连接的依赖性。 然后,我们在
Photon Server之上切换到一个自写的网络代码,稍后再进行介绍。
考虑在同步PvP游戏中组织客户端之间的交互的机制。 其中最受欢迎的:
- P2P或点对点 。 比赛的所有逻辑都托管在其中一个客户端上,几乎不需要我们支付任何流量费用。 但是作弊者的范围以及对举办比赛的客户的高要求以及NAT的限制都不允许我们将这种解决方案用于手机游戏。
- 客户服务器 。 相反,专用服务器可以让您完全控制比赛中发生的一切(再见,作弊),其性能可以让您计算一些特定于我们项目的内容。 同样,许多大型托管服务提供商都拥有自己的子网结构,从而为最终用户提供了最小的延迟。
决定编写一个威权服务器。
与点对点(左)和客户端服务器(右)联网客户端和服务器之间的数据传输
我们使用
Photon Server-这使我们能够根据多年来已经制定的方案(在War Robots中使用它)为该项目快速部署必要的基础结构。
Photon Server仅对我们而言是一种传输解决方案,而没有与特定游戏引擎紧密联系的高级设计。 这提供了一些优势,因为可以随时替换数据传输库。
游戏服务器是Photon容器中的多线程应用程序。 为每次比赛创建一个单独的流,该流封装了整个工作逻辑,并防止一场比赛对另一场比赛的影响。 所有服务器连接均由Photon控制,并将来自客户端的数据添加到队列中,然后将其解析为ECS。
Photon Server容器中匹配流的一般方案每场比赛分为几个阶段:
- 游戏客户端在所谓的配对服务中排队。 一旦收集了满足一定条件的所需玩家数量,他便使用gRPC将其报告给游戏服务器。 同时,传输创建游戏所需的所有数据。

建立比赛的一般方案 - 在游戏服务器上,比赛的初始化开始。 处理并准备了所有匹配参数,包括地图数据以及从匹配创建服务接收的所有客户数据。 处理和准备数据意味着我们解析所有必要的数据并将其写入我们称为RuleBook的实体的特殊子集。 它存储比赛统计信息(在其过程中不会更改),并且将在游戏服务器上的连接和授权过程中一次或在失去连接后重新连接时传输给所有客户端。 静态匹配数据包括地图配置(通过将地图连接到物理引擎的ECS组件显示地图),客户数据(昵称,他们在战斗中拥有或不会改变的武器集等)。
- 进行比赛。 组成服务器上的游戏的ECS系统开始工作。 所有系统都在每秒滴答30帧。
- 如果播放器未在一定间隔内发送输入,则每一帧都读取并解压缩播放器的输入或副本。
- 然后,在同一帧中,在ECS系统中处理输入,即:玩家状态更改; 他的投入影响着的世界; 以及其他玩家的状态。
- 在帧结束时,将为玩家打包最终的世界状态并通过网络发送。
- 比赛结束时,结果将发送到客户端和微服务,后者使用gRPC处理比赛的奖励以及比赛的分析人员。
- 之后,匹配流逐渐变小,流关闭。
一帧内服务器上的操作顺序在客户端,连接到匹配项的过程如下:
- 首先,请求将服务排队以通过websocket创建匹配并通过protobuf进行序列化。
- 在创建比赛时,此服务会告知客户端游戏服务器地址,并在比赛之前转移客户端所需的其他有效负载。 现在,客户端已准备好在游戏服务器上启动授权过程。
- 客户端创建一个UDP套接字,并开始向游戏服务器发送请求以连接到比赛以及一些凭证。 服务器已经在等待该客户端。 连接后,他会向他提供所有必要的数据以启动游戏并首次显示世界。 其中包括:RuleBook(比赛的静态数据列表),以及StringIntMap,我们将其称为游戏中使用的行的数据,这些数据将在比赛期间由整数标识。 这是节省流量的必要步骤,因为 每帧通过线路会给网络造成很大的负担。 例如,所有玩家名称,类名称,武器标识符,帐户等,所有信息均写入StringIntMap,并在其中使用简单的整数数据进行编码。
当玩家直接影响其他用户(造成损坏,施加效果等)时,会在服务器上搜索状态历史记录,以比较客户端在特定模拟滴答声中实际看到的游戏世界以及当时服务器上与他人之间发生的情况。游戏实体。
例如,您向客户射击。 对于您来说,这是即时发生的,但是与显示的周围环境相比,客户已经提前“逃跑”了一段时间。 因此,由于要对玩家的行为进行本地预测,因此服务器需要了解对手在射击时所处的位置以及处于什么状态(也许他们已经死了,或者反而变得无敌了)。 服务器会检查所有因素,并对所造成的损害做出判断。
请求创建比赛,连接到游戏服务器并进行授权序列化和反序列化,打包和解包匹配的第一个字节
我们拥有专有的二进制数据序列化,对于数据传输,我们使用UDP。
UDP是在客户端和服务器之间快速发送消息的最明显的选择,通常,尽快显示数据比原则上显示它们更为重要。 遗失的包裹会进行调整,但每种情况下的问题都可以单独解决,例如 由于数据总是从客户端到服务器再到服务器,因此您可以输入客户端和服务器之间的连接概念。
为了基于对ECS结构的声明式描述创建最佳且方便的代码,我们使用代码生成。 创建组件时,还会为其生成序列化和反序列化规则。 序列化基于自定义二进制打包程序,该打包程序允许您以最经济的方式打包数据。 在其操作期间获得的字节集不是最佳的,但是它允许您创建一个流,从该流中可以读取一些数据包数据,而无需对其进行完全反序列化。
实际上,1500字节(又名MTU)的数据传输限制是可以通过以太网传输的最大数据包大小。 可以在网络的每一跳上配置此属性,并且通常甚至在1500字节以下。 如果发送大于1500字节的数据包会怎样? 数据包分片开始。 即 每个数据包将被强制拆分为几个片段,这些片段将分别从一个接口发送到另一个接口。 它们可以通过完全不同的路由发送,并且在网络层向您的应用程序发送粘合的数据包之前,接收此类数据包的时间会大大增加。
对于Photon,该库将以可靠的UDP模式强制开始发送此类数据包。 即 光子将等待数据包的每个片段,并在转发过程中丢失丢失的片段时将其转发。 但是,在需要最小网络延迟的游戏中,网络部分的这种工作是不可接受的。 因此,建议将转发的数据包的大小减少到最小且不超过建议的1500个字节(在我们的游戏中,一个完整状态的大小不超过1000个字节;使用增量压缩的数据包的大小为200个字节)。
来自服务器的每个数据包都有一个简短的标头,其中包含几个描述数据包类型的字节。 客户端首先解压缩这组字节,然后确定我们要处理的包。 在授权过程中,我们严重依赖反序列化机制的此属性:为了不超过建议的1500个字节的数据包大小,我们将RuleBook和StringIntMap包分为几个阶段。 为了了解我们从服务器获得的确切信息(游戏规则或状态本身),我们使用了包头。
在开发项目的新功能时,包装尺寸正在稳步增长。 当我们遇到这个问题时,决定编写自己的增量压缩系统,以及客户端不需要的数据上下文裁剪。
上下文相关的网络流量优化。 增量压缩
上下文数据裁剪是根据客户端正确显示世界所需的数据以及他们自己的数据的本地预测才能正常工作而手动编写的。 然后,将增量压缩应用于其余数据。
我们的游戏每一刻都会产生一个新的世界状态,必须将其包装并传递给客户。 通常,增量压缩是首先将具有所有必要数据的完整状态发送到客户端,然后仅发送对此数据的更改。 可以表示如下:
deltaGameState = newGameState-prevGameState但是,对于每个客户端,将发送不同的数据,并且仅丢失一个数据包可能导致您必须转发整个世界的事实。
对于网络而言,转发整个世界的状态是一项相当昂贵的任务。 因此,我们修改了该方法,并发出了当前处理的世界状态与客户端准确接收到的状态之间的差异。 为此,客户端在其带有输入的数据包中还会发送一个订单号,这是他已经准确接收到的游戏状态的唯一标识符。 现在,服务器根据什么状态知道构建增量压缩的必要性。 在服务器准备数据的下一帧之前,客户端通常没有时间向服务器发送其具有的报价号。 因此,在客户端上有世界服务器状态的历史记录,服务器生成的deltaGameState补丁适用于该历史记录。
项目中客户端与服务器交互频率的图示让我们详细介绍客户发送的内容。 在经典射击游戏中,这样的程序包称为ClientCmd,其中包含有关玩家按下的按键以及创建团队的时间的信息。 在输入数据包内部,我们发送了更多数据:
public sealed class InputSample {
有一些有趣的观点。 首先,客户端告诉服务器在哪个刻度中看到它无法预测的游戏世界的所有对象(WorldTick)。 似乎客户可以因为当地的预测而“停下来”环游世界,并亲自奔跑射击。 事实并非如此。 我们只信任来自客户的一套有限的价值观,并且不要让他超越过去超过1秒钟。 WorldTick字段还用作确认包,基于此包构建增量压缩。
您可以在数据包中找到浮点数。 通常,此类值通常用于从玩家的操纵杆获取读数,但由于它们具有较大的“反弹”且通常过于准确,因此无法很好地通过网络传输。 我们对这些数字进行量化,并使用二进制打包器打包,以使它们不超过可以容纳几位的整数值,具体取决于其大小。 因此,来自瞄准操纵杆的输入包被破坏了:
if (Math.Abs(s.AimMagnitudeCompressed) < float.Epsilon) { packer.PackByte(0, 1); } else { packer.PackByte(1, 1); float min = 0; float max = 1; float step = 0.001f;
发送输入时另一个有趣的功能是某些命令可以发送多次。 很多时候,我们被问到如果一个人按下了极限能力并且丢失了带有输入的小包,该怎么办? 我们只是多次发送此输入。 这看起来像保证交付,但更灵活,更快。 因为 输入数据包的大小非常小,我们可以将几个相邻的播放器输入打包到结果数据包中。 目前,确定其数量的窗口大小为5。
在每个刻度中在客户端上生成并输入到服务器的输入数据包此类数据的传输速度最快,最可靠,足以解决我们的问题而无需使用可靠的UDP。 我们从这样一个事实出发:连续丢失如此大量的数据包的概率非常低,这表明整个网络的质量严重下降。 如果发生这种情况,服务器将简单地复制并播放玩家最近收到的输入,并希望它保持不变。
如果客户端意识到他很长时间没有通过网络接收到数据包,则重新连接到服务器的过程开始。 就服务器本身而言,它监视来自播放器的输入队列是否完整。
代替结论和参考
游戏服务器上还有许多其他系统,负责检测,调试和编辑“增益”比赛,游戏设计师无需重新启动,记录和监视服务器状态即可更新配置。 我们也想更详细地但单独地写这个。
首先,在移动平台上开发网络游戏时,应注意高ping(大约200毫秒),数据丢失频率稍高以及发送的数据大小的客户端的正确操作。 而且您需要明确适应1500字节的数据包限制,以避免碎片和流量延迟。
有用的链接:
该项目以前的文章:
- “我们如何在移动快节奏射手上摇摆:技术和方法 。 ”
- “我们如何以及为什么编写我们的ECS 。 ”
- “当我们编写移动PvP射击游戏的网络代码时:客户端上的播放器同步 。 ”