Создание многопользовательской веб-игры в жанре .io

в 9:49, , рубрики: javascript, multiplayer, node.js, webpack, игра на javascript, разработка игр, Сетевые технологии
image

Вышедшая в 2015 году Agar.io стала прародителем нового жанра игр .io, популярность которого с тех пор сильно возросла. Рост популярности игр .io я испытал на себе: за последние три года я создал и продал две игры этого жанра..

На случай, если вы никогда раньше не слышали о таких играх: это бесплатные многопользовательские веб-игры, в которых легко участвовать (не требуется учётная запись). Обычно они сталкивают на одной арене множество противоборствующих игроков. Другие знаменитые игры жанра .io: Slither.io и Diep.io.

В этом посте мы будем разбираться, как с нуля создать игру .io. Для этого достаточно будет только знания Javascript: вам нужно понимать такие вещи, как синтаксис ES6, ключевое слово this и Promises. Даже если вы знаете Javascript не в совершенстве, то всё равно сможете разобраться в большей части поста.

Пример игры .io

Для помощи в обучении мы будем ссылаться на пример игры .io. Попробуйте в сыграть в неё!

Создание многопользовательской веб-игры в жанре .io - 2

Игра довольно проста: вы управляете кораблём на арене, где есть другие игроки. Ваш корабль автоматически стреляет снарядами и вы пытаетесь попасть в других игроков, в то же время избегая их снарядов.

1. Краткий обзор/структура проекта

Рекомендую скачать исходный код примера игры, чтобы вы могли следовать за мной.

В примере используется следующее:

  • Express — самый популярный веб-фреймворк для Node.js, управляющий веб-сервером игры.
  • socket.io — библиотека websocket для обмена данными между браузером и сервером.
  • Webpack — менеджер модулей. О том, зачем использовать Webpack, можно прочитать здесь.

Вот как выглядит структура каталога проекта:

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

public/

Всё в папке 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 будет начинать отсюда и станет рекурсивно искать другие импортированные файлы.
  • Выходной JS нашей сборки Webpack будет располагаться в каталоге dist/. Я буду называть этот файл нашим пакетом JS.
  • Мы используем Babel, и в частности конфигурацию @babel/preset-env для транспиляции (transpiling) нашего кода JS для старых браузеров.
  • Мы используем плагин для извлечения всех CSS, на которые ссылаются файлы JS, и для объединения их в одном месте. Я буду называть его нашим пакетом 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

и зайти в веб-браузере на localhost:3000. Сервер разработки будет автоматически пересобирать заново пакеты JS и CSS в процессе изменения кода — просто обновите страницу, чтобы увидеть все изменения!

3. Входные точки клиента

Давайте приступим к самому коду игры. Для начала нам потребуется страница index.html, при посещении сайта браузер будет загружать её первой. Наша страница будет довольно простой:

index.html

<!DOCTYPE html>
<html>
<head>
  <title>An example .io game</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Username" />
    <button id="play-button">PLAY</button>
  </div>
</body>
</html>

Этот пример кода слегка упрощён для понятности, то же самое я сделаю и со многими другими примерами поста. Полный код всегда можно посмотреть на Github.

У нас есть:

  • Элемент HTML5 Canvas (<canvas>), который мы будем использовать для рендеринга игры.
  • <link> для добавления нашего пакета CSS.
  • <script> для добавления нашего пакета Javascript.
  • Главное меню с именем пользователя <input> и кнопкой «PLAY» (<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. Обмен данными клиента

В этой игре для общения с сервером мы используем хорошо известную библиотеку socket.io. В Socket.io есть встроенная поддержка WebSockets, которые хорошо подходят для двусторонней коммуникации: мы можем отправлять сообщения серверу и сервер может отправлять сообщения нам по тому же соединению.

У нас будет один файл src/client/networking.js, который займётся всеми коммуникациями с сервером:

networking.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 разрешается только тогда, когда мы установили соединение.
  • Если соединение успешно установлено, мы регистрируем callback-функции (processGameUpdate() и onGameOver()) для сообщений, которые мы можем получать от сервера.
  • Экспортируем play() и updateDirection(), чтобы их могли использовать другие файлы.

5. Рендеринг клиента

Настало время отобразить на экране картинку!

…но прежде чем мы сможем это сделать, нужно скачать все изображения (ресурсы), которые для этого необходимы. Давайте напишем менеджер ресурсов:

assets.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, которые отрисовывают именно перечисленные выше четыре пункта:

render.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()) не так важны, но вот один простой пример:

