Створення розрахованої на багато користувачів веб-ігри в жанрі .io

Створення розрахованої на багато користувачів веб-ігри в жанрі .io
Вийшла у 2015 році Agar.io стала прабатьком нового жанру ігор .io, популярність якого з того часу сильно зросла. Зростання популярності ігор .io я відчув на собі: за останні три роки я створив та продав дві гри цього жанру..

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

У цьому пості ми розбиратимемося, як з нуля створити гру .io. Для цього достатньо буде лише знання Javascript: вам потрібно розуміти такі речі, як синтаксис ES6, ключове слово this и обіцянки. Навіть якщо ви знаєте Javascript не досконало, то все одно зможете розібратися в більшій частині посту.

Приклад гри .io

Для допомоги в навчанні ми посилатимемося на приклад гри .io. Спробуйте зіграти в неї!

Створення розрахованої на багато користувачів веб-ігри в жанрі .io
Гра досить проста: ви керуєте кораблем на арені, де є інші гравці. Ваш корабель автоматично стріляє снарядами і ви намагаєтеся потрапити в інших гравців, водночас уникаючи їх снарядів.

1. Короткий огляд/структура проекту

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

У прикладі використовується таке:

  • Експрес — найпопулярніший веб-фреймворк для 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/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/preset-env для транспіляції нашого коду JS для старих браузерів.
  • Ми використовуємо плагін для отримання всіх CSS, на які посилаються файли JS, і для об'єднання їх в одному місці. Я називатиму його нашим пакетом CSS.

Ви могли помітити дивні імена файлів пакетів '[name].[contenthash].ext'. Вони містяться підстановки імен файлів Веб-пакет: [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

An example .io game  PLAY

Цей приклад коду трохи спрощений для зрозумілості, те саме я зроблю і з багатьма іншими прикладами посту. Повний код завжди можна подивитися на 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>). Наша гра досить проста, тому нам достатньо малювати лише таке:

  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. Клієнтське введення

Настав час зробити гру грабельної! Схема керування буде дуже простою: для зміни напрямку руху можна використовувати мишу (на комп'ютері) або торкання екрана (на мобільному пристрої). Щоб реалізувати це, ми зареєструємо Слухачі подій для подій 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. Стан клієнта

Цей розділ найскладніший у першій частині посту. Не засмучуйтесь, якщо не зрозумієте його з першого прочитання! Можете навіть пропустити його та повернутися до нього пізніше.

Останній шматок пазла, який потрібний для завершення клієнт-серверного коду - це були. Помнете фрагмент коду з розділу «Рендеринг клієнта»?

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: інформація про гравця, який отримує це оновлення.
  • інші: масив інформації про інших гравців, що беруть участь у тій самій грі.
  • кулі: масив інформації про снаряди у грі.
  • лідерів: поточні дані таблиці лідерів У цьому пості ми їх не враховуватимемо.

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
На жаль, ніщо не ідеальне. Реалістичнішою буде така картина:
Створення розрахованої на багато користувачів веб-ігри в жанрі .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);

  // 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
Оновлення гри безпосередньо зліва від 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 під назвою Експрес. Його налаштуванням займеться наш файл вхідної точки сервера 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
Тут потрібно уважно поставитися ще до кількох аспектів:

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

Кінець

От і все! Ми розглянули все, що потрібно знати для створення веб-ігри жанру .io. Що далі? Зберіть власну гру .io!

Весь код прикладу має відкриті вихідні коди та викладений на Github.

Джерело: habr.com

Додати коментар або відгук