Agar.io или как создать браузерную мультиплеер io-игру

Agar.io или как создать браузерную мультиплеер io-игру

18 марта, 202112 минут на чтение
Подписывайтесь на [Код // Дурова] в Telegram. У нас уже 190 000 подписчиков[Код // Дурова] в Telegram

Игра Agar.io вышла в 2015 году. Она произвела такое впечатление на игровую индустрию, что впоследствии появился отдельный жанр под названием «io игры».

Это бесплатные мультиплеер-игры к которым легко подключиться, при этом регистрация не нужна. Как правило, в таких играх есть большая арена, где все игроки соперничают друг с другом. Помимо Agar.io в этом жанре также очень популярны Slither.io и Diep.io. Кроме того, можете попробовать игру от читателя «Кода Дурова» по этой ссылке.

Статья является переводом с английского с этого источника. В ней рассказывается, как создать io игру с нуля. Всё, что нужно для этого — знать JavaScript, а именно практические навыки ES6, понимание как использовать «this» и промисы. И даже если вы не знакомы с JavaScript, большую часть статьи сможете понять.

1. Обзор проекта и его структуры

Для наглядного примера лучше скачать исходный код игры.

Наш проект будет использовать:

  • Express.js — самый популярный веб фреймворк для Node.js
  • Socket.io — WebSocket-библиотека для настройки коммуникации между сервером и клиентом
  • Webpack модуль
Вот как будет выглядеть структура проекта

/public

Всё, что находится в этой папке, будет статически обрабатываться нашим сервером. А в папку /public/assets необходимо размещать картинки нашего проекта.

src/

Весь  исходный код хранится в папке src/

  • client/ — для хранения файлов клиента,
  • server/ — для хранения файлов сервера,
  • shared/ — содержит файлы, которые импортированы из клиента и сервера.

2. Настройка проекта, настройка инструментов для разработки

Как говорилось ранее, мы будем использовать 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',
    }),
  ],
};

Нас прежде всего интересуют строки:


   game: './src/client/index.js',

src/client/index.js — это входная точка у Javascript. Webpack начнёт свою работу оттуда и далее рекурсивно будет просматривать остальные файлы, что были указаны в файле.

   filename: '[name].[contenthash].js',
   path: path.resolve(__dirname, 'dist'),

JS точка выхода нашего Webpack билда будет расположена в папке /dist. Я буду называть этот файл JS bundle.

           presets: ['@babel/preset-env'],

Мы используем Babel, а именно @babel/preset-env конфиг, для конфигурации нашего JS кода под старые браузеры.

      filename: 'index.html',

Мы используем плагин для извлечения CSS на который ссылается наш JS код и объединяем это все вместе в bundle. Я буду называть это CSS bundle.

Вы могли заметить странные '[name].[contenthash].ext' названия bundle файлов. Они включают в себя Webpack названия, в поле [name] будет подставлено имя точки входа — game, а contenthash заменяется хешем файла. Мы делаем это для оптимизации кеширования, чтобы позволить браузерам кешировать наши файлы вечно, потому что, если bundle меняется, то меняется имя файла тоже (contenthash меняется). В финальном результате файл будет выглядеть вот так: game.dbee4534d3r4345n.js.

Файл webpack.common.js — это базовый конфиг, который мы импортируем для наших development/production конфигураций. Вот пример development конфигурации:

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 для оптимизации размера бандлов, когда запускаем в production.

Локальная установка

Рекомендуется устанавливать проект на локальном компьютере, это довольно просто. Для этого нужно установить Node и NPM, а после выполнить эти команды:

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

И все, теперь можно начинать. Чтобы запустить development server, надо выполнить:

$ npm run develop

Затем следует перейти в браузере по адресу localhost:3000, после чего development server автоматически пересоздаст JS и CSS бандлы во время редактирования кода: чтобы увидеть изменения, необходимо будет просто обновить страницу.

3. Точки входа к клиенту. index.html и index.js

Теперь давайте перейдём к коду. Чтобы начать, нужно создать 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>

У нас есть:

  • HTML5 Canvas (<canvas>), который используется для рендеринга игры,
  • <link> для подключения CSS bundle,
  • <script> для подключения Javascript bundle,
  • Главное меню с Username <input> и кнопкой “PLAY” <button>.

