Розробка ММО РПГ – практичний посібник. Сервер (частина 2)

Рерурс літій
  • Реалізація AI: як зробити максимально просто?
  • RPC клієнт-сервер: json або бінарна «самопальщина»?
  • Асинхронні сокети або багатопотокова архітектура?
  • Кешування об'єктів на рівні програми або більше пам'яті для СУБД?
  • Робота з БД без Reflection API: чи дійсно це так складно?
        Сьогодні ми продовжимо розглядати архітектуру й особливості реалізації ігрового backend'а на С++ онлайн ігри на прикладі ММО РПГ «Зоряні Примари». Це друга частина статті про сервер, початок можна прочитати тут.

Модуль AI.
        Зазвичай реалізація AI – це досить складний процес. Але нам вдалося зробити це «малою кров'ю» насамперед за рахунок використання Actions. Фактично AI представляє кінцевий автомат, який може кудись летіти, збирати ресурси, нападати на інші космічні кораблі і переміщатися між системами. На момент створення AI реалізація всіх цих дій вже була в Actions для управління кораблем гравця. Тобто все написання TBaseAI – це створення завантаження даних з БД для кінцевого автомата і сам цей автомат з декількох дій, що досить просто реалізації.
        Трохи складнощів з'явилося тільки після введення таких монстрів, як «Бос», «Золотий Бос» і «Королева Роя». У них є специфічні навички, які доступні тільки їм. Реалізація цих навичок повністю знаходиться в їх класах AI (TBossBaseAI, TGoldenBossAI і TMotherOfNomadAI).
        Так само для AI довелося створити клас TAISpaceShip, нащадок від TSpaceShip, який містить в собі примірник TBaseAI і викликає TBaseAI ::Update зі свого Update.
        AI повинен отримувати повідомлення про події, наприклад про те, що на нього напали. Для цього ми зробили його нащадком від ISpaceShipNotifyReciver і TSpaceShip відправляє йому необхідні дані. Іншими словами, правильне архітектурне рішення дозволило нам повністю уніфікувати спілкування модуля Space з власником корабля, чи то гравець або AI.
        На закінчення наводимо діаграму класів на рис.4 (для більшої наочності діаграма дещо спрощено).

Діаграма класів модуля AI

Модуль Quest.
       На етапі вибору реалізації квестової системи першою думкою було використання якогось скриптової мови типу LUA, який би дозволив «писати що завгодно». Але для роботи з LUA все одно необхідно експортувати методи і колбеки в саму машину LUA, а це дуже незручно і призводить до написання великої кількості додаткового коду, який нічого не робить, окрім як є медіатором. Враховуючи, що квестова система у нас дуже проста (в результаті у нас всього 25 команд), одночасно може бути не більше одного квесту, ніяких розгалужень у квестах немає, ми вирішили зробити свій парсер квестів. Ось приклад з квестового файлу прологу tutor.xmq:

show reel 1
then
flyto 1000 -1900 insys StartSystem
then
show reel 2
then
flyto 950 -1450 insys StartSystem
then
show reel 3
then
spawn_oku ship 1 000_nrds near_user -400 200 insys StartSystem

       Навскидку і так все зрозуміло: показати ролик 1, полетіти в точку з координатами 1000;-1900 у стартовій системі, потім показати ролик 2 і т. д. Файл настільки простий, що нам вдалося навіть гейм-дизайнера навчити його правити і дописувати квести і балансувати потрібні йому параметри.
       Архітектурно це виглядає так. Є клас TQuestParser який власне парсити квестові файли і містить у собі фабрику для класів-нащадків TQuestCondition. Для кожної команди є свій нащадок від TQuestCondition, який і реалізує необхідний функціонал. При цьому самі квестові класи-команди не містять ніяких даних (крім завантажених безпосередньо з квестового файлу), всі їхні методи оголошені як const. Дані містяться в класі TQuest. Це дозволяє утримувати тільки одну копію квестових команд, «підсовуючи» їх необхідні дані, специфічні для конкретного користувача (наприклад, скільки він вже убив номадів заданого типу). Так само це спрощує збереження квестових даних в БД – всі вони зібрані в одному місці.
       Об'єкт-owner об'єкта TQuest повинен реалізовувати інтерфейс IQuestUser (який містить такі команди, як, наприклад, AddQuestMoney) і має повідомляти TQuest про події, що відбуваються (наприклад, коли знищується будь-який корабель, TQuest відправляється повідомлення з його сигнатурою, щоб квестова команда могла порівняти той чи це корабель, який потрібно було знищити). TQuest ж переправляє ця подія безпосередньо в квест-команду і якщо квест-команда закінчилася, то переходить до наступної команди.
       загалом, цей модуль наскільки простий, що навіть немає сенсу наводити його діаграму класів :).

