Създаване на мултиплейър уеб игра в жанра .io

Създаване на мултиплейър уеб игра в жанра .io
Пусната през 2015 година Agar.io стана прародител на нов жанр игри .io, популярността на който оттогава силно нарасна. Ръстът на популярността на игрите .io го изпитах на собствена кожа: през последните три години аз създадох и продадох две игри от този жанр..

В случай, че никога преди не сте чували за такива игри: това са безплатни мултиплейър уеб игри, в които е лесно да участвате (не се изисква акаунт). Обикновено те събират много съперничещи си играчи на една арена. Други известни игри от жанра .io: Slither.io и Diep.io.

В този пост ще разгледаме как от нулата да създадете игра .io. За това е достатъчно да познавате Javascript: трябва да разбирате такива неща като синтаксиса ES6, ключовата дума това и Promises. Дори и да не познавате Javascript напълно, все пак ще можете да разберете по-голямата част от поста.

Пример за игра .io

За помощ в обучението ще се позоваваме на пример на игра .io. Опитайте да я играете!

Създаване на мултиплейър уеб игра в жанра .io
Играта е доста проста: управлявате кораб на арената, където има и други играчи. Вашият кораб автоматично стреля снаряди и вие се опитвате да уцелите другите играчи, като същевременно избягвате техните снаряди.

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

Локальна настройка

Рекомендую встановити проект на локальній машині, щоб ви могли слідувати за етапами, зазначеними в цьому дописі. Налаштування просте: по-перше, в системі мають бути встановлені Узел и 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>Пример за .io игра</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="Потребителско име" />
    <button id="play-button">ИГРАЙ</button>
  </div>
</body>
</html>

Този пример код е леко опростен за яснота, същото ще направя и с много други примери в публикацията. Пълният код винаги може да бъде видян на Github.

Имаме:

  • HTML5 Canvas елемент (<canvas>), който ще използваме за рендериране на играта.
  • <link> за добавяне на нашия CSS пакет.
  • <script> за добавяне на нашия JavaScript пакет.
  • Основно меню с име на потребителя <input> и бутон „PLAY“ (

След зареждане на домашната страница в браузъра ще започне да се изпълнява 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(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('Свързано с сървъра!');
    resolve();
  });
});

