快节奏多人游戏同步(2)-客户端预测与服务器调和
第二篇终于翻译结束了,发现直接在 github 网页上修改草稿还是挺方便的,抽空就可以随时翻译一下,最后再整理发布。关于这一篇没有太多好说的基本上就是稍微有一点点干货,翻译的不好希望大家见谅,同样贴出「原文地址」,不是很清晰的地方大家可以对照着看一下。
PART 1 概述
在本系列的第一篇文章中,我们简单的讨论了『权威服务器和傀儡客户端模型』,即客户端发送输入到服务器,由服务器更新游戏状态后返回给客户端,最后再由客户端渲染的流程。
这样简单的流程可能会导致从用户输入到最终屏幕显示之间的延迟,例如玩家按下向右按键后半秒角色才开始移动,这是因为客户端的输入必须先发送到服务器,由服务器计算游戏状态并将结果发回客户端而导致的延迟。
在非局域网环境下,延迟可能达到1/10秒,可能会导致游戏响应不够灵敏,最坏的情况可能导致基本玩不了。。在本文中,我们将尽可能的最小化这个问题带来的影响,直至彻底解决的方案。
PART 2 客户端预测
纵使有一些作弊的玩家,但大多数情况下服务器都是在处理有效请求(包括非作弊玩家的请求和某些未在特定情况下作弊的客户端的请求)这意味着大多数输入将会是有效的且游戏状态是完全可预测的。也就是说,如果你的角色在(10,10)位置,当按下向右按键时,该角色一定会移动到(11,10)。
如果游戏世界可预测性足够强(也就是说,一个给定的游戏状态加上一组输入,将可以得到固定的结果)的话,我们可以尝试利用这个特性。
首先假设我们的延迟是100ms,并且由一个格子移动到下一个格子的动画需要播放100ms,那么在最原始的实现中,整个角色的动作将延迟200ms
因为游戏世界时可预测的,因此我们可以认为发到服务器的指令将会被立即成功执行,在这种情况下,客户端就可以预测输入执行以后的游戏状态了,而且大多数情况下这个预测都是基本正确的。
因此我们可以发送输入后在等待服务器返回的同时立刻渲染输入执行成功后的结果,而不是傻等着服务器返回结果后再进行渲染。这样大多数情况下客户端自行计算的结果基本上是与服务器的返回相匹配的。
所以现在在玩家输入与屏幕渲染之间完全没有延迟了,而且游戏服务器依然是权威服务器。因为当被破解的客户端发送无效请求时虽然会在自己的屏幕上显示出来,但并不会影响到其他玩家。
PART 3 同步问题
在上面的例子中,我们刚好选了一个可以让一切正常运行的数值,那么接下来我们考虑一下以下场景,客户端到服务器的延迟是250ms,但是从一个格子移动到另一个格子只需要100ms,并且玩家尝试两次按下向右按键来向右移动两格。
按照目前的实现,我们将看到如下状况。
于是我们在 t = 250 ms 的时候就会面临一个非常 interesting 的问题,当接收到新的游戏状态时,客户端预测的位置已经到达 x = 12,但是服务器认为最新的坐标是 x = 11,因为权威服务器的缘故,客户端必须将角色移回 x = 11,但是紧接着,新的 x = 12 的状态在 t = 350 的时间到达,因此角色的位置又顺移回去了。。
从玩家的角度来看,他按下两次向右按钮后,角色向右移动两格,原地停留50ms后,向左顺移一格,又原地停留100ms再向右顺移一格,很明显这种情况令人难以接受。
PART 4 服务器调和
问题的关键在于,客户端显示的是「当前时间」的游戏状态,但是因为延迟的关系,收到来自服务器的回复是「过去」的游戏状态。因为在收到服务器回包的时间点服务器还没有处理完客户端在该时间点之前发送的全部输入。
这并不是一个非常严重的问题,首先,客户端在每次请求的时候加上一个编号,在我们的例子中,第一次按键请求编号为 #1,第二次按键的请求编号为 #2。服务器回复的时候将其处理过最后一个请求的编号包含在消息中。
那么现在,在 t = 250 的时候,服务器回复说 “基于#1号请求的结果,你的坐标是x=11”,因为权威服务器的关系,角色坐标将会被设置为x = 11。如果客户端将每一个发送至服务器的指令都保存下来的话,当收到了服务器的回包,客户端就知道服务器已经执行了 #1 号请求,所以就可以将保存下来的#1号请求的备份删除掉了,并且客户端还知道服务器目前尚未处理#2号请求的更新结果,因此客户端就可以基于服务器已经认证过的请求#1的结果结合服务器尚未处理的请求#2来重新预测客户端的当前状态。
所以在 t = 250 的时候,客户端收到 “x = 11, 上次处理的请求 = #1”。于是客户端丢弃了#1号请求的备份,因为服务器尚未处理#2号请求,因此客户端依然保留#2号请求的备份。于是客户端将会基于服务器发送的 x = 11 来更新其内部游戏状态,然后将服务器尚未接收到的请求进行模拟计算。于是 #2号请求“向右移动”将会得到正确的结果,x = 12。
继续我们的例子,在 t = 350 的时候,从服务器收到了新的游戏状态 “x = 12, 上次处理的请求 = #2”。于是客户端丢弃了#2号请求前的所有备份,并将游戏状态更新到了x = 12。目前已经没有尚未被处理的请求了,进程结束在了正确的状态。
PART 5 误差
虽然以上的例子只讨论了移动的情况,但是同样地规则几乎可以适用于任何状况,例如在回合制对抗游戏中,当玩家攻击其他的角色时,可以先把最新的血条「显示」出来就好像已经完成攻击了一样,但是直到服务器回复之前最好不要真正的更新角色的血量。
因为游戏状态非常复杂,因此很多时候并不能轻易的逆转。就算是客户端状态认为某个角色的血量掉到0,你也要尽可能避免在收到服务器的确认之前就杀死该角色。(如果其他角色在你致死攻击前使用了急救包而服务器并没有告诉你呢?)
于是一个有趣的结论诞生了,即使整个游戏世界是完全可预测的而且没有任何客户端作弊,在服务器调和后依然无法保证客户端预测的游戏状态与服务器下发的真实状态完全匹配。虽然在只有单个玩家的情况下这种情景不可能发生,但在多个玩家同时连接到服务器的情况下很容易发生。我们将在下一篇文章中讨论这个问题。
PART 6 总结
当我们使用权威服务器时,即使是在等待服务器处理你的输入也要尽可能让玩家感到反应迅速。要做到这一点,需要客户端提前将其发送到服务器的输入进行模拟并显示结果。当客户端收到服务器的状态更新时,客户端再重新根据服务器发来的新状态来计算后续输入的运行结果。
原文链接:https://snatix.com/2018/05/14/016-client-side-prediction-and-server-reconciliation/