simplestarの技術ブログ

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

Unity:URPでParallaxMapping

# 前書き

私が作っているキューブの世界
simplestar-tech.hatenablog.com

そろそろキューブの見た目を更新する時が来た

この先のステップとしては

1.AO, Albedo, Height, Normal, Specular テクスチャを入力とする、Height Map をちゃんと利用する Shader Graph が作られている
2.8192 x 8192 のテクスチャにインデックスで区分けして AO, Albedo, Height, Normal, Specular テクスチャ計5枚を用意、余白を指定して高速出力できるプログラムが作られている
3.余白を考慮したテクスチャ座標サンプルができるゲームロジックになっている

が想像できる

今回は ParallaxOffset を使った1.ステップについて解説

# Shader Graph

ここのところだいぶ Shader Graph に慣れてきたので、UV 操作する ParallaxOffset SubGraph があるならこんな感じのグラフになる

f:id:simplestar_tech:20200210122756p:plain
SimpleParallaxOffset

肝心の SubShader の中身がこれ

f:id:simplestar_tech:20200210122915p:plain
Simple Parallax Offset

見た目はこんな感じで

f:id:simplestar_tech:20200210123016j:plain
OFF, ON, OFF

情報源はこちらのやりとり

How do I use a Heightmap in Shader Graph?
https://forum.unity.com/threads/how-do-i-use-a-heightmap-in-shader-graph.538170/

後に解説を求めて参考にしたフォローアップ記事
LearnOpenGL - Parallax Mapping

まとめ

即興で作れるグラフなので
ソース公開しなくてもいいかな

Unity: URPのモザイクシェーダーと水面シェーダー

f:id:simplestar_tech:20200209162015j:plain
Simple Interactive Water

1/14 に twitter で知り合った方に、その方が作成したオンライン VR ゲームに誘われてログイン
こちらのマイクの設定が初めてで最後までつなぎ方がわからなかったのだけど、面白い話を聞かせてもらいました。

なんとも VR 空間の、それも Unity の URP でモザイクとインタラクティブな水面シェーダーがあるとうれしいとのこと

モザイクシェーダーはその日話を終えた 22:00 から作り始めて、朝2:00頃までにいきおいで完成させて公開
github.com

その後も、URP VR 空間における水の表現は自分もほしいところだったので、作り始めます。
Twitter に水面シェーダーの制作過程を記したところ、最大でツィートインプレッションが 15万を超えて
この20日でフォロワーが 500人くらい増えるなど、知名度が上がる出来事がありました。

後にこの出来事が人生を大成功へ導く大きな一歩だったと気づくことになるのですが、それはまた別のお話(おい

とりあえず、一連のシェアされたツィートを並べると次の通り


何が言いたいかというと

まとめ

ここのところ hatena の記事数は少ないけど、アクティブに学習とアウトプットを続けて、ちゃんとキューブのゲームにつながる技術習得とアセット入手まで進んでますよ自分
という、いつか記事数だけ見て、疑問に思っている自分にメッセージを残しておきます。

そうそう、これも記録してなかったけど PlayFab Meetup #2 で発表もしてきました。
なかなか好評でしたよ。
jpfug.connpass.com

自分の発表資料はこちらで公開中
docs.google.com

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