電通総研 テックブログ

電通総研が運営する技術ブログ

UE OnlineSubsystemでオンラインマルチプレイを実装する(C++)

こんにちは!金融ソリューション事業部の山下です。
本記事では、Unreal EngineのPluginであるOnlineSubsystemを利用して、インターネット経由で同時接続するオンラインマルチプレイ機能をC++で実装する手順を紹介します。

前提知識

ネットワークモデル

オンラインゲームにおけるネットワークモデルには複数の選択肢があります。
Unreal Engineでは、基本的に「Client-Server」モデルが採用されております。

  • Peer-To-Peerモデル:プレイヤー同士でゲーム情報を相互通信する方式。プレイヤー数の増加に伴い通信が増大してしまう。また、ゲーム内に「唯一の正しいステート」が存在しない為、緻密な判定やプレイ精度が求められるゲームには不向きです。
  • Client-Serverモデル:「唯一の正しいステート」を持つゲームサーバーに対して、ゲームクライアントが接続する方式。ゲームクライアントから送られた情報は、ゲームサーバー経由で各ゲームクライアントにBroadCastされます。

ゲームサーバー/ゲームクライアント

よく混同されますが、WebサービスにおけるWebサーバーとWebクライアントとは異なります。

  • ゲームサーバー:Client-Serverモデルにおける、「唯一の正しいステート」を持つサーバーです。
  • ゲームクライアント:Client-Serverモデルにおける、ゲームを実行するクライアントです。

UEにおけるゲームサーバー方式

Unrealn Engine では、以下2種類のゲームサーバー方式が利用可能です。

  • ListenServer:ゲームサーバー上でグラフィックのレンダリングをします。特定のプレイヤーがゲームサーバーを兼ねることにより、運営リソースを節約できます。一方で、ユーザーの端末スペックに依存してしまうこと、また多人数のゲームには不向きである点が欠点です。
  • DedicatedServer:ゲームサーバー上でグラフィックのレンダリングを行わいません。運営側でサーバーを用意する必要があり、ユーザー数の増加に伴いインフラコストもかかりますが、多人数のゲームにも対応可能です。

ゲームセッション

よく混同されますが、ゲームセッションとWebサービスのセッションは異なります。
ゲームセッションは、具体的にはゲームサーバー上で動作するゲームインスタンスを指します。
複数のプレイヤーが同一のゲームセッションに接続することで、「同じゲーム空間の共有 = マルチプレイ」が可能になります。

オンラインサービス

一般的なオンラインマルチプレイゲームでは、UserやSession、AchievementやFriendなどの機能が必要になります。
そこでサービスプラットフォーム(Steam、Xbox liveFacebookなど)では、このような機能がオンラインサービスとして提供されております。
サービスプラットフォームを利用せずに自前で構築することももちろん可能です。例えばAWSではGameLiftなどのサービスも提供しており、DedicatedServerのホスティングに加えてオンラインサービスの提供もされています。

AWS GameLiftの利用方法については、孫さんの記事をぜひご覧ください。

OnlineSubsystem

Unreal Engineが提供するPluginです。
各オンラインサービスプラットフォームにアクセスする為の共通モジュールおよびインターフェースが提供されています。
Steam、Xbox liveFacebook、EOSなどマルチプラットフォームのゲームが、基本的にはコンフィギューレーションを調整するだけで1コードベースでマルチプラットフォームの実装が可能です。

インターフェースには、Session、Friends, Achievementsなどが提供されています。
本記事では、基本的なCreateSession()とFindSessions()、JoinSession()を用います。
詳細は以下をご覧ください。
https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/Interfaces/IOnlineSession/

実施手順

今回は、検証を簡易にするためListenServer方式で検証を行います。
また、セッション管理を行うオンラインサービスについては、開発用IDが無償提供されているSteamネットワークを使用します。

  1. UEプロジェクト作成と各種設定
  2. Session Interfaceの作成
  3. CreateSession()の実装
  4. JoinGameSession()の実装
  5. 端末2台を用いた接続確認

