Привет хабр!

В данной статье я хочу разобрать мультиплеер в Unreal Engine в контексте игрового процесса.

Опираться я буду на всеобщеизвестный unreal network compendium, приправленный моим собственным опытом.


Основная часть

Репликация

Мультиплеер так таковой представляет из себя взаимодействие меж Сервером и Клиентом. По факту, суть взаимодействия проста - клиент полностью должен копировать все то, что происходит на сервере, т.е Сервер должен Реплицировать данные клиенту. Из этого следует, что сервер и клиент, это две копии игры, запущенные на разных машинах, где клиент обязан подчиняться серверу. Отметим несколько тезисов, необходимых для полного понимания картины:

Во первых: Сервер может выступать как dedicated, т.е удаленный частный сервер, собранный их исходников. На нем отсутствует какая либо визуальная часть, вроде VFX эффектов, виджетов, ALocalPlayer и т.д.

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

Во вторых: Данные нужно синхронизировать меж несколькими клиентами, чтобы создавалась иллюзия, что они играют друг с другом (впал в депрессию после осознания). Центром для синхронизации выступает как раз таки сервер. Он производит все необходимые просчеты для их дальнешей репликации клиентам. Кроме того, выполнение важной игровой логики на сервере предотврощает читерство, т.е подмену каких либо переменных (здоровье, урон и пр.), хотя, во всяком случае, эти выводы следуют друг из друга. Но какие данные можно синхронизировать, а какие нет?

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

AGameMode - класс, отвечающий за "правила игры" (все несколько сложнее и абстракнее, но пусть будет пока так). Поскольку клиент обделен возможностью на эти "правила влиять", то у него он отсутствует, и при попытке вызывать на клиенте:

GetWorld()->GetAuthGameMode();

Вам вернется nullptr. Тоже самое касается и UGameSession.

UGameSession - класс, отвечающий за подключение клиента к серверу. Он эдакий Менеджер все подключающихся игроков. В свою очередь, у клиента для контроля сесии есть APlayerController и APlayerState.

В Network Compendium есть великолепное изображение сей логики:

Рис. 1. Схема репликации классов.
Рис. 1. Схема репликации классов.

Как я уже высказался: AGameMode присутсвует сугубо на сервере. В нем лежат "правила игры".

И у сервера, и у клиентов есть:

  1. AGameState - класс, в котором хранятся важные переменные сессии,т.е состояние игры (точки для захвата, текущие очки каждой команд, виды команд и пр.,), т.е постоянно меняющиеся состояния. Он так же реплицируется клиентам.

  2. APlayerState - состояние игрока внутри игры. Не путать с представлением, т.е с APlayerController. Разница в том, что копия APlayerState каждого игрока есть у клиентов. Т.е если на сервере 30 игроков и 1 dedicated сервер, то у каждого из 30 игроков будет 29 копий APlayerState друг друга, и один свой. Может возникнуть вопрос - что там хранить?

    На самом деле, все, что вы хотели бы показать другим игрокам. Самый банальный пример: Таблица счета из шутеров. Там отображаются убийства, ассисты, смерти...(отсылка на горячё любимый battlefield 2042). Тогда почему бы не хранить все счетчики убийств, очки и пр. в APlayerState и не отображать их у каждого подключенного клиента?

  3. APawn - класс, для которого клиент может вызывать Possess или Unposses, т.е вселится и контролировать персонажа своим APlayerController, что заставит Pawn'а принимать от вас инпуты. Кроме того, уничтожение APawn на сервере повлечет уничтожение его у всех клиентов (но это прирагатива всех AActor'ов)*

    P.S Вы не контролируете павна напрямую. Вы лишь отправляете на сервер запрос с тем, чтобы ваш павн совершил какое либо действие.

Далее, мы медленно приближаемся к другому важному моменту:

У всех AActor'ов есть свой "владелец". На рисунке представлено, что APlayerController есть на сервере и у клиента, который этим контроллером владеет. (Это как раз таки отсылает нас к нужде контролировать павна лишь с помощью вызовов на сервер) Попытка получить

UGameplayStatics::GetPlayerController(GetWorld(),/*1.2.3.4...*/);

Вернет вам nullptr, т.к у клиента есть лишь его контроллер.

Теперь про контроль: все Actor'ы имеют свою роль, в зависимости от того, на какой машине они находятся. На машине сервера (Authority),для каждого actor'а будет верно следующее равенство:

Actor->GetRemoteRole() == ENetRole::ROLE_Authority;

Для клиента же, попытка вызвать эту же функцию у реплицированного с сервера actor'а даст следующий результат:

Actor->GetRemoteRole() == ENetRole::ROLE_SimulatedProxy;

И наконец: для всех pawn'ов, которых контролирует клиент, т.е для которых он вызывал APlayerController::Possess(Pawn); на клиенте будет верно следующее:

Actor->GetRemoteRole() == ENetRole::ROLE_AutonomousProxy;

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

И последнее: виджеты.

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

Дополнение:

Кроме виджетов, ваша копия UGameInstance присутствует сугубо у вас. Точно так же, как и UGameInstanceSubsystem.

UGameInstance не является наследником класса AActor, и, следовательно, не реплицируется. Попытка вызывать функцию с клиента на сервер с обращением к UGameInstance может дать непредсказуемый результат.

Рис 2. Схема репликации из Network Compendium.
Рис 2. Схема репликации из Network Compendium.

RPC функции и репликация

