simplestarの技術ブログ

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

Unity:QuadのSubDivision Script 実装例

前置き

f:id:simplestar_tech:20200126194256p:plain
メッシュが細かければ、height 指定でこのような見た目が作れます

URP で Tessellation がいまだ用意されていない(調べた限り HDRP は可能)

Tesselation というのはこういうメッシュ細分化
ja.wikipedia.org

いますぐ平面のメッシュ細分化処理がほしい!

Mesh 操作の基本を確認

qiita.com

あ、作れる!

作った

        var subDivIndices = new int[6 * subDivCount * subDivCount];
        var subDivVerts = new Vector3[4 * subDivCount * subDivCount];
        var subDivUvs = new Vector2[4 * subDivCount * subDivCount];
        var edgeLength = 1.0f / subDivCount;
        for (int xIndex = 0; xIndex < subDivCount; xIndex++)
        {
            var offsetX = edgeLength * xIndex;
            for (int yIndex = 0; yIndex < subDivCount; yIndex++)
            {
                var offsetY = edgeLength * yIndex;
                var offsetIndex = subDivCount * xIndex + yIndex;

                var leftBottom = new Vector3(offsetX - 0.5f, offsetY - 0.5f);
                var rightBottom = leftBottom + new Vector3(edgeLength, 0);
                var leftUp = leftBottom + new Vector3(0, edgeLength);
                var rightUp = leftBottom + new Vector3(edgeLength, edgeLength);

                subDivVerts[4 * offsetIndex + 0] = leftBottom;
                subDivVerts[4 * offsetIndex + 1] = rightBottom;
                subDivVerts[4 * offsetIndex + 2] = leftUp;
                subDivVerts[4 * offsetIndex + 3] = rightUp;

                var uvLeftBottom = new Vector2(offsetX, offsetY);
                var uvRightBottom = uvLeftBottom + new Vector2(edgeLength, 0);
                var uvLeftUp = uvLeftBottom + new Vector2(0, edgeLength);
                var uvRightUp = uvLeftBottom + new Vector2(edgeLength, edgeLength);

                subDivUvs[4 * offsetIndex + 0] = uvLeftBottom;
                subDivUvs[4 * offsetIndex + 1] = uvRightBottom;
                subDivUvs[4 * offsetIndex + 2] = uvLeftUp;
                subDivUvs[4 * offsetIndex + 3] = uvRightUp;

                subDivIndices[6 * offsetIndex + 0] = 4 * offsetIndex + 0;
                subDivIndices[6 * offsetIndex + 1] = 4 * offsetIndex + 3;
                subDivIndices[6 * offsetIndex + 2] = 4 * offsetIndex + 1;
                subDivIndices[6 * offsetIndex + 3] = 4 * offsetIndex + 3;
                subDivIndices[6 * offsetIndex + 4] = 4 * offsetIndex + 0;
                subDivIndices[6 * offsetIndex + 5] = 4 * offsetIndex + 2;
            }
        }

        var subDivMesh = new Mesh();
        subDivMesh.name = "subDivMesh";
        subDivMesh.SetVertices(subDivVerts);
        subDivMesh.SetTriangles(subDivIndices, 0);
        subDivMesh.SetUVs(0, subDivUvs);
        subDivMesh.RecalculateBounds();
        subDivMesh.RecalculateNormals();
        subDivMesh.RecalculateTangents();

結果

Unity:はじめてのAddressable Assets(AWS CloudFront に S3 バケット設定でCDNを有効化)

前書き

未公開の作業記事が形にならないまま積み重なっていますが、明確なゴールとして切れる課題が見つかったので今回の記事を書きます。

はじめての Addressable Assets と題して、AWS の CloudFront についてもはじめて触ってみて
Unity のアセットを CDN 経由でダウンロードして利用するところまで確認してみます。

季節に応じてタイトルの BGM を変えたり、キューブのマテリアルをリッチなものにブラッシュアップしたりと
ゲームをリリースした後もロジック以外の装飾部分を更新したくなることもあるかと思い、あらかじめ AssetBundle を利用する仕組みを入れて初回リリースしたいと考えています。

それを支えるコンテンツ配信方法の今を確認して Unity ゲームに取り入れてみることにしました。

AWS CloudFront のファイルダウンロードの確認

Wep ページのように何万人から閲覧されても大丈夫な仕組みが CDN です。(コンテンツ配信ネットワークのこと)
Amazon のサービスに CDN なら CloudFront を使えという教えがあります。(ただの宣伝)

用語として CloudFront 一つの単位をディストリビューション(配信)と呼ぶそうで、サービスの最初のアクションは配信の作成になります。
配信にはオリジナルとなるファイルが必要で、このオリジナル指定には Amazon S3 のシンプルストレージサービスを利用するのが定石のようす。

具体的な手順は S3 のバケットを作り
CloudFront の配信を作るときに S3 のバケットを指定し、Origin Access Identity の自動生成により世界から秘匿されている S3 にアクセスできる唯一の存在として配信を作ります。

詳細な手順はここに書いてあります。
dev.classmethod.jp

S3 のダウンロード url を指定すると CloudFront を経由して、手元にダウンロードができるようになりました。
これにて一件落着(CloudFront ってこんなに扱いが楽だったのか)

Addressable Assets の利用

基本は Haruma:K さんのこちらの記事シリーズを読むだけ
light11.hatenadiary.com

AssetBundle の対象にしたい Prefab や AudioClip にアドレスを振って、ディフォルトの Group に所属させます。
気になる AssetBundle のビルドを行うと次のグループの設定に示されている Build パスに .bundle ファイルが出力されます。

f:id:simplestar_tech:20200113213020p:plain
グループの設定

さらに
light11.hatenadiary.com
LocalLoadPathとRemoteLoadPathにはhttp://[PrivateIpAddress]:[HostingServicePort]と入力しておけば Hosting Service によりローカルでダウンロード込みの動作確認ができました。
(BuildPath の方も Local と Remote それぞれ LoadPath に合わせてビルドしておく必要がある様子、プロファイルを切り替えるだけではロード元が切り替わらなかったような…理解を妨げる現象だった)

本当の更新タイミングがまだ自信ないけど、プロファイルを作って切り替えるだけで、様々なビルド設定を試すことができたので、概要をつかむことができました。
AssetBundle ビルドした成果物を CloudFront からダウンロードできるように RemoteLoadPath のサーバー(S3)に配置するだけで、表題のはじめての Addressable Assets が確認できました。
CloudFront 経由で AssetBundle をダウンロードして、ゲーム内で AssetReferenceとして使うことができました。