実施環境/ツール

1. UEプロジェクト作成と各種設定

Unreal EngineのNew Project > Third Personテンプレートを選択します。Project Defaultsで、C++を選択します。

今回、プロジェクト名は「OnlineMultiplaySteam」としました。

Edit >Pluginを開きます。

「Online Subsystem Steam」を選択します。

Restartが求められるので再起動します。

次に、Visual Studioエディタに移ります。
SolusionExplorerで以下のファイルを開きます。
Games > OnlineMultiplaySteam > Source > OnlineMultiplaySteam > OnlineMultiplaySteam.Build.cs

PublicDependencyModuleに"OnlineSubsystemSteam", "OnlineSubsystem"を追加します。
11行目を以下に書き換え、Buildします。

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "OnlineSubsystemSteam", "OnlineSubsystem" });

次に、DefaultEngine.iniを修正します。
SolusionExplorerで以下のファイルを開きます。
Games > OnlineMultiplaySteam > Config > DefaultEngine.ini

こちらのUEドキュメントを参考に、以下を追記します。

[/Script/Engine.GameEngine]
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver")

[OnlineSubsystem]
DefaultPlatformService=Steam

[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480

[/Script/OnlineSubsystemSteam.SteamNetDriver]
NetConnectionClassName="OnlineSubsystemSteam.SteamNetConnection"

今回使用するSteamDevAppIdは480となっております。これはSteamから開発用に提供されているサンプルゲーム(SpaceWar)のIDです。本番開発で用いる場合は、自身でAppIdを取得する必要がありますのでご注意ください。

最後にプロジェクトファイルを生成します。
Editorを閉じて、File Explorerで「Saved」「Intermidiate」「Binaries」ファイルを削除します。その後、「Generate Visual Studio project files」でプロジェクトファイルを生成します。

これでプロジェクト設定は完了です。

2. Session Interfaceの作成

ThirdPersonTemplateのCharacterクラスを修正します。
Unreal Engine独自のプレフィックス(クラス名にF,A、型名にF,Uなど)については、公式のコーディング規約をご参照ください。

OnlineMultiplaySteamCharacter.hを編集します。
includeに以下を追加します。

#include "Interfaces/OnlineSessionInterface.h"

記載する行について、"....generated.h"が一番最下部になる点にはご注意ください。

class内に、以下を追加します。

public:
    IOnlineSessionPtr OnlineSessionInterface;

SessionInterfaceの変数が宣言できました。
IOnlineSessionインターフェイス仕様は、以下を参照してください。
https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/Interfaces/IOnlineSession/

OnlineMultiplaySteamCharacter.cppを編集します。
includeに以下を追加します。

#include "OnlineSubsystem.h"

コンストラクタの最下部に、以下を追記します。

IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get();
if (OnlineSubsystem)    {
    OnlineSessionInterface = OnlineSubsystem->GetSessionInterface();
    if (GEngine) {
        GEngine->AddOnScreenDebugMessage(
            15.f,
            Color::Blue,
            String::Printf(TEXT("Found subsystem %s"),
            *OnlineSubsystem->GetSubsystemName().ToString())
        );
    }
}

OnlineSubsystemを使って、事前に指定したSteamと接続するためのInterfaceを取得しています。
本処理はCharacterクラスに追記しているため、キャラクターがレベルにSpawnするタイミングでSessionInterfaceが作成されることになります。

IOnlineSubsystemのインターフェイス仕様は、以下をご確認ください。
https://docs.unrealengine.com/5.1/en-US/API/Plugins/OnlineSubsystem/IOnlineSubsystem/

ビルドしてゲームを立ち上げます。
以下のように左側に青字のメッセージが表示されることを確認します。

SessionInterfaceが無事作成され、Steamネットワークに接続ができました。

3. CreateSession()の実装

作成したSessionInterfaceを使って、ゲームセッションの作成を行います。
事前に、セッション作成後の移動先レベルを作っておきます。レベルはDafaultのままで、名称はLobbyとします。

OnlineMultiplaySteamCharacter.hを編集します。
BluePrintでイベント(今回はKey1押下イベント)を受け取ってセッションを実行する為に、
protectedセクションを作成して以下BlueprintCallable関数を追加します。

protected:
    UFUNCTION(BlueprintCallable)
    void CreateGameSession();

OnlineSessionInterfaceでCreateGameSesion()を実行後、コールバック関数であるOnCreateSessionComplete()を実行します。

同じくprotectedセクションに、セッション作成後に呼びだすコールバック関数も追加します。

 void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);

