创建多人 .io 网页游戏

创建多人 .io 网页游戏
2015年发布 Agar.io 成为新流派的鼻祖 游戏.io从那时起它就越来越受欢迎。 我亲身经历了.io游戏受欢迎程度的上升:在过去的三年里,我 创建并销售了两款此类游戏。.

如果您以前从未听说过这些游戏,这些是易于玩的免费多人网页游戏(无需帐户)。 他们通常在同一竞技场面对许多对手。 其他著名的.io游戏: Slither.io и Diep.io.

在这篇文章中,我们将探讨如何 从头开始创建 .io 游戏。 为此,只需了解 Javascript 就足够了:您需要了解语法等内容 ES6,关键字 this и 承诺。 即使你对 Javascript 的了解并不完美,你仍然可以理解这篇文章的大部分内容。

.io游戏示例

对于学习帮助,我们将参考 .io游戏示例。 尝试玩一下吧!

创建多人 .io 网页游戏
游戏非常简单:您在有其他玩家的竞技场中控制一艘船。 你的飞船会自动发射射弹,你会尝试击中其他玩家,同时避开他们的射弹。

1. 项目简要概述/结构

建议 下载源代码 示例游戏,以便您可以关注我。

该示例使用以下内容:

  • 快捷配送 是最流行的 Node.js Web 框架,用于管理游戏的 Web 服务器。
  • 套接字.io - 用于在浏览器和服务器之间交换数据的 websocket 库。
  • 的WebPack - 模块管理器。 您可以阅读有关为什么使用 Webpack 的信息。 这里.

项目目录结构如下:

public/
    assets/
        ...
src/
    client/
        css/
            ...
        html/
            index.html
        index.js
        ...
    server/
        server.js
        ...
    shared/
        constants.js

上市/

所有内容都在一个文件夹中 public/ 将由服务器静态提交。 在 public/assets/ 包含我们项目使用的图像。

src /

所有源代码都在文件夹中 src/... 名字 client/ и server/ 为自己说话并 shared/ 包含由客户端和服务器导入的常量文件。

2. 程序集/项目设置

如上所述,我们使用模块管理器来构建项目。 的WebPack。 让我们看一下我们的 Webpack 配置:

webpack.common.js:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    game: './src/client/index.js',
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'src/client/html/index.html',
    }),
  ],
};

这里最重要的几行是:

  • src/client/index.js 是 Javascript (JS) 客户端的入口点。 Webpack 将从此处开始并递归搜索其他导入的文件。
  • 我们的 Webpack 构建的输出 JS 将位于目录中 dist/。 我将此文件称为我们的 js包.
  • 我们用 巴贝尔,特别是配置 @babel/预设环境 为旧版浏览器转译我们的 JS 代码。
  • 我们使用一个插件来提取 JS 文件引用的所有 CSS 并将它们组合到一个地方。 我会称他为我们的 CSS包.

您可能已经注意到奇怪的包文件名 '[name].[contenthash].ext'. 他们包含 文件名替换 Webpack: [name] 将被替换为输入点的名称(在我们的例子中,这个 game)和 [contenthash] 将被替换为文件内容的哈希值。 我们这样做是为了 优化哈希项目 - 你可以告诉浏览器无限期地缓存我们的JS包,因为 如果包发生变化,那么它的文件名也会发生变化 (变化 contenthash)。 最终结果将是视图文件的名称 game.dbeee76e91a97d0c7207.js.

文件 webpack.common.js 是我们导入到开发和完成的项目配置中的基本配置文件。 这是一个开发配置示例:

webpack.dev.js

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

为了效率,我们在开发过程中使用 webpack.dev.js,并切换到 webpack.prod.js在部署到生产时优化包大小。

本地设置

我建议在本地计算机上安装该项目,以便您可以按照本文中列出的步骤进行操作。 设置很简单:首先,系统必须已安装 Node и NPM。 接下来你需要做的

$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install

现在您就可以出发了! 要启动开发服务器,只需运行

$ npm run develop

并转到网络浏览器 本地主机:3000。 开发服务器将随着代码的更改自动重建 JS 和 CSS 包 - 只需刷新页面即可查看所有更改!

3. 客户端入口点

