LoginSignup
22
10

More than 3 years have passed since last update.

【Pun2Task】 Photon Unity Networking2(PUN2) を async/await で扱えるようにしてみた話

Last updated at Posted at 2020-12-22

「Pun2Task」の紹介

Pun2Task

Pun2TaskとはPUN2async/awaitを使って利用できるようにするUnity向けのライブラリです。

UniTaskを利用しているため利用時は一緒にインポートしてください。
(パッケージマネージャから導入すると自動で導入されます)

PUN2とは

Exit Games社が提供する「Unity向けのリアルタイムクライドサーバおよびそのUnityライブラリ」のサービス総称をPUNと呼びます。
PUN2とはそのUnity向けライブラリのver.2のことであり、正式名称はPhoton Unity Networking 2です。

PUNを用いるとクラウドサーバを利用して比較的簡単にオンラインゲームが作れます。
しかも同時接続数が20クライアントまでだったら無料で使える点などから、個人開発や同人ゲーム開発などでよくされているサービスです。

PUN2のAPIデザイン

PUN2のAPIは「MonoBehavoiurと同じ様にイベント発生時に対応したコールバックメソッドが呼び出される」という仕組みになっています。

たとえば「サーバにログインして部屋に入る」という処理は次のように記述します。

サーバにログインして部屋に入る
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class LoginSample : MonoBehaviourPunCallbacks
{
    private void Start()
    {
        // サーバへ接続する
        PhotonNetwork.ConnectUsingSettings();
    }

    // サーバへの接続が成功した時に呼ばれる
    public override void OnConnectedToMaster()
    {
        // 部屋に参加する
        // 部屋がない場合は新規作成、ある場合は既存の部屋に参加する
        PhotonNetwork.JoinOrCreateRoom("test_room", new RoomOptions(), TypedLobby.Default);
    }


    // 部屋に参加できた
    public override void OnJoinedRoom()
    {
        // プレイヤ作成
        PhotonNetwork.Instantiate("Player", Vector3.zero, Quaternion.identity);
    }
}

PUN1のころはインタフェース定義などが無かったため、一字一句イベント関数名を間違えずに定義しなくてはいけなく大変でした。
PUN2からはインタフェース定義や基底クラスが準備されており、それを実装/継承すればOKになりました。

イベント関数呼び出しの弱点

このような「イベント関数を呼び出す」というAPIデザインはシンプルであり挙動の理解はしやすいです。
その一方で「手続き同士が断絶する」という弱点があります。

たとえばPhotonNetwork.JoinOrCreateRoomは「すでに部屋があるならそこに参加し、なければ新規作成する」というAPIです。
このAPIは次のような動作をします。

  • 部屋を新規作成した場合はOnCreatedRoomが実行され、その後にOnJoinedRoomが実行される
  • 部屋がすでにあった場合は参加を試み、成功した場合はOnJoinedRoomが実行される
  • 部屋に参加できなかった場合はOnJoinRoomFailedが実行される。

APIの挙動自体はシンプルです。それ故これを用いて複雑な処理を書こうとすると厳しくなります。

仮に「部屋名のリストがあり、順番に入室を試していく」という処理を作るとします。

部屋リストに対して順番に参加する
using System;
using Photon.Pun;
using UnityEngine;

public sealed class RoomJoinBehaviour : MonoBehaviourPunCallbacks
{
    private string[] _currentRoomList;
    private int _currentIndex;
    private Action<string> _onSuccess;
    private Action _onFailed;

    /// <summary>
    /// 与えられた部屋名リストを利用して部屋に参加する
    /// </summary>
    /// <param name="roomList">部屋名のリスト</param>
    /// <param name="onSuccess">成功時のコールバック</param>
    /// <param name="onFailed">すべて失敗したときのコールバック</param>
    public void ConnectAnyRoom(string[] roomList, Action<string> onSuccess, Action onFailed)
    {
        _currentRoomList = roomList;
        _currentIndex = 0;
        _onSuccess = onSuccess;
        _onFailed = onFailed;

        PhotonNetwork.JoinRoom(_currentRoomList[_currentIndex]);
    }


    // 部屋に参加できた
    public override void OnJoinedRoom()
    {
        Debug.Log($"{_currentRoomList[_currentIndex]}に参加しました。");

        _onSuccess?.Invoke(_currentRoomList[_currentIndex]);

        // プレイヤ作成
        PhotonNetwork.Instantiate("Player", Vector3.zero, Quaternion.identity);
    }

