文/kisence

从今年下半年开始制作一款实时对战游戏以来,我就在着手写一个帧同步的游戏框架,其中包含了服务器框架和客户端框架,该框架目前已经开源。

期间踩过无数的坑,充分领略到了国内中文技术文档十分稀少和零散的问题,所以在这里我想写下我走过的路,以便于后来者参考。

首先,我希望写一个前后端能统一语言的框架,以至于在前端写好的游戏逻辑,拿到后端就可以直接使用。

其次,我的目标是写一个同步框架,在框架层面解决同步问题,在此之上写游戏逻辑的时候不需要再考虑游戏同步的问题。

目前看来,这两个目标都得到了比较好的完成。

下面是解决方法。

首先要解决的是前后端语言一致的问题

这里我使用了一个c#服务器框架 SupperScoket

遇到的几个坑是:

1.导出这个框架到在Mono上运行时报一个找不到window API的错误,解决方法是使用.Net 4.0以上版本的SupperSocket

2.框架在使用TCP模式时,有时会报出一个Send byte Time out 的异常,解决方法是使用TrySend方法,并在返回false时关闭连接。

第二个要解决的是同步框架的问题

这个问题比较复杂,如何在书写游戏逻辑的时候感受不到同步问题的存在?如果每个事件都要等服务器的回包,还要体验流畅,只能从预处理和追赶两个角度入手。

预处理就是,这个事件还没有发生,但是考虑到网络延迟的存在,提前先把结果发送给每个客户端,然后客户端到了这个时刻再把这个事件表现出来,典型的例子就是皇室战争。

如果说没有办法做到预处理呢,比如说玩家的操作需要立即响应,那么其他玩家收到这个事件的时候必然已经迟了,所以就要做追赶,比较典型的就是影子跟随算法。

但是这两种做法必然要在游戏逻辑中做对应的处理,开发者要时刻清醒此时是预测还是追赶,增加逻辑的复杂性,而且游戏的表现可能也参差不齐,有些地方也许同步的好,有些地方可能不好,要调优需要在每个地方都下功夫,增加开发时间。

那么应该怎么办呢,最理想的方法当然是全部当成本地计算,这样就无需考虑是追赶还是预测的问题了,那么网络游戏怎么全当成本地计算呢?当然就是帧同步了。

关于帧同步网上已经有很多资料,在此不再赘述,但是关于帧同步有一个核心的问题,那就是它在网络差的时候表现很差,这一点我们可以从星际争霸和魔兽争霸这些游戏中看出来,一旦有人卡顿,所有人都要停下等这个人的消息,但是我们知道手游《王者荣耀》这款游戏就是帧同步做的,他是怎么解决这一问题的呢?在《王者荣耀》负责人在unite 2017大会分享中我们没有看到这一解决方案,这可能是他们的商业机密,但是在看了暴雪分享的守望先锋同步机制之后,我得到了一个我自己的解决方案。

那就是预测回滚和解。

原理很简单,游戏开始时,每个客户端按照帧同步的方案推进着游戏,但是如果遇到服务器没能及时返回其他玩家操作的时候,给对应的玩家预测一个操作(复制该玩家最后一次操作),并继续推进游戏,如果在其后收到了服务器玩家关于这个人的操作,则把游戏回滚到预测开始的那一帧重新计算一遍,然后和现在游戏世界的表现和解。

如果服务器迟迟没有收到某个玩家的消息,则会给这个玩家预测一个消息(复制该玩家的最后一次操作)然后推送给所有玩家,包括那个掉线的玩家。其他玩家会以这个预测操作为准计算接下来的游戏世界,而这个掉线玩家也会收到这个预测操作,并且替换掉玩家实际进行的操作,重新计算一遍游戏世界。保证每个客户端的输入一致。

原理说起来简单,但是其实有几个难点。

第一个难点就在于回滚,如何回滚到预测开始的那一帧呢,要记录下每一帧的变化,然后逐帧退回吗?还是把每一帧的数据做一个快照保存下来?

其实这个问题实现起来不难,关键是从性能考虑,如果把每一帧的数据都快照下来,内存可能会吃紧,如果做逐帧退回的方式,实现起来相对复杂,并且在性能上也可能有问题。

这里就引入了ECS架构帮助我简化了这一问题,在ECS架构中,C 也就是component(组件),它是纯数据的集合,并且 E 也就是 Entity(实体) 集中存放在一起,这方便了我对它们的集中操作,

在ECS架构的帮助下,我实现了对组件进行快照式的存储,对实体进行了增量式的存储,实现了对数据的回滚。

第二个难点在于和解,由于预测操作和玩家真实操作的不同,重计算出来的世界必然预测的世界有差异,那么怎样以尽量不引人注意的形式,把预测世界过渡到真实世界呢,这一点守望先锋的分享中提到了一部分,但是没有完全解答这个问题。

