Photon Fusion for Unityでマッチングシステムを実装する～実用編

# はじめに
Photon Fusionアンバサダーのニム式です。

オンラインマルチプレイゲームを作る場合、プレイヤー同士のマッチングシステムを用意したい場合がほとんどだと思います。

Photon Fusionには、そのためにMatchmakingAPIというものが用意されています。

前回の記事では、MatchmakingAPIの基本的な実装について紹介しました。

https://qiita.com/nimushiki/items/c91a52f4cd94374e5d92

本記事では過去記事で紹介した公式サンプルを元に、MatchmakingAPIの具体的なユースケースを紹介します。

### 前提記事

Photon Fusionの基本的な解説は以下の記事で行っていますので、そちらを参照下さい。

https://qiita.com/nimushiki/items/d6fe4f0cbe0437677fa8

### 動作確認環境

Windows 11 Home 22H2

Unity 2022.3.2f1

Fusion SDK 1.1.8 F Build 725

# MatchmakingAPIについて

プレイヤー同士をマッチングさせるための情報のやりとりや、同一セッションに接続しゲームを開始するための機能です。基本的なAPIの使い方含め、詳しくは過去記事にて解説しています。

https://qiita.com/nimushiki/items/c91a52f4cd94374e5d92

# 実装例

今回のサンプルは、ゲームを始めるとロビーに接続し、接続したプレイヤー同士でいくつか存在するルールを選択可能な多人数対戦ゲームです。

個人での制作やデモ程度の小規模ゲームを想定した作りになっています。

なお記事内のコードは公式サンプルのSocialHubをベースに実装しています。

https://doc.photonengine.com/ja-jp/fusion/current/technical-samples/fusion-social-hub

## ロビー機能

接続した全てのプレイヤーが集まり、対戦ルールを選択するためのロビーです。モンスターハンターにおける集会場やストリートファイター6のバトルハブのようなものです。

![ロビー画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/434af8f7-5fb9-4f17-abb5-2142fe57085c.png)

![ロビーのシーン構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/25492fa4-2eb0-54cd-4842-5b624a5e6012.png)

![Fusion接続.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/4e571a59-fdc6-4fe7-064a-ec9c63948287.png)


### セッション作成機能

SocialHubサンプルがベースになっており、ConnectionDataを複数用意することで接続先を分けられるようにしています。ロビーに参加しているプレイヤーが任意のルールを選択したり、参加状況を参照できるようにします。

![ロビーの使い方](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/f4084f02-8215-ad2d-e044-00c849556f0f.png)


### ロビーのプライベート化

マルチプレイゲームでは、友人など限定したメンバーでゲームを開始し無関係なプレイヤーが入れないようにしたい、といったことがあると思います。いわゆるプライベートマッチです。

このままだとすべての人が同じロビーに接続しますが、限定したプレイヤーのみ参加できるプライベートなロビーを設定する方法を追加します。

やり方はStartGameArgsのCustomLobbyNameに共有可能なランダム文字列を設定するだけです。

プレイヤーがプライベートの設定にチェックする度に文字列が生成されるようになっており、プレイヤー同士でゲーム外でその文字列をやりとりすることで同じ設定のロビーに接続することができます。

![ロビーのプライベート化のシーン構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2576800/02bb63c6-afb1-e318-a8ba-1b63e9dd3a1c.png)

```HubOptionManager.cs

public class HubOptionManager : MonoBehaviour
{
    [SerializeField] private TMP_InputField passwordText;
    [SerializeField] private ConnectionData _initialConnection;
    [SerializeField] private Toggle privateModeToggle;

    private void Start()
    {
        _initialConnection.PrivateLobbyPass = "";
    }

    public void SetPassword()
    {
        Guid g = Guid.NewGuid();
        var pass = g.ToString("N").Substring(0, 6);
        
        passwordText.text = privateModeToggle.isOn ? pass : "";
        _initialConnection.PrivateLobbyPass = pass;
    }

    public void SetPassword(string pass)
    {
        _initialConnection.PrivateLobbyPass = pass; 
    }
}
```

```ConnectionManager.cs

var startResult = await connection.Runner.StartGame(new StartGameArgs()
{
    CustomLobbyName = "Lobby" + connectionData.PrivateLobbyPass, //ここでパスワードを設定
    GameMode = gameMode,
    SessionProperties = sessionProperties,
    DisableClientSessionCreation = false,
    Scene = scene,
    PlayerCount = connectionData.MaxClients,
    Initialized = onInitialized,
    SceneManager = sceneManager,
    ObjectPool = connection.Runner.GetComponent<INetworkObjectPool>(),
});
```

