simplestarの技術ブログ

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

CubeWalk:インベントリ情報を受け取って表示する機能

概要

構想によって作られたステップ 2/7 を今回はクリアしたいと思います。
simplestar-tech.hatenablog.com

表題の通り PlayFab からインベントリの情報を取得して、これを UI で表示します。

簡易なシーン

PlayFab のスクリプトをシーンに追加して試験環境を作ります。

参考ページはこちら
qiita.com

私は例によって、必ずログイン後に実行されるような仕組みを入れてみました。

    private static void GetUserInventory()
    {
        PlayFabLogin.AfterLoginCall(() =>
        {
            PlayFabClientAPI.GetUserInventory(new GetUserInventoryRequest(), result =>
            {
                foreach (ItemInstance item in result.Inventory)
                {
                    Debug.Log(item.ItemId);
                    Debug.Log(item.DisplayName);
                    if (null != item.CustomData)
                    {
                        foreach (var customData in item.CustomData)
                        {
                            Debug.Log($"key = {customData.Key}, value = {customData.Value}");
                        }
                    }
                }
            }, error => Debug.Log(error.GenerateErrorReport()));
        });
    }

ブロックのアイテムID、表示名、残数、アイテムインスタンスIDなどが ItemInstance から取得できます。
CustomData は null でした。設定前なので
スロットに 99 個までアイテムを詰め込んで、CustomData に情報がなかったら、クライアントから詰めてあげるのはどうかと考えています。
先に進めます。

キューブのテクスチャをスロットに配置

まずは見た目の話で、スロットの子オブジェクトとして Image を置き、そこに Shader を割り当てることができるか?
できると、アイテムの表示とキューブの表示が統一できて良さそうと考えました。

できます。

3Dのマテリアルを2Dに当てるのは間違いだったと思い始めました。
uGUIに3Dのモデルって置けますか?
Canvas のモードを Screen Space Camera にすることで、できました。

となると、既存のキューブ選択や、配置前のオブジェクトの作成コードって使いまわせますか?
共通部分を抜き出して Mesh 処理に任せられないでしょうか?

できます。具体的には次の通り、キューブ作成コードを関数化できました!

        /// <summary>
        /// キューブ情報からキューブオブジェクトを作成
        /// </summary>
        /// <param name="prefab">元となるprefab</param>
        /// <param name="parent">親オブジェクト</param>
        /// <param name="pData">キューブデータ4byteのポインタ</param>
        /// <param name="vertexCount">キューブ情報に基づく頂点数</param>
        /// <param name="meshShader">シェーダー</param>
        /// <returns>キューブオブジェクト</returns>
        internal unsafe GameObject InstantiateCubeObject(GameObject prefab, Transform parent, byte* pData, int vertexCount, Shader meshShader = null)
        {
            // 再作成
            var cubeObject = Instantiate(prefab, parent);

            #region マテリアルの設定
            if (null == meshShader)
            {
                meshShader = this.meshShader;
            }
            if (!this.materials.ContainsKey(meshShader.GetInstanceID()))
            {
                var material = new Material(meshShader);
                material.SetTexture("_MainTex", this.textureAlbedo);
                this.materials.Add(meshShader.GetInstanceID(), material);
            }
            var renderer = cubeObject.GetComponent<MeshRenderer>();
            renderer.sharedMaterial = this.materials[meshShader.GetInstanceID()];
            #endregion

            // キューブデータの作成
            var nativeVertices = new NativeArray<ChunkMeshSystem.CustomVertexLayout>(vertexCount, Allocator.Temp);
            var meshData = new ChunkMeshData { vertexCount = vertexCount, pVertices = (float*)nativeVertices.GetUnsafePtr() };
            ChunkMeshSystem.FillCubeVertices(pData, ref meshData);

            // メッシュ作成
            var mesh = new Mesh();
            mesh.Clear();
            // メッシュレイアウトを定義
            var layout = new[]
            {
                new VertexAttributeDescriptor(VertexAttribute.Position, VertexAttributeFormat.Float32, 3),
                new VertexAttributeDescriptor(VertexAttribute.TexCoord0, VertexAttributeFormat.Float32, 2),
            };

            mesh.SetVertexBufferParams(vertexCount, layout);
            mesh.SetIndexBufferParams(vertexCount, IndexFormat.UInt32);

            // インデックスを作成
            var nativeIndices = new NativeArray<int>(vertexCount, Allocator.Temp);
            for (int index = 0; index < vertexCount; index++)
            {
                nativeIndices[index] = index;
            }

            // 頂点データとインデックスを設定
            mesh.SetVertexBufferData(nativeVertices, 0, 0, vertexCount);
            mesh.SetIndexBufferData(nativeIndices, 0, 0, vertexCount);

            // ネイティブ配列を開放
            nativeVertices.Dispose();
            nativeIndices.Dispose();

            // サブメッシュの定義
            mesh.subMeshCount = 1;
            mesh.SetSubMesh(0, new SubMeshDescriptor
            {
                topology = MeshTopology.Triangles,
                vertexCount = vertexCount,
                indexCount = vertexCount,
                baseVertex = 0,
                firstVertex = 0,
                indexStart = 0
            });

            // 法線と接戦とバウンディングボックスを作成
            mesh.RecalculateNormals();
            mesh.RecalculateTangents();
            mesh.RecalculateBounds();

            // メッシュの設定
            var meshFilter = cubeObject.GetComponent<MeshFilter>();
            if (null != meshFilter.sharedMesh)
            {
                meshFilter.sharedMesh.Clear();
            }
            meshFilter.sharedMesh = mesh;
            return cubeObject;
        }

