在Pixonic DevGAMM会谈中,我们的DTO Anton Grigoriev也发表了讲话。 我们公司的负责人已经说过,我们正在开发一个新的PvP射击游戏,而Anton则分享了该项目架构的某些细微差别。 他讲述了如何进行开发,以使客户端游戏逻辑中的更改自动显示在服务器上(反之亦然),以及是否有可能不编写代码而将流量最小化。 以下是报告的记录和成绩单。
我不会学习如何做某事,我会谈论我们如何做某事。 这样您就不会踩到同一把耙子,可以利用我们的经验。 一年半以前,我们公司的员工不知道如何在手机上射击。 您说的如何,您有War Robots,1亿下载量,150万DAU。 但是在这个游戏中,机器人的速度非常慢,我们想做一个快速射击者,战争机器人的架构不允许这样做。
我们知道如何做,但没有经验。 然后,我们聘请了一位有此经验的人说:做同样的事情,已经做了一百遍,只有做得更好。 然后他们坐下来,开始思考建筑。

来到实体组件系统(ECS)。 我想很多人都知道这是什么。 世界上的所有对象均由实体表示。 例如,一个玩家,他的枪,地图上的某些物体。 它们具有由组件描述的属性。 例如,“变形”部分是玩家在太空中的位置,“健康”部分是他的健康。 有逻辑-它是独立的,由系统表示。 通常,系统是Execute()方法,该方法通过某种类型的组件并在游戏世界中对其进行处理。 例如,MoveSystem遍历Movement的所有组件,查看该组件中的速度,参数,并在此基础上计算对象的新位置,即 将其写入Transform。
这样的架构具有其自身的特征。 在ECS上进行开发时,您需要以不同的方式思考和做事。 优点之一是组合而不是多重继承。 还记得C ++中具有多重继承的菱形吗? 他所有的问题。 ECS并非如此。

第二个特征是逻辑和数据的分离,我已经谈到过。 这给了我们什么? 我们可以批量存储世界的状态及其历史,可以对其进行序列化,可以通过网络发送此数据并进行实时更改。 这只是内存中的数据-我们可以随时更改任何值。 因此,更改游戏逻辑(或进行调试)非常方便。
跟踪系统调用顺序也很重要。 所有系统都相继被Execute()方法调用,并且理想情况下应该是独立的。 实际上,这不会发生。 一个系统改变了世界,然后另一个系统使用了它。 而且,如果我们打破了这个顺序,游戏将有所不同。 可能不多,但绝对不同于以前。
最后,对我们来说,主要也是最重要的功能之一是我们可以在客户端和服务器上执行相同的代码。
给开发人员一个机会,他将找到99个做出决定的方法和理由,而不使用现有的方法。 我想很多人做到了。 当时我们正在寻找ECS框架。 我们考虑了Entitas,Artemis C#,Ash.net和我们自己的解决方案,这些解决方案可以根据来找我们的专家的经验编写。

不要尝试阅读幻灯片上写的内容,它不是那么重要。 重要的是列中有多少绿色和红色。 绿色表示解决方案支持要求,红色-不支持,黄色-支持但不完全。
在专栏中,ECS可能是我们的解决方案。 如您所见,它凉爽了-我们可以支持更多的要求。 结果,我们不支持其中一些(主要是因为它们是不需要的),而有些,如果没有它们,我们就无法做进一步的工作。 我们选择了建筑,并且工作了很长时间,制作了一个最低限度的可玩性版本,并且...

