[UE4] UE4.27 の新機能 Online Subsystem EOS を使ってみる

# 記事概要
UE 4.27 で追加された OnlineSubsystemEOS プラグインを使って、ThirdPerson Template をマルチプレイ化する方法を説明します (音声通話機能付き)
プロジェクトは下記レポジトリで公開しています。

https://github.com/uguisuan/UE4_OSSEOS_Sample

## 前提知識
### Online Subsystem とは
UE4 のネットワーク機能の一つであり、様々な種類があるオンラインサービスプラットフォームを、設定を切り替えるだけで共通のインタフェースで利用できる様にする機能です

https://docs.unrealengine.com/4.27/ja/ProgrammingAndScripting/Online/

### Epic Online Services とは
Epic Gamesが提供する、ゲーム向けのオンラインサービスプラットフォームです。
マッチメイキングやランキング/P2P通信など様々な機能を提供しており、プラットフォームやゲームエンジンに関わらず利用可能です。
しかも無料。

https://dev.epicgames.com/ja/services


## マルチプレイ化の方針
* ThridPerson テンプレートをEOS のサービスを使ってマルチプレイ化する
 * クライアント同士はListenServer型のP2P接続
 * EOS へのログインにはEpic アカウント及び [Epic アカウントサービス](https://dev.epicgames.com/docs/services/ja/EpicAccountServices/index.html) を利用する
 * ゲームセッション管理のバックエンドサービスには、[EOS Lobby サービス](https://dev.epicgames.com/docs/services/ja/GameServices/Lobbies/index.html)を利用する
 * 音声通話には [EOS Voice サービス](https://dev.epicgames.com/docs/services/ja/GameServices/Voice/index.html)を利用する

## EOS の利用準備
[Developer Portal](https://dev.epicgames.com/portal/) にアクセスして、自分の組織を作成します．
各組織内では、ゲームに対応した「製品」を作成することで、各ゲームからEOS サービスにアクセスすることができるようになります


* DevPortal上で製品を新規作成
 * 今回は "QiitaSample" という名前で製品を作成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/04d2724f-3b8a-4909-2b26-fcd964eb077f.png)

* Client ID/Secret を追加する
 * クライアントポリシーのタイプでは、"GameClient" を設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/5703397e-e357-d31c-9036-d1c122a47ca5.png)
 
* Epic アカウントサービスをセットアップする
 * "アプリケーション" の「設定」からブランドの設定/パーミッション/クライアントのそれぞれを設定する (各URLなどは適当な値でOK)
     * パーミッションでは、"Online Presence"/"Friends" の両方を有効化する
     * クライアントには、先程作成したClient IDを設定する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/5cd2db95-72e2-e8f6-964f-2156ff7dc5a5.png)

ここで作成した、各ID などは後段のOSS EOSの設定で利用します

## ThirdPerson Template をOSS EOSでマルチプレイ化してみる
### 準備
UE4 エディタを起動し、ThirdPerson テンプレートを選択してプロジェクトを作成します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/3a3675a9-efb9-4bfe-cfad-a67f86c27c3d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/2a242e5a-61c3-8fa9-c3e8-7a4aebab85dc.png)

#### Plugin のインストール
Online Subsystem EOS と　EOS Voice Chat プラグインを有効化し、UE4エディタを再起動します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/1ebe0779-0d7d-d53d-ad53-bd325560468f.png)

再起動後、先程DevPortal上で作成した IDなどをOSS EOSプラグインの設定に追加していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/31f6724c-edc3-450c-cd6c-85424d1ac989.png)

* 今回変更した設定