render.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. Клиентский ввод

Настало время сделать игру играбельной! Схема управления будет очень простой: для изменения направления движения можно использовать мышь (на компьютере) или касание экрана (на мобильном устройстве). Чтобы реализовать это, мы зарегистрируем Event Listeners для событий Mouse и Touch.
Всем этим займётся src/client/input.js:

input.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() — это Event Listeners, вызывающие updateDirection() (из networking.js) при совершении события ввода (например, при перемещении мыши). updateDirection() занимается обменом сообщениями с сервером, который обрабатывает событие ввода и соответствующим образом обновляет состояние игры.

7. Состояние клиента

Этот раздел — самый сложный в первой части поста. Не расстраивайтесь, если не поймёте его с первого прочтения! Можете даже пропустить его и вернуться к нему позже.

Последний кусок пазла, который нужен для завершения клиент-серверного кода — это state. Помните фрагмент кода из раздела «Рендеринг клиента»?

render.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: информация об игроке, получающего это обновление.
  • others: массив информации о других игроках, участвующих в той же игре.
  • bullets: массив информации о снарядах в игре.
  • leaderboard: текущие данные таблицы лидеров. В этом посте мы их учитывать не будем.

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

Красиво и понятно! Но если бы всё было так просто. Одна из причин, по которым такая реализация проблематична: она ограничивает частоту кадров рендеринга частотой тактов сервера.

Частота кадров (Frame Rate): количество кадров (т.е. вызовов render()) в секунду, или FPS. В играх обычно стремятся достичь не менее 60 FPS.

Частота тактов (Tick Rate): частота, с которой сервер отправляет обновления игры клиентам. Часто она ниже, чем частота кадров. В нашей игре сервер работает с частотой 30 тактов в секунду.

Если мы просто будем рендерить последнее обновление игры, то FPS по сути никогда не сможет превысить 30, потому что мы никогда не получаем от сервера больше 30 обновлений в секунду. Даже если мы будем вызывать render() 60 раз в секунду, то половина этих вызовов будет просто перерисовывать то же самое, по сути не делая ничего. Ещё одна проблема наивной реализации заключается в том, что она подвержена задержкам. При идеальной скорости Интернета клиент будет получать обновление игры ровно через каждые 33 мс (30 в секунду):

Создание многопользовательской веб-игры в жанре .io - 3

К сожалению, ничто не идеально. Более реалистичной будет такая картина:

Создание многопользовательской веб-игры в жанре .io - 4

Наивная реализация — это практически наихудший случай, когда дело доходит до задержек. Если обновление игры принимается с задержкой 50 мс, то клиент затормаживается на лишние 50 мс, потому что он по-прежнему рендерит состояние игры из предыдущего обновления. Можете представить, насколько это неудобно для игрока: из-за произвольных торможений игра будет казаться дёрганной и нестабильной.

7.2 Улучшенное состояние клиента

Мы внесём в наивную реализацию некоторые улучшения. Во-первых, мы используем задержку рендеринга на 100 мс. Это означает, что «текущее» состояние клиента всегда будет отставать от состояния игры на сервере на 100 мс. Например, если на сервере время равно 150, то на клиенте будет рендериться состояние, в котором был сервер во время 50:

Создание многопользовательской веб-игры в жанре .io - 5

Это даёт нам буфер в 100 мс, позволяющий пережить непредсказуемое время получения обновлений игры:

Создание многопользовательской веб-игры в жанре .io - 6

Расплатой за это будет постоянная задержка ввода (input lag) на 100 мс. Это незначительная жертва за плавный игровой процесс — большинство игроков (особенно казуальных) даже не заметит этой задержки. Людям гораздо проще приспособиться к постоянной задержке в 100 мс, чем играть с непредсказуемой задержкой.