### Late Joinの許可

NetworkRunner.SessionInfo.IsOpenはゲームが開始してから、他のプレイヤーがあとから参加できるようにするオプションです。trueの場合、ロビーとセッションが一致していればstartgameで同じセッションに参加することができます。

SocialHubサンプルではConnectionManager.csのLoadDungeonLevelメソッドでゲーム開始時にisOpenをfalseにしてしまっているため、isPrivateSessionの値によって設定できるように書き換えます。

```ConnectionData.cs

public bool LateJoin = false;
```

```ConnectionManager.cs

public void LoadDungeonLevel()
{
    if (!_dungeonConnection.IsRunning) return;

    _dungeonConnection.App.RPC_ShutdownRunner(Lobby);

    if (_dungeonConnection.Runner.IsServer)
    {
		_dungeonConnection.Runner.SessionInfo.IsOpen =  Convert.ToBoolean((int)_dungeonConnection.Runner.SessionInfo.Properties["isPrivateSession"]); //追加
        _dungeonConnection.Runner.SetActiveScene(_dungeonConnection.ActiveConnection.SceneIndex);
    }
}

public async Task ConnectToRunner(ConnectionData connectionData, Action<NetworkRunner> onInitialized = default, Action<ShutdownReason> onFailed = default)
{
	// 略
	var sessionProperties = new Dictionary<string, SessionProperty>()
    {
        { "ID", (int)connectionData.ID },
        {"Target", connectionData.Target.ToString() },
        {"isPrivateLobby", (int)connectionData.isPrivateLobby },
        {"PrivateSessionPass", connectionData.PrivateSessionPass },
        {"isPrivateSession", Convert.ToInt16(connectionData.isPrivateSession) },
        {"LateJoin", Convert.ToInt16(connectionData.LateJoin) },
    };

	// 略

	var startResult = await connection.Runner.StartGame(new StartGameArgs()
    {
        IsVisible = !connectionData.isPrivateSession,
        CustomLobbyName = "Lobby" + connectionData.PrivateLobbyPass,
        SessionName = connectionData.Target == Lobby ? "Lobby" : "huge",
        GameMode = gameMode,
        SessionProperties = sessionProperties,
        DisableClientSessionCreation = false,
        Scene = scene,
        PlayerCount = connectionData.MaxClients,
        Initialized = onInitialized,
        SceneManager = sceneManager,
        ObjectPool = connection.Runner.GetComponent<INetworkObjectPool>(),
    });
}

```

## プライベートマッチ

特定のプレイヤー以外が入ってこれないプライベートマッチに設定する、つまりセッションを非公開状態にするには、大きく2つのアプローチがあります。フィルタで検索側が非表示にするか、そもそも検索できないようにするか、です。

RoomDataManager.csはセッション一覧を取得して表示の更新をするスクリプトです。

```RoomDataManager.cs

public void OnSessionListUpdated(NetworkRunner runner, List<SessionInfo> sessionList)
{
    //DungeonLobbyUIの情報の更新
    InterfaceManager.Instance.DungeonLobbyUI.UpdateSessionInfo(runner,sessionList);
    
    //フィルタ処理
    var filteredSessionList = sessionList
        .Where(x => x.Properties["Target"] != ConnectionData.ConnectionTarget.Lobby.ToString()) //ロビー自体
        .Where(x => x.Properties["isPrivateLobby"] == Convert.ToInt16(false)) //プライベートモードのフィルタ
        .ToList();
    
    //余るオブジェクトは非アクティブにする
    if (roomDataList.Count != 0 && roomDataList.Count > filteredSessionList.Count)
    {
        int abs = roomDataList.Count - filteredSessionList.Count;
        for (int i = 0; i < abs; i++)
        {
            roomDataList[filteredSessionList.Count + i].SetActive(false);
        }
    }
    
    var noData = filteredSessionList.Count == 0; 
    _noSessionText.SetActive(noData);

    //データがない時の処理
    if (noData)
    {
        foreach (var roomdata in roomDataList)
        {
            roomdata.SetActive(false);
        }
        return;
    }
    
    //不足分UIオブジェクトを生成
    if (roomDataList.Count < filteredSessionList.Count)
    {
        int num = filteredSessionList.Count - roomDataList.Count;
        for (int i = 0; i < num; i++)
        {
            var item = Instantiate(roomDataPrefab, this.transform);
            roomDataList.Add(item);
        }
    }
    
    //リストデータをUIオブジェクトに流す
    for (int i = 0; i < filteredSessionList.Count; i++)
    {
        var session = filteredSessionList[i];
        session.Properties.TryGetValue("PrivateSessionPass", out var value);
        roomDataList[i].SetData(
            value?.PropertyValue.ToString()
            ,session.PlayerCount + "/" + session.MaxPlayers
            ,session.Name);
        roomDataList[i].SetActive(true);
    }

}
```

