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 ステップを終えました。