Для синхронизации сервера и клиента у UE есть следующие инструменты:

  1. RPC функции.

  2. Репликация переменных.

  1. RPC функции - функции, которые вызываются на одной машины, а исполняются на другой. Например, функция вызываная на клиенте, будет исполнена на сервере и наоборот. Важно понимать, что вызывать RPC функции могут лишь все наследники класса AActor, кроме того, вызывать их может только владелец этого AActor'а. Следовательно, сервер может проводить любые операции, и вызывать любые типы RPC функци на клиенты.

    Рассмотрим на примерах:

Рис. 3 Примеры RPC функций.
Рис. 3 Примеры RPC функций.

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

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

Необходимо указывать один из них.

Всего функций - 3 типа.

  • Client

  • Server

  • Multicast

Client - вызывается на владеющим этим Actor'ом клиенте.

void AUEPlayerController::ServerCall_Implementation()
{  
	//Do smth 
  ClientCall();
}

void AUEPlayerController::ClientCall_Implementation()
{
	//Вызовется на владеющим этим AActor'ом клиенте.
  //В нашем случае, клиенте, который вызывал ServerCall со своего PlayerController'a.     
}

Server - вызывается на сервере.

NetMulticast - вызывается на всех клиентах и сервере.

void AUEPlayerController::ServerCall_Implementation()
{
	//Do smth
	MulticastCall();
}

void AUEPlayerController::MulticastCall_Implementation()                                            
{
	//Вызовется на всех клиетах, при условии, что запрос на вызов поступил с сервера.
}

P.S Реализация RPC функции должна быть помечена в конце _Implementation.

Кроме того, есть еще один модификатор : WithValidation.

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

UFUNCTION(Client,Unreliable, WithValidation)
void ClientCall(int32 SomeParameter);

UFUNCTION()
bool ClientCall_Validate(int32 SomeParameter);
void AUEPlayerController::ClientCall_Implementation(int32 Damage)
{
	//Вызовется на владеющим этим AActor'ом клиенте.
}

bool AUEPlayerController::ClientCall_Validate(int32 Damage)
{
	//Если параметр Damage удовлетворяет проверке, функция будет вызывана.
	return Damage <= 100;
  //В ином случае, (!)вызывающая машина будет отключена от сервера/клиента(!)
}

В Network Compendium если отличная репрезентация того, как работают RPC вызовы с разных машин:

Рис. 4. Вызов RPC функций с сервера.
Рис. 4. Вызов RPC функций с сервера.
Рис. 5. Вызов RPC функций с клиента ч. 1.
Рис. 5. Вызов RPC функций с клиента ч. 1.
Рис. 6. Вызов RPC функций с клиента ч. 2.
Рис. 6. Вызов RPC функций с клиента ч. 2.
  1. Репликация.

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

Для того чтобы включить это репликацию, добавим следующие модификаторы:

	UPROPERTY(Replicated)
	int32 ReplicatedInt;

	UPROPERTY(ReplicatedUsing = OnReplicatedUsingIntChanged)
	int32 ReplicatedUsingInt;

	UFUNCTION()
	void OnReplicatedUsingIntChanged();

Replicated - Модификатор, помечающий переменную так, чтобы та копировала состояние с сервера.

ReplicatedUsing - Модификатор, помечающий переменную так, чтобы та не просто копировала состояние, а каждый раз, как состояние этой переменной менялось на сервере, на всех клиентах вызывалась функция OnReplicatedUsingIntChanged()

ReplicatedUsing может быть использован, например, в случае, если переменная HealthPoints изменилась, то на клиентах мы бы заспавнили виджет, оповещающий об этом событии.

Иначе говоря, функция, привязанная модификатором ReplicatedUsing, обязана оповещать клиентов об изменении переменной, и как-то на это реагировать.

Теперь, собственно, одна из причин, почему только поля AActor'ов могут быть реплицированы:

Мало пометить переменную Replicated или ReplicatedUsing ,

У каждого AActor'а есть виртуальная функция:

virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

Которая отвечает за Репликацию непосредственно.

Последний шаг для включения репликации переменной:

void AUEPlayerController::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	//Указываем репликатор, класс, содержащий реплицируемое поле, и поле непосредственно.
	DOREPLIFETIME(AUEPlayerController, ReplicatedInt);
	DOREPLIFETIME(AUEPlayerController, ReplicatedUsingInt);
}

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

void AUEPlayerController::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	//Указываем репликатор, класс, содержащий реплицируемое поле, и поле непосредственно.
	DOREPLIFETIME(AUEPlayerController, ReplicatedInt);
	DOREPLIFETIME_CONDITION(AUEPlayerController, ReplicatedUsingInt,COND_SkipOwner);
}

Теперь репликация ReplicatedUsingInt будет выполнятся с условие SkipOwner, т.е реплицироваться будет всем клиентам, кроме владельца этого AActor'а.

Кроме SkipOwner есть следующие условия:

  • COND_InitialOnly - репликация будет выполнена лишь раз при инициализации AActor'а у клиента.

  • COND_OwnerOnly - Репликация будет выполняться только для машины владельца этого AActor'а.

  • COND_SimulatedOnly - Репликация будет выполняться только для машины того клиента, роль AActor'а на которой будет ENetRole::ROLE_SimulatedProxy.

  • COND_AutonomousOnly - Репликация будет выполняться только для машины того клиента, роль AActor'а на которой ENetRole::ROLE_AutonomousProxy.

  • COND_SimulatedOrPhysics - Выполнится только для машины клиента, на которой физика AActor'а реплицируется или на которой выставлен флаг bRepPhysics.

  • COND_InitialOrOwner - Будет выполняться только для машины клиента единожды, или для владельца клиента постоянно.

  • COND_Custom - Не имеет конкретного условия. Но требуется включение или выключение через SetCustomIsActiveOverride.


Заключение

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

Спасибо что читали!

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

Источники

Комментарии (0)