Разработка мультиплеера на Blueprints. Часть 1
В русскоязычном коммьюнити (да и в любом другом) очень мало информации по разработке мультиплеерных игр на UE4. Хочу поделиться с читателями не только выработанными приемами, но и ошибками, которые совершил во время работы с сетью.
Статья рассчитана на людей, начинающих свой путь в создании мультиплеерных проектов, но уже овладевших базой блюпринтов. Весь материал взят из моего пока что невыпущенного проекта — Greed.
С чего начать?
Сразу определим важнейшую догму геймдева: порядок — в основе всего. Прежде, чем открывать движок, обязательно распишите концепт-док, не поленитесь зайти в Trello и создать список того, что вы вообще собираетесь делать. Пропустив этот шаг, вы рискуете нарваться на кучу переработок и переосмыслений своего детища в ближайшем будущем.
Организация папок
Допустим, все из вышеперечисленного у нас есть. Теперь начинается самое веселье — открываем движок. Плохим тоном считается закидывание пустого проекта всякого рода папками. Это моментально создает хаос в контент браузере из-за различающейся иерархии наборов.
Чтобы не упасть в грязь лицом на самом старте, посмотрим, какие существуют типы структуризации файлов.
Родственный
Все файлы, относящиеся к абстрактному объекту, хранятся в общей папке. Например, мы создали папку под персонажа. Скелет, текстуры, материалы, блюпринты и другие ассеты будут храниться в его личной директории.
Плюсы:
- Все близкие по семантике файлы находятся рядышком. Не приходится бегать по директориям.
- Легко запомнить, что и где находится.
Минусы:
- Частые повторы наименований папок.
- Высокий риск создать хаос внутри даже маленькой директории.
Категориальный
Файлы определенного типа хранятся в созданной для этого директории. Например, мы поместили все блюпринты в одну папку. Загвоздка в том, что существует множество классов, которые нужно логически разделить на подкатегории.
Плюсы:
- Все схожие файлы рядом друг с другом. Логично и просто.
- Легко соблюсти иерархию папок по убыванию важности. Наверху — “родители”, ниже — “дочки”.
Минусы:
- Блуждания по папкам в поиске связки файлов (например, персонажа и звука его шагов).
- Огромное количество файлов внутри одной директории. Взглядом едва получится что-то найти.
Что же выбрать?
Выбирайте, исходя из обстановки. Если вы работаете в одиночку, первый вариант, скорее всего, больше придется по душе. Для работы в команде предпочтителен второй в силу своей понятности.
И помните — ни в коем случае нельзя их совмещать! Представьте, что вы работаете в команде, и каждый расфасовывает файлы предпочтительным образом — контент браузер просто-напросто превратится в свалку, на чистку которой уйдет несколько дней. К тому же надо умудриться ничего не поломать.
Какие папки делать-то?
Я всегда придерживаюсь категориальной структуры:
- Assets — здесь хранятся все папки и файлы, относящиеся к мешам, текстурам, материалам и партиклам.
- Blueprints — здесь содержатся все блюпринтовые классы, начиная от персонажей и заканчивая нотифаями.
- Data — в этой папке хранятся структуры, енамы и дата тейблы — все, что хоть как-то можно отнести к статистике.
- Maps — игровые уровни и более ничего.
- Media — звуки, музыка, видеозаписи, сиквенсы и шрифты.
- UI — все виджеты.
Когда мы навели порядок и определили, где и что будет находиться, приступаем к осознанию того, в каком ключе нам придется работать с мультиплеером в UE4.
Морально готовимся к мультиплееру
В документации к UE4 есть отличная статья, покрывающая все аспекты работы с сетью. К сожалению, не каждый начинающий поймет, о чем там вообще говорится (к тому же на примерах C++). Поэтому я постараюсь изложить ее как можно проще в блюпринтовом варианте.
Итак, игровая логика разделяется на две сущности:
Клиент — машина игрока, визуализирующая графику игры и рассчитывающая не важные для геймплея вещи. Например, проигрывание анимации: чтобы не нагружать сеть пакетами, все движения костей просчитываются на машине игрока; сервер же только передает ссылку на нужную анимацию.
Сервер — машина, в которой существует наша игра. На сервере должны выполняться важные для геймплея куски кода, которые по необходимости сообщают какую-то информацию клиенту или вовсе скрывают ее от глаз игрока. У клиента не должно быть доступа к серверным переменным, иначе он сможет читерить. Например, просчет оставшихся патронов должен рассчитываться только на сервере, чтобы игрок не мог наплодить себе миллиард снарядов и получить преимущество над оппонентами.
Все сказанное, разумеется, предполагает условности. Если вам, например, необходимо отобразить ragdoll у всех клиентов одинаково — просчитайте его на сервере, никто не запрещает. Это ваше право как разработчика решать, что и где происходит.
Система на пальцах
Unreal Engine 4 предлагает разработчику систему RPC (Remote Procedure Calls), которая позволяет клиенту и серверу общаться друг с другом через ивенты.
По совместительству с этой системой существует другой тип общения — репликация переменных — передача данных происходит только с сервера на клиент. Сервер делится со всеми игроками тем, что он у себя посчитал.
Также мы можем реплицировать любой объект на сцене. Когда эктор реплицируется, он существует в двух копиях: на клиенте и на сервере. Если к серверу подключены два игрока, в игре уже две клиентские копии и одна серверная.
Пример
Мы подключились к сессии CS:GO и играем за террориста, в другой же команде — один спецназовец. Получается, сейчас в игре три копии террориста и три копии спецназовца. У каждого из персонажей собственный owner — владелец — у которого есть явное преимущество в управлении своим персонажем.
Наша беседа с сервером представляется в виде "запрос - обдумывание - ответ". Мы говорим серверу — хочу пойти вперед! Передаем ему вектор нашего движения, сервер обдумывает, можем ли мы туда пойти, и выносит окончательный вердикт в виде нашего перемещения. Другой игрок не сможет нас подвинуть — у него нет таких привилегий.
Но! Спецназовец все-таки видит наши перемещения. Как же это происходит? Обдумав наш запрос, сервер может послать ответ только нам, а может и всем клиентам, как в случае с перемещением.
А как происходит рассылка не на все клиенты, а конкретно на наш? Допустим, мы хотим посмотреть на свой новый купленный красивый ножик. Другим игрокам это ни к чему показывать — это может сбить их с толку.
Так как мы — заботливые разработчики, которые уберегают игрока от него же самого — добавим условие, что посмотреть ножик можно только тогда, когда по нам не стреляют. Для этого нужно обратиться к серверу и узнать, когда наносился последний урон:
— Сервер, все ок? Могу я ножик посмотреть?
— Так, с последней полученной пули прошло 5 секунд... Все отлично, братан. Отправляю разрешение только на твою копию персонажа.
Если бы просмотр ножика не влиял на геймплей, мы могли не спрашивать сервер, а просчитать все на клиенте и сразу запустить анимацию. Вы решаете, какой из способов лучше для вашей игры.
Переходим к коду
Как же выглядят эти запросы в коде? Логически все происходит так же, как и в примере с CS:GO, только с наложением некоторых условий.
Теперь рассмотрим эти самые условия, которые выражены в виде RPC. Это типы ивентов, которые позволяют разработчику настраивать "тоннели", через которые игроки будут отправлять запросы серверу, а сервер — свои ответы игрокам.
Чтобы превратить обычный ивент в RPC, нам достаточно кликнуть по вкладке “Replicates” в деталях ивента и выбрать тип передачи данных.
RPC
Далее стоит учесть, каким образом мы будем вызывать ивент. У каждого RPC ивента есть условие, только выполнив которое мы продолжим выполнение кода.
Теперь рассмотрим, в чем особенность каждого RPC и какие нюансы скрывает от нас движок.
Run on owning client
Ивент вызывается с сервера и проигрывается на клиентской копии объекта. Например, сервер проверил, может ли игрок посмотреть на свой ножик, и отправил ему разрешение на совершение этого действия.
Run on server
Ивент вызывается с клиента, но выполняется на сервере. Например, мы хотим проиграть анимацию атаки и не хотим нагружать сервер, поэтому все разрешения и типы атак просчитываем на клиенте.
Так или иначе, монтаж захочет подвинуть персонажа вперед, что потребует вмешательство сервера. Стоит знать, что единственно "правильная" копия персонажа находится на сервере, поэтому ему мы передаем только монтаж, который надо проиграть. Что же произойдет дальше — посмотрим в следующем в виде RPC.
Multicast
Вызывается на сервере и исполняется на всех клиентах. Мы запросили у сервера проигрыш монтажа и он дал добро всем клиентским копиям. Получается, каждый игрок увидит, как мы перемещаемся, да еще и проигрываем монтаж атаки.
Что за “Reliable”?
Так получилось, что сервер — не всемогущ. Иногда пакеты, которые игрок отправляет на сервер, или сервер — игрокам, могут затеряться в бесконечных просторах сети. Особенно часто это происходит, когда на сервере свыше десятка клиентов.
Чтобы мы убедились в том, что ивент точно произойдет и никто не потеряет свою часть пакетов, можно выставить галочку на Reliable в настройках ивента. Движок насильно протолкнет пакеты через сеть и получит ответ от клиента, что данные получены. Если нет — пересылка произойдет еще раз.
Когда использовать “Reliable”?
Но все не так просто. Злоупотребляя параметром Reliable, мы рискуем нарваться на высокую нагрузку сервера. Чтобы этого не произошло, ивенты, на которые мы его выставляем, должны быть геймплейно важны.
Представьте, если игрок выстрелит из оружия, увидит анимацию и эффекты от выстрела, но враг не получит урона. Это не игрок "криворукий", это запрос на выстрел затерялся и не дошел до сервера. Либо дошел до сервера, но затерялся на моменте рассылки всем клиентам.
Чтобы таких казусов не было, мы и выставляем Reliable. Но! Представим ситуацию, когда мы стреляем по рандомному ящику на столе. Его падение для игрового процесса совсем не важно, т.е. никак не повлияет на баланс. На такие события Reliable не нужен — нам ни к чему нагружать сервер просчетом визуальной части.
Разделяем логику
Чтобы лишний раз не создавать RPC ивенты, мы можем просто разделить уже имеющуюся логику.
На картинке ниже представлена функция OnRepDead; это значит, что когда переменная Dead изменяется на сервере, функция, привязанная к этой переменной, запустится сразу на всех клиентах и на сервере. Такие переменные имеют тип репликации OnRepNotify, который можно выставить во вкладке Details.
Следуем по цепочке: if Dead == True then… О боже! Неизвестная нам нода!
Спокойно! Эта функция очень проста — Switch Has Authority позволяет нам разделить логику на то, что происходит на сервере, и на то, что происходит на клиенте. Authority = сервер, Remote = клиент.
Получается, что вся техническая байда пойдет по ветке Authority, т.к. она геймплейно важна для нас; а вся визуальная (в нашем случае — включение рэгдолла на скелете) — по ветке Remote. Это сделано для того, чтобы сократить код и не возлагать на клиента или на сервер то, чем он заниматься не должен.
Реплицируемые переменные
Еще одной деталью при общении сервера и клиента являются переменные. Здесь все гораздо проще: переменная идет только с одного конца в другой — с сервера на клиент.
Если переменная реплицированная, это значит, что единственно верное ее значение содержится только на сервере и только сервер может его изменить, что приведет к перезаписи значения и на клиенте. Не доверяем игрокам!
Настройки репликации у Экторов
Чтобы сервер знал, каких экторов ему надо обновлять, выставим настройки репликации в Class Defaults нужного нам класса.
Рассмотрим каждый из параметров по убыванию важности:
Replicates — основной параметр репликации.
Включен — сервер знает об этом экторе и периодически его обновляет.
Выключен — эктор существует только на клиентах. RPC на нереплицируемом экторе работать не будет.
Net Priority — показывает главенство эктора над другими при одновременном обновлении.
Значение — чем выше значение, тем больше вероятность того, что эктор обновится на клиентах.
Net Update Frequency — обозначает, сколько раз в секунду эктор будет обновляться на клиентах.
Значение — чем выше значение, тем чаще объект обновится за одну секунду. Стандартные 100 раз — нереальное (в прямом смысле) число. Максимум эктор обновится 10 раз, и то это будет много. Выбирайте число, исходя из важности этого эктора для сетевой игры.
Always Relevant — в любой ситуации эктор будет реплицироваться ВСЕМ клиентам.
Включен — всем игрокам доступны реплицируемые переменные и ивенты этого эктора. Перезаписывает Only Relevant to Owner, если на том стоит галочка.
Выключен — актуализирует параметр Net Cull Distance Squared, отвечающий за репликацию переменных экторам, находящимся в радиусе его действия.
Only Relevant to Owner — эктор реплицируется только своему владельцу.
Включен — все переменные и ивенты доступны только владельцу этого эктора.
Выключен — эктор полностью реплицируется всем клиентам.
Net Use Owner Relevancy — эктор копирует приоритет сети у своего владельца.
Включен — проверяет, доступен ли сети владелец (если таковой имеется) и копипастит себе его Net Priority.
Выключен — эктор пользуется своими настройками сети.
Replicate Movement — отправляет на клиент все перемещения объекта, выполняемые на сервере. За частоту обновления позиции отвечает Net Update Frequency.
Включен — все движковые ивенты, привязанные к Movement Component, будут вызываться на сервере и запускаться на клиентах.
Выключен — Movement Component не будет работать в сетевой игре.
Net Load on Client — определяет, загрузить ли объект на клиентах.
Включен — загружает объекты на клиентах на Begin Play.
Выключен — объект будет существовать на сервере, но не на клиентах.
Net Cull Distance Squared — показывает квадрат расстояния от клиента до эктора, за которым эктор перестанет реплицироваться. По умолчанию дистанция проверяется раз в 5 секунд.
Значение — чем выше значение, тем дальше от начала координат убежит ваш эктор, прежде чем он перестанет реплицироваться.
Дебаггинг
Не чурайтесь использовать инструменты отладки. Поверьте, они сэкономят вам кучу времени, а их освоение занимает от силы 10 минут (как оказалось, для многих это страшная цифра!).
Print String
Что может быть проще старого доброго Принт Стринга? Эта штука делается в два клика и позволяет увидеть значения, находящиеся и на клиенте, и на сервере.
Breakpoints
Брейкпоинты — более крутая штука. С помощью них можно отследить цепочку из блюпринтов от ее начала до конца. Все, что нужно сделать — выбрать ноду, нажать F9, запустить игру и выполнить прописанное в коде действие.