こんにちは、トイロジックでシステムプログラマー 兼 プログラム系タスクのなんでも屋をしているなんでも屋Nです。本記事では株式会社スクウェア・エニックスより発売された新感覚アワパーティシューター『FOAMSTARS』の内部に組み込まれているネットワークプレイヤー同期管理システムについて解説したいと思います。
※今回はインゲームでの管理に絞り、セッションやマッチメイキングの話はしません。

UE4におけるネットワークプレイヤー同期のおさらい

ネットワークゲームを作る場合、必ずプレイヤーの同期システムを作る必要があります。UE4はデフォルトでネットワークゲームにおけるプレイヤーの同期の仕組みが用意されており、プログラマは一切プレイヤーの同期処理を書かなくてもそれらはエンジンが自動で同期してくれます。(詳しくは「ネットワーク マルチプレイヤーの基本(UE4公式)」を参照してください)

基本的にサーバーは「AGameMode::PreLogin」「AGameMode::Login」「AGameMode::PostLogin」でクライアントプレイヤーの入場前・入場・退場後を検知できます。

そして参加後はサーバー・クライアント”両方”の「AGameState::PlayerArray」に登録され、この配列を参照することで現在サーバー(ロビーレベル)に参加しているプレイヤー全員のプレイヤー情報である「APlayerState」に、サーバー上でもクライアント上でもアクセスすることができます。


// [サーバー限定]: プレイヤーがサーバーに参加した
AMyGameMode::PostLogin(APlayerController* LoginPlayerController){
     /* なにか高潔な参加時処理 */
}

// [サーバー・クライアント]: 毎フレーム参加プレイヤーに処理を施す
void AMyGameState::PlayerUpdate(float DeltaSec){
    for(int32 i = 0; i < PlayerArray.Num(); ++i){
      AMyPlayerState* PlState = Cast<AMyPlayerState>(PlayerArray[i]);
      if(IsValid(PlState)){
          // サーバー上
          if(HasAuthority()){
                /* サーバー上で何か魅力的で独創的な、サーバー上プレイヤーデータに対する処理 */
          }
          // クライアント上
          else{
             /* クライアント側で何か冒涜的な、クライント上プレイヤーデータに対する処理 */
          }
     }
   }
}

このようにUE4はオンラインゲームにおいて必須であるプレイヤーの参加・退出をエンジン側で自動でイイカンジに対応してくれており、プログラマが実装しなくてもプレイヤー同期を行うことができます。しかし『FOAMSTARS』のような大規模なオンラインゲームを作る場合、デフォルトの動作だけではいくつか不都合なことがあり、その同期したプレイヤー情報をより詳細に監視・管理しなければなりません。

そのためこの記事では「エンジンの同期処理をベースとしつつも、それをより安全に監視・管理する仕組み」についてお話します。

サーバーに参加したプレイヤーの”クライアント側”に処理を施したいケース

ここからは「起動中のサーバー(ロビーレベル)に、クライアントが1人づつ入ってくるシチュエーション」で話をします。

サーバーがロビーというレベルで起動済み(既にALobbyGameMode::Tick動いている)の状態でプレイヤーが参加したとき、そのプレイヤーに対してクライアント上で初期化を行おうとします。

LobbyのGameMode側ではAGameMode::PostLoginでプレイヤーの入場が検知できるので、そのタイミングでPlayerControllerに対して初期化を行おうとします。(初期化関数はALobbyPlayerControllerでRPC(Client)で定義しておきます)


// サーバーから呼び出され、クライアント上で実行されるRPC関数として初期化を定義
// 大事なRPC通信なので「Reliable」をつけて、何があっても届くように圧をかけておく
UFUNCTION(Client,Reliable)
void ClientRPC_InitJoinedLobbyPlayerOnClient(const FLobbyInfo& ServerLobbyInfo);

