simplestarの技術ブログ

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

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);
        }

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

まとめ

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

PlayFab:CloudScriptの実行頻度からクライアントを改竄したリクエストを無視する

前置き

自作ゲームの PlayFab におけるチート行為防止の話です。

f:id:simplestar_tech:20191227192113p:plain
現在の見た目(ここのところ変わってませんね)

キューブで構成される世界データをたった一つのサーバーに格納しています。
simplestar-tech.hatenablog.com

今のところユーザーからのリクエストはほぼすべて信用して処理しているのでかなり危険なつくりとなっています。
最近ゲームの仕様として、最も優れたゲーム内ツールを使ったとしても、一秒はブロックの破壊に要することに決めました。

となると、あるユーザーがキューブに対するリクエストを連続で行う場合に、一秒より短いリクエストをしてきた場合
これはクライアント改竄にほかなりません(またはコンピュータ内の時間の流れを速めているチート行為かもしれません)

別に最強すぎるピッケルとか作ってゲーム内で効率的に地面を掘ってもらってもよいのですが、データを集約しているサーバーにアクセスが集中してしまうのは
ほかのお客さんに対して不利益が生じてしまいます。アクセスが遅くなったり、貴重なアイテムの争奪戦で常に負かされてしまったりと

そこで、PlayFab の CloudScript でそういう高頻度アクセスの対処方法って何がベストなのかな~と調べたのが始まりでした。

UserInternalData に前回のリクエスト時刻を打つ

見つけたやり取りがこちら

どうやら UserInternalData にユーザーごとの情報を格納して、なんとか検知してみるといいとのことです。
自分は UserInternalData に LastRequestTime を記入して、次のリクエスト時にこれを参照して、ちゃんと 1秒以上経っていることを確認してからリクエストを処理し始めることにしました。
実験して気づけましたが、for文などでほぼ同時にリクエストすると、InternalData に書き込み終える前にリクエストを並列で処理し始めてしまいます。
そんなときのために DataVersion が確実にインクリメントされていることをチェックしています。(これ必須でした)

実際に動作確認できたコードがこちら

// cubedata request
handlers.requestCubeData = function(args, context) {
  const now = Date.now();
  // get last request time
  const lastTimeKey = "LastRequestTimeCubeData";
  const getInternalDataResponse = server.GetUserInternalData({
    PlayFabId: currentPlayerId,
    Keys: [lastTimeKey]
  });
  // request must have 1000 ms interval
  const lastTimeData = getInternalDataResponse.Data[lastTimeKey];
  if (null != lastTimeData) {
    const lastTime = Number(lastTimeData.Value);
    if (1000 > now - lastTime) {
      return JSON.stringify({
        status: 400,
        message: "called in interval"
      });
    }
  }
  // imidiately set last request time
  const updateInternalDataResponse = server.UpdateUserInternalData({
    PlayFabId: currentPlayerId,
    Data: {
      [lastTimeKey]: now
    }
  });
  // check DataVersion up 1
  if (
    updateInternalDataResponse.DataVersion !=
    getInternalDataResponse.DataVersion + 1
  ) {
    return JSON.stringify({
      status: 401,
      message: "Data inconsistency"
    });
  }

  // do something

クライアントを改竄してチート検出できているかチェック

f:id:simplestar_tech:20191227191344p:plain
連続で8回リクエストしたうち、一回だけ成功したので、大成功

まとめ

知見としては、連続して CloudScript を実行すると、関数は並列で走ることが裏付けされたことが大きい収穫

また DataVersion が関数内で正しくインクリメントされたケースを使えば、特定ユーザーの高速連続リクエストの不正なものだけを拒否できました。
良い例を示せたんじゃないかな?

Unity:ゲーム開始前にロジックでヒエラルキー編集

概要

アイテムのスロットを結構な数 たとえば 81 個とか追加して、連番で名前を打つといった作業
3~4個リネームしている途中で手を止めて、プログラムに任せたくなりますよね(守るべきは時間ではなく、心)
PlayMode にせずにスクリプトからヒエラルキーをロジックで編集する方法を記録してみたいと思います。

ExecuteInEditMode 属性をつける

このクラスは非ゲーム実行中も動くよと宣言

using UnityEngine;

[ExecuteInEditMode]
public class SlotAdder : MonoBehaviour
{
    public GameObject prefabSlot;
    public Transform parent;
    public int index;

    public void AddSlot()
    {
        var button = Instantiate(prefabSlot, parent);
        button.name = "slot" + index.ToString("00");
        index += 1;
    }
}

CustomEditor 属性をつける

さっきの AddSlot をボタン押したときに実行できると楽ですよね。
インスペクターにボタンを置いて、そこからクラスの関数を呼び出すことができます。

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(SlotAdder))]
public class SlotAdderButton : Editor 
{
    public override void OnInspectorGUI()
    {
        // 元のインスペクター部分を表示
        base.OnInspectorGUI();

        // targetを変換して対象を取得
        var slotAdder = target as SlotAdder;

        // publicなメソッドを実行するボタン
        if (GUILayout.Button("Add Slot"))
        {
            slotAdder.AddSlot();
        }
    }
}

