HIKI Tech Blog

yhikishimaのブログ。ゆるくUE4やUnity、web開発の記事を書いてます。

UE5 + Unity Game Servicesでマルチプレイ vol.3 マッチメイクの実装

1. はじめに

前回の Vol.1 Vol.2 の続き。

前回はマッチメイクのAPIを叩く準備までできたので、今度は実際にマッチマイクの実装を追加していきます。

APIのドキュメントはこちら。

Unity Services Web API docs

APIは以下の種類があるようです。

  • Tickets

    • Create a matchmaking ticket
      • マッチングのチケット作成
    • Delete a matchmaking ticket
      • マッチングのチケット削除
    • Gets the status of a ticket match assignment in the matchmaker
      • マッチメイクの状態を確認
  • Backfill

    • Approve a backfill ticket
      • バックフィルチケットの承認
    • Create a backfill ticket
      • バックフィルチケットを作成
    • Delete a backfill ticket
      • バックフィルチケットを削除
    • Update a backfill ticket
      • バックフィルチケットの更新

上記を使用していきます。マッチングを最短で検証するため、必要最低限のAPIだけ追加していきます。 (エラーのハンドリングなどは一旦なし)

2. マッチングチケット作成

Create a matchmaking ticket

Unity Services Web API docs

こちらのAPIを使って、マッチングチケットを発行します。

今回はマッチングのトリガーとして、BPをワールド上に配置して、BPのOverlapでイベントを設定します。

void ACPP_MatchUI::NotifyActorBeginOverlap(AActor* OtherActor)
{
    Super::NotifyActorBeginOverlap(OtherActor);

    UE_LOG(LogTemp, Log, TEXT("ACPP_MatchUI::NotifyActorBeginOverlap %s"), *GetNameSafe(OtherActor));

    StartMatching();
}

以下のような関数を追加します。

AuthToken に関しては、前回取得できるよになった accessToken を設定してください。

void ACPP_MatchUI::StartMatching()
{
    if (bIsMatching)
    {
        return;
    }
    bIsMatching = true;
    
    UE_LOG(LogTemp, Log, TEXT("ACPP_MatchUI::StartMatching"));
    
    if (MatchAuth->GetAuthToken().IsEmpty())
    {
        UE_LOG(LogTemp, Warning, TEXT("ACPP_MatchUI::NotifyActorBeginOverlap MatchAuth->GetAuthToken() is Empy"));
        return;
    }

    // Matching Request
    if (MatchMakeRequest)
    {
        MatchMakeRequest->CreateMatchMakeTicket(MatchAuth->GetAuthToken());
        
        GetWorldTimerManager().SetTimer(MatchingStatusHandle, this, &ACPP_MatchUI::TryMatchMakeStatus, 3.f, true);
    }
}

リクエスト用のクラスを追加して、以下のように追加します。

リクエストのURLもConfig.csに追加します。

static const FString MATCHMAKE_TICKETS_URL = TEXT("https://matchmaker.services.api.unity.com/v2/tickets");
void UCPP_MatchMakeRequest::Init()
{
    Http = NewObject();
}

void UCPP_MatchMakeRequest::CreateMatchMakeTicket(const FString& AuthToken)
{
    // リクエストボディの作成
    TSharedPtr JsonObject = MakeShareable(new FJsonObject);

    // キュー名設定
    JsonObject->SetStringField("queueName", TEXT("MultiPlay"));

    // Attributes設定
    TSharedPtr Attributes = MakeShareable(new FJsonObject);
    Attributes->SetStringField("region", TEXT("Asia")); // region

    JsonObject->SetObjectField("attributes", Attributes.ToSharedRef());

    // player 設定
    TSharedPtr Player = MakeShareable(new FJsonObject);
    Player->SetStringField("id", FGuid().NewGuid().ToString()); // playerId

    // 必要あればオプション追加
    
    // 配列に追加
    TArray> PlayerObjects;
    PlayerObjects.Add(MakeShareable(new FJsonValueObject(Player.ToSharedRef())));

    JsonObject->SetArrayField("players", PlayerObjects);
 
    // OutputStringにJson書き出し
    FString OutputString;
    TSharedRef> JsonWriter = TJsonWriterFactory<>::Create(&OutputString);
    FJsonSerializer::Serialize(JsonObject.ToSharedRef(), JsonWriter);

    // リクエスト
    Http->Request(
        *MATCHMAKE_TICKETS_URL,
        EHttpVerbs::POST,
        *GetHeader(AuthToken),
        OutputString,
        [=](const FString& Response)
    {
        // レスポンスをパース
        TSharedPtr ResponseObject;
        TSharedRef> Reader = TJsonReaderFactory<>::Create(Response);
        if (FJsonSerializer::Deserialize(Reader, ResponseObject))
        {
            // チケットIDを取得
            TicketId = ResponseObject->GetStringField(TEXT("id"));
        }
    });
}

