simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

VRoidHubにログインしないでゲームを続けられる実装調査

ログインしないときの動作を策定するにあたり、現状を整理し、作業ログを残します。

簡単に今のフローをおさらい

VRoid Hub のログインは VRoid SDK のサンプルに任せっきり
ゲームを起動すると

f:id:simplestar_tech:20190915150327p:plain
起動直後
「VRoid Hub に接続」ボタンを表示、同時に「☓」の閉じるボタンがある
接続ボタンを押すと、次のとおり Web ブラウザが起動し、連携許諾の画面の後、認証コードを表示する画面が出てくる
f:id:simplestar_tech:20190915150745p:plain
連携します→認証コード入力画面
一応毎回認証コード違うので、もうこの値を入れても有効にはなりません…モザイクもかけない

で、ゲーム画面に戻ると

f:id:simplestar_tech:20190915150944p:plain
認証コード入れて
このような画面になっているので、先程のコードを入力する
f:id:simplestar_tech:20190915151034p:plain
キャラクター選択画面
キャラクター選択ができるが、このときも「☓」ボタンでダイアログを閉じることが可能

キャラを選択すると「利用する」「キャンセル」ボタンがある

f:id:simplestar_tech:20190915151314p:plain
選択した後

キャンセルすると、一つ前のキャラクター選択画面に戻る

「利用する」を選ぶと、次の通りキャラクターがゲームに登場する

f:id:simplestar_tech:20190915151655p:plain
ゲーム画面

ログインをやめたとき

「次のディフォルトキャラを利用しますか?」
「利用する」「キャンセル」

f:id:simplestar_tech:20190915151314p:plain
選択した後
を出してみようと思う

この画面は CharacterLicenseScrollView といって

まぁ、調べると複雑なのでユーザーは VRoidHubController の SetOnCancelHandler ですべてのキャンセルをハンドリングします。
ここで CharacterLicenseScrollView をアクティブ化するロジックをハンドラに追加してみましょう。
期待では、キャラ情報のない画面が出る

具体的にはこのように書き

        this.controller.SetOnCancelHandler(() => {
            this.licensePanel.SetActive(true);
            this.menuCanvas.SetActive(true);
            this.controller.gameObject.SetActive(true);
        });

結果は次の通り

f:id:simplestar_tech:20190915155525p:plain
キャラ情報のない画面が出る

ボタンとして「利用する」「キャンセル」があるが
「利用する」を押した場合は、独自の VRM ロードロジックに付け替える
「キャンセル」を押した場合は、元のログイン画面の表示につけかえる
想定できる問題に、キャンセルした後から普通のフローをたどると、「利用する」ボタンのロジックが切り替わったままになるので
キャンセル時にすべてを元に戻すことをしなければならない