原来是最无法播放的版本。 玩家不断回滚,刹车,服务器挂在比赛中间。 不可能玩。 失败的原因是什么?
原因#1,最重要的是缺乏经验。 但是,如何呢? 我们聘请了一位经验丰富的人,他应该做的一切都很好。 是的,但实际上我们只给了他部分工作。 我们说:“这是您的游戏服务器,请继续努力。” 在我们的体系结构中(稍后会详细介绍),客户端扮演着非常重要的角色。 而这正是我们给了一个没有必要经验的人的一部分。 不,他是一位优秀的程序员,参议员-根本没有经验。 即 他不知道那会是什么样的耙子。
原因2-不切实际的分配。 80 KB /帧。 有很多吗? 如果考虑到每秒有30帧,那么在一秒钟内我们将获得2.5 MB,而对于5分钟的匹配,已经超过600 MB。 简而言之,很多。 垃圾收集器开始强烈尝试释放所有这些内存(当我们需要越来越多的内存时),这会导致峰值。 考虑到我们想要每秒30帧,这些尖峰会极大地干扰我们。 而且,在客户端和服务器上。
进行分配的主要原因是我们不断分配数据数组。 几乎每次每帧。 使用过的LINQ,lambda表达式和Photon。 Photon是我们熟悉并在War Robots中使用的网络库。 一切似乎都很好,但是每次发送或接收数据时都会分配内存。
如果我们解决了第一个问题(改写到我们的自定义集合,进行了缓存),那么Photon实际上是不需要做的,因为它是第三方库。 只能减小数据包的大小,而我们只有5 KB。 很多吗 是的 有MTU-这是通过UDP发送的最小实际数据包大小,而不会将数据包分成小部分。 它大约为1.5 KB,而我们只有5 KB(平均来说,还有更多)。
因此,光子将我们的包装切成小包装,并以可靠的价格将每件寄出 保证交货。 每次零件未到达时,他都会反复发送。 我们得到了更多的延迟,并且网络无法正常工作。
所有这些分配导致这样一个事实,当我们需要33个帧时,我们收到了大约100毫秒的帧,然后进行渲染,模拟和其他操作-所有这些都占用了CPU。 所有这些问题都很复杂,即 不可能决定一个,一切都会好起来的。 有必要立即解决所有问题。
另一个小问题是在开发过程中-大量的存储库。 幻灯片上写着5,但在我看来,它们甚至更多。 所有这些存储库(用于客户端,游戏服务器,通用代码,设置等)均通过子模块连接到客户端和游戏服务器的两个主要存储库中。 很难合作。 程序员可以与Git,SVN一起使用,但也有艺术家,设计师等。 我认为许多人试图教给艺术家或设计师如何使用版本控制系统。 这确实很难,所以如果您的设计师知道该怎么做-照顾他,他就是有价值的员工。 在我们的案例中,甚至程序员都吓坏了,结果,我们将所有内容都缩减到一个存储库中。
这是解决该问题的好方法。 我们有一个带有服务器的文件夹和一个带有客户端的文件夹。 该服务器由游戏服务器项目,代码生成器和辅助工具组成。

客户端是Unity客户端和通用代码。 通用代码是世界数据结构,即 实体,组件和系统仿真。 此代码主要由服务器生成器生成。 服务器使用它。 即 这是客户端和服务器的通用部分。
生活 我们采用TeamCity,将其设置在我们的存储库中,收集并部署服务器。 每次客户更改常规逻辑时,都在这里组装游戏服务器-现在不需要服务器程序员。 通常有一个服务器,一个客户端和一些功能。 客户在家中看到服务器,在家中看到服务器,有一天它将为他们工作。 在我们的情况下,情况并非如此-客户端可以编写此功能,并且一切都可以在服务器上进行。
比赛由一个公共部分(称为ECS)和一个演示文稿(这些是统一的MonoBehavior类,GameObjects,模型,效果-世界所代表的所有内容)组成。 他们没有连接。

在它们之间有Presenters,可以同时使用这两个部分。 如您所知,这是MVP(模型-视图-演示器),如有必要,可以替换其中的任何一个。 还有另一部分与网络配合使用(在幻灯片上-网络)。 这是有关世界的信息的序列化,输入序列化,发送到服务器,服务器接收,到服务器的连接等。
更多喜欢。 我们将这部分内容替换为一个不是真实的,通过网络而是虚拟的软件包。 我们在客户端内部创建一个对象并向他发送消息。 它实现了服务器模拟-现在该对象可以完成游戏服务器上发生的所有事情。 其余的玩家将被机器人取代。

做完了 我们获得了游戏,并且无需游戏服务器即可对其进行测试。 这是什么意思? 这意味着艺术家产生了新的效果,可以单击编辑器中的“播放”按钮,立即进入地图上的比赛并查看其工作方式。 或者为客户程序员调试他们编写的内容。
但是我们走得更远,并附加到网络延迟抖动ping(这是网络上的数据包未按其发送顺序到达的时间)和其他网络事物的这一层仿真。 结果,我们在没有游戏服务器的情况下获得了几乎真实的比赛。 它有效,已验证。
让我们回到代码生成。

我已经说过,我们在游戏服务器中有一个代码生成器。 有一种特定于域的语言,它实际上是一个简单的C#类。 在这种情况下,该类生。 我们用我们的属性标记它。 例如,有一个组件属性。 他说健康是我们世界的组成部分。 基于此属性,生成器将创建一个新的C#类,其中将包含许多内容。 它们可以手写,但是会生成。 例如,将组件添加到实体的方法,搜索组件,序列化数据的方法等。 有一个DontSend类型的属性,它表示不需要通过网络发送字段-服务器不需要它,或者客户端不需要它。 或属性Mach,它报告玩家的最大健康值是一千。 这给了我们什么? 我们不发送占用32位(int)的字段,而是发送10位-少三倍。 这样的代码生成器使我们能够将数据包大小从5 KB减小到1。

