I wanted to share this asset with you: Simple Interactive Water for URP VR by simplestar-game #UnityAssetStore https://assetstore.unity.com/packages/2d/textures-materials/water/simple-interactive-water-for-urp-vr-162033
良い日でした!
I wanted to share this asset with you: Simple Interactive Water for URP VR by simplestar-game #UnityAssetStore https://assetstore.unity.com/packages/2d/textures-materials/water/simple-interactive-water-for-urp-vr-162033
良い日でした!
先月、こちらに登壇して 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 のスタジオのリージョンを日本にできればいいんだけどね…オレゴンにあるのは間違いない
今は東京リージョンにキャッシュサーバーを置くのは間違い確定なので、しばらくオレゴンリージョンに置いて作業を進めます。
キューブの見た目を更新する目的で動いています。
前回は Shader ができたところまで
simplestar-tech.hatenablog.com
今回はテクスチャを連番で結合する処理を作ります。
大分前に python の pillow で画像処理を書きましたが…
github.com
処理が遅すぎるのと
画像のエッジの引き延ばしを行っていなかったので、キューブ群を遠目で見ると結合部分が光ったり黒くなったり…とにかく見た目が良くない
その部分を今回は imagemagick を利用して解決していきます。
参考までに、昔のキューブの見た目は頑張ってこんなの
simplestar-tech.hatenablog.com
もう2年以上、技術選定を続けているという…今年は完成させるぞ!
simplestar-tech.hatenablog.com
まずは Windows 環境で使えるように portable 版をダウンロードして展開し PATH を通しておきます。
imagemagick.org
以降、Windows 環境だと convert コマンドを magick にしなければバッティングする旨を imagemagick が教えてくれるので
magick コマンドを利用します。
そうしたことを念頭に次のマニュアルを完走して、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 倍で指定というながれ
変換した画像を利用するという小技について、まずはおさらい。
こちらの画像を入力に(昔、自分が描いた絵です)
こちらの処理を実行すると
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 幅の赤いフレームを追加
という文(読めました?)
次の結果が得られます。
以下の powershell は Windows 環境で 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 を利用しつつ絵を作ることができました。
ステップ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"
期待通り
水面の波を作ろうと思って、Normal Map 作成の壁?に当たったので、濃淡画像に sobel フィルターとか与えて法線マップ作るプログラムを書こうとしたのですけど
一般的な問題すぎるので、ネットに転がっているだろうと調べたら、Web アプリとして公開されていました。
次の URL を開きます。
https://cpetry.github.io/NormalMap-Online/
次の画面になるので、左下の濃淡画像をクリックしてファイル選択 Download ボタンを押すと Normal Map が手に入りました。
お金払おうとしたけど
日本円の PayPal だと寄付できないんだって…残念
こうやって記事を書いて貢献しますね
ゲームに登場する世界データは全プレイヤー間で一つ
配置場所を表すインデックスでアクセスし、データは 32 bit のキューブ情報の集合体です。
クライアントから直接この世界データにアクセスするにはインデックス指定は必須だと思いますが、配置するときにいずれかのキューブであることを表す情報を渡すのはいかがなものでしょうか?
そう、無限増殖とか、希少なキューブタイプを不正にリクエストすれば簡単に増やすことができてしまいます。
これは許せないですね。
最近作られてきているインベントリ操作まわり
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 を詰めてもらうことにしました。
最後にしてしまいましたが、最も重要な処理を書くことにします。
サーバー処理が重たくなりますが、これも無限増殖というチートを防ぐため、受け入れましょう。
取れることが確認できたコードがこちらです。
// 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 }); } }
一応、クライアント改竄による不正にアイテムを増やそうとする対応をわざと入れてみましたが、弾かれました。
うまくチェック機構が機能しているみたい
まずは配置に成功したら、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 モード時も有効になっていてほしい→対応
これが大変なケースがあって、例えばアイテムスロットに空きがあって、そこにアイテムが追加されてほしいじゃないですか
でもそういう計算をサーバーで行おうとすると、すべてのアイテムのすべての 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(); } } } }
上記の工程を経て、タイミングが良ければ…一見してアイテムを消費したり、掘削してキューブを取得したりができていそう
ここからデバッグしていきます。
ともかく、クライアントから送られてくるアイテム情報を利用せず、スロット番号だけを頼りにサーバーにあるインベントリ情報から世界データを更新
安全にアイテムの利用、キューブの破壊ができているようです。
現在のゲームプレイの様子
だんだん作るものが具体的になってきました。
現在はキューブを配置するときのためにホットバーが必要です。
インベントリ操作は Inventory モードに入ったときで、そのときにはホットバーが不要
それ以外の Explore モードではホットバーが必要です。
ホットバーとは、インベントリ表示のときに現れる一段目の 0 ~ 8 スロットを指します。
こんなの想像してます。
インベントリの 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 の State Machine って Mecanim の AnimatorController だなぁと気づき
デバッグ時にステートを遷移を可視化しつつ、これをスクリプトから操作や取得できたらいいなというのが
この記事のモチベーションです。
どうもステート名を外から取るには Asset をロードしなければならないとのことであきらめ
Enum 値とステート名のハッシュ値の関連から、なるべくコストをかけずに Enum 値として State を見極められるようにしたいと思います。
次のような Explore(探索モード)を必ず通って各ステートへ切り替わるステートマシンを用意します。
トリガーはステート名を指定すると、そのステートへ移動する設定
これをスクリプト側で、現在のステートと次のステートへの移動を 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; }