では、まず、ロジック付替えるテストコード
動的に永続イベントを無効化して付け替えるには、このように書くしかなさそう…いい知見を得ました。

    private void OnCancelButtonClicked()
    {
        // 固定キャラクター利用を拒否されたので、最初のログイン画面に切り替え
        this.loginCanvas.SetActive(true);
        this.mainPanel.SetActive(true);
        this.menuCanvas.SetActive(false);
        this.licensePanel.SetActive(false);
        // 切り替えたボタンのロジックを元に戻す
        this.acceptButton.onClick.RemoveAllListeners();
        this.acceptButton.onClick.SetPersistentListenerState(0, UnityEventCallState.EditorAndRuntime);
        this.retryButton.onClick.RemoveAllListeners();
        this.retryButton.onClick.SetPersistentListenerState(0, UnityEventCallState.EditorAndRuntime);
        this.cancelButton.onClick.RemoveAllListeners();
        this.cancelButton.onClick.SetPersistentListenerState(0, UnityEventCallState.EditorAndRuntime);
    }

    private void OnAcceptButtonClicked()
    {
        // streamingAssets から vrm をインスタンス化
        var vrmFilePath = Path.Combine(Application.streamingAssetsPath, "VRM/defaultvrm.vrm");
        var context = new VRMImporterContext();
        context.Parse(vrmFilePath);
        context.LoadAsync(() => {
            context.ShowMeshes();
            var vrmModel = context.Root;
            vrmModel.name = "defaultvrm";

            // プレイヤーとしてコンポーネントを追加
            ComponentUtil.DeleteAllChildren(this.transform);
            int layerMask = LayerMask.NameToLayer(LayerMask_Player);
            vrmModel.SetLayerRecursively(layerMask);
            vrmModel.SetTagRecursively(Tag_Player);
            // ただし characterModelId はディフォルト値とする
            this.SetModel("defaultvrm", vrmModel, isUserPlayerFlag: true);

            // VRoid Hub メニューを閉じる
            this.controller.Close();
        }, (exception) => { Debug.LogError($"defaultvrm load error: {exception.Message}"); });
    }

    private void Start()
    {
        this.controller.Open();
        this.controller.SetOnLoadHandler((characterModelId, vrmModel) =>
        {
            ComponentUtil.DeleteAllChildren(this.transform);
            int layerMask = LayerMask.NameToLayer(LayerMask_Player);
            vrmModel.SetLayerRecursively(layerMask);
            vrmModel.SetTagRecursively(Tag_Player);
            this.SetModel(characterModelId, vrmModel, isUserPlayerFlag:true);
        });
        this.controller.SetOnCancelHandler(() => {
            // キャラクター利用画面を表示
            this.licensePanel.SetActive(true);
            this.menuCanvas.SetActive(true);
            this.mainPanel.SetActive(false);
            this.loginCanvas.SetActive(false);
            this.controller.gameObject.SetActive(true);
            // 既存の「利用する」ボタンと「キャンセル」ボタンの動作を無効化
            this.acceptButton.onClick.SetPersistentListenerState(0, UnityEventCallState.Off);
            this.acceptButton.onClick.RemoveAllListeners();
            this.retryButton.onClick.SetPersistentListenerState(0, UnityEventCallState.Off);
            this.retryButton.onClick.RemoveAllListeners();
            this.cancelButton.onClick.SetPersistentListenerState(0, UnityEventCallState.Off);
            this.cancelButton.onClick.RemoveAllListeners();
            // 新しいロジックに付け替え
            this.acceptButton.onClick.AddListener(this.OnAcceptButtonClicked);
            this.cancelButton.onClick.AddListener(this.OnCancelButtonClicked);

            // streamingAssets から vrm 情報を取得
            var characterLicensePanel = this.licensePanel.GetComponent<CharacterLicensePanel>();
            characterLicensePanel.Init(new VRoidSDK.CharacterModel
            {
                portrait_image = new VRoidSDK.PortraitImage { original = new VRoidSDK.WebImage { url = "" } },
                name = @"<b><size=28>千駄ヶ谷 篠</size></b>
<size=24> Welcome School Uniform 2019 </size>
<size=24> 作者:<b> VRoidプロジェクト </b></size>",
                license = new VRoidSDK.CharacterLicense {
                    characterization_allowed_user = "everyone",
                    violent_expression = "allow",
                    sexual_expression = "allow",
                    corporate_commercial_use = "allow",
                    personal_commercial_use = "profit",
                    modification = "allow",
                    redistribution = "allow",
                    credit = "unnecessary"
                }
            });
            this.caracterImage.texture = this.defaultvrmIcon;
        });
    }

キャンセルして元に戻すことができているので、あとは利用するボタンを押した時に、リソースから VRM を読み出そうと思います。
Prefab のインスタンス化で良いかな?→惜しいところで、vrmAvatar が内蔵されないので、キャラクターを動かす時に不都合を確認
ファイルから直接読み込むように StreamingAssets から直接読み込むようにしました。

色々修正して上の実装の通り

現在、VRoid Hub の接続を拒むと、次の画面が出て、利用するボタンを押すと、この子が使えるようになってます。

f:id:simplestar_tech:20190915210658p:plain
VRoid Hub の接続を拒むと出てくる画面

あとは、通信時にキャラクターIDを正しく相手に送る必要があるので、正しい ID とやらを確認します。
648876553405728395 でした。

そして、VRoid Hub への接続を拒んだクライアントでは、常にキャラクターをこの子とする実装が求められます。
それもテストしてみましょう。

あれ、方やログインしてないのに動いちゃった
そういうものなのかな

ああ、キャッシュをクリアしたらやっぱり認証でエラーが確認できました。
そういうときは、ディフォルトの vrm を読み込むようにしましょう。

これで行けると思うんだけど…

    IEnumerator CoWaitEmptyMapAndLoadAsync(ContentUrlResponse response)
    {
        // ロードは複数同時に走らせない
        while (this.isVRoidLoading)
        {
            yield return null;
        }
        this.isVRoidLoading = true;
        this.lastUrlResponse = response;
        if (Authentication.Instance.IsAuthorized())
        {
            // ログインできている場合は API 経由でキャラデータをロード
            var characterModel = new CharacterModel();
            characterModel.id = response.URL;
            HubModelDeserializer.Instance.LoadCharacterAsync(characterModel, OnLoadComplete, OnDownloadProgress, OnError);
        }
        else
        {
            // ログインしていない場合は、ディフォルト VRM で代用
            var vrmFilePath = Path.Combine(Application.streamingAssetsPath, "VRM/defaultvrm.vrm");
            var context = new VRM.VRMImporterContext();
            context.Parse(vrmFilePath);
            context.LoadAsync(() => {
                context.ShowMeshes();
                var vrmModel = context.Root;
                vrmModel.name = response.UserUniqueId.ToString();
                this.OnLoadComplete(vrmModel);
            }, (exception) => { Debug.LogError($"defaultvrm load error: {exception.Message}"); });
        }
    }

