ワールドでラジオボタンを作りたかった

ワールドでラジオボタンを作りたかった
約4分で読めます
ラジオボタンの動作サンプル

課題

ミラーの切り替えボタン(HQミラー、LQミラー、BGミラー)が既存アセットに存在していたのですが、
ちょっと設定を変えたりする際にCyanTriggerを書き換える必要があったので、少しめんどくさかった。
また、このボタンを押したらこれがONになってこれがOFFになってと、いちいちON/OFF両方の設定を入れる必要があり、
これがまた個人的にめんどくさかった。

実現したかったこと

  • ボタンごとの責務を明確にする
  • ラジオボタンのグループ化がシンプルに実現できる
  • すべてのボタンがOFFの状態にできる

やったこと

ラジオボタンを簡単に作れるUdonSharpプログラムを書きました。
正確にはGPT-5.4に書いてもらって、Opus4.6にレビューしてもらったものを、最終的に私がレビューしたものです。

プログラミングはAIに任せるのが時短になっていいですね。
正直、ないとやってられないですわ。

使い方

ヒエラルキー

ヒエラルキー

MirrorSystemオブジェクトを親として、子供にボタンやミラー本体をぶら下げています。
まぁ適当な構造でもいいんですが。

RadioButtonGroup

RadioButtonGroup

  • 動作モード:ローカルかグローバルかの切り替え
  • ラジオボタン一覧:グループとして扱う各ボタンを登録するところ
  • 初期選択:初期値で選択しておくラジオボタンのIndex番号、同じボタンを押したらOFFできるようにするかの設定

初期選択は、どのラジオボタンを最初からONにしておくかの設定です。
今回はミラーなので最初からONにする必要はないため、-1を指定することで全てのボタンがデフォルトでOFFになります。
また、同じ理由でミラーをOFFにする必要もあるので、同じボタンを再度押したらOFFにできるようにもしています。

RadioButton

RadioButton

  • 所属グループ:このボタンが所属するラジオボタングループのオブジェクト
  • 選択時に有効化する対象:ボタンを押したときに有効化するオブジェクト一覧
  • アニメーター:ボタンのアニメーター(自動で指定される)
  • 選択時アニメーション:OFF→ONにしたときに流すアニメーション
  • 非選択時アニメーション:ON→OFFにしたときに流すアニメーション
  • 選択時サウンド:OFF→ONにしたときに流すSE
  • 非選択時サウンド:ON→OFFにしたときに流すSE

ボタン自体は普通のトグルスイッチと同じ感じですね。
ボタンのメッシュに適当なコライダーをくっつけて、IsTriggerにするやつです。

アニメーションとサウンドは元々このアセットについていたので、このプログラムでも設定できるようにしました。
押した感じがするアニメーションと、押下時のSEですね。

各ボタンにはそのボタンが押されたときにON/OFFされるオブジェクトのみ登録します。
なので、普通のトグルスイッチと全く同じ設定でOKということです。
この画像だとHQミラーのみ責務を持っていることになります。
LQミラーとBGミラーは別ボタンが責務を持っており、同一ラジオボタングループに登録しておくことで、自動的に切り替わる形です。

アニメーター

アニメーター

UdonSharpから直接ステート名を指定してアニメーションを再生するので、遷移条件を付ける必要はありません。
Idleは初期状態のアニメーションです。空っぽでもOK。

実際のコード

以下は実際のコードになっております。
コピペすればそのまま使えると思います~~。

コピペでも動くと思いますが、オブジェクト名や参照先、Animator の設定などはご自身の環境に合わせて適宜調整してくださいませ。
もし上手く動かない場合は、Inspector 上の参照漏れやステート名の違いをまずご確認ください。

RadioButtonGroup