キャッシュの保存先

再読み込みとかの試験で困ったので、記録だけしますね。
Windows 環境だと
C:\Users\yourname\AppData\LocalLow\Unity の下に DefaultCampanyName_ProductName というフォルダが作られて、そこに保存されます。
試しに RemotePath に不正な値を入れても、キャッシュがあるかぎり成功しました。
逆に、キャッシュをクリアするとだめになり、ちゃんと CloudFront から AssetBundle を CDN で引けていることを確認できました。

暗号化が必要?

製品に利用するなら暗号化も考えておかないと、ユーザーがテクスチャとか、次のリリース準備物を先取りしてしまうので、必要なのかなーと考えましたが
調べてみると、なんとも、Unity の Addressable Assets の恩恵が受けられなくなるとかで
暗号化はサポートされるまでは Addressable Assets を利用した暗号化なしのゲームにしておこうと思います。

まとめ

基本的に外部記事を調べて、はじめて触る場合でも困らない情報が示されていたこと
試すことで、以前から気になっていた CDN で AssetBundle を配信する手順が確認できてよかった。

これからAddressable のグループをどう定義するか考えていけばいいのかな?

「NORMALMAP ONLINE」ノーマルマップをノイズ画像から作る

前置き

水面の波を作ろうと思って、Normal Map 作成の壁?に当たったので、濃淡画像に sobel フィルターとか与えて法線マップ作るプログラムを書こうとしたのですけど
一般的な問題すぎるので、ネットに転がっているだろうと調べたら、Web アプリとして公開されていました。

NORMALMAP ONLINE

次の URL を開きます。
https://cpetry.github.io/NormalMap-Online/

次の画面になるので、左下の濃淡画像をクリックしてファイル選択 Download ボタンを押すと Normal Map が手に入りました。

f:id:simplestar_tech:20200105152742p:plain
作成画面

お金払おうとしたけど
日本円の PayPal だと寄付できないんだって…残念

こうやって記事を書いて貢献しますね

2019年を振り返る

1月

VRMとやらを触ったり、新しい InputSystem とかゲームの土台に導入してみたり、Unity でのゲーム作りについて勉強してました。

simplestar-tech.hatenablog.com

3月

AWS を Unity から直接さわるなど、オンラインゲームを作るためのインフラの調査とか始めてました。

simplestar-tech.hatenablog.com

4月

不足した情報をもとにオンラインゲームを構想してました。やりたいことを書いては、できないことを確認していた頃

simplestar-tech.hatenablog.com

5月

オンライン要素には二系統あって、ひとつは全員で一つの世界データの共有、もう一つは少数のプレイヤー間のアニメ同期です。
GW期間では Cy# さんの MagicOnion を使って少数プレイヤー間のアニメーション同期部分を試していました。

simplestar-tech.hatenablog.com

6月

ECS でメッシュ生成の土台作りに入ります。

simplestar-tech.hatenablog.com

7月

LWRP で VRM のシェーダーを生かしたかったのでシェーダーを作るなど

simplestar-tech.hatenablog.com

8月

全員で一つの世界データの共有するため、バックエンドとして PlayFab を調べ始めたのがこの頃

simplestar-tech.hatenablog.com

9月

少数プレイヤー間でアニメ同期する部分と世界データ共有の部分をつなぎ合わせ
マウスでクリックするとキューブが破壊されるなど、クライアントサイドだけで基本を揃え始めます

simplestar-tech.hatenablog.com

10月

まだ世界データをどういったデータベースに配置しようか悩んでますね。Azure に手を出してみたり

simplestar-tech.hatenablog.com

11月

ゲームに特化したバイナリキャッシュサーバーを作って一つの世界データを共有する話を解決します。

simplestar-tech.hatenablog.com


不正できないキューブ操作を考え始めました。

simplestar-tech.hatenablog.com

12月

構想した内容をだいたい形にしたところ

simplestar-tech.hatenablog.com

まとめ

オンライン要素を頑張って習得した一年だったのかな。

CubeWalk:ブロックの破壊と配置後にアイテムビューで更新結果が反映されている

構想の 5/7 ステップに来ました

毎回更新を見てくれているみんなにはなじみの構想記事
simplestar-tech.hatenablog.com

今回は前回 4.5 ステップとしてやるべきと示したブロックの破壊と配置によるアイテムスロットの操作と
その時に行われるリクエストの反映、オーバースロット時の挙動を載せておきます。

アイテムデータの初回取得

プレイヤーはゲームが始まってすぐにブロックを破壊したがるものですが、そのときに自身のアイテム情報をすべて知っておっく必要があります。
ゲーム開始時にアイテムを取得しておくことにします。

インベントリを開いたときは諸々ロックする

まずインゲームでは Ctrl キーを押したときにカメラ回転が固定され、マウスのカーソルが現れカーソル移動できるようになります
もう一度 Ctrl キーを押すと、マウス移動でカメラ回転が行われるようになり、マウスカーソルが消えます

インゲーム中は後者のマウスカーソルが出ていないときにのみ E キーでインベントリが表示されるようになり
Ctrl キー同様にカメラ回転が固定され、マウスカーソルが現れるようになります

Ctrl キーはこのとき押しても効果はありません

このあたりは InputModeSwitcher あたりに任せられないだろうか?

ゲームステートを StateMachine で

AnimatorController にアクセスすると現在のステートを知ることができる
ビジュアライズと遷移のロジックをこの AnimatorController に任せることはできないだろうか?

できる
別記事を書く

書いたよ
simplestar-tech.hatenablog.com

こちらをゲームに組み込むことにしました。

ステートの切り替え

ゲームロジックは上記のステートマシンの現在のステートによって切り替わるとして
具体的には

Explore モードのときに
R Ctrl キーを押すと、カメラ回転がロックされてカーソルが現れ、カーソルの先にあるキューブがもこもこします
もう一度 R Ctrl キーを押すか Esc キーを押すと Explore モードに戻ってきます(もこもこできなくなり、カーソルが消えて、カメラが自由回転する)

Explore モードのときに
T キーを押すと、テキストボックスが現れて、テキストを打ち込めます。(このときあらゆるキー操作が無効になりテキストの入力のみ行える)
Enter を押すか Esc キーを押すことで Explore モードに戻ってきます

Explore モードのときに
E キーを押すと、インベントリが現れ、カメラ回転がロックされて、カーソルが現れ、カーソルの先にあるスロットアイテムをホールドしてスロット移動できるようになります。
もう一度 Eキーを押すか、Esc キーを押すとインベントリが消え、カーソルが消え、カメラが自由回転できるようになり、Explore モードに戻ってきます