void ALobbyPlayerController::ClientRPC_InitJoinedLobbyPlayerOnClient_Implementaion(const FLobbyInfo& ServerLobbyInfo){
    /* なにかハイセンスな初期化処理 */
}

// プレイヤーがサーバーに参加した
ALobbyGameMode::PostLogin(APlayerController* LoginPlayerController){
    ALobbyPlayerController* LobbyController = Cast<ALobbyPlayerController>(LoginPlayerController);
    if(IsValid(LobbyController)){
        // サーバーロビー状態を引数で渡してRPC(Client)でクライアント上で初期化
        LobbyController->ClientRPC_InitJoinedLobbyPlayerOnClient(ServerLobbyInfo);  
   }
}

これは一見するとうまく動作するように見えるハイセンスなコードに見えます。UE4は基底で「RPC(リモートプロシージャーコール)関数」と呼ばれる同期呼び出し関数を簡単に定義・利用することができますので。しかし、実はこのAGameMode::PostLoginの時点ではまだサーバー上に参加通知が来ている段階のため、このようにクライアント側にAPlayerControllerのRPC送信を飛ばすといくつか不都合なことがあります。それをこれから話していきます。

プレイヤー参加時にRPCが送られると…

プレイヤーが参加時にRPC通信を行う際の不都合なことは『AGameMode::PostLoginの時点ではまだサーバー上に参加通知が来ている段階のため「クライアント」では【準備】が整っていない』ということです。ここでいう『【準備】が整っていない』が指すものは沢山ありますが代表的なのは下記の通りです。

  1. クライアントの「ClientRPC_InitJoinedLobbyPlayerOnClient」が呼び出された時点でエンジン関数「BeginPlay」「PostInitializeComponent」などが既に呼び出されているかは保証されていない
  2. ほかのパラメーター(レプリケーションパラメーターなど)が同期されていない可能性がある

それでは実際にコードで見てみましょう。


UPROPERTY(Replicated)
int32 LobbyPlayerIDAtServer{ -1 };

int32 LocalPlayerUniqueID{ -1 };

//エンジンのBeginPlay
void ALobbyPlayerController::BeginPlay(){
    Super::BeginPlay(); 
    // サーバーではサーバー上でのIDとして設定しレプリケーションで同期する
    if(HasAuthority()){
      LobbyPlayerIDAtServer = FStaticGameUtil::MakeServerUniqueIDHash(this);
    }
    // ローカル上での初期化をしておく
   InitOnLocal();
}
//ローカル上での初期化
void ALobbyPlayerController::InitOnLocal(){
    // ローカル上でのみ使用するPlayerIDをハッシュ値で作成しておく
    LocalPlayerUniqueID = FStaticGameUtil::MakeLocalUniqueIDHash(this);

    /* その他、ロビーで使う様々な項目に対する華麗な初期化コードが並んでいる光景を想像してください */
}
// [RPC/Client] サーバーから呼び出され、クライアント上で実行されるRPC関数として初期化を定義
void ALobbyPlayerController::ClientRPC_InitJoinedLobbyPlayerOnClient_Implementaion(){
    // ローカルのプレイヤーシステムの参照をとっておく
   ALocalPlayerManager* LocalPlayerManager = FStaticGameUtil::GetLocalPlayerManager();
   // InitOnLocalで作成済みのPlayerを登録する。
   LocalPlayerManager->RegisterLocalPlayer(LobbyPlayerIDAtServer,LocalPlayerUniqueID,this);
}

これらのコードは実際にはツッコミどころが多い(NULLチェックしてない、PlayeriDをローカルで初期化しているなど)と思いますがわかりやすくするために意図的にこういった処理にしています。さてこのコードの中の「ClientRPC_InitJoinedLobbyPlayerOnClient」にはいくつかの問題があります。
 

【1】LocalPlayerManager = FStaticGameUtil::GetLocalPlayerManager();

この「FStaticGameUtil::GetLocalPlayerManager」という関数はAGameStateが保持している「ALocalPlayerManager」というクラスの参照を取得するStatic関数です。ALocalPlayerManagerはGameStateで作成されます。


