創建多人 .io 網頁遊戲

創建多人 .io 網頁遊戲
2015年發布 Agar.io 成為新流派的鼻祖 遊戲.io從那時起它就越來越受歡迎。 我親身經歷了.io遊戲受歡迎程度的上升:在過去的三年裡,我 創建並銷售了兩款此類游戲。.

如果您以前從未聽說過這些遊戲,這些是易於玩的免費多人網頁遊戲(無需帳戶)。 他們通常在同一競技場面對許多對手。 其他著名的.io遊戲: Slither.io и Diep.io.

在這篇文章中,我們將探討如何 從頭開始創建 .io 遊戲。 為此,只需了解 Javascript 就足夠了:您需要了解語法等內容 ES6, 關鍵字 this и 承諾。 即使你對 Javascript 的了解並不完美,你仍然可以理解這篇文章的大部分內容。

.io遊戲示例

對於學習幫助,我們將參考 .io遊戲示例。 嘗試玩一下吧!

創建多人 .io 網頁遊戲
遊戲非常簡單:您在有其他玩家的競技場中控制一艘船。 你的飛船會自動發射射彈,你會嘗試擊中其他玩家,同時避開他們的射彈。

1. 項目簡要概述/結構

我建議 下載源代碼 示例遊戲,以便您可以關注我。

該示例使用以下內容:

  • 特快 是最流行的 Node.js Web 框架,用於管理遊戲的 Web 服務器。
  • 套接字.io - 用於在瀏覽器和服務器之間交換數據的 websocket 庫。
  • 的WebPack - 模塊管理器。 您可以閱讀有關為什麼使用 Webpack 的信息。 這裡.

項目目錄結構如下:

public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

上市/

所有內容都在一個文件夾中 public/ 將由服務器靜態提交。 在 public/assets/ 包含我們項目使用的圖像。

src /

所有源代碼都在文件夾中 src/。 標題 client/ и server/ 為自己說話並 shared/ 包含由客戶端和服務器導入的常量文件。

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包.
  • 我們用 巴貝爾,特別是配置 @babel/預設環境 為舊版瀏覽器轉譯我們的 JS 代碼。
  • 我們使用一個插件來提取 JS 文件引用的所有 CSS 並將它們組合到一個地方。 我會稱他為我們的 CSS包.

您可能已經註意到奇怪的包文件名 '[name].[contenthash].ext'。 他們包含 文件名替換 Webpack: [name] 將被替換為輸入點的名稱(在我們的例子中,這個 game),和 [contenthash] 將被替換為文件內容的哈希值。 我們這樣做是為了 優化哈希項目 - 你可以告訴瀏覽器無限期地緩存我們的JS包,因為 如果包發生變化,那麼它的文件名也會發生變化 (變化 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在部署到生產時優化包大小。

本地設置

我建議在本地計算機上安裝該項目,以便您可以按照本文中列出的步驟進行操作。 設置很簡單:首先,系統必須已安裝 節點 и NPM。 接下來你需要做的

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

現在您就可以出發了! 要啟動開發服務器,只需運行

$ npm run develop

並轉到網絡瀏覽器 本地主機:3000。 開發服務器將隨著代碼的更改自動重建 JS 和 CSS 包 - 只需刷新頁面即可查看所有更改!

3. 客戶端入口點

讓我們開始討論遊戲代碼本身。 首先我們需要一個頁面 index.html,當訪問該網站時,瀏覽器將首先加載它。 我們的頁面將非常簡單:

的index.html

.io 遊戲示例 玩

為了清楚起見,此代碼示例已稍微簡化,我將對許多其他帖子示例執行相同的操作。 完整的代碼可以隨時查看 Github上.

我們有:

  • HTML5 畫布元素 (<canvas>)我們將用它來渲染遊戲。
  • <link> 添加我們的 CSS 包。
  • <script> 添加我們的 Javascript 包。
  • 帶用戶名的主菜單 <input> 和“播放”按鈕(<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);
  };
});