を実装して動作するか見てみます。

具体的な実装はこんな感じになりました

using Cinemachine;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.InputSystem;

/// <summary>
/// ゲーム内の入力モード・ステート管理
/// </summary>
public class InputModeStateMachine : MonoBehaviour
{
    #region SceneComponents
    [SerializeField] Transform vrmCharacters = null;
    [SerializeField] CinemachineFreeLook cinemachineFreeLook = null;
    [SerializeField] TextChatGUI textChatGUI = null;
    #endregion

    #region Assets
    [SerializeField] Texture2D cursorImage;
    #endregion

    /// <summary>
    /// 入力モード変更のイベント
    /// </summary>
    internal UnityAction<InputModeState, InputModeState> onChangeInputMode;

    /// <summary>
    /// 各種入力モードへのキーバインド
    /// </summary>
    public InputAction miningAction;
    public InputAction inventoryAction;
    public InputAction textChatAction;
    public InputAction escapeAction;

    void Start()
    {
        // ステートマシンを Layer 0 に持つ AnimatorController を持つ Animator を期待
        this.animator = GetComponent<Animator>();
        // 各ステート名は Enum 名と同じように作られていることを期待
        for (InputModeState stateEnum = InputModeState.Explore; stateEnum < InputModeState.Max; stateEnum++)
        {
            var fullPath = INPUT_STATE_LAYER_NAME + "." + stateEnum.ToString();
            var hash = Animator.StringToHash(fullPath);
            this.stateHashToEnum.Add(hash, stateEnum);
        }

        // ゲーム開始時にカメラ回転ロック解除
        this.LockFreeLook(lockFreeLook: false);
        // ゲームカーソルの設定
        this.SetCursorImage(this.cursorImage);

        // Explore ↔ Mining
        this.miningAction.performed +=
            ctx =>
            {
                // 現在のステート情報
                var stateInfo = this.animator.GetCurrentAnimatorStateInfo(INPUT_STATE_LAYER_INDEX);
                // ハッシュ値から Enum 値へマップ
                var currentState = this.stateHashToEnum[stateInfo.fullPathHash];
                switch (currentState)
                {
                    case InputModeState.Explore:
                        this.animator.SetTrigger(InputModeState.Mining.ToString());
                        this.LockUnlockCursor(true);
                        this.LockFreeLook(true);
                        this.onChangeInputMode?.Invoke(currentState, InputModeState.Mining);
                        break;
                    case InputModeState.Mining:
                        this.animator.SetTrigger(InputModeState.Explore.ToString());
                        this.LockUnlockCursor(false);
                        this.LockFreeLook(false);
                        this.onChangeInputMode?.Invoke(currentState, InputModeState.Explore);
                        break;
                    case InputModeState.TextChat:
                        break;
                    default:
                        break;
                }
            };

        // Explore ↔ Inventory
        this.inventoryAction.performed +=
            ctx =>
            {
                // 現在のステート情報
                var stateInfo = this.animator.GetCurrentAnimatorStateInfo(INPUT_STATE_LAYER_INDEX);
                // ハッシュ値から Enum 値へマップ
                var currentState = this.stateHashToEnum[stateInfo.fullPathHash];
                switch (currentState)
                {
                    case InputModeState.Explore:
                        this.animator.SetTrigger(InputModeState.Inventory.ToString());
                        this.LockUnlockCursor(true);
                        this.LockFreeLook(true);
                        this.onChangeInputMode?.Invoke(currentState, InputModeState.Inventory);
                        break;
                    case InputModeState.Inventory:
                        this.animator.SetTrigger(InputModeState.Explore.ToString());
                        this.LockUnlockCursor(false);
                        this.LockFreeLook(false);
                        this.onChangeInputMode?.Invoke(currentState, InputModeState.Explore);
                        break;
                    case InputModeState.TextChat:
                        break;
                    default:
                        break;
                }
            };

        // Explore ↔ TextChat
        this.textChatGUI.onEndEditMessage += OnEndEditMessage;
        this.textChatAction.performed +=
            ctx =>
            {
                // 現在のステート情報
                var stateInfo = this.animator.GetCurrentAnimatorStateInfo(INPUT_STATE_LAYER_INDEX);
                // ハッシュ値から Enum 値へマップ
                var currentState = this.stateHashToEnum[stateInfo.fullPathHash];
                switch (currentState)
                {
                    case InputModeState.Explore:
                        this.animator.SetTrigger(InputModeState.TextChat.ToString());
                        this.LockUnlockCursor(true);
                        this.onChangeInputMode?.Invoke(currentState, InputModeState.TextChat);
                        break;
                    case InputModeState.TextChat:
                        break;
                    default:
                        break;
                }
            };

        // Any → Explore
        this.escapeAction.performed +=
            ctx =>
            {
                // 現在のステート情報
                var stateInfo = this.animator.GetCurrentAnimatorStateInfo(INPUT_STATE_LAYER_INDEX);
                // ハッシュ値から Enum 値へマップ
                var currentState = this.stateHashToEnum[stateInfo.fullPathHash];
                switch (currentState)
                {
                    case InputModeState.Explore:
                    case InputModeState.TextChat:
                        break;
                    default:
                        this.animator.SetTrigger(InputModeState.Explore.ToString());
                        this.LockUnlockCursor(false);
                        this.LockFreeLook(false);
                        this.onChangeInputMode?.Invoke(currentState, InputModeState.Explore);
                        break;
                }
            };
    }

    /// <summary>
    /// テキスト入力完了
    /// </summary>
    private void OnEndEditMessage(string text)
    {
        this.animator.SetTrigger(InputModeState.Explore.ToString());
        this.LockUnlockCursor(false);
        this.onChangeInputMode?.Invoke(InputModeState.TextChat, InputModeState.Explore);
    }

    public void OnEnable()
    {
        this.miningAction.Enable();
        this.inventoryAction.Enable();
        this.textChatAction.Enable();
        this.escapeAction.Enable();
    }

    public void OnDisable()
    {
        this.miningAction.Disable();
        this.inventoryAction.Disable();
        this.textChatAction.Disable();
        this.escapeAction.Disable();
    }

    /// <summary>
    /// カーソルのロック
    /// </summary>
    /// <param name="lockCursor">ロックするときは false</param>
    void LockUnlockCursor(bool lockCursor)
    {
        var vInput = this.vrmCharacters.GetComponentInChildren<Invector.vCharacterController.vThirdPersonInput>();
        if (null != vInput)
        {
            // カーソル再表示とカーソルロック解除
            vInput.ShowCursor(lockCursor);
            vInput.LockCursor(lockCursor);
            if (!lockCursor)
            {
                // ゲームカーソルの再設定
                this.SetCursorImage(this.cursorImage);
            }
        }
    }

