この記事の要約
- 複数のボタンが付いた操作パネルをできるだけ簡単に作成する方法を解説する。
- VCIでは素直に親SubItem(筐体)と相対的な位置関係を保つ子SubItem(ボタン)を表現することはできない。
- 解決策:
- 目に見える子要素をSubItemではなく通常のGameObjectとして表現する。
- 前述のGameObjectにコライダーを持つSubItemをUpdate毎に追従するさせることで親子関係を疑似的に表現する。
- 持ち手とボタンは同時に触れにくいように離す
環境・前提条件
Unity2021.3.6f1
UniVRM と UniVCI の UnityPackage を導入済み
Luaスクリプトを含む基本的なVCIを作れる知識
この記事で作るVCIの仕様
- 3つのボタンがある
- リモコン全体を移動するための持ち手(白い棒)がある
- 3つのボタンのうち最後に触れたボタンが"選択状態"となり、黄緑色の枠でハイライトされる
- ハイライト時に音が鳴る
- "選択状態"となれるボタンは1つのみ
- "選択状態"になっているボタンをUseするとボタンを"使用"できる
- "使用"すると音が鳴る
- どのボタンからも手を離すと"選択状態"のボタンはなくなる
- 2つ以上のボタンに同時に触れたときは正しく動作しない場合があるが今回は許容する
VCIの仕様による制約:SubItemは親子になれない
リモコンのような移動可能な筐体の上に操作可能なボタンが付いたアイテムをどのようにVCIで表現すれば良いでしょうか?Grab可能な筐体SubItemの上に付いたUse可能なボタンSubItemを表現するために、ボタンを筐体の子にするという方法は使えません。VCIの仕様として、SubItemはVCIのルート直下になければならないという制約があるためです。
表示用のオブジェクトと操作用のSubItemを分ける
この記事では疑似的な親子関係を作ることによってこの問題を解決する方法を提案します。
今回提案する方法ではヒエラルキーがこのようになります。
Handle
(持ち手) SubItem の子にRedButton
、BlueButton
、YellowButton
、Highlight
GameObject があります(Soundの事は今回は気にしないでください)。
例としてRedButton
GameObjectのインスペクターを見てみましょう。
赤いボタンのメッシュ、メッシュレンダラー、マテリアルだけがあります。つまり、RedButton
は見た目、つまり「ビジュアル」だけのためにあるもので、何の機能も持っていないことになります。RedButton
GameObjectはHandle
SubItemの子になっているため、ユーザーがHandle
を動かすとそれに追従して相対的な位置を保ちます。
この「ビジュアル」にUseやOnTriggerEnterなどの機能を持たせるために、別のSubItemが存在しています。VCIのルート直下にあるRedButtonSubItem
GameObjectがそれです。
RedButtonSubItem
VCISubItem コンポーネントを持っているため SubItem として機能します。しかし、ご覧の通りRigidbodyとBoxColliderしかありません。メッシュが存在しないのです。赤いボタンとぴったりのサイズのコライダーだけが存在しています。
勘が良い方はもうお分かりでしょう。そうです、スクリプトで毎UpdateRedButtonSubItem
(SubItem)をRedButton
(ビジュアル)の位置に移動させることによりビジュアルがある位置をUse/OnTriggerEnter可能エリアとして扱えるようにするのです。
local redButtonVisual = vci.assets.GetTransform("RedButton")
local redButtonSubItem = vci.assets.GetTransform("RedButtonSubItem")
function update()
redButtonSubItem.SetPosition(redButtonVisual.GetPosition())
redButtonSubItem.SetRotation(redButtonVisual.GetRotation())
end
処理の流れはこうなります
- ユーザーが
Handle
を動かす - 子である
RedButton
もそれに追従する - 次のUpdateで
RedButtonSubItem
がRedButton
の位置に移動する - ユーザーにはあたかも
RedButton
がUse可能であるかのように見える
持ち手とボタンの位置を分ける
ところでこのT字型リモコン、一見へんてこな形をしていますがこれには深い意味があります。なぜ、現実のリモコンのように板状の筐体の上にボタンが乗っている形ではないのか分かりますか?
このようにボタンと筐体が隣接する形にした場合、これを使用するユーザーはイライラすることになります。
下図はダメなリモコンの例を真横から見たところです。
図にはユーザーの手の当たり判定であるカプセルコライダーを重ねて表示しています。
この時、ユーザーが筐体をGrabして移動させようとしても、かなりの確率で筐体をGrabできません。なぜでしょう?これはVirtualCastに、片手で一度にGrabできるSubItemは1つまでという制約があるからです。上図の状態のとき、Grabしているのは筐体なのかボタンなのか定かではありません。ボタンをGrabしてしまった場合はリモコンを移動させられないのです。また、Useするときも同じで、ボタンをUseしたかったのに、筐体をUseしてしまったら何も起こりません。想像しただけでもイライラします。
この不都合を回避するために、持ち手とボタンの位置を明確に分けています。また、持ち手とボタンを同時に触れてしまわないように若干距離を持たせています。本当はボタンとボタンの間ももっとスペースを開けた方が良いのですが今回はこれくらいにしておきます。
同時押しできないようにする
さて、後はRedButtonSubItem
のonUseを取ればそれで完成でしょうか?いえ、まだです。
もし、ユーザーの手が2つのボタンに同時に触れている状態でUseされたらどうなるのでしょう?
この図の場合、onUseを発行するのは赤いボタンか黄色いボタン、どちらでしょうか?
分かりません!
VirtualCastでは2つのSubItemを片手で同時にUseすることはできません。かなりの確率で、ユーザーは黄色いボタンを押したつもりなのに赤いボタンを押した判定にしまうのです。
この誤認を回避するため、同時押しできないようにする機構を実装します。
3つのボタンのうち、どれか1つだけを"選択状態"にできるようにし、"選択状態"になっているボタンをUseした時だけボタンを使用したと判定するようにします。
"選択状態"にあるボタンをユーザーに分かりやすく見せるため、Highlight
GameObject を用意しました。
これはボタンのビジュアルと同じ階層にあり、中身も他のビジュアルと変わりありません。一回り大きくて、色が違うだけです。
以下はこれに対応するonTriggerEnterのスクリプトです。
-- 現在選択中のボタン(nil, RED, BLUE, YELLOW のいずれか)
local selectedButton = nil
function onTriggerEnter(item, hit)
-- 手以外のオブジェクトの衝突は無視
if hit ~= "HandPointMarker" then
return
end
-- 持ち手への衝突は無視
if item == "Handle" then
return
end
-- 手で触れたボタンを選択中とする
if item == "RedButtonSubItem" then
selectedButton = "RED"
return
end
if item == "BlueButtonSubItem" then
selectedButton = "BLUE"
return
end
if item == "YellowButtonSubItem" then
selectedButton = "YELLOW"
return
end
end
これにより、selectedButton
には常に1つのボタン名だけが代入されている状態になります。
onTriggerExitのスクリプトも必要ですが、それはこの記事の最後に全体スクリプトの一部として載せておきます。
1つのボタンのみが"選択状態"にあることを保証できるようになったので、次にそれがユーザーの目に分かるようにします。
function update()
updateHighlightPosition()
end
-- 現在選択中のボタンを示すハイライトを表示
function updateHighlightPosition()
-- 何も選択されていないときはハイライトを非表示に
if selectedButton == nil then
HighlightVisual.SetActive(false)
return
end
-- 選択されているボタンの位置にハイライトオブジェクトを移動
if selectedButton == "RED" then
HighlightVisual.SetActive(true)
HighlightVisual.SetLocalPosition(redButtonVisual.GetLocalPosition())
return
end
if selectedButton == "BLUE" then
HighlightVisual.SetActive(true)
HighlightVisual.SetLocalPosition(blueButtonVisual.GetLocalPosition())
return
end
if selectedButton == "YELLOW" then
HighlightVisual.SetActive(true)
HighlightVisual.SetLocalPosition(yellowButtonVisual.GetLocalPosition())
return
end
end
最後に、selectedButton
とfunction onUse(use)
のuse
に飛んできたボタンが一致したときだけアクションを起こすようにプログラムします。
function onUse(use)
-- 持ち手をuseしても何も起きない
if use == "Handle" then
return
end
-- 現在選択されているボタンとUseされたボタンが同じならボタンを使用したとする
if use == "RedButtonSubItem" and selectedButton == "RED" then
-- ここにボタンを押したときに実行したい処理を書く
return
end
if use == "BlueButtonSubItem" and selectedButton == "BLUE" then
-- ここにボタンを押したときに実行したい処理を書く
return
end
if use == "YellowButtonSubItem" and selectedButton == "YELLOW" then
-- ここにボタンを押したときに実行したい処理を書く
return
end
end
完成したスクリプト
今までの機能に加え、ボタンを"選択状態"にした時とボタンを実際にUseした時に音が鳴るようにしました。
ちょっとした音を入れるだけでも、ボタンを操作するといった単純な体験を楽しいものにすることができます。
VRSNSの利用目的は基本的にはエンタメなのですから、可能な限りユーザーを楽しませることを忘れないようにしましょう。
local redButtonSubItemName = "RedButtonSubItem"
local blueButtonSubItemName = "BlueButtonSubItem"
local yellowButtonSubItemName = "YellowButtonSubItem"
local handleSubItemName = "Handle"
local handleSubItem = vci.assets.GetTransform(handleSubItemName)
local redButtonVisual = vci.assets.GetTransform("RedButton")
local blueButtonVisual = vci.assets.GetTransform("BlueButton")
local yellowButtonVisual = vci.assets.GetTransform("YellowButton")
local HighlightVisual = vci.assets.GetTransform("Highlight")
local redButtonSubItem = vci.assets.GetTransform(redButtonSubItemName)
local blueButtonSubItem = vci.assets.GetTransform(blueButtonSubItemName)
local yellowButtonSubItem = vci.assets.GetTransform(yellowButtonSubItemName)
-- 現在選択中のボタン(nil, RED, BLUE, YELLOW のいずれか)
local selectedButton = nil
-- 前回のupdate時に選択されていたボタンを覚えておく(nil, RED, BLUE, YELLOW のいずれか)
local buttonSelectedInLastUpdate = nil
-- ハイライトオブジェクトを非表示に
HighlightVisual.SetActive(false)
function onTriggerEnter(item, hit)
-- 手以外のオブジェクトの衝突は無視
if hit ~= "HandPointMarker" then
return
end
-- 持ち手への衝突は無視
if item == handleSubItemName then
return
end
-- 手で触れたボタンを選択中とする
if item == redButtonSubItemName then
selectedButton = "RED"
return
end
if item == blueButtonSubItemName then
selectedButton = "BLUE"
return
end
if item == yellowButtonSubItemName then
selectedButton = "YELLOW"
return
end
end
function onTriggerExit(item, hit)
-- 手以外のオブジェクトの衝突は無視
if hit ~= "HandPointMarker" then
return
end
-- 持ち手部分への衝突は無視
if item == handleSubItemName then
return
end
-- 選択中のボタンから手が離れたらなにも選択されていないとする
if item == redButtonSubItemName and selectedButton == "RED" then
selectedButton = nil
return
end
if item == blueButtonSubItemName and selectedButton == "BLUE" then
selectedButton = nil
return
end
if item == yellowButtonSubItemName and selectedButton == "YELLOW" then
selectedButton = nil
return
end
end
function onUse(use)
-- 持ち手をuseしても何も起きない
if use == handleSubItemName then
return
end
-- 現在選択されているボタンとUseされたボタンが同じならボタンを使用したとする
if use == redButtonSubItemName and selectedButton == "RED" then
playConfirmSound()
-- ここにボタンを押したときに実行したい処理を書く
return
end
if use == blueButtonSubItemName and selectedButton == "BLUE" then
playConfirmSound()
-- ここにボタンを押したときに実行したい処理を書く
return
end
if use == yellowButtonSubItemName and selectedButton == "YELLOW" then
playConfirmSound()
-- ここにボタンを押したときに実行したい処理を書く
return
end
end
function playConfirmSound()
vci.assets.audio._ALL_PlayOneShot("Confirm", 1)
end
function update()
updateButtonSubItemPositions()
updateHighlightPosition()
end
-- ダミーコライダーの位置を更新
function updateButtonSubItemPositions()
-- 赤ボタンのダミーコライダーをビジュアルの位置に追従させる
redButtonSubItem.SetPosition(redButtonVisual.GetPosition())
redButtonSubItem.SetRotation(redButtonVisual.GetRotation())
-- 青ボタンのダミーコライダーをビジュアルの位置に追従させる
blueButtonSubItem.SetPosition(blueButtonVisual.GetPosition())
blueButtonSubItem.SetRotation(blueButtonVisual.GetRotation())
-- 黄色ボタンのダミーコライダーをビジュアルの位置に追従させる
yellowButtonSubItem.SetPosition(yellowButtonVisual.GetPosition())
yellowButtonSubItem.SetRotation(yellowButtonVisual.GetRotation())
end
-- 現在選択中のボタンを示すハイライトを表示
function updateHighlightPosition()
-- 前回のupdate時から選択されているボタンが変更されていない場合は終了
if buttonSelectedInLastUpdate == selectedButton then
return
end
buttonSelectedInLastUpdate = selectedButton
-- 何も選択されていないときはハイライトを非表示に
if selectedButton == nil then
HighlightVisual.SetActive(false)
return
end
-- 選択されているボタンの位置にハイライトオブジェクトを移動
if selectedButton == "RED" then
HighlightVisual.SetActive(true)
HighlightVisual.SetLocalPosition(redButtonVisual.GetLocalPosition())
playSelectionSound()
return
end
if selectedButton == "BLUE" then
HighlightVisual.SetActive(true)
HighlightVisual.SetLocalPosition(blueButtonVisual.GetLocalPosition())
playSelectionSound()
return
end
if selectedButton == "YELLOW" then
HighlightVisual.SetActive(true)
HighlightVisual.SetLocalPosition(yellowButtonVisual.GetLocalPosition())
playSelectionSound()
return
end
end
-- ボタン選択時に音を鳴らします
function playSelectionSound()
vci.assets.audio._ALL_PlayOneShot("Move_Highlight", 1)
end
VCIの配布
このチュートリアルで作成したVCIをCC0で配布します。テンプレートとしてご利用いただくことができます。
また、本VCIに含まれる効果音は筆者が効果音制作ソフトで自作したもののため再配布していただいて構いません。
GoogleDrive
TSO