simplestarの技術ブログ

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

CubeWalk: go言語cacheサーバーのリージョン選定

前書き

先月、こちらに登壇して PlayFab の CloudScript で同時実行を回避しつつ、不正させないゲームのためのアイディアを語ってみたのですが
jpfug.connpass.com
質問者から CloudScript が実行されている場所って US West オレゴンだから、日本にキャッシュサーバー置くと
結果的に情報路が長くなって、片道 1.3万キロメートルだから、理論上通信に最小 0.1 秒ほどの不要な遅れが足されて
あと日本でサーバー立てる方がランニングコストも通信費も高いですよね

とご指摘いただいたのです。

なるほど、go言語キャッシュサーバーを US West オレゴンに置いて、そこにアクセスする CloudScript も用意して
実際の通信速度に差が 0.1 秒ほど出るのか試してみますね!

やったこと

以前行った時のログをたよりにドキュメントを読み直し 東京リージョンと同じ構成でオレゴンリージョンにも同じ golang キャッシュサーバーを構築します。
simplestar-tech.hatenablog.com

まず、自宅(日本、東京)の pc からリクエストをそれぞれの golang サーバーに送ってヘルスチェックレスポンスが返ってくるまでの時間を計測

東京リージョンは 110~180 ms を要してました。
オレゴンリージョンは 610~680 ms を要してました。

この、それぞれのサーバーにヘルスチェックリクエストをする PlayFab CloudScript を javascript で実装し
自宅(日本、東京)の pc 上で実行する Unity クライアントから、CloudScript を呼び出してヘルスチェックレスポンスが返ってくるまでの時間を計測しました。

東京リージョンは 650~700 ms を要してました。
オレゴンリージョンは 220~300 ms を要してました。

結果

日本・東京の Unity クライアントから、PlayFab CloudScript を呼び出して golang のキャッシュサーバーのレスポンスを確認して Unity に戻ってくるまでの時間を計測しました。
サーバーの場所:
東京リージョンは 650~700 ms を要してました。
オレゴンリージョンは 220~300 ms を要してました。

PlayFab の PlayStream には、CloudScript 呼び出しの詳細が json で記録されますが
東京リージョンに置いた golang キャッシュサーバーにヘルスチェックを返してもらうのに "ExecutionTimeSeconds": 0.5307192
オレゴンリージョンに置いた golang キャッシュサーバーにヘルスチェックを返してもらうのに "ExecutionTimeSeconds": 0.0244125

PlayFab のスタジオのリージョンを日本にできればいいんだけどね…オレゴンにあるのは間違いない
今は東京リージョンにキャッシュサーバーを置くのは間違い確定なので、しばらくオレゴンリージョンに置いて作業を進めます。

Imagemagick:画像のエッジを引き延ばして格子状に再配置

前書き

キューブの見た目を更新する目的で動いています。

前回は Shader ができたところまで
simplestar-tech.hatenablog.com

今回はテクスチャを連番で結合する処理を作ります。
大分前に python の pillow で画像処理を書きましたが…
github.com
処理が遅すぎるのと
画像のエッジの引き延ばしを行っていなかったので、キューブ群を遠目で見ると結合部分が光ったり黒くなったり…とにかく見た目が良くない
その部分を今回は imagemagick を利用して解決していきます。

参考までに、昔のキューブの見た目は頑張ってこんなの
simplestar-tech.hatenablog.com

もう2年以上、技術選定を続けているという…今年は完成させるぞ!
simplestar-tech.hatenablog.com

Imagemagick の基本を習得

まずは Windows 環境で使えるように portable 版をダウンロードして展開し PATH を通しておきます。
imagemagick.org

以降、Windows 環境だと convert コマンドを magick にしなければバッティングする旨を imagemagick が教えてくれるので
magick コマンドを利用します。
そうしたことを念頭に次のマニュアルを完走して、imagemagick を身体になじませました。

imagemagick.biz

Imagemagick での画像のエッジ引き延ばし

情報ソースはこちら
www.imagemagick.org

細かいことは置いとくと、次の倍率を指定するだけで余白を引き延ばすことができます。
(残念ながら distort は基本操作には出てこない)

magick in.png -set option:distort:viewport %[fx:w*(1+0.05)]x%[fx:h*(1+0.05)] -virtual-pixel Edge -distort SRT "0,0 1,1 0 %[fx:w/(2/0.05)],%[fx:h/(2/0.05)]" out.png

ちょっとだけ解説すると
distort:vieport が left, top 引き延ばし幅に関与&全体の幅高さ倍率指定
virtual^pixel Edge が right, bottom の引き延ばし幅に関与していて、要するに 1 引いた倍率を 1/2 倍で指定というながれ

画像を変換してタイル状に並べる

変換した画像を利用するという小技について、まずはおさらい。
こちらの画像を入力に(昔、自分が描いた絵です)

f:id:simplestar_tech:20200210204717j:plain
in.jpg

こちらの処理を実行すると