    /// <summary>
    /// Free Look カメラのロック
    /// </summary>
    /// <param name="lockFreeLook">ロックするときは true </param>
    void LockFreeLook(bool lockFreeLook)
    {
        var axisNameX = "";
        var axisNameY = "";
        if (lockFreeLook)
        {
            this.cinemachineFreeLook.m_XAxis.m_InputAxisValue = 0;
            this.cinemachineFreeLook.m_YAxis.m_InputAxisValue = 0;
        }
        else
        {
            if (null != Gamepad.current)
            {
                axisNameX = "RightAnalogHorizontal";
                axisNameY = "RightAnalogVertical";
            }
            else
            {
                axisNameX = "Mouse X";
                axisNameY = "Mouse Y";
            }
        }
        this.cinemachineFreeLook.m_XAxis.m_InputAxisName = axisNameX;
        this.cinemachineFreeLook.m_YAxis.m_InputAxisName = axisNameY;
    }

    void SetCursorImage(Texture2D cursorTexture)
    {
        // TextureScale.Bilinear(cursorTexture, 16, 16);
        Vector2 hotspot = new Vector2(0.293f, 0.078f) * 64;
        // カーソルの画像を Texture に設定
        Cursor.SetCursor(cursorTexture, hotspot, CursorMode.ForceSoftware);
    }

    /// <summary>
    /// ステート名
    /// </summary>
    public enum InputModeState
    {
        Explore = 0,
        Inventory,
        Mining,
        TextChat,

        Max
    }

    // デバッグ用
    InputModeState nextState = InputModeState.Inventory;
    /// <summary>
    /// ステート名HashからステートEnumへのマップ
    /// </summary>
    Dictionary<int, InputModeState> stateHashToEnum = new Dictionary<int, InputModeState>();
    /// <summary>
    /// レイヤー名(fullpath でプリフィックスに利用)
    /// </summary>
    const string INPUT_STATE_LAYER_NAME = "InputStateLayer";
    /// <summary>
    /// ステートマシンのレイヤーインデックス
    /// </summary>
    const int INPUT_STATE_LAYER_INDEX = 0;
    /// <summary>
    /// ステートマシンのコントローラを持つアニメータ
    /// </summary>
    Animator animator;
}

実際動かしてみると、TextChat の方、いつのまにか Canvas のモードを変えてたから位置計算がおかしくて表示されなくなっている
あんあに苦労したのに… ScreenSpace Camera なら実際の配置場所に置けるはずなので、調整してみます。

テキストバルーンの再配置

ChatBalloonLocater クラスについて覚えているだろうか
simplestar-tech.hatenablog.com
すでに6カ月ほど経過しているので、すっかり忘れてしまっていますが…

作る手順はここ
simplestar-tech.hatenablog.com

読んでわかったことは、座標系が px 単位の画面上の配置だということ
テキストに気づけるように画面内に収めようと頑張っていますが、今回もこれができるかは不明

最終的にこうなりました…
今までの苦労が消えました すっきり

using UnityEngine;

/// <summary>
/// キャラの頭の上の吹き出し位置を毎フレーム更新
/// </summary>
public class ChatBalloonLocater : MonoBehaviour
{
    #region UI Connection
    internal Transform balloonTransform;
    #endregion

    void OnGUI()
    {
        if (null == this.balloonTransform || !this.balloonTransform.gameObject.activeSelf)
        {
            return;
        }

        this.balloonTransform.position = this.transform.position;
    }
}

現在の動き

まだ本題に入る前の準備工程ですが、いろいろと整理され始めたので、変化した現在のゲームの様子をご覧ください

ブロックを破壊するとスロットの数が増える

というか、スロットに数を表示してないので、まずはそこから行います。

slot の子供に image を配置して TextMeshPro UI をセットしてアウトラインを引いてみた

f:id:simplestar_tech:20191219235505p:plain
アイテム数の表示

ViewSlot クラスを定義して、そこに PlayFab の ItemInstance クラスと、slotIndex, count を詰められるようにします。
これを次のようにスロット index 指定で最新状態にできるようにします。

        /// <summary>
        /// Slot 指定でビューを最新の状態に更新
        /// </summary>
        /// <param name="slotIndex">対象スロットインデックス</param>
        private void UpdateSlotView(int slotIndex)
        {
            // 保存する CustomData にスロット変更を反映
            if (null != this.items[slotIndex])
            {
                this.items[slotIndex].slotIndex = slotIndex;
            }

            // スロットの CubeObject を現在の状態となるように更新
            Destroy(this.slotCubes[slotIndex]);
            var parent = this.slotTransforms[slotIndex];
            var textMeshPro = parent.GetComponentInChildren<TextMeshProUGUI>();
            if (null != textMeshPro)
            {
                textMeshPro.text = "";
            }
            var cubeObject = this.CreateSlotCubeObject(this.items[slotIndex]);
            if (null != cubeObject)
            {
                cubeObject.transform.SetParent(parent);
                cubeObject.transform.localPosition = Vector3.zero;
                cubeObject.transform.localRotation = Quaternion.identity;
                cubeObject.transform.localScale = new Vector3(50, 50, 1);
                if (null != textMeshPro)
                {
                    textMeshPro.text = this.items[slotIndex].count.ToString();
                }
            }
            this.slotCubes[slotIndex] = cubeObject;

            // インベントリ操作完了時にリクエストする編集したアイテムを記録
            if (null != this.items[slotIndex] && null != this.items[slotIndex].item && !this.editItems.ContainsKey(this.items[slotIndex].item.ItemInstanceId))
            {
                this.editItems.Add(this.items[slotIndex].item.ItemInstanceId, this.items[slotIndex]);
            }
        }

Grid Layout で自動で敷き詰められるタイルに Button コンポーネントを追加すれば、クリックイベントが取れるようになりますので
そこに次のロジックを書けば、クリックでアイテムをホールドしつつ、その操作によってアイテムの数が見えるようになります。

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

ブロックを破壊するとスロットの数が増える

どうやって?

インベントリの数は増えているので、そいつの CustomData にある slot ~を見つけ出して
最も若いスロットにて 99x2 に到達していなかったら + 2 して
到達していたら、次のスロットに加算する感じ

サーバーの更新回数は極力減らしたい…

そこで、数が合わなかった時
最も若いスロットに余剰数を振り分けるのはどうか

それもダメ