1 KB <1.5-即 我们遇到了MTU。 光子停止切割,网络变得更好了。 她几乎所有的问题都消失了。 但是我们走得更远,并进行了增量压缩。

这是当您发送一个完整状态,然后仅发送其更改时。 整个世界不会立即完全改变。 只有某些部分会不断变化,并且这些变化的大小比状态本身小得多。 我们平均收到300个字节,即 比原来少17倍。
如果您已经进入MTU,为什么需要这样做? 游戏不断发展,出现了新功能,并随之出现了对象,实体,新组件。 数据量正在增长。 如果我们停止在1 KB,我们将很快回到相同的问题。 现在,将其重写以进行增量压缩后,我们将不会很快实现。
现在最甜蜜的部分。 同步处理 如果您玩射击游戏,则您知道什么是输入滞后-单击该按钮后,角色会在一段时间(例如半秒)后开始移动。 对于暴民类型的某些游戏,这是正常现象。 但是在射击游戏中,您希望英雄射击并在那里造成伤害。

为什么发生输入滞后? 客户端收集玩家的输入(输入)并将其发送到游戏服务器(发送需要时间)。 然后游戏服务器对其进行处理(再次,时间)并将结果发送回(再次,时间)。 这是一个延迟。 如何删除? 有一种叫做预测的东西-客户端不等待服务器的响应,而是立即开始尝试做与游戏服务器相同的事情,即 假装。 接受玩家输入并开始模拟。 我们只模拟一个本地客户,因为我们不知道其他参与者的输入-他们不会来找我们。 因此,我们仅在播放器上运行模拟系统。
首先,它可以减少仿真时间。 客户端一接收到输入,便开始仿真,并且相对于游戏服务器而言要领先几步。 假设在这张照片中,他正在模拟20号刻度线。 在这一点上,游戏服务器模拟过去的刻度号15。 客户过去,将来都会再次看到世界其他地方。 当他向服务器发送第20个滴答声时,到达此输入时,游戏服务器将已经开始模拟第18个滴答声或已经模拟了第20个滴答声。 如果是18,则将其放在缓冲区中,到达20,处理并将结果返回。
假设现在他正在模拟15号刻度线。 处理后,将结果返回给客户端。 客户具有他预测的某种模拟的第15个刻度,第15个游戏状态和游戏世界。 与服务器的比较开始。 实际上,他不比较整个世界,而只是比较他的客户,因为我们对世界其他地方不负责。 我们只为自己负责。 如果玩家重合,一切都很好,这意味着我们正确地进行了模拟,物理工作正确地进行了,并且没有发生碰撞。 然后,我们继续模拟第20个刻度线,第21个刻度线,依此类推。
如果客户/玩家不匹配,则表示我们在某个地方弄错了。 示例:由于物理学不是确定性的,因此它无法正确计算我们的位置或发生了某些事情。 也许只是一个错误。 然后客户端从游戏服务器获取状态,因为游戏服务器已经确认了状态(他信任服务器-如果他不信任服务器,则玩家会作弊),并将其余部分从15日重新模拟为20日。 因为此时间分支现在是错误的。
创建一个新的时间分支,即 平行世界。 我们在一tick中重新模拟了这五个tick。 一旦我们的模拟花费了5毫秒,但是如果我们需要模拟10个滴答声,那么它已经是50毫秒,而我们不会陷入30毫秒。 他们进行了优化,得到了1毫秒的时间-现在在10毫秒内处理了10个滴答声。 因为仍然有渲染。
所有这些事情都在客户身上起作用,我们在没有必要经验的情况下将其交给了客户。 减去-我们有一个fakap,还有-程序员现在知道如何正确地做到这一点。

该方案具有其自身的特征。 左图中的客户正试图追踪敌人。 他在第20跳时,对手在第15跳时。 因为ping和客户端比服务器早5个滴答声。 客户射击,必须准确击中并造成伤害,甚至爆头。 但是服务器上的情况有所不同-服务器开始模拟第20个滴答时,敌人可能已经在移动。 例如,如果敌人在移动。 从理论上讲,我们不应该得到。 但是,如果这样行得通,那么由于不断的失误,没人会玩在线射击游戏。 根据ping的不同,击中的可能性也有所变化:ping越差,您得到的效果越差。 因此,他们的做法有所不同。
服务器获取整个世界并将其滚动到玩家看到世界的柚木中。 服务器知道它的时间,将其回滚到第15个刻度,然后看到左图。 他认为玩家应该已经击中,并且已经在第20个滴答声中对对手造成伤害。 一切都很好。 差不多了 如果敌人逃跑并越过障碍物,那么我们已经爆破了墙。 但这是一个已知的问题,玩家对此有所了解,不必担心。 这样就可以了,没有什么可做的。

