Мультиплеер в Unreal Engine: Подключение и Хостинг

Введение

Привет Хабр!

В этом цикле статей я планирую раскрыть механику работы мультиплеера в UE 4 и 5. Хочу начать с подключения, а далее уже буду отталкиваться от ваших желаний и вашей поддержки.

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

Я хочу задействовать 2 ключевые фигуры: IOnlineSubsystem — для взаимодействия с сервисами будущей игры и AGameSession — ключевой класс, обрабатывающий подключение непосредственно.

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

1. Описание

IOnlineSubsystem — это singleton объект, который представляет из себя набор интерфейсов для взаимодействия с каким либо сервисом (Steam, Origin, PS store и т.д). Следовательно, чтобы ваша игра умела работать с этим сервисом, необходимо поставить в конфиге наследника этих интерфесов, написанного специально под этот сервис. В текущем гайде мы будем использовать OnlineSubsystemSteam.

Как я уже сказал, IOnlineSubsytem — это в первую очередь набор интерфейсов, причем очень высокоуровневых. Чтобы вашу игру не приходилось переписывать под разные платформы взаимодействие с этими сервисамы было очень сильно упрощено (благо для разработчиков). Для полного понимания картины прошу вас самостоятельно зайти в исходники OnlineSubsystemSteam.

В гайде не будет настройки OnlineSubsystemSteam, т.к руководств по этой теме множество.

Рис. 1. Месторасположение OnlineSubsystemSteamРис. 1. Месторасположение OnlineSubsystemSteam

AGameSession — это объект, существующий исключительно на сервере. Он отвечает за подключение игрока к сессии и действует как обертка над интерфейсом.

Очень советую ознакомиться с хеддером GameSession.h по адресу GameFramework/GameSession.h для полного понимания механизмов работы данного класса.

Рис. 2. Методы класса AGameSession для присоединения игрока.Рис. 2. Методы класса AGameSession для присоединения игрока.

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

2. Подключение к серверу

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

  1. Поиск сессии.

  2. Присоединение к сессии.

  3. Выход из сессии.

Теперь подробно о каждом пункте:

  1. Поиск. Все найденые сессии нужно где-то хранить и как-то искать. Создадим поле в нашем кастомном классе и функцию поиска:

#pragma once
#include "CPP_GameSession.generated.h"

UCLASS(config = Game)
class ACPP_GameSession : public AGameSession
{
	GENERATED_BODY()
public:
	/**
	 * @param UserId Юзер, инициирующий поиск.
	 * @param bIsLAN Если ищем в локальной сети
	 * @param bIsPresence Будет поиск сессий с тем же флагом.                                                   
	 * Используется с сервисами вроде стим'а,
	 * чтобы позволить игрокам подключаться друг к другу через список друзей, к примеру.  
	 */
	void FindSessions(TSharedPtr UserId, bool bIsLAN, bool bIsPresence);
private:
	/**Структура, хранящая найденые сессии. */
	TSharedPtr SearchSettings;
	
};

Не забудьте в файл вашего проекта (который заканчивается на .Build.cs) подключить следующие модули:

	PublicDependencyModuleNames.AddRange(new string[] { 
			"OnlineSubsystem",
			"OnlineSubsystemUtils"
		});

Непосредственно сам поиск:

void ACPP_GameSession::FindSessions(TSharedPtr UserId, bool bIsLAN, bool bIsPresence)
{
  //Берем singleton объект
	IOnlineSubsystem* OnlineSub = Online::GetSubsystem(GetWorld());
	if (OnlineSub)
	{		
  	//Мы очень часто будем обращаться к этому интерфейсу
    //В каждой OnlineSubsystem они работают по-своему
    //В нашем случае, мы берем интерфейс Нашей сессии
    //Пока игрок не подключился к серверу, он сам является сервером
    //И по этой причине у нас есть наша "сессия",
    //которую мы используем для подключения к сессии другого игрока.
		IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();

		if (Sessions.IsValid() && UserId.IsValid())
		{
      //Заполняем интерфейс сессии необходимыми настройками
			SearchSettings = MakeShareable(new FOnlineSessionSearch());
			SearchSettings->bIsLanQuery = bIsLAN;
			SearchSettings->MaxSearchResults = 100;

      //Некоторые настройки будут заполняться в QuerySettings
			if (bIsPresence)
			{
        //Выставляем указываем, что параметр это SEARCH_PRESENCE и передаем наш флаг с типом сравнения EOnlineComparisonOp::Equals
				SearchSettings->QuerySettings.Set(SEARCH_PRESENCE, bIsPresence, EOnlineComparisonOp::Equals);
			}

			//Когда поиск сессий окончится - будет вызвана функция
      //Привязанная к делегату OnFindSessionsCompleteDelegate (об этом ниже)
      //Кроме того нам потребуется этот делегат в будущем отвязать,
      //Поэтому создадим делегат Handler и привяжем его:
      OnFindSessionsCompleteDelegateHandle = Sessions->AddOnFindSessionsCompleteDelegate_Handle(OnFindSessionsCompleteDelegate);

			//Инициируем поиск, передавая ID искателя (игрока) и наши настройки.
			Sessions->FindSessions(*UserId, SearchSettings.ToSharedRef());
		}
	}
}