让我们开始讨论游戏代码本身。 首先我们需要一个页面 index.html,当访问该网站时,浏览器将首先加载它。 我们的页面将非常简单:

index.html的

.io 游戏示例 玩

为了清楚起见,此代码示例已稍微简化,我将对许多其他帖子示例执行相同的操作。 完整的代码可以随时查看 Github上.

我们有:

  • HTML5 画布元素 (<canvas>)我们将用它来渲染游戏。
  • <link> 添加我们的 CSS 包。
  • <script> 添加我们的 Javascript 包。
  • 带用户名的主菜单 <input> 和“播放”按钮(<button>).

加载主页后,浏览器将开始执行Javascript代码,从入口点JS文件开始: src/client/index.js.

index.js

import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';

import './css/main.css';

const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

这听起来可能很复杂,但这里并没有发生太多事情:

  1. 导入其他几个 JS 文件。
  2. CSS 导入(因此 Webpack 知道将它们包含在我们的 CSS 包中)。
  3. 发射 connect() 与服务器建立连接并运行 downloadAssets() 下载渲染游戏所需的图像。
  4. 第3阶段完成后 显示主菜单(playMenu).
  5. 设置按下“PLAY”按钮的处理程序。 当按下按钮时,代码会初始化游戏并告诉服务器我们已经准备好开始玩了。

我们的客户端-服务器逻辑的主要“内容”位于由文件导入的那些文件中 index.js。 现在我们将按顺序考虑它们。

4. 客户数据交换

在这个游戏中,我们使用一个知名的库与服务器进行通信 套接字.io。 Socket.io 具有本机支持 WebSockets的,非常适合双向通信:我们可以向服务器发送消息 и 服务器可以在同一连接上向我们发送消息。

我们将有一个文件 src/client/networking.js谁来照顾 所有 与服务器的通信:

网络.js

import io from 'socket.io-client';
import { processGameUpdate } from './state';

const Constants = require('../shared/constants');

const socket = io(`ws://${window.location.host}`);
const connectedPromise = new Promise(resolve => {
  socket.on('connect', () => {
    console.log('Connected to server!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    // Register callbacks
    socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
    socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
  })
);

export const play = username => {
  socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};

export const updateDirection = dir => {
  socket.emit(Constants.MSG_TYPES.INPUT, dir);
};

为了清晰起见,该代码也被稍微缩短。

该文件中有三个主要操作:

  • 我们正在尝试连接到服务器。 connectedPromise 仅当我们建立连接时才允许。
  • 如果连接成功,我们注册回调函数(processGameUpdate() и onGameOver())用于我们可以从服务器接收的消息。
  • 我们出口 play() и updateDirection()以便其他文件可以使用它们。

5. 客户端渲染

是时候在屏幕上显示图片了!

…但在此之前,我们需要下载所需的所有图像(资源)。 我们来写一个资源管理器:

资产.js

const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));

