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

2021-05-27 19:04:58 浏览数 (1)

原文:https://victorzhou.com/blog/build-an-io-game-part-1

GitHub: https://github.com/vzhou842/example-.io-game

深入探索一个 .io 游戏的 Javascript client-side(客户端)。

如果您以前从未听说过 .io 游戏:它们是免费的多人 web 游戏,易于加入(无需帐户),并且通常在一个区域内让许多玩家相互竞争。其他著名的 .io 游戏包括 Slither.ioDiep.io

  • Slither.io:http://slither.io
  • Diep.io:https://diep.io

在本文中,我们将了解如何从头开始构建.io游戏。您所需要的只是 Javascript 的实用知识:您应该熟悉 ES6 语法,this 关键字和 Promises之类的内容。即使您对 Javascript 并不是最熟悉的,您仍然应该可以阅读本文的大部分内容。

一个 .io 游戏示例

为了帮助我们学习,我们将参考 https://example-io-game.victorzhou.com。

这是一款非常简单的游戏:你和其他玩家一起控制竞技场中的一艘船。你的飞船会自动发射子弹,你会试图用自己的子弹击中其他玩家,同时避开他们。

目录

这是由两部分组成的系列文章的第 1 部分。我们将在这篇文章中介绍以下内容:

  1. 项目概况/结构:项目的高级视图。
  2. 构建/项目设置:开发工具、配置和设置。
  3. Client 入口:index.html 和 index.js。
  4. Client 网络通信:与服务器通信。
  5. Client 渲染:下载 image 资源 渲染游戏。
  6. Client 输入:让用户真正玩游戏。
  7. Client 状态:处理来自服务器的游戏更新。

1. 项目概况/结构

我建议下载示例游戏的源代码,以便您可以更好的继续阅读。

我们的示例游戏使用了:

  • Express,Node.js 最受欢迎的 Web 框架,以为其 Web 服务器提供动力。
  • socket.io,一个 websocket 库,用于在浏览器和服务器之间进行通信。
  • Webpack,一个模块打包器。

项目目录的结构如下所示:

代码语言:javascript复制
public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

public/

我们的服务器将静态服务 public/ 文件夹中的所有内容。 public/assets/ 包含我们项目使用的图片资源。

src/

所有源代码都在 src/ 文件夹中。 client/server/ 很容易说明,shared/ 包含一个由 client 和 server 导入的常量文件。

2. 构建/项目设置

如前所述,我们正在使用 Webpack 模块打包器来构建我们的项目。让我们看一下我们的 Webpack 配置:

webpack.common.js

代码语言:javascript复制
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    game: './src/client/index.js',
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src/client/html/index.html',
    }),
  ],
};
  • src/client/index.js 是 Javascript (JS) 客户端入口点。Webpack 将从那里开始,递归地查找其他导入的文件。
  • 我们的 Webpack 构建的 JS 输出将放置在 dist/ 目录中。我将此文件称为 JS bundle。
  • 我们正在使用 Babel,特别是 @babel/preset-env 配置,来为旧浏览器编译 JS 代码。
  • 我们正在使用一个插件来提取 JS 文件引用的所有 CSS 并将其捆绑在一起。我将其称为 CSS bundle。

您可能已经注意到奇怪的 '[name].[contenthash].ext' 捆绑文件名。它们包括 Webpack 文件名替换:[name] 将替换为入口点名称(这是game),[contenthash]将替换为文件内容的哈希。我们这样做是为了优化缓存 - 我们可以告诉浏览器永远缓存我们的 JS bundle,因为如果 JS bundle 更改,其文件名也将更改(contenthash 也会更改)。最终结果是一个文件名,例如:game.dbeee76e91a97d0c7207.js

webpack.common.js 文件是我们在开发和生产配置中导入的基本配置文件。例如,下面是开发配置:

webpack.dev.js

代码语言:javascript复制
const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

我们在开发过程中使用 webpack.dev.js 来提高效率,并在部署到生产环境时切换到 webpack.prod.js 来优化包的大小。

本地设置

我建议在您的本地计算机上安装该项目,以便您可以按照本文的其余内容进行操作。设置很简单:首先,确保已安装 NodeNPM。然后,

代码语言:javascript复制
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install

您就可以出发了!要运行开发服务器,只需

代码语言:javascript复制
$ npm run develop

并在网络浏览器中访问 localhost:3000。当您编辑代码时,开发服务器将自动重建 JS 和 CSS bundles - 只需刷新即可查看更改!

3. Client 入口

让我们来看看实际的游戏代码。首先,我们需要一个 index.html 页面, 这是您的浏览器访问网站时首先加载的内容。我们的将非常简单:

index.html

代码语言:javascript复制
<!DOCTYPE html>
<html>
<head>
  <title>An example .io game</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Username" />
    <button id="play-button">PLAY</button>
  </div>
</body>
</html>

我们有:

  • 我们将使用 HTML5 Canvas(<canvas>)元素来渲染游戏。
  • <link> 包含我们的 CSS bundle。
  • <script> 包含我们的 Javascript bundle。
  • 主菜单,带有用户名 <input>“PLAY” <button>

一旦主页加载到浏览器中,我们的 Javascript 代码就会开始执行, 从我们的 JS 入口文件 src/client/index.js 开始。

