VCI でボタンが複数ついたリモコンを作る方法

# この記事の要約
![2022122003571920_New Room_英 涼太.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/823e1fc4-1396-7af4-1a65-a462da10aeb6.png)

- 複数のボタンが付いた操作パネルをできるだけ簡単に作成する方法を解説する。
- VCIでは素直に親SubItem(筐体)と相対的な位置関係を保つ子SubItem(ボタン)を表現することはできない。
- 解決策:
    - 目に見える子要素をSubItemではなく通常のGameObjectとして表現する。
    - 前述のGameObjectにコライダーを持つSubItemをUpdate毎に追従するさせることで親子関係を疑似的に表現する。
    - 持ち手とボタンは同時に触れにくいように離す

# 環境・前提条件
```
Unity2021.3.6f1
UniVRM と　UniVCI の UnityPackage を導入済み
Luaスクリプトを含む基本的なVCIを作れる知識
```
# この記事で作るVCIの仕様
- 3つのボタンがある
- リモコン全体を移動するための持ち手(白い棒)がある
- 3つのボタンのうち最後に触れたボタンが"選択状態"となり、黄緑色の枠でハイライトされる
    - ハイライト時に音が鳴る
- "選択状態"となれるボタンは１つのみ
- "選択状態"になっているボタンをUseするとボタンを"使用"できる
    - "使用"すると音が鳴る
- どのボタンからも手を離すと"選択状態"のボタンはなくなる
- 2つ以上のボタンに同時に触れたときは正しく動作しない場合があるが今回は許容する
<iframe width="560" height="315" src="https://www.youtube.com/embed/9-DsCtC7RzU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

# VCIの仕様による制約:SubItemは親子になれない
リモコンのような移動可能な筐体の上に操作可能なボタンが付いたアイテムをどのようにVCIで表現すれば良いでしょうか？Grab可能な筐体SubItemの上に付いたUse可能なボタンSubItemを表現するために、ボタンを筐体の子にするという方法は使えません。VCIの仕様として、SubItemはVCIのルート直下になければならないという制約があるためです。

# 表示用のオブジェクトと操作用のSubItemを分ける
この記事では疑似的な親子関係を作ることによってこの問題を解決する方法を提案します。
今回提案する方法ではヒエラルキーがこのようになります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/f4dd1346-4329-9110-72e2-5710fc58fc9e.png" width=80%>
`Handle`(持ち手) SubItem の子に`RedButton`、`BlueButton`、`YellowButton`、`Highlight` GameObject があります(Soundの事は今回は気にしないでください)。
例として`RedButton` GameObjectのインスペクターを見てみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/d2b2067c-4623-da51-e1e0-71629f58414f.png)
赤いボタンのメッシュ、メッシュレンダラー、マテリアルだけがあります。つまり、`RedButton`は見た目、つまり「ビジュアル」だけのためにあるもので、何の機能も持っていないことになります。`RedButton` GameObjectは`Handle` SubItemの子になっているため、ユーザーが`Handle`を動かすとそれに追従して相対的な位置を保ちます。
この「ビジュアル」にUseやOnTriggerEnterなどの機能を持たせるために、別のSubItemが存在しています。VCIのルート直下にある`RedButtonSubItem` GameObjectがそれです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/8dbea88b-c9ff-d2ea-4bee-8f0a7cd14b55.png)
`RedButtonSubItem` VCISubItem コンポーネントを持っているため SubItem として機能します。しかし、ご覧の通りRigidbodyとBoxColliderしかありません。メッシュが存在しないのです。赤いボタンとぴったりのサイズのコライダーだけが存在しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/99ac9a81-8841-b174-c176-5451dde25f47.png)
勘が良い方はもうお分かりでしょう。そうです、スクリプトで毎Update`RedButtonSubItem`(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
```
処理の流れはこうなります
1. ユーザーが`Handle`を動かす
1. 子である`RedButton`もそれに追従する
1. 次のUpdateで`RedButtonSubItem`が`RedButton`の位置に移動する
1. ユーザーにはあたかも`RedButton`がUse可能であるかのように見える

# 持ち手とボタンの位置を分ける
![2022122003571920_New Room_英 涼太.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/823e1fc4-1396-7af4-1a65-a462da10aeb6.png)
ところでこのT字型リモコン、一見へんてこな形をしていますがこれには深い意味があります。なぜ、現実のリモコンのように板状の筐体の上にボタンが乗っている形ではないのか分かりますか？
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/b8c0c16d-13c2-ae74-1ff7-43cbcec9f7ec.png" width=50%>
このようにボタンと筐体が隣接する形にした場合、これを使用するユーザーはイライラすることになります。
下図はダメなリモコンの例を真横から見たところです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/d753a297-b45b-d394-62c0-108e156973ce.png" width=50%>
図にはユーザーの手の当たり判定であるカプセルコライダーを重ねて表示しています。
この時、ユーザーが筐体をGrabして移動させようとしても、かなりの確率で筐体をGrabできません。なぜでしょう？これはVirtualCastに、片手で一度にGrabできるSubItemは1つまでという制約があるからです。上図の状態のとき、Grabしているのは筐体なのかボタンなのか定かではありません。ボタンをGrabしてしまった場合はリモコンを移動させられないのです。また、Useするときも同じで、ボタンをUseしたかったのに、筐体をUseしてしまったら何も起こりません。想像しただけでもイライラします。

この不都合を回避するために、持ち手とボタンの位置を明確に分けています。また、持ち手とボタンを同時に触れてしまわないように若干距離を持たせています。本当はボタンとボタンの間ももっとスペースを開けた方が良いのですが今回はこれくらいにしておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/8f1b9df1-84b3-776c-6877-7385633d440f.png)

# 同時押しできないようにする
さて、後は`RedButtonSubItem`のonUseを取ればそれで完成でしょうか？いえ、まだです。
もし、ユーザーの手が2つのボタンに同時に触れている状態でUseされたらどうなるのでしょう？
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/e8ac10e9-18b0-ef52-bd4d-0f143331b143.png" width=50%>
この図の場合、onUseを発行するのは赤いボタンか黄色いボタン、どちらでしょうか？

分かりません！

VirtualCastでは2つのSubItemを片手で同時にUseすることはできません。かなりの確率で、ユーザーは黄色いボタンを押したつもりなのに赤いボタンを押した判定にしまうのです。

この誤認を回避するため、同時押しできないようにする機構を実装します。
3つのボタンのうち、どれか１つだけを"選択状態"にできるようにし、"選択状態"になっているボタンをUseした時だけボタンを使用したと判定するようにします。

"選択状態"にあるボタンをユーザーに分かりやすく見せるため、`Highlight` GameObject を用意しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367873/725220e5-5313-9c81-bcb6-630e2b2d9f33.png)
これはボタンのビジュアルと同じ階層にあり、中身も他のビジュアルと変わりありません。一回り大きくて、色が違うだけです。

以下はこれに対応する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` には常に１つのボタン名だけが代入されている状態になります。
onTriggerExitのスクリプトも必要ですが、それはこの記事の最後に全体スクリプトの一部として載せておきます。

１つのボタンのみが"選択状態"にあることを保証できるようになったので、次にそれがユーザーの目に分かるようにします。
```
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](https://drive.google.com/file/d/1Ngs-3YoJOzHhLlqEVi229uCsXubFoLb3/view?usp=sharing)
[TSO](https://seed.online/products/d5dc2420002c151934b3b6c682ebff1c846e92b98f8ef79ca5ac1449d1bfd74d)