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 ステップとして、ブロック配置とブロック破壊におけるアイテムスロットの残数の増減を書くことにします。

CubeWalk:マウス操作でインベントリのアイテムを移動できる機能

概要

半月前に構想した内容がこちら
simplestar-tech.hatenablog.com

前々回、前回で進んだ内容が以下
1.R Ctrl でキューブ、SideA, SideB キューブと配置形状が切り替わる
2.オンラインのインベントリ情報を受け取って表示する機能

今回は 3/7 ステップとして
3.マウス操作でインベントリのアイテムを移動できる機能

を作ります。

構想した手順

スロットをクリックして
アイテムがあればアイテムをホールド、なければ何も起きない

クリックを取得

まずは、スロット側にクリックイベントで何か関数呼び出しできるようにしてみます

using UnityEngine;
using UnityEngine.UI;

public class ItemSlot : MonoBehaviour
{
    void Start()
    {
        this.button = GetComponent<Button>();
        this.button.onClick.AddListener(this.OnClick);
    }

    public void OnClick()
    {
        Debug.Log("clicked " + this.gameObject.name);
    }

    Button button;
}

Image UI に Button コンポーネントをつける必要あり

ところでキューブが配置されても、呼ばれる?→はい、大丈夫

ホールドとは

クリックしたスロットにアイテムがあるか見て
ある場合は、そのアイテムをホールド
特別な入れ物にスロットのアイテムを全部入れて、元のスロットを空にする

実装のイメージがわいたのですが
slot の uGUI の slot に Button コンポーネントを静的にアタッチしておき、名前で解決している部分で、OnClickイベントハンドラをセットします。
そうすれば、すべてのスロットのクリックを取得することができ、スロットが空なのかどうか判定できる要素にアクセスできます。

以下が、具現化したコード 期待通り動きました。

        // 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)
                {
                    if(int.TryParse(match.Groups[1].Value, out int slotIndex))
                    {
                        this.slotTransforms[slotIndex] = slotImage;

                        // クリックイベントハンドラを登録
                        var button = slotImage.GetComponent<Button>();
                        if (null != button)
                        {
                            button.onClick.AddListener(() => { this.OnClickSlot(slotIndex); });
                        }
                    }
                }
            }
        }

        private void OnClickSlot(int slotIndex)
        {
            Debug.Log("clicked " + slotIndex);
        }

スロットが空とは?
null でしょうか? → はい

このように見分けます。

        private void OnClickSlot(int slotIndex)
        {
            Debug.Log("clicked " + slotIndex);
            var viewItem = this.viewItems[slotIndex];
            if (null != viewItem && 0 < viewItem.count)
            {
                Debug.Log("itemName " + viewItem.item.DisplayName);
            }
        }

ホルダー

ホールドするときは特別なホルダーと呼ばれる ItemView 保管庫に ItemView が格納されます
それだけ

アイテムがあるスロットをクリックしたときに発生し
ホルダーにアイテムがあるときは、交換するようにホールドします

ホルダーは具体的には ItemView クラス変数
nullable です

ビューを更新するには?

render 関数を作って、データに更新があったら render するというのを考えたのですが、それだとキューブメッシュ作成が 81 回も行われてしまうので
変化が生まれるクリックしたスロットにのみ、キューブオブジェクトを除去したり、生成して設置したりすることにします。

f:id:simplestar_tech:20191212221921p:plain
クリックによってホールド、配置が行えるようになりました

デバッグしていて思ったことは、サーバーから情報を取得したときにアイテムslotの状況が残ってしまい、図のようにローカルでアイテムが複製されてしまいました。
サーバーから情報を引いてくるときにクリアします。

ホールド中はアイテムがカーソルに付随

ホールドしたアイテムが null じゃないなら

f:id:simplestar_tech:20191213002125p:plain
できた

