如何構建一個多人(.io) Web 遊戲,第 2 部分
原文:How to Build a Multiplayer (.io) Web Game, Part 2
探索 .io
遊戲背後的後端伺服器。
上篇:如何構建一個多人(.io) Web 遊戲,第 1 部分
在本文中,我們將看看為示例 io
遊戲提供支援的 Node.js
後端:
目錄
在這篇文章中,我們將討論以下主題:
- 伺服器入口(Server Entrypoint):設定
Express
和socket.io
。 - 服務端 Game(The Server Game):管理伺服器端遊戲狀態。
- 服務端遊戲物件(Server Game Objects):實現玩家和子彈。
- 碰撞檢測(Collision Detection):查詢擊中玩家的子彈。
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()
方法包含了最重要的伺服器端邏輯。讓我們按順序來看看它的作用:
- 計算自上次
update()
以來dt
過去了多少時間。 - 如果需要的話,更新每顆子彈並銷燬它。稍後我們將看到這個實現 — 現在,我們只需要知道如果子彈應該被銷燬(因為它是越界的),那麼
bullet.update()
將返回true
。 - 更新每個玩家並根據需要建立子彈。稍後我們還將看到該實現 -
player.update()
可能返回Bullet
物件。 - 使用
applyCollisions()
檢查子彈與玩家之間的碰撞,該函式返回擊中玩家的子彈陣列。對於返回的每個子彈,我們都會增加發射它的玩家的得分(通過player.onDealtDamage()
),然後從我們的bullets
陣列中刪除子彈。 - 通知並刪除任何死玩家。
- 每隔一次呼叫
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()
建立遊戲更新以傳送給玩家。它主要通過呼叫為 Player
和 Bullet
類實現的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;
}
這種簡單的碰撞檢測背後的數學原理是,兩個圓僅在其中心之間的距離≤半徑總和時才“碰撞”。
在這種情況下,兩個圓心之間的距離恰好是其半徑的總和:
在這裡,我們還需要注意其他幾件事:
- 確保子彈不能擊中建立它的玩家。我們通過對照
player.id
檢查bullet.parentID
來實現。 - 當子彈與多個玩家同時碰撞時,確保子彈在邊緣情況下僅“命中”一次。我們使用
break
語句來解決這個問題:一旦找到與子彈相撞的玩家,我們將停止尋找並繼續尋找下一個子彈。
我是為少。
微信:uuhells123。
公眾號:黑客下午茶。
謝謝點贊支援