    // 部屋に参加できなかった
    public override void OnJoinRoomFailed(short returnCode, string message)
    {
        Debug.LogError($"{_currentRoomList[_currentIndex]}に参加失敗。 Message={message}");

        if (++_currentIndex < _currentRoomList.Length)
        {
            // 次の部屋につなぐ
            PhotonNetwork.JoinRoom(_currentRoomList[_currentIndex]);
        }
        else
        {
            _onFailed?.Invoke();
        }
    }
}

このように実装自体は可能なのですが、フィールド変数を経由して文脈を維持するということが必要となってしまいます。
この例ではまだ簡単な方で、もうちょっと凝ったエラーハンドリングを追加したりしようとするとさらにややこしいことになってしまいます。

というわけで、素のPUN2のAPIデザインでは凝ったことがやりにくいという欠点がありました。

Pun2Taskでできること

そこでPUN2のAPIの使いにくさを解消できるライブラリがPun2Taskです。
PUN2の各種APIをUniTaskとして扱うことができるようになります。

PhotonNetwork.~というAPIのasync/await対応

さきほどの「サーバにログインして部屋に入る」という処理をasync/awaitを用いて次のように記述することが可能となります。

Pun2Taskでのログイン処理
using Cysharp.Threading.Tasks;
using Pun2Task;
using UnityEngine;

public class LoginSample : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        var token = this.GetCancellationTokenOnDestroy();

        try
        {
            // サーバに接続する
            await Pun2TaskNetwork.ConnectUsingSettingsAsync(token);

            // ルームへの参加または新規作成
            var isFirstUser = await Pun2TaskNetwork.JoinOrCreateRoomAsync(
                roomName: "test_room",
                roomOptions: default,
                typedLobby: default,
                token: token);

            Debug.Log("部屋に参加成功");
            Debug.Log($"自分が部屋を作成したか? = {isFirstUser}");
        }
        catch (Pun2TaskNetwork.ConnectionFailedException ex)
        {
            // サーバに接続できなかった
            Debug.LogError(ex);
        }
        catch (Pun2TaskNetwork.FailedToCreateRoomException ex)
        {
            // 何らかの理由で部屋が作れなかった
            Debug.LogError(ex);
        }
        catch (Pun2TaskNetwork.FailedToJoinRoomException ex)
        {
            // 部屋に参加できなかった
            Debug.LogError(ex);
        }
    }
}
  • PUN2の各種APIをawaitで待機できる
  • API実行失敗時のエラー通知は例外という形でハンドリングできる

このようにPun2Taskを利用することで使いにくかったPUN2のイベントコールバックを考えず、素直にasync/awaitで処理を記述できるようになりました。

おまけ:「部屋名のリストがあり、順番に入室を試していく」のPun2Task

さっきの「部屋名のリストがあり、順番に入室を試していく」をPun2Taskを使って書くとこうなります。

Pun2Taskで部屋に順番に参加する処理
/// <summary>
/// 与えられたルーム一覧に順番に参加を試みる
/// 成功時:そのルームの名前
/// 失敗時:InvalidOperationException
/// </summary>
private async UniTask<string> JoinAnyRoomAsync(string[] rooms)
{
    return await rooms.ToUniTaskAsyncEnumerable()
        .SelectAwaitWithCancellation(async (roomName, ct) =>
        {
            try
            {
                // 成功したらそのときの部屋名を返す
                await Pun2TaskNetwork.JoinRoomAsync(roomName, null, ct);
                return roomName;
            }
            catch (Pun2TaskNetwork.Pun2TaskException ex)
            {
                // 失敗時はnullを返す
                Debug.LogException(ex);
                return null;
            }
        }).FirstAsync(x => x != null);
}

Pun2TaskによりAPIをUniTask化できるので、UniTaskAsyncEnumerableUniTask.Linqが併用可能です。
そのためこれくらいシンプルに記述ができます。

参考 : UniTaskAsyncEnumerable

各種イベントコールバックのUniTask化

OnConnected()OnJoinedRoom()といったそれぞれのイベントコールバックを単体でUniTaskとして扱うこともできます。

