原文:How to Build a Multiplayer (.io) Web Game, Part 2

探索 .io 游戏背后的后端服务器。

上篇:如何构建一个多人(.io) Web 游戏,第 1 部分

在本文中,我们将看看为示例 io 游戏提供支持的 Node.js 后端:

目录

在这篇文章中,我们将讨论以下主题:

  1. 服务器入口(Server Entrypoint):设置 Expresssocket.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

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

这种简单的碰撞检测背后的数学原理是,两个圆仅在其中心之间的距离≤半径总和时才“碰撞”。

在这种情况下,两个圆心之间的距离恰好是其半径的总和:

在这里,我们还需要注意其他几件事:

  • 确保子弹不能击中创建它的玩家。我们通过对照 player.id 检查 bullet.parentID 来实现。
  • 当子弹与多个玩家同时碰撞时,确保子弹在边缘情况下仅“命中”一次。我们使用 break 语句来解决这个问题:一旦找到与子弹相撞的玩家,我们将停止寻找并继续寻找下一个子弹。
我是为少。
微信:uuhells123。
公众号:黑客下午茶。
谢谢点赞支持!

如何构建一个多人(.io) Web 游戏,第 2 部分的更多相关文章

  1. 如何构建一个多人(.io) Web 游戏,第 1 部分

    原文:How to Build a Multiplayer (.io) Web Game, Part 1 GitHub: https://github.com/vzhou842/example-.io ...

  2. 构建一个用于产品介绍的WEB应用

    为了让用户更好地了解您的产品功能,您在发布新产品或者升级产品功能的时候,不妨使用一个产品介绍的向导,引导用户熟悉产品功能和流程.本文将给您介绍一款优秀的用于产品介绍的WEB应用. 就像微博或邮箱这类W ...

  3. 从零构建一个简单的 Python Web框架

    为什么你想要自己构建一个 web 框架呢?我想,原因有以下几点: 你有一个新奇的想法,觉得将会取代其他的框架 你想要获得一些名气 你遇到的问题很独特,以至于现有的框架不太合适 你对 web 框架是如何 ...

  4. 使用SignalR构建一个最基本的web聊天室

    What is SignalR ASP.NET SignalR is a new library for ASP.NET developers that simplifies the process ...

  5. 构建一个最简单的web应用并部署及启动

    第一种构建方式:不使用maven File-new-Dynamic Web Project,用这种方式构建的web项目是在web.xml文件中配置了welcome-file的,但是却没有对应的文件,所 ...

  6. 构建一个基于 Spring 的 RESTful Web Service

    本文详细介绍了基于Spring创建一个“hello world” RESTful web service工程的步骤. 目标 构建一个service,接收如下HTTP GET请求: http://loc ...

  7. [译]Spring Boot 构建一个RESTful Web服务

    翻译地址:https://spring.io/guides/gs/rest-service/ 构建一个RESTful Web服务 本指南将指导您完成使用spring创建一个“hello world”R ...

  8. Spring Boot 构建一个 RESTful Web Service

    1  项目目标: 构建一个 web service,接收get 请求 http://localhost:8080/greeting 响应一个json 结果: {"id":1,&qu ...

  9. maven 构建一个web项目

    maven已经大型的Java项目的管理工具,其功能非常强大,这里简单总结一下maven构建web项目的过程.本文介绍的是集成环境下的maven构建web项目. 一.准备 1.安装maven. 2.把m ...

随机推荐

  1. UWB硬件设计相关内容

    1.dw1000最小系统 2.器件选择建议: 射频前端  射频前端需要将差分信号转换成单端射频信号,一般使用HHM1595A1(俗称巴伦). 频率参考  晶振一般选择38.4MHZ的TCXO,但是要注 ...

  2. XSS攻击与防止

    1.XSS又称CSS, cross sitescript, 跨站脚本攻击,是web程序中常见的漏洞 XSS属于被动式且用于客户端的攻击方式 XSS攻击类似于SQL注入攻击,攻击之前,我们先找到一个存在 ...

  3. SpringBoot从入门到精通教程(四)

    前端时间整合SSM ,发现了一个现象,在整合的时候 配置文件过于复杂. 1.建工程,建目录,导入jar包. 2.配置 数据源 映射信息 等等 ... 3. 还有 各种 拦截器,控制器 ,头都大了... ...

  4. ​grafana 的主体架构是如何设计的?

    ​grafana 的主体架构是如何设计的? grafana 是非常强大的可视化项目,它最早从 kibana 生成出来,渐渐也已经形成了自己的生态了.研究完 grafana 生态之后,只有一句话:可视化 ...

  5. MySQL的修仙者之旅,不来看看你的修为如何吗?

    目录 因为我个人比较喜欢看修仙类的小说,所以本文的主体部分借用修仙者的修为等级,将学习旅程划分成:练气.筑基.结丹.元婴.化神.飞升六个段位,你可以看下你大概在哪个段位上哦! 本文目录: 我为什么要写 ...

  6. 用matlab提取jpg曲线数据或者jpg图片重新复原

    I = imread('111.jpg');%读取处理好的图片,必须是严格坐标轴线为边界的图片 I=rgb2gray(I); %灰度变化 I(I>200)=255; %二值化 I(I<=2 ...

  7. android 使用 Lottie

    1.添加依赖 dependencies { implementation 'com.airbnb.android:lottie:2.5.5'//lottie } 2.1layout实现 <?xm ...

  8. Linux 路由 策略路由

    Linux 路由 策略路由 目录 Linux 路由 策略路由 一.路由表 编辑路由表配置文件:/etc/iproute2/rt_tables添加删除修改路由表 二.IP策略 查看IP策略 添加IP策略 ...

  9. 基于websocket的netty demo

    前面2文 基于http的netty demo 基于socket的netty demo 讲了netty在http和socket的使用,下面讲讲netty如何使用websocket websocket是h ...

  10. 基于Python的接口自动化实战-基础篇之pymysql模块操作数据库

    引言 在进行功能或者接口测试时常常需要通过连接数据库,操作和查看相关的数据表数据,用于构建测试数据.核对功能.验证数据一致性,接口的数据库操作是否正确等.因此,在进行接口自动化测试时,我们一样绕不开接 ...