これを使って、インベントリスロットにキューブアイコンを設置できるようになりました!

f:id:simplestar_tech:20191124163736p:plain
キューブアイコン

でもこのキャンバスって、オーバーレイじゃないから、邪魔な背景が来ませんか?
はい、邪魔になります

やっぱり、2D 用のシェーダーに書き換えた方がいいでしょうか?
次に移ります。

UV計算するシェーダー

使うテクスチャは同じで、UV計算を正しく行ってみることにします。
計算式はこちら

               // テクスチャ座標計算に使う変数を計算
                int row = (byte)sideType % 17;
                int col = (byte)sideType / 17;

                const float shrinkAmount = 232f / 4096f;
                float offsetU = col / 4.0f;
                float offsetV = shrinkAmount * (16 - row) % 17 + 0.0371f;

                    // 回転を考慮して UV 設定
                    var pUV = pPosition + 3;
                    pUV[0] = pUV[0] * shrinkAmount + offsetU;
                    pUV[1] = pUV[1] * shrinkAmount + offsetV;

uv 値のソースは次の通り

            #region 原点中心にプリズム二本で構成されるキューブの頂点データ定義→NativeArray確保
            const float halfSide = ChunkConst.CubeSide / 2.0f;
            var vertices = new float[]{
                // Prism A                       // Top
                -halfSide, +halfSide, +halfSide, 0, 1,
                +halfSide, +halfSide, +halfSide, 1, 1,
                +halfSide, +halfSide, -halfSide, 1, 0,
                                                 // Bottom
                +halfSide, -halfSide, -halfSide, 1, 0,
                +halfSide, -halfSide, +halfSide, 0, 0,
                -halfSide, -halfSide, +halfSide, 0, 1,
                                                 // Right up
                +halfSide, +halfSide, -halfSide, 1, 1,
                +halfSide, +halfSide, +halfSide, 2, 1,
                +halfSide, -halfSide, +halfSide, 2, 0,
                                                 // Right down
                +halfSide, -halfSide, +halfSide, 2, 0,
                +halfSide, -halfSide, -halfSide, 1, 0,
                +halfSide, +halfSide, -halfSide, 1, 1,
                                                 // Forward up
                +halfSide, +halfSide, +halfSide, 2, 1,
                -halfSide, +halfSide, +halfSide, 3, 1,
                -halfSide, -halfSide, +halfSide, 3, 0,
                                                 // Forward down
                -halfSide, -halfSide, +halfSide, 3, 0,
                +halfSide, -halfSide, +halfSide, 2, 0,
                +halfSide, +halfSide, +halfSide, 2, 1,
                                                 // Cross up
                -halfSide, +halfSide, +halfSide, 3, 1,
                +halfSide, +halfSide, -halfSide, 4.4142f, 1,
                +halfSide, -halfSide, -halfSide, 4.4142f, 0,
                                                 // Cross down
                +halfSide, -halfSide, -halfSide, 4.4142f, 0,
                -halfSide, -halfSide, +halfSide, 3, 0,
                -halfSide, +halfSide, +halfSide, 3, 1,
                // Prism B                       // BottomB
                -halfSide, -halfSide, +halfSide, 0, 1,
                -halfSide, -halfSide, -halfSide, 1, 1,
                +halfSide, -halfSide, -halfSide, 1, 0,
                                                 // TopB
                +halfSide, +halfSide, -halfSide, 1, 0,
                -halfSide, +halfSide, -halfSide, 0, 0,
                -halfSide, +halfSide, +halfSide, 0, 1,
                                                 // Left up B
                -halfSide, +halfSide, +halfSide, 1, 1,
                -halfSide, +halfSide, -halfSide, 2, 1,
                -halfSide, -halfSide, -halfSide, 2, 0,
                                                 // Left down B
                -halfSide, -halfSide, -halfSide, 2, 0,
                -halfSide, -halfSide, +halfSide, 1, 0,
                -halfSide, +halfSide, +halfSide, 1, 1,
                                                 // Back up B
                -halfSide, +halfSide, -halfSide, 2, 1,
                +halfSide, +halfSide, -halfSide, 3, 1,
                +halfSide, -halfSide, -halfSide, 3, 0,
                                                 // Back down B
                +halfSide, -halfSide, -halfSide, 3, 0,
                -halfSide, -halfSide, -halfSide, 2, 0,
                -halfSide, +halfSide, -halfSide, 2, 1,
                                                 // Cross up
                +halfSide, +halfSide, -halfSide, 3, 1,
                -halfSide, +halfSide, +halfSide, 4.4142f, 1,
                -halfSide, -halfSide, +halfSide, 4.4142f, 0,
                                                 // Cross down
                -halfSide, -halfSide, +halfSide, 4.4142f, 0,
                +halfSide, -halfSide, -halfSide, 3, 0,
                +halfSide, +halfSide, -halfSide, 3, 1,
            };