這聽起來可能很複雜,但這裡並沒有發生太多事情:

  1. 導入其他幾個 JS 文件。
  2. CSS 導入(因此 Webpack 知道將它們包含在我們的 CSS 包中)。
  3. Запуск connect() 與服務器建立連接並運行 downloadAssets() 下載渲染遊戲所需的圖像。
  4. 第3階段完成後 顯示主菜單(playMenu).
  5. 設置按下“PLAY”按鈕的處理程序。 當按下按鈕時,代碼會初始化遊戲並告訴服務器我們已經準備好開始玩了。

我們的客戶端-服務器邏輯的主要“內容”位於由文件導入的那些文件中 index.js。 現在我們將按順序考慮它們。

4. 客戶數據交換

在這個遊戲中,我們使用一個知名的庫與服務器進行通信 套接字.io。 Socket.io 具有本機支持 WebSockets的,非常適合雙向通信:我們可以向服務器發送消息 и 服務器可以在同一連接上向我們發送消息。

我們將有一個文件 src/client/networking.js誰來照顧 每個人 與服務器的通信:

網絡.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);
};

為了清晰起見,該代碼也被稍微縮短。

該文件中有三個主要操作:

  • 我們正在嘗試連接到服務器。 connectedPromise 僅當我們建立連接時才允許。
  • 如果連接成功,我們註冊回調函數(processGameUpdate() и onGameOver())用於我們可以從服務器接收的消息。
  • 我們出口 play() и updateDirection()以便其他文件可以使用它們。

5. 客戶端渲染

是時候在屏幕上顯示圖片了!

…但在此之前,我們需要下載所需的所有圖像(資源)。 我們來寫一個資源管理器:

資產.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,它將文件名的鍵綁定到對象的值 Image。 當資源被加載時,我們將其存儲在一個對像中 assets 以便將來快速訪問。 何時允許下載每個單獨的資源(即 所有 資源),我們允許 downloadPromise.

下載資源後就可以開始渲染了。 如前所述,要在網頁上繪圖,我們使用 HTML5畫布 (<canvas>)。 我們的遊戲非常簡單,所以我們只需要繪製以下內容:

  1. 背景
  2. 玩家船
  3. 遊戲中的其他玩家
  4. 貝殼

這是重要的片段 src/client/render.js,它準確呈現上面列出的四個項目:

渲染.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())並不那麼重要,但這裡有一個簡單的例子:

渲染.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,
  );
}

請注意,我們正在使用該方法 getAsset(),之前曾在 asset.js!

如果您有興趣了解其他渲染助手,請閱讀其餘部分。 src/client/render.js.

6. 客戶輸入

是時候做個遊戲了 可玩的! 控制方案將非常簡單:要改變移動方向,您可以使用鼠標(在計算機上)或觸摸屏幕(在移動設備上)。 為了實現這一點,我們將註冊 事件監聽器 用於鼠標和触摸事件。
會照顧好這一切 src/client/input.js:

輸入.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. 客戶狀態

這一部分是本文第一部分中最困難的部分。 如果您第一次閱讀時不明白,請不要灰心! 您甚至可以跳過它並稍後再返回。

完成客戶端/服務器代碼所需的最後一塊拼圖是 。 還記得客戶端渲染部分的代碼片段嗎?

渲染.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
    }
  ]
}

每個遊戲更新包含五個相同的字段:

  • t:指示此更新創建時間的服務器時間戳。
  • me:有關接收此更新的玩家的信息。
  • 其他類:有關參與同一遊戲的其他玩家的一系列信息。
  • 子彈:有關遊戲中射彈的一系列信息。
  • 排行榜:當前排行榜數據。 在這篇文章中,我們不會考慮它們。

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;
}

又好又清晰! 但如果事情就是這麼簡單就好了。 這種實現存在問題的原因之一是: 它將渲染幀速率限制為服務器時鐘速率.

幀率:幀數(即調用 render())每秒,或 FPS。 遊戲通常力爭達到至少 60 FPS。

報價變動率:服務器向客戶端發送遊戲更新的頻率。 它通常低於幀速率。 在我們的遊戲中,服務器以每秒 30 個週期的頻率運行。

如果我們只渲染遊戲的最新更新,那麼 FPS 基本上永遠不會超過 30,因為 我們每秒從服務器獲得的更新不會超過 30 次。 即使我們打電話 render() 每秒 60 次,那麼其中一半的調用只會重繪相同的內容,基本上什麼也不做。 天真的實現的另一個問題是它 容易出現延誤。 在理想的互聯網速度下,客戶端將每 33 毫秒收到一次遊戲更新(每秒 30 次):

