開發與維運

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

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 的其餘部分。

Leave a Reply

Your email address will not be published. Required fields are marked *