因此,我们达到了每秒30个滴答声,每秒30帧。 现在大约有600位玩家同时在我们的服务器上玩。 比赛中有6位玩家,即 大约100场比赛。 我们没有服务器程序员,我们不需要它。 客户端使用C#在Unity编辑器Rider中编写所有逻辑,并且可以在游戏服务器上使用。 几乎总是如此。 我们将数据包大小减少了17倍,并将内存分配减少了80倍-现在在客户端和服务器上甚至不到一千字节。 平均ping是200-250毫秒,现在是150毫秒。200是移动网络游戏的标准,与PC不同,PC的运行速度要快得多,尤其是在本地网络上。

我们计划隔离在单独框架中编写的内容,以将其用于其他项目。 但是到目前为止,还没有关于开源的话题。 并在那里添加插值。 现在我们每秒有30个滴答声,我们可以按滴答声绘制。 但是有些游戏每秒20个滴答声或10个滴答声就足够了,因此,如果我们每秒绘制10次,角色就会猛烈地移动。 因此,需要插值。 我们编写了自己的网络库而不是Photon-那里没有内存分配。
仍然有一些部分您无法用手编写,但是会生成代码。 例如,当我们将世界状况发送给客户时,我们会切掉他不需要的数据。 当我们用手进行操作时以及出现新功能时,我们忘记了切出这些数据,就会出问题。 实际上,这可以通过标记某些属性来生成。
听众的提问
-您正在使用什么进行代码生成? 你自己决定?-一切都很简单-双手。 我们本来想准备一些东西,但是事实证明,用我们自己的双手来书写,速度更快。 遵循此路径,无论现在还是现在,它都运行良好。
-您拒绝了服务器开发人员,但由于重用了相同的代码,因此您不仅减少了开发时间。 Unity不支持最新版本的C#,它在引擎盖下拥有自己的引擎。 您不能使用.NET Core,不能使用最新功能,某些结构等。 表现不会因此受到损失吗?-当我们开始做所有这些事情时,我们认为为了不使用类而是使用结构,它应该工作得更快。 我们编写了一个原型,说明了它在代码中的外观,程序员将如何使用这些结构编写逻辑。 真是不舒服。 我们决定上课,而现在的表现对我们来说已经足够了。
-您现在如何生活而无需插值? 如果快照不在正确的框架中,您如何假装自己是玩家?-我们有插值法,只是插值法不是可视的,而是插在网络上的那些数据包上。 假设我们有18、19和20个州。 18-, 20-, 19- , — . , .
— , ?— 2D — , . : UDP, , : , . .
— ?“是的,当然。” - ( , , ), 2 : , .
— . ? ? 1000 , ? , ?— , . , -, , . , 30 .
— , ?— . , ( ), . — , , . - , , . , , , , . .
— ECS, , ? ?— 30 , . 80 , . .
— prediction. 20- - , , - — , ? , . - ?— : . (, 15-) 16-,17-,18- .
— ?— , . , , . Entity ( ), . — , . ID , .
— - — , , , ? , ?— , , . 3D , — , , - . , , . top-down, — . . , , , . .
— ?— . 这也会发生。
— , , - . - . - , , , 500 , , - - . ?— .

, .. 20- 20- , . — , . : 20- , ? , . , — - . , . , « - , 21-, 18-». : «, - ». .
— .. , ?— , .
— reliable UDP — - ?— Photon, Photon reliable UDP, unreliable, c .
— ?— , -. , . , . , . , . 100%, , 80%, .
— ?— , , Photon , MTU.
— ? ?— , , , . . , . , , , .
— , , ?— , . , . , , . , - . , . , .
— / — - . , .— , . , ( ), -, , -. , . — , .
— , - . ?— , . : ECS, . , ECS . , ECS . , . , , , ( , , , , , ). 2D , , 3D — . 3D , , . . - , . , - -, .
— , ECS , . , , C#?— — .
— .. ES ? , ECS — , , , . .. ECS — , .— , , . , . — , , . , O - , , .
— , ECS- ?— -, ECS , , ( ) — , . , — . — , , . , , , ..
Pixonic DevGAMM Talks