毎回リクエストを投げる必要がある
クライアントから
サーバーは数が合えば ok とする

となると、破壊時に
アイテムID と重なるアイテムのインスタンスIDを取り出し、CustomData を編集する

ホットバーの作り方が見えてきた

今のアイテムの一番若い 0~8 インデックスのプロキシとして、非インベントリ時に UI として表示できればいい
そこがいっぱいになったら、アイテムボックスに入れないという処理が簡単になるアイディア

items これをいじれれば…いや、基本は次のコードでいけると思う

                // スロット情報を持たないアイテムを空いているスロットに格納
                foreach (var item in noSlotItems)
                {
                    List<ViewItem> viewItemList = new List<ViewItem>();
                    for (int slotIndex = 0; slotIndex < this.items.Length; slotIndex++)
                    {
                        if (null == this.items[slotIndex] && 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.items[slotIndex] = new ViewItem { slotIndex = slotIndex, item = item, count = slotMaxPrismCount };
                                    viewItemList.Add(this.items[slotIndex]);
                                    for (; slotIndex < this.items.Length; slotIndex++)
                                    {
                                        if (null == this.items[slotIndex])
                                        {
                                            break;
                                        }
                                    }
                                }
                                else if (0 < ((int)item.RemainingUses - overSlot * slotMaxPrismCount))
                                {
                                    this.items[slotIndex] = new ViewItem { slotIndex = slotIndex, item = item, count = (int)item.RemainingUses - overSlot * slotMaxPrismCount };
                                    viewItemList.Add(this.items[slotIndex]);
                                    this.UpdateSlotView(slotIndex);
                                }
                            }
                            break;
                        }
                    }
                    // 変更を加えたアイテムの CustomData の slot~追加リクエストを行う
                    if (0 < viewItemList.Count)
                    {
                        this.UpdateUserInventoryItemCustomData(item, viewItemList);
                    }
                }

加えて以下のロジックが原型になりそう

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 };
                                this.UpdateSlotView(slotIndex);
                                totalSlotCount += itemCount;
                                hasSlot = true;
                            }
                        }

そうか、すでにある CustomData の最も若い番号に加算する
そしてそれをリクエストする ですね

CustomData を持たない場合は、適当に若い空きスロットにアイテムを置く
CustomData に登録する
そしてそれをリクエストする

リクエストはいつ?

world データを変更したとき、つまり掘削時に次のように対象 Cube を Air に変更しようとします。

        // 対象キューブを空気に
        var chunkInt3 = this.chunkCollider.gameObject.GetComponent<ChunkMeshInfo>().chunkInt3;
        this.cubeDataWorld.SetCubeData(this.cubeCenter, chunkInt3, CubeCategoryType.Basic, CubeRotationType.Top000, SideType.Air, SideType.Air);

成功するとサーバー側で Inventory の値を更新するはずなので、その成功時にインベントリを変更すると良さそう
リクエストは具体的には、次のようにして行われていた

            // バイナリキャッシュサーバーへキューブアクションをリクエスト
            var microWorldCubeDataByteOffset = this.GetMicroWorldCubeDataByteOffset(microWorldKey, chunkKeyXYZ, cubeIndex);
            PlayFabCloudScript.RequestCubeAction(microWorldName, 1, microWorldCubeDataByteOffset, (byte)category, (byte)rotationType, (byte)sideTypeA, (byte)sideTypeB, null );

あ、あと、ユーザーからのアイテムリクエストを信じてはならない(無限増殖できてしまうので)

ということで、そのあたりを別記事に書きました。
simplestar-tech.hatenablog.com

連続リクエス

サーバー側で、前回のユーザーの呼び出し時刻を記録して、チートであるかを判定することができるとのこと
これについては、ここで別記事を書いてみた。
simplestar-tech.hatenablog.com

まとめ

話題としては
ブロックの破壊と配置によるアイテムスロットの操作と
その時に行われるリクエストの反映、オーバースロット時の挙動
だったのですが、思いのほか悩んで、別記事にいくつも逃がす形で 5/7 ステップを終えました。

CubeWalk:スロット番号をリクエストするとアイテムが増えたり減ったり

前置き

ゲームに登場する世界データは全プレイヤー間で一つ
配置場所を表すインデックスでアクセスし、データは 32 bit のキューブ情報の集合体です。
クライアントから直接この世界データにアクセスするにはインデックス指定は必須だと思いますが、配置するときにいずれかのキューブであることを表す情報を渡すのはいかがなものでしょうか?

そう、無限増殖とか、希少なキューブタイプを不正にリクエストすれば簡単に増やすことができてしまいます。
これは許せないですね。

f:id:simplestar_tech:20191229215957p:plain
ホットバーのアイテムを配置している様子(今回はこの仕組みを作ります)

リクエストにキューブのタイプを含めない

最近作られてきているインベントリ操作まわり
simplestar-tech.hatenablog.com


アイテムの CustomData に slotXX とカウントという情報が入るようになったので
クライアントからは、どのスロットから sideA, sideB を取り出すのか、そのスロット番号を与えれば良いことに気づけた

ということで、サーバーでスロット番号からアイテムを特定するロジックを組んでみることにしました。

スロットにあるアイテムを特定するには

とにかく配置したいキューブが収められているスロットを選択できないと話にならないので、先に UI としてホットバーを作りました。
simplestar-tech.hatenablog.com

現在のリクエスト内容は?

次の実装の通り

            // バイナリキャッシュサーバーへキューブアクションをリクエスト
            var microWorldCubeDataByteOffset = this.GetMicroWorldCubeDataByteOffset(microWorldKey, chunkKeyXYZ, cubeIndex);
            PlayFabCloudScript.RequestCubeAction(microWorldName, 1, microWorldCubeDataByteOffset, (byte)category, (byte)rotationType, (byte)sideTypeA, (byte)sideTypeB, requestResult =>
            {
                Debug.Log($"requestResult.status = {requestResult.status} message = {requestResult.message}");
            });

rotationType までは送る必要があります(まぁ、回転情報を知っているのはクライアントだけなので仕方なし)
sideTypeA,B を送るところをアクティブな Slot 番号にするのはどうか

現在のアクティブスロット番号

取れない。
ということで取れるように例のアクティブスロット更新イベントを購読させます。

CubeLocator にて次の実装を書いて、常に現在のアクティブスロットインデックスを同期して持たせます。

            // アクティブスロット変更イベントを購読
            this.itemInventory.onChangeActiveSlot += this.OnChangeActiveSlot;
        }

        /// <summary>
        /// インベントリのアクティブスロット変更イベント
        /// </summary>
        /// <param name="activeViewItem">新しいアイテム情報</param>
        private void OnChangeActiveSlot(ViewItem activeViewItem)
        {
             this.activeSlotIndex = activeViewItem.slotIndex;
        }

