如何构建一个多人(.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. 我们在当前渲染时间之前和之后都有更新,所以我们可以插值!
相关文章
|
2月前
|
前端开发 JavaScript 开发者
JavaScript:构建动态Web的核心力量
JavaScript:构建动态Web的核心力量
|
6月前
|
前端开发 算法 API
构建高性能图像处理Web应用:Next.js与TailwindCSS实践
本文分享了构建在线图像黑白转换工具的技术实践,涵盖技术栈选择、架构设计与性能优化。项目采用Next.js提供优秀的SSR性能和SEO支持,TailwindCSS加速UI开发,WebAssembly实现高性能图像处理算法。通过渐进式处理、WebWorker隔离及内存管理等策略,解决大图像处理性能瓶颈,并确保跨浏览器兼容性和移动设备优化。实际应用案例展示了其即时处理、高质量输出和客户端隐私保护等特点。未来计划引入WebGPU加速、AI增强等功能,进一步提升用户体验。此技术栈为Web图像处理应用提供了高效可行的解决方案。
|
5月前
|
开发框架 JSON 中间件
Go语言Web开发框架实践:使用 Gin 快速构建 Web 服务
Gin 是一个高效、轻量级的 Go 语言 Web 框架,支持中间件机制,非常适合开发 RESTful API。本文从安装到进阶技巧全面解析 Gin 的使用:快速入门示例(Hello Gin)、定义 RESTful 用户服务(增删改查接口实现),以及推荐实践如参数校验、中间件和路由分组等。通过对比标准库 `net/http`,Gin 提供更简洁灵活的开发体验。此外,还推荐了 GORM、Viper、Zap 等配合使用的工具库,助力高效开发。
|
JSON 前端开发 API
使用Python和Flask构建简易Web API
使用Python和Flask构建简易Web API
669 86
|
8月前
|
数据可视化 图形学 UED
从模型托管到交互开发:DataV 如何简化三维 Web 应用构建?
从模型托管到交互开发:DataV 如何简化三维 Web 应用构建?
226 2
|
9月前
|
安全 Linux 开发工具
零基础构建开源项目OpenIM桌面应用和pc web- Electron篇
OpenIM 为开发者提供开源即时通讯 SDK,作为 Twilio、Sendbird 等云服务的替代方案。借助 OpenIM,开发者可以构建安全可靠的即时通讯应用,如 WeChat、Zoom、Slack 等。 本仓库基于开源版 OpenIM SDK 开发,提供了一款基于 Electron 的即时通讯应用。您可以使用此应用程序作为 OpenIM SDK 的参考实现。本项目同时引用了 @openim/electron-client-sdk 和 @openim/wasm-client-sdk,分别为 Electron 版本和 Web 版本的 SDK,可以同时构建 PC Web 程序和桌面应用(Wi
677 2
|
11月前
|
安全 应用服务中间件 网络安全
实战经验分享:利用免费SSL证书构建安全可靠的Web应用
本文分享了利用免费SSL证书构建安全Web应用的实战经验,涵盖选择合适的证书颁发机构、申请与获取证书、配置Web服务器、优化安全性及实际案例。帮助开发者提升应用安全性,增强用户信任。
|
12月前
|
监控 前端开发 JavaScript
使用 MERN 堆栈构建可扩展 Web 应用程序的最佳实践
使用 MERN 堆栈构建可扩展 Web 应用程序的最佳实践
219 6
|
12月前
|
存储 消息中间件 缓存
构建互联网高性能WEB系统经验总结
如何构建一个优秀的高性能、高可靠的应用系统对每一个开发者至关重要
105 2
|
JSON API 数据格式
使用Python和Flask构建简单的Web API
使用Python和Flask构建简单的Web API

热门文章

最新文章

下一篇
oss云网关配置