RadioButtonGroup.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class RadioButtonGroup : UdonSharpBehaviour
{
[Header("動作モード")]
public ToggleScope toggleScope = ToggleScope.Global;
[Header("ラジオボタン一覧")]
public RadioButton[] radioButtons;
[Header("初期選択")]
public int initialSelectedIndex;
public bool allowDeselectSelectedButton = true;
[UdonSynced] private int selectedIndex = -1;
private bool hasInitialized;
private bool hasAppliedState;
private int lastAppliedIndex = -1;
private void Reset()
{
radioButtons = GetComponentsInChildren<RadioButton>(true);
}
private void Start()
{
if (!hasInitialized)
{
selectedIndex = GetNormalizedIndex(initialSelectedIndex);
hasInitialized = true;
}
ApplySelection(false);
}
public void SelectButton(RadioButton radioButton)
{
int index = GetButtonIndex(radioButton);
if (index < 0) return;
SelectIndex(index);
}
public void SelectIndex(int index)
{
index = GetNormalizedIndex(index);
if (allowDeselectSelectedButton && hasAppliedState && selectedIndex == index)
{
index = -1;
}
if (hasAppliedState && selectedIndex == index) return;
if (toggleScope == ToggleScope.Local)
{
selectedIndex = index;
hasInitialized = true;
ApplySelection(true);
return;
}
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
selectedIndex = index;
hasInitialized = true;
RequestSerialization();
ApplySelection(true);
}
public override void OnDeserialization()
{
if (toggleScope == ToggleScope.Local) return;
hasInitialized = true;
bool playFeedback = hasAppliedState && selectedIndex != lastAppliedIndex;
ApplySelection(playFeedback);
}
public int GetButtonIndex(RadioButton radioButton)
{
if (radioButtons == null || radioButton == null) return -1;
int len = radioButtons.Length;
for (int i = 0; i < len; i++)
{
if (radioButtons[i] == radioButton) return i;
}
return -1;
}
public bool IsSelected(RadioButton radioButton)
{
return GetButtonIndex(radioButton) == selectedIndex;
}
private void ApplySelection(bool playFeedback)
{
if (radioButtons == null)
{
lastAppliedIndex = selectedIndex;
hasAppliedState = true;
return;
}
int len = radioButtons.Length;
for (int i = 0; i < len; i++)
{
RadioButton radioButton = radioButtons[i];
if (radioButton == null) continue;
radioButton.ApplyState(i == selectedIndex, playFeedback);
}
lastAppliedIndex = selectedIndex;
hasAppliedState = true;
}
private int GetNormalizedIndex(int index)
{
if (radioButtons == null || radioButtons.Length == 0) return -1;
if (index < 0) return -1;
if (index >= radioButtons.Length) return radioButtons.Length - 1;
return index;
}
}

RadioButton

RadioButton.cs
using UdonSharp;
using UnityEngine;
using VRC.Udon;
public class RadioButton : UdonSharpBehaviour
{
[Header("所属グループ")]
public RadioButtonGroup radioGroup;
[Header("選択時に有効化する対象")]
public GameObject[] activeTargets;
[Header("アニメーター")]
public Animator buttonAnimator;
[Header("選択時アニメーション")]
public string onStateName = "SwitchOn";
public int onAnimatorLayer;
[Header("非選択時アニメーション")]
public string offStateName = "SwitchOff";
public int offAnimatorLayer;
[Header("選択時サウンド")]
public AudioSource onAudio;
[Header("非選択時サウンド")]
public AudioSource offAudio;
private bool hasAppliedState;
private bool lastAppliedState;
private void Reset()
{
buttonAnimator = GetComponent<Animator>();
if (radioGroup == null)
{
radioGroup = GetComponentInParent<RadioButtonGroup>();
}
}
private void Start()
{
if (buttonAnimator == null)
{
buttonAnimator = GetComponent<Animator>();
}
if (radioGroup == null)
{
radioGroup = GetComponentInParent<RadioButtonGroup>();
}
}
public override void Interact()
{
if (radioGroup == null) return;
radioGroup.SelectButton(this);
}
public void ApplyState(bool isSelected, bool playFeedback)
{
SetTargetsActive(isSelected);
bool shouldPlay = playFeedback && (!hasAppliedState || isSelected != lastAppliedState);
lastAppliedState = isSelected;
hasAppliedState = true;
if (!shouldPlay) return;
PlayAnimation(isSelected);
PlayAudio(isSelected);
}
private void SetTargetsActive(bool activeState)
{
int len = activeTargets.Length;
for (int i = 0; i < len; i++)
{
GameObject target = activeTargets[i];
if (target == null) continue;
target.SetActive(activeState);
}
}
private void PlayAnimation(bool isSelected)
{
if (buttonAnimator == null) return;
string stateName = isSelected ? onStateName : offStateName;
if (string.IsNullOrEmpty(stateName)) return;
int layer = isSelected ? onAnimatorLayer : offAnimatorLayer;
buttonAnimator.Play(stateName, layer, 0f);
}
private void PlayAudio(bool isSelected)
{
AudioSource targetAudio = isSelected ? onAudio : offAudio;
if (targetAudio == null) return;
targetAudio.Stop();
targetAudio.Play();
}
}

関連記事

さにゃんの魔導書庫 -Library of Arcane Whispers-
/ 3 min read

さにゃんの魔導書庫 -Library of Arcane Whispers-

魔法使いまっふぉ用のホームワールドを作りました

ぶいちゃアバタープロジェクトのつくりかた
/ 8 min read

ぶいちゃアバタープロジェクトのつくりかた

私がアバタープロジェクトを新しく作るときにやってることとか

VRChatのSSをしっとり寄りにレタッチしてみた
/ 3 min read

VRChatのSSをしっとり寄りにレタッチしてみた

Adobe Photoshop 2026で、暗さを残しつつ湿度と血色を足す感じのレタッチ手順をまとめました。

次に読む