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

原文:How to Build a Multiplayer (.io) Web Game, Part 1css

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

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

若是您之前从未据说过 .io 游戏:它们是免费的多人 web 游戏,易于加入(无需账户),
而且一般在一个区域内让许多玩家相互竞争。其余著名的 .io 游戏包括 Slither.ioDiep.iowebpack

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

一个 .io 游戏示例

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

这是一款很是简单的游戏:你和其余玩家一块儿控制竞技场中的一艘船。
你的飞船会自动发射子弹,你会试图用本身的子弹击中其余玩家,同时避开他们。web

目录

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

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

1. 项目概况/结构

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

咱们的示例游戏使用了:数组

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

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

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

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

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

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

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

本地设置

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

$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install

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

$ npm run develop

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

3. Client 入口

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

index.html

<!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

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

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

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

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

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

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 Native 客户端状态

getCurrentState() 的 native 实现能够直接返回最近收到的游戏更新的数据。

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次,这些调用中的一半也只会重绘彻底相同的内容,实际上什么也没作。

Native 实现的另外一个问题是它很容易滞后。在完美的互联网条件下,客户端将彻底每33毫秒(每秒30个)收到一次游戏更新:

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

当涉及到延迟时,native 实现几乎是最糟糕的状况。
若是游戏更新晚到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种状况:

  1. base < 0,意味着在当前渲染时间以前没有更新(请参见上面的 getBaseUpdate() 的实现)。因为渲染延迟,这可能会在游戏开始时发生。在这种状况下,咱们将使用最新的更新。
  2. base 是咱们最新的更新(😢)。这种状况多是因为网络链接的延迟或较差形成的。在本例中,咱们还使用了最新的更新。
  3. 咱们在当前渲染时间以前和以后都有更新,因此咱们能够插值!

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

我是为少。
微信:uuhells123。
公众号:黑客下午茶。
谢谢点赞支持👍👍👍!
相关文章
相关标签/搜索