privateセクションを追加して、コールバック関数をBindするためのDelegete変数も追加します。

private:
    FOnCreateSessionCompleteDelegate CreateSessionCompleteDelegate;

続いて、.cppファイルにDelegete変数をコールバック関数とbindした上で、OnlineSessionInterfaceのDelegateハンドラーに登録していきます。

OnlineMultiplaySteamCharacter.cppに以下を追加します。

コンストラクタの冒頭に、以下のようにDelegate変数にコールバック関数をBindします。

AOnlineMultiplaySteamCharacter::AOnlineMultiplaySteamCharacter():
CreateSessionCompleteDelegate(FOnCreateSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnCreateSessionComplete))

CreateGameSession ()を追加します。
少し長いコードになってきたので、折りたたみ表示にします。詳細はコメントをご確認ください。

CreateGameSession()

void AOnlineMultiplaySteamCharacter::CreateGameSession()
{
    // called when pressed 1 key
    if (!OnlineSessionInterface.IsValid())
    {
        return;
    }

// check existing session
auto ExistingSession = OnlineSessionInterface->GetNamedSession(NAME_GameSession);
if (ExistingSession != nullptr)
{
    OnlineSessionInterface->DestroySession(NAME_GameSession);
}

// Add Delegete variable to OnlineSessionInterface
OnlineSessionInterface->AddOnCreateSessionCompleteDelegate_Handle(CreateSessionCompleteDelegate);

// Create Session Settings
TSharedPtr <FOnlineSessionSettings> SessionSettings = MakeShareable(new FOnlineSessionSettings());
SessionSettings->bIsLANMatch = false;
SessionSettings->NumPublicConnections = 4;
SessionSettings->bAllowJoinInProgress = true;
SessionSettings->bAllowJoinViaPresence = true;
SessionSettings->bShouldAdvertise = true;
SessionSettings->bUsesPresence = true;
SessionSettings->bUseLobbiesIfAvailable = true;
SessionSettings->Set(FName("MatchType"), FString("FreeForAll"), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);

//Create Session
const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();

OnlineSessionInterface->CreateSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, *SessionSettings);
}

SessionSettingsを用意してSessionInterface->CreateSession関数を呼んでいます。
また、コンストラクタでも触れたDelegate変数をハンドラーに登録しています。
SessionSettingのパラメーターはこちらを参照してください。

セッション作成後に呼びだすコールバック関数を実装します。

OnCreateSessionComplete()

void AOnlineMultiplaySteamCharacter::OnCreateSessionComplete(FName SessionName, bool bWasSuccessful)
{
    if (bWasSuccessful)
    {
        if (GEngine)
        {
            GEngine->AddOnScreenDebugMessage(
                -1,
                15.f,
                FColor::Blue,
                FString::Printf(TEXT("Successsfully Created session: %s"), *SessionName.ToString())
            );
        }


    UWorld* World = GetWorld();
    if (World)
    {
            World->ServerTravel(FString("/Game/ThirdPerson/Maps/Lobby?Listen"));  
    }
}
else {
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(
            -1,
            15.f,
            FColor::Red,
            FString(TEXT("Failed to create session!"))
        );
    }
}