Когда поиск сессий окончится, нам надо об этом узнать.

Создадим несколько делегатов и функций для этого:

UCLASS(config = Game)
class ACPP_GameSession : public AGameSession
{
	GENERATED_BODY()
...
protected:
	virtual void BeginPlay() override;
private:
	/** Делегат на создание сессии */
	FOnCreateSessionCompleteDelegate OnCreateSessionCompleteDelegate;
	/** Делегат на старт сессии */
	FOnStartSessionCompleteDelegate OnStartSessionCompleteDelegate;
	/** Делегат на уничтожение сессии (инициируется сервером) */
	FOnDestroySessionCompleteDelegate OnDestroySessionCompleteDelegate;
	/** Делегат на окончание поиска сессии */
	FOnFindSessionsCompleteDelegate OnFindSessionsCompleteDelegate;
	/** Делегат на присоединение к сессии*/
	FOnJoinSessionCompleteDelegate OnJoinSessionCompleteDelegate;
  
  //Ниже перечислим функции для каждого делегата.
  
 /**
	 * @param SessionName Имя сессии, для которой вызывается callback
	 * @param bWasSuccessful true, если ассинхронный процесс выполнен без ошибок
	 */
	virtual void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);

	/**
	 * @param SessionName Имя сессии, для которой вызывается callback 
	 * @param bWasSuccessful true, если ассинхронный процесс выполнен без ошибок
	 */
	void OnStartOnlineGameComplete(FName SessionName, bool bWasSuccessful);
	/**
	 * @param bWasSuccessful true, если ассинхронный процесс выполнен без ошибок
	 */
	void OnFindSessionsComplete(bool bWasSuccessful);

	/**
	 * @param SessionName Имя сессии, для которой вызывается callback 
	 * @param bWasSuccessful true, если ассинхронный процесс выполнен без ошибок
	 */
	void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);

	/**
	 * @param SessionName Имя сессии, для которой вызывается callback
	 * @param bWasSuccessful true, если ассинхронный процесс выполнен без ошибок
	 */
	virtual void OnDestroySessionComplete(FName SessionName, bool bWasSuccessful);	
	
  /** Handler'ы наших делегатов */
	FDelegateHandle OnStartSessionCompleteDelegateHandle;
	FDelegateHandle OnCreateSessionCompleteDelegateHandle;
	FDelegateHandle OnDestroySessionCompleteDelegateHandle;
	FDelegateHandle OnFindSessionsCompleteDelegateHandle;
	FDelegateHandle OnJoinSessionCompleteDelegateHandle;
  };

Теперь привяжем их к функциям:

...
void ACPP_GameSession::BeginPlay()
{
	Super::BeginPlay();

	OnCreateSessionCompleteDelegate = FOnCreateSessionCompleteDelegate::CreateUObject(this, &ACPP_GameSession::OnCreateSessionComplete);
	OnStartSessionCompleteDelegate = FOnStartSessionCompleteDelegate::CreateUObject(this, &ACPP_GameSession::OnStartOnlineGameComplete);
	OnFindSessionsCompleteDelegate = FOnFindSessionsCompleteDelegate::CreateUObject(this, &ACPP_GameSession::OnFindSessionsComplete);
	OnJoinSessionCompleteDelegate = FOnJoinSessionCompleteDelegate::CreateUObject(this, &ACPP_GameSession::OnJoinSessionComplete);
	OnDestroySessionCompleteDelegate = FOnDestroySessionCompleteDelegate::CreateUObject(this, &ACPP_GameSession::OnDestroySessionComplete);
}
...

Теперь как только поиск закончится — будет вызвана наша функция OnFindSessionsComplete(). Обработаем ее:

void ACPP_GameSession::OnFindSessionsComplete(bool bWasSuccessful)
{
	// Опять берем наш Subsystem
	IOnlineSubsystem* const OnlineSub = IOnlineSubsystem::Get();
	if (OnlineSub)
	{
		// Снова просим интерфейс сессии.
		IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
		if (Sessions.IsValid())
		{
      //Чистим делегат
			Sessions->ClearOnFindSessionsCompleteDelegate_Handle(OnFindSessionsCompleteDelegateHandle);
			// Если кол-во найденых сессий не нуль
			if (SearchSettings->SearchResults.Num() > 0)
			{
        //В целях дебага можем вывести параметры каждой найденой сессии.
				for (int32 SearchIdx = 0; SearchIdx < SearchSettings->SearchResults.Num(); SearchIdx++)
				{
					//Как уже отмечалось, SearchSettings хранит иформацию о найденых сессиях
          //Просим вывести нам имя всех найденых сессий:
					GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, FString::Printf(TEXT("Session Number: %d | Sessionname: %s "), SearchIdx + 1, *(SearchSettings->SearchResults[SearchIdx].Session.OwningUserName)));
				}
			}
		}
	}
}

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

  1. Присоединение к сессии.

