用状态同步的方式实现一个坦克大战的小游戏,这也是一次全新的尝试,从游戏的效果来看,在正常的网络速度下效果符合预期。这里跟大家分享下游戏客户端中用到的关键技术点。
一、 同步方式的选择,状态同步or 帧同步?
状态同步: 同步的是游戏中的各种状态,游戏逻辑由服务器实现,只是将计算后的结果同步给客户端,客户端根据收到的状态,同步本地的游戏状态。
实现状态同步的一般流程是:
客户端上传操作至服务器 ====> 服务器收到后按固定频率计算游戏行为的结果,然后以广播的形式以固定频率下发至客户端 ====>客户端根据收到的状态,渲染游戏画面
值得注意的是,状态同步是一种不严谨的同步,只是保证了各种状态的一致性,并不能保证各客户端在同一时刻显示同样的画面。比如子弹击中坦克,那么状态同步能保证子弹确实击中了坦克,但有可能有的客户端先看到击中的画面,有的客户端后看到。所以状态同步对网络的要求不高。还有一点,服务器实现游戏逻辑的计算,安全性相对客户端实现游戏逻辑计算高很多。
帧同步: 同步的是操作指令,指令包含当前的帧索引。客户端上传指令至服务器,服务器通过广播下发至每个客户端, 客户端再根据指令,本地计算游戏逻辑并进行渲染。帧同步确保服务器每帧下发的指令是一致的。如果出现网络不好的情况,导致帧在本地积压,或者客户端同时收到多个帧信息,则选择加快速度渲染,或者直接跳至最新的一帧(不建议,除非断线重连)。
实现帧同步的一般流程是:
同步随机数种 (用于游戏中暴击等计算) ====> 客户端上传操作指令 ====> 服务器按固定频率广播所有客户端的操作 ====> 客户端根据收到的操作,进行逻辑运算并渲染游戏画面
严格的帧同步每个帧都会等待所有的玩家上传操作至服务器后,才会广播指令至各客户端,这样的就能严格保证各个玩家的操作是同步的,但是,这样做的坏处也是显而易见的,这局游戏的网络延迟,就是网络最差的那个玩家的网络延迟,这样对网络好的玩家显得不公平,而且游戏的操作手感也不好。所以延伸出来的乐观锁帧同步方式,就是用来保证网络好的玩家不受延迟高的玩家的影响。乐观锁就是默认在定时器触发的那一刻,所有的玩家操作已经上传,然后再广播至各个玩家。对于延迟高的玩家,会感觉操作延迟,但对于网络情况良好的玩家,会感觉很顺畅。至于说这对于网络不好的玩家不公平,只能说网络不好就不要玩这种对于网络要求高的游戏嘛,当然得优先保证大多数玩家的体验了。
对于坦克大战采用状态同步的考量:
对于多人在线小游戏来说,其实这两种同步方式都可以。但考虑到实际应用场景,我们还是选择了状态同步。第一、游戏逻辑在服务端实现,所以我们更新一款游戏,直接更新服务端就好了。第二、对于移动端的网络不稳定,所以选择对网络要求稍微低的状态同步。
对于坦克大战我们也把一些游戏逻辑交给了客户端,我们选择将坦克位置由服务器广播至客户端,而子弹这个不是很关键的信息,我们是直接将开火指令广播至客户端,客户端直接本地渲染,直到收到来自服务器的生命周期结束的讯息为止(击中了障碍物)。这部分放到客户端直接渲染,是因为像子弹这种类型的元素,方向跟运动速度不变,除非碰到障碍物消失。所以,没有必要将子弹的信息,也通过服务端广播至客户端。一个游戏子弹有很多,仅仅附带位置信息,那也是很占用带宽的。其实后续可以考虑,子弹生命结束也由客户端控制。至于他击中的物体会怎么样,这个由服务端判断
二、客户端与服务器时间的校准
多人对战游戏,需要一个统一的时间轴来运转整个游戏世界。而这个时间轴,就是服务器的时间轴。所以CS之间的时间同步,对整个游戏的运行是至关重要的。
服务端时间 = 客户端时间 + RTT/2 + difftime
屏幕快照 2018-06-27 上午103446.png这里t1 为客户端发送请求的时间, t2为服务端收到请求的时间,t3为客户端收到ack的时间。则有:
t3-t1 = RTT
t1 + RTT/2 + diff = t2
那么根据以上公式可以算出,客户端与服务端的时间差diff.这样就同步了客户端与服务端的时间。
这里默认客户端到服务端的延迟跟服务端到客户端的延迟是一样的,可以多做几次,取平均值。
三、客户端的状态同步的实现
不同于单机游戏,多人对战游戏需要同步各个客户端的信息,因此理论上来说没法做到像单机游戏那种立即操作立即显示。客户端的操作需要经过服务端的确认才能显示。整个过程是这样的:
屏幕快照.png 客户端上传至服务端
屏幕快照 2018-06-27 上午103412.png 服务端广播至客户端
这里是分开两个过程,客户端的操作不会立即对客户端渲染产生影响,可以对比下单机游戏的过程是这样的:
操作 ==> 本地进行游戏逻辑运算 ==> 渲染游戏画面==>操作 ...
这里的网络数据包包括 状态 + 操作 + 服务端时间, 状态包括坦克的位置、方向、速度, 操作包括坦克开火、移动、停止,每发生一个动作,就将动作push到队列里等待发送。
可以注意到,只有确保服务端收到一个操作之后,客户端才会从队列里取出下一个操作发送。如果不用队列进行缓存,则有可能出现在一个周期内发送多个操作的情况。
四、客户端做插值平滑游戏画面
由于服务端广播至客户端的帧为15帧/s, 如果按照这样的频率做本地渲染,这样得到的效果是让人无法接受的。看到的游戏画面是不连续的。也许有人认为视频25帧/s就已经非常流畅了,那么把服务端的广播频率设为25帧/s就可以了。其实,视频的显示与游戏的显示机制是不一样的,视频可以由一幅幅画连续播放得到,而这幅画是由连续曝光得到,所以是一段时间的信息,而游戏是直接显示,就是一个时间点的信息。所以游戏的帧率太少会让人觉得很卡,一顿一顿的。
要平滑的显示游戏画面,会选择一个插值的操作。就是基于两个已知的状态做插值,让本来的两帧数据,在渲染的时候,细分成多帧数据。服务端广播帧率为15帧/s, 而本地渲染的是大约60帧/s.我们做个完美的假设,假设收到来自服务端的数据,倒数第二帧坦克位置为(X0,Y0); 最新收到的一帧中坦克的位置为(X1,Y1);之间相隔1/15s。由于客户端帧率是60帧/s, 故理论上可以服务端的一帧变为客户端的四帧。但是,会发现由于js是单线程的,其定时器无法做到完美的定时,js的定时器也是一个坑。它每隔dt个时间段,渲染一次游戏画面,而dt是小范围波动的。所以有:
CodeCogsEqn.gif 其中 Xnow 是现在需要渲染的x坐标位置,ts是两个消息自带的时间戳的差值,基本为66ms, 而dt的累加永远要小于等于ts,当dt的累加大于ts时,说明渲染时间过长,而坦克的x坐标已经到达X1的位置了,因此不需要再移动,故当dt的累加大于ts时做无效处理。
五、过程中可改进的点
1.传输的信息没有进一步压缩
用的都是json格式,而且玩家的数目与需要广播的消息是成线性关系的,所以需要进一步压缩,才能更好的支持数量很多的玩家。也是节省玩家流量的一个措施。
2.可以提前渲染一至两帧的图像
目前是完全根据服务端的信息来进行渲染,包括自己操纵的那辆坦克。可以通过客户端预测和与服务端的协调,来提前一两帧显示自己操纵的那辆坦克的位置。为何不走另外一个极端,即马上操作,马上得到?因为这不是单机游戏,需要与服务器保持一致,只能提前显示一至两帧,也就是最多提前显示120ms的画面,再多的话就有问题,会导致坦克在服务端的位置与客户端的位置相差过大,进一步导致整个游戏时间的不一致。
3.几乎同时收到服务端多个帧的数据
这个问题在mac 本上出现过,抓包发现本来间隔66ms的帧数据,有时候会只间隔几毫秒。这样导致的现象就是,坦克会一顿一顿,出现闪现。对于该问题偶然出现会有办法改进,假如短时间内收到3个包,那么直接丢弃前两个包。这样坦克的画面会突然加快,但总比闪现效果要好。如果经常出现,则目前是无解的,客户端没收到来自服务端的帧数据,是做等待处理的,看起来是闪现的效果。只能通过提高网络质量或者网卡设备来避免这个问题。
网友评论