セッション作成完了後にシンプルなログメッセージを表示しています。
またLobbyレベルへの移動は、World->ServerTravel()関数を用いました。
オプションパラメーターでListenサーバーを指定しています。

イベントを受け取るために、EditorでThirdPersonCharacterのBPを開いて以下ノードを追加します。

これで、Key1を押下すると、BluePrint Callableで実装したCreate Game Session関数が実行されます。

コンパイル後、ゲームを立ち上げます。

Key1を押下します。

成功ログが画面に表示されれば、セッション作成は完了です。
これで、ListenServer方式のゲームサーバー側の処理は完了しました。

注意:オンラインシステムへの接続を試す際、ゲームをEditor Viewportで実行しても正しく接続されません。Standsloneモード or パッケージ化した上で実行してください。

4. JoinGameSession()の実装

BluePrintでイベント(こちらはKey2押下イベント)を受け取ってセッションに参加する処理を実装します。

OnlineMultiplaySteamCharacter.hを編集します。
protectedセクションを作成して以下BlueprintCallable関数を追加します。

protected:
    UFUNCTION(BlueprintCallable)
    void JoinGameSession();

このJoinGameSesion()の中では、「セッションの検索」と「セッションへの参加」の2つの処理を行います。
その為、今回はそれぞれのコールバック関数を2つ用意します。

 void OnFindSessionsComplete(bool bWasSuccessful);
    void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
    

privateセクションに、Delegete変数も2つ追加します。
また、検索条件を格納する為のSharedPointerも追加します。

 FOnFindSessionsCompleteDelegate FindSessionsCompleteDelegate;
    FOnJoinSessionCompleteDelegate JoinSessionCompleteDelegate;

    // Search Setting
    TSharedPtr<FOnlineSessionSearch> SessionSearch;

続いてOnlineMultiplaySteamCharacter.cppを編集します。
includeに以下を追加します。

#include "OnlineSessionSettings.h"

3.と同様にコンストラクタにて、Delegate変数にコールバック関数をbindします。

 FindSessionsCompleteDelegate(FOnFindSessionsCompleteDelegate::CreateUObject(this, &ThisClass::OnFindSessionsComplete)),
    JoinSessionCompleteDelegate(FOnJoinSessionCompleteDelegate::CreateUObject(this, &ThisClass::OnJoinSessionComplete))

以下関数を実装します。

  • void JoinGameSession();
  • void OnFindSessionsComplete();
  • void OnJoinSessionComplete();

こちらもそれぞれ折りたたみ表示にします。詳細はコメントをご確認ください。

まずJoinGameSession()では、検索条件であるSessionSearchを設定してFindSessionsを実行します。
SessionSearchのパラメーターはこちらをご覧ください。

JoinGameSession()

void AOnlineMultiplaySteamCharacter::JoinGameSession()
{
    // called when pressing 2 key
    if (!OnlineSessionInterface.IsValid())
    {
        return;
    }
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(
            -1,
            15.f,
            FColor::Blue,
            FString::Printf(TEXT("pressed 2 and Executed function: JoinGameSession()"))
        );
    }

OnlineSessionInterface->AddOnFindSessionsCompleteDelegate_Handle(FindSessionsCompleteDelegate);

SessionSearch = MakeShareable(new FOnlineSessionSearch());
SessionSearch->MaxSearchResults = 10000;
SessionSearch->bIsLanQuery = false;
SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);

const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
OnlineSessionInterface->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), SessionSearch.ToSharedRef());
}

OnFindSessionsComplete()は、FindSessions()実行後のコールバック関数です。
検索結果のSession情報を用いて、セッションに参加します。
今回利用しているSteamAppIDは世界中の人が使っている為、SessionSettingで定義したMatchTypeにフィルタリングをしています。取得したセッション情報を引数に、JoinSession()を実行します。

OnFindSessionsComplete()

