原文:https://victorzhou.com/blog/build-an-io-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
语句来解决这个问题:一旦找到与子弹相撞的玩家,我们将停止寻找并继续寻找下一个子弹。
我是为少。