創建多人 .io 網頁遊戲
不幸的是,沒有什麼是完美的。 更現實的情況是:
創建多人 .io 網頁遊戲
就延遲而言,幼稚的實現實際上是最糟糕的情況。 如果延遲 50 毫秒收到遊戲更新,則 客戶攤位 額外的 50 毫秒,因為它仍在渲染上次更新的遊戲狀態。 你可以想像這對於玩家來說是多麼不舒服:任意剎車會讓遊戲感覺生澀和不穩定。

7.2 改進的客戶端狀態

我們將對幼稚的實現進行一些改進。 首先,我們使用 渲染延遲 100 毫秒。 這意味著客戶端的“當前”狀態將始終落後於服務器上的遊戲狀態 100 毫秒。 例如,如果服務器上的時間是 150,那麼客戶端就會渲染出服務器當時的狀態 50:

創建多人 .io 網頁遊戲
這為我們提供了 100 毫秒的緩衝區來應對不可預測的遊戲更新時間:

創建多人 .io 網頁遊戲
這樣做的回報將是永久的 輸入滯後 100 毫秒。 對於流暢的遊戲體驗來說,這是一個小小的犧牲——大多數玩家(尤其是休閒玩家)甚至不會注意到這種延遲。 對於人們來說,適應恆定的 100 毫秒延遲比適應不可預測的延遲要容易得多。

我們還可以使用另一種技術,稱為 客戶端預測,它在減少感知延遲方面做得很好,但本文不會討論。

我們正在使用的另一個改進是 線性插值。 由於渲染延遲,我們通常會比客戶端中的當前時間提前至少一次更新。 被叫時 getCurrentState(),我們可以執行 線性插值 客戶端當前時間之前和之後的遊戲更新之間:

創建多人 .io 網頁遊戲
這解決了幀速率問題:我們現在可以以我們想要的任何幀速率渲染獨特的幀!

7.3 實現增強的客戶端狀態

實現示例在 src/client/state.js 同時使用渲染延遲和線性插值,但時間不會太長。 讓我們將代碼分成兩部分。 這是第一個:

狀態.js 第 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。 然後,為了檢查內存使用情況,我們刪除之前的所有舊更新 基礎更新因為我們不再需要它們了。

什麼是“基本更新”? 這 我們通過從服務器當前時間向後移動找到的第一個更新。 還記得這張圖嗎?

創建多人 .io 網頁遊戲
“客戶端渲染時間”左側的遊戲更新是基礎更新。

基礎更新有什麼用? 為什麼我們可以放棄對基線的更新? 為了弄清楚這一點,讓我們 最後 考慮實施 getCurrentState():

狀態.js 第 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),
    };
  }
}

我們處理三種情況:

  1. base < 0 意味著在當前渲染時間之前沒有更新(參見上面的實現 getBaseUpdate())。 由於渲染延遲,這種情況可能會在遊戲開始時發生。 在這種情況下,我們使用收到的最新更新。
  2. base 是我們的最新更新。 這可能是由於網絡延遲或互聯網連接不良造成的。 在這種情況下,我們也使用我們擁有的最新更新。
  3. 我們在當前渲染時間之前和之後都有更新,因此我們可以 !

剩下的一切都在 state.js 是線性插值的實現,是簡單(但無聊)的數學。 如果您想親自探索,請打開 state.jsGithub上.

第 2 部分. 後端服務器

在這一部分中,我們將看一下控制我們的 Node.js 後端 .io遊戲示例.

1. 服務器入口點

為了管理 Web 服務器,我們將使用一個名為 Node.js 的流行 Web 框架 特快。 它將由我們的服務器入口點文件配置 src/server/server.js:

服務器.js 第 1 部分

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');

// Setup an Express server
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Setup Webpack for development
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Static serve the dist/ folder in production
  app.use(express.static('dist'));
}

// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

還記得我們在第一部分中討論過 Webpack 嗎? 這是我們將使用 Webpack 配置的地方。 我們將以兩種方式使用它們:

  • 使用 webpack-dev-中間件 自動重建我們的開發包,或者
  • 靜態傳輸文件夾 dist/,Webpack 將在生產構建後將我們的文件寫入其中。

