「Pun2Task」の紹介
Pun2Task
Pun2Task
とはPUN2
をasync/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
を用いて次のように記述することが可能となります。
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
を使って書くとこうなります。
/// <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
が併用可能です。
そのためこれくらいシンプルに記述ができます。
各種イベントコールバックのUniTask化
OnConnected()
やOnJoinedRoom()
といったそれぞれのイベントコールバックを単体で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
で扱えるようになっています。
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 :
MonoBehaviourPunCallbacks
の実装 - Pun2TaskCallback : 利用者が直接触れるstaticクラス
PunCallbacksBridge
PunCallbacksBridge
はPUN2上で発行されるイベントを受け取り、UniTask
に変換して提供するオブジェクトです。
たとえば「OnJoinRoomFailed
」というイベントは次のような実装でUniTask
に変換されています。
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)を使わないのか
このような何度も発火するイベント処理にはUniRx
のObservable
の方が適している場合もあります。
ではなぜ今回はObservable
を使わなかったのかというと、最終的な変換対象がUniTaskだからです。
変換したイベントをObservable
のまま取り回すのであればUniRx
に変換してしまってもよいでしょう。
ですが今回は「async/awaitでイベントを待てるようにする」が目標であったため、わざわざUniRx
を経由して変換する意味がなかったということになります。
Pun2TaskCallback
Pun2TaskCallback
はライブラリ利用者がアクセスすることを意図したstaticクラスです。
実装としてはPunCallbacksBridge
へのアクセスを提供しているだけです。
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
はPhotonNetwork.~
から始まるAPIをasync/await
に適した形で提供するラッパクラスです。
さきほどのPun2TaskCallback
を組み合わせて処理を構築しています。
たとえば「JoinOrCreateRoomAsync
」は次のような実装となっています。
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
の実装紹介ででてきた機能などもこちらの本で詳しく解説されています。