すると、以下のようにチケットが発行されるかと思います。

取得したチケットIDをもとにStatusを1秒に1回程度(今回は3秒に1回にしてます。)確認します。

追加したコードは以下。

static const FString MATCHMAKE_TICKET_STATUS_URL = TEXT("https://matchmaker.services.api.unity.com/v2/tickets/status");
void ACPP_MatchUI::TryMatchMakeStatus()
{
    if (MatchMakeRequest->GetTicketId().IsEmpty())
    {
        return;
    }

    if (MatchMakeRequest->GetMatchingStatus().IsFound())
    {
        // タイマークリア
        GetWorldTimerManager().ClearTimer(MatchingStatusHandle);

        // マップ遷移
        ToConnect();
        return;
    }
    
    MatchMakeRequest->GetMatchMakeTicketStatus(MatchAuth->GetAuthToken());
}

void UCPP_MatchMakeRequest::GetMatchMakeTicketStatus(const FString& AuthToken)
{
    const FString& URL = MATCHMAKE_TICKET_STATUS_URL + TEXT("?id=") + TicketId;

    // リクエスト
    Http->Request(
        URL,
        EHttpVerbs::GET,
        *GetHeader(AuthToken),
        TEXT(""),
        [=](const FString& Response)
    {
        // レスポンスをパース
        TSharedPtr ResponseObject;
        TSharedRef> Reader = TJsonReaderFactory<>::Create(Response);
        if (FJsonSerializer::Deserialize(Reader, ResponseObject))
        {
            MatchingStatus.IP = ResponseObject->GetStringField(TEXT("ip"));
            MatchingStatus.Port = ResponseObject->GetStringField(TEXT("port"));
            MatchingStatus.Status = ResponseObject->GetStringField(TEXT("status"));
            MatchingStatus.AssignmentType = ResponseObject->GetStringField(TEXT("assignmentType"));
            MatchingStatus.MatchId = ResponseObject->GetStringField(TEXT("matchId"));
        }
    });
}

上記のように追加して、プレイすると以下のようにレスポンスが返ってくるかと思います。

レスポンスに含まれる。IPPort をもとにマップ遷移します。

void ACPP_MatchUI::ToConnect() const
{
    // マップ遷移
    const FString& URL = MatchMakeRequest->GetMatchingStatus().IP + TEXT(":") + MatchMakeRequest->GetMatchingStatus().Port;
    UGameplayStatics::OpenLevel(this, *URL, true, TEXT(""));
}

すると、以前に作成したUnity Game ServicesのサーバーにJoinすることができます。

サービスアカウントのダッシュボードのServersを確認すると、 Allocated となっているのも確認できると思います。

これで基本的なチケット発行から、サーバーへの遷移はできるようになりました。

ここから、複数人のプレイヤーをマッチングさせます。

3. バックフィルチケットを使ってマッチング

該当のサーバーのMaxPlayerが1以上になっていれば、まだ複数人が該当のサーバーにジョインできる状態になります。

ただ、普通にもう一つクライアントを立ち上げてチケットを発行しても、別のサーバーに入ってしまうだけになります。

そこでサーバー側でバックフィルを登録する処理を追加します。

以前追加した ACPP_GameSession のクラスに以下を追加します。

void ACPP_GameSession::InitMultiplayServerSystem()
{
    if (ServerQueryHandlerSubsystem)
    {
        return;
    }
    
    UGameInstance* GameInstance = GetWorld()->GetGameInstance();
    
    GameServerSubsystem = GameInstance->GetSubsystem();
    GameServerSubsystem->SubscribeToServerEvents();

    GameServerSubsystem->OnAllocate.AddDynamic(this, &ACPP_GameSession::OnAllocate);

    ServerQueryHandlerSubsystem = GameInstance->GetSubsystem();

    // Auth設定
    MatchAuth = NewObject();

    // Matching Request
    MatchMakeRequest = NewObject();
    if (MatchMakeRequest)
    {
        MatchMakeRequest->Init();
    }
}

