11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

VCIAdvent Calendar 2022

Day 20

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

Posted at

この記事の要約

2022122003571920_New Room_英 涼太.png

  • 複数のボタンが付いた操作パネルをできるだけ簡単に作成する方法を解説する。
  • 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 の子にRedButtonBlueButtonYellowButtonHighlight GameObject があります(Soundの事は今回は気にしないでください)。
例としてRedButton GameObjectのインスペクターを見てみましょう。
image.png
赤いボタンのメッシュ、メッシュレンダラー、マテリアルだけがあります。つまり、RedButtonは見た目、つまり「ビジュアル」だけのためにあるもので、何の機能も持っていないことになります。RedButton GameObjectはHandle SubItemの子になっているため、ユーザーがHandleを動かすとそれに追従して相対的な位置を保ちます。
この「ビジュアル」にUseやOnTriggerEnterなどの機能を持たせるために、別のSubItemが存在しています。VCIのルート直下にあるRedButtonSubItem GameObjectがそれです。
image.png
RedButtonSubItem VCISubItem コンポーネントを持っているため SubItem として機能します。しかし、ご覧の通りRigidbodyとBoxColliderしかありません。メッシュが存在しないのです。赤いボタンとぴったりのサイズのコライダーだけが存在しています。
image.png
勘が良い方はもうお分かりでしょう。そうです、スクリプトで毎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

処理の流れはこうなります

  1. ユーザーがHandleを動かす
  2. 子であるRedButtonもそれに追従する
  3. 次のUpdateでRedButtonSubItemRedButtonの位置に移動する
  4. ユーザーにはあたかもRedButtonがUse可能であるかのように見える

持ち手とボタンの位置を分ける

2022122003571920_New Room_英 涼太.png
ところでこのT字型リモコン、一見へんてこな形をしていますがこれには深い意味があります。なぜ、現実のリモコンのように板状の筐体の上にボタンが乗っている形ではないのか分かりますか?

このようにボタンと筐体が隣接する形にした場合、これを使用するユーザーはイライラすることになります。
下図はダメなリモコンの例を真横から見たところです。

図にはユーザーの手の当たり判定であるカプセルコライダーを重ねて表示しています。
この時、ユーザーが筐体をGrabして移動させようとしても、かなりの確率で筐体をGrabできません。なぜでしょう?これはVirtualCastに、片手で一度にGrabできるSubItemは1つまでという制約があるからです。上図の状態のとき、Grabしているのは筐体なのかボタンなのか定かではありません。ボタンをGrabしてしまった場合はリモコンを移動させられないのです。また、Useするときも同じで、ボタンをUseしたかったのに、筐体をUseしてしまったら何も起こりません。想像しただけでもイライラします。

この不都合を回避するために、持ち手とボタンの位置を明確に分けています。また、持ち手とボタンを同時に触れてしまわないように若干距離を持たせています。本当はボタンとボタンの間ももっとスペースを開けた方が良いのですが今回はこれくらいにしておきます。
image.png

同時押しできないようにする

さて、後はRedButtonSubItemのonUseを取ればそれで完成でしょうか?いえ、まだです。
もし、ユーザーの手が2つのボタンに同時に触れている状態でUseされたらどうなるのでしょう?

この図の場合、onUseを発行するのは赤いボタンか黄色いボタン、どちらでしょうか?

分かりません!

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

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

"選択状態"にあるボタンをユーザーに分かりやすく見せるため、Highlight GameObject を用意しました。
image.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 には常に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

最後に、selectedButtonfunction 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

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?