こんな感じのコードでマウスカーソルについてくる形です。

        private void OnClickSlot(int slotIndex)
        {
            Debug.Log("clicked " + slotIndex);
            var item = this.items[slotIndex];
            this.items[slotIndex] = this.itemHolder;
            this.itemHolder = item;

            // スロットの 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;

            Destroy(this.holderCube);
            this.holderCube = CreateSlotCubeObject(this.itemHolder);
            if (null != this.holderCube)
            {
                this.holderCube.transform.SetParent(this.panelInventory);
                this.holderCube.transform.localScale = new Vector3(25, 25, 1);
            }
        }

        void OnGUI()
        {
            Event currentEvent = Event.current;
            Vector2 mousePos = new Vector2();

            // Get the mouse position from Event.
            // Note that the y position from Event is inverted.
            mousePos.x = currentEvent.mousePosition.x;
            mousePos.y = cam.pixelHeight - currentEvent.mousePosition.y;

            var point = cam.ScreenToWorldPoint(new Vector3(mousePos.x + 14, mousePos.y + 14, cam.nearClipPlane + 0.008f));
            if (null != this.holderCube)
            {
                this.holderCube.transform.position = point;
            }
        }

まとめ

アイテムビューを開いたとき、マウスカーソルが出てくるモードがあるので、そのときにクリックして
アイテムがあればアイテムをホールド、なければ何も起きない
ホールドしていた状態でクリックしたとき、スロットだったらスロットに配置する
スロットでなければ…の一歩手前まで動作確認できました。

3.マウス操作でインベントリのアイテムを移動できる機能
が動きました。

これで 3/7 の進捗ですね。
残りも頑張ります

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
ホットバーの若い番号にアイテムが追加されている様子

CubeWalk:R Ctrl でキューブ、SideA, SideB と配置形状が切り替わる

概要

構想に近づけるため、最初のステップを踏みます
simplestar-tech.hatenablog.com


右クリックすると無限に 2 x 2 のプリズムを配置する現在
これを右クリック中 R Ctrl キーを押すと、プリズム A, プリズム B と切り替わり
その時に右クリックを終了するとプリズムが配置されるというもの

f:id:simplestar_tech:20191124010211p:plain
プリズム単位で配置
これを実現してみます。

実装イメージ

新しい InputAction をこうやって使う感じかな

        this.modeAction.performed +=
            ctx =>
            {
                if (this.locatingFlag)
                {
                    // 配置モードを切り替える
                    this.mode++;
                    if (Mode.Max == this.mode)
                    {
                        this.mode = Mode.Cube;
                    }
                }
            };

事前配置は完成

そのまま配置すると、キューブが置かれてしまった
それもそのはず、配置時にモードは渡っていない

カーソルが当たっているときに正しくキューブが出てこない

なんのこっちゃですが、見た目がおかしい不具合あり
直した

配置時に配置モードを利用する

       // 対象キューブ表面を適当なブロックに
        var sideTypeA = SideType.Air;
        var sideTypeB = SideType.Air;
        switch (this.mode)
        {
            case Mode.Cube:
                sideTypeA = SideType.Frost;
                sideTypeB = SideType.Frost;
                break;
            case Mode.SideA:
                sideTypeA = SideType.Frost;
                break;
            case Mode.SideB:
                sideTypeB = SideType.Frost;
                break;
            default:
                break;
        }
        var chunkInt3 = this.cubeDataWorld.CubePositionToChunkInt3(this.cubeCenter);
        this.cubeDataWorld.SetCubeData(this.cubeCenter, chunkInt3, CubeCategoryType.Basic, this.preLocateCubeRotationType, sideTypeA, sideTypeB);
        Destroy(this.preLocateCubeObject);
        this.preLocateCubeObject = null;

これでいいのかな?

なんか不具合
メッシュオブジェクトの作り方に問題がありそう

そう、オフセットの問題だった、解決

ということでできたー!

小ゴール達成なので、次に進めます

CubeWalk:世界の境界の向こう側は無

世界データは最大 67MB

まずは事実から
バイナリキャッシュサーバーに 16 x 16 x 16 x 16 x 16 x 16 x 4 byte のデータを格納しています。
一時間おきに PlayFab からパスワード付きのリクエストを https で送信して PlayFab の CDNgzip 圧縮データをアップロードしています。
クライアントゲームは PlayFab にログイン成功後、最新のデータを CDN からダウンロードして gzip 解凍して

はい、ここまで
ここから zero を 1 にする作業です。

キャッシュサーバーに保存される世界データを小世界と呼ぶことにします。小世界は世界に 16 x 16 x 16 個存在し、現在関心を置いているのは中央が原点の小世界。
将来的に他の小世界から始まることを考えると、プレイヤーの開始位置は世界で様々としたときに
自分がいる小世界のデータをダウンロードして開始することになります。

小世界の果てに着いたらどうなるの?