|パラメータ名|説明|変更内容|
|:---|:---|:---|
|Default Artifact Name|デフォルトで利用する製品名|作成した製品の名前を入力|
|Enable Overlay|[EOS Overlay](https://dev.epicgames.com/docs/services/ja/EpicGamesStore/TechFeaturesConfig/EOSOverlay/index.html) を有効化するフラグ|チェックを入れる|
|Enable Social Overlay|[EOS Social Overlay](https://dev.epicgames.com/docs/services/ja/EpicAccountServices/SocialOverlayOverview/index.html) を有効化するフラグ|チェックを入れる|
|ArtifactName|製品の名前|Defautl Artifact Nameと同じものを入力|
|Client ID|Client のID|DevPortalで作成したものを入力|
|Client Secret|Client 秘密情報|DevPortal で作成したものを入力|
|Product ID|製品のID|DevPortal で作成したものを入力|
|Sandbox ID|EOS を利用する際に指定する環境|製品作成時に自動で作成される Live サンドボックスのIDを入力|
|Deployment ID|EOS を利用する際に指定する環境|製品作成時に自動で作成される Release デプロイメントのIDを入力|
|Encryption Key|Title Storage サービスの暗号化鍵|今回はオプショナル. "111111...." を入れておく (64桁)|
|Use Epic Account Service|認証にEpic Account Serviceを利用フラグ|今回は利用するのでチェックを入れておく|

#### iniファイルの設定
{Project Dir}/config/DefautEngine.ini に以下を追記します

* デフォルトで利用するOnlineSubsystem をOSS EOS に変更

```
[OnlineSubsystem]
DefaultPlatformService=EOS
bHasVoiceEnabled=true
```

* 利用するNetDriver をEOS のものに変更

```
[/Script/Engine.GameEngine]
!NetDriverDefinitions=ClearArray
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemEOS.NetDriverEOS",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver")
```

* 他クライアントと通信を行う際に、EOS P2P サービスを利用する用に設定

```
[/Script/OnlineSubsystemEOS.NetDriverEOS]
bIsUsingP2PSockets=true
```

### OSS を使った実装
Online Subsystem は 基本的には C++クラスで実装する必要があります。
(Session やLogin などBluePrint対応しているものもありますが、今回は詳細な設定などを行うためにC++で実装します)

実装の基本方針は以下です。

* PlayerContoller クラスを継承した "MyPlayerController" クラスを作成し、こちらのクラスにすべての実装を詰め込む (ログイン、セッション作成、セッション参加/削除)
* 実装した各機能は、特定のキーを押された場合にThirdPerson Characterクラスから呼び出すようにBluePrintを設定する

#### クラス作成とモジュールの読み込み
"ファイル" => "新規C++クラス" から、PlayerController クラスを継承した MyPlayerController クラスを作成し Visual Studio で開きます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/c19d6178-aa44-35ae-fc45-94816be03a41.png)


C++での実装を追加していきますが、まずは OnlineSubsystem のモジュールを利用するため、ゲームプロジェクト以下にある Souce/{Project名}.Build.cs に "OnlineSubsystem" 及び "OnlineSubsystemUtils" を追加します

```C++:QiitaSample.Build.cs
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "OnlineSubsystem", "OnlineSubsystemUtils" });
```

#### MyPlayerController クラス
まずは、UE4 のBluePrint からキー入力で呼び出せるように、MyPlayerController.h で以下の関数を宣言します

```C++:MyPlayerController.h
public:
	// EOS へのログイン
	UFUNCTION(BlueprintCallable, Category = "Qiita Sample")
		void Login();

	// セッションの作成 (バックエンドでは EOS ロビーを作成する)
	UFUNCTION(BlueprintCallable, Category = "Qiita Sample")
		bool HostSession(); 

	// セッションの作成及び見つかったセッションへの参加
	UFUNCTION(BlueprintCallable, Category = "Qiita Sample")
		void FindSession(); 

	// セッションの削除
	UFUNCTION(BlueprintCallable, Category = "Qiita Sample")
		void KillSession(); 
```

#### ThirdPersonCharacter クラス
次に、ThirdPersonCharacterクラスのBluePrintを編集して、キーボードからの入力に対してそれぞれ対応した処理が実行されるようにします．
今回の実装では、下記の様なキー入力割当にしています

|キー|呼び出す関数名|内容|
|:---:|:---:|:---|
|L|Login()|EOSへのログイン. <br>起動引数から認証情報を受け取り、デベロッパー認証ツールを使って認証する|
|H|HostSession()|ゲームセッションの作成.<br>バックエンドではEOSロビーサービスを使って音声通話機能付きルームを作成する|
|F|FindSession()|ゲームセッションを検索し、見つかったセッションへ参加する. <br>参加完了後のレベルの移動は自前で行う必要がある|
|K|KillSession()|現在参加しているセッションを破棄する|

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/2e55504d-64d4-4ec0-8ff6-bec5ea62b141.png)


#### 各関数のC++ での実装
MyPlayerControllerクラスで、実際の処理を記述していきます.
本項では簡略化のため各関数内でのOSS EOS 呼び出し処理周りのみ記載します.
詳細はGitHubレポジトリ内のファイルを確認してください

* ログイン処理