void ALobbyGameState::MakeLocalManager(){
      ALocalPlayerManager* LocalPlayerManager  = SpawnLocalManager<ALocalPlayerManager>();
}

しかし実は「ClientRPC_InitJoinedLobbyPlayerOnClient」の時点ではローカル上のゲームステートにこのクラスが作成されておらず、NULLが返却されます。
 

【2】LocalPlayerManager->RegisterLocalPlayer(LobbyPlayerIDAtServer,LocalPlayerUniqueID,this);

これには3つの問題があります。

  1. LocalPlayerManagerがNULL
    【1】を参照。
  2. LobbyPlayerIDAtServerがレプリケーションされていない
    レプリケーション前にこの関数にきているためです。
  3. LocalPlayerUniqueIDが設定されていない
    「BeginPlay」が呼ばれる前にこの関数にきているためです。

駆け足でしたが以上が「AGameMode::PostLoginでClientRPC_InitJoinedLobbyPlayerOnClientを呼び出すと「クライアント」では【準備】が整っていない」という言葉の意味です。

解決策:「CSNotify (ClientStateNotify)」の導入

それではこの問題をどのように解決すればいいでしょうか。一例として『FOAMSTARS』では以下の仕組みを導入して解決しました。

  • クライアントから「同期・準備などの状態情報」を送る「CSNotify(Client-State-Notify)」という仕組みを用意
  • サーバーは全クライアントから「CSNotify」を受け取り、クライアントの同期状態などをモニタリングできるようにする
  • サーバーは「CSNotify」を受け取ると必要に応じてイベントを発行

具体的な実装を見てみましょう。ここでは分かりやすくするために「CSNotify(Client-State-Notify)」をただのenum classとします。


// CSNotify(Client-State-Notify)
UENUM(BlueprintType)
EClientStateNotifyID : uint8{
    READY_LOCAL_MANAGERS,            // クライアント上で各種マネージャーシステムが同期・使用できる
    REPLICATED_PLAYER_PARAMS,     // クライアント上にプレイヤーの同期パラメーターがレプリケーションされた
    CALLED_PLAYER_BEGIN_PLAY,      // クライアント上でプレイヤーのBeginPlayが呼ばれた
};

// CSNotifyの送信
UFUNCTION(Server,Reliable)
void ServerRPC_SendCSNotify(EClientStateNotifyID NotifyID);

void ServerRPC_SendCSNotify_Implementaion(EClientStateNotifyID NotifyID){
    ALobbyGameMode* LobbyMode = GetWorld()->GetAuthGameMode<ALobbyGameMode>();
    if(IsValid(LobbyMode)){
         LobbyMode->RecvCSNotify(this,NotifyID);
    }
}

 // 受信したCSNotifyを保存しておくMap
 TMap<int32,TArray<EClientStateNotifyID>> ServerRecvCSNotifyMap;

// CSNotifyの受信
void RecvCSNotify(LobbyPlayerController* Sender,EClientStateNotifyID NotifyID){
    if(IsValid(Sender)){
       // 受信リストに積む
     ServerRecvCSNotifyMap[Sender->GetUniqueID()].Add(NotifyID);
       // 受信の種類によってなにか処理をする場合
       switch(NotifyID){
           /* それぞれのCFNotifyに対する処理 */
       };
    }
}

これで簡単なCSNotifyの定義と送受信処理の完成です。ではこちらを使用して先ほどの処理をこのように変えてみましょう。


UFUNCTION()
void OnRep_LobbyPlayerIDAtServer();

UPROPERTY(ReplicatedUsing="OnRep_LobbyPlayerIDAtServer")
int32 LobbyPlayerIDAtServer{ -1 };