できました!

f:id:simplestar_tech:20190915220315p:plain
VRoid Hub にログインせずにオンラインチャット

色んなユーザーがルームに入ってきても、全部ディフォルトのキャラとして映ります…
これにて機能追加完了

PlayFab:ログインタイミングを気にせずプログラミングする術

最初にひらめいたのはアクションをキューイングすればいいんだよ!の一言

言葉をプログラムに書き下すと次の通り
ちゃんと期待通り動いた

前の記事
simplestar-tech.hatenablog.com

の TitleData を取得する関数の処理、コールバックで成功を返すつくりなので、このように改造が自由

    /// <summary>
    /// タイトルデータを取得
    /// </summary>
    /// <typeparam name="T">取得したいデータのクラスを指定</typeparam>
    /// <param name="callback">取得成功時の処理</param>
    public static void GetTitleData<T>(UnityAction<T[]> callback)
    {
        PlayFabLogin.AfterLoginCall(() =>
        {
            PlayFabClientAPI.GetTitleData(new GetTitleDataRequest { }, result =>
            {
                var type = typeof(T);
                var titleData = JsonSerializer.Deserialize<T[]>(result.Data[type.Name]);
                callback?.Invoke(titleData);
            },
                error => { Debug.LogError($"GetTitleData: Fail...{error.GenerateErrorReport()}"); });
        });
    }

からくりは次のようにして実装しました。

using PlayFab;
using PlayFab.ClientModels;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;

/// <summary>
/// PlayFab のログインをゲーム開始時に試行し、ログイン前に呼ばれた API 処理を処理
/// </summary>
public class PlayFabLogin : MonoBehaviour
{
    /// <summary>
    /// 引数アクションをログインしてから実行する
    /// </summary>
    /// <param name="action"></param>
    public static void AfterLoginCall(UnityAction action)
    {
        if (isPlayFabLogin)
        {
            // もうログインしてるんだからそのまま実行
            action?.Invoke();
        }
        else
        {
            // まだログインしてないのでキューイング
            actionQueue.Enqueue(action);
        }
    }

    void Start()
    {
        // タイトルIDをメタデータから取得
        if (string.IsNullOrEmpty(PlayFabSettings.TitleId))
        {
            Debug.LogError("PlayFab TitleId is Empty!");
            return;
        }
        // ログインを試行
        var request = new LoginWithCustomIDRequest { CustomId = "GettingStartedGuide", CreateAccount = true };
        PlayFabClientAPI.LoginWithCustomID(request, OnLoginSuccess, OnLoginFailure);
    }

    /// <summary>
    /// ログイン成功
    /// </summary>
    /// <param name="result">EntityTokenなど</param>
    void OnLoginSuccess(LoginResult result)
    {
        Debug.Log("PlayFab ready to call API");
        // キューにあるタスクを順に実行
        while (0 < actionQueue.Count)
        {
            var action = actionQueue.Dequeue();
            action?.Invoke();
        }
        // ログインしたフラグ変更
        isPlayFabLogin = true;
    }

    /// <summary>
    /// 何らかの理由でログインに失敗
    /// </summary>
    /// <param name="error">エラー情報</param>
    void OnLoginFailure(PlayFabError error)
    {
        Debug.LogWarning("Something went wrong with PlayFab API call.");
        Debug.LogError($"Here's some debug information: {error.GenerateErrorReport()}");
        // ログインせずゲーム続けると、どんどんキューに詰められてスタックオーバーフローするかもね
    }

    /// <summary>
    /// ゲーム開始時にログインするが、もしもその前に API コールがあったらこのキューに関数が詰められる
    /// </summary>
    static Queue<UnityAction> actionQueue = new Queue<UnityAction>();
    /// <summary>
    /// PlayFab のタイトルログインに成功したら true に変わる
    /// </summary>
    static bool isPlayFabLogin = false;
}

同じように、ログインタイミング気にしてログインしたか確認して~とかいう依存コード書く前に、参考にどうぞ

PlayFab:タイトルデータで接続先サーバーを知る

基本はここで学びます。
qiita.com

任意のデータ構造を配列で取得できるようになったら、少し汎用的なタイトルデータ取得コードを記述します。

C#ジェネリックの型名を文字列で取得するには