あとはこれをリクエストにつなげるだけ

    /// <summary>
    /// キューブアクションをリクエスト
    /// </summary>
    public static void RequestCubeAction(string microWorldIndex, int cubeAction, int byteOffset, byte category, byte rotation, byte sideA, byte sideB, int activeSlotIndex, UnityAction<RequestCubeDataResult> callback)
    {
        ExecuteCloudScript<RequestCubeDataResult>("requestCubeData", new
        {
            index = microWorldIndex,
            action = cubeAction,
            offset = byteOffset,
            category,
            rotation,
            sideA,
            sideB,
            slot = activeSlotIndex
        },
            successResult =>
        {
            callback?.Invoke(successResult);
        });
    }

空のキューブを配置するときは slot に -1 を詰めてもらうことにしました。

CloudScript 側で slot から Cube が取れるか?

最後にしてしまいましたが、最も重要な処理を書くことにします。
サーバー処理が重たくなりますが、これも無限増殖というチートを防ぐため、受け入れましょう。

取れることが確認できたコードがこちらです。

  // get slot item
  let item = null;
  let remainingUses = 0;
  let slotRemainingUses = 0;
  const playerInventory = server.GetUserInventory({
    PlayFabId: currentPlayerId
  });
  const length = playerInventory.Inventory.length;
  for (let i = 0; i < length; i++) {
    item = playerInventory.Inventory[i];
    if (null != item.CustomData) {
      const pattern = /slot([0-9]{2})$/;
      for (let key in item.CustomData) {
        const match = key.match(pattern);
        if (null != match && match[1] == args.slot) {
          slotRemainingUses = item.CustomData[key];
          break;
        }
      }
      if (0 < slotRemainingUses) {
        remainingUses = item.RemainingUses;
        break;
      }
    }
  }

入力を厳しくチェック

クライアントからのリクエストがサーバーのインベントリの情報とすべてマッチしていることを確認します。
本当はクライアントのロジックで不正なリクエストは絶対発生しないように作ります。
それでも改竄してアイテム増殖を行おうという人がこのロジックで弾かれて、バイナリキャッシュサーバーまで処理が届けられなくなります。

  // validate slot
  if (0 != args.action && -1 != args.slot) {
    // get slot item
    let item = null;
    let slotRemainingUses = 0;
    const playerInventory = server.GetUserInventory({
      PlayFabId: currentPlayerId
    });
    const length = playerInventory.Inventory.length;
    for (let i = 0; i < length; i++) {
      item = playerInventory.Inventory[i];
      if (null != item.CustomData) {
        const pattern = /slot([0-9]{2})$/;
        for (let key in item.CustomData) {
          const match = key.match(pattern);
          if (null != match && match[1] == args.slot) {
            slotRemainingUses = item.CustomData[key];
            break;
          }
        }
        if (0 < slotRemainingUses) {
          break;
        }
      }
    }

    // check slot remaining
    if (0 <= args.slot && 0 == slotRemainingUses) {
      return JSON.stringify({
        status: 402,
        message: "empty slot"
      });
    }

    // check match input side types
    let uses = 0;
    if (0 != args.sideA) {
      if (args.sideA != item.ItemId) {
        return JSON.stringify({
          status: 403,
          message:
            "required itemId: " + args.sideA + " but ItemId: " + item.ItemId
        });
      }
      uses++;
    }
    if (0 != args.sideB) {
      if (args.sideB != item.ItemId) {
        return JSON.stringify({
          status: 404,
          message:
            "required itemId: " + args.sideB + " but ItemId: " + item.ItemId
        });
      }
      uses++;
    }

    // check remaining
    if (uses > slotRemainingUses) {
      return JSON.stringify({
        status: 405,
        message:
          "required remainging uses: " +
          uses +
          " but slotRemainingUses: " +
          slotRemainingUses
      });
    }
  }

一応、クライアント改竄による不正にアイテムを増やそうとする対応をわざと入れてみましたが、弾かれました。
f:id:simplestar_tech:20191229104922p:plain
うまくチェック機構が機能しているみたい

サーバー側でスロットのアイテムが減る

まずは配置に成功したら、uses の数だけ CustomData からカウントを減らします。
チェック機構で必ず残数があることを確かめているので、ここは単純に減らすだけ

加えてアイテム本体の数を減らす必要があります。

PlayFab の API
api.playfab.com

調べると次の通り

  // if cube updated, grant cube items
  if (1 == args.action && 201 == response.status) {
    // grant replaced item
    let grants = [];
    if (0 != response.sideA) {
      grants.push(response.sideA);
    }
    if (0 != response.sideB) {
      grants.push(response.sideB);
    }
    if (0 < grants.length) {
      server.GrantItemsToUser({
        PlayFabId: currentPlayerId,
        CatalogVersion: "cube",
        ItemIds: grants
      });
    }
    // reduce item of use
    if (null != item && 0 < uses) {
      const reduceCount = -uses;
      server.ModifyItemUses({
        PlayFabId: currentPlayerId,
        ItemInstanceId: item.ItemInstanceId,
        UsesToAdd: reduceCount
      });
    }
  }

サーバー側でスロット内のアイテム数が減る

先ほどの例はアイテム自体のカウントの消費ですが、これから行うのは CustomData 内のカウントの消費です。
スロットのアイテムを消費するとなると、こうですかね(期待通り、スロット番号が減っていきました)

