Разработка ММО РПГ – практическое руководство. Сервер (часть 1)
Игровой backend: из каких модулей он должен состоять? Расчет параметров персонажа: виртуальные методы или сложение массивов? Логика поведения: на каком уровне она должна находится? Перемещение персонажей: кто этим должен управлять? Сегодня мы продолжим знакомиться с разработкой и проектированием он-лайн игры на примере космической ММО RPG «Звездные Призраки». В этой статье речь пойдет о backend’е на С++ и она будет насквозь техническая. В тексте будет много отсылок к функционалу «Звездных Призраков», но я постараюсь излагать материал так, чтобы вам не было нужды вникать (и играть) в наш продукт. Однако, для лучшего понимания материала желательно потратить пару минут и посмотреть, как это все выглядит. В статье мы сосредотчимся именно на архитектурных решениях применительно к backend’у MMO RPG в реальном времени. Исходного кода будет не много и он точно не будет содержать таких специфических для С++ вещей как множественное наследование или шаблоны. Задача данной статьи помочь в проектировании игрового сервера и ознакомить всех желающих со спецификой игрового backend’а. Описываемые решения достаточно универсальны и вполне подойдут для многих RPG. В качестве иллюстрации в конце статьи я приведу пример использования описанной архитектуры в игре «про эльфов». Выбор технологии Чтобы реализовать задуманный нами геймплей, был необходим сервер с постоянным сокетным соединением и достаточно малым временем отклика на действие любого пользователя — не более 50 мс, не считая пинга. Выбор технологий, которые позволяли удовлетворить такие требования, не так уж и велик. На тот момент у нашей кампании уже был опыт реализации backend’а на С++ для неигрового проекта, и поэтому выбор был сделан в пользу именно С++: у нас были и люди, и опыт в этой технологии. Возможно, Java (или некая другая технология) была бы лучшим решением, но в нашей команде не было сильного Java-разработчика, не говоря уже об архитекторе с опытом создания серверных решений. В такой ситуации нанимать новых специалистов, тратить месяцы и десятки тысяч долларов на то, чтобы проверить что лучше, а так же выкинуть работающий и оттестированный код на С++, который мы легко могли повторно использовать — все это далеко выходило за рамки нашего бюджета и отведенного времени на разработку. Я затрудняюсь ответить каким бы вышел сервер на Java (или какой-то другой технологии), но на С++ мы получили именно то, что нам требовалось, к тому же за вменяемые сроки. Общая схема сервера Сервер состоит из следующих модулей (см. рис. 1).
Ship содержит данные об устройствах и текущих параметрах корабля, а так же занимается расчетом этих параметров в соответствии с установленными устройствами. Это самый нижний уровень, на который опираются все остальные модули сервера. Space — это модуль описания мира и объектов в мире. На этом уровне у объектов появляются их координаты, текущие векторы движения, реализовано взаимодействие объектов (обработка выстрелов, нанесения урона и прочее). AI — это модуль, реализующий AI мобов, а так же специфические навыки NPC. Quests — реализация квестовой системы. Main — содержит весь функционал, отвечающий за взаимодействие с пользователем (сокеты, потоки и пр.), а так же специфический для персонажа функционал (навыки, бафы, достижения, крафт и прочее). Packets — автогенерируемый модуль, содержащий обертки для пакетов и реализующий RPC клиент<->сервер. В этой части статьи мы рассмотрим архитектуру модулей Ship и Space. Архитектура остальных модулей будет рассмотрена в следующей части этой статьи.
Модуль Ship Прототипы предметов и предметы Этот модуль оперирует материальными объектами типа «предмет». То есть со всем, что можно положить в трюм, выбросить в космос, купить в магазине или передать другому игроку. Так же этот модуль считает базовые и производные от установленных устройств параметры корабля.
На рис. 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
if (m_pStaticModifier) m_xStatic.Add (*m_pStaticModifier);// прибавим навыки пилота, бафы и прочее, что приходит сверху
// и вот тут ещё момент — нужно прибавить ко всему оружию параметры, которые висят на корпусе for (unsigned i=0; 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 (для большей наглядности диаграмма несколько упрощена).
Не смотря на алгоритмическую сложность, с архитектурной точки зрения этот модуль достаточно прост. Все объекты в космосе (корабли, астероиды, планеты, контейнеры, звезда) являются потомками 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, а так же некоторые аспекты работы с БД. И, конечно же, обещанную адаптацию для игры «про эльфов».