RPC клієнт<->сервер (і модуль Packets).
       При проектуванні мережевої підсистеми першим бажанням було використовувати json або AMF (т. к. клієнт на флеш, а це рідний бінарний формат флеша). Практично відразу обидві ці ідеї були відкинуті: гра в реальному часі і використовується TCP з'єднання, тому необхідно мінімізувати розмір пакета, щоб шанс втрати пакету (і його повторної передачі, TCP ж все-таки :) був мінімальний. Втрата пакета TCP і його ретрансляція – це досить довгий процес, який може привести до лагів. Звичайно, цього б хотілося уникнути. Другий, не менш важливий момент – це обмежена пропусканная здатність мережевої карти. Це може здатися смішним, але мені доводилося робити аудит однієї гри, в якій через використання json і системи polling-based, а не event-based, розробники вперлися саме у пропускну здатність мережевої карти. І після цього всі читаються і красиво названі поля в json довелося називати в стилі A, B і т. д. для того, щоб мінімізувати розмір пакету. Що стосується AMF, то це закритий формат Adobe, тому ми вирішили з ним не зв'язуватися – мало що там вирішать змінити, а нам потім шукай проблему.
       В результаті ми реалізували дуже простий формат пакета. Він складається з заголовка, що містить повну довжину пакета і типу пакета. Але ж потрібен ще код, який буде упаковувати/розпаковувати в/з бінарного вигляду самі структури даних, а так само сигналізувати про приходять пакетах. І робити це однаково й на сервері, і на клієнті. Писати всю цю купу коду руками на двох мовах (клієнт і сервер), а потім підтримувати – це дуже клопітно. Тому ми написали на РНР скрипт, який приймає XML з описом всіх пакетів і генерує по них необхідні класи для клієнта і сервера. Крім генерації власне самих класів пакетів і серіалізації в них для сервера генерується ще один спеціальний додатковий клас TStdUserProcessor. Цей клас містить колбеки для кожного типу пакету (що дозволяє централізовано керувати типами прийнятих пакетів на даній стадії роботи), а кожен колбек створює екземпляр класу пакету і завантажує в нього двійкові дані, після чого викликає його обробник. У коді це виглядає так:

virtual void OnClientLoginPacket(TClientLoginPacket& val)=0;
void OnClientLoginPacketRecived(TByteOStream& ba);

void TStdUserProcessor::OnClientLoginPacketRecived(TByteOStream& ba) { TClientLoginPacket p; ba>>p; OnClientLoginPacket(p); }

       тобто для класу-нащадка від TStdUserProcessor реалізується прозорий міст «клієнт<->сервер», де відправка пакету з клієнта – це простий виклик методу в TUserProcessor.
       А хто викликає ці колбеки? TStdUserProcessor – нащадок від класу TBaseUserProcessor, який і виконує m_xSocket.Recv, а так само виконує розбиття бінарного потоку на пакети, знаходить в заголовку тип пакету і по цьому типу знаходить необхідний колбек. Виглядає це так:

