Unity Lobby β でマルチプレイ用のロビーを作成する【Unity Gaming Services】

Unity Lobby

Unity Lobby は Unity 公式のロビー作成用のサービスです.
Unity Lobby を利用することで,プレイヤーが複数人と一緒にマッチ出来るロビーを作成することができます.

記事作成時はベータ版となっております.

サンプルプロジェクト

ロビーに関するサンプルを一から UI を作るのは大変であることと,
クエリ関連で結構手間ということもあり,今回の記事では Unity 公式の Relay & Lobby サンプルを利用いたします.

f:id:xrdnk:20211103201631p:plain

github.com

ちなみに API 部分を少しラッピングしたものを一応以下のリポジトリにあげております.

github.com

動作の様子

順番に右のプレイヤーがロビー検索(初回は見つからず)→右のプレイヤーがロビー作成→
左のプレイヤーがロビー検索→見つかったロビーに入室→プレイヤー情報(感情)更新って感じです.

本サンプルでは以下の Unity Gaming Services が利用されています.

  • Unity Relay
  • Unity Lobby
  • Vivox

そのため,ロビー機能もありつつ,ホストがリレーサーバを作ることができ,
加えてマッチングしたプレイヤーはお互いボイスチャットを行うこともできます.

UGS 版の Vivox の利用方法については,またいずれ記事にする予定です.

インストール方法

Add Package by Name に com.unity.services.lobby を追加することで完了します.

あるいは manifest.json に以下を追加すればできます.

"dependencies": {
    "com.unity.services.lobby": "1.0.0-pre.6",
 }

解説

Unity Lobby の機能

Lobby の機能自体は至って単純で,以下の通りです.

  • ロビー作成処理
  • ロビー検索処理
  • ロビー更新処理
  • ロビー取得処理
  • 過去に入室したことがあるロビーID一覧の取得処理
  • ロビー削除処理
  • ロビーハートビート処理
  • ロビー内のプレイヤー情報更新処理
  • プレイヤージョイン処理(ID・コード・クイックの3種)
  • プレイヤーリムーブ処理

準備設定

まず,Unity Lobby を利用するために準備が必要になります.
Unity Editor 側で Project Settings > Services で Unity Project ID を設定します.

f:id:xrdnk:20211030162853p:plain

次に Unity Dashboard 側で Relay を利用したいUnity Project を選択します.
そして,Get Started から表示されている手順通りに進めば Lobby が ON になります.

f:id:xrdnk:20211103201405p:plain

ベータ版では,Relay も一緒に ON にするかのダイアログが表示されます.
ベータ版では両方 ON にしないと利用できないようなのでここでは ON にしましょう.

これで UGS の Lobby を利用できる準備が完了しました.

認証処理

Unity Gaming Services は基本的には Unity Authentication で認証を通してからでないと使えません.
なので,公式サンプルでは 一回 Unity Authentication で匿名認証処理を行っています.

xrdnk.hateblo.jp

ロビー作成処理

ロビー作成処理は以下の通りです.

        /// <summary>
        /// ロビー情報の作成処理 (CREATE)
        /// </summary>
        /// <param name="lobbyName">ロビー名</param>
        /// <param name="maxPlayers">ロビー内の最大プレイヤー人数</param>
        /// <param name="options">ロビー作成設定</param>
        /// <returns>ロビーオブジェクト</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<Lobby> CreateLobbyAsync
        (
            string lobbyName,
            int maxPlayers,
            CreateLobbyOptions options = null
        )
        {
            try
            {
                return await Lobbies.Instance.CreateLobbyAsync(lobbyName, maxPlayers, options);
            }
            catch (LobbyServiceException lse)
            {
                throw new LobbyServiceException(lse);
            }
        }