另一項重要任務 server.js 就是設置服務器 套接字.io它只連接到 Express 服務器:

服務器.js 第 2 部分

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

// Setup Express
// ...
const server = app.listen(port);
console.log(`Server listening on port ${port}`);

// Setup socket.io
const io = socketio(server);

// Listen for socket.io connections
io.on('connection', socket => {
  console.log('Player connected!', socket.id);

  socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on(Constants.MSG_TYPES.INPUT, handleInput);
  socket.on('disconnect', onDisconnect);
});

成功建立與服務器的 socket.io 連接後,我們為新套接字設置事件處理程序。 事件處理程序通過委託給單例對象來處理從客戶端接收到的消息 game:

服務器.js 第 3 部分

const Game = require('./game');

// ...

// Setup the Game
const game = new Game();

function joinGame(username) {
  game.addPlayer(this, username);
}

function handleInput(dir) {
  game.handleInput(this, dir);
}

function onDisconnect() {
  game.removePlayer(this);
}

我們正在創建一個 .io 遊戲,因此我們只需要一份副本 Game (“遊戲”)- 所有玩家都在同一個競技場中玩! 在下一節中,我們將看到這個類是如何工作的。 Game.

2. 遊戲服務器

Game 包含服務器端最重要的邏輯。 它有兩個主要任務: 球員管理 и 遊戲模擬.

讓我們從第一個任務開始,玩家管理。

遊戲.js 第 1 部分

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

class Game {
  constructor() {
    this.sockets = {};
    this.players = {};
    this.bullets = [];
    this.lastUpdateTime = Date.now();
    this.shouldSendUpdate = false;
    setInterval(this.update.bind(this), 1000 / 60);
  }

  addPlayer(socket, username) {
    this.sockets[socket.id] = socket;

    // Generate a position to start this player at.
    const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
    this.players[socket.id] = new Player(socket.id, username, x, y);
  }

  removePlayer(socket) {
    delete this.sockets[socket.id];
    delete this.players[socket.id];
  }

  handleInput(socket, dir) {
    if (this.players[socket.id]) {
      this.players[socket.id].setDirection(dir);
    }
  }

  // ...
}

在本場比賽中,我們將通過場地來識別球員 id 他們的 socket.io 套接字(如果您感到困惑,請返回 server.js)。 Socket.io 本身為每個套接字分配了一個唯一的 id所以我們不需要擔心這個。 我會打電話給他 玩家ID.

考慮到這一點,讓我們探索類中的實例變量 Game:

  • sockets 是將玩家 ID 綁定到與玩家關聯的套接字的對象。 它允許我們在恆定時間內通過玩家 ID 訪問套接字。
  • players 是一個將玩家ID綁定到代碼>Player對象的對象

bullets 是一個對像數組 Bullet,沒有明確的順序。
lastUpdateTime 是遊戲上次更新的時間戳。 我們很快就會看到它是如何使用的。
shouldSendUpdate 是一個輔助變量。 我們很快也會看到它的使用。
方法 addPlayer(), removePlayer() и handleInput() 無需解釋,它們用於 server.js。 如果您需要刷新記憶,請返回更高一點。

最後一行 constructor() 啟動 更新周期 遊戲(更新頻率為60次/秒):

遊戲.js 第 2 部分

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

class Game {
  // ...

  update() {
    // Calculate time elapsed
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) / 1000;
    this.lastUpdateTime = now;

    // Update each bullet
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    // Update each player
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    // Apply collisions, give players score for hitting bullets
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // Check if any players are dead
    Object.keys(this.sockets).forEach(playerID => {
      const socket = this.sockets[playerID];
      const player = this.players[playerID];
      if (player.hp <= 0) {
        socket.emit(Constants.MSG_TYPES.GAME_OVER);
        this.removePlayer(socket);
      }
    });

    // Send a game update to each player every other time
    if (this.shouldSendUpdate) {
      const leaderboard = this.getLeaderboard();
      Object.keys(this.sockets).forEach(playerID => {
        const socket = this.sockets[playerID];
        const player = this.players[playerID];
        socket.emit(
          Constants.MSG_TYPES.GAME_UPDATE,
          this.createUpdate(player, leaderboard),
        );
      });
      this.shouldSendUpdate = false;
    } else {
      this.shouldSendUpdate = true;
    }
  }

  // ...
}