void TStdUserProcessor::AddCallbacks() {
AddCallback( NNNetworkPackets::ClientLogin, &TStdUserProcessor::OnClientLoginPacketRecived );
}

void TBaseUserProcessor::RecvData() {
if( !m_xSocket || m_xSocket->State()!=NNSocketState::Connected ) return;

if( !m_xSocket->AvailData() ) return;

m_xReciver.RecvData();

if( !m_xReciver.IsPacketRecived() ) return;

// а тепер знайдемо колбек і викличемо його за типом, заодно "відрізавши" дані службовий хедер
int type = m_xReciver.Data()->Type;
if( type>=int(m_vCallbacks.size()) ) _ERROR("NoCallback for class "<<type);
SLocalCallbackType cb = m_vCallbacks[type];
if(cb==NULL) _ERROR("NoCallback for class "<<type);
TStdUserProcessor* c_ptr = (TStdUserProcessor*)this;
const uint8* data_ptr = (const uint8*)m_xReciver.Data();
data_ptr += sizeof(TNetworkPacket);
TByteOStream byte_os( data_ptr, m_xReciver.Data()->Size-sizeof(TNetworkPacket) );

if( m_xIgnoreAllPackets==0 ) {
(*c_ptr.*cb)( byte_os );
}

m_xReciver.ClearPacket();
}


Сокетная модель.
       Тепер ми поговоримо, мабуть, про найцікавіше – про використовуваної сокетной моделі. Є два «класичних» підходу до роботи з сокетами: асинхронний і багатопотокові. Перший загалом-то, швидше, ніж багатопотоковий (оскільки немає перемикання контексту потоку) і з ним менше проблем: все в одному потоці і ніяких проблем з розсинхронізацією даних або dead lock'ами. Другий дає більш швидкий відгук на дію користувача (якщо не всі ресурси з'їли великою кількістю потоків), але несе купу проблем з багатопотоковою зверненням до даних. Ні один з цих підходів нас не влаштував, тому ми обрали змішану модель асинхронного-багатопотокове. Поясню докладніше.
       «Зоряні Примари» — гра про космос, тому ігровий світ розбитий на локації спочатку. Кожна сонячна система – окрема локація, переміщення між системами відбувається з допомогою гіпер-брами. Це поділ ігрового світу нам і підказало архітектурне рішення – на кожну сонячну систему виділяється окремий потік, робота з сокетами в цьому потоці виконується асинхронно. Так само створюється декілька потоків для обслуговування планет і перехідних станів (гіперпростору, завантаження/вивантаження даних тощо). Фактично гіперприжок – це переміщення об'єкта з одного потоку до іншого. Таке архітектурне рішення дозволяє не тільки легко масштабувати систему (аж до виділення окремого сервера на кожну сонячну систему), але і суттєво спрощує взаємодію користувачів в межах однієї сонячної системи. А адже саме польоти і бої в космосі для гри є критично важливими. Бонусом йде автоматичне використання багатоядерної архітектури і практично повна відсутність об'єктів синхронізації – гравці з різних систем між собою практично не взаємодіють, а, перебуваючи в одній системі, вони знаходяться в одному потоці.

