Chapter 11

🤝 ロビーを利用したマッチメイキング

o8que
o8que
2021.04.11に更新

このチャプターでは、ロビーを利用したマッチメイキングの実装方法について、サンプルプロジェクト(🎮)を例にして紹介していきます。

PUN2では、ロビー機能は推奨されていないことに注意してください。小規模なオンラインゲームなら問題にはなりませんが、同時接続人数が多いオンラインゲームでロビー機能を使用すると、多数のプレイヤーに対して多数のルームの更新通知を送るための大量の通信と処理が発生する可能性があります。

ルームリストのキャッシュ

ルームリストが更新された時に呼ばれるコールバック(OnRoomListUpdate())の引数からは、更新されたルーム情報の差分を取得できますが、ルームリスト全体を取得する機能はありません。

PUNではPhotonNetwork.GetRoomList()でルームリスト全体が取得できましたが、PUN2ではロビー機能が非推奨になったこともあり、廃止されて無くなっています。

そのため、ロビーを利用する場合には、ルームリストをキャッシュするクラス(RoomList)を独自に実装して、ロビーに参加している間はいつでもルームリスト全体を取得できるようにしておくと便利です。

RoomList.cs
using System.Collections;
using System.Collections.Generic;
using Photon.Realtime;

// IEnumerable<RoomInfo>インターフェースを実装して、foreachでルーム情報を列挙できるようにする
public class RoomList : IEnumerable<RoomInfo>
{
    private Dictionary<string, RoomInfo> dictionary = new Dictionary<string, RoomInfo>();

    public void Update(List<RoomInfo> changedRoomList) {
        foreach (var info in changedRoomList) {
            if (!info.RemovedFromList) {
                dictionary[info.Name] = info;
            } else {
                dictionary.Remove(info.Name);
            }
        }
    }

    public void Clear() {
        dictionary.Clear();
    }

    // 指定したルーム名のルーム情報があれば取得する
    public bool TryGetRoomInfo(string roomName, out RoomInfo roomInfo) {
        return dictionary.TryGetValue(roomName, out roomInfo);
    }

    public IEnumerator<RoomInfo> GetEnumerator() {
        foreach (var kvp in dictionary) {
            yield return kvp.Value;
        }
    }

    IEnumerator IEnumerable.GetEnumerator() {
        return GetEnumerator();
    }
}

RoomList自体ではロビー関連のコールバックを受け取るようにはしていないので、適切なタイミングでUpdate()メソッドやClear()メソッドを呼び出してルームリストを更新します。

using System.Collections.Generic;
using Photon.Pun;
using Photon.Realtime;

public class RoomListSample : MonoBehaviourPunCallbacks
{
    private RoomList roomList = new RoomList();

    public override void OnJoinedLobby() {
        roomList.Clear();
    }

    public override void OnRoomListUpdate(List<RoomInfo> changedRoomList) {
        roomList.Update(changedRoomList);
    }

    public override void OnLeftLobby() {
        roomList.Clear();
    }
}

ルーム固定型のロビー

ルームの数や種類をあらかじめ決めて用意しておくと、プレイヤーはそれらのルームの中から一つを選んで参加できるようになります。PhotonNetwork.JoinOrCreateRoom()を使用して、決められたルーム名のルームが既に存在するなら参加、ルームが存在しなければ作成してから参加するようにしましょう。

ここでは、ルーム数を5つに固定して用意するとして、以下のようなルーム参加ボタンのUIをサンプルプロジェクトに追加してみましょう。


MatchmakingView.cs
using System.Collections.Generic;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class MatchmakingView : MonoBehaviourPunCallbacks
{
    private RoomList roomList = new RoomList();
    private List<RoomButton> roomButtonList = new List<RoomButton>();
    private CanvasGroup canvasGroup;

    private void Start() {
        canvasGroup = GetComponent<CanvasGroup>();
        // ロビーに参加するまでは、全てのルーム参加ボタンを押せないようにする
        canvasGroup.interactable = false;

        // 全てのルーム参加ボタンを初期化する
        int roomId = 1;
        foreach (Transform child in transform) {
            if (child.TryGetComponent<RoomButton>(out var roomButton)) {
                roomButton.Init(this, roomId++);
                roomButtonList.Add(roomButton);
            }
        }
    }

    public override void OnJoinedLobby() {
        // ロビーに参加したら、ルーム参加ボタンを押せるようにする
        canvasGroup.interactable = true;
    }

    public override void OnRoomListUpdate(List<RoomInfo> changedRoomList) {
        roomList.Update(changedRoomList);

        // 全てのルーム参加ボタンの表示を更新する
        foreach (var roomButton in roomButtonList) {
            if (roomList.TryGetRoomInfo(roomButton.RoomName, out var roomInfo)) {
                roomButton.SetPlayerCount(roomInfo.PlayerCount);
            } else {
                roomButton.SetPlayerCount(0);
            }
        }
    }

    public void OnJoiningRoom() {
        // ルーム参加処理中は、全てのルーム参加ボタンを押せないようにする
        canvasGroup.interactable = false;
    }

    public override void OnJoinedRoom() {
        // ルームへの参加が成功したら、UIを非表示にする
        gameObject.SetActive(false);
    }

    public override void OnJoinRoomFailed(short returnCode, string message) {
        // ルームへの参加が失敗したら、再びルーム参加ボタンを押せるようにする
        canvasGroup.interactable = true;
    }
}

