28/10/2019

Разработка мультиплеера на Blueprints. Часть 2

Разработка мультиплеера на Blueprints. Часть 1

Типы серверов

Существуют два типа серверов: listen и dedicated. У каждого из них свои особенности запуска и существования в сетевом пространстве. Стоит запомнить, что вся серверная логика вычисляется только на центральном процессоре (CPU), в то время как визуализацией занимается графический процессор (GPU).

Listen-сервер

Когда клиент запускает у себя на машине и игру и сервер в виде одного процесса — это и есть listen. Все подключенные клиенты отсылают ему запросы, машина их "обдумывает" и возвращает соответствующий вердикт.

Но дела с самой машиной, на которой хостится сервер, обстоят куда интереснее. Получается, что у хост-игрока есть доступ и к клиенту, и к серверу, а это значит, что он… Может читерить!

Все верно. Именно поэтому в кибер-спортивных дисциплинах никогда не используется этот тип серверов: он слишком простой и неконтролируемый. К тому же, если хост-игрок случайно выйдет из игры, весь прогресс вместе с ним уйдет в небытие.

Чтобы активировать listen-сервер в редакторе, достаточно выставить двух игроков в настройках запуска. Первое запущенное окно и будет нашим серверо-клиентом. 

В своей игре я использую dedicated сервер, поэтому, если вам интересен именно listen-тип, вы можете изучить эту тему по туториалу на канале Epic Games.

Плюсы:

  • Легко настроить. Все необходимые функции есть в блюпринтах.
  • Билдится вместе с игрой, не требуя дополнительных манипуляций вне движка.

Минусы:

  • Чувствителен к типу подсоединения. Если вы с товарищами не соединены по локальной сети, у вас вряд ли получится подключиться к хост-машине.
  • Огромные просторы для читерства. Поэтому listen-серверы зачастую используют в кооперативных сюжетных играх, где читерить, собственно, не имеет смысла.

Dedicated-сервер

Dedicated — он же выделенный сервер — запускается как отдельный от игры процесс. Запустить его можно как на клиентском компьютере, так и на специально выделенной для этого машине.

Проще всего представить выделенный сервер в виде консоли, в которой отображается все происходящие внутри события (иначе говоря — логи) и с которой мы можем взаимодействовать вне игры. Т.е. выделенный сервер может прекрасно жить и без подключенных к нему игроков.

Чтобы включить выделенный сервер внутри редактора, достаточно поставить галочку в соответствующем окошке.

С выделенным сервером дела обстоят куда серьезнее. Внутри редактора он будет работать по нажатии одной кнопки, но вот чтобы сервер работал в упакованной игре — это совсем другой разговор. Для этого нам придется сделать три вещи:

  1. Забилдить движок с Гита. Иначе говоря, мы должны собрать кастомную версию движка от Epic Games. Туториал, как это сделать.
  2. Создать C++ класс в проекте и изменить некоторые значения в исходном коде. В ссылке выше описано, как это сделать.
  3. Забилдить сам сервер.

Плюсы:

  • Имеет широчайший набор параметров для настройки в силу открытого кода.
  • Практически 100% защита от читерства. Только мы имеем доступ к консоли.
  • Работает без хост-клиента.

Минусы:

  • Очень муторная подготовка и настройка. Полное осознание может занять от двух дней.
  • Невыгодно с точки зрения финансов. Т.к. игроки не могут сами запустить выделенный сервер (в некоторых ситуациях все же могут), нам придется закупать машины для хоста.

Какой тип сервера использовать?

Опять же — все зависит от целей. Если вы планируете делать сюжетный кооператив — однозначно используйте listen. Если ваша цель — создать сессионный шутер или целую киберспортивную дисциплину — используйте dedicated.

Теперь, когда мы изучили основы работы с сетью, можно подступиться к детальной разработке. 
Весь дальнейший код предполагает использование dedicated-сервера.

Закладываем фундамент

Game Mode

Начнем с главного — Game Mode. Это сердце нашей игры и, по совместительству, сервера. И существует он только на сервере, поэтому все данные и функции, которые мы там храним, надежно защищены от любопытных игроков.

Все базовые манипуляции по типу спавна и выбора стартовых персонажей, паузы, перехода между уровнями и хранения данных о количестве игроков мы должны проводить через Гейм Мод

Алгоритм назначения выбранных персонажей. Если игроки не договариваются между собой (а так бывает часто!), приходится вмешиваться разработчику.

Game State