どうやって小世界を特定するかは後回しにして、もし自身の小世界の終わりにたどり着いたとしたら、そこはどうなっているのでしょうか?
そこは無であり、ブロックを置こうとしても置けない空間です。

それでは小世界の移動ができないのか?

いいえ、小世界をまたぐゲートをまたぐことで小世界と小世界をつなぐことができます。
ゲートは世界を管理できる simplestar だけが設置できるものとし、このゲームの人気が出ない限り出現することはありません。

ゲートはどこに存在するのか?

小世界は上下左右に 6つの小世界と隣接しています。
世界の中央から上下左右に 6軸を延長して境界と交差したブロックを門とし、特別な破壊不能ブロックによって囲われて作られています。

想像しているゲートはこんな感じ

f:id:simplestar_tech:20191116193641p:plain
ゲートの案

プレイヤーが物理的にこのゲートに入った後、そのゲートの先にある小世界のデータのダウンロードに成功した時、その門の向こう側が構築され
プレイヤーは二つの世界を自由に行き来できるようになります。

門をくぐる行為が成功しない限り、門の向こう側の世界は無としてありつづけます。

小世界の名前

始まりの小世界は原点を表す 000 の文字列がキーになり
x 方向に進んで突き当たるゲートをくぐると 100 のキーの世界へ行けます

  • x 方向だったなら F00 の世界へ行けます

y 方向に上昇してゲートを潜ると010の世界へ行くことになります。

XYZ の値を意味しているわけで 000 ~ FFF の全4096世界を表現します。

自分がどの小世界なのかを判定するのは、チャンクのキーが頼りです。
チャンクには 0~255, 0~255, 0~255 の三次元インデックスが振られています。

世界 000 はチャンク
0 - 8 ~ 7 つまり各軸 248~255, 0 ~ 7 のチャンクのキーインデックスに収まっているときに世界 000 にいることが確定します。

チャンクキーから小世界の名前はわかるのか?

そろそろエンジニア向けの話になってきました。
ロジック書けますか?

それぞれの世界には中心チャンクが存在します。並べてみるとわかってくるのではないでしょうか?
小世界の名前 → 中心チャンクキー
E00 → 224, 0, 0
F00 → 240, 0, 0
000 → 0, 0, 0
100 → 16, 0, 0
200 → 32, 0, 0
こんな感じで 16 ずつ中心チャンクキーが移動します。

つまり 1/16倍して round to int すれば 0 ~ 15 の値になるのでは?
試験してみたところ

残念

0~8 までが 0
9~23 までが 1 になってしまった

本当は 0~7 までが 0 で
9~24 までが 1 であってほしいのに

そこで小さい値を詰めて調整することにした
期待通りのインデックス文字列が得られるようになりました。

        for (int i = 0; i < 256; i++)
        {
            int index = Mathf.RoundToInt((i + 0.1f) / 16f);
            if (16 <= index) index = 0;
            Debug.Log($"i = {i}, index = {index.ToString("X")}");
        }

小世界の大きさ

16 x 16 x 16 キューブのチャンクが、前後左右上下で 8 チャンクの距離なので、一方向に 16 x 8 = 128 [cube] まで
実際に遠目で見てみるとこんな感じ

f:id:simplestar_tech:20191116233850p:plain
水平な世界の図