MatchmakingViewのゲームオブジェクトの子要素として、ルーム参加ボタン(RoomButton)を5つ作成します。各ボタンのコンポーネントや値は全て同じなので、1つ作成したら、残りは複製で作成しましょう。


RoomButton.cs
using Photon.Pun;
using Photon.Realtime;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class RoomButton : MonoBehaviour
{
    private const int MaxPlayers = 4;

    [SerializeField]
    private TextMeshProUGUI label = default;

    private MatchmakingView matchmakingView;
    private Button button;

    public string RoomName { get; private set; }

    public void Init(MatchmakingView parentView, int roomId) {
        matchmakingView = parentView;
        RoomName = $"Room{roomId}";

        button = GetComponent<Button>();
        button.interactable = false;
        button.onClick.AddListener(OnButtonClick);
    }

    private void OnButtonClick() {
        // ルーム参加処理中は、全ての参加ボタンを押せないようにする
        matchmakingView.OnJoiningRoom();

        // ボタンに対応したルーム名のルームに参加する(ルームが存在しなければ作成してから参加する)
        var roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = MaxPlayers;
        PhotonNetwork.JoinOrCreateRoom(RoomName, roomOptions, TypedLobby.Default);
    }

    public void SetPlayerCount(int playerCount) {
        label.text = $"{RoomName}\n{playerCount} / {MaxPlayers}";

        // ルームが満員でない時のみ、ルーム参加ボタンを押せるようにする
        button.interactable = (playerCount < MaxPlayers);
    }
}

SampleSceneのスクリプトでは、マスターサーバーへ接続した時に呼ばれるコールバックの処理を、ルームへ参加する処理からロビーへ参加する処理に変更しておきます。


SampleScene.cs
 using Photon.Pun;
 using UnityEngine;

 public class SampleScene : MonoBehaviourPunCallbacks
 {
     private void Start() {
         PhotonNetwork.NickName = "Player";
         PhotonNetwork.ConnectUsingSettings();
     }

     public override void OnConnectedToMaster() {
-        PhotonNetwork.JoinOrCreateRoom("Room", new RoomOptions(), TypedLobby.Default);
+        PhotonNetwork.JoinLobby();
     }

     public override void OnJoinedRoom() {
         var position = new Vector3(Random.Range(-3f, 3f), Random.Range(-3f, 3f));
         PhotonNetwork.Instantiate("Avatar", position, Quaternion.identity);

         if (PhotonNetwork.IsMasterClient) {
             PhotonNetwork.CurrentRoom.SetStartTime(PhotonNetwork.ServerTimestamp);
         }
     }
 }

ルーム選択型のロビー

ロビー機能を利用すれば、プレイヤーが自由にルームを作成したり、作成されたルームへ参加したりするマッチメイキングの仕組みを実現できます。ルームリストからルームを選択して参加する場合にはPhotonNetwork.JoinRoom()を使用し、新規にルームを作成する場合はPhotonNetwork.CreateRoom()を使用すると良いでしょう。

ここでは、以下のようなマッチメイキング用のUIをサンプルプロジェクトに追加してみましょう。


MatchmakingView.cs
using Photon.Pun;
using Photon.Realtime;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class MatchmakingView : MonoBehaviourPunCallbacks
{
    [SerializeField]
    private RoomListView roomListView = default;
    [SerializeField]
    private TMP_InputField roomNameInputField = default;
    [SerializeField]
    private Button createRoomButton = default;

    private CanvasGroup canvasGroup;

    private void Start() {
        canvasGroup = GetComponent<CanvasGroup>();
        // ロビーに参加するまでは、入力できないようにする
        canvasGroup.interactable = false;

        // ルームリスト表示を初期化する
        roomListView.Init(this);

        roomNameInputField.onValueChanged.AddListener(OnRoomNameInputFieldValueChanged);
        createRoomButton.onClick.AddListener(OnCreateRoomButtonClick);
    }

    public override void OnJoinedLobby() {
        // ロビーに参加したら、入力できるようにする
        canvasGroup.interactable = true;
    }

    private void OnRoomNameInputFieldValueChanged(string value) {
        // ルーム名が1文字以上入力されている時のみ、ルーム作成ボタンを押せるようにする
        createRoomButton.interactable = (value.Length > 0);
    }

    private void OnCreateRoomButtonClick() {
        // ルーム作成処理中は、入力できないようにする
        canvasGroup.interactable = false;

        // 入力フィールドに入力したルーム名のルームを作成する
        var roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = 4;
        PhotonNetwork.CreateRoom(roomNameInputField.text, roomOptions);
    }

    public override void OnCreateRoomFailed(short returnCode, string message) {
        // ルームの作成が失敗したら、再び入力できるようにする
        roomNameInputField.text = string.Empty;
        canvasGroup.interactable = true;
    }

    public void OnJoiningRoom() {
        // ルーム参加処理中は、入力できないようにする
        canvasGroup.interactable = false;
    }

    public override void OnJoinedRoom() {
        // ルームへの参加が成功したら、UIを非表示にする
        gameObject.SetActive(false);
    }

    public override void OnJoinRoomFailed(short returnCode, string message) {
        // ルームへの参加が失敗したら、再び入力できるようにする
        canvasGroup.interactable = true;
    }
}

