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 でのエラー

github.com

C# SDK の source 配下にあるファイル群を Unity Assets 配下に入れればOKですが,
最新版 1.101 では PlayFabSettings.cs でエラーが出ます.

これについては,南さんがフォーラムで言及済です.

community.playfab.com

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_0NET_STANDARD_2_0 は違います…(とてもめんどい)….
前者は .NET Core で,後者は Unity です.

対処方法

南さんがフォーラムに書かれていた通り,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>();
        }
    }
}

動作確認

上記のコードで実装確認してみます.

gyazo.com

ログインができました.

終わりに

実は本日イワケンさんのXR開発ブートキャンプに参加しまして,その際に PlayFab を集中的に触れてみた次第です.
結構集中して捗ったので,この場をお借りして感謝を申し上げます.

参考文献

shirokurohitsuji.studio

こちらの記事を利用すれば,C# SDK を UPM 管理できるっぽい.自分も試そうと今度試そう.

qiita.com

こちらを利用すれば,Unity SDK で async / await が利用できる模様.どうだろうか…試すほかない.

community.playfab.com

Rx Wrapper もあるっぽいけど,ますますややこしくなっている感じある.
やっぱりこういう処理は async / await の方がシックリくるマン.