index.js

代码语言:javascript复制
import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

这似乎很复杂,但实际上并没有那么多事情发生:

  • 导入一堆其他 JS 文件。
  • 导入一些 CSS(因此 Webpack 知道将其包含在我们的 CSS bundle 中)。
  • 运行 connect() 来建立到服务器的连接,运行 downloadAssets() 来下载渲染游戏所需的图像。
  • 步骤 3 完成后,显示主菜单(playMenu)。
  • 为 “PLAY” 按钮设置一个点击处理程序。如果点击,初始化游戏并告诉服务器我们准备好玩了。

客户端逻辑的核心驻留在由 index.js 导入的其他文件中。接下来我们将逐一讨论这些问题。

4. Client 网络通信

对于此游戏,我们将使用众所周知的 socket.io 库与服务器进行通信。Socket.io 包含对 WebSocket 的内置支持, 这非常适合双向通讯:我们可以将消息发送到服务器,而服务器可以通过同一连接向我们发送消息。

我们将有一个文件 src/client/networking.js,它负责所有与服务器的通信:

networking.js

代码语言:javascript复制
import io from 'socket.io-client';
import { processGameUpdate } from './state';

const Constants = require('../shared/constants');

const socket = io(`ws://${window.location.host}`);
const connectedPromise = new Promise(resolve => {
  socket.on('connect', () => {
    console.log('Connected to server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Register callbacks
    socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
    socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
  })
);

export const play = username => {
  socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};

export const updateDirection = dir => {
  socket.emit(Constants.MSG_TYPES.INPUT, dir);
};

此文件中发生3件主要事情:

  • 我们尝试连接到服务器。只有建立连接后,connectedPromise 才能解析。
  • 如果连接成功,我们注册回调( processGameUpdate()onGameOver() )我们可能从服务器接收到的消息。
  • 我们导出 play()updateDirection() 以供其他文件使用。

5. Client 渲染

是时候让东西出现在屏幕上了!

但在此之前,我们必须下载所需的所有图像(资源)。让我们写一个资源管理器:

assets.js

代码语言:javascript复制
const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];

管理 assets 并不难实现!主要思想是保留一个 assets 对象,它将文件名 key 映射到一个 Image 对象值。当一个 asset 下载完成后,我们将其保存到 assets 对象中,以便以后检索。最后,一旦每个 asset 下载都已 resolve(意味着所有 assets 都已下载),我们就 resolve downloadPromise

随着资源的下载,我们可以继续进行渲染。如前所述,我们正在使用 HTML5 画布(<canvas>)绘制到我们的网页上。我们的游戏非常简单,所以我们需要画的是:

  1. 背景
  2. 我们玩家的飞船
  3. 游戏中的其他玩家
  4. 子弹

这是 src/client/render.js 的重要部分,它准确地绘制了我上面列出的那四件事:

render.js

代码语言:javascript复制
import { getAsset } from './assets';
import { getCurrentState } from './state';

const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;

// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

function render() {
  const { me, others, bullets } = getCurrentState();
  if (!me) {
    return;
  }

  // Draw background
  renderBackground(me.x, me.y);

  // Draw all bullets
  bullets.forEach(renderBullet.bind(null, me));

  // Draw all players
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Helper functions here excluded

let renderInterval = null;
export function startRendering() {
  renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
  clearInterval(renderInterval);
}

render() 是该文件的主要函数。startRendering()stopRendering() 控制 60 FPS 渲染循环的激活。

各个渲染帮助函数(例如 renderBullet() )的具体实现并不那么重要,但这是一个简单的示例:

render.js

代码语言:javascript复制
function renderBullet(me, bullet) {
  const { x, y } = bullet;
  context.drawImage(
    getAsset('bullet.svg'),
    canvas.width / 2   x - me.x - BULLET_RADIUS,
    canvas.height / 2   y - me.y - BULLET_RADIUS,
    BULLET_RADIUS * 2,
    BULLET_RADIUS * 2,
  );
}

请注意,我们如何使用前面在 asset.js 中看到的 getAsset() 方法!

如果你对其他渲染帮助函数感兴趣,请阅读 src/client/render.js 的其余部分。

6. Client 输入?️

现在该使游戏变得可玩了!我们的 control scheme 非常简单:使用鼠标(在桌面上)或触摸屏幕(在移动设备上)来控制移动方向。为此,我们将为 Mouse 和 Touch 事件注册事件监听器。

src/client/input.js 会处理这些问题:

input.js

代码语言:javascript复制
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

代码语言:javascript复制
import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Do the rendering
  // ...
}

getCurrentState() 必须能够根据从服务器接收到的游戏更新随时向我们提供客户端的当前游戏状态。这是服务器可能发送的游戏更新示例:

代码语言:javascript复制
{
  "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

代码语言:javascript复制
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

代码语言:javascript复制
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

代码语言:javascript复制
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. 我们在当前渲染时间之前和之后都有更新,所以我们可以插值!

state.js 剩下的就是线性插值的实现,这只是一些简单(但很无聊)的数学运算。如果您想查看,请在 Github 上查看 state.js。

  • https://github.com/vzhou842/example-.io-game/blob/master/src/client/state.js

0 人点赞