void AOnlineMultiplaySteamCharacter::OnFindSessionsComplete(bool bWasSuccessful)
{
    if (!OnlineSessionInterface.IsValid())
    {
        return;
    }

if (bWasSuccessful)
{
    GEngine->AddOnScreenDebugMessage(-1,15.f,FColor::Cyan, FString::Printf(TEXT("FindSession Complete! SearchResults.Num() = %d"), SessionSearch->SearchResults.Num()));

    for (auto Result : SessionSearch->SearchResults)
    {
        FString Id = Result.GetSessionIdStr();
        FString User = Result.Session.OwningUserName;
        FString MatchType;
        Result.Session.SessionSettings.Get(FName("MatchType"), MatchType);

        if (GEngine)
        {
            GEngine->AddOnScreenDebugMessage(
                -1,
                15.f,
                FColor::Cyan,
                FString::Printf(TEXT("Successsfully Find Session! Id: %s , OwningUser: %s"), *Id, *User)
            );
        }

        if (MatchType == FString("FreeForAll"))
        {
            if (GEngine)
            {
                GEngine->AddOnScreenDebugMessage(
                    -1,
                    15.f,
                    FColor::Cyan,
                    FString::Printf(TEXT("Joining Match Type: %s"), *MatchType)
                );
            }
            OnlineSessionInterface->AddOnJoinSessionCompleteDelegate_Handle(JoinSessionCompleteDelegate);

            const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
            OnlineSessionInterface->JoinSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, Result);
        }
    }
}
else {
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(
            -1,
            15.f,
            FColor::Red,
            FString(TEXT("Failed to join session!"))
        );
    }
}
}

OnJoinSessionComplete()では、セッションへの参加後、セッション情報からIPアドレスを取得してレベルに移動します。
レベルの移動には、PlayerController->ClientTravel()を用います。

OnJoinSessionComplete()

void AOnlineMultiplaySteamCharacter::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
    if (!OnlineSessionInterface.IsValid())
    {
        return;
    }
    FString Address;
    if (OnlineSessionInterface->GetResolvedConnectString(NAME_GameSession, Address))
    {
        if (GEngine)
        {
            GEngine->AddOnScreenDebugMessage(
                -1, 15.f, FColor::Yellow, 
                FString::Printf(TEXT("Connect string: %s"), *Address)
            );
        }
        APlayerController* PlayerController = GetGameInstance()->GetFirstLocalPlayerController();
        if (PlayerController) 
        {
            PlayerController->ClientTravel(Address, ETravelType::TRAVEL_Absolute);
        }
    }

}

最後に、EditorでThirdPersonCharacterのBPを開いて以下ノードを追加します。

5. 端末2台を用いた接続確認

マルチプレイの検証のために、Windowsマシンを2つ用意します。
また、Steam IDも2つ用意します(Steam IDが同一だとセッションに入れない為)。
各Steamアカウントの設定で、Download Regionを同一にする必要がある点にもご注意ください。

1台目で、Key1を押下します。

2台目で、Key2を押下します。

操作をしてみて、動きが正しく同期されていることを確認します。

無事、オンライン経由でマルチプレイを実現しました。

所感

今回は、OnlineSubSystemを用いて、インターネット経由のオンラインマルチプレイを実装しました。
基本的にはUnreal Engineで提供されているOnlineSubsystem Pluginを用いることで実現可能なため、単純なユースケースであれば比較的実装はしやすい印象でした。
以前紹介したPixelStreamingと組み合わせることで、今回用意したような高スペックなマシン不要で、オンラインマルチプレイを実現できます。
これらについては今後も検証していきたいと思います。

以前の記事でも紹介したように、現在ISIDはweb3領域のグループ横断組織を立ち上げ、Web3およびメタバース領域のR&Dを行っております。

もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ご連絡ください。
私たちと同じチームで働いてくれる仲間を、是非お待ちしております!
ISID採用ページ

参考

執筆:@yamashita.yuki、レビュー:@wakamoto.ryosuke
Shodoで執筆されました