想像することはできると思います。
描画結果を確認したところ真っ黒でした。

あれ、なんで GameView だけ真っ黒なのでしょう?
それは、こういう落とし穴でした。
https://forum.unity.com/threads/shader-graph-ui-image-shader.628111/

解決には Screen Space Camera のレンダーモードにするしかないらしい
となると、先ほどのキューブメッシュ作成関数と同じあきらめる理由になります。

ここにきて、またキューブ作成案が浮上!

邪魔になる問題は残りますが
Plane Distance をカメラの near plane ぎりぎりまで近づければなんとかなるので、それでいくことに!

ちょっと無駄骨でしたが、次の ShaderGraph はお蔵入りです。

f:id:simplestar_tech:20191125081334p:plain
上記のUV計算する ShaderGraph

Cube メッシュの対応を行えば Inventory のアイコンも完成するはずで
最後に笑うことができそうです。うれしい!

InventoryCanvas

キャンバスのモードが違うものを用意

f:id:simplestar_tech:20191125081035p:plain
Screen Space Camera でうまくできたけど…

さきほどからゲーム内だけ真っ黒になる話題がありますが
UIにだけ Camera 側から Directional Light を放つ感じで用意します。
そうしないと、暗闇で作業したときに UI も暗くなっちゃうことになりますので

さて

これはキューブに UI レイヤーを適用して、directional light を ui だけに当てれば解決します。
また、既存の directional light は ui に当てないことを注意する必要があります。
これで解決することができました。

まだスロットを決定していないアイテムの場所

CustomData の値を頼りにスロットにアイテムを置こうとしていますが、最初は CustomData は null です。

すべてがふわふわしている状況ですが、まずどこに置くか決めていきます。

ホットバーを作ります。

ホットバーの空いている若い番号
99個オーバーしない限りは、そのスロット番号を CustomData に詰めるとし

99個オーバーしたら、次の若いスロット番号を使うことにします。

ホットバーは特別なスロットではなく 0~8 のインデックスが振られたただのスロットということにします。

そろそろ手が動かせるところなのでスロットの背景画像用の png ファイルを用意します。
ClipStudio で作ります。

これを Grid Layout で並べてみました
左下から右上にかけて、みぎに並ぶように slot インデックスが振られています。(そういうレイアウト指定が可能)

f:id:simplestar_tech:20191208153205p:plain
スロット表示

ゲーム開始時にスロット番号にあった配列を作ります。

        // Start is called before the first frame update
        void Start()
        {
            // パネルに並ぶslot名からimage配列を作成
            foreach (Transform slotImage in this.panelInventory)
            {
                var match = Regex.Match(slotImage.name, "slot([0-9]{2})");
                if(match.Success && 2 == match.Groups.Count)
                {
                    this.slotTransforms[int.Parse(match.Groups[1].Value)] = slotImage;
                }
            }
        }

インベントリ情報を受け取ったら
カスタムデータがあるものについて、まずはスロットに情報を埋めます。(今はありません)
slot00 とかでマッチしたら 0 スロットに value の値を入れることにします。(今はありません)

しつこいですが、最初はカスタムデータを持たないので、若いスロットからアイテムを詰めていきます。