Мы можем использовать и другую технику под названием «прогнозирование на стороне клиента», которая хорошо справляется со снижением воспринимаемых задержек, но в этом посте она рассматриваться не будет.

Ещё одно улучшение, которое мы используем — это линейная интерполяция. Из-за задержки рендеринга мы обычно как минимум на одно обновление обгоняем текущее время в клиенте. Когда вызывается getCurrentState(), мы можем выполнить линейную интерполяцию между обновлениями игры непосредственно перед и после текущим временем в клиенте:

Создание многопользовательской веб-игры в жанре .io - 7

Это решает проблему с частотой кадров: теперь мы можем рендерить уникальные кадры с любой нужной нам частотой!

7.3 Реализация улучшенного состояния клиента

Пример реализации в src/client/state.js использует и задержку рендеринга, и линейную интерполяцию, но это ненадолго. Давайте разобьём код на две части. Вот первая:

state.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 - 8

Обновление игры непосредственно слева от «Client Render Time» и является базовым обновлением.

Для чего используется базовое обновление? Почему мы можем отбрасывать обновления до базового? Чтобы разобраться в этом, давайте наконец-то рассмотрим реализацию getCurrentState():

state.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.js на Github.

Часть 2. Бэкенд-сервер

В этой части мы рассмотрим бэкенд Node.js, управляющий нашим примером игры .io.

1. Входная точка сервера

Для управления веб-сервером мы будем использовать популярный веб-фреймворк для Node.js под названием Express. Его настройкой займётся наш файл входной точки сервера src/server/server.js:

server.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-middleware для автоматической пересборки наших пакетов разработки, или
  • Статически передавать папку dist/, в которую Webpack будет записывать наши файлы после сборки продакшена.

Ещё одна важная задача server.js заключается в настройке сервера socket.io, который просто подключается к серверу Express:

server.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:

server.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») – все игроки играют на одной арене! В следующем разделе мы посмотрим, как работает этот класс Game.

2. Game сервера

Класс Game содержит самую важную логику на стороне сервера. Он имеет две основные задачи: управление игроками и симуляция игры.

Давайте начнём с первой задачи – с управления игроками.

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 игрока к объекту code>Player

bullets — это массив объектов Bullet, не имеющий определённого порядка.
lastUpdateTime — это метка времени момента последнего обновления игры. Вскоре мы увидим, как она используется.
shouldSendUpdate — это вспомогательная переменная. Её использование мы тоже увидим вскоре.
Методы addPlayer(), removePlayer() и handleInput() объяснять не нужно, они используются в server.js. Если вам нужно освежить память, вернитесь немного выше.

Последняя строка constructor() запускает цикл обновления игры (с частотой 60 обновлений/с):

game.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():

game.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:

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. Распознавание коллизий

Единственное, что нам осталось – распознавать, когда снаряды попадают в игроков! Вспомните этот фрагмент кода из метода update() в классе Game:

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.

Вот как выглядит наша реализация распознавания коллизий:

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

Это простое распознавание коллизий основано на том факте, что два круга сталкиваются, если расстояние между их центрами меньше суммы их радиусов. Вот случай, когда расстояние между центрами двух кругов точно равно сумме их радиусов:

Создание многопользовательской веб-игры в жанре .io - 9

Здесь нужно внимательно отнестись ещё к паре аспектов:

  • Снаряд не должен попадать в создавшего его игрока. Этого можно достичь, сравнивая bullet.parentID с player.id.
  • Снаряд должен попадать только один раз в предельном случае одновременного столкновения с несколькими игроками. Эту задачу мы решим с помощью оператора break: как только найден игрок, столкнувшийся со снарядом, мы прекращаем поиск и переходим к следующему снаряду.

Конец

Вот и всё! Мы рассмотрели всё, что необходимо знать для создания веб-игры жанра .io. Что дальше? Соберите собственную игру .io!

Весь код примера имеет открытые исходники и выложен на Github.

Автор: PatientZero

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js