function downloadAsset(assetName) {
  return new Promise(resolve => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];

资源管理并不难实施! 主要思想是存储一个对象 assets,它将文件名的键绑定到对象的值 Image。 当资源被加载时,我们将其存储在一个对象中 assets 以便将来快速访问。 何时允许下载每个单独的资源(即 所有 资源),我们允许 downloadPromise.

下载资源后就可以开始渲染了。 如前所述,要在网页上绘图,我们使用 HTML5 Canvas (<canvas>)。 我们的游戏非常简单,所以我们只需要绘制以下内容:

  1. 背景
  2. 玩家船
  3. 游戏中的其他玩家
  4. 弹药

这是重要的片段 src/client/render.js,它准确呈现上面列出的四个项目:

渲染.js

import { getAsset } from './assets';
import { getCurrentState } from './state';

const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;

// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

function render() {
  const { me, others, bullets } = getCurrentState();
  if (!me) {
    return;
  }

  // Draw background
  renderBackground(me.x, me.y);

  // Draw all bullets
  bullets.forEach(renderBullet.bind(null, me));

  // Draw all players
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Helper functions here excluded

let renderInterval = null;
export function startRendering() {
  renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
  clearInterval(renderInterval);
}

为了清晰起见,该代码也被缩短。

render() 是这个文件的主要功能。 startRendering() и stopRendering() 控制 60 FPS 渲染循环的激活。

各个渲染辅助函数的具体实现(例如 renderBullet())并不那么重要,但这里有一个简单的例子:

渲染.js

function renderBullet(me, bullet) {
  const { x, y } = bullet;
  context.drawImage(
    getAsset('bullet.svg'),
    canvas.width / 2 + x - me.x - BULLET_RADIUS,
    canvas.height / 2 + y - me.y - BULLET_RADIUS,
    BULLET_RADIUS * 2,
    BULLET_RADIUS * 2,
  );
}

请注意,我们正在使用该方法 getAsset(),之前曾在 asset.js!

如果您有兴趣了解其他渲染助手,请阅读其余部分。 src/client/render.js.

6. 客户输入

是时候做个游戏了 可玩的! 控制方案将非常简单:要改变移动方向,您可以使用鼠标(在计算机上)或触摸屏幕(在移动设备上)。 为了实现这一点,我们将注册 事件监听器 用于鼠标和触摸事件。
会照顾好这一切 src/client/input.js:

输入.js

import { updateDirection } from './networking';

function onMouseInput(e) {
  handleInput(e.clientX, e.clientY);
}

function onTouchInput(e) {
  const touch = e.touches[0];
  handleInput(touch.clientX, touch.clientY);
}

function handleInput(x, y) {
  const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
  updateDirection(dir);
}

export function startCapturingInput() {
  window.addEventListener('mousemove', onMouseInput);
  window.addEventListener('touchmove', onTouchInput);
}

export function stopCapturingInput() {
  window.removeEventListener('mousemove', onMouseInput);
  window.removeEventListener('touchmove', onTouchInput);
}

onMouseInput() и onTouchInput() 是调用的事件监听器 updateDirection() (的 networking.js)当输入事件发生时(例如,当鼠标移动时)。 updateDirection() 处理与服务器的消息传递,服务器处理输入事件并相应地更新游戏状态。

7. 客户状态

这一部分是本文第一部分中最困难的部分。 如果您第一次阅读时不明白,请不要灰心! 您甚至可以跳过它并稍后再返回。

完成客户端/服务器代码所需的最后一块拼图是 。 还记得客户端渲染部分的代码片段吗?

渲染.js

import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Do the rendering
  // ...
}

getCurrentState() 应该能够为我们提供客户端中游戏的当前状态 在任何时间点 基于从服务器收到的更新。 以下是服务器可以发送的游戏更新的示例:

{
  "t": 1555960373725,
  "me": {
    "x": 2213.8050880413657,
    "y": 1469.370893425012,
    "direction": 1.3082443894581433,
    "id": "AhzgAtklgo2FJvwWAADO",
    "hp": 100
  },
  "others": [],
  "bullets": [
    {
      "id": "RUJfJ8Y18n",
      "x": 2354.029197099604,
      "y": 1431.6848318262666
    },
    {
      "id": "ctg5rht5s",
      "x": 2260.546457727445,
      "y": 1456.8088728920968
    }
  ],
  "leaderboard": [
    {
      "username": "Player",
      "score": 3
    }
  ]
}

每个游戏更新包含五个相同的字段:

  • t:指示此更新创建时间的服务器时间戳。
  • me:有关接收此更新的玩家的信息。
  • 其他类:有关参与同一游戏的其他玩家的一系列信息。
  • 子弹:有关游戏中射弹的一系列信息。
  • 排行榜:当前排行榜数据。 在这篇文章中,我们不会考虑它们。

7.1 原始客户端状态

简单的实现 getCurrentState() 只能直接返回最近收到的游戏更新的数据。

naive-state.js

let lastGameUpdate = null;

// Handle a newly received game update.
export function processGameUpdate(update) {
  lastGameUpdate = update;
}

export function getCurrentState() {
  return lastGameUpdate;
}

又好又清晰! 但如果事情就是这么简单就好了。 这种实现存在问题的原因之一是: 它将渲染帧速率限制为服务器时钟速率.

帧率:帧数(即调用 render())每秒,或 FPS。 游戏通常力争达到至少 60 FPS。

报价变动率:服务器向客户端发送游戏更新的频率。 它通常低于帧速率。 在我们的游戏中,服务器以每秒 30 个周期的频率运行。

如果我们只渲染游戏的最新更新,那么 FPS 基本上永远不会超过 30,因为 我们每秒从服务器获得的更新不会超过 30 次。 即使我们打电话 render() 每秒 60 次,那么这些调用中有一半只会重绘相同的内容,基本上什么也不做。 天真的实现的另一个问题是它 容易出现延误。 在理想的互联网速度下,客户端将每 33 毫秒收到一次游戏更新(每秒 30 次):

创建多人 .io 网页游戏
不幸的是,没有什么是完美的。 更现实的情况是:
创建多人 .io 网页游戏
就延迟而言,幼稚的实现实际上是最糟糕的情况。 如果延迟 50 毫秒收到游戏更新,则 客户摊位 额外的 50 毫秒,因为它仍在渲染上次更新的游戏状态。 你可以想象这对于玩家来说是多么不舒服:任意刹车会让游戏感觉生涩和不稳定。

7.2 改进的客户端状态

我们将对幼稚的实现进行一些改进。 首先,我们使用 渲染延迟 100 毫秒。 这意味着客户端的“当前”状态将始终落后于服务器上的游戏状态 100 毫秒。 例如,如果服务器上的时间是 150,那么客户端就会渲染出服务器当时的状态 50:

创建多人 .io 网页游戏
这为我们提供了 100 毫秒的缓冲区来应对不可预测的游戏更新时间:

创建多人 .io 网页游戏
这样做的回报将是永久的 输入滞后 100 毫秒。 对于流畅的游戏体验来说,这是一个小小的牺牲——大多数玩家(尤其是休闲玩家)甚至不会注意到这种延迟。 对于人们来说,适应恒定的 100 毫秒延迟比适应不可预测的延迟要容易得多。

我们还可以使用另一种技术,称为 客户端预测,它在减少感知延迟方面做得很好,但本文不会讨论。

我们正在使用的另一个改进是 线性插值。 由于渲染延迟,我们通常会比客户端中的当前时间提前至少一次更新。 被叫时 getCurrentState(),我们可以执行 线性插值 客户端当前时间之前和之后的游戏更新之间:

创建多人 .io 网页游戏
这解决了帧速率问题:我们现在可以以我们想要的任何帧速率渲染独特的帧!

7.3 实现增强的客户端状态

实现示例在 src/client/state.js 同时使用渲染延迟和线性插值,但时间不会太长。 让我们将代码分成两部分。 这是第一个:

状态.js 第 1 部分

const RENDER_DELAY = 100;

const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;

export function initState() {
  gameStart = 0;
  firstServerTimestamp = 0;
}

export function processGameUpdate(update) {
  if (!firstServerTimestamp) {
    firstServerTimestamp = update.t;
    gameStart = Date.now();
  }
  gameUpdates.push(update);

  // Keep only one game update before the current server time
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
  const serverTime = currentServerTime();
  for (let i = gameUpdates.length - 1; i >= 0; i--) {
    if (gameUpdates[i].t <= serverTime) {
      return i;
    }
  }
  return -1;
}

第一步是弄清楚什么 currentServerTime()。 正如我们之前看到的,每个游戏更新都包含服务器时间戳。 我们希望使用渲染延迟来延迟服务器 100 毫秒渲染图像,但是 我们永远不会知道服务器上的当前时间,因为我们不知道任何更新需要多长时间才能到达我们手中。 互联网是不可预测的,其速度差异很大!

为了解决这个问题,我们可以使用一个合理的近似: 假装第一个更新立即到达。 如果这是真的,那么我们就会知道这个特定时刻的服务器时间! 我们将服务器的时间戳存储在 firstServerTimestamp 并保留我们的 当地 (客户端)同一时刻的时间戳 gameStart.

等一下。 不应该是服务器时间=客户端时间吗? 为什么要区分“服务器时间戳”和“客户端时间戳”? 这是一个很好的问题! 事实证明它们不是同一件事。 Date.now() 将在客户端和服务器中返回不同的时间戳,这取决于这些机器的本地因素。 永远不要假设所有机器上的时间戳都是相同的。

现在我们明白了什么是 currentServerTime(): 它返回 当前渲染时间的服务器时间戳。 换句话说,这是服务器的当前时间(firstServerTimestamp <+ (Date.now() - gameStart)) 减去渲染延迟 (RENDER_DELAY).

现在让我们看看我们如何处理游戏更新。 当从更新服务器收到时,它被称为 processGameUpdate()然后我们将新的更新保存到数组中 gameUpdates。 然后,为了检查内存使用情况,我们删除之前的所有旧更新 基础更新因为我们不再需要它们了。

什么是“基本更新”? 这 我们通过从服务器当前时间向后移动找到的第一个更新。 还记得这张图吗?

创建多人 .io 网页游戏
“客户端渲染时间”左侧的游戏更新是基础更新。

基础更新有什么用? 为什么我们可以放弃对基线的更新? 为了弄清楚这一点,让我们 最后 考虑实施 getCurrentState():

状态.js 第 2 部分

export function getCurrentState() {
  if (!firstServerTimestamp) {
    return {};
  }

  const base = getBaseUpdate();
  const serverTime = currentServerTime();

  // If base is the most recent update we have, use its state.
  // Else, interpolate between its state and the state of (base + 1).
  if (base < 0) {
    return gameUpdates[gameUpdates.length - 1];
  } else if (base === gameUpdates.length - 1) {
    return gameUpdates[base];
  } else {
    const baseUpdate = gameUpdates[base];
    const next = gameUpdates[base + 1];
    const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
    return {
      me: interpolateObject(baseUpdate.me, next.me, r),
      others: interpolateObjectArray(baseUpdate.others, next.others, r),
      bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
    };
  }
}

我们处理三种情况:

  1. base < 0 意味着在当前渲染时间之前没有更新(参见上面的实现 getBaseUpdate())。 由于渲染延迟,这种情况可能会在游戏开始时发生。 在这种情况下,我们使用收到的最新更新。
  2. base 是我们的最新更新。 这可能是由于网络延迟或互联网连接不良造成的。 在这种情况下,我们也使用我们拥有的最新更新。
  3. 我们在当前渲染时间之前和之后都有更新,因此我们可以 !

剩下的一切都在 state.js 是线性插值的实现,是简单(但无聊)的数学。 如果您想亲自探索,请打开 state.jsGithub上.

第 2 部分. 后端服务器

在这一部分中,我们将看一下控制我们的 Node.js 后端 .io游戏示例.

1. 服务器入口点

为了管理 Web 服务器,我们将使用一个名为 Node.js 的流行 Web 框架 快捷配送。 它将由我们的服务器入口点文件配置 src/server/server.js:

服务器.js 第 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}`);

还记得我们在第一部分中讨论过 Webpack 吗? 这是我们将使用 Webpack 配置的地方。 我们将以两种方式使用它们:

  • 使用 webpack-dev-中间件 自动重建我们的开发包,或者
  • 静态传输文件夹 dist/,Webpack 将在生产构建后将我们的文件写入其中。

另一项重要任务 server.js 就是设置服务器 套接字.io它只连接到 Express 服务器:

服务器.js 第 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 连接后,我们为新套接字设置事件处理程序。 事件处理程序通过委托给单例对象来处理从客户端接收到的消息 game:

服务器.js 第 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 (“游戏”)- 所有玩家都在同一个竞技场中玩! 在下一节中,我们将看到这个类是如何工作的。 Game.

2. 游戏服务器

Game 包含服务器端最重要的逻辑。 它有两个主要任务: 球员管理 и 游戏模拟.

让我们从第一个任务开始,玩家管理。

游戏.js 第 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);
    }
  }

  // ...
}

在本场比赛中,我们将通过场地来识别球员 id 他们的 socket.io 套接字(如果您感到困惑,请返回 server.js)。 Socket.io 本身为每个套接字分配了一个唯一的 id所以我们不需要担心这个。 我会打电话给他 玩家ID.

考虑到这一点,让我们探索类中的实例变量 Game:

  • sockets 是将玩家 ID 绑定到与玩家关联的套接字的对象。 它允许我们在恒定时间内通过玩家 ID 访问套接字。
  • players 是一个将玩家ID绑定到代码>Player对象的对象

bullets 是一个对象数组 Bullet,没有明确的顺序。
lastUpdateTime 是游戏上次更新的时间戳。 我们很快就会看到它是如何使用的。
shouldSendUpdate 是一个辅助变量。 我们很快也会看到它的使用。
方法 addPlayer(), removePlayer() и handleInput() 无需解释,它们用于 server.js。 如果您需要刷新记忆,请返回更高一点。

最后一行 constructor() 启动 更新周期 游戏(更新频率为60次/秒):

游戏.js 第 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. 计算多长时间 dt 自上次以来已过去 update().
  2. 刷新每个射弹并在必要时销毁它们。 稍后我们将看到此功能的实现。 目前,我们只要知道这一点就足够了 bullet.update() 回报 true如果射弹应该被销毁 (他走出了竞技场)。
  3. 更新每个玩家并在必要时生成射弹。 稍后我们还将看到这个实现 - player.update() 可以返回一个对象 Bullet.
  4. 检查射弹和玩家之间的碰撞 applyCollisions(),它返回击中玩家的射弹数组。 对于每个返回的射弹,我们都会增加发射它的玩家的分数(使用 player.onDealtDamage())然后从数组中移除射弹 bullets.
  5. 通知并消灭所有被杀死的玩家。
  6. 向所有玩家发送游戏更新 每一秒 被叫时的次数 update()。 这有助于我们跟踪上面提到的辅助变量。 shouldSendUpdate... 作为 update() 称为60次/秒,我们发送游戏更新30次/秒。 因此, 时钟频率 服务器时钟为 30 个时钟/秒(我们在第一部分中讨论了时钟速率)。

为什么只发送游戏更新 通过时间 ? 保存频道。 每秒 30 次游戏更新已经很多了!

为什么不直接打电话 update() 每秒30次? 提高游戏模拟性。 更常被称为 update(),游戏模拟越准确。 但不要因挑战的数量而过于得意忘形。 update(),因为这是一项计算成本较高的任务 - 每秒 60 次就足够了。

班级的其余部分 Game 由使用的辅助方法组成 update():

游戏.js 第 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() 非常简单 - 它按分数对玩家进行排序,取前五名,然后返回每个玩家的用户名和分数。

createUpdate() 用于 update() 创建分发给玩家的游戏更新。 它的主要任务是调用方法 serializeForUpdate()为班级实施 Player и Bullet。 请注意,它仅将数据传递给每个玩家 最近的 玩家和投射物 - 无需传输远离玩家的游戏对象的信息!

3. 服务器上的游戏对象

在我们的游戏中,弹丸和玩家实际上非常相似:它们都是抽象的、圆形的、可移动的游戏对象。 为了利用玩家和射弹之间的这种相似性,让我们从实现基类开始 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:

子弹.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 仅以下扩展:

  • 使用包 短ID 用于随机生成 id 弹丸。
  • 添加字段 parentID这样您就可以追踪创建此射弹的玩家。
  • 添加返回值 update(),等于 true如果射弹在竞技场之外(还记得我们在上一节中讨论过这一点吗?)。

让我们继续 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. 碰撞检测

我们唯一要做的就是识别射弹何时击中玩家! 记住方法中的这段代码 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(),它返回击中玩家的所有射弹。 幸运的是,这并不难做到,因为

  • 所有碰撞物体都是圆形,这是实现碰撞检测的最简单的形状。
  • 我们已经有一个方法了 distanceTo(),我们在课堂上一节中实现了 Object.

这是我们的碰撞检测实现的样子:

碰撞.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;
}

这个简单的碰撞检测基于以下事实: 如果两个圆的圆心之间的距离小于它们的半径之和,则两个圆发生碰撞。 这是两个圆心之间的距离恰好等于它们的半径之和的情况:

创建多人 .io 网页游戏
这里还有几个方面需要考虑:

  • 射弹不得击中创造它的玩家。 这可以通过比较来实现 bullet.parentID с player.id.
  • 在多个玩家同时碰撞的极限情况下,射弹只能击中一次。 我们将使用运算符来解决这个问题 break:一旦发现与弹丸碰撞的玩家,我们就停止搜索并继续处理下一个弹丸。

结束

就这样! 我们已经介绍了创建 .io 网页游戏所需了解的所有内容。 下一步是什么? 构建您自己的 .io 游戏!

所有示例代码都是开源的并发布在 Github上.

来源: habr.com

添加评论