原文:How to Build a Multiplayer (.io) Web Game, Part 2webpack
探索 .io
遊戲背後的後端服務器。web
上篇:如何構建一個多人(.io) Web 遊戲,第 1 部分express
在本文中,咱們將看看爲示例 io
遊戲提供支持的 Node.js
後端:後端
在這篇文章中,咱們將討論如下主題:數組
Express
和 socket.io
。咱們將使用 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 配置的地方。咱們要麼app
webpack-dev-middleware
自動重建咱們的開發包,或者dist/
文件夾,Webpack 在生產構建後將在該文件夾中寫入咱們的文件。server.js
的另外一個主要工做是設置您的 socket.io
服務器,該服務器實際上只是附加到 Express 服務器上:框架
server.js, Part 2
dom
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
類的工做方式。
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()
方法進行操做。還要注意,它僅向任何給定玩家發送有關附近玩家和子彈的數據 - 無需包含有關遠離玩家的遊戲對象的信息!
在咱們的遊戲中,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
的類數量的增長,這對於較大的項目尤爲重要。
剩下要作的就是檢測子彈什麼時候擊中玩家! 從 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。 公衆號:黑客下午茶。 謝謝點贊支持👍👍👍!