如何構建一個多人(.io) Web 遊戲,第 1 部分

為少發表於2021-01-18

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

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

深入探索一個 .io 遊戲的 Javascript client-side(客戶端)。

如果您以前從未聽說過 .io 遊戲:它們是免費的多人 web 遊戲,易於加入(無需帳戶),
並且通常在一個區域內讓許多玩家相互競爭。其他著名的 .io 遊戲包括 Slither.ioDiep.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,一個模組打包器。

專案目錄的結構如下所示:

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。
公眾號:黑客下午茶。
謝謝點贊支援???!

相關文章