// reduce item of use
    if (null != item && 0 < uses) {
      const reduceCount = -uses;
      server.ModifyItemUses({
        PlayFabId: currentPlayerId,
        ItemInstanceId: item.ItemInstanceId,
        UsesToAdd: reduceCount
      });

      // modify slot count
      if (null != matchecKey) {
        const nextSlotRemainUses = slotRemainingUses - uses;
        if (0 < nextSlotRemainUses) {
          const data = {
            [matchecKey]: nextSlotRemainUses
          };
          server.UpdateUserInventoryItemCustomData({
            PlayFabId: currentPlayerId,
            ItemInstanceId: item.ItemInstanceId,
            Data: data
          });
        } else {
          server.UpdateUserInventoryItemCustomData({
            PlayFabId: currentPlayerId,
            ItemInstanceId: item.ItemInstanceId,
            KeysToRemove: [matchecKey]
          });
        }
      }

最後 2個一緒に減らしたときにインベントリからアイテム自体が消えたので、成功ですね。
PlayFab の管理メニューから確認できました。

現在のクライアントの残り作業

いま rock アイテム固定になっているので、スロットのアイテムで上書きします。

           // 対象キューブ面にインベントリのアクティブスロットの値を設定
            var sideTypeA = SideType.Air;
            var sideTypeB = SideType.Air;
            var activeSlotType = 0;
            if (null != itemInventory.ActiveSlotItem)
            {
                var itemInstance = itemInventory.ActiveSlotItem.item;
                if (null != itemInstance)
                {
                    if (int.TryParse(itemInstance.ItemId, out int itemId))
                    {
                        activeSlotType = itemId;
                    }
                }
            }
            switch (this.locateMode)
            {
                case LocateMode.Cube:
                    sideTypeA = (SideType)activeSlotType;
                    sideTypeB = (SideType)activeSlotType;
                    break;
                case LocateMode.SideA:
                    sideTypeA = (SideType)activeSlotType;
                    break;
                case LocateMode.SideB:
                    sideTypeB = (SideType)activeSlotType;
                    break;
                default:
                    break;
            }

これでいけるはず

クライアントのロジックはそのほかにも削除に成功したなら、表示しているスロット内容も変更しないといけない

こんなかんじかな

        /// <summary>
        /// 世界のキューブデータ更新結果
        /// </summary>
        private void OnResponseCubeData(int lastSlotIndex, SideType sideTypeA, SideType sideTypeB, RequestCubeDataResult requestResult)
        {
            if (-1 != lastSlotIndex)
            {
                // 指定スロットにて A, B を消費した通知
                var lastItem = this.items[lastSlotIndex];
                if (null != lastItem)
                {
                    var lastCount = lastItem.count;
                    if (SideType.Air != sideTypeA)
                    {
                        lastCount--;
                    }
                    if (SideType.Air != sideTypeB)
                    {
                        lastCount--;
                    }
                    // カウントを減らして該当スロットの表示内容を更新
                    lastItem.count = lastCount;
                    this.UpdateSlotView(lastSlotIndex);
                }
            }
            else
            {
                // 単なるキューブの取得のケース

                // どこに置けるのか?探す

            }
        }

あと数字が残るバグもあったので直します。
ほか、スロット変更は mining モード時も有効になっていてほしい→対応

サーバー側で slot カウントも増やさなければならない

これが大変なケースがあって、例えばアイテムスロットに空きがあって、そこにアイテムが追加されてほしいじゃないですか
でもそういう計算をサーバーで行おうとすると、すべてのアイテムのすべての CustomData にある slotXX の XX の部分を総なめして
空きがある最小の値を見つけ出し、ここに置くと宣言する…という計算を走らせなければならない

それはやめようと思う
やめると困るのは、新しいキューブを手に入れたとき
サーバーで適切なスロットに格納されないから、ローカルで頑張らなければならない

そこで、サーバーで大変なケースになっていることはわかるので、その時だけクライアント側で計算してもらい
正しいスロット番号状態をリクエストしてもらうのはどうだろう

そうしよう、ということでサーバー側は簡易な以下のコードでスロット内の数を加算することにしました。
動作確認済みです。

    // grant replaced item
    let grants = [];
    if (0 != response.sideA) {
      grants.push(response.sideA);
    }
    if (0 != response.sideB) {
      grants.push(response.sideB);
    }
    if (0 < grants.length) {
      server.GrantItemsToUser({
        PlayFabId: currentPlayerId,
        CatalogVersion: "cube",
        ItemIds: grants
      });
      // grant item loop
      for (let itemId of grants) {
        let addSlotIndex = 1000;
        let addSlotKey = null;
        let addSlotCount = 99 * 2;
        let addSlotItem = null;
        // inventory item loop
        const playerInventory = server.GetUserInventory({
          PlayFabId: currentPlayerId
        });
        for (let slotItem of playerInventory.Inventory) {
          if (itemId != slotItem.ItemId) {
            // check item id is same
            continue;
          }
          if (null != slotItem.CustomData) {
            const pattern = /slot([0-9]{2})$/;
            for (let key in slotItem.CustomData) {
              const match = key.match(pattern);
              // has slot CustomData
              if (null != match && 99 * 2 > slotItem.CustomData[key]) {
                // has space of add
                if (addSlotIndex > match[1]) {
                  // lower index
                  addSlotIndex = match[1];
                  addSlotKey = key;
                  addSlotCount = Number(slotItem.CustomData[key]);
                  addSlotItem = slotItem;
                }
              }
            }
          }
        }
        if (
          null != addSlotKey &&
          1000 > addSlotIndex &&
          99 * 2 > addSlotCount &&
          null != addSlotItem
        ) {
          // add slot count
          const newCount = addSlotCount + 1;
          server.UpdateUserInventoryItemCustomData({
            PlayFabId: currentPlayerId,
            ItemInstanceId: addSlotItem.ItemInstanceId,
            Data: {
              [addSlotKey]: newCount
            }
          });
        }
      }
    }

最後にクライアント側でのアイテムの増加

サーバー側で行われたアイテム増減の結果は返ってきませんので
クライアント側で、サーバーの状態を類推して合わせてあげる必要があります。

       /// <summary>
        /// 世界のキューブデータ更新結果
        /// </summary>
        private void OnResponseCubeData(int lastSlotIndex, SideType sideTypeA, SideType sideTypeB, RequestCubeDataResult requestResult)
        {
            if (201 != requestResult.status)
            {
                // キャッシュサーバーに変更がなかったのでアイテム個数修正を無視
                return;
            }
            if (-1 != lastSlotIndex)
            {
                // 指定スロットを消費した通知と考えられる
                var lastItem = this.items[lastSlotIndex];
                if (null != lastItem)
                {
                    var lastCount = lastItem.count;
                    if (SideType.Air != sideTypeA)
                    {
                        lastCount--;
                    }
                    if (SideType.Air != sideTypeB)
                    {
                        lastCount--;
                    }
                    // カウントを減らして該当スロットの表示内容を更新
                    lastItem.count = lastCount;
                    if (0 >= lastItem.count)
                    {
                        // アイテムを使い切ったので情報を削除
                        this.items[lastSlotIndex] = null;
                    }
                    this.UpdateSlotView(lastSlotIndex);
                }
            }
            else
            {
                // 掘削によるキューブ取得
                List<int> grants = new List<int>();
                if (0 != requestResult.sideA)
                {
                    grants.Add(requestResult.sideA);
                }
                if (0 != requestResult.sideB)
                {
                    grants.Add(requestResult.sideB);
                }
                foreach (var grantItemId in grants)
                {
                    var reloadFlag = true;
                    for (int slotIndex = 0; slotIndex < MAX_SLOT_INDEX - 1; slotIndex++)
                    {
                        var item = this.items[slotIndex];
                        if (null == item)
                        {
                            continue;
                        }
                        if (null == item.item)
                        {
                            continue;
                        }
                        if (int.TryParse(item.item.ItemId, out int itemId))
                        {
                            if (itemId == grantItemId)
                            {
                                // 同じアイテムの最も若いスロット番号
                                if (99 * 2 > item.count)
                                {
                                    // 空きがあるならカウントを一つ増やす
                                    var newCount = item.count;
                                    newCount++;
                                    item.count = newCount;
                                    this.UpdateSlotView(slotIndex);
                                    reloadFlag = false;
                                    break;
                                }
                            }
                        }
                    }
                    if (reloadFlag)
                    {
                        // どこにも同じアイテムで空きスロットがないので、最新情報を取得してインベントリを初期化
                        this.OnOpenInventory();
                    }
                }
            }
        }

まとめ

上記の工程を経て、タイミングが良ければ…一見してアイテムを消費したり、掘削してキューブを取得したりができていそう
ここからデバッグしていきます。

ともかく、クライアントから送られてくるアイテム情報を利用せず、スロット番号だけを頼りにサーバーにあるインベントリ情報から世界データを更新
安全にアイテムの利用、キューブの破壊ができているようです。

現在のゲームプレイの様子

CubeWalk:ホットバーUIの作成

前置き

だんだん作るものが具体的になってきました。
現在はキューブを配置するときのためにホットバーが必要です。
インベントリ操作は Inventory モードに入ったときで、そのときにはホットバーが不要
それ以外の Explore モードではホットバーが必要です。

ホットバーとは、インベントリ表示のときに現れる一段目の 0 ~ 8 スロットを指します。

こんなの想像してます。

f:id:simplestar_tech:20191229001917p:plain
ホットバーのイメージ

とりあえず複製

インベントリの 1段目ならば、それを複製してしまえばよいです。

入力モード切替で表示と非表示

実装から察するに、次のイベントを購読することになるはず
inputModeStateMachine.onChangeInputMode

よく見ていくと実は違っていて、すでに Inventory に関するクラスが、インベントリを開くときと閉じるときでイベントを発行しています。
これを購読することにしました。

            this.itemInventory.onOpenInventory += OnOpenInventory;
            this.itemInventory.onCloseInventory += OnCloseInventory;
        }

        private void OnOpenInventory()
        {
            this.panelHotbar.gameObject.SetActive(false);
        }

        private void OnCloseInventory()
        {
            this.panelHotbar.gameObject.SetActive(true);
        }

