- Игровой backend: из каких модулей он должен состоять?
- Расчет параметров персонажа: виртуальные методы или сложение массивов?
- Логика поведения: на каком уровне она должна находится?
- Перемещение персонажей: кто этим должен управлять?
Сегодня мы продолжим знакомиться с разработкой и проектированием он-лайн игры на примере космической ММО RPG «Звездные Призраки». В этой статье речь пойдет о backend'е на С++ и она будет насквозь техническая.
В тексте будет много отсылок к функционалу «Звездных Призраков», но я постараюсь излагать материал так, чтобы вам не было нужды вникать (и играть) в наш продукт. Однако, для лучшего понимания материала желательно потратить пару минут и посмотреть, как это все выглядит.
В статье мы сосредоточимся именно на архитектурных решениях применительно к backend'у MMO RPG в реальном времени. Исходного кода будет не много и он точно не будет содержать таких специфических для С++ вещей как множественное наследование или шаблоны. Задача данной статьи помочь в проектировании игрового сервера и ознакомить всех желающих со спецификой игрового backend'а.
Описываемые решения достаточно универсальны и вполне подойдут для многих RPG. В качестве иллюстрации в конце статьи я приведу пример использования описанной архитектуры в игре «про эльфов».
Выбор технологии
Чтобы реализовать задуманный нами геймплей, был необходим сервер с постоянным сокетным соединением и достаточно малым временем отклика на действие любого пользователя — не более 50мс, не считая пинга. Выбор технологий, которые позволяли удовлетворить такие требования, не так уж и велик. На тот момент у нашей кампании уже был опыт реализации backend'а на С++ для неигрового проекта, и поэтому выбор был сделан в пользу именно С++: у нас были и люди, и опыт в этой технологии.
Возможно, Java (или некая другая технология) была бы лучшим решением, но в нашей команде не было сильного Java-разработчика, не говоря уже об архитекторе с опытом создания серверных решений. В такой ситуации нанимать новых специалистов, тратить месяцы и десятки тысяч долларов на то, чтобы проверить что лучше, а так же выкинуть работающий и оттестированный код на С++, который мы легко могли повторно использовать – все это далеко выходило за рамки нашего бюджета и отведенного времени на разработку.
Я затрудняюсь ответить каким бы вышел сервер на Java (или какой-то другой технологии), но на С++ мы получили именно то, что нам требовалось, к тому же за вменяемые сроки.
Общая схема сервера
Сервер состоит из следующих модулей (см. рис.1).
![Общая схема сервера](https://habrastorage.org/files/6ac/785/b51/6ac785b518dd4e19bf3666f5302f6b28.png)
- Ship содержит данные об устройствах и текущих параметрах корабля, а так же занимается расчетом этих параметров в соответствии с установленными устройствами. Это самый нижний уровень, на который опираются все остальные модули сервера.
- Space – это модуль описания мира и объектов в мире. На этом уровне у объектов появляются их координаты, текущие векторы движения, реализовано взаимодействие объектов (обработка выстрелов, нанесения урона и прочее).
- AI – это модуль, реализующий AI мобов, а так же специфические навыки NPC.
- Quests – реализация квестовой системы.
- Main – содержит весь функционал, отвечающий за взаимодействие с пользователем (сокеты, потоки и пр.), а так же специфический для персонажа функционал (навыки, бафы, достижения, крафт и прочее).
- Packets – автогенерируемый модуль, содержащий обертки для пакетов и реализующий RPC клиент<->сервер.
В этой части статьи мы рассмотрим архитектуру модулей Ship и Space. Архитектура остальных модулей будет рассмотрена в следующей части этой статьи.
Модуль Ship
Прототипы предметов и предметы
Этот модуль оперирует материальными объектами типа «предмет». То есть со всем, что можно положить в трюм, выбросить в космос, купить в магазине или передать другому игроку. Так же этот модуль считает базовые и производные от установленных устройств параметры корабля.
![Диаграмма классов модуля Ship](https://habrastorage.org/files/469/3fd/821/4693fd8211b245e2a48431807d900df4.png)
На рис. 2 представлена диаграмма классов (диаграмма упрощена для большей наглядности). Вы видите разделение классов на две части: внизу прототипы предметов, а вверху – сами предметы. Прототипы полностью статичны и безлики – они загружаются из БД, не могут изменяться и никому не принадлежат. А объекты предметов (все потомки от ICargo), наоборот, могут быть модифицированы и содержат в себе уникальный ID, который позволяет идентифицировать конкретный предмет и определить в каком месте он находится (трюм, склад, контейнер в космосе, магазин и т.д.). Такой подход добавляет гибкости и позволяет модифицировать функционал предметов, не затрагивая другие классы.
В нашем решении большинство потомков ICargo (вернее все, кроме TDevice и TShip) являются просто проксями для своих прототипов. Тогда возникает вопрос: а так ли они были нужны? Ведь проще создавать потомков прототипов, с добавлением уникального ID для идентификации, да и дело с концом? Нет, не проще. Но при таком подходе, во-первых, нам все равно потребовалось бы два класса на предмет (прототип и потомок), а во-вторых, у нас бы смешивались динамические данные со статическими (ведь прототипы неизменны). Вдобавок ко всему, конечно же, увеличился бы расход памяти и время создания предмета, потому что необходимо было бы клонировать прототип со всеми его полями. В подтверждение сказанному приведу такой пример: изначально у нас в игре не было чипов, и когда они появились, то все изменения свелись к добавлению пары классов TMicromodule/TMicromoduleProto с добавлением функционала по учету установленных чипов в TDevice. Класс TShip, как и все прочие классы, не был затронут вообще.
Расчет параметров корабля и обрудования
В «Звездных Призраках» есть много различных типов устройств (турели, ракетницы, радар, система маскировки, защитное поле, усилители урона и прочее). Казалось бы, для каждого из них необходимо делать класс-поток от TDevice и реализовывать там специфичный функционал для этого устройства. Но давайте еще раз взглянем на общую схему сервера и описание модуля Ship: этот модуль, в основном, просто предоставляет итоговые расчетные параметры корабля более верхнему уровню, при этом сам функции предметов не выполняет. Поясню на примере. Класс TShip содержит параметр ScanningRange – радиус работы радара, – но фактическую фильтрацию объектов по дальности он не делает. И, что самое главное, на уровне модуля Ship сделать эту фильтрацию не получится, так как у объектов нет координат в пространстве. Самое время спросить себя: есть ли смысл создавать пару классов TRadarPrototype (как потомка от TProtoBase) и TRadar (как потомка от TDevice), отдельную таблицу в БД для этого класса и страницу в админке только ради одного поля ScanningRange? Ответ очевиден: смысл всех этих строк кода и классов весьма сомнителен. Именно поэтому мы создали один класс TStaticParams, содержащий в себе все параметры, которые могут быть у любого устройства в игре, а также класс TPrototypeMod, который может загружать из БД TStaticParams.
Конечно это излишество, но не очень большое: на данный момент класс TStaticParams содержит всего 34 поля типа int. А вот взамен мы получили несколько отличных плюшек. Во-первых, простоту модификации. Теперь можно создавать новые типы устройств и параметров без создания новых классов. Во-вторых, простоту подсчета параметров. Достаточно просто сложить все одноименные поля всех TStaticParams в корабле, чтобы получить итоговые параметры! Никаких виртуальных вызовов или downcast'ов – простая операция «+=» в цикле. В-третьих, мы получили геймдизайнерскую гибкость. Например, у нас в игре есть чип, который может быть установлен в любое устройство, и дает он НР. Такой механизм позволяет геймдизайнерам резвиться так как им захочется, при этом абсолютно не дергая программистов по каждой мелочи типа «ребзя, допишите мне тут капарик, чтобы я мог задавать в устройстве маскировки бонус к уклонению».
И это еще не все. Так как у нас один класс с параметрами для любого устройства, нам очень легко удалось реализовать рандомизацию параметров и заточку. TStaticParams – это массив, поэтому в админке геймдизайнер при создании устройства может указать до трех параметров (индексов в массиве), и процент разброса в этих параметров. При создании предмета, TDevice в первую очередь копирует данные из TPrototypeMod.TStaticParams в свой экземпляр TStaticParams. Потом он просматривает индексы разброса и если они установлены, бросает кубик и рандомизирует параметры. Значение кубика сохраняется в полях TDevice, чтобы после загрузки из БД параметры не изменились. Заточка выполняется аналогично: в админке геймдизайнер указывает MainParam для устройства. То есть устройство знает индекс параметра, который необходимо увеличить на +10% за каждую успешную заточку.
Но есть один нюанс при расчете параметров оружия: их нельзя просто суммировать с параметрами остальных устройств. Простое суммирование приведет к тому, что если у вас установлено больше одного оружия, то вы сложите, в том числе и такие параметры, как WeaponRange всех пушек на борту, хотя так быть не должно. С другой стороны, если это артефакт, который увеличивает радиус действия оружия, то мы должны прибавить его к WeaponRange оружия. Мы решили эту проблемы следующим образом: во-первых, TStaticParams содержит два массива – общие параметры, которые всегда можно складывать безопасно (например, НР, ScanningRange и т.д.) и так называемые WeaponParams, которые в общем случае складывать нельзя. И только если устройство не является оружием, его параметры необходимо прибавить к параметрам оружия. Выглядит это все так:
void TShip::Recalc() {
m_xStatic.Set(0);
TDevice* dev = NULL;
for(unsigned i=0;i<m_vSlots.size();i++) {
dev = m_vSlots[i].Device();
if( !dev || !dev->IsOnline() ) continue;
if( dev->IsWeapon() ) {
m_xStatic.AddDevice( dev->Static() );//типа HP там прибавать
} else {
m_xStatic.Add( dev->Static() );
}
}//for i
if(m_pStaticModifier) m_xStatic.Add( *m_pStaticModifier );// прибавим навыки пилота, бафы и прочее, что приходит сверху
// и вот тут ещё момент - нужно прибавить ко всему оружию параметры, которые висят на корпусе
for(unsigned i=0;i<m_vSlots.size();i++) {
dev = m_vSlots[i].Device();
if( !dev || !dev->IsOnline() || !dev->IsWeapon() ) continue;
dev->SetWeapon( &m_xStatic );
}//for i
}
В первом цикле мы суммируем все параметры к итоговым параметрам корабля, но для оружия прибавляем только общие параметры, не оружейные. Потом прибавляем параметры навыков. И, в самом конце, даем оружию указатель на TStaticParams из которого оно должно прибавить только оружейные параметры.
Расчет выстрела
Кроме расчета параметров устройств и проверки на возможность установки в слот, класс TShip выполняет еще одну функцию – расчет параметров выстрела. Делает это метод SFireResult TShip::Fire(NSlotPlace slot). Этот метод проверяет возможность выстрела (оружие ли это вообще, закончился ли кулдаун устройства, есть ли патроны для выстрела), рассчитывает наносимый урон, количество произведенных выстрелов, а также бросает кубик на допустимые флаги выстрела (такие, как нанесение критического урона). Все параметры записываются в структуру SFireResult, устройство ставится в кул-даун, списываются боеприпасы, результат выстрела возвращается. При этом TShip не может проверить ни дальность, ни параметры объекта, в который стреляют (например, если у объекта есть защита и урон нужно снизить). Это делает верхний уровень Space, который располагает всеми необходимыми данными.
Остальные классы модуля Ship
Класс TProtoBase содержит в себе общие данные для любого предмета, такие как ImageID, Name, Level и т.д.
ICargo содержит в себе указатель на TProtoBase и проксирует его данные наружу, а также предоставляет Factory для создания предметов. В этом ему помогает класс-синглтон TDeviceHandbook, который загружает из БД все протитипы и содержит указатели на них.
Класс TCargoBay – это хранилище объектов типа ICargo. Он умеет сохранять свое состояние в БД и предоставляет ряд сервисных функций таких как: поиск ближайшего свободного слота, поиск совместимого стакабельного предмета (например, патронов, чтобы объединить с другими патронами) и т.п. Потомки от этого класса накладывают ограничения на типы хранимых предметов (например, в ангаре можно хранить только корабли, а на складе – все кроме кораблей), и, по необходимости, ограничения на количество доступных ячеек для хранения.
Класс IShipNotifyReciver является интерфейсным и обеспечивает связь с более высоким уровнем. Например, передачу на уровень Main сообщения о начале регенерации, чтобы можно было отправить соответствующий пакет клиенту.
Модуль Space
Этот модуль оперирует космическими объектами (КО), такими как космические корабли, астероиды, планеты и т.д. У всех КО есть текущие координаты в космосе и вектор их движения. Диаграмма классов представлена на рис.3 (для большей наглядности диаграмма несколько упрощена).
![Диаграмма классов модуля Space](https://habrastorage.org/files/b6a/172/465/b6a1724655034202b7ba1fa64e7c1be3.png)
Не смотря на алгоритмическую сложность, с архитектурной точки зрения этот модуль достаточно прост. Все объекты в космосе (корабли, астероиды, планеты, контейнеры, звезда) являются потомками TSpaceObject и находятся в объекте типа TSystem. У TSpaceObject есть текущие координаты, размер и два объекта, управляющие его поведением – это FlyCommand (потомок от ISpaceFlyTo) и Action (потомок от ISpaceAction). FlyCommand вычисляет текущие координаты объекта и его текущую скорость (в данный момент времени). Алгоритм расчета зависит от типа команды: для движения по орбите он один, для линейного перемещения другой, для перемещения с плавными поворотами – третий. Action отвечает за более сложные алгоритмы перемещения объекта. Например, TFollowShipAction выполняет преследование указанной цели. Для этого она в каждом вызове Update проверяет, изменились ли координаты цели и если да, то заменяет в Owner команду FlyCommand (с указанными новыми координатами цели). Введение Action позволило существенно упростить создание AI и избежать дублирования кода, так как функционал, реализуемый в Action необходим и кораблям игроков и ботам.
Наличие FlyCommand позволяет легко задать необходимый тип движения для любого объекта в космосе и передать эту команду на клиент в виде коэффициентов уравнения движения. Это позволяет существенно уменьшить количество передаваемых данных и упростить реализацию нового поведения на стороне сервера.
Нанесение урона
У класса TSpaceObject есть два виртуальных метода – CorrectDamage и ApplyDamage, а у класса TSystem есть метод DoDamage. Когда какой-то объект хочет нанести урон другому объекту (например, астероид попал в другой объект), он сообщает это TSystem. Система вызывает CorrectDamage и, если урон не нулевой (например, планета имунна к любым видам урона), то она отправляет сообщение о нанесенном уроне «наверх» (чтобы передать в клиенты) и вызывает ApplyDamage, чтобы реципиент выполнил специфические действия (например, корабль уменьшает НР и если НР равно нулю, корабль выбрасывает контейнеры в космос).
Класс TSpaceShip содержит метод FireSlot, который реализует стрельбу спец.абилками. Он проверяет допустимую дистанцию, потом вызывает TShip:: Fire и в зависимости от типа абилки производит дальнейшие действия. Например, для MissileLauncher создает ракеты.
Остальные классы модуля Space
Класс ISpaceShipNotifyReciver используется в TSpaceShip для передачи в верхний модуль сообщений типа «меня атаковали», «я убит», «готов к гиперпереходу» и т.д.
Класс ISpaceSystemNotifyReciver используется в TSystem для передачи наверх сообщений о добавлении/удалении космических объектов, о новых FlyCommands и о нанесении урона.
Класс TGalaxy является синглтоном и содержит в себе список всех TSystem в Галактике.
To be continued
В следующей статье цикла мы рассмотрим модули AI, Quest, Main, а так же некоторые аспекты работы с БД. И, конечно же, обещанную адаптацию для игры «про эльфов».