```C++:MyPlayerController.cpp
void AMyPlayerController::Login()
{
...
	int ControllerId = LP->GetControllerId();
	if (Identity->GetLoginStatus(ControllerId) != ELoginStatus::LoggedIn)
	{
		// Login処理後に呼び出されるコールバックを登録
		Identity->AddOnLoginCompleteDelegate_Handle(ControllerId, FOnLoginCompleteDelegate::CreateUObject(this, &AMyPlayerController::OnLoginCompleteConstruct));
		// 起動引数から認証情報を取得してログインする
		Identity->AutoLogin(ControllerId);
	}
...
}

void AMyPlayerController::OnLoginCompleteConstruct(int32 LocalUserNum, bool bWasSuccessful, const FUniqueNetId& UserId, const FString& Error)
{
...
	int ControllerId = LP->GetControllerId();
	FUniqueNetIdRepl uniqueId = PlayerState->GetUniqueId();
	uniqueId.SetUniqueNetId(FUniqueNetIdWrapper(UserId).GetUniqueNetId());

	// ログイン後のコールバック内で、ユーザのProduct User IDをPlayerStateに登録する
	PlayerState->SetUniqueId(uniqueId);
...
}
```

* セッション作成

```C++:MyPlayerController.cpp
bool AMyPlayerController::HostSession()
{
...
	TSharedPtr<class FOnlineSessionSettings> SessionSettings = MakeShareable(new FOnlineSessionSettings());
	SessionSettings->NumPublicConnections = 4;
	SessionSettings->NumPrivateConnections = 0;

	// セッションの検索を許可する
	SessionSettings->bShouldAdvertise = true;
	// Session開始後でも参加を許可する
	SessionSettings->bAllowJoinInProgress = true;
	// 招待を許可する
	SessionSettings->bAllowInvites = true;
	// プレゼンスにロビー情報を載せる
	SessionSettings->bUsesPresence = true;
	// Session IDを知っているユーザであれば参加可能にする
	SessionSettings->bAllowJoinViaPresence = true; 
	// EOSロビーサービスを利用するように設定
	SessionSettings->bUseLobbiesIfAvailable = true;
	// ロビーによる音声通話が可能であれば利用するように設定
	SessionSettings->bUseLobbiesVoiceChatIfAvailable = true; 
	// 検索用のキーワードとして "Custom" を設定
	SessionSettings->Set(SEARCH_KEYWORDS, FString("Custom"), EOnlineDataAdvertisementType::ViaOnlineService); 

	// セッション作成完了時に呼び出されるコールバック関数を設定
	Sessions->AddOnCreateSessionCompleteDelegate_Handle(FOnCreateSessionCompleteDelegate::CreateUObject(this, &AMyPlayerController::OnCreateSessionCompleteDelegate));

	TSharedPtr<const FUniqueNetId> UniqueNetIdptr = GetLocalPlayer()->GetPreferredUniqueNetId().GetUniqueNetId();
	bool bResult = Sessions->CreateSession(*UniqueNetIdptr, SESSION_NAME, *SessionSettings);
...
}
...
void AMyPlayerController::OnCreateSessionCompleteDelegate(FName InSessionName, bool bWasSuccessful)
{
	if (bWasSuccessful) {
		// セッション作成に成功したら、レベルを移動する ("listen" オプション付き)
		UGameplayStatics::OpenLevel(this, FName(TEXT("/Game/ThirdPersonBP/Maps/ThirdPersonExampleMap")), true, "listen");
	}
}
```

* セッション検索及び参加

```C++:MyPlayerController.cpp
void AMyPlayerController::FindSession()
{
...
	SearchSettings = MakeShareable(new FOnlineSessionSearch());

	// プレゼンスに表示されるセッションを検索
	SearchSettings->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);
	// ロビーを優先的に利用する
	SearchSettings->QuerySettings.Set(SEARCH_LOBBIES, true, EOnlineComparisonOp::Equals);
	// 検索用のキーワードに "Custom" が設定してあるロビーを検索
	SearchSettings->QuerySettings.Set(SEARCH_KEYWORDS, FString("Custom"), EOnlineComparisonOp::Equals);

	// 検索完了後に呼び出されるコールバック関数を設定
	Sessions->AddOnFindSessionsCompleteDelegate_Handle(FOnFindSessionsCompleteDelegate::CreateUObject(this, &AMyPlayerController::OnFindSessionsCompleteDelegate));

	// ユーザ及び検索条件を指定してセッションを検索
	TSharedRef<FOnlineSessionSearch> SearchSettingsRef = SearchSettings.ToSharedRef();
	TSharedPtr<const FUniqueNetId> UniqueNetIdptr = GetLocalPlayer()->GetPreferredUniqueNetId().GetUniqueNetId();
	bool bIsSuccess = Sessions->FindSessions(*UniqueNetIdptr, SearchSettingsRef);
...
}

void AMyPlayerController::OnFindSessionsCompleteDelegate(bool bWasSuccessful) {
...
	if (bWasSuccessful) {
		// 検索に成功したらセッション参加処理を呼び出す
		const TCHAR* SessionId = *SearchSettings->SearchResults[0].GetSessionIdStr();
		JoinSession(SearchSettings->SearchResults[0]);
	}
...
}

void AMyPlayerController::JoinSession(FOnlineSessionSearchResult SearchResult) {
...
	if (SearchResult.IsValid()) {
		// セッション参加後に呼び出されるコールバック関数を設定
		Sessions->AddOnJoinSessionCompleteDelegate_Handle(FOnJoinSessionCompleteDelegate::CreateUObject(this, &AMyPlayerController::OnJoinSessionCompleteDelegate));

		// ユーザを指定してセッションに参加
		TSharedPtr<const FUniqueNetId> UniqueNetIdptr = GetLocalPlayer()->GetPreferredUniqueNetId().GetUniqueNetId();
		Sessions->JoinSession(*UniqueNetIdptr, SESSION_NAME, SearchResult);
	}
...
}

void AMyPlayerController::OnJoinSessionCompleteDelegate(FName SessionName, EOnJoinSessionCompleteResult::Type Result) {
...
	if (Result == EOnJoinSessionCompleteResult::Success)
	{
		FString ConnectString;
		if (Sessions->GetResolvedConnectString(SESSION_NAME, ConnectString))
		{
			// セッションへの参加に成功したらClientTravel で遷移 
			AMyPlayerController::ClientTravel(ConnectString, TRAVEL_Absolute);
		}
	}
...
}
```