magick in.jpg -resize 200x200 -size 300x300 xc:blue +swap -gravity center -compose over -composite -mattecolor red -frame 10x10 exit.jpg

意味は、200x200 にアスペクト比を保ったまま縮小して、blue 一色で塗りつぶした 300x300 の画像を新規作成、並び順を入れ替えて、conposite により青を背景に重ね合わせて、10pixel 幅の赤いフレームを追加
という文(読めました?)

次の結果が得られます。

f:id:simplestar_tech:20200210204915j:plain
exit.jpg

PowerShell で実行する複数ファイル作成とタイリングモンタージュ

以下の powershellWindows 環境で imagemagick に PATH が通っていれば動きます。

テクスチャはこちらの有料アセット(single entity license)を購入したものを利用します。
Yughues PBR Nature Materials
assetstore.unity.com

tga ファイルは magick で変換すると上下逆転しますし、Specular は png に tga から変換すると背景色が真っ白に変わるので(本当は黒)
一度 jpg に中間ファイルを出して、上下反転しつつもなんとか
あと、ファイルによっては正方形ではないので正方形に直します。
加えて、左右に 8 pixel の余白を設けるように 1008 x 1008 に圧縮してタイリングしたいと思います。

を実現する Powershell が以下の通り

# [AO, Diffuse, Height, Normal, Specular]
$target = "Specular"
New-Item $target -ItemType Directory -Force
$dir = "~\Assets\Yughues PBS Nature Materials"
$imgs = Get-ChildItem -Recurse -Path "$dir\*$target.tga"
foreach ($img in $imgs) {
    $fullName = $img.FullName
    $jpg = "$target\" + [io.path]::ChangeExtension($img.Name, "jpg")
    $id = identify $fullName
    $edge = 0.0158730
    if ($id -like "*512x1024*") {
        magick $fullName -resize 504x1008 -flip -write mpr:i +delete mpr:i mpr:i +append -set option:distort:viewport "%[fx:w*(1+$edge)]x%[fx:h*(1+$edge)]" -virtual-pixel Edge -distort SRT "0,0 1,1 0 %[fx:w/(2/$edge)],%[fx:h/(2/$edge)]" -quality 100 $jpg
    }
    else {
        magick $fullName -resize 1008x1008 -flip -set option:distort:viewport "%[fx:w*(1+$edge)]x%[fx:h*(1+$edge)]" -virtual-pixel Edge -distort SRT "0,0 1,1 0 %[fx:w/(2/$edge)],%[fx:h/(2/$edge)]" -quality 100 $jpg
    }
    echo $jpg
}
montage "$target\*$target.jpg" -tile 8x8 -geometry 1024x1024 "0_$target.png"

出力されるテクスチャを使って、Height map を利用しつつ絵を作ることができました。

f:id:simplestar_tech:20200211123626p:plain
8x8 の 64 分割テクスチャによる 1 マテリアル描画で全キューブ描画

ステップ2はクリアです。
残すはゲーム内の UV 操作のみ

追記

余白をエッジ引き延ばしするのもよかった
だけど parallax で使う場合はかなり余白を利用することになるし、そこがエッジ引き延ばしだとまた不自然な見た目になったので
リピートラッピングする方法を考案しました。

次の命令は画像の上下左右ななめ方向すべてにタイリングする例です

magick in.jpg -resize 960x960 -flip -write mpr:i +delete mpr:i mpr:i mpr:i +append -write mpr:j +delete mpr:j mpr:j mpr:j -append -gravity center -crop 1024x1024+0+0 -quality 100 out.jpg

これをスクリプトにつなげると

# [AO, Diffuse, Height, Normal, Specular]
$target = "Diffuse"
New-Item $target -ItemType Directory -Force
$dir = "D:\github\Unity\UniversalParallaxOffset\Assets\Yughues PBS Nature Materials"
$imgs = Get-ChildItem -Recurse -Path "$dir\*$target.tga"
foreach ($img in $imgs) {
    $fullName = $img.FullName
    $jpg = "$target\" + [io.path]::ChangeExtension($img.Name, "jpg")
    $id = identify $fullName
    if ($id -like "*512x1024*") {
        magick $fullName -resize 480x960 -flip -write mpr:i +delete mpr:i mpr:i +append -write mpr:j +delete mpr:j mpr:j mpr:j +append -write mpr:k +delete mpr:k mpr:k mpr:k -append -gravity center -crop 1024x1024+0+0 -quality 100 $jpg
    }
    else {
        magick $fullName -resize 960x960 -flip -write mpr:j +delete mpr:j mpr:j mpr:j +append -write mpr:k +delete mpr:k mpr:k mpr:k -append -gravity center -crop 1024x1024+0+0 -quality 100 $jpg
    }
    echo $jpg
}
montage "$target\*$target.jpg" -tile 8x8 -geometry 1024x1024 "0_$target.png"

期待通り

「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 だと寄付できないんだって…残念

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

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

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

まとめ

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

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
というステート遷移のデバッグログが流れていきます。

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