ボタンの見た目

f:id:simplestar_tech:20191222222701p:plain
今から頑張るようなことじゃないかも

Unity:Animatorからステートを取得

前書き

ゲーム開発していて、入力モードが増えてきて、ステート管理していきたいと思う中
Unity の State Machine って Mecanim の AnimatorController だなぁと気づき
デバッグ時にステートを遷移を可視化しつつ、これをスクリプトから操作や取得できたらいいなというのが
この記事のモチベーションです。

Animator からステート名は取れない

どうもステート名を外から取るには Asset をロードしなければならないとのことであきらめ
Enum 値とステート名のハッシュ値の関連から、なるべくコストをかけずに Enum 値として State を見極められるようにしたいと思います。

アイディアの具現化

次のような Explore(探索モード)を必ず通って各ステートへ切り替わるステートマシンを用意します。
トリガーはステート名を指定すると、そのステートへ移動する設定

f:id:simplestar_tech:20191215163853p:plain
AnimatorControllerの例

これをスクリプト側で、現在のステートと次のステートへの移動を Enum で取り扱えるように工夫したものがこちら

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ゲーム内の入力モード・ステート管理
/// </summary>
public class InputModeStateMachine : MonoBehaviour
{
    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);
        }
    }

    // Update is called once per frame
    void Update()
    {
        // スペースキーを押すと、ステートが切り替わる
        if (Input.GetKeyDown(KeyCode.Space))
        {
            // 現在のステート情報
            var stateInfo = this.animator.GetCurrentAnimatorStateInfo(INPUT_STATE_LAYER_INDEX);
            // ハッシュ値から Enum 値へマップ
            var currentState = this.stateHashToEnum[stateInfo.fullPathHash];
            Debug.Log(currentState.ToString());

            // Explore を中継にステートを切り替える(デバッグ機能)
            if (currentState != InputModeState.Explore)
            {
                this.animator.SetTrigger(InputModeState.Explore.ToString());
                Debug.Log(InputModeState.Explore.ToString());
            }
            else
            {
                this.animator.SetTrigger(nextState.ToString());
                Debug.Log(nextState.ToString());
                this.nextState = this.nextState + 1;
                if (InputModeState.Max == this.nextState)
                {
                    this.nextState = InputModeState.Explore + 1;
                }
            }
        }
    }

    /// <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;
}

まとめ

期待通り、スペースキーを連打すると
Explore → Inventory → Explore → Mining → Explore → TextChat → Explore
というステート遷移のデバッグログが流れていきます。

別にスクリプトだけでステート遷移してもよかったんですけど
可視化することと、その可視化したものでステート管理することで、変なステート遷移をコードに仕込まなくて済むなぁと
ちょっと回りくどかったかな

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