bool ACPP_GameSession::JoinSession(TSharedPtr UserId, FName InSessionName, const FOnlineSessionSearchResult& SearchResult)
{
	bool bSuccessful = false;
	
	IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    
	if (OnlineSub)
	{
		//Интерфейс нашей сессии сессии
		IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
    
		if (Sessions.IsValid() && UserId.IsValid())
		{
			//Все тоже самое - просим интерфейс подключится к сессии и ждем,
			//пока вызовется функция, привязанная к делегату OnJoinSessionCompleteDelegate
			OnJoinSessionCompleteDelegateHandle = Sessions->AddOnJoinSessionCompleteDelegate_Handle(OnJoinSessionCompleteDelegate);
			bSuccessful = Sessions->JoinSession(*UserId, InSessionName, SearchResult);
		}
	}
	return bSuccessful;
}

Обработаем вызов делегата:

void ACPP_GameSession::OnJoinSessionComplete(FName InSessionName, EOnJoinSessionCompleteResult::Type Result)
{
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (OnlineSub)
    {
    	IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
    	if (Sessions.IsValid())
    	{
    		//Т.к этот вызов завершен - чистим делегат
    		Sessions->ClearOnJoinSessionCompleteDelegate_Handle(OnJoinSessionCompleteDelegateHandle);
    
    		//PlayerController входа непосредственно
    		APlayerController* const PlayerController = GetGameInstance()->GetFirstLocalPlayerController();
    
    		// Поскольку у всех Online subsystem разные URL для подключения
        //Необходимо попоросить наш интерфес собрать для нас этот URL и положить его в эту стрингу
    		FString TravelURL;
    		if (PlayerController && Sessions->GetResolvedConnectString(InSessionName, TravelURL))
    		{
    			// И, наконец, перенос:
    			PlayerController->ClientTravel(TravelURL, TRAVEL_Absolute);
    		}
    	}
    }
}

После выполнения сих действий должен произойти сам travel.

Если вы залезете в функцию ClientTravel, то обнаружите, что это не более чем просто OpenLevel с нужным URL и несколькими настройками.

  1. Выход из сессии.

Поскольку мы находимся на сервере, у нас нет доступа к GameSession и все действия необходимо выполнять сугубо с помощью контроллера и subsystem.

Где нибудь в контроллере или, как сделал я, в

UCPP_ClientTravelSubsystem: public UGameInstanceSubsystem

class UCPP_ClientTravelSubsystem  : public UGameInstanceSubsystem
{
	GENERATED_BODY()
  
  public:
 	//Уничтожение сессии
	void DestroySession();
  
  private:
  //Добавим делегат Handle на уничтожение сессии
  FDelegateHandle OnDestroySessionCompleteDelegateHandle;
  
  //Вызовется, когда сессия будет уничтожена
  void DestroySessionComplete(FName InSessionName, bool bWasSuccessful);
}

*Я не буду разбирать что из себя представляют другие Subsystem, т.к это выходит далеко за рамки этого гайда. Вы можете использовать любой другой объект, которым владеет клиент (GameInstance или PlayerController)

Добавим функцию DestroyClientSession()

void UCPP_ClientTravelSubsystem :: DestroyClientSession()
{
	IOnlineSubsystem* Subsystem = Online::GetSubsystem(GetWorld());
	if(Subsystem)
	{
		IOnlineSessionPtr Session = Subsystem->GetSessionInterface();
		if(Session.IsValid())
		{
      //Подвязываемся на событие Destroy'я
			OnDestroySessionCompleteDelegateHandle = Session->AddOnDestroySessionCompleteDelegate_Handle(OnDestroySessionComplete);
			//Банально просим наш интерфейс уничтожить эту сессию.
      Session->DestroySession(NAME_GameSession);
		}
	}                                                                                             
}

Обработаем наш Destroy:

void UCPP_ClientTravelSubsystem::DestroySessionComplete(FName InSessionName, bool bWasSuccessful)
{
	if(bWasSuccessful)
	{
  	//Если прошло успешно, откроем какую нибудь карту
		UGameplayStatics::OpenLevel(MapName)
	}
}

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

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

3. Хостинг

У хостинга следующий пайплайн:

  1. Создание сессии

  2. Старт сессии

  3. Destroy сессии

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

  1. Создание сессии:

