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

简介: 如何构建一个多人(.io) Web 游戏,第 2 部分

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


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


微信图片_20220611120716.png


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


  • 确保子弹不能击中创建它的玩家。我们通过对照 player.id 检查 bullet.parentID 来实现。
  • 当子弹与多个玩家同时碰撞时,确保子弹在边缘情况下仅“命中”一次。我们使用 break 语句来解决这个问题:一旦找到与子弹相撞的玩家,我们将停止寻找并继续寻找下一个子弹。
相关文章
|
16天前
|
编解码 前端开发 JavaScript
构建高效响应式Web界面:现代前端框架的比较
【4月更文挑战第9天】在移动设备和多样屏幕尺寸盛行的时代,构建能够适应不同视口的响应式Web界面变得至关重要。本文深入探讨了几种流行的前端框架——Bootstrap、Foundation和Tailwind CSS,分析它们在创建响应式设计中的优势与局限。通过对比这些框架的栅格系统、组件库和定制化能力,开发者可以更好地理解如何选择合适的工具来优化前端开发流程,并最终实现高性能、跨平台兼容的用户界面。
|
17天前
|
前端开发 JavaScript 关系型数据库
从前端到后端:构建现代化Web应用的技术探索
在当今互联网时代,Web应用的开发已成为了各行各业不可或缺的一部分。从前端到后端,这篇文章将带你深入探索如何构建现代化的Web应用。我们将介绍多种技术,包括前端开发、后端开发以及各种编程语言(如Java、Python、C、PHP、Go)和数据库,帮助你了解如何利用这些技术构建出高效、安全和可扩展的Web应用。
|
11天前
|
数据库 开发者 Python
Python中使用Flask构建简单Web应用的例子
【4月更文挑战第15天】Flask是一个轻量级的Python Web框架,它允许开发者快速搭建Web应用,同时保持代码的简洁和清晰。下面,我们将通过一个简单的例子来展示如何在Python中使用Flask创建一个基本的Web应用。
|
14天前
|
JavaScript 前端开发 API
Vue.js:构建高效且灵活的Web应用的利器
Vue.js:构建高效且灵活的Web应用的利器
|
22天前
|
编解码 前端开发 开发者
构建响应式Web界面:Flexbox与Grid布局的深度对比
【4月更文挑战第4天】 在现代前端开发中,构建灵活且响应式的用户界面是至关重要的。随着移动设备浏览量的增加,能够适应不同屏幕尺寸和分辨率的布局技术变得必不可少。Flexbox和Grid是CSS提供的两种强大的布局机制,它们各自以独特的方式解决了响应式设计的挑战。本文将深入探讨Flexbox和Grid的核心概念、使用场景和性能考量,为开发者提供在面对不同布局需求时做出明智选择的依据。
|
1月前
|
编解码 前端开发 开发者
构建响应式Web界面:Flexbox的力量
【2月更文挑战第28天】 在现代网页设计中,创建能在不同设备上保持一致性和功能性的响应式界面是至关重要的。Flexbox,一个CSS布局模块,为前端开发者提供了强大工具来轻松实现灵活的布局设计。本文将深入探讨Flexbox的核心概念、使用场景以及如何通过它来优化响应式设计流程。
|
1月前
|
前端开发 开发者 UED
构建响应式Web界面:Flexbox与Grid布局的深度解析
【2月更文挑战第28天】 在现代前端开发中,打造灵活且适应不同屏幕尺寸的用户界面是至关重要的。随着移动设备的普及,响应式设计已经成为网页制作不可或缺的一部分。本文将深入探讨两种强大的CSS布局模块——Flexbox和Grid,它们如何简化布局创建过程,并赋予设计师更大的灵活性去构建动态和流畅的响应式界面。通过对这两种技术的比较、使用场景分析以及代码示例,读者将能够更好地理解何时以及如何使用这些工具来提升前端项目的质量和效率。
16 0
|
1月前
|
编解码 前端开发 开发者
构建响应式Web界面:Flexbox布局的全面指南
【2月更文挑战第28天】 在当今多变的设备屏幕尺寸和分辨率中,创建一个能够适应不同视口的响应式Web界面至关重要。本文深入探讨了CSS Flexbox布局模块,它是一种设计灵活且强大的方式来创建复杂的响应式布局。我们将透过概念解析、关键属性讲解以及实际案例分析,帮助前端开发者掌握Flexbox的核心原理和应用技巧,以实现流畅的页面布局调整和优化用户体验。
|
1月前
|
物联网 调度 开发者
构建高效Python Web应用:异步编程与Tornado框架解析
【2月更文挑战第27天】 在处理高并发的Web应用场景时,传统的同步阻塞模型往往难以满足性能需求。本文将深入探讨Python世界中的异步编程概念,并结合Tornado这一轻量级、非阻塞式Web服务器及框架,展示如何构建高性能的Web应用。通过实例驱动的方法论,我们将剖析Tornado的核心组件,包括其IOLoop、异步HTTP客户端和服务器端处理机制,以及与协程集成的细节。文章旨在为开发者提供一套实践指南,帮助他们利用Python实现快速响应和资源高效的Web服务。
31 2
|
1月前
|
监控 前端开发 JavaScript
构建高性能Web应用:前端性能优化的关键策略与实践
本文将深入探讨前端性能优化的关键策略与实践,从资源加载、渲染优化、代码压缩等多个方面提供实用的优化建议。通过对前端性能优化的深入剖析,帮助开发者全面提升Web应用的用户体验和性能表现。