Так как Гейм Мод существует только на сервере, нашим посредником в общении с ним является Game State, который реплицируется на все клиенты. В Гейм Стейте принято хранить любые данные, изменяющиеся в течение игры. Например: подсчет очков команды, таймеры начала и конца раунда, распределение по командам — все то, что мы можем без всяких зазрений совести показать игрокам. 

Пример того, как мы начинаем игру в Гейм Стейте. Чтобы клиенты не могли повлиять на отсчет времени до начала раунда, мы с помощью Switch Has Authority выполним логику только на сервере. 

Параллельно создадим персонажа и настроим ему репликацию. 

Также не забываем создать собственные классы Player Controller и Player State. Остальное нас пока не интересует.

Даем Гейм Моду знать, какие базовые классы ему использовать.

Player Controller

Этот класс является первым инструментом игрока для связи с сервером. Он существует и на сервере, и на клиенте, поэтому между ними всегда будет открытый канал, по которому можно общаться. Также из Плеер Контроллера мы можем спокойно связаться с Гейм Стейтом, а через Гейм Стейт — с Гейм Модом.

Например, через Контроллер мы можем сообщить Гейм Стейту о нашей готовности к игре.

  

И, когда у нас наберется достаточно таких "подписей", через Гейм Стейт мы можем сообщить Гейм Моду о начале раунда.

Player State

Плеер Стейт предназначен больше для геймплейных манипуляций. В этом классе выгодно хранить информацию о набранных очках и состоянии персонажа (жив / мертв). 

Через Плеер Стейт мы так же, как и в Контроллере, можем оповещать сервер о присутствии игрока.

Теперь, когда у нас есть база, мы можем приступить к созданию конкретных геймплейных фич.

Разрабатываем фичи

Боевая система

Разберемся сперва с межсетевой боевкой. Т.к. наш персонаж может сражаться только с ботами, придумывать каких-то хитросплетений в получении урона не придется. Принцип нанесения урона даже по AI примерно такой же, как и с игроками.

Посмотрим, как происходит сама атака. Безусловно, она должна выполняться через монтаж, а в монтаже должны содержаться нотифаи, которые и сообщают оружию, когда надо совершать проверку на попадание.

Да, Рыцарь без ног и головы. Но нас интересует один нотифай, а вернее Notify State — Hit.
В нем мы ищем эктор компонент, занимающийся вооружением, вытаскиваем из него ссылку на оружие и включаем на нем коллизицию для обнаружения попадания. По прошествии нотифая коллизия отключается — это сделано в целях оптимизации. 

Теперь — про само оружие. Это реплицируемый класс, экземпляры которого должны спавниться только на сервере. Собственно, на сервере мы и будем проверять, попал ли в коллизию какой-нибудь объект; а также отправлять серверу запросы на нанесение урона.

BP_CollisionHandler — Actor Component, созданный для сортировки врагов, по которым мы совершаем удар. Т.к. принципы получения урона у AI и игрового персонажа немного различаются (несмотря на то, что интерфейс один), мне пришлось сделать для этого разные функции.

Так выглядит ивент получения урона у AI. Т.к. бот существует в двух копиях (на сервере и на клиенте), а нам нужно просчитать урон лишь единожды, то мы выполняем эту операцию только на сервере с помощью Authority.

Я предпочел не использовать встроенные функции нанесения урона, а создал свою с помощью Blueprint Interface.

Через интерфейс можно передавать любую информацию об атаке.

Интерфейсы прекрасно работают по сети, более того, они позволяют нам передавать дополнительную информацию об атаке, что, при желании, можно сделать через класс DamageClass и функцию ApplyDamage.

Так выглядит функция интерфейса, прописанная в родительском классе персонажа. Здесь просчитываются такие вещи, как уменьшение здоровья, отброс персонажа, эффекты крови, проверка на то, оказался ли удар смертельным и т.д.

Не стоит думать о мультиплеерной боевке как об отличной от синглплеерной. Принцип тот же — определяем цепочку нанесения урона (от оружия, от рук, от проджектайла) и просто передаем данные серверной копии эктора, которая произведет конечные вычисления.

AI

Боты — довольно простая тема. Они всегда управляются сервером и реплицируются на клиенты, поэтому "плохая связь = тупящие боты". 

Настройки репликации в базовом классе AI.
Этот ивент запускается с сервера. Он сообщает, в какую точку было попадание и было ли оно критическим.
Так как у бота уже реплицируется движение, достаточно лишь переместить его на сервере.

Помните, что бот существует и на клиенте. Поэтому смело пользуйтесь Switch Has Authority и следите за тем, на какой сущности выполняется код.

3D карта мира