CreateLobbyOptions の中身は以下の通りです.

    /// <summary>
    /// ロビー作成リクエストのオプションのパラメータクラスです
    /// </summary>
    public class CreateLobbyOptions 
    {
        /// <summary>
        /// ロビーが公開されているかどうかを示し、クエリ結果に表示されます。
        /// ロビーが公開されていない場合、作成者は LobbyCode を他のユーザと共有することができ、そのユーザは LobbyCode を使ってこのロビーに参加することができます。
        /// null のままにしておくと、デフォルト値が使用されます。
        /// </summary>
        public bool? IsPrivate { get; set; }

        /// <summary>
        /// ロビー作成者に関する情報
        /// </summary>
        public Player Player { get; set; }

        /// <summary>
        /// ロビーに適用されるカスタムゲーム固有のプロパティ(例:mapNameやgameType)
        /// </summary>
        public Dictionary<string, DataObject> Data { get; set; }
    }

結構ロビー作成するための情報はいろいろ設定できますが,
正直面倒だったらデフォルトの設定で任せてもいいのではないかなと思います.

注意として,公開・非公開設定は CreateLobbyOptions.IsPrivateDataObject.VisibilityOptions の2種類あります.

CreateLobbyOptions.IsPrivate はロビーの公開・非公開設定です.
公開設定の場合はクエリ検索すると検索で見つかりますが,非公開設定の場合は検索しても見つかりません.
その場合はジョインコードまたは ID でロビーに入室することになります.

DataObject.VisibilityOptions は,ロビー内のデータの公開設定を行うことができます.
Public(公開)Member(メンバー限定)Private(非公開) の3つのタイプになります.

ロビー検索処理

