如何构建一个多人(.io) Web 游戏,第 1 部分(二)

简介: 如何构建一个多人(.io) Web 游戏,第 1 部分(二)

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个)收到一次游戏更新

微信图片_20220611120405.png


可悲的是,没有什么比这更完美。一个更现实的表示可能看起来像这样:


微信图片_20220611120407.png


当涉及到延迟时,原生实现几乎是最糟糕的情况。如果游戏更新晚到50毫秒,客户端会多冻结50毫秒,因为它仍在渲染前一个更新的游戏状态。你可以想象这对玩家来说是多么糟糕的体验:游戏会因为随机冻结而感到不安和不稳定。


7.2 更好的客户端状态


我们将对这个简单的实现进行一些简单的改进。第一种是使用100毫秒的渲染延迟,这意味着“当前”客户端状态总是比服务器的游戏状态滞后100毫秒。例如,如果服务器的时间是150,客户端呈现的状态将是服务器在时间50时的状态:

微信图片_20220611120428.png

这给了我们100毫秒的缓冲区来容忍不可预测的游戏更新到来:


微信图片_20220611120430.png


这样做的代价是恒定的100毫秒输入延迟。对于拥有稳定流畅的游戏玩法来说,这是一个小小的代价——大多数玩家(尤其是休闲玩家)甚至不会注意到游戏的延迟。对人类来说,适应恒定的100毫秒的延迟要比尝试应付不可预测的延迟容易得多。


我们可以使用另一种称为“客户端预测”的技术,该技术可以有效地减少感知到的滞后,但这超出了本文的范围。


我们将进行的另一项改进是使用线性插值。由于渲染延迟,通常我们会比当前客户端时间早至少更新1次。每当调用 getCurrentState() 时,我们都可以在当前客户端时间前后立即在游戏更新之间进行线性插值:


微信图片_20220611120452.png

这解决了我们的帧率问题:我们现在可以随心所欲地渲染独特的帧了!


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 数组中。然后,为了检查内存使用情况,我们删除了在基本更新之前的所有旧更新,因为我们不再需要它们了。

基本更新到底是什么?这是我们从当前服务器时间倒退时发现的第一个更新。还记得这张图吗?


微信图片_20220611120515.png


“客户端渲染时间”左边的游戏更新是基础更新。

基础更新的用途是什么?为什么我们可以丢弃基础更新之前的更新?最后让我们看看 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种情况:


  1. base < 0,意味着在当前渲染时间之前没有更新(请参见上面的 getBaseUpdate() 的实现)。由于渲染延迟,这可能会在游戏开始时发生。在这种情况下,我们将使用最新的更新。
  2. base 是我们最新的更新(😢)。这种情况可能是由于网络连接的延迟或较差造成的。在本例中,我们还使用了最新的更新。
  3. 我们在当前渲染时间之前和之后都有更新,所以我们可以插值!
相关文章
|
3天前
|
缓存 数据库连接 数据库
构建高性能的Python Web应用:优化技巧与最佳实践
本文探讨了如何通过优化技巧和最佳实践来构建高性能的Python Web应用。从代码优化到服务器配置,我们将深入研究提高Python Web应用性能的各个方面。通过本文,读者将了解到一系列提高Python Web应用性能的方法,从而更好地应对高并发和大流量的挑战。
|
8天前
|
数据采集 存储 XML
如何利用Python构建高效的Web爬虫
本文将介绍如何使用Python语言以及相关的库和工具,构建一个高效的Web爬虫。通过深入讨论爬虫的基本原理、常用的爬虫框架以及优化技巧,读者将能够了解如何编写可靠、高效的爬虫程序,实现数据的快速获取和处理。
|
14天前
|
运维 前端开发 JavaScript
【专栏:HTML进阶篇】HTML与Web标准:构建可访问与可维护的网页
【4月更文挑战第30天】本文探讨了HTML与Web标准的关系,强调遵循标准对创建高质量、可访问、可维护网页的重要性。通过使用语义化标签、提供文本替代、合理使用表格和列表,可提升网页可访问性;通过结构化文档、添加注释、分离结构与表现,能增强网页可维护性。遵循Web标准,可确保网页在不同设备上的兼容性,并满足各类用户需求。
|
14天前
|
开发框架 Dart 前端开发
【Flutter前端技术开发专栏】Flutter中的Web支持:构建跨平台Web应用
【4月更文挑战第30天】Flutter,Google的开源跨平台框架,已延伸至Web领域,让开发者能用同一代码库构建移动和Web应用。Flutter Web通过将Dart代码编译成JavaScript和WASM运行在Web上。尽管性能可能不及原生Web应用,但适合交互性强、UI复杂的应用。开发者应关注性能优化、兼容性测试,并利用Flutter的声明式UI、热重载等优势。随着其发展,Flutter Web为跨平台开发带来更多潜力。
【Flutter前端技术开发专栏】Flutter中的Web支持:构建跨平台Web应用
|
14天前
|
缓存 监控 测试技术
【Go语言专栏】使用Go语言构建高性能Web服务
【4月更文挑战第30天】本文探讨了使用Go语言构建高性能Web服务的策略,包括Go语言在并发处理和内存管理上的优势、基本原则(如保持简单、缓存和并发控制)、标准库与第三方框架的选择、编写高效的HTTP处理器、数据库优化以及性能测试和监控。通过遵循最佳实践,开发者可以充分利用Go语言的特性,构建出高性能的Web服务。
|
15天前
|
网络协议 数据库 开发者
构建高效Python Web应用:异步编程与Tornado框架
【4月更文挑战第29天】在Web开发领域,响应时间和并发处理能力是衡量应用性能的关键指标。Python作为一种广泛使用的编程语言,其异步编程特性为创建高性能Web服务提供了可能。本文将深入探讨Python中的异步编程概念,并介绍Tornado框架如何利用这一机制来提升Web应用的性能。通过实例分析,我们将了解如何在实际应用中实现高效的请求处理和I/O操作,以及如何优化数据库查询,以支持更高的并发用户数和更快的响应时间。
|
15天前
|
开发者 Python
使用Python构建Web应用的简介
【4月更文挑战第28天】
|
15天前
|
缓存 前端开发 JavaScript
|
15天前
|
前端开发 JavaScript 开发工具
前端技术栈:构建现代Web应用的基石与实践
前端技术栈:构建现代Web应用的基石与实践
24 3
|
15天前
|
缓存 负载均衡 前端开发
构建高性能Web应用的关键技术
本文探讨了构建高性能Web应用的关键技术,包括前端优化、后端架构、数据库设计等方面的最佳实践。通过优化前端页面加载速度、采用高效的后端框架和数据库引擎,可以显著提升Web应用的性能和用户体验。