Самой сложной частью разработки Greed стал 3D-интерфейс карты, с помощью которого второй персонаж влияет на реальную карту мира.

Здесь я совершил огромную ошибку, не продумав, как будет проходить коммуникация между "реальным" и "виртуальным". Во-первых, репликация быть должна — и быть должна она только на клиенте, владеющем картой. Это значит, что она релевантна только своему владельцу.

Сетевые настройки класса 3D карты.

Благодаря этому мы можем понизить нагрузку на другие клиенты, т.к. серверу не придется "раздавать" информацию на все копии реплицируемого объекта. Всякие конструкции и эффекты на карте будут исполняться только на копии владельца. 

Не забудем, что карта должна уметь спавнить объекты. Мы подошли к важному правилу мультиплеера — спавнит объекты только сервер.

Когда мы делаем Spawn Actor from Class, сервер создает у себя главную копию объекта и автоматически реплицирует копии на все остальные клиенты. Если мы будем спавнить объект через Multicast, то в сессии появятся две копии объекта, притом про одну из них сервер будет ни слухом ни духом.

Спавним объект на реальной локации только через сервер. Всю информацию можно заранее просчитать на клиенте.

Также карта должна знать, какие функциональные объекты ей черпать из реальной локации. Для этого объекты, изменяющиеся с течением игры, должны быть реплицируемые. Если мы спавним статичную колонну, то и репликация ей, скорее всего, не понадобится.

Так сложилось, что в моей игре коммуникация второго персонажа с реальной локацией проходит в два этапа: с игрового интерфейса на 3D карту и с 3D карты на реальную. Второй этап мы только что обсудили, поэтому сейчас мы перейдем к первому и рассмотрим, как с помощью виджетов отправлять запросы на сервер.

Репликация Виджетов

Я выделил виджеты в отдельную тему, потому что у меня ушло несколько месяцев на осознание того, что… Репликации виджетов не существует. Да, эти, казалось бы, примитивные объекты имеет муторную систему коммуникации с сервером, потому что существуют они только на клиенте.

Для общения виджетов с сервером нам нужен посредник. И так получилось, что на момент появления на экране виджет знает только своего владельца. Ага! Это тот, кто нам нужен!

Вместо персонажа рекомендуется брать посредником именно его контроллер. В этом случае, если персонаж уничтожится, виджеты продолжат существовать вместе с контроллером.

Теперь необходимо создать нужный ивент на персонаже и, закастовав к владельцу, вызвать этот ивент прямо в виджете.

Ивент запустится безо всяких проблем, т.к. мы вызываем его с владельца.

Есть и второй вариант — во время создания виджета получить ссылку на другого посредника. Например, 3D карту. 

Чтобы на ноде Create Widget отобразился пин на референс, мы создадим переменную внутри этого виджета и поставим ей галочки на Instance Editable и Expose on Spawn.

При выборе посредника не забудьте, что его класс должен быть реплицируем.

Сетевая оптимизация

Т.к. эту тему мы уже затрагивали, предлагаю вывести набор правил, которых стоит придерживаться во время всей разработки:

  • Реплицируйте только тех экторов, которые важны для геймплея и меняются с течением игры.
  • Выставляйте соответствующий NetUpdateFrequency. Чем важнее частое обновление эктора — тем выше должно быть значение.
  • Reliable RPC должны использоваться только на геймплейно важных ивентах.
  • Просчитывайте на сервере только геймплейно важные куски кода. Визуализацию и менее важные вещи оставьте клиентам.
  • Клиентов старайтесь тоже не нагружать. Порой что-то выгоднее просчитать на сервере, чем скидывать эту ношу на клиента.
  • Не используйте сетевую релевантность, если она вам не нужна.
  • Используйте NetCullDistance для оптимизации клиента. Однако при входе эктора в эту дистанцию он будет заново создан, что может нагрузить клиент сполна.
  • Используйте NetworkProfiler — встроенную утилиту для обнаружения сетевых особенностей вашего кода.

На хабре есть статья, посвященная сетевой оптимизации. Обязательно уделите ей внимание, особенно, если вы работаете на C++.

Бонус

Один из разработчиков Mail.ru собрал актуальный вопросник по работе с сетью, который упоминается в этой статье. Мы попробуем ответить на некоторые из представленных вопросов. Заодно читатели проверят полученные из этой статьи знания, а я — достоверность знаний, которые вам преподал :)