方法 update() 包含可能是最重要的服務器端邏輯部分。 以下是它的作用(按順序):

  1. 計算多長時間 dt 自上次以來已過去 update().
  2. 刷新每個射彈並在必要時銷毀它們。 稍後我們將看到此功能的實現。 目前,我們只要知道這一點就足夠了 bullet.update() 回報 true如果射彈應該被銷毀 (他走出了競技場)。
  3. 更新每個玩家並在必要時創建一個射彈。 稍後我們還將看到這個實現 - player.update() 可以返回一個對象 Bullet.
  4. 檢查射彈和玩家之間的碰撞 applyCollisions(),它返回擊中玩家的射彈數組。 對於每個返回的射彈,我們都會增加發射它的玩家的分數(使用 player.onDealtDamage())然後從數組中移除射彈 bullets.
  5. 通知並消滅所有被殺死的玩家。
  6. 向所有玩家發送遊戲更新 每一秒 被叫時的次數 update()。 這有助於我們跟踪上面提到的輔助變量。 shouldSendUpdate。 因為 update() 稱為60次/秒,我們發送遊戲更新30次/秒。 因此, 時鐘頻率 服務器時鐘為 30 個時鐘/秒(我們在第一部分中討論了時鐘速率)。

為什麼只發送遊戲更新 通過時間 ? 保存頻道。 每秒 30 次遊戲更新已經很多了!

為什麼不直接打電話 update() 每秒30次? 提高遊戲模擬性。 更常被稱為 update(),遊戲模擬越準確。 但不要因挑戰的數量而過於得意忘形。 update(),因為這是一項計算成本較高的任務 - 每秒 60 次就足夠了。

班級的其餘部分 Game 由使用的輔助方法組成 update():

遊戲.js 第 3 部分

class Game {
  // ...

  getLeaderboard() {
    return Object.values(this.players)
      .sort((p1, p2) => p2.score - p1.score)
      .slice(0, 5)
      .map(p => ({ username: p.username, score: Math.round(p.score) }));
  }

  createUpdate(player, leaderboard) {
    const nearbyPlayers = Object.values(this.players).filter(
      p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );
    const nearbyBullets = this.bullets.filter(
      b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
    );

    return {
      t: Date.now(),
      me: player.serializeForUpdate(),
      others: nearbyPlayers.map(p => p.serializeForUpdate()),
      bullets: nearbyBullets.map(b => b.serializeForUpdate()),
      leaderboard,
    };
  }
}

getLeaderboard() 非常簡單 - 它按分數對玩家進行排序,取前五名,然後返回每個玩家的用戶名和分數。

createUpdate() 用於 update() 創建分發給玩家的遊戲更新。 它的主要任務是調用方法 serializeForUpdate()為班級實施 Player и Bullet。 請注意,它僅將數據傳遞給每個玩家 最近的 玩家和投射物 - 無需傳輸遠離玩家的遊戲對象的信息!

3. 服務器上的遊戲對象

在我們的遊戲中,彈丸和玩家實際上非常相似:它們都是抽象的、圓形的、可移動的遊戲對象。 為了利用玩家和射彈之間的這種相似性,讓我們從實現基類開始 Object:

對象.js

class Object {
  constructor(id, x, y, dir, speed) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.direction = dir;
    this.speed = speed;
  }

  update(dt) {
    this.x += dt * this.speed * Math.sin(this.direction);
    this.y -= dt * this.speed * Math.cos(this.direction);
  }

  distanceTo(object) {
    const dx = this.x - object.x;
    const dy = this.y - object.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  setDirection(dir) {
    this.direction = dir;
  }

  serializeForUpdate() {
    return {
      id: this.id,
      x: this.x,
      y: this.y,
    };
  }
}

這裡沒有什麼複雜的事情發生。 這個類將是擴展的一個很好的錨點。 我們來看看班級如何 Bullet 用途 Object:

子彈.js

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

class Bullet extends ObjectClass {
  constructor(parentID, x, y, dir) {
    super(shortid(), x, y, dir, Constants.BULLET_SPEED);
    this.parentID = parentID;
  }

