6. Client 输入🕹️
现在该使游戏变得可玩了!我们的 control scheme 非常简单:使用鼠标(在桌面上)或触摸屏幕(在移动设备上)来控制移动方向。为此,我们将为 Mouse 和 Touch 事件注册事件监听器。
src/client/input.js
会处理这些问题:
input.js
import { updateDirection } from './networking'; function onMouseInput(e) { handleInput(e.clientX, e.clientY); } function onTouchInput(e) { const touch = e.touches[0]; handleInput(touch.clientX, touch.clientY); } function handleInput(x, y) { const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y); updateDirection(dir); } export function startCapturingInput() { window.addEventListener('mousemove', onMouseInput); window.addEventListener('touchmove', onTouchInput); } export function stopCapturingInput() { window.removeEventListener('mousemove', onMouseInput); window.removeEventListener('touchmove', onTouchInput); }
onMouseInput()
和 onTouchInput()
是事件监听器,当输入事件发生(例如:鼠标移动)时, 它们调用 updateDirection()
(来自 networking.js
)。 updateDirection()
负责向服务器发送消息,服务器将处理输入事件并相应地更新游戏状态。
7. Client 状态
这部分是这篇文章中最先进的部分。如果你一遍读不懂所有内容,不要灰心!请随意跳过这一节,稍后再来讨论它。
完成客户端代码所需的最后一个难题是状态。还记得“客户端渲染”部分的这段代码吗?
render.js
import { getCurrentState } from './state'; function render() { const { me, others, bullets } = getCurrentState(); // Do the rendering // ... }
getCurrentState()
必须能够根据从服务器接收到的游戏更新随时向我们提供客户端的当前游戏状态。这是服务器可能发送的游戏更新示例:
{ "t": 1555960373725, "me": { "x": 2213.8050880413657, "y": 1469.370893425012, "direction": 1.3082443894581433, "id": "AhzgAtklgo2FJvwWAADO", "hp": 100 }, "others": [], "bullets": [ { "id": "RUJfJ8Y18n", "x": 2354.029197099604, "y": 1431.6848318262666 }, { "id": "ctg5rht5s", "x": 2260.546457727445, "y": 1456.8088728920968 } ], "leaderboard": [ { "username": "Player", "score": 3 } ] }
每个游戏更新都具有以下 5 个字段:
- t:创建此更新的服务器时间戳。
- me:接收更新的玩家的 player 信息。
- others:同一游戏中其他玩家的玩家信息数组。
- bullets:在游戏中的 bullets 子弹信息的数组。
- leaderboard:当前排行榜数据。
7.1 原生客户端状态
getCurrentState()
的原生实现可以直接返回最近收到的游戏更新的数据。
naive-state.js
let lastGameUpdate = null; // Handle a newly received game update. export function processGameUpdate(update) { lastGameUpdate = update; } export function getCurrentState() { return lastGameUpdate; }
干净整洁!如果那么简单就好了。此实现存在问题的原因之一是因为它将渲染帧速率限制为服务器 tick 速率。
- Frame Rate:每秒的帧数(即,
render()
调用)或 FPS。游戏通常以至少 60 FPS 为目标。 - Tick Rate:服务器向客户端发送游戏更新的速度。这通常低于帧速率。对于我们的游戏,服务器以每秒30 ticks 的速度运行。
如果我们仅提供最新的游戏更新,则我们的有效 FPS 不能超过 30,因为我们永远不会从服务器每秒收到超过 30 的更新。即使我们每秒调用 render()
60次,这些调用中的一半也只会重绘完全相同的内容,实际上什么也没做。
原生实现的另一个问题是它很容易滞后。在完美的互联网条件下,客户端将完全每33毫秒(每秒30个)收到一次游戏更新
可悲的是,没有什么比这更完美。一个更现实的表示可能看起来像这样:
当涉及到延迟时,原生实现几乎是最糟糕的情况。如果游戏更新晚到50毫秒,客户端会多冻结50毫秒,因为它仍在渲染前一个更新的游戏状态。你可以想象这对玩家来说是多么糟糕的体验:游戏会因为随机冻结而感到不安和不稳定。
7.2 更好的客户端状态
我们将对这个简单的实现进行一些简单的改进。第一种是使用100毫秒的渲染延迟,这意味着“当前”客户端状态总是比服务器的游戏状态滞后100毫秒。例如,如果服务器的时间是150,客户端呈现的状态将是服务器在时间50时的状态:
这给了我们100毫秒的缓冲区来容忍不可预测的游戏更新到来:
这样做的代价是恒定的100毫秒输入延迟。对于拥有稳定流畅的游戏玩法来说,这是一个小小的代价——大多数玩家(尤其是休闲玩家)甚至不会注意到游戏的延迟。对人类来说,适应恒定的100毫秒的延迟要比尝试应付不可预测的延迟容易得多。
我们可以使用另一种称为“客户端预测”的技术,该技术可以有效地减少感知到的滞后,但这超出了本文的范围。
我们将进行的另一项改进是使用线性插值。由于渲染延迟,通常我们会比当前客户端时间早至少更新1次。每当调用 getCurrentState()
时,我们都可以在当前客户端时间前后立即在游戏更新之间进行线性插值:
这解决了我们的帧率问题:我们现在可以随心所欲地渲染独特的帧了!
7.3 实现更好的客户端状态
src/client/state.js
中的示例实现使用了渲染延迟和线性插值,但有点长。让我们把它分解成几个部分。这是第一个:
state.js, Part 1
const RENDER_DELAY = 100; const gameUpdates = []; let gameStart = 0; let firstServerTimestamp = 0; export function initState() { gameStart = 0; firstServerTimestamp = 0; } export function processGameUpdate(update) { if (!firstServerTimestamp) { firstServerTimestamp = update.t; gameStart = Date.now(); } gameUpdates.push(update); // Keep only one game update before the current server time const base = getBaseUpdate(); if (base > 0) { gameUpdates.splice(0, base); } } function currentServerTime() { return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY; } // Returns the index of the base update, the first game update before // current server time, or -1 if N/A. function getBaseUpdate() { const serverTime = currentServerTime(); for (let i = gameUpdates.length - 1; i >= 0; i--) { if (gameUpdates[i].t <= serverTime) { return i; } } return -1; }
首先要了解的是 currentServerTime()
的功能。如前所述,每个游戏更新都包含服务器时间戳。我们希望使用渲染延迟来在服务器后渲染100毫秒,但我们永远不会知道服务器上的当前时间,因为我们不知道任何给定更新要花费多长时间。互联网是无法预测的,并且变化很大!
为了解决这个问题,我们将使用一个合理的近似方法:我们假设第一个更新立即到达。如果这是真的,那么我们就会知道服务器在那一刻的时间!我们在 firstServerTimestamp
中存储服务器时间戳,在 gameStart
中存储本地(客户端)时间戳。
哇,等一下。服务器上的时间不应该等于客户端上的时间吗?为什么在“服务器时间戳”和“客户端时间戳”之间有区别?这是个好问题,读者们!事实证明,它们不一样。Date.now()
将根据客户端和服务器的本地因素返回不同的时间戳。永远不要假设您的时间戳在不同机器之间是一致的。
现在很清楚 currentServerTime()
的作用了:它返回当前渲染时间的服务器时间戳。换句话说,它是当前服务器时间(firstServerTimestamp + (Date.now() - gameStart)
) 减去渲染延迟(RENDER_DELAY
)。
接下来,让我们了解如何处理游戏更新。processGameUpdate()
在从服务器接收到更新时被调用,我们将新更新存储在 gameUpdates
数组中。然后,为了检查内存使用情况,我们删除了在基本更新之前的所有旧更新,因为我们不再需要它们了。
基本更新到底是什么?这是我们从当前服务器时间倒退时发现的第一个更新。还记得这张图吗?
“客户端渲染时间”左边的游戏更新是基础更新。
基础更新的用途是什么?为什么我们可以丢弃基础更新之前的更新?最后让我们看看 getCurrentState()
的实现,以找出:
state.js, Part 2
export function getCurrentState() { if (!firstServerTimestamp) { return {}; } const base = getBaseUpdate(); const serverTime = currentServerTime(); // If base is the most recent update we have, use its state. // Else, interpolate between its state and the state of (base + 1). if (base < 0) { return gameUpdates[gameUpdates.length - 1]; } else if (base === gameUpdates.length - 1) { return gameUpdates[base]; } else { const baseUpdate = gameUpdates[base]; const next = gameUpdates[base + 1]; const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t); return { me: interpolateObject(baseUpdate.me, next.me, r), others: interpolateObjectArray(baseUpdate.others, next.others, r), bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r), }; } }
我们处理3种情况:
base < 0
,意味着在当前渲染时间之前没有更新(请参见上面的getBaseUpdate()
的实现)。由于渲染延迟,这可能会在游戏开始时发生。在这种情况下,我们将使用最新的更新。base
是我们最新的更新(😢)。这种情况可能是由于网络连接的延迟或较差造成的。在本例中,我们还使用了最新的更新。- 我们在当前渲染时间之前和之后都有更新,所以我们可以插值!