インベントリのスロット状態変更と連動

インベントリ側がスロット状態の変更をイベント発行してくれたら、これを購読したいなぁと思います。
そうすれば、ホットバーの内容も連動して切り替わっていけそう

もっと、簡略化方法を思いついた
ホットバー作成とインベントリ作成を連動しておいて、それぞれ panel を分けておく
データ上は一緒にするというもの

言葉ではどういったイメージなのか難しいのでコードにしてみるとこう!

        void Start()
        {
            // パネルに並ぶslot名からTransform配列を作成(Hotbar用)
            this.RetrieveSlotTransforms(this.panelHotbar);
            // パネルに並ぶslot名からTransform配列を作成(Inventory用)
            this.RetrieveSlotTransforms(this.panelInventory);
        }

        /// <summary>
        /// パネルに並ぶslot名からTransform配列を作成
        /// </summary>
        private void RetrieveSlotTransforms(Transform panelTransform)
        {
            foreach (Transform slotImage in panelTransform)
            {
                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); });
                        }
                    }
                }
            }
        }

これで完全同期となりました

アクティブなスロットを切り替えたい

いざ Minging モードでキューブを配置したとき、いったいどのキューブを置くのか?
そもそもキューブアイテムを選択していないときに置くという操作はできないはずだが

これを外部処理にどう伝えるのか

単にインベントリクラスにGetItemを作っておけば外部の人たちは困らないはず

        /// <summary>
        /// インベントリで現在アクティブとしているスロットのアイテム
        /// </summary>
        internal ViewItem ActiveSlotItem { get; private set; }

ところで、誰がどうやってこの ActiveSlotItem を決定するかだが…
Inventory クラスに行わせてしまいましょう。

ユーザの入力は?
マインクラフトだと確か
マウスホイールを回すと、画面最下部のホットバーでアイテムが選択されます。ツルハシと剣を持ち替えるときなどに使います。

1~9キー
各数字キーを押すと、ホットバー(アイテムスロット)のアイテムを選択できます。ホットバー左端のマスが1で、右端が9です

うーん、数字キーは今のところいらないかな
となると次の実装でクリア

            // マウスホイールでアクティブスロットを移動
            this.selectSlotAction.performed +=
            ctx =>
            {
                this.activeSlotIndex += (int)Mathf.Sign(ctx.ReadValue<float>());
                this.activeSlotIndex = this.activeSlotIndex % 9;
                if (0 > this.activeSlotIndex)
                {
                    this.activeSlotIndex += 9;
                }
            };

アクティブであることを表示するために画像を用意してこんなコードで見た目を調整

            // マウスホイールでアクティブスロットを移動
            this.selectSlotAction.performed +=
            ctx =>
            {
                this.activeSlotIndex -= (int)Mathf.Sign(ctx.ReadValue<float>());
                this.activeSlotIndex = this.activeSlotIndex % 9;
                if (0 > this.activeSlotIndex)
                {
                    this.activeSlotIndex += 9;
                }
                this.ChangeActiveSlot(this.activeSlotIndex);
            };


            // 初回起動時にデータは取得しておく
            this.OnOpenInventory();

            // ディフォルトのアクティブスロットの表示
            this.ChangeActiveSlot(this.activeSlotIndex);
        }

        /// <summary>
        /// アクティブスロット変更
        /// </summary>
        private void ChangeActiveSlot(int nextSlotIndex)
        {
            // 実際に表示している枠を移動
            this.imageActiveFrame.SetParent(this.slotTransforms[nextSlotIndex]);
            this.imageActiveFrame.localPosition = Vector3.zero;
            this.ActiveSlotItem = this.items[nextSlotIndex];
            // 外部にアクティブなアイテム変更を通知
            this.onChangeActiveSlot?.Invoke(this.ActiveSlotItem);
        }

これによって、こんなことができるようになりました。

まとめ

要するに見た目がこういうやつを紆余曲折しながら形にしたという話でした