Робимо чергову .io-гру



Так звані .io-ігри — це браузерні massively multiplayer action-ігри, в яких безліч людей борються з надлишками вільного часу. Massively multiplayer — це означає, що гра являє собою розраховану на масовку з великої кількості (сотень+) гравців, що грають в загальній локації. Існує думка, що все почалося з гри Agar.io (клітини в чашці Петрі). Іншими успішними прикладами можна назвати Slither.io (змійки) і Diep.io (танчики). Якщо вірити статистиці, то кожен день в ці ігри грають мільйони гравців. Сьогодні існують десятки різних .io-ігор, більшість з яких можна знайти, загуглив за запитом «io games list».
Я розповім про те, як ми робили нашу .io-гру Oceanar.io — гру про рибок та інших морських мешканців, постараюся при цьому зосередитися на питаннях загального технічного пристрою всієї системи, і дам кілька скромних рад.
Постараюся описати пристрій системи максимально просто, в такому форматі, в якому це було б найбільш зрозуміле для мене самого — на пальцях, без поглиблень в код.
Ігровий процес
Для початку потрібно було вирішити, про що буде гра. Обміркувавши ігрову механіку двох найбільш успішних представників — Агарио і Слизарио — ми вирішили, що гра повинна володіти наступними властивостями:
  1. Керований персонаж повинен набиратися сил поступово протягом тривалого часу, і цей набір сил повинен відображатися візуально.
  2. Потрібно щоб був сенс грати дуже довго — багато годин поспіль. Гравець, який програв 5 годин, повинен мати певну перевагу перед тим, хто програв 3.
  3. Ігрову перевагу не повинно залежати виключно від часу, проведеного в грі. Має бути розумний баланс між відіграним часом і ігровим майстерністю. Людина, який відіграв кілька хвилин, повинен мати можливість перемогти того, хто грає вже кілька годин. Нехай це буде дуже складно, нехай тут знадобиться фактор удачі, але така можливість повинна бути.
  4. Бонуси, отримані в результаті перемоги на іншим гравцем, не повинні беззастережно діставатися переможцю — у інших гравців повинна бути можливість відрізати їх частину.
  5. Мати хоча б щось оригінальне :)