また、サーバー側でAPIを叩くのにも認証が必要となります。

認証のクラスに以下の処理を追加します。

void UCPP_MatchAuth::ServerAuthRequest()
{
    // URLを作成
    const FString URL = TEXT("http://localhost:8086/token");

    UHTTP* Request = NewObject();
    Request->Request(*URL, EHttpVerbs::GET, TEXT(""), TEXT(""), [=](const FString& Response)
    {
        // レスポンスをパース
        TSharedPtr JsonObject;
        TSharedRef> Reader = TJsonReaderFactory<>::Create(Response);
        if (FJsonSerializer::Deserialize(Reader, JsonObject))
        {
            // AuthTokenを取得
            AuthToken = JsonObject->GetStringField(TEXT("token"));
        }
    });
}
void ACPP_GameSession::OnReadyServerSuccess()
{
    UE_LOG(LogTemp, Log, TEXT("OnReadyServerSuccess"));

    // Auth認証
    if (MatchAuth)
    {
        MatchAuth->ServerAuthRequest();
    }
    
    // Palyload AllocationのDelegateセット
    OnPlayloadSuccess.BindDynamic(this, &ACPP_GameSession::OnPayloadAllocationSuccess);
    OnPlayloadFailure.BindDynamic(this, &ACPP_GameSession::OnPayloadAllocationFailed);
    
    GameServerSubsystem->GetPayloadAllocation(OnPlayloadSuccess, OnPlayloadFailure);
}

なぜ http://localhost:8086/token なのかは謎ですが、、ひとまずこの処理を追加して、サーバービルドして、ファイルをアップロードしてのちに、接続させて、サーバーのログを確認します。

また、一緒に GetPayloadAllocation にてマッチング情報も取得しておきます。

void ACPP_GameSession::OnPayloadAllocationSuccess(FString Payload)
{
    UE_LOG(LogTemp, Log, TEXT("OnPayloadAllocationSuccess Payload(%s)"), *Payload);

    // Modelでパース
    ResultModel.ParseFromJson(Payload);

    if (!ResultModel.BackfillTicketId.IsEmpty())
    {
        // 1秒に一回リクエスト
        GetWorldTimerManager().SetTimer(BackfillApproveHandle, this, &ACPP_GameSession::TryApproveBackfill, 1.f, true);
    }
}

void ACPP_GameSession::OnPayloadAllocationFailed(FMultiplayPayloadAllocationErrorResponse ErrorResponse)
{
    UE_LOG(LogTemp, Log, TEXT("OnPayloadAllocationFailed : %s"), *ErrorResponse.ErrorMessage);
}

レスポンスのModelに関しては以下を定義

struct FMatchmakingResultsModel
{
    struct FTeamInfo
    {
        FString TeamName;
        FString TeamId;
        TArray PlayerIDs;

        void Parse(const FString& JsonString)
        {
            TSharedPtr ResponseObject;
            TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString);
            if (FJsonSerializer::Deserialize(Reader, ResponseObject))
            {
                TeamName = ResponseObject->GetStringField(TEXT("TeamName"));
                TeamId = ResponseObject->GetStringField(TEXT("TeamId"));
                TArray> PlayerIDsArray = ResponseObject->GetArrayField(TEXT("PlayerIds"));
                for (TSharedPtr PlayerID : PlayerIDsArray)
                {
                    PlayerIDs.Add(PlayerID->AsString());
                }
            }
        }
    };

    struct FPlayerInfo
    {
        FString Id;
        FString CustomData;
        FString QosResults;
        
        void Parse(const FString& JsonString)
        {
            TSharedPtr ResponseObject;
            TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString);
            if (FJsonSerializer::Deserialize(Reader, ResponseObject))
            {
                Id = ResponseObject->GetStringField(TEXT("Id"));
                CustomData = ResponseObject->GetStringField(TEXT("CustomData"));
                QosResults = ResponseObject->GetStringField(TEXT("QosResults"));
            }
        }
    };
    
    struct FMatchProperties 
    {
        FString Region;
        FString BackfillTicketId;

        TArray Teams = TArray();
        TArray Players = TArray();

        void Parse(TSharedPtr& JsonObject)
        {
            Region = JsonObject->GetStringField(TEXT("Region"));
            BackfillTicketId = JsonObject->GetStringField(TEXT("BackfillTicketId"));

            TArray> TeamInfoArray = JsonObject->GetArrayField(TEXT("Teams"));
            for (TSharedPtr TeamJson : TeamInfoArray)
            {
                FTeamInfo Team;
                Team.Parse(TeamJson->AsString());
                Teams.Add(Team);
            }

            TArray> PlayerArray = JsonObject->GetArrayField(TEXT("Players"));
            for (TSharedPtr PlayerJson : PlayerArray)
            {
                FPlayerInfo Player;
                Player.Parse(PlayerJson->AsString());
                Players.Add(Player);
            }
        }
    };

    FMatchProperties MatchProperties;
    
    FString QueueName;
    FString PoolName;
    FString EnvironmentId;
    FString BackfillTicketId;
    FString MatchId;
    FString PoolId;

    void ParseFromJson(const FString& JsonString)
    {
        TSharedPtr ResponseObject;
        TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString);
        if (FJsonSerializer::Deserialize(Reader, ResponseObject))
        {
            TSharedPtr MatchPropertiesJsonObj = ResponseObject->GetObjectField(TEXT("MatchProperties"));
            MatchProperties.Parse(MatchPropertiesJsonObj);

            QueueName = ResponseObject->GetStringField(TEXT("QueueName"));
            PoolName = ResponseObject->GetStringField(TEXT("PoolName"));
            EnvironmentId = ResponseObject->GetStringField(TEXT("EnvironmentId"));
            BackfillTicketId = ResponseObject->GetStringField(TEXT("BackfillTicketId"));
            MatchId = ResponseObject->GetStringField(TEXT("MatchId"));
            PoolId = ResponseObject->GetStringField(TEXT("PoolId"));
        }
    }
};