各種コールバックイベントのUniTask化
private async UniTaskVoid Callbacks(CancellationToken token)
{
    // 各種コールバックを待てる
    await Pun2TaskCallback.OnConnectedAsync();
    await Pun2TaskCallback.OnCreatedRoomAsync();
    await Pun2TaskCallback.OnJoinedRoomAsync();
    await Pun2TaskCallback.OnLeftRoomAsync();
    // etc.

    // パラメータの取得も可能
    DisconnectCause disconnectCause = await Pun2TaskCallback.OnDisconnectedAsync();
    Player newPlayer = await Pun2TaskCallback.OnPlayerEnteredRoomAsync();
    Player leftPlayer = await Pun2TaskCallback.OnPlayerLeftRoomAsync();
    // etc.

    // OnPlayerEnteredRoom and OnPlayerLeftRoomAsync は UniTaskAsyncEnumerableとしても扱える
    Pun2TaskCallback
        .OnPlayerEnteredRoomAsyncEnumerable()
        .ForEachAsync(x => Debug.Log(x.NickName), cancellationToken: token);

    Pun2TaskCallback
        .OnPlayerLeftRoomAsyncEnumerable()
        .ForEachAsync(x => Debug.Log(x.NickName), cancellationToken: token);
}

PhotonViewの拡張(IsMineの管理)

また、PhotonView.IsMine(オブジェクトの所有権)もasync/awaitUniTaskAsyncEnumerableで扱えるようになっています。

PhotonViewの拡張
public class SampleView : MonoBehaviourPun
{
    private void Start()
    {
        // 所有権の遷移を IUniTaskAsyncEnumerable<bool> で監視できる。
        photonView
            .IsMineAsyncEnumerable()
            .Subscribe(x =>
            {
                // 所有者が変化したら通知される。
                Debug.Log($"IsMine = {x}");
            })
            .AddTo(this.GetCancellationTokenOnDestroy());
    }

    public void Update()
    {
        if (Input.GetKeyDown(KeyCode.A))
        {
            UniTask.Void(async () =>
            {
                // 所有権の取得を要求して、それが完了するまでawaitできる。
                await photonView.RequestOwnershipAsync();

                Debug.Log("Got ownership!");
            });
        }
    }
}

「Pun2Task」の実装解説

Pun2Taskでできることはわかったので、ではそれをどのように実装したのかを解説します。

Pun2TaskCallbackの実装

Pun2TaskCallbackOnConnected()OnJoinedRoom()といったイベントコールバックをUniTaskとして提供する機構です。
実装は2つに分けられています。

PunCallbacksBridge

PunCallbacksBridgeはPUN2上で発行されるイベントを受け取り、UniTaskに変換して提供するオブジェクトです。

たとえば「OnJoinRoomFailed」というイベントは次のような実装でUniTaskに変換されています。

PunCallbacksBridge
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;

namespace Pun2Task.Callbacks
{
    internal sealed class PunCallbacksBridge : MonoBehaviourPunCallbacks
    {

        private AsyncReactiveProperty<(short returnCode, string message)> _onJoinRoomFailed;

        // Pun2TaskCallbackからアクセスされるプロパティ
        public UniTask<(short returnCode, string message)> OnJoinRoomFailedAsync
        {
            get
            {
                if (_onJoinRoomFailed == null)
                {
                    _onJoinRoomFailed = new AsyncReactiveProperty<(short returnCode, string message)>(default);
                    _onJoinRoomFailed.AddTo(this.GetCancellationTokenOnDestroy());
                }

                return _onJoinRoomFailed.WaitAsync();
            }
        }

        // MonoBehaviourPunCallbacksが実行するコールバック本体
        public override void OnJoinRoomFailed(short returnCode, string message)
        {
            if (_onJoinRoomFailed != null)
            {
                _onJoinRoomFailed.Value = (returnCode, message);
            }
        }

        /* 
        * ↓ 他にも実装が並ぶ…
        */

    }
}

仕組みとしては単純にAsyncReactivePropertyを用いているだけです。

手続き的にUniTaskを生成する方法としてはUniTaskCompletionSourceがありますが、こちらは1回しか利用することができません。
このようなイベントのように何度の発火する非同期処理をUniTask化するにはAsyncReactivePropertyの方が適しています

なぜUniRx(Observable)を使わないのか

このような何度も発火するイベント処理にはUniRxObservableの方が適している場合もあります。

ではなぜ今回はObservableを使わなかったのかというと、最終的な変換対象がUniTaskだからです。
変換したイベントをObservableのまま取り回すのであればUniRxに変換してしまってもよいでしょう。

ですが今回は「async/awaitでイベントを待てるようにする」が目標であったため、わざわざUniRxを経由して変換する意味がなかったということになります。

Pun2TaskCallback

Pun2TaskCallbackはライブラリ利用者がアクセスすることを意図したstaticクラスです。
実装としてはPunCallbacksBridgeへのアクセスを提供しているだけです。

Pun2TaskCallback
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using ExitGames.Client.Photon;
using Photon.Realtime;
using Pun2Task.Callbacks;
using UnityEngine;