Ми порахували, що буде оригінально, якщо буде рости не сила ігрового істоти безпосередньо, а кількість цих істот. Друга ідея — це можливість об'єднувати пари слабших істот в одне більш сильне. Таким чином, на початку у нас є одна істота, яке з часом розростається в зграю, а зграя схлопывается назад у меншу кількість більш сильних істот. Пара істот першого рівня може луснути одна істота другого, пара істот другого — в одну істоту третього, і так далі. Так ми отримуємо експоненціально-замедляющуюся прокачування, яку можна розгойдувати майже необмежену кількість часу.
Тепер залишилося вибрати художню складову. Вибір стояв між зграєю мікроорганізмів — вірусів, бактерій, протистов, пріонів, і зграєю морських мешканців — рибок, крабів, медуз. Після деяких роздумів було зроблено вибір на користь рибок. На цьому закінчимо з мистецькою частиною, і перейдемо до технічної :)
У мене немає досвіду в створенні серйозних MMO-ігор, але, гулі понабивав на своїх минулих проектах, а також почитавши про досвід різних розробників (наприклад у мене склалася думка, що клієнт-серверна архітектура .io-ігор принципово не сильно відрізняється від інших MMO-ігор — в її основі лежать приблизно ті ж ідеї. В якості одного з важливих (і приємних для розробників-одинаків) відмінностей .io-ігор я б виділив істотно спрощені ігрові правила: якщо серйозної MMORPG для обчислення шкоди від удару сокирою по голові гобліна на сервері потрібно відіграти цілу вериницу правил, то в .io-грі це буде проста формула в один рядок коду. Тобто, .io-гра — це та ж MMO з великою кількістю гравців, але набагато більш проста з точки зору організації своїх нутрощів. Досить проста, щоб її міг написати один програміст, і клієнтську і серверну частину.
Клієнт
Ігровий клієнт — це браузер з відкритою у ньому html-сторінкою. Технології ті ж, що й у більшості інших .io-ігор, а саме: мову програмування — JavaScript, графіка — WebGL, взаємодія з ігровим сервером — через WebSockets. Так вже склалося, що .io-ігри не прийнято озвучувати (хоча всі технічні можливості для цього є), тому ми вирішили, що наша гра буде без звуку (щоб не привертати зайвої уваги у начальства на роботі).
Спрощено кажучи, вся двіжуха відбувається на сервері, а клієнт є лише монітором-візуалізатором цієї движухи. Все що вміє робити клієнт крім пасивного визуализирования — це відправляти на сервер дії гравця (в нашому випадку — координати курсору миші і стану її кнопок). Більше нічого клієнт не вміє, і не повинен вміти — ніяка ігрова логіка не повинна виконуватися на клієнта, інакше це моментально стане можливістю для чітерства. Таким чином, з програмної точки зору, клієнт — це об'єкт, який, серед іншого:
  1. Уміє відправляти на сервер дії користувача.
  2. Вміє приймати з сервера повідомлення і диспетчеризувати їх.
  3. Зберігає у себе копію ігрового світу, що потрапляє в його полі зору.
  4. Візуалізує збережену копію ігрового світу.
Ось приклади повідомлень і дій по їх обробці з боку клієнта:
Показати рибу з ідентифікатором creature_id, яка знаходиться в позиції position, має вектор швидкості velocity, гравцеві належить з ідентифікатором owner_id, і є рибою типу creature_type_id.
З точки зору JavaScript це буде наприклад ось такий об'єкт:
{
message_type_id: 1,
creature_id: 68328,
creature_type_id: 2,
owner_id: 9306,
position: { x: 1.436, y: -39.32 },
velocity: { x: -0.17235, y: -0.1157 }
}

Одержавши таке повідомлення, клієнт додасть рибу в мапуа риб, і коли справа дійде до відтворення ігрової сцени, риба з'явиться на екрані.
Риба з ідентифікатором id1 вкусила рибу з ідентифікатором id2.
Одержавши це повідомлення, клієнт візьме координати риби з ідентифікатором id2, і додасть з такими ж координатами міхур в масив бульбашок, а також пляма крові в масив плям крові. Далі вивід виведе їх на екран.
Таким чином можна сказати, що повідомлення від сервера клієнту — це в основному повідомлення про те, що в поле зору клієнта щось сталося. Щось таке, про що клієнт повинен знати, щоб правильним і зрозумілим чином (графічно) повідомити про цього гравця. Приклади інших типів повідомлень — «гра закінчена з таким рахунком», «в гру увійшов гравець з таким ім'ям», «список топ-10 гравців», «список всіх гравців і їх координат» (для відображення їх на карті), і так далі.

Сервер — це програма, усередині якого і відбувається вся гра. Сервер складається з ігрових кімнат. Ігрова кімната — це екземпляр ігровий локації. Кімната має адекватні для геймплея геометричні розміри і ліміт на кількість гравців у ній. Коли всі ігрові кімнати заповнені, сервер створює нову кімнату, і гравці поступово розподіляються рівномірно між зайнятими і вільної кімнатами. Ігрові кімнати ніяк між собою не пов'язані, і працюють повністю в ізоляції один від одного. На самому верхньому рівні сервер вміє приймати вхідне з'єднання, вибирати потрібну ігрову кімнату, в разі потреби — створювати нову, і відправляти гравця в обрану кімнату.
Наш сервер, написаний на C++, з сторонніх бібліотек використовується Boost, мережева частина — Boost.Asio. Розробка і тестування — Visual Studio / Windows, робочий сервер — GCC / Linux.
Ігрова кімната
Тут відбувається вся ігрова логіка. Кімната — це об'єкт, який, серед іншого:
  1. Зберігає контейнери ігрових об'єктів: істот, їжі, спостерігачів.
  2. Вміє приймати від клієнтів повідомлення і диспетчеризувати їх.
  3. Вміє повідомляти клієнтів про події, що наступили в кімнаті. Деякі з повідомлень відправляються тільки якщо дані події настали в полі зору клієнта.
  4. Реалізує функцію переходу кімнати зі стану An стан An+1 (апдейт ігрового світу).
  5. Періодично виконує апдейт ігрового світу з плином часу (в нашому випадку — 10 разів у секунду).
Знежирений інтерфейс ігрової кімнати на псевдо-коді:
class room
{
methods:
join(user);
update(dt);
dispatch(protocol::kill_creature);
dispatch(protocol::consume_food);
dispatch ...
dispatch ...
dispatch ...

властивості:
container<creature> creatures;
container<food> foods;
container<observer> observers;
}

Апдейт ігрового світу
Це функція, яка реалізує зміни в ігровому світі, що відбулися за фіксований відрізок часу dt (в нашому випадку — 0,1 секунди), я буду називати цю функцію update. В загальних рисах update ігрової кімнати — це виклик update для ігрових об'єктів, тобто виклик update далі вниз по сходах агрегації, від загального до приватного. Простий приклад: нехай у нас є риба, яка перебуває в координатах position, і рухається з вектором швидкості velocity. Тоді її функція update — це обчислення її позиції через dt часу:
next_position = position + dt * velocity;

Крім переміщення, риба може за це dt прийняти рішення відправитися в якесь інше місце, і тоді, крім оновлення своїх координат, в результаті update може змінитися її вектор швидкості velocity. На псевдо-коді update кімнати виглядає наступним чином:
room::update(dt)
{
message_queue.dispatch(); // Диспетчеризовать повідомлення, адресовані кімнаті, що знаходяться в черзі

foreach(creature in creatures)
{
creature.update(dt);
}

foreach(food in foods)
{
food.update(dt);
}

foreach(observer in observers)
{
observer.update(dt);
}

spawn_food();
spawn_npc();
}

В результаті виконання нижчерозміщеного update (наприклад — update істоти) кімнаті можуть бути відправлені повідомлення, які будуть опрацьовані на її еследующем update. Наприклад — краб вкусив рибу, і риба загинула. Потрібно видалити загиблу рибу з контейнера риб, створити їжу на місці загиблої риби, а також повідомити про гравців загибелі риби і появі їжі. Для цього краб всередині свого update відправить кімнаті повідомлення про те, що він вбив рибу, а кімната під час диспетчеризації цього повідомлення виконає всі необхідні дії — вилучення риби, створення їжі, повідомлення клієнтів.
Розбиття ігрового простору
У ряді алгоритмів, що реалізують логіку гри, потрібно вміти перебирати об'єкти, найближчі до цього. Наприклад — показати користувачеві всіх риб в поле його зору, або перевірити, чи не збирається дана риба вкусити когось зі свого оточення. Якщо ми будемо перебирати пари об'єктів за принципом «кожен з кожним», то ми отримаємо квадратичну складність, а це нам не потрібно, ні для CPU, ні для трафіку.
Для того, щоб позбутися від квадратичної складності, у нас використовується наступний спосіб: простір розбивається на клітинки, і для кожної клітинки зберігається список об'єктів, які знаходяться на даній ділянці ігрового простору. Доступ до списку клітинки за її координатами має константну складність.
Якщо нас цікавить список об'єктів, найближчих до даного, ми запитуємо списки клітинки, в якій знаходиться цей об'єкт, а також списки об'єктів з сусідніх клітинок. Разом 9 клітинок.
Розбиття на клітинки робиться в два незалежних дублюючих шару — клітини маленького розміру для перевірки ближніх взаємодій типу зіткнень, укусів, поеданий, і клітинки великого розміру для вирішення питань видимості — попаданні об'єктів у полі зору гравця.
Таким чином, складність алгоритмів залишається квадратичних, але в квадрат зводиться вже не загальна кількість ігрових об'єктів в кімнаті, а тільки їх кількість у полі видимості алгоритму.
Протокол
Окреме питання — протокол взаємодії сервера і клієнтів. Під протоколом я маю на увазі опис набору і структури повідомлень, якими будуть обмінюватися сервер та клієнти, їх серіалізацію при відправці, і десеріалізацію і диспетчеризацію при отриманні. Я використовував своє власне спрощене велосипедне рішення — кодогенератор з JSON-описувача пакетів (так вже вийшло). Якщо вам потрібно готове серйозне рішення, а не велосипед, тобто ru.wikipedia.org/wiki/Protocol_Buffers, який теж є кодогенератором.
Чому кодогенерация?
  1. Дозволяє мінімізувати код, який потрібно писати руками.
  2. Швидкість роботи — в кодогенераторе ви не обмежені жодними правилами, можна згенерувати максимально швидкий код, в рамках використовуваної мови.
  3. Обсяг переданих даних — можна (і потрібно) щільно упаковувати дані у відповідності з вашими потребами, мінімізуючи трафік (про це окремо нижче). З кодогенератором особливо зручно ввести додаткові атрибути до полів повідомлень, які визначають правила упаковки цих полів.
Черги повідомлень
Для відправлення повідомлень кімнаті використовується multiple producer single consumer чергу. Для відправки повідомлень клієнтам — single producer single consumer. Обидва типи черг — самописно-велосипедні. Ймовірно, можна було скористатися Boost.Lockfree, але, знову ж таки, так вже вийшло. Для (де-)серіалізації та диспетчеризації повідомлень, які подорожують всередині сервера, використовується той же кодогенерированный механізм, що і для передачі повідомлень по мережі.
Я постарався описати загальну структуру гри і деякі її окремо взяті, важливі на мій погляд моменти, щоб у вас склалася повна картина того, як влаштована вся система цілком. Сподіваюся, у мене це вийшло :)
Поради
Якщо ви зібралися зробити свою .io-гру, то я можу сказати лише одне — беріть і робіть! :) І ось кілька скромних порад:
Боти
Разом з клієнтом і сервером відразу ж зробіть ботів. Причому боти повинні ходити на сервер по мережі, по тому ж протоколу, що і клієнти, і «бачити» ту ж інформацію, що і реальні гравці. Це істотно полегшує налагодження, а також дозволяє оцінити споживані ресурси в умовах, наближених до бойових. Скільки всього гравців онлайн потягне сервер? Коли ми зіткнемося в CPU або ширину мережевого каналу? На ці та деякі інші питання вам допоможуть відповісти боти, тим самим позбавивши вас від неприємних сюрпризів після релізу. Крім того, боти дозволять зробити гру не надто нудною, поки в ній мало живих гравців.
Мережевий трафік
Так от, мінімізація мережевого трафіку. Зазвичай у хостинг-провайдерів трафік або платний по лічильнику, або передплачений пакет з можливістю докупки, або безлімітний, але із зірочкою-виноскою, за якою знаходиться застереження, що насправді не такий вже він і безлімітний — при вичерпанні певного обсягу вас будуть чекати каральні заходи у вигляді, наприклад, зменшення ширини каналу, або ж таки доведеться платити зверху. Тому ретельно продумайте ваш протокол і заощаджуйте кожен байт! У нас 10000 живих гравців, відіграли за добу, генерували за цю добу близько 200 гігабайт ігрового трафіку. Напевно це може здатися не так вже багато, але не забувайте, що об'єм споживаного трафіку буде рости разом з вашою аудиторією. Кілька зекономлених байт в окремо взятому пакеті можуть зекономити сотні мегабайт або навіть гігабайти трафіку в добу.
Ведіть статистику трафіку з розкладкою за типом ігрових пакетів. Швидше за все ви побачите, що незважаючи на те, що у вас кілька десятків типів ігрових пакетів, основна частина трафіку припадає на кілька з них — їм-то і слід приділити особливу увагу.
HTTPS
Якщо ви захочете зробити вашу гру в тому числі iframe-грою ВКонтакте, то доведеться примусово переїхати на HTTPS, оскільки сам раз vk.com працює через HTTPS, то і від iframe всередині нього буде вимагатися той же протокол. А сайт, що працює на HTTPS, може працювати з SSL-вебсокетами.
Список технологій
Ось повний список технологій, які вам потрібні для того, щоб зробити .io-гру:
  1. HTML / CSS — верстаємо сайт.
  2. JavaScript — програмуємо клієнтську частину гри.
  3. WebGL / OpenGL ES Shading Language — робимо графон.
  4. WebSocket — робимо мережу.
  5. Protocol Buffers / Велосипед — робимо протокол клієнт-серверного спілкування.
  6. Мова, на якому буде написано ігровий сервер. Тут вибір величезний: Java, Scala, C++, Rust, Erlang, Haskell… Що тільки вашій душі завгодно.
  7. ...
  8. PROFIT!
Результат
Наша гра все ще знаходиться в беті, але подивитися на результат можна вже зараз: oceanar.io (поки що працює тільки для десктопа, мобільні і планшетні версії на черзі).
Дякую за увагу. В коментарях відповім на ваші питання.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.