### フィルタ用の設定をする

- SessionPropertyの設定

StartGameのオプションのSessionPropertyにPrivareModeフラグを追加し、検索側がフィルタ処理をすることで見えないようにします。

またパスワードとなるランダム文字列を設定し、参加側がセッションを判別できるようにします。

SessionPropertyはUpdateCustomPropertiesで随時変更可能ですが、セッション情報はOnSessionListUpdatedでの通信に含まれてしまうため、無駄な通信が発生してしまう点には注意が必要です。

```ConnectionManager.cs

public async Task ConnectToRunner(ConnectionData connectionData, Action<NetworkRunner> onInitialized = default, Action<ShutdownReason> onFailed = default)
{
	// 略
	var sessionProperties = new Dictionary<string, SessionProperty>()
    {
        { "ID", (int)connectionData.ID },
        {"Target", connectionData.Target.ToString() },
        {"isPrivateLobby", (int)connectionData.isPrivateLobby },
        {"PrivateSessionPass", connectionData.PrivateSessionPass }, //パスの設定
        {"isPrivateSession", Convert.ToInt16(connectionData.isPrivateSession) }, //プライベーの判別値
        {"LateJoin", Convert.ToInt16(connectionData.LateJoin) }, 
    };
	// 略
}
```

```RoomDataManager.cs

public void OnSessionListUpdated(NetworkRunner runner, List<SessionInfo> sessionList)
{
    // 略
    
    //フィルタ処理
    var filteredSessionList = sessionList
        .Where(x => x.Properties["Target"] != ConnectionData.ConnectionTarget.Lobby.ToString())
        .Where(x => x.Properties["isPrivateLobby"] == Convert.ToInt16(false)) //プライベートモードのフィルタ
        .ToList();

    // 略
}
```

- isOpenの設定

セッションの設定であるisOpenでは、falseにすると他のプレイヤーが後から参加出来ないようになります。そのためセッション作成時のStartGameではなく、ゲームスタート後にNetworkRunner.SessionInfo.isOpenで変更するのがよいでしょう。

画像

```ConnectionManager.cs

public void LoadDungeonLevel()
{
    if (!_dungeonConnection.IsRunning) return;

    _dungeonConnection.App.RPC_ShutdownRunner(Lobby);

    if (_dungeonConnection.Runner.IsServer)
    {
        _dungeonConnection.Runner.SessionInfo.IsOpen =  Convert.ToBoolean((int)_dungeonConnection.Runner.SessionInfo.Properties["isPrivateSession"]);
        _dungeonConnection.Runner.SetActiveScene(_dungeonConnection.ActiveConnection.SceneIndex);
    }
}
```

### 不可視状態にする

- CustomLobbyNameの設定

そもそもロビーを新しく作成してしまう方法で、前項のロビーのプライベート化と同じです。あるロビーから他のロビーの情報は見えない仕様のため、OnSessionListUpdatedで無駄な通信を発生させません。

なお、公開状態の変更をするには再接続（ロビーの作り直し）が必要です。

- isVisibleの設定

StartGameのオプションIsVisibleではセッションを不可視状態にする事ができます。こちらもOnSessionListUpdatedの対象にならないため無駄な通信を発生させませんが、SessionNameが分かればあとから参加することが可能です。

設定の反映はStartGame時に行っていますが、ゲームの仕様によっては接続後にNetworkRunner.SessionInfo.IsVisibleで設定した方が良いかもしれません。

画像

```ConnectionManager.cs

var startResult = await connection.Runner.StartGame(new StartGameArgs()
{
    IsVisible = !connectionData.isPrivateSession, //ここで設定
    CustomLobbyName = "Lobby" + connectionData.PrivateLobbyPass, 
    GameMode = gameMode,
    SessionProperties = sessionProperties,
    DisableClientSessionCreation = false,
    Scene = scene,
    PlayerCount = connectionData.MaxClients,
    Initialized = onInitialized,
    SceneManager = sceneManager,
    ObjectPool = connection.Runner.GetComponent<INetworkObjectPool>(),
});
```