キューブの情報はダウンロード後にすべて作るとしてこのようにします。

        /// <summary>
        /// 未読み込みの場合のみデータをロード
        /// </summary>
        void LoadChunkData(ChunkLoadTask chunkLoadTask)
        {
            this.ChunkInt3ToChunkKey(chunkLoadTask.chunkInt3, out var chunkKeyXYZ);
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = chunkKeyXYZ.x * byteMax * byteMax + chunkKeyXYZ.z * byteMax + chunkKeyXYZ.y;
            if (null == this.ppChunkData[chunkIndex])
            {
                // チャンクが所属する小世界のデータをバッファに書き込み
                var microWorldKey = this.ChunkKeyToMicroWorldKey(chunkKeyXYZ);
                var microWorldName = this.MicroWorldNameFromKey(microWorldKey);
                if (this.cubedataLibrary.ContainsKey(microWorldName))
                {
                    // cubedataLibrary には小世界すべての情報が格納されているので、目的のチャンク情報にアクセスするためのオフセットを計算
                    const int eight = 8;
                    const int deight = 16;
                    var offsetX = chunkLoadTask.chunkInt3.x - microWorldKey.x * ChunkConst.ChunkSizeX - eight;
                    offsetX = AdjustLocalOffsetForChunkKey(byteMax, deight, offsetX);
                    var offsetZ = chunkLoadTask.chunkInt3.z - microWorldKey.z * ChunkConst.ChunkSizeZ - eight;
                    offsetZ = AdjustLocalOffsetForChunkKey(byteMax, deight, offsetZ);
                    var offsetY = chunkLoadTask.chunkInt3.y - microWorldKey.y * ChunkConst.ChunkSizeY - eight;
                    offsetY = AdjustLocalOffsetForChunkKey(byteMax, deight, offsetY);
                    var chunkDataOffset = offsetX * deight * deight + offsetZ * deight + offsetY;
                    var bufferByteOffset = chunkDataOffset * deight * deight * deight * 4;

                    // cubedataLibrary から目的のチャンクの情報部分をコピー
                    var chunkDataSize = sizeof(int) * ChunkConst.ChunkSizeX * ChunkConst.ChunkSizeY * ChunkConst.ChunkSizeZ;
                    var microWorldData = this.cubedataLibrary[microWorldName];
                    var chunkData = new byte[chunkDataSize];
                    Buffer.BlockCopy(microWorldData, bufferByteOffset, chunkData, 0, chunkDataSize);
                    var nativeArray = new NativeArray<byte>(chunkData, Allocator.TempJob);
                    var pNativeChunkData = (int*)nativeArray.GetUnsafePtr();
                    var pChunkData = (int*)(UnsafeUtility.Malloc(chunkDataSize, sizeof(int), Allocator.Persistent));
                    UnsafeUtility.MemCpy(pChunkData, pNativeChunkData, chunkDataSize);
                    this.ppChunkData[chunkIndex] = pChunkData;
                    nativeArray.Dispose();
                }
                else
                {
                    // 小世界データがライブラリにないので null のままにしておく
                }
            }
        }

        /// <summary>
        /// 0をまたぐインデックスのためのオフセット調整
        /// </summary>
        private static int AdjustLocalOffsetForChunkKey(int byteMax, int deight, int offset)
        {
            if (deight < offset)
            {
                offset = offset - byteMax + deight;
            }
            if (0 > offset)
            {
                offset += deight;
            }

            return offset;
        }

世界の果ての様子

期待通りこんな感じでした。

f:id:simplestar_tech:20191117005258p:plain
小世界の果て

PlayFab:ログインタイミングを気にせずプログラミングする術~ファイル編

コンテキスト

PlayFab から世界データ拾ってきたいなぁ
でもログインまだだった 失敗

なんてことが絶対に起こらないような

PlayFab から世界データ取得を成功させる仕組みを考えたい

もう作ってた!

simplestar-tech.hatenablog.com

しばらく Unity から離れてたから自分で記事にしていることすら忘れていた

このアイディアをファイルに対しても使ってみます。

ファイルのダウンロードについてはこちらの Qiita の記事が参考になりました。(これも自分が書いてる)
qiita.com

実装詳細

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

public class PlayFabContentFile : MonoBehaviour
{
    /// <summary>
    /// Content Delivery Network からファイルコンテンツを取得
    /// </summary>
    /// <param name="key">PlayFabのTitleのRoot/以下のファイルオブジェクトキー</param>
    /// <param name="onComplete">完了時アクションでnullが返ってきたら失敗を意味する</param>
    public void GetContentFileData(string key, Action<byte[]> onComplete)
    {
        GetDownloadUrl(key, presignedUrl =>
        {
            GetFile(key, presignedUrl, onComplete);
        });
    }

    void GetDownloadUrl(string key, Action<string> onComplete)
    {
        PlayFabLogin.AfterLoginCall(() =>
        {
            PlayFabClientAPI.GetContentDownloadUrl(new GetContentDownloadUrlRequest()
            {
                Key = key,
                ThruCDN = true
            }, result => onComplete?.Invoke(result.URL),
            error => Debug.LogError(error.GenerateErrorReport()));
        });
    }

    void GetFile(string key, string preauthorizedUrl, Action<byte[]> onComplete)
    {
        StartCoroutine(this.GetData(key, preauthorizedUrl, onComplete));
    }

