開發與維運

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

1. 服務器入口(Server Entrypoint)


我們將使用 Express(一種流行的 Node.js Web 框架)為我們的 Web 服務器提供動力。我們的服務器入口文件 src/server/server.js 負責設置:

server.js, Part 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}`);

還記得本系列第1部分中討論 Webpack 嗎?這是我們使用 Webpack 配置的地方。我們要麼

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

server.js 的另一個主要工作是設置您的 socket.io 服務器,該服務器實際上只是附加到 Express 服務器上:

server.js, Part 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 連接時,我們都會為新 socket 設置事件處理程序。事件處理程序通過委派給單例 game 對象來處理從客戶端收到的消息:

server.js, Part 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 實例(“the Game”)- 所有玩家都在同一個競技場上玩!我們將在下一節中介紹該 Game類的工作方式。

2. 服務端 Game(The Server Game)


Game 類包含最重要的服務器端邏輯。它有兩個主要工作:管理玩家和模擬遊戲。

讓我們從第一個開始:管理玩家。

game.js, Part 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);
    }
  }
  // ...
}

在本遊戲中,我們的慣例是通過 socket.io socket 的 id 字段來識別玩家(如果感到困惑,請參考 server.js)。Socket.io 會為我們為每個 socket 分配一個唯一的 id,因此我們不必擔心。我將其稱為 player ID

考慮到這一點,讓我們來看一下 Game 類中的實例變量:

  • sockets 是將 player ID 映射到與該玩家關聯的 socket 的對象。這樣一來,我們就可以通過玩家的 ID 持續訪問 sockets。
  • players 是將 player ID 映射到與該玩家相關聯的 Player 對象的對象。這樣我們就可以通過玩家的 ID 快速訪問玩家對象。
  • bullets 是沒有特定順序的 Bullet(子彈) 對象數組。
  • lastUpdateTime 是上一次遊戲更新發生的時間戳。我們將看到一些使用。
  • shouldSendUpdate 是一個輔助變量。我們也會看到一些用法。

addPlayer()removePlayer()handleInput() 是在 server.js 中使用的非常不言自明的方法。如果需要提醒,請向上滾動查看它!

constructor() 的最後一行啟動遊戲的更新循環(每秒 60 次更新):

game.js, Part 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. 計算自上次 update() 以來 dt 過去了多少時間。
  2. 如果需要的話,更新每顆子彈並銷燬它。稍後我們將看到這個實現 — 現在,我們只需要知道如果子彈應該被銷燬(因為它是越界的),那麼 bullet.update() 將返回 true
  3. 更新每個玩家並根據需要創建子彈。稍後我們還將看到該實現 - player.update() 可能返回 Bullet 對象。
  4. 使用 applyCollisions() 檢查子彈與玩家之間的碰撞,該函數返回擊中玩家的子彈數組。對於返回的每個子彈,我們都會增加發射它的玩家的得分(通過 player.onDealtDamage()),然後從我們的 bullets 數組中刪除子彈。
  5. 通知並刪除任何死玩家。
  6. 每隔一次調用 update() 就向所有玩家發送一次遊戲更新。前面提到的 shouldSendUpdate 輔助變量可以幫助我們跟蹤它。由於 update() 每秒鐘被調用60次,我們每秒鐘發送30次遊戲更新。因此,我們的服務器的 tick rate 是 30 ticks/秒(我們在第1部分中討論了 tick rate)。

為什麼只每隔一段時間發送一次遊戲更新? 節省帶寬。每秒30個遊戲更新足夠了!

那麼為什麼不只是每秒30次調用 update() 呢? 以提高遊戲模擬的質量。調用 update() 的次數越多,遊戲模擬的精度就越高。不過,我們不想對 update() 調用太過瘋狂,因為那在計算上會非常昂貴 - 每秒60個是很好的。

我們的 Game 類的其餘部分由 update() 中使用的輔助方法組成:

game.js, Part 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() 非常簡單 - 它按得分對玩家進行排序,排在前5名,並返回每個用戶名和得分。

update() 中使用 createUpdate() 創建遊戲更新以發送給玩家。它主要通過調用為 PlayerBullet 類實現的serializeForUpdate() 方法進行操作。還要注意,它僅向任何給定玩家發送有關附近玩家和子彈的數據 - 無需包含有關遠離玩家的遊戲對象的信息!

3. 服務端遊戲對象(Server Game Objects)


在我們的遊戲中,Players 和 Bullets 實際上非常相似:都是短暫的,圓形的,移動的遊戲對象。為了在實現 Players 和 Bullets 時利用這種相似性,我們將從 Object 的基類開始:

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 的:

bullet.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 的唯一擴展是:

  • 使用 shortid 包隨機生成子彈的 id
  • 添加 parentID 字段,這樣我們就可以追蹤哪個玩家創建了這個子彈。
  • 如果子彈超出範圍,在 update() 中添加一個返回值,值為 true(還記得在前一節中討論過這個問題嗎?)

前進到 Player

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. 碰撞檢測(Collision Detection)


剩下要做的就是檢測子彈何時擊中玩家!從 Game 類的 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() 方法,該方法返回擊中玩家的所有子彈。幸運的是,這並不難,因為

  • 我們所有可碰撞的對象都是圓形,這是實現碰撞檢測的最簡單形狀。
  • 我們已經在上一節的 Object 類中實現了 distanceTo() 方法。

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

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

這種簡單的碰撞檢測背後的數學原理是,兩個圓僅在其中心之間的距離≤半徑總和時才“碰撞”。在這種情況下,兩個圓心之間的距離恰好是其半徑的總和:

微信圖片_20220611120716.png

在這裡,我們還需要注意其他幾件事:

  • 確保子彈不能擊中創建它的玩家。我們通過對照 player.id 檢查 bullet.parentID 來實現。
  • 當子彈與多個玩家同時碰撞時,確保子彈在邊緣情況下僅“命中”一次。我們使用 break 語句來解決這個問題:一旦找到與子彈相撞的玩家,我們將停止尋找並繼續尋找下一個子彈。

Leave a Reply

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