入力フィールド(InputField)はインスペクターから、制限文字数を6、テキストコンテンツのタイプをAlphanumericにします。

ルームリストの表示部分は、ルームリスト表示の親要素(RoomListView)と子要素(RoomListViewElement)で構成します。

まず、親要素(RoomListView)のスクロールビューは、ゲームオブジェクトの追加ボタンから作成します。横方向にはスクロールしないので、スクロールビューの「Scrollbar Horizontal」は削除しておきましょう。


RoomListView.cs
using System.Collections.Generic;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;
using UnityEngine.UI;

public class RoomListView : MonoBehaviourPunCallbacks
{
    private const int MaxElements = 20;

    [SerializeField]
    private RoomListViewElement elementPrefab = default;

    private RoomList roomList = new RoomList();
    private List<RoomListViewElement> elementList = new List<RoomListViewElement>(MaxElements);
    private ScrollRect scrollRect;

    public void Init(MatchmakingView parentView) {
        scrollRect = GetComponent<ScrollRect>();

        // ルームリスト要素(ルーム参加ボタン)を生成して初期化する
        for (int i = 0; i < MaxElements; i++) {
            var element = Instantiate(elementPrefab, scrollRect.content);
            element.Init(parentView);
            element.Hide();
            elementList.Add(element);
        }
    }

    public override void OnRoomListUpdate(List<RoomInfo> changedRoomList) {
        roomList.Update(changedRoomList);

        // 存在するルームの数だけルームリスト要素を表示する
        int index = 0;
        foreach (var roomInfo in roomList) {
            elementList[index++].Show(roomInfo);
        }

        // 残りのルームリスト要素を非表示にする
        for (int i = index; i < MaxElements; i++) {
            elementList[i].Hide();
        }
    }
}

スクロールビューの「Viewport」には、デフォルトでMaskコンポーネントが追加されていますが、より処理が軽いRectMask2Dコンポーネントに差し替えます。

スクロールビューの「Content」には、VerticalLayoutGroupコンポーネントを追加して子要素を自動的に整列、そしてContentSizeFitterコンポーネントを追加して子要素の数に応じて自動的に高さが調整されるようにしておきます。

次に、ルームリストの子要素(RoomListViewElement)のプレハブを作成します。作成した後に、ルームリストの親要素(RoomListView)のインスペクターから、プレハブの参照をelementPrefabにアタッチしましょう。



RoomListViewElement.cs
using Photon.Pun;
using Photon.Realtime;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class RoomListViewElement : MonoBehaviour
{
    [SerializeField]
    private TextMeshProUGUI nameLabel = default;
    [SerializeField]
    private TextMeshProUGUI playerCounter = default;

    private MatchmakingView matchmakingView;
    private Button button;

    public void Init(MatchmakingView parentView) {
        matchmakingView = parentView;

        button = GetComponent<Button>();
        button.onClick.AddListener(OnButtonClick);
    }

    private void OnButtonClick() {
        // ルーム参加処理中は、入力できないようにする
        matchmakingView.OnJoiningRoom();

        // ボタンに対応したルーム名のルームに参加する
        PhotonNetwork.JoinRoom(nameLabel.text);
    }

    public void Show(RoomInfo roomInfo) {
        nameLabel.text = roomInfo.Name;
        playerCounter.SetText("{0} / {1}", roomInfo.PlayerCount, roomInfo.MaxPlayers);

        // ルームが満員でない時のみ、参加ボタンを押せるようにする
        button.interactable = (roomInfo.PlayerCount < roomInfo.MaxPlayers);

        gameObject.SetActive(true);
    }

    public void Hide() {
        gameObject.SetActive(false);
    }
}

SampleSceneのスクリプトでは、マスターサーバーへ接続した時に呼ばれるコールバックの処理を、ルームへ参加する処理からロビーへ参加する処理に変更しておきます。


SampleScene.cs
 using Photon.Pun;
 using UnityEngine;

 public class SampleScene : MonoBehaviourPunCallbacks
 {
     private void Start() {
         PhotonNetwork.NickName = "Player";
         PhotonNetwork.ConnectUsingSettings();
     }

     public override void OnConnectedToMaster() {
-        PhotonNetwork.JoinOrCreateRoom("Room", new RoomOptions(), TypedLobby.Default);
+        PhotonNetwork.JoinLobby();
     }

     public override void OnJoinedRoom() {
         var position = new Vector3(Random.Range(-3f, 3f), Random.Range(-3f, 3f));
         PhotonNetwork.Instantiate("Avatar", position, Quaternion.identity);

         if (PhotonNetwork.IsMasterClient) {
             PhotonNetwork.CurrentRoom.SetStartTime(PhotonNetwork.ServerTimestamp);
         }
     }
 }