export const connect = onGameOver => (
  connectedPromise.then(() => {
    \/\/ Регистрираме callback функции
    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(`Изтеглено ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `\/assets\/${assetName}`;
  });
}

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

Не е толкова сложно да се управляват ресурсите! Основната идея е да се съхранява обект assets, който свързва ключа на името на файла с стойността на обекта Снимка. Когато ресурсът е изтеглен, го запазваме в обекта 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;

// Вземете графичния контекст на канваса
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');

// Направете канваса на цял екран
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

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

  // Нанесете фона
  renderBackground(me.x, me.y);

  // Нанесете всички проектилите
  bullets.forEach(renderBullet.bind(null, me));

  // Нанесете всички играчи
  renderPlayer(me, me);
  others.forEach(renderPlayer.bind(null, me));
}

// ... Помощни функции тук са пропуснати

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. Вход от клиента

Време е да направим играта играбелна! Схемата на управлението ще бъде много проста: за промяна на посоката на движение може да се използва мишката (на компютър) или докосване на екрана (на мобилно устройство). За да го реализираме, ще регистрираме Събитийни слушатели за събития 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() — това са събитийни слушатели, които извикват updateDirection() (от networking.js) при задействане на входно събитие (например, при движение на мишката). updateDirection() осъществява комуникация със сървър, който обработва събитието на входа и съответно актуализира състоянието на играта.

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

Тази част е най-сложната в първата част на поста. Не се разстройвайте, ако не я разберете от първия път! Можете дори да я пропуснете и да се върнете към нея по-късно.

Последният парче от пъзела, необходимо за завършване на клиент-сървърния код, е state. Помните ли фрагмента от код в раздела „Рендеринг на клиента“?

render.js

import { getCurrentState } from './state';

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

  // Извършваме рендеринг
  // ...
}

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;

// Обработваме новополучена актуализация на играта.
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
За съжаление, нищо не е идеално. По-реалистичната картина би била следната:
Създаване на мултиплейър уеб игра в жанра .io
Наивната реализация е почти най-лошият случай, когато става въпрос за забавяния. Ако обновлението на играта се приема със забавяне от 50 мс, то клиентът ще се забави с допълнителни 50 мс, защото все още рендерира състоянието на играта от предишното обновление. Можете ли да си представите колко неудобно е за играча: заради произволни забавяния играта ще изглежда тромава и нестабилна.

7.2 Подобреното състояние на клиента

Ще направим някои подобрения в наивната реализация. Първо, ще използваме рендериране със забавяне от 100 мс. Това означава, че „текущото“ състояние на клиента винаги ще изостава от състоянието на играта на сървъра с 100 мс. Например, ако времето на сървъра е 150, тогава на клиента ще се рендерира състояние, в което е бил сървъра по време на 50:

Създаване на мултиплейър уеб игра в жанра .io
Това ни дава буфер от 100 мс, позволяващ да преживеем непредсказуемото време за получаване на обновления на играта:

Създаване на мултиплейър уеб игра в жанра .io
Цената за това ще бъде постоянна забавяне на входа (input lag) от 100 мс. Това е незначителна жертва за плавен игрови процес — повечето играчи (особено казуалните) дори няма да забележат това забавяне. На хората е много по-лесно да се адаптират към постоянно забавяне от 100 мс, отколкото да играят с непредсказуемо забавяне.

Можем да използваме и друга техника, наречена „прогнозиране от страната на клиента“, която добре се справя с намаляването на възприеманите забавяния, но в този пост няма да бъде разглеждана.

Още едно подобрение, което използваме — това е линейната интерполация. Поради забавянето на рендерирането обикновено поне на едно обновление изпреварваме текущото време в клиента. Когато бъде извикан getCurrentState(), можем да извършим линейна интерполация между актуализациите на играта незабавно преди и след текущото време в клиента:

Създаване на мултиплейър уеб игра в жанра .io
Това решава проблема с честотата на кадрите: сега можем да рендерираме уникални кадри с всяка необходима честота!

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

  // Дръжте само едно актуализиране на играта преди текущото време на сървъра
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

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

// Връща индекса на базовото актуализиране, първото актуализиране на играта преди
// текущото време на сървъра или -1, ако не е налично.
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
Обновлението на играта, непосредствено отляво на "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');

// Настройка сервера Express
const app = express();
app.use(express.static('public'));

if (process.env.NODE_ENV === 'development') {
  // Настройка Webpack для разработки
  const compiler = webpack(webpackConfig);
  app.use(webpackDevMiddleware(compiler));
} else {
  // Статическая подача папки dist\/ в продакшене
  app.use(express.static('dist'));
}

// Прослушивание порта
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Сервер слушает на порту ${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');

// Настройка Express
// ...
const server = app.listen(port);
console.log(`Сервер слушает на порту ${port}`);

// Настройка socket.io
const io = socketio(server);

// Прослушивание подключений socket.io
io.on('connection', socket => {
  console.log('Игрок подключен!', 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');

// ...

// Настройка игры
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() {
    \/\/ Изчислява времето, което е преминало
    const now = Date.now();
    const dt = (now - this.lastUpdateTime) \/ 1000;
    this.lastUpdateTime = now;

    \/\/ Обновява всяка куршум
    const bulletsToRemove = [];
    this.bullets.forEach(bullet => {
      if (bullet.update(dt)) {
        \/\/ Уничтожава този куршум
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    \/\/ Обновява всеки играч
    Object.keys(this.sockets).forEach(playerID => {
      const player = this.players[playerID];
      const newBullet = player.update(dt);
      if (newBullet) {
        this.bullets.push(newBullet);
      }
    });

    \/\/ Прилага сблъсъци, дава на играчите точки за уцелени куршуми
    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),
    );

    \/\/ Проверява дали някой играч е мъртъв
    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);
      }
    });

    \/\/ Изпраща актуализация на играта до всеки играч всеки втори път
    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() връща истинно, ако снарядът трябва да бъде унищожен (той е излязъл извън границите на арената).
  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)  b.distanceTo(player)  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;
  }

  // Връща true, ако куршумът трябва да бъде унищожен
  update(dt) {
    super.update(dt);
    return this.x  Constants.MAP_SIZE || this.y  Constants.MAP_SIZE;
  }
}

Реализация Bullet много кратка! Добавихме към Object единствено следните разширения:

  • Използването на пакета shortid за случайно генериране id на куршум.
  • Добавяне на полето parentID, за да може да се проследява играчът, създал този куршум.
  • Добавяне на връщаната стойност в update(), което е равно на истинно, ако куршумът е извън арената (помнете, говорихме за това в предишната част?).

Да преминем към 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;
  }

  // Връща новосъздаден куршум или null.
  update(dt) {
    super.update(dt);

    // Актуализиране на резултата
    this.score += dt * Constants.SCORE_PER_SECOND;

    // Уверете се, че играчът остава в границите
    this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));

    // Изстрелване на куршум, ако е необходимо
    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() {
    // ...

    // Прилагане на колизии, даване на точки на играчите за уцелването на снарядите
    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
Тук трябва да обърнем внимание и на няколко аспекта:

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

Край

И това е всичко! Прегледахме всичко, което трябва да знаете за създаването на уеб игра от жанра .io. Какво следва? Създайте собствена игра .io!

Целият код на примера е с отворен код и е публикуван на Github.

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster