simplestarの技術ブログ

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

CubeWalk:カスタムデータを更新するリクエスト CloudScript の実装

構想7ステップの4つ目

半月前の構想をまとめたものがこちら
simplestar-tech.hatenablog.com

今回は表題の通り Microsoft PlayFab の CloudScript を用意して、クライアントで行うアイテムslot変更操作をサーバーに反映させます。
クライアントでスロット操作を計算して、リクエストした後、サーバー側でそのリクエストが正しいか確認してデータを変更します。

f:id:simplestar_tech:20191215011407p:plain
アイテム操作の図

クライアントからのリクエス

サーバーではアイテムスロットの総数が、現在のアイテムの個数と一致していることを判定したいので
リクエストには現在のスロットすべての合計値を渡さなければなりません

アイテムモードへの移行と、アイテムモード解除イベントを取得し
アイテムモード解除時に、変更を加えたアイテムについてのカスタムデータの更新をリクエストすることにします。

アイテムモードのオンオフ

E キーだったかな
>プレイヤーのインベントリはEキーを押すことで開くことが出来る。インベントリはEscキーでも閉じることが出来る

誰が E キーイベントをキャッチするのか
UserInventory クラスかな

次の InputSystem の実装で、UIの表示・非表示は問題なく動きます

            this.inventoryAction.performed +=
            ctx =>
            {
                this.panelInventory.gameObject.SetActive(true);
            };
            this.escapeAction.performed +=
            ctx =>
            {
                this.panelInventory.gameObject.SetActive(false);
            };

Eキーでインベントリが表示されるときと Escape で Inventory が閉じられるときにイベントを発行するようにしておきます

インベントリを表示するときに最新データを取得

前々回
simplestar-tech.hatenablog.com
の実装を関数化してこんな感じでイベントハンドラとして登録するだけ

            this.onOpenInventory += this.OnOpenInventory;

            this.inventoryAction.performed +=
            ctx =>
            {
                if (!this.panelInventory.gameObject.activeSelf)
                {
                    this.panelInventory.gameObject.SetActive(true);
                    this.onOpenInventory?.Invoke();
                }
                
            };

インベントリを閉じたときに、CustomData 変更をリクエス

まず、サーバー側の CloudScript を次のように実装していました。
詳しくは前々回
simplestar-tech.hatenablog.com


こんな感じで、クライアントからはサーバーにあるアイテム数と一致するように各スロットに振り分けたアイテム数を実際のアイテム数となるように調整しなければならない(必ず正の整数で)

// Update Item Slot
handlers.updateItemSlot = function (args, context) {
    // integrate slot item count
    var pattern = /slot([0-9]{2})$/;
    var slotTotal = 0;
    for (key in args.data) {
        var match = key.match(pattern);
        if (null != match) {
            var value = args.data[key];
            if (false == isNaN(value)) {
                if (0 < value) {
                    slotTotal += args.data[key];
                }
            }
        }
    }
    // get item remainingUses
    var remainingUses = null;
    var playerInventory = server.GetUserInventory({ PlayFabId: currentPlayerId });
    var length = playerInventory.Inventory.length;
    for (var i = 0; i < length; i++) {
        var item = playerInventory.Inventory[i];
        if (item.ItemInstanceId == args.itemInstanceId) {
            remainingUses = item.RemainingUses;
            break;
        }
    }

    // update CustomData
    var response = { status: 400 };
    if (null != remainingUses && remainingUses == slotTotal) {
        var request = {
            PlayFabId: currentPlayerId,
            ItemInstanceId: args.itemInstanceId,
            Data: args.data,
            KeysToRemove: args.keysToRemove
        };
        server.UpdateUserInventoryItemCustomData(request);
        response = { status: 200 }
    }
    var jsonString = JSON.stringify(response);
    return jsonString;
};

であるからして、クライアントは CustomData つまりはスロットのアイテム数に変更を加えたアイテムを覚えておく必要があり
そのアイテムについてスロットの設定をすべて列挙してリクエストしなければならない

気づいたことに、前々回の対応には不備としてアイテムが増えたときの CustomData の修正が抜けていたが
後で考えることにします。

今はリクエストをなげて、その不備を確認するところから

クライアントでこれを実現するとなると、このようなコードになります。

        /// <summary>
        /// スロットをクリックしたときのアイテムのホールド操作
        /// </summary>
        /// <param name="slotIndex">クリックしたスロット</param>
        private void OnClickSlot(int slotIndex)
        {
            // スロットとホルダーを交換
            var item = this.items[slotIndex];
            this.items[slotIndex] = this.items[MAX_SLOT_INDEX];
            if (null != this.items[slotIndex])
            {
                this.items[slotIndex].slotIndex = slotIndex;
            }
            this.items[MAX_SLOT_INDEX] = item;
            if (null != this.items[MAX_SLOT_INDEX])
            {
                this.items[MAX_SLOT_INDEX].slotIndex = MAX_SLOT_INDEX;
            }

            // スロットの CubeObject を現在の状態となるように更新
            Destroy(this.slotCubes[slotIndex]);
            var cubeObject = this.CreateSlotCubeObject(this.items[slotIndex]);
            if (null != cubeObject)
            {
                cubeObject.transform.SetParent(this.slotTransforms[slotIndex]);
                cubeObject.transform.localPosition = Vector3.zero;
                cubeObject.transform.localScale = new Vector3(50, 50, 1);
            }
            this.slotCubes[slotIndex] = cubeObject;

            // ホルダーの CubeObject を現在の状態となるように更新
            Destroy(this.slotCubes[MAX_SLOT_INDEX]);
            this.slotCubes[MAX_SLOT_INDEX] = this.CreateSlotCubeObject(this.items[MAX_SLOT_INDEX]);
            if (null != this.slotCubes[MAX_SLOT_INDEX])
            {
                this.slotCubes[MAX_SLOT_INDEX].transform.SetParent(this.panelInventory);
                this.slotCubes[MAX_SLOT_INDEX].transform.localScale = new Vector3(25, 25, 1);
            }
            // 編集したアイテムとして記録
            if (null != item && null != item.item && !this.editItems.ContainsKey(item.item.ItemInstanceId))
            {
                this.editItems.Add(item.item.ItemInstanceId, item);
            }
        }

上記のようにアイテムを記録しておいて、インベントリを閉じるときに次の通り、アイテムスロットの更新をリクエストします。

        private void OnCloseInventory()
        {
            // 編集したアイテムのインスタンスIDを持つスロットをすべて収集
            foreach (var edit in this.editItems)
            {
                var itemInstanceId = edit.Key;
                List<ViewItem> viewItemList = new List<ViewItem>();
                foreach (var view in this.items)
                {
                    if (null != view && null != view.item)
                    {
                        if (0 == string.Compare(view.item.ItemInstanceId, itemInstanceId))
                        {
                            viewItemList.Add(view);
                        }
                    }
                }
                // 収集したスロット情報で上書きをリクエスト(スロット表記と実際のアイテム数が合えば承認されます)
                this.UpdateUserInventoryItemCustomData(edit.Value.item, viewItemList);
            }
            this.editItems.Clear();
        }

UpdateUserInventoryCustomData 関数はこんな感じ(前々回に作った)

        /// <summary>
        /// アイテムのカスタムデータ更新リクエスト
        /// </summary>
        /// <param name="item">対象のアイテム</param>
        /// <param name="viewItemList">カスタムデータがなかったのでやむを得ず空いてるスロットにセットしたアイテム一覧</param>
        internal void UpdateUserInventoryItemCustomData(ItemInstance item, List<ViewItem> viewItemList)
        {
            List<string> keysToRemove = new List<string>();
            if (null != item.CustomData)
            {
                foreach (var keyValuePair in item.CustomData)
                {
                    var match = Regex.Match(keyValuePair.Key, "slot([0-9]{2})");
                    if (match.Success)
                    {
                        bool removeTarget = true;
                        foreach (var viewItem in viewItemList)
                        {
                            var newKey = $"slot{viewItem.slotIndex.ToString("00")}";
                            if (keyValuePair.Key == newKey)
                            {
                                removeTarget = false;
                                break;
                            }
                        }
                        if (removeTarget)
                        {
                            // スロットのキーパターンで、今回の上書きキーとは別のキーは削除する(スロットの移動対応)
                            keysToRemove.Add(keyValuePair.Key);
                        }
                    }
                }
            }
            Dictionary<string, string> customData = new Dictionary<string, string>();
            foreach (var viewItem in viewItemList)
            {
                var newKey = $"slot{viewItem.slotIndex.ToString("00")}";
                customData[newKey] = viewItem.count.ToString();
            }
            var itemInstanceId = item.ItemInstanceId;
            PlayFabCloudScript.UpdateItemSlotRequest(itemInstanceId, customData, keysToRemove, (result) => {
                Debug.Log($"result.status = {result.status}");
            });
        }

うまくいかないケースとは

たとえばブロックを破壊したときにサーバー側でインベントリのアイテム数が増えますが
CustomData のスロットの所属数は増えません(そこまで面倒見切れない)

