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

# 「Pun2Task」の紹介

## Pun2Task

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

- [Pun2Task](https://github.com/TORISOUP/Pun2Task)

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

## PUN2とは

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

- [PUN](https://www.photonengine.com/ja-JP/PUN)
- [PUN2(AssetStore)](https://assetstore.unity.com/packages/tools/network/pun-2-free-119922)


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


## PUN2のAPIデザイン

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

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


```cs:サーバにログインして部屋に入る
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の挙動自体はシンプルです。それ故これを用いて複雑な処理を書こうとすると厳しくなります。

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


```cs:部屋リストに対して順番に参加する
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`を用いて次のように記述することが可能となります。

```cs: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`を使って書くとこうなります。

```cs: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`化できるので、`UniTaskAsyncEnumerable`と`UniTask.Linq`が併用可能です。
そのためこれくらいシンプルに記述ができます。

参考 : [UniTaskAsyncEnumerable](https://qiita.com/toRisouP/items/8f66fd952eaffeaf3107#unitaskasyncenumerableiunitaskasyncenumerable)


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

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

```cs:各種コールバックイベントの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/await`や`UniTaskAsyncEnumerable`で扱えるようになっています。

```cs: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の実装

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

- [PunCallbacksBridge](https://github.com/TORISOUP/Pun2Task/blob/master/Assets/Plugins/Pun2Task/Callbacks/PunCallbacksBridge.cs) : `MonoBehaviourPunCallbacks`の実装
- [Pun2TaskCallback](https://github.com/TORISOUP/Pun2Task/blob/master/Assets/Plugins/Pun2Task/Pun2TaskCallback.cs) : 利用者が直接触れるstaticクラス

### PunCallbacksBridge

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

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

```cs: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](https://qiita.com/toRisouP/items/8f66fd952eaffeaf3107#asyncreactiveproperty)の方が適しています**。


##### なぜUniRx（Observable）を使わないのか

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

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

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


### Pun2TaskCallback

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


```cs: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の実装

- [Pun2TaskNetwork](https://github.com/TORISOUP/Pun2Task/blob/master/Assets/Plugins/Pun2Task/Pun2TaskNetwork.cs)

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

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

```cs: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の拡張の実装

- [PunViewCallback](https://github.com/TORISOUP/Pun2Task/blob/master/Assets/Plugins/Pun2Task/PunViewCallback.cs)

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


# まとめ

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

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

# さいごに


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

- [UniRx/UniTask完全理解](https://asciidwango.jp/post/631460628113014785/unirxunitask%E5%AE%8C%E5%85%A8%E7%90%86%E8%A7%A3)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/6b8033a6-679d-45f7-b6ad-30b37ab94cee.png)