实际上解决这个问题的思路是,先确定哪些是可以和解的,哪些是不可以和解的,然后分头处理。怎么分头处理呢,就是可以和解的在预测计算中就表现,不可以和解的,要等到真正的数据来了才进行表现。

那么哪些是可以和解的呢?就是在玩家不知不觉间就可以过渡到的,比如说物体的位置,动画。这里有很多的技术可以做这种和解,比如说影子跟随算法。

不可以和解的比如说冒出的血条数字,你不能说伤害数字都冒出来了,然后又塞回去。

但其实有一个难点是,飞弹能不能和解?

显然,飞弹的位置是可以和解的,但是飞弹的创建与销毁呢?这里涉及到一个游戏表现的问题,如果飞弹的创建要等到服务器回包才出现,那么这个表现在网络差的时候就太糟糕了。

所以一定要可以和解,不能和解创造条件也要和解。

下面是解决方案

其实一部分解决方法在难点1已经提到了,首先要建立一个对实体的回滚系统,保证飞弹能回滚。

但是还有一个问题,在回滚的过程中要先把这个飞弹销毁,但是如果重计算的结果是仍然创建这个飞弹呢?难道要再把这个飞弹再创建一次?虽然我们可以用池来避免频繁的创建销毁,但是粒子系统从池中取出仍然有重新构建的开销。

很自然的想到可以延迟派发创建的事件,在数据层面这个实体已经被重计算的很多次了,但只要这个实体仍然存在我就不再派发这个实体的创建事件。销毁也是一样。

但是我如何确定我两次创建的实体的是一个呢?要知道我们框架的设计目标是开发时尽量避免对同步系统的感知,也就是我们游戏逻辑并不知道现在的数据是真实的数据还是预测的数据,要在创建这个体的的时候判断这个实体是否已经在预测时创建过了显然不应该是我们游戏逻辑应该做的,可我们的框架又如何确定两个实体是否一致呢。

直接比较它们两个是否相等肯定不行,把他们的数据取出来一一比对又太耗时。

我采用的方法是我称之为特征码的方法,在构建一个实体的时候,用一个字符串去描述这个实体,这个字符串要尽量简略而又不能与其他特征码重复,然后自己实现的hash方法(.NET自带的GetHashCode有平台差异)把这个字符串转化为一个Int作为这个实体的唯一标识符,在创建实体的时候,只需要判断这个实体ID和缓存中的ID是否一致就可以判断这个实体是否已经在预测中存在了,从而实现延迟派发。

再说一点其他的技术细节

1.实体的集中创建与销毁

现在的架构中可以看到是一帧结束后把所有的实体集中的创建与销毁,为什么要这样做呢,实际上是为了重计算服务,当重计算进行时要先进行回滚,我根据回滚数据得知它是在某一帧里被创建的,但是不知道在哪个系统中,这就有可能造成,在实际计算中某个对象实际上在稍晚的时间被创建,不会被较早时间执行的系统所影响,但是回滚后,这个对象被创建在了较早的时刻(这一帧的开始),导致较早执行的系统也影响了他导致计算错误,为避免这一问题,我采用统一创建和销毁时刻的方式解决。

这一方式有一个问题,就是创建飞弹等对象时至少要延迟一帧,在主观感觉上就慢一点,守望先锋也提到了这一问题,他们提到后来把创建的时刻重新拿回了游戏逻辑内,我估计是在保存回滚数据时把是在哪个系统创建的实体也保存了下来,这样就可以避免计算错误的问题,目前在我的框架里还没有优化这一问题。

2.断线重连

关于断线重连这一点,使用ECS架构的优势就体现出来了,传统的帧同步断线重连只能把所有的玩家的数据从头输入一遍重计算,时间很长,而ECS可以很方便的把服务器的当前数据全部发送给重连的客户端,让客户端直接从重连的那一帧开始游戏,避免了漫长的重连过程。

3.常见的不同步情况

1.MomentComponentBase 组件的DeepCopy方法没有正确的拷贝全部数据

2.有些组件从本地创建和通过服务器同步消息创建的值有差异(比如有些组件有特殊的构造方法,而通过服务器同步的组件不执行构造方法)

3.前后端代码不统一

4.前后端数据表不统一

5.在进行整数计算的时候,数值溢出

6.浮点数计算误差(读表也会出这个错误)

7.同步逻辑之外的数值修改(例如付费复活)

原文链接https://www.kisence.com/2017/11/ ... g-bu-de-xie-xin-de/

锐亚教育

锐亚教育,游戏开发论坛|游戏制作人|游戏策划|游戏开发|独立游戏|游戏产业|游戏研发|游戏运营| unity|unity3d|unity3d官网|unity3d 教程|金融帝国3|8k8k8k|mcafee8.5i|游戏蛮牛|蛮牛 unity|蛮牛