网络延迟是所有实时同步的游戏都会遇到的问题,下面是关于实时同步问题的一些思考和处理方法。具体的解决方法可能比较特殊,首先这里的服务器并不跑定时器(除了一个游戏结束倒计时的定时器),由前端驱动,延迟的情况下主要是由前端来预测或纠正,服务器辅助,处理和转发,据我的了解好像没什么人这样子搞吧。所以看完如果觉得我这边有考虑不周的,或者有更好的思路,欢迎拍砖 / 交流
首先这是一个两边出兵对攻的游戏,只有2个玩家,而战场上士兵/英雄的数量也不会太多,最多不会超过50个吧,士兵都是有AI的,不被玩家控制。玩家能做的操作很有限,这里只以出兵这个操作为例。在游戏开始的时候,会有一次校时操作,这个后面会说到。
玩家A在Tick1点出兵,播放各种出兵特效之后,Tick1 + Delay = Tick2,在Tick2这个时间才会真正出兵,Delay等于抬手时间,也就是一个允许的延迟时间,并不等于网络延迟,应该理解为一个前摇动画的播放时间,这个时间越长越好,在不影响玩家体验的前提下(例如弄一个士兵生产队列,从质量发出到士兵生产完毕,这可需要不少时间)。
这时候玩家请求服务器,请求的内容包含Tick1 + 出兵指令,服务器收到指令,对服务器而言,这时可能有两种情况,第一种是在Tick2之前收到,第二种是在Tick2之后收到
在Tick2之前收到,服务器可以转发操作,告诉所有的客户端,我们在Tick2出兵(包的内容包含Tick2和出兵指令),这时候对另外一个玩家,他可能看不到出兵的特效,而是直接看到出兵,这没有问题,或者他看到的特效在时间上晚了一些,这都是可以接受的,不影响游戏逻辑的。
在Tick2之后收到,服务器可以丢弃这个操作,或者立刻执行这个操作,这时候已经是Tick3了,广播给所有玩家,在Tick3出一个兵,因为这时服务器已经是Tick3,那么客户端收到出兵指令的时间是已经过了的,这时候会导致客户端一定会进行一次纠正,那么我们可以再延一延,让这个指令在Tick4再执行,Tick3 + Delay2 = Tick4。丢弃,Tick3,Tick4执行的结果分别是,玩家的操作无效,战斗需要经过一次纠正(纠正会导致玩家看到奇怪的东西),以及玩家的操作延迟了,但是看到的东西是正常的,只是我的操作晚了一段时间而已。
所有的指令从触发到生效都不是立即的,都是经过延迟的,哪怕我的网络延迟在1ms之内,我这个指令都要在delay时间之后才执行,而前后端都会有一个指令Queue,来记录在哪一帧应该执行哪一个指令。
所有的指令,只要做到在第几帧执行的统一,就可以保证结果的统一。
客户端接收到指令的时候,这时候又有2种可能,分别是在Tick2之前,以及Tick2之后
在Tick2之前收到,那么皆大欢喜。在Tick2之后收到,那么我们这个操作就延迟了,可能要进行纠正,这里说可能,因为还有一线生机。就是玩家点出兵之后,并不等服务器返回,而是在Tick2时自动出兵,这样子的一个好处是,客户端在网络比较差的情况下,所有的东西都可以得到反馈。虽然这些反馈可能是错误的,但是没关系,关键是玩家体验的流畅,错了最后肯定会被纠正,只要处理好纠正时候的表现就可以了。
而且这样子做不一定是错的,如果服务器在Tick2之前收到我的请求,那么服务器会在Tick2执行出兵的逻辑,而我在Tick2之后收到服务器的响应,但我也在Tick2执行了出兵的逻辑,这是同一帧,这种情况下是不需要纠正的,因为虽然延迟了,但是我们正确预测到了结果。需要纠正的有两种情况,第一种是B玩家在Tick2之后收到,因为B玩家是无法预测到A玩家在Tick1点击了出兵,在Tick2出了一个兵。第二种是服务器在Tick2之后才收到,那么两个端可能都需要纠正,这里说可能,因为还有另外的一线生机。
如果我们不做预测,而是等服务器的结果,根据服务器的结果来执行,这种情况下,客户端的表现是流畅的,战斗是流畅的,只是我的点击没有立即反应而已。服务器在Tick3下发(内容包含Tick4 + 出兵指令),在Tick4执行,客户端只要在Tick4之前能收到,就可以在Tick4执行出兵,那么结果也是正确的。这种甚至我们可以以服务器收到的时间为准,服务器每次收到都在当前时间的基础上延迟一段时间来执行,完全无视玩家点击的时间,只要客户端在这段延迟时间内收到结果,也是不需要纠正的。
这两种一线生机的区别在于,一个是忽略了包从服务器回来的延迟,一个是忽略了包到服务器的延迟,一个是保证客户端流畅,一个是尽量避免纠正。具体要在实际环境中测试才能知道,哪种更适合我们游戏。由于所有指令的执行会放在一个队列中,所以这几种方式的切换只需要改动少量的代码。当我们拿捏不准的时候,尽可能让这部分可以被灵活地调整。整个服务器这边的Tick机制就是可以被灵活调整的(因为我不跑定时器)。
最后就是纠正了,首先纠正是由前端发起的,当然后端也知道前端延了,后端的处理其实比较随意,无关紧要,前面说的,丢弃,立即执行,或者延迟执行,都是可以的,但看上去延迟执行是个更好的主意,这个也看具体游戏吧。前端如何知道自己延了,这个问题其实很简单,在战斗开始的时候进行一次校时,然后双方都以一致的频率,例如每秒10帧,从第0帧开始前进(实际上服务器只是记录一个时间,并不跑定时器)。
其实前后端只要记录了第0帧的这个开始时间,是很容易算出当前是在第几帧的,前后端的这个开始时间并不相等,只是逻辑上相对而已。当然,这个时间也会存在误差,误差的结果就是,一边快一点或者慢一点。但是没关系,我们不是按照时间来算,我们是按照帧来算,咱不要求同一个时间两个端的内容是完完全全的一样,咱只要求结果一样。例如一台设备性能很差,每秒5帧,但是他的结果不会错误,游戏10秒后结束,他这边就20秒后结束,但结束时的结果是一样的就行,至于操作,如果存在这样的可能性,那服务器就把操作延迟执行,对前端而已可能按下去要等好几秒才能响应,但这时候都已经不是网络延迟的问题,是设备卡顿的问题。前端一直在跑,但是跑不过来,要解决这个问题,只能是换手机,当然我们自己要保证在性能很差的手机上能跑起来才行,例如两三年前的机器,但实际上咱也不需要花太多心思在这上面,这个玩家这么烂的手机都不舍得换,怎么舍得往游戏里面充钱呢?直接放弃他了。
服务器并不跑计时器,这是什么原因呢?有一部分是性能的原因,每个子弹,怪物,BUFF这些可能都需要挂计时器,我不希望服务器挂太多的计时器。比较大的一部分是让拿捏不准的这部分更加的可控。例如这个游戏不做实时同步了,让事情变得不是那么糟糕。只需要少许的改动,就可以实现服务器的校验。
不跑计时器怎么做呢?很简单,首先你还是需要有一个计时管理的,类似Schedule,游戏逻辑中添加的计时器全放到这里面,当客户端请求的时候,我们先从上一次计算的时间模拟到当前时间,将当前时间减去上次的时间,算出有N帧,然后直接 loop N次计时器,这个loop和客户端的loop相比,就是少了一些判断逝去时间是否小于最小间隔时间,如果是则sleep一下这样的代码,而改成了一个for循环,循环N次。当然,loop的不一定只是计时器,但是一定包含计时器,而且最主要的就是计时器。
loop完之后,将上次的时间记录为当前时间,然后进行校验,转发指令。这时并不执行指令,而是把通过校验的指令放入指令队列中,等待下次执行。指令执行的结果是什么,服务器现在是不知道的。可以看到是客户端的请求来驱动服务器,而不是计时器来驱动服务器运行逻辑。这就有点类似回合制了。那么还有一个问题,假设客户端都不发请求,那服务器不就动不了了?服务器会跑一个计时器,这个计时器只做一件事,就是游戏结束,模拟玩家发一个空的指令到服务器这边,服务器loop到游戏结束,然后下发战斗结果。
不跑计时器,如何让当这个游戏从实时同步变成非实时同步变得简单呢?可以这样子,客户端所有发送的指令,全部都在客户端直接运行,但是记录下来,然后在游戏结束时,请求一次服务器,将指令队列发给服务器,服务器只需要设置指令队列,然后执行一次loop到游戏结束的调用,自然可以校验到战斗结果。这个非实时同步的需求,本身就是存在的,例如单刷副本,这个我们是需要校验的。所以只需要在外面进行一层包装,就可以替换他们。而且改动的代码量并不多。
接下来还是说纠正的问题,当客户端发现服务器下来的包延迟了,超过可接收的时间了,客户端需要向服务器请求最新的数据来刷新,这个过程中,客户端是正常运行的,然后当数据下来之后,大概花0.5-1秒的时候来刷新数据。将本地有服务器没有的对象干掉,本地没有服务器有的对象创建,两边都有的数据进行一个平滑的插值计算,让他在这段时间过渡到最新的数据。这里一定会产生一些玩家看起来很奇怪的画面,在上面加一个正在重新连接...,玩家应该会比较能接受这小段看上去奇怪的画面。
过渡的时间内,本地的实时逻辑帧是一直在正常运行的,它记录服务器当前运行到第几帧了,本地还有另外一个帧变量,这个变量表示当前逻辑帧,这两个帧都是逻辑帧,正常而言,这两个帧是相等的,但当纠正发生时,当前帧会小于实时帧,例如第200帧发现我本地需要纠正,然后请求服务器,服务器下发了最新,也就是201帧的结果下来,到客户端这边,已经是207帧了,但重置到最新的数据,也就是201帧,然后开始加速执行。执行到两个逻辑帧相等,即恢复正常速度执行。
首先加速是可行的,因为服务器可以瞬间执行完,那么客户端为什么不能加速执行完呢?最关键的是,每一帧的结果都是一样的,前面从200帧之前,客户端有一部分的结果就已经错误了,纠正的本质是把错误的结果丢弃,然后重新设置正确的结果。再继续运行。
另外假设是客户端跑得太慢,跟不上服务器的话,那实际上服务器使用延迟执行的方式,基本上是不会有纠正的需求的,因为所有指令对于这台设备而言,都是未发生的,客户端只能慢慢跑,慢慢演示战斗结果。这样的设备上,动画都是一卡一卡的,问题已经从网络延迟的问题转变为设备的性能问题了,这是另外一个优化的话题了。
逻辑帧和显示帧的分离,有这么几个目的,逻辑帧用来保证逻辑的准确性,也就是这场游戏一共跑2000帧,执行2000次逻辑处理。这部分是前后端共用的。至于前端的显示刷新了8000帧,还是6000帧,影响的只是动画的平滑度而已,不影响结果,前端花100秒还是200秒来跑完游戏,也是不影响结果的。第二是方便纠错和加速,逻辑帧和显示帧的交互流程是这样的,每次逻辑帧执行的时候,会修改类似位置这样的属性,或者改变一些状态。显示帧负责平滑地从当前位置,状态改变到逻辑帧修改的位置和状态,并播放相应的动画。逻辑帧并不直接改变这些属性,而是将这些修改放到一个每个对象特有的数据组件中,逻辑判断时,是取这些里面的数据来判断,而不是显示层的数据。每次更新,显示帧都会做一个从当前过渡到该数据的逻辑。逻辑帧的频率加快了,对显示帧而已也是没有影响的,两者互相独立,逻辑帧只管逻辑处理还有写数据,显示帧只管取出数据来进行显示。
客户端这边,逻辑帧和显示帧都是由同一个Schedule来驱动运行的,但频率不同,他们比较大的一点区别是,逻辑帧每一帧的dt是一个固定的值,例如每秒10次逻辑帧,那么这个dt就固定是0.1,而显示帧是根据实际的逝去时间来作为dt的。这里的dt指的是每次update传入的逝去时间。每次逻辑帧写入的数据除了位置状态等数据,还会包含一个需要显示帧在多长时间内模拟完成的数据,这个也是一个定值0.1。显示帧拿到位置数据,我要移动去哪,再拿到时间数据,多长时间内移动到,那么就可以执行平滑显示的逻辑了。
至于2G 3G的网络延迟,这个和PC的延迟有什么区别呢?一个是延迟会更大,另外一个就是不稳定。延迟的数据是多少,这个数据意义不是太大,因为这个数据只有一些参考意义,实际的延迟并不是按照这个数据来,而是很不稳定的。可能你蹲个厕所,把厕所门一关,信号一下子就差了很多,这是很常见的。因为移动设备是可移动的,移动到不同的地方都可能有不同的延迟,这时候可能有两个数据会比较有用,一个是2G 3G比较快的速度是怎样,另一个是2G 3G比较慢的速度是怎样。在这样差的网络下,必定会出现很多次的延迟纠正,但只要我们能收到消息,游戏就可以运行。客户端模拟是可以获得高延迟下好很多的体验,因为每次点击都有反应,虽然真正生效的时间延迟了,但你的操作还是生效了。2G 3G的另外一个问题就是断线,断线重连其实又是另外一个略有蛋疼的话题了。
在2G 3G下,在高延迟下给用户带来不差的体验,这个是实时同步比较难做到的,关键看游戏指令的可延迟性,实时要求的高低,玩家操作的频繁度,以及错误纠正时的体验,能否让玩家接受。如果不行,那么就需要弱化它,例如在进入游戏之前,检测一下ping值,如果太高,则提醒玩家,你当前的网络延迟较高,让玩家自己决定在不在高延迟的环境下游戏。实际上对于网络延迟,绝大多数的玩家都不陌生,打了一剂预防针之后,对后面的反应不及时的体验包容性会高一点。在延迟高的情况下,建议玩家去刷单机副本,也是一个可行的方案。另外,还有一个方案,虽然有点欺骗玩家,但只要玩家觉得游戏流畅就可以了,就是派AI的机器人跟高延迟的玩家打。
整个同步的想法目前正在试验中,但理论上是可行的,看上去可能有些复杂,但实际的代码框架搭建起来之后,代码写其实是很简洁的,因为所有的逻辑都不需要关注延迟。并且可能会变化的部分被隔离开了,每个东西尽可能地独立。当然,在实现的过程中肯定是会碰到更多的问题,有问题解决就是了,关键是要有思路,有解决问题的方向。
整个想法的落地大概是这样的,前后端都是用的C++,前端是2dx,后端是kxserver,后端搭建一套模拟2dx的框架,实现一份简化版的Director,Schedule,Node,Component,然后制定编写逻辑相关组件的规则,用自己写的消息机制来传递消息,当有一部分逻辑需要执行到显示相关的内容时,可以用事件来处理,客户端存在这个监听者,而服务端不存在。另外也可以使用预处理,根据是否定义了Running In Server这样一个宏来预处理一些代码。
设计中是分了比较多的层和模块,来确保万一哪个不行了,不影响到其他的代码。在落地的过程中会持续地完善代码,打磨,验证想法。希望这个项目结束后,能沉淀下一套Cocos2d-x网络实时同步的规范和前后端简易框架,来复用到其他有类似需求的项目中。