若いスロットからアイテムを詰めていくコードはこんな感じ

                PlayFabInventory.GetUserInventory(result => {
                    this.userInventory = result.Inventory;
                    List<ItemInstance> noSlotItems = new List<ItemInstance>();
                    foreach (var item in this.userInventory)
                    {
                        if ("0" == item.ItemId)
                        {
                            continue;
                        }
                        Debug.Log(item.ItemId);
                        Debug.Log(item.DisplayName);
                        Debug.Log(item.RemainingUses);
                        if (null != item.CustomData)
                        {
                            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))
                                {
                                    this.viewItems[int.Parse(match.Groups[1].Value)] = new ViewItem { item = item, count = itemCount };
                                }
                                Debug.Log($"key = {customData.Key}, value = {customData.Value}");
                            }
                        }
                        else
                        {
                            noSlotItems.Add(item);
                        }
                    }
                    foreach (var item in noSlotItems)
                    {
                        for (int viewItemIndex = 0; viewItemIndex < this.viewItems.Length; viewItemIndex++)
                        {
                            if (null == this.viewItems[viewItemIndex] && null != item.RemainingUses)
                            {
                                const int slotMaxPrismCount = 99 * 2;
                                int overSlot = Mathf.FloorToInt((int)item.RemainingUses / slotMaxPrismCount);
                                for (int i = overSlot; i >= 0; i--)
                                {
                                    if (0 != i)
                                    {
                                        this.viewItems[viewItemIndex] = new ViewItem { item = item, count = slotMaxPrismCount };
                                        for (; viewItemIndex < this.viewItems.Length; viewItemIndex++)
                                        {
                                            if (null == this.viewItems[viewItemIndex])
                                            {
                                                break;
                                            }
                                        }
                                    }
                                    else if (0 < ((int)item.RemainingUses - overSlot * slotMaxPrismCount))
                                    {
                                        this.viewItems[viewItemIndex] = new ViewItem { item = item, count = (int)item.RemainingUses - overSlot * slotMaxPrismCount };
                                    }
                                }
                                break;
                            }
                        }
                    }
                    for (int viewItemIndex = 0; viewItemIndex < this.viewItems.Length; viewItemIndex++)
                    {
                        if (null != this.viewItems[viewItemIndex])
                        {
                            this.CreateBasicCubeObject(viewItemIndex, this.viewItems[viewItemIndex]);
                        }
                    }
                });
            }

上記でいうところの viewItems に格納することによって CustomData に次から slot00:99 とかの値が入ってほしいので、ここでカスタムDataの更新リクエストをクライアントから行います。

クライアントからアイテムの CustomData を更新する

アイテム数を増やすのではなく、装備状況などをゲーム情報としてカスタム管理するので、これはクライアントから投げても ok とします。
この世界ではカスタムデータをどんなに偽装しても、最終的にはアイテム数を処理するので、整合性がとれなくなった場合は、リクエストされたカスタムデータが無視されます
PlayFab の api の情報によれば、サーバー側でしかカスタムデータを編集できないので、クライアントからは、どのslotにいくつ移動したという旨のリクエストを投げます。

アイテムに関するすべてのカスタムデータを一度に送ることを考えます。
サーバー側ではカスタムデータの総数がアイテム数より低いことを確かめてもらうためです。
サーバーは、リクエストに不整合がないことを確認し、承認する形でカスタムデータを変更します。

C# 側からのリクエストは次の通り

        /// <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, null);
        }

呼び出している関数はこちら

    /// <summary>
    /// アイテムのスロット情報変更をリクエスト
    /// </summary>
    public static void UpdateItemSlotRequest(string itemInstanceId, object data, object keysToRemove, UnityAction<UpdateItemSlotResult> callback)
    {
        ExecuteCloudScript<UpdateItemSlotResult>("updateItemSlot", new { itemInstanceId = itemInstanceId, data = data, keysToRemove = keysToRemove }, successResult =>
        {
            callback?.Invoke(successResult);
        });
    }

CloudScript はこんな感じ

// Update Item Slot
handlers.updateItemSlot = function (args, context) {
    var request = {
        PlayFabId: currentPlayerId, 
        ItemInstanceId: args.itemInstanceId,
        Data: args.data,
        KeysToRemove: args.keysToRemove
    };
    var response = server.UpdateUserInventoryItemCustomData(request);
    var jsonString = JSON.stringify(response);
    return jsonString;
};

サーバー側でスロットのカウントとアイテム数を精査

カスタムデータが slot xx のものの値を合計して、アイテム総数と一致しているかをチェックする機能を Cloud Script 側に入れます。
正しく status 200 が返ってきたことを確認できたコードがこちら

// 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) {
            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 (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;
};

まとめ

ユーザーのインベントリ情報を取得、ローカルでの若い番号のスロットにアイテムを詰める方法を
サーバーにリクエストして、サーバー側で不整合がないか確認を行い、合格したときにカスタムデータを更新
次回から、そのカスタムデータを使ってスロットにアイテムを格納する

できました

f:id:simplestar_tech:20191210004706p:plain
ホットバーの若い番号にアイテムが追加されている様子