Unity に PlayFab C# SDK を導入して async / await を利用できるようにする
PlayFab
ゲーム開発に特化された BaaS の一つ. 出来ること一部を挙げると以下のような感じです.
- ユーザ管理
- アイテム管理
- ゲーム内通貨
- ランキング機能
PlayFab で出来ることは以下の南さん(PlayFabと言えばこの方)のスライドがわかりやすくまとめています.
www.slideshare.net
今回匿名ログイン処理を async / await で書きたいというモチベーションがあり, 色々試してみたところ,地雷等があったので記事に書き起こしました.
コールバック地獄
www.slideshare.net
こちらの南さんのスライドにも書かれておりますが,PlayFab Unity SDK は Unity 5.3 以降の対象としているため, ログインのような処理が async / await を利用できる非同期処理が利用できません(Unity 5 は C# 4.0 で,async / await は C# 5.0 から). なので,イベントコールバック形式を採用していると思われます.これがコールバック地獄に陥りやすい問題になっています. (コールバック地獄でも別に問題ないって方は別に大丈夫です.)
そこで PlayFab Unity SDK ではなく,Plain C# SDK を利用することで,async / await が使えるようにしました. Plain C# SDK は Unity でも使えますが,WebGL では現時点では動かないことに留意する必要があります.
PlayFabSettings.cs でのエラー
C# SDK の source 配下にあるファイル群を Unity Assets 配下に入れればOKですが, 最新版 1.101 では PlayFabSettings.cs でエラーが出ます.
これについては,南さんがフォーラムで言及済です.
PlayFabSettings.cs
public static string LocalApiServer { get { #if NET45 || NETSTANDARD2_0 || UNITY_EDITOR || UNITY_STANDALONE return PlayFabUtil.GetLocalSettingsFile().LocalApiServer; // ← ここでエラーが出る #else return _localApiServer; #endif } #if !NET45 && !NETSTANDARD2_0 && !UNITY_EDITOR && !UNITY_STANDALONE || !NET_STANDARD_2_0 set { _localApiServer = value; } #endif }
PlayFabUtil.cs
#if NET45 || NETSTANDARD2_0 [ThreadStatic] private static StringBuilder _sb; /// <summary> /// A threadsafe way to block and load a text file /// /// Load a text file, and return the file as text. /// Used for small (usually json) files. /// </summary> private static string ReadAllFileText(string filename) { if (!File.Exists(filename)) { return string.Empty; } if (_sb == null) { _sb = new StringBuilder(); } _sb.Length = 0; using (var fs = new FileStream(filename, FileMode.Open)) { using (BinaryReader br = new BinaryReader(fs)) { while (br.BaseStream.Position != br.BaseStream.Length) { _sb.Append(br.ReadChar()); } } } return _sb.ToString(); } internal static LocalSettingsFile GetLocalSettingsFile() { string envFileContent = null; string currDir = Directory.GetCurrentDirectory(); string currDirEnvFile = Path.Combine(currDir, _localSettingsFileName); if (File.Exists(currDirEnvFile)) { envFileContent = ReadAllFileText(currDirEnvFile); } else { string tempDir = Path.GetTempPath(); string tempDirEnvFile = Path.Combine(tempDir, _localSettingsFileName); if (File.Exists(tempDirEnvFile)) { envFileContent = ReadAllFileText(tempDirEnvFile); } } if (!string.IsNullOrEmpty(envFileContent)) { var jsonPlugin = PluginManager.GetPlugin<ISerializerPlugin>(PluginContract.PlayFab_Serializer); return jsonPlugin.DeserializeObject<LocalSettingsFile>(envFileContent); } return new LocalSettingsFile(); }
Unity は .NET Standard 2.0 対応してるやんけ!とお思いかもしれませんが,
NETSTANDARD2_0
と NET_STANDARD_2_0
は違います…(とてもめんどい)….
前者は .NET Core で,後者は Unity です.
ターゲットフレームワークのシンボル、.NET Coreだと“NETSTANDARD2_0"でUnityだと"NET_STANDARD_2_0"なの、区別がつくからいいかもしれないし面倒なだけかもしれない。 / “ターゲット フレームワーク | Microsoft Docs” https://t.co/o8y9sxMnDL
— neuecc (@neuecc) 2020年2月4日
対処方法
南さんがフォーラムに書かれていた通り,NET_STANDARD_2_0
を #if
の所に加えるのがいいですが,
これでは SDK のバージョンが上がるたびに上書きされてしまうので,強引ではありますが,
Scripting Define Symbols に NET45
または NETSTANDARD2_0
を Apply してみる手もあります.
一旦今回はこれでやってみます.(他コードにも影響を及ぼす可能性があるのでよくないかもしれない)
サンプルコード
いつもの如く,Passive View Pattern + VContainer で実装してみました.
Domain.Login
ILoginService
using System; using Cysharp.Threading.Tasks; namespace Denity.PlayfabPractice.Login { public interface ILoginService { UniTask<(bool isSuccess, string message)> Login(string username); IObservable<(bool isSuccess, string message)> OnLoginProcessAsObservable(); } }
PlayFabLoginService
using System; using Cysharp.Threading.Tasks; using PlayFab; using PlayFab.ClientModels; using UniRx; namespace Denity.PlayfabPractice.Login { public sealed class PlayFabLoginService : ILoginService { readonly Subject<(bool isSuccess, string message)> _loginProcessSubject = new Subject<(bool isSuccess, string message)>(); public IObservable<(bool isSuccess, string message)> OnLoginProcessAsObservable() => _loginProcessSubject; public PlayFabLoginService() { // Enter PlayFab Title ID PlayFabSettings.staticSettings.TitleId = "XXX00"; } public async UniTask<(bool isSuccess, string message)> Login(string username) { var request = new LoginWithCustomIDRequest { TitleId = PlayFabSettings.staticSettings.TitleId, CustomId = username, CreateAccount = true }; var response = await PlayFabClientAPI.LoginWithCustomIDAsync(request); var isSuccess = response.Error is null; var message = isSuccess ? $"Login Success. Your PlayFab ID is {response.Result.PlayFabId}" : $"Login Failure... {response.Error.ErrorMessage}"; _loginProcessSubject.OnNext((isSuccess, message)); return (isSuccess, message); } } }
Presentation
LoginPresenter
using System; using Cysharp.Threading.Tasks; using Denity.PlayfabPractice.Login; using Denity.PlayfabPractice.Presentation.View; using UniRx; using VContainer.Unity; namespace Denity.PlayfabPractice.Presentation.Presenter { public sealed class LoginPresenter : IStartable, IDisposable { readonly ILoginService _loginService; readonly ILoginView _loginView; readonly CompositeDisposable _disposable; public LoginPresenter ( ILoginService loginService, ILoginView loginView ) { _loginService = loginService; _loginView = loginView; _disposable = new CompositeDisposable(); } public void Start() { _loginView.OnLoginTriggerAsObservable() .Subscribe(username => _loginService.Login(username).Forget()) .AddTo(_disposable); _loginService.OnLoginProcessAsObservable() .Subscribe(tupleValue => _loginView.DisplayLoginResult(tupleValue.isSuccess, tupleValue.message)) .AddTo(_disposable); } public void Dispose() { _disposable.Dispose(); } } }
ILoginView
using System; namespace Denity.PlayfabPractice.Presentation.View { public interface ILoginView { IObservable<string> OnLoginTriggerAsObservable(); void DisplayLoginResult(bool isSuccess, string message); } }
LoginUIView
using System; using UniRx; using UnityEngine; using UnityEngine.UI; namespace Denity.PlayfabPractice.Presentation.View { public class LoginUIView : MonoBehaviour, ILoginView { [SerializeField] InputField _inputFieldName; [SerializeField] Text _textLoginResult; [SerializeField] Button _buttonLogin; readonly Subject<string> _loginTriggerSubject = new Subject<string>(); public IObservable<string> OnLoginTriggerAsObservable() => _loginTriggerSubject; void Start() { _buttonLogin.OnClickAsObservable() .Subscribe(_ => _loginTriggerSubject.OnNext(_inputFieldName.text)) .AddTo(this); _inputFieldName.ObserveEveryValueChanged(field => field.text) .Select(string.IsNullOrEmpty) .Subscribe(isNullOrEmpty => { _textLoginResult.text = string.Empty; _buttonLogin.interactable = !isNullOrEmpty; }) .AddTo(this); } public void DisplayLoginResult(bool isSuccess, string message) => _textLoginResult.text = isSuccess ? $"<color=green>{message}</color>" : $"<color=red>{message}</color>"; void OnDestroy() { _loginTriggerSubject.Dispose(); } } }
DIContainer
LoginLifetimeScope
using Denity.PlayfabPractice.Login; using Denity.PlayfabPractice.Presentation.Presenter; using Denity.PlayfabPractice.Presentation.View; using UnityEngine; using VContainer; using VContainer.Unity; namespace Denity.PlayfabPractice.LifeCycle { public sealed class LoginLifetimeScope : LifetimeScope { [SerializeField] LoginUIView _loginUIView; protected override void Configure(IContainerBuilder builder) { builder.Register<PlayFabLoginService>(Lifetime.Singleton).AsImplementedInterfaces(); builder.RegisterInstance(_loginUIView).AsImplementedInterfaces(); builder.RegisterEntryPoint<LoginPresenter>(); } } }
動作確認
上記のコードで実装確認してみます.
ログインができました.
終わりに
実は本日イワケンさんのXR開発ブートキャンプに参加しまして,その際に PlayFab を集中的に触れてみた次第です. 結構集中して捗ったので,この場をお借りして感謝を申し上げます.
「触りたかった技術に触れる」3時間で13個の開発進捗が生まれた XR開発ブートキャンプレポート|イワケン @iwaken71 #note https://t.co/c9LxFI8RCR
— イワケン@AR好き (@iwaken71) 2021年9月19日
参考文献
こちらの記事を利用すれば,C# SDK を UPM 管理できるっぽい.自分も試そうと今度試そう.
こちらを利用すれば,Unity SDK で async / await が利用できる模様.どうだろうか…試すほかない.
Rx Wrapper もあるっぽいけど,ますますややこしくなっている感じある. やっぱりこういう処理は async / await の方がシックリくるマン.