一、简介
序
本文是探索如何制作快节奏多人游戏相关技术和算法的系列文章中的第一章。如果你熟悉多人游戏背后的概念,可以放心跳过本章 - 接下来是一些介绍性的讨论。
作弊问题
一切都始于作弊。
做为游戏开发者,通常不会关心是否有人在你的单人游戏中作弊,因为他的行为只会影响他自个儿。作弊的玩家可能不会按照你设计的过程来体验游戏,但这已经是他自己的游戏,他们有权利想怎么玩就怎么玩。
多人游戏则不同。在所有竞技游戏中,作弊玩家不仅只提升自己的体验,同时也破坏了其它玩家的体验。做为开发者你得避免这种行为,因为这将导致玩家离开你的作品。
有许多办法能防止作弊,但最重要的一点(也可能是唯一真正有意义的一点)很简单:不信任玩家。做好最坏的打算 - 玩家会尝试作弊。
权威服务器和哑客户端 (Authoritative servers and dumb clients)
这是一个看似简单的解决方案 - 所有的游戏逻辑在你的服务端实现,客户端仅做游戏表现。换句话说,就是游戏客户端向服务器上送输入(玩家的按键,指令),服务端运行游戏逻辑并下发运行结果给客户端。这种做法可称之为“权威服务器(authoritative server)”,因为游戏中发生的一切都由服务器控制。
当然,你的游戏服务端可能有能被利用的漏洞,那超出了我们的讨论范围。但使用权威服务器这种模式能防止大部分的攻击。比如,玩家的血量以服务器的为准,被攻击的客户端能在本地将玩家的血量改大100倍,但服务器那还是只剩10%的血量 - 当玩家被攻击时还是会死掉,客户端怎么改也没有用。
玩家游戏中的位置信息也不能相信客户端。否则,被攻击的客户端通过上报位置给服务器说“我在(10,10)”并在一秒后上报“我在(20,10),达到穿墙或行动快于其它玩家的目的。相反,服务器知道玩家的位置在(10,10),客户端上报指令说“向右移动一格”,服务器运行更新内部状态计算出玩家新的位置在(11,10),并返回客户端“你现在在(11,10)”:
<ignore_js_op>
总而言之:游戏状态完全由服务端管理。客户端上送操作给服务器,服务器定期更新游戏状态,并下发新的游戏状态给客户端进行表现渲染。
网络处理
上述哑客户端(dumb client)的方案在慢节奏的回合制游戏上工作得很好,如策略或者是牌类游戏。在局域网、或是无延迟的通讯环境下也运行良好。但用于快节奏网络游戏特别是在互联网环境下就完蛋了。
先说物理环境。设想你在旧金山,连到位于纽约的服务器,大概4000公里或者2500英里(大概是两倍北京到香港的距离)。字节在Internet上传输接近光速(达到低级别的光脉冲、电缆中的电子,或电磁波的速度),光速大概30万公里/秒,所以这个距离大概需要13毫秒。
这听起来非常快,而且是非常乐观的设置 - 假设了数据能以光速直线传输,其实不然。在实际情况下,数据需要从路由器到路由器之间通过一系列的中转(网络术语称之为hops),达不到光速;因为数据包必须复制、检查、重新路由,所以路由器本身会造成一些延迟。
为求说法,我们假设数据在客户端到服务器耗时50毫秒,这比较接近最佳场景。如果你从纽约接入位于东京的服务器呢?如果出于某种原因导致网络拥塞?延迟100,200甚至500甚至更大。
回到我们的例子,你的客户端上送指令给服务器说“我按了右方向键”,服务器在50毫秒后收到 ,如果服务器立即处理完请求更新状态并回应,客户端也得在50毫秒后收到新的游戏状态“你现在在(1,0)”。
这样看来,你按下右方向键后有一小会没有任何效果,然后游戏角色才会右移一格。这种处于你的输入指令和响应之间的卡顿不多,但非常明显。当然,半秒的卡顿已经不是明显,会直接导致游戏没法玩。
总结
多人网络游戏如此好玩,但面临全新的挑战。权威服务器的架构能很好的防止作弊,但仅仅这样简单实现将造成游戏响应迟钝。
后续内容我们将尝试基于权威服务器架构来构建一个系统,让玩家得到最小的延迟的体验,达到几乎和本地单机游戏一样的效果。
二、快节奏多人游戏:客户端预测+服务器比对
前言
在本系列的第一章中,我们探讨过一种权威服务器与哑客户端的C/S模型:仅上送输入指令到服务器,然后在服务器更新游戏状态并在响应之后由客户端展现效果。
<ignore_js_op>
此原始实现方案会导致玩家操作到屏幕响应之间的延迟:玩家按下右方向键,游戏角色过一秒才开始移动。这是因为客户端输入必须先传输到服务器,服务器必须处理输入并计算出新的游戏状态,然后新的游戏状态才能回应给客户端表现。
在Internet这样的网络环境中,如果延迟达到十分之一秒,游戏操作就会感觉反应迟钝,最糟的情况则没法玩儿。在这部分内容中,我们得寻求改善并消除这个问题的方法。
客户端预测
尽管会存在一些作弊玩家,但大多数时间里游戏服务器处理的是有效的请求。这意味着游戏状态会按照预期更新;比如在你按了右方向键后角色就会走到(11,10)这个位置。
我们可以利用这一特点,在游戏世界是足够的可预测的情况下(即,给予的游戏状态和一系列输入,运行结果是完全可预测的)。假设存在着100毫秒的滞后,并且游戏角色移动一个格子的动画也需要100毫秒和话。使用之前的实现,整个动作得花200毫秒。
<ignore_js_op>
由于游戏世界是确定性的,我们可以假设玩家上送到服务器的指命都能成功执行。基于这个假设,客户端可以预测指令在处理之后游戏世界的状态,而且预测几乎能完全正确。
相比上送指令然后等待新的游戏状态再开始表现,现在客户端可以在上送指令后就立即展现效果,如果上送的指令正确处理那么等待得到的新游戏状态将与客户端本地计算的状态相一致。
<ignore_js_op>
这样下来,使用权威服务器模式,玩家操作与屏幕表现的效果将完全没有延迟(如果被攻击的客户端发出无效的指令,也能看到相应的效果,但不会影响服务器的状态和其他玩家的表现)。
同步问题
上面的例子里,我专门选的数值让游戏运行得非常好。但是,如果稍微改动一下场景:服务器响应延迟达到250毫秒,移动一格动画消耗100毫秒,玩家连续按了两次右方向键,想向右移动两格。
继续使用上述方法后,会是这个样子:
<ignore_js_op>
问题开始变得有趣。收到游戏新状态的时间t=250ms,而客户端此时预测产生的状态是x=12,但服务器返回的状态却是x=11。因为服务器权威,这样客户端必须把角色退回到x=11的位置。但此时,在t=350时又收到了服务器更新状态,并通知说x=12,因为角色这时又得跳回去。
以玩家的角度看,他按了两次右方向键,角色向右移动了两格,站在那50毫秒后又跳回左边一格,停了100毫秒后再次跳到右边。这显然是无法接受的。
服务器校对
解决此问题的关键在于理解客户端看到的游戏世界是现在时,但由于滞后,从服务端取得的更新实际上是过去时的状态。服务器下发的游戏更新状态,还不是处理完所有客户端上送指令后的状态。
解决这个问题不难。首先,客户端在每个请求中增加序列号;上例中,第一个按键请求序列号为 #1,第二次按键为请求 #2。然后服务器的回应也带上相应的序号:
<ignore_js_op>
这样的话,当t=250,服务端说 “按请求#1,你的位置在x=11”,然后将服务器中角色位置设置为x=11。现在假设客户端留有发往服务器请求包的所有备份,按照收到的回应,客户端得知服务器处理完请求#1,所以扔掉本地对应的拷贝,客户端知道后续还有#2的回应,所以本地预测会继续。即使有些请求服务器还没处理到,客户端仍能根据服务端最后下发的状态计算出游戏的“当前”状态。
因此,当t=250,客户端收到“x=11,已处理请求#1”,就扔掉请求包备份中的#1,但保留服务端尚未确认的#2的备份,然后将游戏内部状态按服务器的响应设置为x=11,并且会继续表现服务器尚未收到的所有请求,即“向右移”的#2指令,得到的最终正确结果x=12。
之后,当在t=350时收到服务端的游戏新状态时,服务端指示“x=12,已处理请求#2”,这时客户端扔掉#2指令备份,并更新状态为x=12。此时备份中已经没有未处理的指令,所以客户端处理以正确的结果停在这儿。
其它
上面讨论的是移动的处理,但同样的原理适用于其它所有内容。比如回合制战斗游戏,当玩家攻击一个其它角色时,可以展现掉血和伤害数值,但在服务器回应之前还不应该更新那个角色的生命值。
因为游戏状态的复杂性,不是总能简单的应对,可能你得在收到服务器确认前避免一个角色被杀掉,哪怕当前客户端状态中它的生命值低于0,比如这个角色正好在你的致命攻击之前使用了回血包,但服务器还没下发给你。
这又引入一个有趣的问题,即便游戏世界是完全确定的并且没有任何客户端作弊,还是有可能出现客户端预测的状态与服务端下发的状态在比对后不一致。这个情形在单人游戏不可能出现,但在服务器上同时连接了多个玩家时很常见。这将是下一篇文章的主题。
总结
使用权威服务器,需要在客户端等待服务器实际处理上送指令的过程中,给予玩家即时响应的错觉,客户端按玩家的操作模拟出效果,当收到服务端的新状态时,客户端会使用收到的更新和其后上送服务器但还没确认的输入重算之前已预算的状态。
三、实体插值
简介
在本系列第一章,讨论的是权威服务器的概念及其防作弊的作用,但过于简单得使用这个方案会带来可玩性和响应能力相关的问题;到第二章中则提到运用客户端预测来克服这些问题。
这两篇文章的最终结论是一系列关于让一个玩家在互联网有传输延迟的环境下连接权威服务器的情况下,操作游戏角色达到单机游戏体验的概念和技巧。
在本章,我们讨论在相同的服务器有多个玩家控制的角色接入的情况。
服务器时步(Server time step)
上一章,我们把服务器的行为描述的非常简单:读取客户端指令,更新游戏状态,并回应给客户端。但当多个客户端接入时,服务器的主循环逻辑会略有不同。
此时,在快节奏游戏中的多个客户端可能会同时上送指令(玩家飞快的操作按键,鼠标移动和点击来发出指令)。每次从每个客户端收到上送指令都处理游戏世界的状更新和广播的话,将消耗大量CPU和带宽。
将收到的客户端指令不做处理立即放到队列中,然后以较低频率定期处理游戏世界的更新(比如每秒更新10次)是个好办法。这样的话,每次更新的延迟是100毫秒,我们称之为时步(time step)。服务器在每次循环更新时,处理掉所有未处理的客户端指令(可能要以比时步更小的时间增量处理,方便预测物理行为),然后将新的游戏状态广播给客户端。
总之,游戏世界的更新只以预期的频率进行,而与客户端指令的上送和数量无关。
处理低频更新
从客户端来看,这种方式和之前一样能顺畅的运行:客户端预测处理与更新间隔的延迟无关,所以在相对较少的状态更新时也能清晰的预测处理。但由于游戏状态广播的频率低(如上每100毫秒一次),那么客户端只有游戏世界中非常少量的可到处移动的那些其它实体的信息。
之前第一种实现会在收到新状态时更新其它玩家角色的位置,那这些角色原本平滑的移动变成了每100毫秒分散的移动,成了非常断断续续的瞬移。如下图:
<ignore_js_op>
根据游戏类型相应有各种针对此问题的解决方案;通常情况下,游戏中的实体越可预测就越好解决。
航位推算(Dead reckoning)
设想我们开发一款赛车游戏,车速非常快所以很好预测,比如赛车的速度每秒100米,一秒之后它将位于起点后大致100米的位置。为什么是“大致”?在一秒之间,车子可以加速或减速了一点儿,或者是左转或右转了一点儿,注意这个词“一点儿”,汽车的机动性就是这样,因为无论玩家如何实际操作,赛车在高速行驶期间其时间点所在的位置很大程度取决于其之前的位置、速度和方向。换句话说,赛车不会立即出现180度转向。
那当服务器每100毫秒下发一次更新的话如何处理?客户端收到下发的每台竞技赛车的速度和朝向,要下个100毫秒后才收到新的信息,仍得展现赛车继续在行驶。最简单的做法就是假设100毫秒内车速和朝向不变,然后通过这些参数本地运行赛车的物理表现。然后在100毫秒收到服务器更新后再修正车的位置。
修正处理可大也可小,取决于很多因素。如果玩家保持赛车直线前进并且没有改变车速,那么预测的位置将精确的对应服务器通知要修正的位置。另一方面,如果玩家被什么东西撞毁,那预测的位置也会错得离谱。
注意航位推算法更适用于低速场景,比如战舰。实际上,“航位推算”就是起源于海上航行。
实体插值
有不少场景无法应用航位推算法:特别是当玩家方向和速度会随时改变的情景下。例如3D射击游戏,玩家们通常会高速的跑动、停止,转弯,这时航位法几乎无效,因为位置和速度不再能通过之前的数据进行预测。
收到服务器为准的数据时不能立即更新玩家的位置,这会让玩家每100毫秒闪跳一段距离而使得游戏没法玩。
那在每100毫秒得到服务端位置数据时该怎么做呢?诀窍在于在这期间如何展现玩家角色的动画。答案的关键是以相对于玩家过去的状态来展现其它玩家。
比如在t=1000这个时间点收到位置数据时,已经有了时间点t=900的数据,所以可以知道玩家在t=900和t=1000时的位置,因此,从t=1000和t=1100,展现的是其它玩家在t=900到t=1000时的行为状态。这样的话总是以玩家真实的移动数据进行展现,只是滞后了100毫秒。
<ignore_js_op>
从t=900到t=1000的用于插值的位置数据取决于游戏。插值处理通常效果良好,否则也可以让服务器在每次更新时下发更多详细的行动数据来改善插值效果,比如玩家路线的一系列直线段或者是每10毫秒的位置采样(只需要发送小的行动的增量数据就不会放大下发的数据量,因为这种情况在数据传输时会被大量优化)。
需要注意,使用这种方式时,每个玩家看到的游戏世界的表现会稍微不同,因为每个玩家看到的自己是现在时但看到的其它实体是过去时,但是就算在快节奏游戏中看其它实体有100毫秒的差异感觉也不明显。
也有例外:当处于要求大量空间和时间精度的场景中,如当某玩家枪击其他玩家时,因为看到的其他玩家是过去时,瞄准有100毫秒滞后,这表示你正在射击的是100毫秒之前的目标!我们会在下一章处理这个问题。
总结
在有网络延迟并使用低频率更新的权威服务器的C/S架构中,仍然必须为玩家提供游戏的连续性和平滑运行的错觉。在本系列第二部分中讨论过通过客户端预测和服务器校对的方式实时展现玩家行动的方法,这样做确保本地玩家的行为立即生效,并消除了影响游戏可玩性的延迟。但存在其它游戏实体时仍有问题,这一章则讨论了解决这类问题的两个方法。
第一个是航位推算(dead reckoning),适用于某些类型的模拟:即游戏中实体的位置可以通过如位置、速度和加速度这些已有数据估算出可用值。如果不符合这个条件这个方法就会失效。
第二个是实体插值(entity interpolation),不做预测而是使用服务器的真实数据,来展现出稍微在时间上延迟的其它实体。
最终的效果是玩家的角色处于现在时但游戏中的其它实体处于过去时,这通常能创造出极其流畅的体验。
但一切还没结束,在需要高精度的空间和时间的情况时以上方法就失效了,比如射击一个运动中的目标:客户端2展现出来的客户端1的位置,与服务器中和客户端1中的位置不一致,那就别想爆头了!当然得有爆头,下一章来解决这个问题!
四、爆头
概要
前三章解释的C/S方案可以总结如下:
- 客户端上送给服务器的所有请求都带上时间戳
- 服务器处理输入指令并更新游戏状态
- 服务器定期下发游戏状态给所有客户端
- 客户端上送指令并在立即本地展现效果
- 客户端取得游戏状态
- 同步预测服务端状态
- 使用已有状态为其它实体插值
从客户端角度看,会产生两个重要结果:
- 玩家看到的自己是现在时
- 玩家看到的其它实体是过去时
这个方法多数时有效,但在时间空间精度有要求的时候会很成问题,比如爆头。
滞后补偿(Lag Compensation)
当你端起狙击枪完美的瞄准对手的脑袋时,你开枪了!必将一击毙命!
但。。。没中
怎么回事儿?!
因为在上述C/S架构中,你瞄准的位置是100毫秒前对手的头所在的位置,就像处于某个光速非常非常慢的宇宙中,你瞄准的是敌人过去的位置,当你扣下扳机时他已经跑了。
幸运的是,这个问题也有一个简单的应对方案,对于大部分玩家在大部分时间也是能被受的(有一个例外会讨论)。
看看是怎么做到的:
- 开枪时,客户端把完整的信息上送给服务器:开枪时精确的时间戳和武器瞄准的精确位置;
- 关键是这一步:因为服务器有所有带有时间戳的客户端指令,服务器能准确的重建游戏世界在过去任何一刻的状态。事实上,服务器可以精确的重建任何客户端在任何时间点的游戏世界的状态。
- 这意味着服务器知道在你出手的瞬间你的枪口对准的是什么 ,那个位置那一刻是对手在过去时的脑袋,而且服务器也知道他脑袋的位置对于你是现在时。
- 服务器处理那个时间点的爆头命中逻辑,并下发状态给客户端。
这个结果所有人都满意!
服务器是老大,所以它总是满意
你也满意因为你瞄准对手的头,开枪,并拿到了一个爆头!
可能只有对手不是完全满意,如果当他被击中时他正站在那儿没动,那就是他的错,对吧?但当时他在跑。。Wow,那你真的是神狙。
但如果他正好从开放地形躲到了墙后边,还正想着安全时就在不到一秒内被击中了呢?
是的,就这么发生了,这是权衡的结果。因为你击中的是过去时的他,就算那几毫秒后他躲起来还是被命中了。
这是有点不公平,但这是所有参与者最满意的解决方案,这比完美一枪去没命中要好太多!
结论
快节奏多人游戏系列到此结束。这类事情要做好的确很棘手,但如果清晰的理解了事情的原理,就不再那么困难。
虽然这些文章是为游戏开发者所写,但另一组读者也会感兴趣,那就是玩家!做为玩家肯定也想知道为什么出现这样的情况,原因是什么。