namespace Pun2Task
{
    public static class Pun2TaskCallback
    {
        private static PunCallbacksBridge _instance;

        private static PunCallbacksBridge GetBridge()
        {
            if (_instance != null) return _instance;

            // PunCallbacksBridge がシーンに存在しないなら生成する
            var gameObject = new GameObject {name = "Pun2TaskCallback"};
            Object.DontDestroyOnLoad(gameObject);
            _instance = gameObject.AddComponent<PunCallbacksBridge>();
            return _instance;
        }

        public static UniTask OnConnectedAsync()
        {
            return GetBridge().OnConnectedAsync;
        }

        /* 
        * ↓ 他にも実装が並ぶ…
        */

    }
}

Pun2TaskNetworkの実装

Pun2TaskNetworkPhotonNetwork.~から始まるAPIをasync/awaitに適した形で提供するラッパクラスです。
さきほどのPun2TaskCallbackを組み合わせて処理を構築しています。

たとえば「JoinOrCreateRoomAsync」は次のような実装となっています。

Pun2TaskNetwork
using System;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;

namespace Pun2Task
{
    public static class Pun2TaskNetwork
    {
        // 部屋に参加する
        // 参加対象の部屋がない場合は新規作成する
        public static async UniTask<bool> JoinOrCreateRoomAsync(
            string roomName,
            RoomOptions roomOptions,
            TypedLobby typedLobby,
            string[] expectedUsers = null,
            CancellationToken token = default)
        {
            // OnCreatedRoom() が実行されたかをチェックするためにAwaiterを保持する
            var createdRoomTask = Pun2TaskCallback.OnCreatedRoomAsync().GetAwaiter();

            // 各種コールバックイベントのうちどれか1個が発火するのを待てるようにする
            // この時点ではまだawaitしない
            var task = UniTask.WhenAny(
                Pun2TaskCallback.OnJoinedRoomAsync().AsAsyncUnitUniTask(),
                Pun2TaskCallback.OnCreateRoomFailedAsync(),
                Pun2TaskCallback.OnJoinRoomFailedAsync());

            // ナマのAPI (PhotonNetwork.JoinOrCreateRoom) を実行
            var valid = PhotonNetwork.JoinOrCreateRoom(roomName, roomOptions, typedLobby, expectedUsers);
            if (!valid) throw new InvalidRoomOperationException("It is not ready to join a room.");

            // コールバックイベントのうちどれが先着で終わるかを待機する
            var (winIndex, // UniTask.WhenAnyに登録したUniTaskのうちどれが終わったかを示すIndex
                _, // OnJoinedRoomAsync は戻り値無し
                (createFailedCode, createFailedMessage),
                (joinFailedCode, joinFailedMessage)) = await task.WithCancellation(token);

            // OnJoinedRoomが発火した場合、OnCreatedRoomが実行されていたかどうかを返す
            // (部屋を新規作成したかどうかの判別ができる)
            if (winIndex == 0) return createdRoomTask.IsCompleted;

            if (winIndex == 1)
            {
                // OnCreateRoomFailed
                throw new FailedToCreateRoomException(createFailedCode, createFailedMessage);
            }
            else
            {
                // OnJoinRoomFailed
                throw new FailedToJoinRoomException(createFailedCode, createFailedMessage);
            }
        }

        /* 
        * ↓ 他にも実装が並ぶ…
        */
    }
}

対象のPhotonNetwork.~のAPIに連動して発火する各種コールバックイベントをUniTask.WhenAnyでまとめて待機してします。
そしてどのイベントが発火したかで処理を分岐しているだけです。

PhotonViewの拡張の実装

IPunOwnershipCallbacksを実装したのち、それを拡張メソッド経由で呼び出しているだけです。
とくに特筆することはないです。

まとめ

Pun2Taskは扱いにくかったPUN2を改善する強力なライブラリです。
PUN2を触っている人はぜひとも導入してほしいです。このためにasync/awaitを学習する価値もあります。

PUN1が主流だった時代にはasync/awaitが使えず、実質的に選択肢がUniRxしかありませんでした。
そのためPhotonRxを使うなどして各種コールバックイベントをObservableに変換して乗り切っていました。
そのときと比べれば実装も洗練され、かなりスマートに扱えるようになったかと思います。

さいごに

UniRxおよびUniTaskについて学べる技術書が出版されました。
今回のPun2Taskの実装紹介ででてきた機能などもこちらの本で詳しく解説されています。

image.png

22
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
10