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

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

![](https://img2020.cnblogs.com/blog/436453/202101/436453-20210119131353846-3275826.png) 原文:[How to Build a Multiplayer (.io) Web Game, Part 2](https://victorzhou.com/blog/build-an-io-game-part-2/) 探索 `.io` 遊戲背後的後端伺服器。 上篇:[如何構建一個多人(.io) Web 遊戲,第 1 部分](https://mp.weixin.qq.com/s/D3wZqeM1ds2a4NydyMty_A) 在本文中,我們將看看為示例 `io` 遊戲提供支援的 `Node.js` 後端: ## 目錄 在這篇文章中,我們將討論以下主題: 1. 伺服器入口(Server Entrypoint):設定 `Express` 和 `socket.io`。 2. 服務端 Game(The Server Game):管理伺服器端遊戲狀態。 3. 服務端遊戲物件(Server Game Objects):實現玩家和子彈。 4. 碰撞檢測(Collision Detection):查詢擊中玩家的子彈。 ## 1. 伺服器入口(Server Entrypoint) 我們將使用 Express(一種流行的 Node.js Web 框架)為我們的 Web 伺服器提供動力。我們的伺服器入口檔案 `src/server/server.js` 負責設定: `server.js, Part 1` ```js 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` ```js 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` ```js 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` ```sh 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` ```js 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` ```js 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` ```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` ```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` ```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` ```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` ```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; } ``` 這種簡單的碰撞檢測背後的數學原理是,兩個圓僅在其中心之間的距離≤半徑總和時才“碰撞”。 在這種情況下,兩個圓心之間的距離恰好是其半徑的總和: ![](https://img2020.cnblogs.com/blog/436453/202101/436453-20210119131336138-993994643.png) 在這裡,我們還需要注意其他幾件事: * 確保子彈不能擊中建立它的玩家。我們通過對照 `player.id` 檢查 `bullet.parentID` 來實現。 * 當子彈與多個玩家同時碰撞時,確保子彈在邊緣情況下僅“命中”一次。我們使用 `break` 語句來解決這個問題:一旦找到與子彈相撞的玩家,我們將停止尋找並繼續尋找下一個子彈。 ``` 我是為少。 微信:uuhells123。 公眾號:黑客下午茶。 謝謝點贊支援