После того, как главная страница загрузится в браузере, начнёт выполняться JavaScript код, начиная с точки входа 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 кода,
  3. Запуск connect() для установки соединения с сервером и запуск downloadAssets() для загрузки нужных картинок, чтобы отрендерить игру,
  4. Как только 3й пункт будет выполнен, выводится на экран главное меню (playMenu)
  5. Настройка обработчика кнопки Play. При нажатии на неё, идёт инициализация и сервер принимает сообщение что клиент готов к игре.

Основная логика клиента будет распределена в файлах, которые импортируются с помощью index.js.

4. Коммуникация клиента с сервером

Для этой задачи используем всем известную библиотеку 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);
};

В этом файле происходит 3 основных момента:

  • connectedPromise подключается к серверу, как только будет установлено соединение;
  • При успешном подключении регистрируются коллбеки (processGameUpdate() и onGameOver()) для получения сообщений с сервера;
  • Происходит экспорт play() и updateDirection() для использования в других файлах.

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

Теперь необходимо настроить проект на вывод на экран. Для этого нужно загрузить все assets, поэтому надо написать сначала Assets manager.

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.

Для рендеринга используется HTML 5 Canvas (<canvas>), который отрисует страницу. Так как игра довольно простая, то достаточно будет выполнить следующие действия:

  • Фон,
  • Корабль игрока,
  • Другие игроки в игре,
  • Пули.

Самые важные части файла 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 для мышки и тачскрина.

нужные функции будет содержать файл 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() — это обработчики событий, которые вызывают функцию updateDirection() (из network.js), когда происходит определённое событие, например, движется мышь.

updateDirection() — обеспечит общение с сервером, который обрабатывает событие ввода и соответствующим образом обновляет игру.

7. Статус клиента: обработка информации с сервера

Последняя часть, которую надо выполнить на клиенте — это состояние. Помните этот код из раздела рендеринга?

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

Этот код выглядит просто и лаконично, но не всё так просто. Одна из причин, по которой эта реализация проблематична, заключается в том, что она ограничивает частоту кадров рендеринга до тактовой частоты сервера.

Частота кадров — количество кадров (например, вызовов render()) в секунду или FPS. Игры обычно нацелены не менее чем на 60 кадров в секунду.

Тактовая частота сервера — скорость, с которой сервер отправляет обновления игры клиентам. Часто это ниже частоты кадров. В нашей игре сервер работает со скоростью 30 обновлений в секунду.

Если мы просто будем рендерить последние обновления сервера, наш FPS не сможет превысить 30 кадров в секунду, потому что мы никогда не будем получать с сервера более 30 обновлений в секунду.

Даже если мы вызовем render() 60 раз в секунду, половина этих вызовов просто перерисует то же самое, фактически ничего не делая.

Еще одна проблема с наивной реализацией заключается в том, что она подвержена задержкам. В идеальных условиях интернета клиент будет получать обновление игры ровно каждые 33 миллисекунды (30 в секунду).

Вот идеальная схема для обновлений.

Но, к сожалению, в реальности обновления выглядят вот так.

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

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

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

Это дает нам буфер 100 мс, чтобы выдерживать непредсказуемые события.

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

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

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

7.3 Имплементация улучшенного состояния клиента

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

State.js

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. Затем, чтобы контролировать использование памяти, удаляются все старые обновления, сделанные до базового обновления, поскольку они больше не нужны.

Что именно представляет собой базовое обновление? Это первое обновление, которое находится перед текущим серверным временем.

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

State.js

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

Здесь обрабатываются 3 события:

  • base <0 означает отсутствие обновлений до текущего времени рендеринга (см. реализацию getBaseUpdate() выше). Это может произойти в самом начале игры из-за задержки рендеринга. В этом случае используем самое последнее обновление, которое у нас есть.
  • base — это самое последнее обновление, которое у нас есть. Это может произойти из-за задержки или плохого подключения к Интернету. В этом случае также используем самое последнее обновление, которое у нас есть.
  • У нас есть обновление как до, так и после текущего времени рендеринга, поэтому мы можем интерполировать.

Все, что осталось в state.js, — это реализация линейной интерполяции, которая представляет собой чистую математику. Если хотите убедиться в этом сами, посмотрите state.js на Github.

18 марта, 2021
Подписывайтесь на [Код // Дурова] в Telegram. У нас уже 190 000 подписчиков[Код // Дурова] в Telegram
Комментарии