void ALobbyPlayerController::BeginPlay(){
    /* 先ほどの処理のまま... */

    // 対応するCSNotifyを送信
    ServerRPC_SendCSNotify(EClientStateNotifyID::CALLED_PLAYER_BEGIN_PLAY);
}
// [ReplicateCallback]: PlayerIDが更新されたことをクライアント側で受信する
void ALobbyPlayerController::OnRep_LobbyPlayerIDAtServer(){
     // 対応するCSNotifyを送信
     ServerRPC_SendCSNotify(EClientStateNotifyID::REPLICATED_PLAYER_PARAMS);
}

void ALobbyGameState::MakeLocalManager(){
      ALocalPlayerManager* LocalPlayerManager  = SpawnLocalManager<ALocalPlayerManager>();

    // 対応するCSNotifyを送信
    ServerRPC_SendCSNotify(EClientStateNotifyID::READY_LOCAL_MANAGERS);
}


ALobbyGameMode::PostLogin(APlayerController* LoginPlayerController){
     // ログイン時は枠だけ確保
     ServerRecvCSNotifyMap.Emplace(LoginPlayerController->GetUniqueID(),TArray<EClientStateNotifyID>());
}
void RecvCSNotify(LobbyPlayerController* Sender,EClientStateNotifyID NotifyID){
     /* 先ほどの処理のまま... */
    int32 PlayerID = Sender->GetUniqueID();

    // 必要なCFNOtifyがすべてそろったらようやく「クライアントは準備完了」とみなす
    if(CheckCSNotifyRecv(ServerRecvCSNotifyMap[PlayerID]) == EClientStateNotifyID::READY_LOCAL_MANAGERS
     && CheckCSNotifyRecv(ServerRecvCSNotifyMap[PlayerID]) == EClientStateNotifyID::CALLED_PLAYER_BEGIN_PLAY
     && CheckCSNotifyRecv(ServerRecvCSNotifyMap[PlayerID]) == EClientStateNotifyID::REPLICATED_PLAYER_PARAMS
   ){
        // サーバーロビー状態を引数で渡してRPC(Client)でクライアント上で初期化
        Sender->ClientRPC_InitJoinedLobbyPlayerOnClient(ServerLobbyInfo);  
   }
}

このようにすることで、ようやくサーバーは「クライアントの状態」を見極めて初期化を行えるようになりました。

最後に

いかがでしたでしょうか。UE4は何もしなくても便利な通信・同期の仕組みが提供されていますが「タイミング」の問題は実装者が意識して必要に応じてハンドリングしなければなりません。今回はCSNotifyを”送信負荷の低い”「enum class (uint8)」にしていますが、場合によっては「int32」「FString」、あるいは「構造体」などでもいいでしょう。

しかし頻繁に通信を行う場合はなるべく通信負荷の低い型を使用してください。また今回説明した「CSNotify」にはいくつかの拡張の余地があります。例えば送信自体は「enum class (uint8)」で行いつつも、それをサーバーで保持する際に、


struct FRecvCSNotifyData{
    EClientStateNotifyID NotifyID{ };
    float RecvTimeSec{ 0.0f }; // 受信時間
}

のような構造体に受信時の情報を格納すると受信の時系列モニタリングの際に重宝しますし、実際『FOAMSTARS』では受信時間(など)を一緒に格納しています。これらの拡張はそれぞれのアプリケーションで要件に合わせて臨機応変に対応していきましょう。

著者紹介 N
2017年にトイロジック入社。『FOAMSTARS』ではマッチング以外のインゲーム内でのネットワークを返した情報のやり取り周りを主に担当しつつ、様々なプログラミングタスクの依頼を受けてなんでも屋家業を担っています。C++の熱狂的支持者でプライベートでもマニアックな研究をしているが、最近はもっぱらUE4のコーディング制約の抜け道を探し中。


トイロジックでは現在、一緒に働くプログラマーを募集しています。

不明点などもお気軽にお問い合わせくださいフルリモート採用も行っております、ご応募お待ちしております!
 
© SQUARE ENIX