  // Returns true if the bullet should be destroyed
  update(dt) {
    super.update(dt);
    return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

履行 Bullet 很短! 我們已經添加到 Object 僅以下擴展:

  • 使用包 短號 用於隨機生成 id 射彈。
  • 添加字段 parentID這樣您就可以追踪創建此射彈的玩家。
  • 添加返回值 update(),等於 true如果射彈在競技場之外(還記得我們在上一節中討論過這一點嗎?)。

讓我們繼續 Player:

播放器.js

const ObjectClass = require('./object');
const Bullet = require('./bullet');
const Constants = require('../shared/constants');

class Player extends ObjectClass {
  constructor(id, username, x, y) {
    super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED);
    this.username = username;
    this.hp = Constants.PLAYER_MAX_HP;
    this.fireCooldown = 0;
    this.score = 0;
  }

  // Returns a newly created bullet, or null.
  update(dt) {
    super.update(dt);

    // Update score
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Make sure the player stays in bounds
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Fire a bullet, if needed
    this.fireCooldown -= dt;
    if (this.fireCooldown <= 0) {
      this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
      return new Bullet(this.id, this.x, this.y, this.direction);
    }
    return null;
  }

  takeBulletDamage() {
    this.hp -= Constants.BULLET_DAMAGE;
  }

  onDealtDamage() {
    this.score += Constants.SCORE_BULLET_HIT;
  }

  serializeForUpdate() {
    return {
      ...(super.serializeForUpdate()),
      direction: this.direction,
      hp: this.hp,
    };
  }
}

玩家比射彈更複雜,因此這個類中應該存儲更多的字段。 他的方法 update() 做了很多工作,特別是如果沒有剩下的,則返回新創建的射彈 fireCooldown (還記得我們在上一節中討論過這個嗎?)。 它還擴展了方法 serializeForUpdate(),因為我們需要在遊戲更新中包含玩家的附加字段。

有一個基類 Object - 避免重複代碼的重要一步。 例如,沒有課 Object 每個遊戲對象必須具有相同的實現 distanceTo(),並且在多個文件中復制粘貼所有這些實現將是一場噩夢。 這對於大型項目尤其重要。當數量擴大時 Object 班級人數不斷增加。

4. 碰撞檢測

我們唯一要做的就是識別射彈何時擊中玩家! 記住方法中的這段代碼 update() 在班上 Game:

遊戲.js

const applyCollisions = require('./collisions');

class Game {
  // ...

  update() {
    // ...

    // Apply collisions, give players score for hitting bullets
    const destroyedBullets = applyCollisions(
      Object.values(this.players),
      this.bullets,
    );
    destroyedBullets.forEach(b => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // ...
  }
}

我們需要實現該方法 applyCollisions(),它返回擊中玩家的所有射彈。 幸運的是,這並不難做到,因為

  • 所有碰撞物體都是圓形,這是實現碰撞檢測的最簡單的形狀。
  • 我們已經有一個方法了 distanceTo(),我們在課堂上一節中實現了 Object.

這是我們的碰撞檢測實現的樣子:

碰撞.js

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

// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
      ) {
        destroyedBullets.push(bullet);
        player.takeBulletDamage();
        break;
      }
    }
  }
  return destroyedBullets;
}

這個簡單的碰撞檢測基於以下事實: 如果兩個圓的圓心之間的距離小於它們的半徑之和,則兩個圓發生碰撞。 這是兩個圓心之間的距離恰好等於它們的半徑之和的情況:

創建多人 .io 網頁遊戲
這裡還有幾個方面需要考慮:

  • 射彈不得擊中創造它的玩家。 這可以通過比較來實現 bullet.parentID с player.id.
  • 在多個玩家同時碰撞的極限情況下,射彈只能擊中一次。 我們將使用運算符來解決這個問題 break:一旦發現與彈丸碰撞的玩家,我們就停止搜索並繼續處理下一個彈丸。

Конец

就這樣! 我們已經介紹了創建 .io 網頁遊戲所需了解的所有內容。 下一步是什麼? 構建您自己的 .io 遊戲!

所有示例代碼都是開源的並發佈在 Github上.

來源: www.habr.com

添加評論