快节奏多人游戏(2):客户端预测与服务器协调

介绍

在本系列文章的第一篇文章中,我们探讨了一个客户端-服务器模型,它具有一个权威的服务器和愚蠢的客户端,客户端只是输入发送指令到服务器,然后服务器发送更新后的游戏状态给客户端进行展示。

该方案的简单实现会导致客户端输入指令和屏幕上的更新之间产生延迟。例如一个玩家按下了右键,角色开始移动前需要等待半秒钟,这是因为客户端的输入必须首先发送给服务器,服务器必须处理输入并计算出最新的游戏状态,并且把更新后的游戏状态再次发送给客户端。

fpm

在因特网这类网络环境中,这里的延迟可能只是十分之几秒,游戏可能感觉最多时没有反应,或者最坏的情况下,游戏会无法继续玩下去。在本文中,我们将找到最小化延迟甚至消除该问题的方法。

客户端预测

即使有一些作弊的玩家,大多数的时候服务器都可以处理有效的请求(来自非作弊客户端和欺骗那些在特定时间没有欺骗的客户)。这意味着收到的大部分输入都是有效的,并会按预期更新游戏状态。也就是说,如果你的角色位于坐标(10, 10)并按下右箭头键,它将结束于坐标(11,10)。

我们可以利用这个优势,如果游戏世界足够确定(即给定游戏状态和一组输入,结果是完全可预测的)。

假设我们有一个 100 毫秒的延迟,并且从一个方格移动到下一个方格的角色动画也需要 100 毫秒,使用上文提到的简单实现,这个操作加上延迟则需要花费 200 毫秒:

fpm

由于世界是确定性的,我们可以先假设我们发送到服务器的输入将成功执行。在此假设下,客户端可以在处理输入后预测游戏世界的状态,并且大多数情况下这将是正确的。

我们可以发送输入并开始渲染输入的结果,就像它们已经成功一样,而不是发送输入并等待新游戏状态开始呈现它。通常情况下,我们等待服务器发送的“真实”游戏状态将匹配本地计算的状态:

fpm

现在玩家的动作和屏幕上的结果之间绝对没有延迟,而服务器仍然具有权威性(如果被黑客攻击的客户端会发送无效输入,它可以在屏幕上呈现任何想要的内容,但它不会影响服务器状态)。

同步问题

在上面的例子中,我仔细选择了的延迟数字能游戏一切正常。但是,请考虑稍微修改的情况:假设我们对服务器有 250 毫秒的延迟,从正方形移动到下一个需要 100 毫秒。我们可以说玩家连续按下了右键2次,并试图向右移动2个方格。

使用上面的客户端预测技术,这将会发生以下事情:

fpm

当新的游戏状态到来时,我们在 t = 250 毫秒遇到一个有趣的问题。客户端此时预测的状态是 x = 12,但服务器说新的游戏状态是 x = 11。由于服务器是权威的,因此客户端必须将角色移回 x = 11。但是,一个新的服务器状态会在 t = 350 到达,表示 x = 12,所以玩家角色将再次跳转到当前正确的游戏状态。

从玩家的角度来看,他按了两次右箭头键,角色向右移动了两个方格,在那里站了 50 毫秒,向左跳了一个方格,在那里站了 100 毫秒,又向右跳了一个方格。你可以想象,这种游戏体验是不可接受的。

服务器对账

解决这个问题的关键是要意识到客户端在当前时间看到游戏世界,但由于滞后,它从服务器获得的更新实际上是过去的游戏状态。当服务器发送更新的游戏状态时,它还没有处理客户端发送的所有命令。

但是,要解决这个问题并不是非常困难。首先,客户端为每个请求添加序列号,在我们的例子中,第一次按键是请求 #1,第二次按键是请求 #2。然后,当服务器回复时,它包括它处理的最后一个输入的序列号:

fpm

现在,在 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 以下了(如果另外一个角色在受到致命打击之前使用了急救箱,而此时服务器还没告诉你)。

这给我们带来了一个有趣的观点,即使游戏世界是完全确定并且没有任何作弊的客户端,客户端预测的状态和服务器发送的状态仍然可能在协调后不匹配。如上所诉,本文的描述不太可能使用在单个玩家身上,但当多个玩家连接到一个服务器上时这些问题很容易就遇到。我们将在下一篇文章继续讲解。

总结

在使用权威的服务器时,你需要等待服务器实际处理的输入时提供一些模拟响应的假象,因此,客户端需要模拟输入的结果,当服务器实际的状态到达时,预测的客户端将从更新的状态中以及客户端发送的输入指令重新计算。