var type = typeof(T);
var titleData = JsonSerializer.Deserialize<T[]>(result.Data[type.Name]);

これを活用して、関数を一つ書けば、Json シリアライズする型ごとに値を取得することができるようになります。
実装は次の通り

using PlayFab;
using PlayFab.ClientModels;
using UnityEngine;
using UnityEngine.Events;
using Utf8Json;

public class PlayFabTitleData : MonoBehaviour
{
    private void Update()
    {
        // Test コードです
        if (Input.GetKeyDown(KeyCode.A))
        {
            GetTitleData<CubeWalkServer>((titleData) => {
                foreach (var data in titleData)
                {
                    Debug.Log($"name = {data.Name}, host = {data.Host}");
                } 
            });
        }
    }

    /// <summary>
    /// タイトルデータを取得
    /// </summary>
    /// <typeparam name="T">取得したいデータのクラスを指定</typeparam>
    /// <param name="callback">取得成功時の処理</param>
    public static void GetTitleData<T>(UnityAction<T[]> callback)
    {
        PlayFabClientAPI.GetTitleData(new GetTitleDataRequest { }, result => {
            var type = typeof(T);
            var titleData = JsonSerializer.Deserialize<T[]>(result.Data[type.Name]);
            callback?.Invoke(titleData); }, 
            error => { Debug.LogError($"GetTitleData: Fail...{error.GenerateErrorReport()}"); });
    }

    /// <summary>
    /// オンラインチャットサーバー情報:タイトルデータ
    /// </summary>
    public class CubeWalkServer
    {
        public string ID { get; set; }
        public string Name { get; set; }
        public string Type { get; set; }
        public string Host { get; set; }
        public int Port { get; set; }
    }
}

amazon linux 2 ami でインスタンス作って docker インストール手順

amazon linux 2 ami でインスタンス作って docker インストール手順

sudo amazon-linux-extras install docker
sudo service docker status
sudo service docker stop
sudo service docker start
sudo service docker status

sudo docker ps

動いた

そうそう sudo うざいので docker コマンドを sudo なしで実行するには docker グループに ec2-user を追加してあげます。
sudo gpasswd -a $(whoami) docker

以降は docker コマンドに sudo いらずになります

WinSCP秘密鍵を .pem を .ppk に変換して接続
dotnet release フォルダを丸コピー
Dockerfile フォルダがあるところで

sudo docker build . -t cubewalkroom:1.0

sudo docker run -d --rm -p 12000:12000 --name cubewalkroom cubewalkroom:1.0 dotnet CubeWalkServer.dll 12000

対象の EC2 インスタンスの IP アドレスを使ってクライアントから接続
void InitializeMagicOnion()
{
// サーバー情報を指定して接続
this.channel = new Channel("13.XXX.XX.XXX", 12000, ChannelCredentials.Insecure);

問題なく接続完了

Magic Onion:複数ポート番号のサーバーをdockerコンテナで

手順だけ記録しておきます。

基本
qiita.com

ここで Dockerfile を記述しているけど

FROM mcr.microsoft.com/dotnet/core/runtime:2.2
WORKDIR /app
COPY . .

とエントリポイントを削除

あとサーバー側の実装で引数一個目で port 番号を受け取って利用するようにしておく。

using Grpc.Core;
using MagicOnion.Hosting;
using MagicOnion.Server;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System.Threading.Tasks;

namespace CubeWalk.Server
{
    class Program
    {
        static async Task Main(string[] args)
        {            
            if (1 > args.Length || !int.TryParse(args[0], out int port))
                return;

            GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger());

            // for SSL/TLS connection
            //var config = new ConfigurationBuilder().AddEnvironmentVariables().Build();
            //var certificates = new List<KeyCertificatePair> { new KeyCertificatePair(File.ReadAllText("server.crt"), File.ReadAllText("server.key")) };
            //var credential = new SslServerCredentials(certificates);

            await MagicOnionHost.CreateDefaultBuilder()
                .UseMagicOnion(
                    new MagicOnionOptions(isReturnExceptionStackTraceInErrorDetail: true),
                    new ServerPort("0.0.0.0", port, ServerCredentials.Insecure))
                    // for SSL/TLS Connection
                    //new ServerPort(config.GetValue<string>("MAGICONION_HOST", "127.0.0.1"), 12345, credential))
                .RunConsoleAsync();
        }
    }
}

out フォルダをカレントにイメージをビルドして

docker build . -t cubewalkroom:1.0

次の run コマンドで port フォワードして、とりあえず 12000 ポートでサーバーを立てます。

$ docker run -d --rm -p 12000-12100:12000-12100 --name cubewalkroom cubewalkroom:1.0 dotnet CubeWalkServer.dll 12000

続いて、ポートを一つずつ消費しながらサーバーを立てます。

$ docker exec -d cubewalkroom dotnet CubeWalkServer.dll 12001
$ docker exec -d cubewalkroom dotnet CubeWalkServer.dll 12002
$ docker exec -d cubewalkroom dotnet CubeWalkServer.dll 12003
.
.
.

それぞれの port に対して接続して利用することができました。

追記:
なんのためにポートを複数使う?
プロセスを分離できるので、分けたあとのお互いの影響 0 じゃん
と思っていたけど、内心パフォーマンスの分散にもなるかなって感じてました→原理的にむしろ悪くする

同じポートを使って詰まることはなく、ソケットを作ってお互い通信し、そのソケットにカーネルがパケットを振り分けるので
つまり、ポート一つに全アクセスが集中するのではなく、分散したソケット同士で通信するので、ポート一つでも分散できていたという話

こんな書式で複数ポートをフォワーディングできるという、ただそれだけの記事になったが
副次的にいろいろとポートとソケット、LinuxNIC, hard IRQ とか知ることができた

PlayFab:Unityゲーム内課金システムPayPal編

個人ゲーム制作でも何百万人にも膨れ上がるユーザーを平気で捌ける順応性のあるバックエンドサービスを短期無料で作れる時代になりました。
Amazon の GameSparks, Google の Firebase, Microsoft の PlayFab, 富士通クラウドテクノロジーズのmobile backend など様々なところから mobile backend as a service (mBaaS)というものが、ウチが一番だ、これから成長するんだと宣伝している時代です。

中でも、ゲーム内に登場するアイテムを実際のお金(USDや日本円)で購入するシステムを提供している Microsoft の PlayFab を、今回は日本語の紹介が少ない中、実際に動かして解説していきたいと思います。

初見さんで PlayFab を Unity で使えるようにする手順が気になる方はこちらを一読してなぞってみましょう。
qiita.com
要約すると PlayFab のアカウントとゲームタイトルを作成して
PlayFab SDK 使うための Unity アセットをインポート後
タイトルIDをエディタ拡張で記入すれば
アカウント作成 or ログインのクライアント API 呼び出しが成功することを確認できます。(慣れてくると数十分の手作業)

もっと具体的には次の一行を Start に置くだけの作業

    public void Start()
    {
        PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
        { CustomId = "GettingStartedGuide", CreateAccount = true },
        OnLoginSuccess, OnLoginFailure);
    }

以降は、上記のカスタムIDにリンクしたプレイヤーとしてゲームの API を叩くことを意識しておいてください。
(何このログイン、キモ!?と思う方は前の記事の「プレイヤー認証」を読んでね)

PlayFab の他に支払い処理をするアドオンとの連携が必須

Microsoft Store, Google PlayApp Store などの配信サービスにアップロードせず、Windows 用のゲームとして審査いらずで課金システムを設けることができます。(そんな手段も取れます)
自身の Web サイトからゲーム本体をダウンロードできるような仕組みにしても良いですね。
ただしクレジットカード情報や実際のお金の移動はデリケートな処理です。そもそもカード会社が我々を信用しない…
そこで PlayFab のアプリ内課金を支援する3rdパーティ製のアドオンにその大事な情報を任せて、
金銭授受の結果だけをゲームに反映させることができる機能が PlayFab にはあります。(もちろん金の移動先はあなたの銀行口座です。)

早くそのやり方を確認したい!という方、細かい手順はすべて次の公式ドキュメントにまとめられています。(省略せず、以下は具体的な手順を日本語で解説してます。)
Non-Receipt Payment Processing
アドオンは PayPal や Steam, Amazon などいくつかありますが、中でもめちゃめちゃ簡単なのが Steam とのこと
でも Steam の開発者登録とアプリ登録って数週間は待つことになるので、本記事はすぐに確認できる PayPal のビジネスアカウントを使って動作確認してみました。
(ゲーム内処理は Steam とほとんど同じです、容易に切り替えも可能)

本当に3rdParty製のアドオンとの連携が必要なんですか?PlayFab単体でどうにかできませんか?→無理です。何かしらの 3rdParty 製のサービスで振り込み先の銀行口座を登録してください。(次が情報ソース)
community.playfab.com

PayPal のビジネスアカウントを作る

初めての人は次のリンクから
PayPal(ペイパル) - かんたん&安全なオンライン決済サービス
にサインアップ&ログインします。

アカウントの種類は2つありますが、今回の課金システムを試すなら受け取りも可能なビジネスアカウントにしましょう。

f:id:simplestar_tech:20190816213711p:plain
あなたが作りたいのはビジネスアカウントですよね

基本的に個人での登録であること、本名と住所を記入して、本人確認のフローを通せばビジネスアカウントの登録手順で詰まることはありません。
長い間パーソナルアカウントだった私は、アカウントの種類変更のアップグレードボタンを押したら、個人情報の確認いらずでビジネスアカウントに秒で昇格しました…

f:id:simplestar_tech:20190816213935p:plain
なんか、証明書類用意する前に振り込み可能なビジネスアカウントできちゃった

個人情報の入力を行ってビジネスアカウントができたなら、PlayFab の Add-onタブにて PayPal のインストールを行います。

f:id:simplestar_tech:20190816093749p:plain
Add-On タブにて PayPal をインストール

記入するのはマーチャントIDだけ、これは PayPal のアカウント情報として確認可能(この情報は公開しちゃいけない)
もう一つの必須項目の return link ってなんでしょう?(世界中から同じ質問が湧いています)
Questions on PayPal payments - Playfab Community
何のことはなく、支払いが完了したときに「お買い上げありがとうございます!」とユーザに示すあなた独自の Web ページの url を指定するとのことでした。(紛らわしい!)
可能ならそのページから、Unity ゲームにフォーカスを戻せたら良いですね。

現在は次の状態

f:id:simplestar_tech:20190816102412p:plain
PayPal ページをアクティブ化

PayPal のビジネスアカウントが必要なのは理解できたと思います。
そのビジネスアカウントのときに現れる歯車マークの設定ボタンから、サードパーティのアクセスを管理する項目「アカウント設定」が初めて選べるようになります。
ここは PlayFab の Add-on の説明にあるとおり、API を 3rd Party ここでいう PlayFab を信頼して処理を代行させる許可を与えるため API 利用者を探す欄に "billing_api1.playfab.com" を記入して
たくさんの許可項目があるなか一つ process Express Checkout payments に該当する「エクスプレス チェックアウトを使って支払いを処理します。」にだけ許可チェックを入れて登録を完了させます。

あと、この先もしこのビジネスアカウントで、支払いをしようとログインしようとしたら、「支払い先とは別のアカウントにしてよ」とメッセージが出てログインに失敗しますので
支払われる様子を最後まで試したい人は、別のメールアドレスでパーソナルアカウントを作っておくと良いでしょう。

ゲーム内の「カタログ」に現実のお金「RM」単位の金額を設定する

これは PlayFab の決まりごとなのですが、セキュアにアイテムの購入・販売をするための仕組みとして、必ず PlayFab ではカタログと呼ばれる価格設定されたアイテムを複数内包する、まさにカタログを定義して利用することになります。
ここで、PayPal を使った購入対象としてカタログとアイテムを指定する時、カタログにてアイテムに価格設定として RM (実際のお金、アメリカドルの0.01つまり1セント1RMという)単位を指定しておかないと PayPal の支払いオプションが入手できないエラーが発生します。
ので、ちゃんとカタログを作るときに RM で、そうですね 1000 RM とすれば 10 ドルのアイテムとして購入することが試せるようになります。

PlayFab のカタログの作り方、理解は次の公式ドキュメントを読むだけでバッチリでした。
カタログ - PlayFab | Microsoft Docs

材料は揃ったのでコードの動作確認をする

先に紹介済みですが、コード例と意味は次の公式ドキュメントが全てを漏れなく解説済みです。
Non-Receipt Payment Processing

これに沿って動かしたときの絵を見てみましょう。
実際にテストに使ったコードがこちら

using PlayFab;
using PlayFab.ClientModels;
using System.Collections.Generic;
using UnityEngine;

public class PlayFabSample : MonoBehaviour
{
    public void Start()
    {
        PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
        { CustomId = "GettingStartedGuide", CreateAccount = true },
        OnLoginSuccess, OnLoginFailure);
    }

    private void OnLoginSuccess(LoginResult result)
    {
        Debug.Log("Congratulations, you made your first successful API call!");
    }

    private void OnLoginFailure(PlayFabError error)
    {
        Debug.LogWarning("Something went wrong with your first API call.  :(");
        Debug.LogError("Here's some debug information:");
        Debug.LogError(error.GenerateErrorReport());
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            PlayFabClientAPI.StartPurchase(new StartPurchaseRequest()
            {
                CatalogVersion = "main",
                Items = new List<ItemPurchaseRequest>() {
                    new ItemPurchaseRequest() {
                        ItemId = "apple",
                        Quantity = 1,
                        Annotation = "Purchased via in-game store"
                    }
                }
            }, OnSuccessOfStartPurchase, 
            error =>
            {
                // Handle error
            });
        }
    }

    void OnSuccessOfStartPurchase(StartPurchaseResult r)
    {
        Debug.Log($"result.OrderId = {r.OrderId}");
        PlayFabClientAPI.PayForPurchase(new PayForPurchaseRequest()
        {
            OrderId = r.OrderId,
            ProviderName = "PayPal",
            Currency = "RM"
        }, result => {
            // Handle success
            Debug.Log($"result.PurchaseConfirmationPageURL = {result.PurchaseConfirmationPageURL}");
            Application.OpenURL(result.PurchaseConfirmationPageURL);
            // 何らかの待機処理を入れて、ここで「お買い上げありがとうございました」の return url からゲームにフォーカスが戻ってくるまで待機する
            OnSuccessOfPayForPurchase(result);
        }, error => {
            // Handle error
        });
    }

    void OnSuccessOfPayForPurchase(PayForPurchaseResult r)
    {
        PlayFabClientAPI.ConfirmPurchase(new ConfirmPurchaseRequest()
        {
            OrderId = r.OrderId
        }, result => {
            // Handle success
            Debug.Log($"result.Items[0].DisplayName = {result.Items[0].DisplayName}");
        }, error => {
            // Handle error
        });;
    }
}

このコードが最後まで成功するまで色々エラーを見てきました。

まず、カタログが無いと、不正なアイテム情報の指定だ、カタログ作れと怒られたり
正しいアイテムを指定しても、それには価格設定が無いから購入対象外だの
PayPal での購入処理をキャンセルしたら ConfirmPurchase の結果を取得してみるとエラー処理に入り
FailedByPaymentProvider で FinalizeTransaction failed, Ack=FailureExpress Checkout PayerID is missing. The PayerID value is invalid. と出るわ
支払いを逃げたからかな?

そこで、支払い用の別の PayPal アカウントを作って実際に 10 USD のアイテムを支払ってみた。
確かに支払われている様子。

f:id:simplestar_tech:20190816231203j:plain
ゲーム内操作で表示されたWebページを承認すると、自身のビジネスアカウントに振り込まれる

この時、ConfirmPurchase は実行せずとも、 StartPurchase で指定したアイテムと個数が Player のインベントリのデータベースに記録されていることを確認できました。

f:id:simplestar_tech:20190816231853j:plain
インベントリに購入したアイテムが自動で追加されている様子
カタログの仕組みを利用すれば、課金完了と同時にアイテムは自動でプレイヤーの手に渡ってくれる親切設計です。

ConfirmPurchase は、あくまで、ちゃんと購入が完了できましたよ、あなたの買ったアイテムはこれこれですよと表示するために行うものでしょう。
(クライアントからアイテム追加のイベントはキックできないので…)

USD 表示のものを登録した銀行口座に出金してみたが…処理が数分では終わらず、どうも数日かかる模様?
いいえ、後で確認した感じ JPY 換算を実行して USD 表示から JPY 表示にしてから資金の移動を行うと正常に処理されました(円で手数料が引かれて)
資金の移動には換算が必要な工程であることを覚えておくと良いかもしれません

f:id:simplestar_tech:20190816232221p:plain
PayPal を使うたびに手数料がかかります。
PayPal の決済手数料はここに書かれている通り
決済手数料|ビジネス向け-PayPal(ペイパル)

ゲーム内通貨を現実のお金で購入してもらい、ゲーム内のお金のやりとりは PlayFab が用意している購入のフローを実行するというのが PlayFab のデザインのようです。
ゲーム内の店舗でのアイテム購入の説明はこちらを参照します。
ストアのクイックスタート - PlayFab | Microsoft Docs

その他、プレイヤー間でアイテムや金銭のトレードを行うようにも API が設計されています。
取り引き - PlayFab | Microsoft Docs
これを利用すれば、不正が行われること無く、活発にゲーム内でプレイヤー同士の交易が行われそうです。

まとめ

バックエンドサービスの群雄割拠時代の PlayFab の位置付け
Unity から PlayFab を1プレイヤーとして利用する手順
3rd パーティ製のアプリ内購入システムを利用しなければならないこと
PayPal と PlayFab との連携手順
実際の購入フローの実演と、登録した口座に振り込まれることの確認
ゲーム内仮想通貨を利用した購入と交易のデザインがあること
を順に確認してきました。

ずっとこういう実際のお金が動くゲームシステムってどうやって作るんだろうと、スキル Zero の状態でしたが
これでゼロが1になった感じです。
まだゲームコンテンツをこれから作っていく段階ですし、そもそもまだそういうお金が実際に動くゲームにするか決めていませんが
選択肢として PlayFab を Unity に導入すれば、課金システムを容易に組み込めることがわかって、個人的に満足です。

みなさんのバックエンド開発・サービス選びの参考になれば幸いです。
ここまで読んで頂きありがとうございました。

PlayFab:UnityのバックエンドとしてのAWS入門

不正を許さないため、オンラインゲームは基本的に現実のお金でのアイテムの購入、ゲーム内の仮想通貨やアイテムの交換はクライアント側では行わずにサーバー側で行います。
そうした不正を許さないバックエンド処理に使う api とデータベースをほぼ無料で使わせてくれるMicrosoft がサポートしている PlayFab をご存知ですか?

「え!まじ!?」と思う入門者が最初に読むべきドキュメントはこちら
docs.microsoft.com

プレイヤー認証

ゲーム開始直後にプレイヤーアカウントを作成するためにIDとパスワードやメール確認などがあると、せっかく遊びに来てくれたお客さんの大半がゲームに触れることなく二度と立ち寄らないことになります。
そんな現実を重く見たPlayFab はアカウント作成のフローを非常に意識していて、最初はデバイス固有の値としてユーザーを見分けるカスタムIDにリンクして、これでユーザーをしばらく特定してゲームを進行させるデザインをとっています。

プレイヤーを作成するのは最初のカスタムIDによるアクセス時で、それ以降はサーバー側でカスタムIDとリンクされた、別のプレイヤーID x 2 を参照する形で全てのクライアントAPIが実行されます。
ゲームを提供する我々をスタジオと見立てて、それぞれ
・マスタープレイヤーID
・タイトルプレイヤーID
をそれぞれスタジオに一意に、タイトル(ゲームごと)に一意に決定して管理します。

戦略的には、後々そのユーザーがミドル・ヘビーユーザーになってくれたときにアカウント連携して、カスタムIDからリンクを解けば、偶然カスタムIDを発見した第三者にアカウントを乗っ取られることもなく
複数デバイスを持つユーザーを一意に見分け、また、スタジオ内の複数のタイトル間をまたぐプレイヤー情報を管理してタイトル間の統計を取ったりと、考え込まれた枠組みでプレイヤーによるクライアント API 実行を実現できます。

「やるじゃん!PlayFab! そこんとこもっと詳しく!」と思った方だけこちらを読むべし→ログインの基本とベストプラクティス - PlayFab | Microsoft Docs

マスターデータ

近年のゲームの面白さはパラメータの調整によって実現されています。
具体的には、プレイヤーの武器の攻撃力、攻撃回数、防御力・ライフ、敵の防御力・ライフ・攻撃力、アイテムの配置・量など(ガチャの確率・配分もかな?)
そういったゲームタイトル固有の静的にデザインされたデータをマスターデータとゲーム業界の人たちは呼ぶようです。(開発者会議とかで)
PlayFab ではそれをタイトルデータとして、テキスト形式だったり、バイナリファイル形式にして、コンテンツ配信ネットワークCDN)を使った非常に効率的な配布方法を提供してくれています。
ゲームマネージャー画面で、開発者がファイルやテキストをアップロードし、クライアントの API から取得するというスタイルでデータの授受を行います。(より具体的にはダウンロード用の一時的なurlを取得してあとは HTTP GET で好きなタイミングでダウンロードしな!って感じだそうです)
詳しくはこちら→コンテンツ配信ネットワークのクイックスタート - PlayFab | Microsoft Docs

ここでとても自然な欲求が生まれてくると思います。
そうだ、みんなで同じデータを共有し、それが動的に切り替わるようなデータも用意したいです!
残念ながら…そんな機能は PlayFab にはありません!
世界中から期待されていますが、将来的な展望として掲げていて未だ実現できていない状況(2019年8月現在)→情報ソース CloudScript Question - Playfab Community

外部サービスとの連携

無いならあきらめて PlayFab 以外で全ユーザーから読み書きするデータストアを外部サービスとして用意しよう。
これしか道がないと PlayFab の中の人も言っているので、ここは Microsoft Azure じゃなくて Amazon Web Services を使います。

手順の概要はこう
1.カスタム CloudScript を作る→具体的な手順 CloudScript のクイックスタート - PlayFab | Microsoft Docs
2.HTTP リクエストを CloudScript 内で同期呼び出しする→要するにこう

以上、url (Amazon API Gateway)の作り方、その先で呼ばれる Lambda の作り方、動き方などは…

四日間ほど Amazon の公式ドキュメントを何十ページと読んで、覚えて、理解していく苦行を続けたけど、欲しかった内容は上記の4ページ + 1 記事だけでした。
これで認証ありAPIを世界に公開しつつ、PlayFab の Cloud Script だけが認証キーを知っている形で、クライアントに一切の秘匿情報を与えずに全プレイヤーが読み書きするデータストアを提供できるようになりました。

なにはともあれ、以下の目標達成!