上記で、バックフィルチケットとサーバー用のTokenが取得できたので、こちらをもとに バックフィルの承認をします。

void ACPP_GameSession::TryApproveBackfill()
{
    if (ServerQueryHandlerSubsystem->GetCurrentPlayers() >= ServerQueryHandlerSubsystem->GetMaxPlayers())
    {
        // 人数が入ってきたら終了
        GetWorldTimerManager().ClearTimer(BackfillApproveHandle);

        // バックフィル削除
        MatchMakeRequest->DeleteBackfillTicket(MatchAuth->GetAuthToken(), ResultModel.BackfillTicketId);
        
        return;
    }

    if (!MatchAuth->GetAuthToken().IsEmpty())
    {
        MatchMakeRequest->ApproveBackfill(MatchAuth->GetAuthToken(), ResultModel.BackfillTicketId);
    }
}

※MaxPlayerの最大値を動的にマッチメイクから取得できるかと思っていたら、そのような機能はなさそうなので、別途ベタに追加してます。。

// BackFill API
static const FString MATCHMAKE_BACKFILL_URL = TEXT("https://matchmaker.services.api.unity.com/v2/backfill");
void UCPP_MatchMakeRequest::ApproveBackfill(const FString& AuthToken, const FString& BackfillId)
{
    const FString& URL = MATCHMAKE_BACKFILL_URL + TEXT("/") + BackfillId + TEXT("/approvals");

    // リクエスト
    Http->Request(
        URL,
        EHttpVerbs::POST,
        *GetHeader(AuthToken),
        TEXT(""),
        [=](const FString& Response)
    {
        // レスポンスをパース
        TSharedPtr ResponseObject;
        TSharedRef> Reader = TJsonReaderFactory<>::Create(Response);
        if (FJsonSerializer::Deserialize(Reader, ResponseObject))
        {
            BackfillApprovealResponse.Id = ResponseObject->GetStringField(TEXT("id"));
            BackfillApprovealResponse.Created = ResponseObject->GetIntegerField(TEXT("created"));

            const TSharedPtr PropertyObj = ResponseObject->GetObjectField(TEXT("properties"));
            BackfillApprovealResponse.Properties.Parse(PropertyObj);

            BackfillApprovealResponse.Attributes = ResponseObject->GetStringField(TEXT("attributes"));
            BackfillApprovealResponse.Connection = ResponseObject->GetStringField(TEXT("connection"));
        }
    });
}

上記を追加したら、再度サーバービルドをして、アップロードしなおし、2人でプレイしてみます。

無事に同じサーバーに入ることができました。

基本的には上記の処理でマッチメイクができるようになったかと思います。

他、細かいルール設定や、ロビーなどを使っていい感じにマッチメイクできるかと思いますが、別途検証していきたいと思います。

参考

How to set up Matchmaker | Unity Gaming Services - YouTube

Unity Matchmaker service