    IEnumerator GetData(string key, string preauthorizedUrl, Action<byte[]> onComplete)
    {
        UnityWebRequest www = UnityWebRequest.Get(preauthorizedUrl);
        yield return www.SendWebRequest();

        if (www.isNetworkError || www.isHttpError)
        {
            Debug.LogError(www.error);
            onComplete?.Invoke(null);
        }
        else
        {
            // 結果をバイナリデータとして取得する
            onComplete?.Invoke(www.downloadHandler.data);
        }
    }
}

テストコード

// オンラインから現在のプレイヤーチャンクが所属する世界データを読み込む
// this.playerChunkCenter→"000"
var worldIndex = "000";
var objectKey = $"world/cubedata{worldIndex}.gz";
playFabContentFile.GetContentFileData(objectKey, cubedata => {
    if (null != cubedata)
    {
        Debug.Log($"download length = {cubedata.Length}");
        cubedata = GZipCompressor.Decompress(cubedata);
        Debug.Log($"unzip length = {cubedata.Length}");
    }
    else
    {
        Debug.LogError($"キー{objectKey}でダウンロードできなかったんだけど…");
    }
});

実行結果

一度も失敗を経験することなく、期待通りの動作を確認できました。

f:id:simplestar_tech:20191113234155p:plain
大成功!

データの解凍についてはこちらの記事を参照
baba-s.hatenablog.com

自分はこんな実装が欲しかったのでちょっと変更

using ICSharpCode.SharpZipLib.GZip;
using System.IO;

// 使い方
//var compressedData = GZipCompressor.Compress(rawData);
//var rawData = GZipCompressor.Decompress(compressedData);

/// <summary>
/// gzip で byte[] の圧縮や解凍を行うクラス
/// </summary>
public static class GZipCompressor
{
    public static byte[] Compress(byte[] rawData)
    {
        using (var memoryStream = new MemoryStream())
        {
            Compress(memoryStream, rawData);
            return memoryStream.ToArray();
        }
    }

    public static byte[] Unzip(byte[] compressedData)
    {
        using (var memoryStream = new MemoryStream())
        {
            Decompress(memoryStream, compressedData);
            return memoryStream.ToArray();
        }
    }

    private static void Compress(Stream stream, byte[] rawData)
    {
        using (var gzipOutputStream = new GZipOutputStream(stream))
        {
            gzipOutputStream.Write(rawData, 0, rawData.Length);
        }
    }

    private static void Decompress(Stream stream, byte[] compressedData)
    {
        var buffer = new byte[4096];
        using (var memoryStream = new MemoryStream(compressedData))
        using (var gzipOutputStream = new GZipInputStream(memoryStream))
        {
            for (int r = -1; r != 0; r = gzipOutputStream.Read(buffer, 0, buffer.Length))
            {
                if (r > 0)
                {
                    stream.Write(buffer, 0, r);
                }
            }
        }
    }
}

ゲームではこのデータの解凍処理が終わったところで、それぞれの世界データを確保してデータを書き込み
これを使って世界メッシュの生成が進むとよいだろうと思う

CubeWalk:水平な世界データから始める

はじめに

PlayFab ログイン成功後、CDNから世界圧縮データを取得し
Unity で展開してから、それぞれのチャンク配列を確保し、データを詰めていく
詰め終わったら、ゲームシーンを開始する

世界圧縮データを最初は作らなければならない

世界データは16,777,216のint配列

ゲーム内のチャンクインデックスの振り方をおさらい

チャンクにはキーがあり、3次元であらわされたものを一次元に直すと次の通り

        /// <summary>
        /// チャンクキーから世界で一意のチャンクインデックスを取得
        /// </summary>
        internal int ChunkKeyToChunkIndex(Vector3Int chunkKeyXYZ)
        {
            var byteMax = (byte.MaxValue + 1);
            return chunkKeyXYZ.x * byteMax * byteMax + chunkKeyXYZ.z * byteMax + chunkKeyXYZ.y;
        }

つまり、y方向にインクリメントで、z方向に 256 単位オフセット、x方向は 256x256 オフセットが足されて特定されます。

同じようなやり方なら y 方向にインクリメントで、z方向に 16 単位オフセット、x方向は 16x16オフセットが足される形でチャンクのインデックスが得られるとする
一つのチャンクが 16 x 16 x 16 の 4096 キューブ情報を持つので、常にチャンクインデックス x 4096 がチャンクのデータポインタとなります。