class ACPP_GameSession : public AGameSession
{
	GENERATED_BODY()
public:
...
	/**
	* Хостим новую сессию
	*
	* @param UserId ID игрока, который инициирует хостинг
	* @param SessionName Имя сессии
	* @param bIsLAN Если хостинг только в локальной сети
	* @param bIsPresence Если сессия помечена как Presence
	* @param MaxNumPlayers Максимальное число игроков
	*
	* @return bool флаг состояния
	*/
	bool HostSession(TSharedPtr UserId, FName SessionName, const FString& GameType, const FString& MapName, bool bIsLAN, bool bIsPresence, int32 MaxNumPlayers);
private:

/**
	 * Вызывается делегатом, когда сессия успешно создана
	 *
	 * @param SessionName Имя сессии, для которой вызывается этот callback
	 * @param bWasSuccessful true если ассинхронный процесс выполнен успешно
	 */
	virtual void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);

	/**
	 * Вызывается делегатом когда сессия успешно начата
	 *
	 * @param SessionName Имя сессии, для которой вызывается этот callback
	 * @param bWasSuccessful true если ассинхронный процесс выполнен успешно
	 */
	void OnStartOnlineGameComplete(FName SessionName, bool bWasSuccessful);
  
  /** Настройки нашей конкретной сессии, которые мы будем заполнять */
	TSharedPtr HostSettings;
  ...
};

Перейдем к реализации:

bool ACPP_GameSession::HostSession(TSharedPtr UserId, FName InSessionName, const FString& GameType, const FString& MapName, bool bIsLAN, bool bIsPresence, int32 MaxNumPlayers)
{
	IOnlineSubsystem* const OnlineSub = Online::GetSubsystem(GetWorld());
	if (OnlineSub)
	{
		IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
		if (Sessions.IsValid())
		{
      //Запоняем HostSettings нашими настройками.
      //Их может быть любое кол-во на ваше усмотрение.
      //Перечислим только самые необходимые:
			HostSettings = MakeShareable(new FOnlineSessionSettings());
			HostSettings->bIsLANMatch = bIsLAN;
			HostSettings->bUsesPresence = bIsPresence;
			HostSettings->NumPublicConnections = MaxNumPlayers;
			
      //В качестве примера представлю еще один вид настройки
      //Есть возможность в HostSettings определенному флагу выставить значение
      //Как здесь: SETTING_MAPNAME выставляем имя нашей карты, чтобы дать возможность
      //Игроку вытащить эту настройку из найденой сессии и выставить фильтр по картам
			HostSettings->Set(SETTING_MAPNAME, MapName, EOnlineDataAdvertisementType::ViaOnlineService);
			
			OnCreateSessionCompleteDelegateHandle = Sessions->AddOnCreateSessionCompleteDelegate_Handle(OnCreateSessionCompleteDelegate);
			//Передаем наши настройки.
      return Sessions->CreateSession(*UserId, InSessionName, *HostSettings);
		}
	}
	return false;
}

Обработаем делегат:

void ACPP_GameSession::OnCreateSessionComplete(FName InSessionName, bool bWasSuccessful)
{
	IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
	if (OnlineSub)
	{
		IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
		if (Sessions.IsValid())
		{
      //Чистим делегат
			Sessions->ClearOnCreateSessionCompleteDelegate_Handle(OnCreateSessionCompleteDelegateHandle);
			if (bWasSuccessful)
			{
        //Привязываем делегат на старт сессии
				OnStartSessionCompleteDelegateHandle = Sessions->AddOnStartSessionCompleteDelegate_Handle(OnStartSessionCompleteDelegate);
				Sessions->StartSession(SessionName);
			}
		}
	}
}
  1. Старт сессии

Тут уже попроще. При последнем вызове делегата просто вызовется OnStartOnlineGameComplete();

void ACPP_GameSession::OnStartOnlineGameComplete(FName _SessionName, bool bWasSuccessful)
{
	IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
	if (OnlineSub)
	{
		IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
		if (Sessions.IsValid())
		{
			//Чистим делегат
      Sessions->ClearOnStartSessionCompleteDelegate_Handle(OnStartSessionCompleteDelegateHandle);
		}

    //Если сессия успешно создана - открываем карту
		if (bWasSuccessful)
		{
      //MapName - имя какой нибудь карты
      //И важно (!), обязательно указываем в параметрах "listen"
      //Маркируя эту карту как онлайн-открытую
			UGameplayStatics::OpenLevel(GetWorld(),MapName , true, "listen");
		}
	}
}
  1. Destroy сессии

Уничтожение сессии производится точно так же, как мы показывали выше у клиента. Единственное различие может быть, если мы поместим реализацию в UGameSession, тут уже на ваше усмотрение.

Заключение

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

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

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

© Habrahabr.ru