ワールドでラジオボタンを作りたかった
課題
ミラーの切り替えボタン(HQミラー、LQミラー、BGミラー)が既存アセットに存在していたのですが、
ちょっと設定を変えたりする際にCyanTriggerを書き換える必要があったので、少しめんどくさかった。
また、このボタンを押したらこれがONになってこれがOFFになってと、いちいちON/OFF両方の設定を入れる必要があり、
これがまた個人的にめんどくさかった。
実現したかったこと
- ボタンごとの責務を明確にする
- ラジオボタンのグループ化がシンプルに実現できる
- すべてのボタンがOFFの状態にできる
やったこと
ラジオボタンを簡単に作れるUdonSharpプログラムを書きました。
正確にはGPT-5.4に書いてもらって、Opus4.6にレビューしてもらったものを、最終的に私がレビューしたものです。
プログラミングはAIに任せるのが時短になっていいですね。
正直、ないとやってられないですわ。
使い方
ヒエラルキー

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

- 動作モード:ローカルかグローバルかの切り替え
- ラジオボタン一覧:グループとして扱う各ボタンを登録するところ
- 初期選択:初期値で選択しておくラジオボタンのIndex番号、同じボタンを押したらOFFできるようにするかの設定
初期選択は、どのラジオボタンを最初からONにしておくかの設定です。
今回はミラーなので最初からONにする必要はないため、-1を指定することで全てのボタンがデフォルトでOFFになります。
また、同じ理由でミラーをOFFにする必要もあるので、同じボタンを再度押したらOFFにできるようにもしています。
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
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
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(); }}