Unity + PlayFab(Party/Lobby)でマルチプレイヤー同期処理を作る

この記事は、[KLab Engineer Advent Calendar 2024](https://qiita.com/advent-calendar/2024/klab) の19日目の記事です。
こんにちは。KLabでエンジニアをしている @tsune2ne です。

マルチプレイヤーゲームの検証でPlayFabを触ることがあったので備忘録替わりの記事です。
PlayFabのSDKや名前がややこしかったり、Unityで使うノウハウがうまく見つけれなかったのでここに残しておきます。

# PlayFab概要
![icon_playfab.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/3f8e7387-643b-e825-af9c-177c6a1a6bad.png)

https://azure.microsoft.com/ja-jp/products/playfab

**PlayFab とは?**
- ゲーム特化のBaas。バックエンド プラットフォーム。
  - CSとモバイルのクロスプラットフォーム対応
- PlayFabは2014年創業のアメリカ企業、2018年にMicrosftが買収。
- 日本を含む様々なサービスで利用されている。

# 提供サービス
CSおよびスマホのプレイヤー認証・プレイヤーデータ管理・ストア提供と販売処理
専用サーバ・テキストチャット・ボイスチャット・マッチメイキング
ランキング・トーナメント・チート防止
エンゲージメント・リテンション・データ分析

# 提供SDK
### PlayFab Services SDK
LiveOps、エコノミー、データ分析をなどの機能の大半を使うことができます。
- プレイヤー認証、プレイヤーデータ管理
- ストア提供と販売処理
- ランキング、トーナメント
- などなど

[SDK の概要 - PlayFab | Microsoft Learn](https://learn.microsoft.com/ja-jp/gaming/playfab/sdks/sdk-overview#playfab-services-sdk)

| 商品設定 | プレイヤー情報 |
|:--:|:--:|
| ![playfab_game-manager-timed-consumables-items-tab.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/732326ec-d587-3a00-5f38-b52d7831902c.png) | ![playfab_game-manager-access-player-details.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/d6b50a05-49ec-76ba-35f0-4f6a8c480e58.png) |

### Playfab Multiplayer Game Server SDK
PlayFab マルチプレイヤー サーバー (MPS) の管理に役立ちます

MPSにはサーバー用プログラムを展開することができ
クライアント用ゲームからPlayFabゲームサーバーにアクセスすることで
多人数同時参加型オンラインゲームを構築することができます。

[サーバー - PlayFab | Microsoft Learn](https://learn.microsoft.com/ja-jp/gaming/playfab/features/multiplayer/servers/)

![playfab_multiplayer-server-hosting-service-diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/4605ab07-7fb0-a8ba-7af0-d7a39ec79336.png)

### PlayFab Lobby and Matchmaking SDK
ロビーとマッチメイキング機能を使用できます。

**Lobby**
- [Azure PlayFab ロビーの概要](https://learn.microsoft.com/ja-jp/gaming/playfab/features/multiplayer/lobby/)
  - 部屋を作成・検索・入室・退室機能

**Matchmaking**
- [マッチメイキング - PlayFab | Microsoft Learn](https://learn.microsoft.com/ja-jp/gaming/playfab/features/multiplayer/matchmaking/)
  - プレイヤーに属性とチケットを付与してルール内のプレイヤーをマッチする機能

![playfab_lobby_matchmaking.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/d6349278-6137-1da0-57d8-d2e4e590879b.png)


### PlayFab Party SDK
PlayFab パーティー SDK は、ゲームのネットワークと音声またはテキストによるチャット通信を提供します。

ユーザー同士のP2P通信に対応
ゲームデータを通信することで安価で少人数マルチプレイの提供が可能。

[Azure Playfab パーティー SDK](https://learn.microsoft.com/ja-jp/gaming/playfab/features/multiplayer/networking/party-sdks)

![playfab_simplified-party-object-hierarchy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/688811f8-3879-07df-4d95-cf05c9f2053e.png)


# Unity＋PlayFab Party のサンプル
![PlayFabPartySample_SS.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/6b790ae7-f5d7-b797-57a7-cd4d25fc84a3.gif)

### 事前作業
- Playfabのアカウント作成
- [PlayFab](https://developer.playfab.com/en-us/my-games)でスタジオとタイトルを作成
- DeveloperSecretKeyをメモ

- パーティSDKインストール
- PlayFabSharedSettingsに作成したタイトルIDとDeveloperSecretKeyを入力

### ログイン処理
諸事情でasync/await対応してます。
コールバックでハンドリングできるのでいい感じに実装してください。

```c#
    async Task<LoginResult> LoginIntoPlayfab()
    {
        var taskCompletionSource = new TaskCompletionSource<LoginResult>();
        var request = new LoginWithCustomIDRequest {
            CustomId = UnityEngine.Random.value.ToString(),
            CreateAccount = true 
        };
        void onSuccess(LoginResult result)
        {
            taskCompletionSource.SetResult(result);
        }
        void onFailure(PlayFabError error)
        {
            throw new PlayFabException(error.ErrorMessage);
        }
        PlayFabClientAPI.LoginWithCustomID(request, onSuccess, onFailure);
        return await taskCompletionSource.Task;
    }
```

### Party部屋作成処理
NetworkIdは他プレイヤーの入室に使うのでどうにかこうにか渡してください。

```c#
    public async Task<string> CreateRoom()
    {
        var result = await LoginIntoPlayfab();
        var taskCompletionSource = new TaskCompletionSource<string>();
        PlayFabMultiplayerManager.Get().OnNetworkJoined += (sender, networkId) =>
        {
            taskCompletionSource.SetResult(networkId);
            IsHost = true;
        };
        PlayFabMultiplayerManager.Get().OnError += (sender, args) =>
        {
            UnityEngine.Debug.LogError(args.Message);
            throw new PlayFabException(args.Message);
        };
        PlayFabMultiplayerManager.Get().CreateAndJoinNetwork();
        return await taskCompletionSource.Task;
    }
```

### Party部屋入室処理
作成時にもらったNetworkIdを使って入室します。

```c#
    public async Task<string> JoinRoom(string networkId)
    {
        var result = await LoginIntoPlayfab();
        var taskCompletionSource = new TaskCompletionSource<string>();
        PlayFabMultiplayerManager.Get().OnNetworkJoined += (sender, networkId) =>
        {
            taskCompletionSource.SetResult(networkId);
        };
        PlayFabMultiplayerManager.Get().OnError += (sender, args) =>
        {
            UnityEngine.Debug.LogError(args.Message);
            throw new PlayFabException(args.Message);
        };
        PlayFabMultiplayerManager.Get().JoinNetwork(networkId);
        return await taskCompletionSource.Task;
    }
```

### データ送信
byte配列でデータ送信できます
```c#
    public void ChangePosition()
    {
        var position = new Vector3(Random.Range(-3f, 3f), 1f, Random.Range(-3f, 3f));
        var text = position.x + ":" + position.y + ":" + position.z;
        var requestAsBytes = Encoding.UTF8.GetBytes(text);
        PlayFabPartyManager.Instance.SendDataMessage(requestAsBytes);
        player.transform.position = position;
    }
```    

```c#
    public void SendDataMessageToAllPlayers(byte[] requestAsBytes)
    {
        PlayFabMultiplayerManager.Get().SendDataMessageToAllPlayers(requestAsBytes);
    }
```

### データ受信
```c#
    public void StartGame()
    {
        // データ変更通知を監視する
        PlayFabMultiplayerManager.Get().OnDataMessageReceived += OnDataMessageReceived;
    }

    void OnDataMessageReceived(object sender, PlayFabPlayer from, byte[] buffer)
    {
        // 受信処理
        var text = Encoding.UTF8.GetString(buffer);
        var numList = text.Split(":");
        var position = new Vector3(float.Parse(numList[0]), float.Parse(numList[1]), float.Parse(numList[2]));
        player.transform.position = position;
    }
```


# Unity＋PlayFab Lobby and Matchmaking のサンプル
![PlayFabLobbySample_SS.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/7e210de9-a077-27f0-1462-1042fd5eba2f.gif)

### 事前作業
- Playfabのアカウント作成
- [PlayFab](https://developer.playfab.com/en-us/my-games)でスタジオとタイトルを作成
- DeveloperSecretKeyをメモ

- ロビーマッチメイキングSDKインストール
- PlayFabSharedSettingsに作成したタイトルIDとDeveloperSecretKeyを入力

### ログイン処理
EntityTokenとEntityKeyは保持します

```c#
    async Task<LoginResult> Login()
    {
        var taskCompletionSource = new TaskCompletionSource<LoginResult>();
        var request = new LoginWithCustomIDRequest
        {
            CustomId = UnityEngine.Random.value.ToString(),
            CreateAccount = true
        };
        void onSuccess(LoginResult result)
        {
            PlayFabMultiplayer.SetEntityToken(result.AuthenticationContext);
            entityKey = new PFEntityKey(result.AuthenticationContext);
            taskCompletionSource.SetResult(result);
        }
        void onFailure(PlayFabError error)
        {
            throw new PlayFabException(error.ErrorMessage);
        }
        PlayFabClientAPI.LoginWithCustomID(request, onSuccess, onFailure);
        return await taskCompletionSource.Task;
    }
```

### ロビー作成処理
```c#
    public async Task<SampleLobby> CreateLobby(string roomName, string networkId)
    {
        var taskCompletionSource = new TaskCompletionSource<SampleLobby>();
        void onSuccess(Lobby lobby, int result)
        {
            if (LobbyError.SUCCEEDED(result))
            {
                this.lobby = lobby;
                taskCompletionSource.SetResult(new SampleLobby(lobby));
            }
            else
            {
                throw new PlayFabException("Error creating a lobby");
            }
            PlayFabMultiplayer.OnLobbyCreateAndJoinCompleted -= onSuccess;
        }
        void onFailure(PlayFabMultiplayerErrorArgs args)
        {
            throw new PlayFabException("Disconnected from lobby!");
        }

        PlayFabMultiplayer.OnLobbyCreateAndJoinCompleted += onSuccess;
        PlayFabMultiplayer.OnError += onFailure;

        var createConfig = new LobbyCreateConfiguration()
        {
            MaxMemberCount = 4,
            OwnerMigrationPolicy = LobbyOwnerMigrationPolicy.Automatic,
            AccessPolicy = LobbyAccessPolicy.Public
        };

        // ロビーの検索に用いるデータの登録
        createConfig.SearchProperties[PropertyNameRoomName] = roomName;

        // ロビーメンバーで共有できるロビーデータの登録
        createConfig.LobbyProperties[PropertyNamePartyNetworkId] = networkId;

        // メンバーデータ
        var joinConfig = new LobbyJoinConfiguration();

        PlayFabMultiplayer.CreateAndJoinLobby(entityKey, createConfig, joinConfig);
        return await taskCompletionSource.Task;
    }
```
Lobbyクラスはそのままは使いにくいので
SampleLobbyクラスに変換して利用します。



### ロビークラス(SampleLobby)
```c#
public class SampleLobby
{
    public string Id { get; private set; }
    public string Name { get; private set; }
    public SampleLobbyMember[] Members { get; private set; }
    public string ConnectionString { get; private set; }

    public string PartyNetworkId;

    public SampleLobby(Lobby lobby)
    {
        Id = lobby.Id;
        Name = lobby.GetSearchProperties()[PlayFabLobbyManager.PropertyNameRoomName];
        PartyNetworkId = lobby.GetLobbyProperties()[PlayFabLobbyManager.PropertyNamePartyNetworkId];
        ConnectionString = lobby.ConnectionString;
    }

    public SampleLobby(LobbySearchResult result)
    {
        Id = result.LobbyId;
        Name = result.SearchProperties[PlayFabLobbyManager.PropertyNameRoomName];
        ConnectionString = result.ConnectionString;
    }
}
```


### ロビー検索処理

```c#
    public async Task<IList<SampleLobby>> ListLobbies()
    {
        var taskCompletionSource = new TaskCompletionSource<IList<SampleLobby>>();
        void onSuccess(IList<LobbySearchResult> searchResults, PFEntityKey newMember, int reason)
        {
            if (LobbyError.SUCCEEDED(reason))
            {
                var lobbies = new SampleLobby[searchResults.Count];
                for (var i = 0; i < lobbies.Length; i++)
                {
                    lobbies[i] = new SampleLobby(searchResults[i]);
                }
                taskCompletionSource.SetResult(lobbies);
            }
            else
            {
                throw new PlayFabException("Error finding lobbies");
            }
            PlayFabMultiplayer.OnLobbyFindLobbiesCompleted -= onSuccess;
        }

        LobbySearchConfiguration config = new LobbySearchConfiguration();
        PlayFabMultiplayer.OnLobbyFindLobbiesCompleted += onSuccess;
        PlayFabMultiplayer.FindLobbies(entityKey, config);
        return await taskCompletionSource.Task;
    }

```
LobbySearchConfigurationを指定することで条件検索できます


### ロビー入室処理
```c#
    public async Task<SampleLobby> JoinLobby(SampleLobby lobby)
    {
        var taskCompletionSource = new TaskCompletionSource<SampleLobby>();
        void onSuccess(Lobby lobby, PFEntityKey newMember, int reason)
        {
            if (LobbyError.SUCCEEDED(reason))
            {
                this.lobby = lobby;
                var sampleLobby = new SampleLobby(lobby);
                taskCompletionSource.SetResult(sampleLobby);
            }
            else
            {
                throw new PlayFabException("Error finding lobbies! reason=" + reason);
            }
            PlayFabMultiplayer.OnLobbyJoinCompleted -= onSuccess;
        }

        PlayFabMultiplayer.OnLobbyJoinCompleted += onSuccess;

        // 参加するメンバー自身のメンバーデータ
        var memberData = new Dictionary<string, string>{};

        PlayFabMultiplayer.JoinLobby(entityKey, lobby.ConnectionString, memberData);
        return await taskCompletionSource.Task;
    }
```

# サンプルコード
https://github.com/tsune2ne/UnityPlayFabSample

# あとがき
なかなかドキュメントがわかりにくく
SDKの名前も公開名と内部名が違っていてかなり混乱しました

SDKごとの対応PFも微妙に違うので
使う場合は気を付けたほうがいいです