Робота з БД.
        «Зоряних Привидів» всі дані гравця (та й взагалі всі дані зберігаються в пам'яті до завершення сесії. І збереження в БД відбувається тільки в момент завершення сесії (наприклад, виходу гравця з гри). Це дозволяє суттєво знизити навантаження на БД. Так само об'єкт TSQLObject виконує перевірку зміни полів і робить UPDATE реально зміненим полів. Реалізується це наступним чином. При завантаженні з БД створюється копія всіх завантажених даних в об'єкті. При виклику SaveToDB() виконується перевірка, значення яких полів не дорівнює значенням, спочатку завантаженим, і тільки вони додаються до запиту. Після виконання ОНОВЛЕННЯ в БД, копії полів так само апдейтятся новими значеннями.
       MySQL виконує команду INSERT довше, ніж команду UPDATE, тому ми прагнули знизити кількість INSERT'ів. У першій реалізації збереження даних користувача в БД, дані про всіх предметах в БД стиралися і заново заносилися. Дуже швидко гравці накопичили сотні (а деякі – тисячі) предметів і така операція стала дуже накладної та довгою. Тоді алгоритм довелося змінити – не змінені об'єкти не чіпати. Крім того, новим об'єктам, яким необхідно робити INSERT, відразу намагаються знайти місце в підписаних на видалення, щоб не викликати пару INSERT/DELETE, а виконати UPDATE.
       Окремо треба сказати про запис значення «NULL» в БД. У зв'язку з особливостями реалізації нашого TSQLObject, ми не можемо записувати і читати «NULL» в/з БД. Якщо поле у класі типу «int», то «NULL» у нього запишеться як «0» і, відповідно, саме «0» буде стояти в запиті UPDATE в БД (а не «NULL», як повинно бути). І це може призвести до проблем – або дані будуть не вірні, або, якщо це поле БД foreign key, то запит буде взагалі помилковим. Для вирішення цієї проблеми нам довелося додати TRIGGER's BEFORE UPDATE на необхідні таблиці, які б «0» перетворювали в «NULL».

Збереження/завантаження об'єктів в/з БД.
       Одна з проблем З++, це неможливість дізнатися під час виконання рядкові назви полів і звернутися до них по строковому назвою. Наприклад, в ActionScript без проблем можна дізнатися назви всіх полів об'єкта, викликати будь-який метод або звернутися до будь-якого поля. Цей механізм дозволяє істотно спростити роботу з БД – вам не потрібно писати окремий код для кожного класу, максимум перерахувати список полів, який необхідно зберігати/завантажувати в/з БД і в яку таблицю це зробити. На щастя, в С++ є такий потужний механізм, як template, що, разом з <cxxabi>, дозволяє вирішити проблему відсутності Reflection API стосовно до завдання роботи з БД.

       Використання нашої бібліотеки Reflection (як вона працює розберемо нижче) виглядає так:
  1. Необхідно отнаследоваться від класу TSQLObject.
  2. Необхідно в самому класі-нащадку написати в секції public DECL_SQL_DISPATCH_TABLE(); (це макрос).
  3. .cpp файлі класу-нащадка перерахувати які поля класу на які поля таблиці відображаються, а так само назва самого класу і назву таблиці в БД. На прикладі класу TDevice це виглядає так:

    BEGIN_SQL_DISPATCH_TABLE(TDevice, device)
    ADD_SQL_FIELD(PrototypeID, m_iPrototypeID)
    ADD_SQL_FIELD(SharpeningCount, m_iSharpeningCount)
    ADD_SQL_FIELD(RepairCount, m_iRepairCount)
    ADD_SQL_FIELD(CurrentStructure, m_iCurrentStructure)
    ADD_SQL_FIELD(DispData, m_sSQLDispData)
    ADD_SQL_FIELD(MicromoduleData, m_sMicromodule)
    ADD_SQL_FIELD(AuthorSign, m_sAuthorSign)
    ADD_SQL_FIELD(Flags, m_iFlags)
    END_SQL_DISPATCH_TABLE()
    

  4. Тепер під час виконання можна викликати методи void LoadFromDB(int id), void SaveToDB() та void DeleteFromDB(). При виклику будуть сформовані відповідні SQL-запити до таблиці БД device і будуть завантажені/збережені дані із зазначених у пп.3 полів.


       Вся робота Reflection заснована не таких ідеях:
  1. За вказівником на полі, використовуючи <cxxabi>, можна отримати рядковий назва типу цього поля. А так само для класу — список його предків.
  2. Якщо створити вказівник на об'єкт, прирівняти його до 0 і від цього покажчика взяти вказівник на поле, то ми отримаємо зміщення поля щодо покажчика на об'єкт. Звичайно, це не може працювати в разі застосування віртуального спадкування, тому до таких класів Reflection потрібно застосовувати з обережністю.
  3. Використовуючи один клас-темлпейт, можна, для будь-якого типу, у якого визначені оператори new, delete,=, = = створити фабрику, яка може створювати, видаляти, оцінювати і порівнювати об'єкти даного типу. Додамо цій фабриці предка з віртуальними методами, які приймають у себе вказівник на об'єкт, але не типізований, а типу void*, і static_cast в сам темплейт, який призведе переданий void* до покажчика на тип, з яким оперує фабрика. І ми отримаємо можливість оперувати об'єктами, не знаючи їх типу.


       Тепер заглянемо всередину макросів.

       Макрос DECL_SQL_DISPATCH_TABLE() робить наступне:
  • virtual const string& SQLTableName(); — перевантаження відповідного методу з TSQLObject
  • static void InitDispatchTable(); — метод ініціалізації даних, необхідних Reflection для роботи з цим об'єктом


       Макрос BEGIN_SQL_DISPATCH_TABLE(ClassType, TableName); робить наступне:
  • Реалізує метод SQLTableName();
  • Оголошує статичний клас TCallFunctionBeforMain, який в конструкторі викликає InitDispatchTable. Завдання такої конструкції – проинциализировать необхідні для Reflection дані до входу в int main(), а так же позбутися від необхідності вручну прописувати в int main() виклик всіх InitDispatchTable з усіх класів.
  • Створює фабрику об'єкта
  • Оголошує змінну ClassType* class_ptr = 0; (використовується в макросах ADD_SQL_FIELD).


       Макрос ADD_SQL_FIELD(VisibleName, InternalName); робить наступне:
  • Розраховує offset поля від початку об'єкта
  • Додає в список полів даного об'єкта offset поля, видиме (зовнішнє) назву для нього і рядковий назва типу поля.


       За кадром залишилося створення власне фабрик типів і створення конверторів string<->об'єкт, а так само місце зберігання всіх даних Reflection. Для зберігання існує клас-одинак TGlobalDispatch. Цей же клас у своєму конструкторі ініціалізує фабрики і рядкові конвертери для більшості простих типів.
       Робота TSQLObject базується на тій ідеї, що використовуючи <cxxabi> можна отримати справжні рядковий назва об'єкта під час виконання. З цього імені запитати у TGlobalDispatch список опублікованих полів цього класу і його предків. Ім'я таблиці можна отримати через виклик SQLTableName(). Рядкові конвертери для полів так само надасть TGlobalDispatch. Тепер не складає труднощів створити необхідний SQL запит і завантажити/завантажити об'єкт.

БД і ID предметів.
       У всіх предметів у грі є унікальний ID, який дозволяє ідентифікувати предмет. Але збереження даних у БД відбувається тільки по завершенні сесії, а предмет може бути створено в будь-який момент. Як бути з ID предмета? Можна прибрати перевірку цілісності даних на рівні БД (відключити AUTO_INCREMENT і PRIMARY KEY) і генерувати унікальні ключі на рівні З++. Але це поганий шлях. По-перше, ви не зможете додавати/забирати/переглядати предмети гравців через адмінку на РНР, необхідно буде писати на С++ якийсь додатковий код для цього. А, по-друге, імовірність помилки у вашому сервері істотно вище, ніж в СУБД. В результаті помилки дані можуть втратити цілісність (адже цілісність тепер контролює БД). І потім цю цілісність доведеться відновлювати вручну, під загальний виття гравців «у мене пропала супер-шмотка, яку я сьогодні вибив». Загалом, ID збереженого об'єкта в БД повинен бути дорівнює унікальному ID предмета в грі. І знову повертаємося до питання: звідки брати цей ID новоствореного предмета? Можна, звичайно, відразу ж зберігати предмет в БД, але це суперечить ідеї «все в пам'яті, збереження в кінці сесії» і, що найголовніше, викличе зупинку потоку, в якому може перебувати не один користувач, до закінчення збереження. І зупинка може перевищити той самий максимальний час відгуку сервера на дії гравця (50мс), яке використовується в ТЗ. Асинхронне збереження тягне за собою інші проблеми: у нас деякий час буде існувати об'єкт без ID.
       Ідея вирішення проблеми з ID з'явилася досить швидко. ID задається типом «int». Всі збережені в БД предмети мають ID більше нуля, а всі новостворені – менше. Звичайно, це означає, що в якийсь момент часу ID об'єкта змінитися і це могло б привести до проблем. Могло б, якби предмет жив далі. Але збереження відбувається в момент завершення сесії, коли предмети вже стоять у черзі на знищення і з ними нічого не можна зробити.

Гра «про ельфів».
       Припустимо, нам необхідно зробити гру, в якій у гравця є персонаж, якого він може одягати, є навички, є магія, можна варити зілля, персонаж може бігати по локаціях і вбивати інших персонажів, отримувати рівні і торгувати з іншими персонажами. Що необхідно зробити з поточним сервером «Зоряних Примар», щоб створити потрібне?

       Перейменуємо TShip в TBody і це буде будь-яка тушка з НР, на яку можна напасти, будь то PC або NPC. TDevice так і залишимо – це буде предмет, яким можна одягнути на тушку (кільце, плащ, кинджал і т. д.). Мікромодулі перейменуємо в руни, і ними можна буде посилити одеваемый шмот. TAmmoPack теж так і залишимо – адже у ельфа може бути цибулю, а луку потрібні стріли. TGoodsPack так само без змін – це будь-які ресурси, адже відьмарям потрібні всякі квіточки і корінці мандрагори для варіння зілля. Залишилося тільки вирішити питання з магією. Додамо один динамічний параметр Mana в тушку (TBody), створимо клас TSpellProto і TSpell, а так само клас TSpellBook, нащадка від TCargoBay. У TSpellBook можна покласти тільки TSpell, а сам TSpellBook додамо в TBody. Можливість кастовать (cast) заклинання – це метод, аналогічний TShip::FireSlot. Тепер ми можемо створити ельфа або дракона (або кабана чи хто там нам потрібен), одягнути його і «записати» у його spell book заклинання. Фактично всі зміни в цьому модулі зводяться до перейменування класів і невеликий правці для додавання магії.

       Перейменуємо модуль Space World, а клас TSystem в TLocation. Переміщення між локаціями зробимо з допомогою телепортів. Телепорт, як ви здогадалися, це колишній TStarGate. Далі, TSpaceObject перейменуємо в TWorldObject, а TSpaceShip в TWorldBody. Тепер у нашого ельфа (або дракона) з'явилися поточні координати і йому можна віддати команду на переміщення. Правда, він не перевіряє перешкоди, а при повороті нарізає кола і намагається зробити «бочку». Логіку поведінки TSystem і команд руху доведеться повністю переробити, і це буде найбільш витратна і важка частина роботи з адаптації сервера під гру про ельфів.

       Якщо в модулі Space були перероблені в тому числі і Actions, то модуль AI запрацює практично відразу, правда без використання магії. Якщо NPC повинні використовувати магію, то доведеться дописати код, аналогічний використанню спец.зброї на рівні TBaseAI.

       Модуль Quest буде слабо змінений за умови, що квестова система не зрада. Максимум — доведеться щось змінити в спауне предметів в космосі (вірніше, вже в локації).

       Модуль Main і Packets залишаться практично без змін. Там додадуться якісь пакети на використання магії, забереться ангар і це, власне, все. Заміна рецептів крафта, типів бафів і т. д. – це все гейм-дизайнерська робота, виконується через адмінку і до програмування відношення не має.

       Ось і все, сервер для гри про ельфів готовий. Залишилося тільки почекати півроку, поки намалюють графіку і зроблять клієнт :).

Джерело: Хабрахабр

0 коментарів

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