* セッションの破棄

```C++:MyPlayerController.cpp
void AMyPlayerController::KillSession()
{
...
	// セッションを破棄
	Sessions->DestroySession(SESSION_NAME);
	// 破棄後にマップを移動
	UGameplayStatics::OpenLevel(this, FName(TEXT("/Game/ThirdPersonBP/Maps/ThirdPersonExampleMap")), true, "");
...
}
```

上記実装で追加した関数宣言をヘッダファイルに追加します

```C++:MyPlayerController.h
private:
	void OnLoginCompleteConstruct(int32 LocalUserNum, bool bWasSuccessful, const FUniqueNetId& UserId, const FString& Error);
	void JoinSession(FOnlineSessionSearchResult SearchResult);
	void OnJoinSessionCompleteDelegate(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
	void OnCreateSessionCompleteDelegate(FName InSessionName, bool bWasSuccessful);
	void OnFindSessionsCompleteDelegate(bool bWasSuccessful);
```



### 起動時の設定
#### 起動方法
OSS EOSを使ったゲームをPIEを使って起動すると、うまく動作しません.
動作確認については別プロセスで起動するスタンドアローンモードを利用します．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/901f47d9-8f84-8f8d-746f-92e195cb3dc4.png)

#### デベロッパー認証ツール
EOSへログインする際の認証情報の取得に、今回は [デベロッパー認証ツール](https://dev.epicgames.com/docs/services/ja/EpicAccountServices/DeveloperAuthenticationTool/index.html) を利用します．

DevPortal から EOS SDK をダウンロードして解凍し、"SDK" -> "Tools" フォルダ内のEOS_DevAuthTool-win32-x64-1.0.1.zip を更に解凍します．
EOS_DevAuthTool.exe を実行するとデベロッパー認証ツールが起動します．

* ツールが動作するPortを指定 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/38441152-d94b-d923-68d6-26dbb61023a0.png)

* ツール上でEpic アカウントにログイン
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/ad98cf23-a98b-fc6b-fd29-b2243254127e.png)

* ツール上でのユーザ識別子を指定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/2070243d-7256-b1e9-05f4-1636235423c3.png)


#### 起動引数の設定
今回のサンプルでは AutoLogin() を利用しているため、起動引数に必要情報を渡す必要があります．
起動引数にデベロッパー認証ツールのホストとポート番号、デベロッパー認証ツール上のユーザ情報を指定するIDを渡すことで、AutoLogin()関数内で自動的にツールから必要な認証情報を取得してくれます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/135b6f12-1d09-1bb2-3496-eaf82431d5fd.png)
上記サンプルでは "-LOG" を付与していますが、これはゲーム起動時のログを表示するためのパラメータです。

---

これで実装は完了です．
サンプルをパッケージ化して実行ファイルを生成した後、それぞれに異なるユーザ識別子を与えて起動し、別々のユーザでEOSにログインすることで実際にセッションのホスト/検索及び参加そして音声通話が可能となります.
(両ゲームでログイン後、片方でセッションの作成 ["H"]をした後, もう片方でセッションの検索/参加 ["J"] をしてください)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/571320/b7b5a7a0-f762-57e4-c131-c5675829edbe.png)