ゲーム内のキューブインデックスの振り方をおさらい

        /// <summary>
        /// キューブの3次元位置表現から一次元の配列インデックスを取得
        /// </summary>
        int CubeInt3ToCubeIndex(Vector3Int cubeInt3)
        {
            return cubeInt3.x * ChunkConst.ChunkSizeZ * ChunkConst.ChunkSizeY + cubeInt3.z * ChunkConst.ChunkSizeY + cubeInt3.y;
        }

つまり、 y 方向にインクリメントで、z方向に 16 単位オフセット、x方向は 16x16オフセットが足される形でキューブのインデックスが得られる
チャンクの時と同じですね。
これでキューブのチャンク内の3次元位置から 0~4095 のインデックスになる

世界を y 方向に二分するインデックス範囲は?

y軸上半分を 0 とし
y 軸下半分を 1 とする値埋めをしたいとしたら

チャンクインデックスを各軸 0~15でループさせる 3重ループを作ったとして
その時にチャンクインデックスをy 方向にインクリメントで、z方向に 16 単位オフセット、x方向は 16x16オフセットが足される形でチャンクのインデックスを求め
その時にチャンクインデックス x 4096 がチャンクのデータポインタとなり
そこから連続して 4096 個を処理するとして
条件はチャンクインデックスが y < 8 とするときに 1 を入れれば良い

では golang で作ってみます。

golang で水平な世界埋め

まずはおさらい
前回のコードに
simplestar-tech.hatenablog.com

simplestar-tech.hatenablog.com

次の main を書けば下準備がそろう

func main() {

	// s3 init
	err := PersistentS3Store.Init(s3bucket, "ap-northeast-1")
	if err != nil {
		log.Fatal("s3 Init error.", err)
		return
	}
	// s3 download backup
	gzdata, err := PersistentS3Store.Get(cubedataObjectKey)
	if err != nil {
		log.Fatal("s3 Get error.", err)
		return
	}
	// unzip backup
	cubedata, err := gUnzipData(gzdata)
	if err != nil {
		log.Fatal("gUnzipData error.", err)
	}

	// zip data
	compressedData, err := gZipData(cubedata)
	if err != nil {
		log.Fatal("gZipData error.", err)
	}

	// local save
	writeFileData(compressedData, cubedataObjectKey)
	// s3 upload
	// err = PersistentS3Store.Set(cubedataObjectKey, compressedData)
	// if err != nil {
	// 	log.Fatal("s3 Set error.", err)
	// }
}

肝心の for 文を次のように書きます。
乱数生成あり

	random := rand.New(rand.NewSource(1))
	deight := 16
	for x := 0; x < deight; x++ {
		chunkX := x * deight * deight
		for z := 0; z < deight; z++ {
			chunkZ := z * deight
			// random blocks
			for y := 0; y < 8; y++ {
				chunkOffset := (chunkX + chunkZ + y) * deight * deight * deight
				for index := 0; index < deight*deight*deight; index++ {
					dataIndex := (chunkOffset + index) * 4
					cubedata[dataIndex+0] = 1
					cubedata[dataIndex+1] = (byte)(random.Intn(24))
					side := (byte)(random.Intn(67))
					cubedata[dataIndex+2] = side
					cubedata[dataIndex+3] = side
				}
			}
			// sky
			for y := 8; y < deight; y++ {
				chunkOffset := (chunkX + chunkZ + y) * deight * deight * deight
				for index := 0; index < deight*deight*deight; index++ {
					dataIndex := (chunkOffset + index) * 4
					cubedata[dataIndex+0] = 0
					cubedata[dataIndex+1] = 0
					cubedata[dataIndex+2] = 0
					cubedata[dataIndex+3] = 0
				}
			}
		}
	}

出力した gz ファイルのサイズは 16.7 MB
さすがに乱数で作ったファイルはそこまで小さくたためなかった

これを s3 に上げて、そのあとキャッシュサーバーを再起動すれば PlayFab の CDN が 16MB ほどのファイルになるはずです。
やってみましょう。

動作確認

PlayFab のスケジュールタスクはタイムアウトエラーが出ているけど
CDNには 17MB のデータがアップロードされていました。
go には何かそういうの回避するのあるのかな?

go側はタイムアウト設定した方がリークがなくなってよいのだとか
christina04.hatenablog.com


あと
>http.request呼び出しタイムアウトは2.5秒です。
解決方法は今のところないのだとさ まぁタイムアウトが起きても、呼び出されたキャッシュサーバーは仕事を完遂するので今は良しとします。
community.playfab.com