Вопрос: где хранить геймплейные данные пользователя (например, инвентарь или количество очков) во время игровой сессии? Является ли APlayerController подходящим местом?
Ответ: и Player Controller и Player State уничтожаются при отключении игрока от сервера. Так что — нет, хранить данные в них ненадежно. Можно хранить данные в персонаже или любом другом объекте, если в процессе игры мы его не уничтожаем. Идеальным решением будет хранить данные в GameInstance, который сохраняется даже при переходе на другой уровень. Есть вариант поймать момент, когда игрок отключается от сервера, и забрать всю информацию, хранящуюся в его Контроллере или Стейте. К сожалению, пока эта функция доступна только в C++. 

Вопрос: как PlayerState'ы реплицируются на клиенты? Каков жизненный цикл PlayerController'а и PlayerState'ов при переподключении игрока?
Ответ: сервер отправляет значения переменных на все клиенты. Все все знают друг о друге. Контроллер и Стейт уничтожаются при отключении игрока или его переходе на новый уровень, поэтому при переподключении под игрока будут созданы новые Контроллер и Стейт.

Вопрос: на каких классах можно вызывать RPC и почему? Можно ли расширить эту возможность на другой класс/сущность? Какие накладные расходы существуют на вызов RPC?
Ответ: RPC можно вызвать на любом объекте, находящемся в сцене, т.е. — экторе. Но чтобы вызов дошел до сервера, контроллер обязан владеть эктором, с которого он хочет отправить RPC.
Эту возможность можно также встроить в Actor Component, который, по сути, является модулем, надбавкой к уже имеющемуся классу. RPC должен быть обоснован, равно как и параметр Reliable. Если мы все можем просчитать на клиенте — то и смысла обращаться к серверу нет. Если злоупотреблять Reliable, можно запросто забить сервер пакетами.

Вопрос: как реплицируются массивы? И как реплицируются вложенные свойства (реплицируемое поле является структурой)?
Ответ: обновление всех элементов массива происходит одновременно при добавлении / удалении / перемещении одного из элементов. Структуры же реплицируют только измененный элемент. Эта фишка была добавлена в UE4, до этого в третьей версии движка структура реплицировалась как эктор, т.е. все и сразу.  

Вопрос: какие ограничения сети существуют? (Цифры!) Как профилировать нагрузку на сеть? Какие методы оптимизации сети применяются? Для чего нужен ForceNetUpdate?
Ответ: во-первых, это частота обновления эктора, которую мы уже рассмотрели. По-умолчанию частота обновления клиента равна 100 раз в секунду. Очевидно, что клиент не сможет так часто обновляться, если его ФПС, конечно, не превышает эту цифру. Выставляйте адекватную частоту обновления. Во-вторых, это приоритет эктора при обновлении. Его значение может быть от 1 до скольки угодно. Если перейти на общие вещи, то сюда можно приписать максимальное количество игроков на один сервер, рекорд по которому превысил 140 подсоединенных клиентов. По большей части, вместимость сервера зависит от железа, на котором хостится сессия.
Чтобы профилировать сеть, достаточно тщательно выбирать реплицируемых экторов. Если в этом нет необходимости — выключите галку с репликации или понизьте его частоту обновления.
Также рекомендуется следить за тем, что вы выполняете на сервере, а что — на клиенте. Если кусок кода не особо важен для геймплея — нагрузите клиент, пусть сам все посчитает.
Общее решение — запустите утилиту Network Profiler и отследите проблемные места в вашем сетевом коде.

ForceNetUpdate вызывает моментальное обновление эктора на всех клиентах. Особенно это полезно, когда вы используете низкую частоту обновления.

Заключение

Старайтесь не думать о сети как о чем-то отличном от обычного кода. Принцип работы остается таким же — вся информация течет последовательно сквозь ноды и через какое-то время достигает места своей реализации. Мультиплеер лишь накладывает такие условия, как выбор сущности, на которой исполняется код; частота синхронизации; типизация сервера и множество других аспектов, которые мы обсудили.

Самое главное — не бойтесь экспериментировать — UE4 предоставляет вам весь необходимый для этого инструментарий.

Текст: Олег Киселев

Recent Posts

Терпение, труд и Backbone

07/11/2019
О Backbone не слышал только ленивый. Детективная игра с элементами стелса в сеттинге антиутопичного Ванкувера. Рассказываем, как пиксельная игра в оболочке Unreal Engine шагает к успеху.

Кому принадлежит ваша игра?

05/11/2019
Специалисты компании Social Quantum помогают нам разобраться, какие шаги необходимо предпринять и какие документы оформить, чтобы избежать споров о правах на игру.

Разработка мультиплеера на Blueprints. Часть 1

28/10/2019
Разработчик проекта Greed делится не только выработанными приемами, но и ошибками, которые совершил во время работы с сетью.