ロビーの検索処理は以下の通りです.

        /// <summary>
        /// ロビーを指定した条件で検索する (READ)
        /// </summary>
        /// <param name="options">検索条件</param>
        /// <returns>検索にヒットしたロビーリスト</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<List<Lobby>> QueryLobbyAsync(QueryLobbiesOptions options = null)
        {
            try
            {
                return (await Lobbies.Instance.QueryLobbiesAsync(options)).Results;
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

QueryLobbiesOptions の中身が結構項目がモリモリです.

    /// <summary>
    /// Lobbyサービスリクエストのクエリパラメータ
    /// </summary>
    public class QueryLobbiesOptions 
    {
        /// <summary>
        /// 検索結果の数.最小値は1,最大値は100.デフォルトは10
        /// </summary>
        public int Count { get; set; } = 10;

        /// <summary>
        /// スキップする結果の数.最大値は1000.デフォルトは0
        /// </summary>
        public int Skip { get; set; } = 0;

        /// <summary>
        /// 検索フィルターにマッチした結果のランダムなサンプルを返すかどうかを指定
        /// </summary>
        public bool SampleResults { get; set; } = false;

        /// <summary>
        /// どのロビーを返すかを絞り込むために使用できるフィルターのリスト
        /// </summary>
        public List<QueryFilter> Filters { get; set; } = false/// <summary>
        /// レスポンスの中で結果をどのように並べるかを定義するオーダーのリスト
        /// </summary>
        public List<QueryOrder> Order { get; set; } です。

        /// <summary>
        /// 結果の次のページを取得するために,後続のクエリリクエストに渡すことができるトークン
        /// </summary>
        public string ContinuationToken { get; set; }.
    }

QueryFilter の項目は多くて全て説明するととてもつらいので,概要だけ説明すると,
「ロビー名」,「最大人数設定」,「最終更新」,「自分が作成したカスタム値」だったり,
「(指定した値に対して)以上,以下,未満,より大きい,同値,等しくない」などのフィルタリングができます.

例えばサンプルプロジェクトでは,「ロビーの色」でフィルタリングができます.

QueryFilter は指定検索値(例えばロビー名など)に対して昇順,降順の設定ができます.

ContinuationToken はページネーションに利用されるトークンです.
QueryOptions.ContinuationTokenResponse.ContinuationToken を渡してページネーションができます.

// ページングに使用する共通のクエリオプション
var queryOptions = new QueryLobbiesOptions
{
    SampleResults = false, // ページネーションを利用する場合,ランダムな結果を使用できない
        Filters = new List <QueryFilter>
        {
            // 公開されているロビーのみをページに含める
            new QueryFilter(
                フィールド QueryFilter.FieldOptions.AvailableSlots,
                // GT = Greater Than つまり,「より大きい」
                op: QueryFilter.OpOptions.GT,
                // 値を 0 と設定したので,「AvailableSlots が 0 より大きい」でフィルタリング
                value: "0")
        },
        Order = new List<QueryOrder>
        {
            // 古いロビーを最初に表示する
            new QueryOrder(true, QueryOrder.FieldOptions.Created),
        }
};

var response = await Lobbies.Instance.QueryLobbiesAsync(queryOptions);
var lobbies = response.Results;

// 次のページが空になっても ContinuationToken は返ってくる
// レスポンスに新しいロビーリストがなくなるまでページングを続ける
while (lobbies.Count > 0) {.
    // 現在のページのロビーでここで何かをする

    // 次のページを取得
    // クエリのオプションでフィルタや順序を変更しないように注意してください。
    // 変更した場合はエラーが返ってくるので,しっかり ContinuationToken のバケツリレーをしましょう
    queryOptions.ContinuationToken = response.ContinuationToken;
    response = await Lobbies.Instance.QueryLobbiesAsync(queryOptions);
    lobbies = response.Results;
}

ロビー更新処理

ロビーの更新処理は以下の通りです.
更新したいロビーのIDを指定し,UpdateLobbyOptions で更新設定を渡します.

        /// <summary>
        /// ロビー情報の更新処理 (UPDATE)
        /// </summary>
        /// <param name="lobbyId">更新するロビー情報のID</param>
        /// <param name="options">ロビー更新情報</param>
        /// <returns>更新されたロビー</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<Lobby> UpdateLobbyAsync
        (
            string lobbyId,
            UpdateLobbyOptions options
        )
        {
            try
            {
                return await Lobbies.Instance.UpdateLobbyAsync(lobbyId, options);
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

UpdateLobbyOptions の中身は以下の通りになります.

    public class UpdateLobbyOptions 
    {
        /// <summary>
        /// ユーザーに表示されるロビーの名前です。すべてのホワイトスペースは名前から切り取られます。
        /// 最小の長さ: 1 最大の長さ: 256.
        /// </summary>
        public string Name { get; set; }

        /// <summary>
        /// ロビーで許可されるプレイヤーの最大人数です。ロビー内の現在のプレイヤー数以上でなければなりません。
        /// 最小値: 1  最大値: 100.
        /// </summary>
        public int? MaxPlayers { get; set; }

        /// <summary>
        /// ロビーが一般に公開されていて、クエリの結果に表示されるかどうかを示します。
        /// ロビーが公開されていない場合、作成者はLobbyCodeを他のユーザーと共有することができ、そのユーザーはLobbyCodeを使ってこのロビーに参加することができます。
        /// </summary>
        public bool? IsPrivate { get; set; }

        /// <summary>
        /// ロビーがロックされているかどうかを示します。
        /// </summary>
        public bool? IsLocked { get; set; }

        /// <summary>
        /// ロビーに追加、更新、または削除するカスタムゲーム固有のプロパティ(mapNameやgameTypeなど)。
        /// 既存のプロパティを削除するには、データに含めますが、プロパティオブジェクトをnullに設定します。値をnullに更新するには、オブジェクトのvalueプロパティをnullに設定します。
        /// </summary>
        public Dictionary<string, DataObject> Data { get; set; }

        /// <summary>
        /// ロビーのホストにするプレイヤーのIDです。これが更新されると、現在のホストはロビーを変更する権限を持たなくなります。
        /// </summary>
        public string HostId { get; set; }
    }

ロビー取得処理

ロビー情報自体の取得処理を行うことができます.

        /// <summary>
        /// 指定ロビーIDのロビー情報を取得する (READ)
        /// </summary>
        /// <param name="lobbyId">ロビーID</param>
        /// <returns>ロビー情報</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<Lobby> GetLobbyAsync(string lobbyId)
        {
            try
            {
                return await Lobbies.Instance.GetLobbyAsync(lobbyId);
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

過去に入室したことがあるロビーID一覧の取得処理

過去に入室したことがあるロビーID一覧を取得することができます.
利用用途としては入室履歴などでしょうか.

        /// <summary>
        /// 過去に入室したことがあるロビーID一覧の取得処理
        /// </summary>
        /// <returns>過去に入室したことがあるロビーID一覧</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<List<string>> GetJoinedLobbiesAsync()
        {
            try
            {
                return await Lobbies.Instance.GetJoinedLobbiesAsync();
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

ロビー削除処理

作成されたロビーを削除する処理になります.

        /// <summary>
        /// ロビー削除処理 (DELETE)
        /// </summary>
        /// <param name="lobbyId">削除するロビーID</param>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask DeleteLobbyAsync(string lobbyId)
        {
            try
            {
                await Lobbies.Instance.DeleteLobbyAsync(lobbyId);
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

ロビーハートビート処理

ロビーに対してハートビート送信を行います.
ハートビートは,ネットワークで接続されたコンピューターやネットワーク機器が接続が有効であることを確認するために,
定期的に送信する信号のことを指します.ここの文脈では,ロビーの接続が有効かどうかのチェックだと捉えて頂ければと思います.

ロビーは30秒間の間ハートビート送信を行わない場合,ロビーは非活性状態になります.
そのため,定期的にハートビート送信を行って接続有効確認を行う必要があります(これを一般的にはキープアライブという)

        // ハートビート送信は30秒間に5回というレート制限があるので,6以上の場合は6にクランプした方がいいかもしれない
        const int MIN_HEARTBEAT_DURATION = 6;

        /// <summary>
        /// ロビーにハートビートを送信する処理
        /// </summary>
        /// <param name="lobbyId">指定ロビーID</param>
        /// <param name="heartBeatDuration">ハートビート送信間隔秒数</param>
        /// <param name="token">CancellationToken</param>
        public async UniTask HeartbeatLobbyAsync
        (
            string lobbyId,
            float heartBeatDuration,
            CancellationToken token
        )
        {
            // ハートビート送信は30秒間の間で5回以上送った場合,429エラーが発生する
            // よって,429エラーが発生しないようにクランプを行うとよい
            if (heartBeatDuration <= MIN_HEARTBEAT_DURATION)
            {
                heartBeatDuration = MIN_HEARTBEAT_DURATION;
            }

            while (!token.IsCancellationRequested)
            {
                await Lobbies.Instance.SendHeartbeatPingAsync(lobbyId);
                await UniTask.Delay(TimeSpan.FromSeconds(heartBeatDuration), cancellationToken: token);
            }
        }

一番楽なのは,ロビー作成処理を行う際に一緒に平行でハートビート定期確認処理を投げるのがいいと思います.
以下はサンプルです.

        /// <summary>
        /// ロビー作成とハートビート定期送信処理を行う
        /// </summary>
        /// <param name="lobbyName">ロビー名</param>
        /// <param name="maxPlayers">最大参加人数</param>
        /// <param name="options">ロビー作成設定</param>
        /// <param name="heartBeatDuration">ハートビート間隔</param>
        /// <param name="token">CancellationToken</param>
        /// <returns>作成したロビー</returns>
        public async UniTask<Lobby> CreateLobbyWithHeartbeatAsync
        (
            string lobbyName,
            int maxPlayers,
            CreateLobbyOptions options,
            float heartBeatDuration,
            CancellationToken token
        )
        {
            Lobby createdLobby = null;
            createdLobby = await CreateLobbyAsync(lobbyName, maxPlayers, options);
            _ = HeartbeatLobbyAsync(createdLobby.Id, heartBeatDuration, token);
            return createdLobby;
        }

ロビー内のプレイヤー情報更新処理

ロビー内にいるプレイヤー情報の更新処理を行います.

        /// <summary>
        /// 指定ロビーのプレイヤー情報の更新処理 (UPDATE)
        /// </summary>
        /// <param name="lobbyId">指定ロビーID</param>
        /// <param name="playerId">更新するプレイヤーのID</param>
        /// <param name="options"></param>
        /// <returns>プレイヤー情報が更新されたロビー</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<Lobby> UpdatePlayerAsync
        (
            string lobbyId,
            string playerId,
            UpdatePlayerOptions options
        )
        {
            try
            {
                return await Lobbies.Instance.UpdatePlayerAsync(lobbyId, playerId, options);
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

公式サンプルでは感情情報の更新に利用されているようです.

UpdatePlayerOptions の中身は以下のようになっています.

    public class UpdatePlayerOptions 
    {
        public string ConnectionInfo { get; set; }

        public Dictionary<string, PlayerDataObject> Data { get; set; }

        public string AllocationId { get; set; }
    }

プレイヤージョイン処理(ID・コード・クイックの3種)

プレイヤーのジョイン(入室)処理は3種類あって,以下の3通りです.

  • JoinLobbyByIdAsync
  • JoinLobbyByCodeAsync
  • QuickJoinLobbyAsync

JoinLobbyByIdAsync

指定したロビーIDに入室する処理です.

        /// <summary>
        /// ロビーIDを用いた入室処理 (JOIN LOBBY)
        /// </summary>
        /// <param name="lobbyId">ロビーID</param>
        /// <returns>入室したロビー情報</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<Lobby> JoinLobbyByIdAsync(string lobbyId)
        {
            try
            {
                return await Lobbies.Instance.JoinLobbyByIdAsync(lobbyId);
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

JoinLobbyByCodeAsync

指定したロビーコードに入室する処理です.
ロビーIDよりロビーコードを利用することが多いと思います.

        /// <summary>
        /// 入室用コードを用いた入室処理 (JOIN LOBBY)
        /// </summary>
        /// <param name="joinCode">入室用コード</param>
        /// <returns>入室したロビー情報</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<Lobby> JoinLobbyByCodeAsync(string joinCode)
        {
            try
            {
                return await Lobbies.Instance.JoinLobbyByCodeAsync(joinCode);
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

QuickJoinLobbyAsync

指定した条件に該当するロビーにとにかくジョインする処理になります.
QuickJoinLobbyOptions には PlayerList<QueryFilter> を設定することができます.

        /// <summary>
        /// 特定の条件下において素早く入室する処理 (QUICK JOIN)
        /// </summary>
        /// <returns>入室したロビー情報</returns>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask<Lobby> QuickJoinLobbyAsync(QuickJoinLobbyOptions options = null)
        {
            try
            {
                return await Lobbies.Instance.QuickJoinLobbyAsync(options);
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

プレイヤーリムーブ処理

ロビーからプレイヤーを取り除く処理は以下の通りになります.
playerId を自身とすれば,退室処理になります.

        /// <summary>
        /// ロビーからプレイヤーを退出する処理 (REMOVE)
        /// </summary>
        /// <param name="lobbyId">指定ロビーID</param>
        /// <param name="playerId">対象のプレイヤーID</param>
        /// <exception cref="LobbyServiceException">LobbyServiceException</exception>
        public async UniTask RemovePlayerAsync(string lobbyId, string playerId)
        {
            try
            {
                await Lobbies.Instance.RemovePlayerAsync(lobbyId, playerId);
            }
            catch (LobbyServiceException lse)
            {
                Debug.LogError(lse);
                throw new LobbyServiceException(lse);
            }
        }

リクエストに対するレート制限

Unity Lobby にはレート制限があり,一定の秒数内にAPIが受け取るリクエストの数を制限しております.
これにより,ネットワーク トラフィックの制御に役立てています.レート制限を超えた場合は 429 エラーが返ってきます.

リクエストタイプ レート制限
検索処理 (Query) 1秒に1回
作成処理 (Create) 6秒に2回
入室処理 (Join) 6秒に2回
クイック入室処理 (Quick Join) 10秒に1回
取得処理 (Get) 1秒に1回
入室したロビーの取得処理 (Get Joined) 30秒に1回
削除処理 (Delete) 1秒に2回
更新処理 (Update) 1秒に5回
ロビー退室/プレイヤーリムーブ処理 1秒に5回
プレイヤー情報更新処理 (Update Player) 5秒に5回
ハートビート送信処理 (HeartBeat) 30秒に5回

参考資料

内容盛沢山でした.理解さえすれば中身自体は単純なはず….

docs.unity.com