となると、アイテム操作に不正がなくても、スロットの所属情報である CustomData を持っている時点で
ブロックを破壊したものなら、次回のアイテム操作がすべて不正操作扱いとなってしまいます。
あと、せっかくアイテムをゲットしたのに、インベントリを開いても数が増えてない

これを解決するには

・アイテムをゲットしたときに CustomData をインクリメント → アイテムゲットはブロック破壊や配置で発生するので、そのような計算コスト払いたくない
・インベントリを開いたときに、合計スロット所属数とアイテム数に差異があったら、訂正してあげる

後者ならクライアントサイドの計算なので大丈夫ですね。
こちらの対応を進めてみます。

こんな感じで、コメントにあるところに、期待する処理を入れることにします。

                    bool hasSlot = false;
                    if (null != item.CustomData)
                    {
                        // スロット指定のあるアイテム数を集計
                        int totalSlotCount = 0;
                        foreach (var customData in item.CustomData)
                        {
                            var match = Regex.Match(customData.Key, "slot([0-9]{2})");
                            if (match.Success && 2 == match.Groups.Count && int.TryParse(customData.Value, out int itemCount))
                            {
                                int slotIndex = int.Parse(match.Groups[1].Value);
                                this.items[slotIndex] = new ViewItem { slotIndex = slotIndex, item = item, count = itemCount };
                                totalSlotCount += itemCount;
                                hasSlot = true;
                            }
                        }
                        // もしスロット数と不一致の場合は正す
                        if (totalSlotCount != item.RemainingUses)
                        {
                            Debug.Log("不一致");
                            // 減らすときは、追加したスロットの先頭から数を減らしていくといいかな、ゼロになったら、次に小さいスロット番号のスロットから数を減らしていく
                            // 増やすときも、先頭から数を増やしていき、MAXになったら次のスロットに埋めていく、もしすべてのスロットがMAXになったら、余っているスロットにアイテムを入れる
                            // 全部処理を終えたら、サーバーにリクエストしておく
                        }
                        else
                        {
                            Debug.Log("一致してます");
                        }
                    }
                    // スロット情報を持たないアイテムを記録
                    if (!hasSlot)
                    {
                        noSlotItems.Add(item);
                    }

ただ、このやり方だと増えたときにもしすべてのスロットが埋まっていたとき、もしオーバーした分を切り捨てたら
サーバーからの承認を一生得られないことになるんですよね(一応、該当アイテムを消費していけば、次のインベントリオープンで回避できるけど)

どんなときにも承認を得られるようにするために、オーバー分をそのまま個数無限でオーバー分として保存できるようにしようと思います
ユーザーからはそのオーバー分は取り戻せるようにしてあげればなんとか

あ、もう一ついい方法を思いついた。
ローカルでキューブを破壊したときに、ローカルでスロット情報をインクリメントして、これをサーバーにリクエストする
これならサーバー負荷が上がらず、クライアントが正しい計算結果をリクエストし続ける限り困る人はいない

一応、何かの拍子で壊れてしまった時だけ操作不能にならない対応を入れることにした。
コメント部分を具体化すると次の通り

// もしスロット数と不一致の場合は正す
if (hasSlot && totalSlotCount != item.RemainingUses)
{
    // 減らすとき、増やすときも、いったんローカルのアイテム情報をすべてクリアして CustomData のスロット情報が無かったことにする
    List<ViewItem> viewItemList = new List<ViewItem>();
    for (int slotIndex = 0; slotIndex < this.items.Length; slotIndex++)
    {
        if (null != this.items[slotIndex] && null != this.items[slotIndex].item && 0 == string.Compare(this.items[slotIndex].item.ItemInstanceId, item.ItemInstanceId))
        {
            this.items[slotIndex] = null;
        }
    }
    hasSlot = false;
}

まとめ

Microsoft PlayFab の CloudScript を用意して、クライアントで行うアイテムslot変更操作をサーバーに反映させ
クライアントでスロット操作を計算して、リクエストした後、サーバー側でそのリクエストが正しいか確認してデータを変更しました。

Eキーでインベントリが開き、Esc キーで閉じます
開いたときにサーバーから最新のインベントリ情報を取得して表示し
Esc キーでインベントリを閉じたときに変更したアイテムに関してのみ操作内容の反映をリクエストします。

正しく動きました。
構想も完璧じゃなくて、さっきひらめいた実装アイディアだと
アイテムを置いたりブロックを破壊したりしたときのアイテムスロットの増減調整をクライアントコードに書かないといけないことになります

次は 4.5 ステップとして、ブロック配置とブロック破壊におけるアイテムスロットの残数の増減を書くことにします。