// Radial Menu|Prefabs|0110 namespace VRTK { using UnityEngine; using System.Collections; using UnityEngine.Events; using System.Collections.Generic; using UnityEngine.UI; using UnityEngine.EventSystems; public struct TouchAngleDeflection { public float angle; public float deflection; /// /// Constructs an object to hold the angle and deflection of the user's touch on the touchpad /// /// The angle of the touch on the radial menu. /// Deflection of the touch, where 0 is the centre and 1 is the edge. public TouchAngleDeflection(float angle, float deflection) { this.angle = angle; this.deflection = deflection; } } public delegate void HapticPulseEventHandler(float strength); /// /// Provides a UI element into the world space that can be dropped into a Controller GameObject and used to create and use Radial Menus from the touchpad. /// /// /// **Prefab Usage:** /// * Place the `VRTK/Prefabs/RadialMenu/RadialMenu` prefab as a child of a Controller script alias GameObject. /// /// /// `VRTK/Examples/030_Controls_RadialTouchpadMenu` displays a radial menu for each controller. The left controller uses the `Hide On Release` variable, so it will only be visible if the left touchpad is being touched. It also uses the `Execute On Unclick` variable to delay execution until the touchpad button is unclicked. The example scene also contains a demonstration of anchoring the RadialMenu to an interactable cube instead of a controller. /// [ExecuteInEditMode] public class VRTK_RadialMenu : MonoBehaviour { [System.Serializable] public class RadialMenuButton { public Sprite ButtonIcon; public UnityEvent OnClick = new UnityEvent(); public UnityEvent OnHold = new UnityEvent(); public UnityEvent OnHoverEnter = new UnityEvent(); public UnityEvent OnHoverExit = new UnityEvent(); } public enum ButtonEvent { hoverOn, hoverOff, click, unclick } [Tooltip("An array of Buttons that define the interactive buttons required to be displayed as part of the radial menu.")] public List buttons = new List(); [Tooltip("The base for each button in the menu, by default set to a dynamic circle arc that will fill up a portion of the menu.")] public GameObject buttonPrefab; [Tooltip("If checked, then the buttons will be auto generated on awake.")] public bool generateOnAwake = true; [Tooltip("Percentage of the menu the buttons should fill, 1.0 is a pie slice, 0.1 is a thin ring.")] [Range(0f, 1f)] public float buttonThickness = 0.5f; [Tooltip("The background colour of the buttons, default is white.")] public Color buttonColor = Color.white; [Tooltip("The distance the buttons should move away from the centre. This creates space between the individual buttons.")] public float offsetDistance = 1; [Tooltip("The additional rotation of the Radial Menu.")] [Range(0, 359)] public float offsetRotation; [Tooltip("Whether button icons should rotate according to their arc or be vertical compared to the controller.")] public bool rotateIcons; [Tooltip("The margin in pixels that the icon should keep within the button.")] public float iconMargin; [Tooltip("Whether the buttons are shown")] public bool isShown; [Tooltip("Whether the buttons should be visible when not in use.")] public bool hideOnRelease; [Tooltip("Whether the button action should happen when the button is released, as opposed to happening immediately when the button is pressed.")] public bool executeOnUnclick; [Tooltip("The base strength of the haptic pulses when the selected button is changed, or a button is pressed. Set to zero to disable.")] [Range(0, 1)] public float baseHapticStrength; [Tooltip("The dead zone in the middle of the dial where the menu does not consider a button is selected. Set to zero to disable.")] [Range(0, 1)] public float deadZone = 0; public event HapticPulseEventHandler FireHapticPulse; //Has to be public to keep state from editor -> play mode? [Tooltip("The actual GameObjects that make up the radial menu.")] public List menuButtons = new List(); protected int currentHover = -1; protected int currentPress = -1; protected Coroutine tweenMenuScaleRoutine; /// /// The HoverButton method is used to set the button hover at a given angle. /// /// The angle on the radial menu. [System.Obsolete("`VRTK_RadialMenu.HoverButton(float)` has been replaced with `VRTK_RadialMenu.HoverButton(TouchAngleDeflection)`. This method will be removed in a future version of VRTK.")] public virtual void HoverButton(float angle) { HoverButton(new TouchAngleDeflection(angle, 1)); } /// /// The HoverButton method is used to set the button hover at a given angle and deflection. /// /// The angle and deflection on the radial menu. public virtual void HoverButton(TouchAngleDeflection givenTouchAngleDeflection) { InteractButton(givenTouchAngleDeflection, ButtonEvent.hoverOn); } /// /// The ClickButton method is used to set the button click at a given angle. /// /// The angle on the radial menu. [System.Obsolete("`VRTK_RadialMenu.ClickButton(float)` has been replaced with `VRTK_RadialMenu.ClickButton(TouchAngleDeflection)`. This method will be removed in a future version of VRTK.")] public virtual void ClickButton(float angle) { ClickButton(new TouchAngleDeflection(angle, 1)); } /// /// The ClickButton method is used to set the button click at a given angle and deflection. /// /// The angle and deflection on the radial menu. public virtual void ClickButton(TouchAngleDeflection givenTouchAngleDeflection) { InteractButton(givenTouchAngleDeflection, ButtonEvent.click); } /// /// The UnClickButton method is used to set the button unclick at a given angle. /// /// The angle on the radial menu. [System.Obsolete("`VRTK_RadialMenu.UnClickButton(float)` has been replaced with `VRTK_RadialMenu.UnClickButton(TouchAngleDeflection)`. This method will be removed in a future version of VRTK.")] public virtual void UnClickButton(float angle) { UnClickButton(new TouchAngleDeflection(angle, 1)); } /// /// The UnClickButton method is used to set the button unclick at a given angle and deflection. /// /// The angle and deflection on the radial menu. public virtual void UnClickButton(TouchAngleDeflection givenTouchAngleDeflection) { InteractButton(givenTouchAngleDeflection, ButtonEvent.unclick); } /// /// The ToggleMenu method is used to show or hide the radial menu. /// public virtual void ToggleMenu() { if (isShown) { HideMenu(true); } else { ShowMenu(); } } /// /// The StopTouching method is used to stop touching the menu. /// public virtual void StopTouching() { if (currentHover != -1) { PointerEventData pointer = new PointerEventData(EventSystem.current); ExecuteEvents.Execute(menuButtons[currentHover], pointer, ExecuteEvents.pointerExitHandler); buttons[currentHover].OnHoverExit.Invoke(); currentHover = -1; } } /// /// The ShowMenu method is used to show the menu. /// public virtual void ShowMenu() { if (!isShown) { isShown = true; InitTweenMenuScale(isShown); } } /// /// The GetButton method is used to get a button from the menu. /// /// The id of the button to retrieve. /// The found radial menu button. public virtual RadialMenuButton GetButton(int id) { if (id < buttons.Count) { return buttons[id]; } return null; } /// /// The HideMenu method is used to hide the menu. /// /// If true then the menu is always hidden. public virtual void HideMenu(bool force) { if (isShown && (hideOnRelease || force)) { isShown = false; InitTweenMenuScale(isShown); } } /// /// The RegenerateButtons method creates all the button arcs and populates them with desired icons. /// public void RegenerateButtons() { RemoveAllButtons(); for (int i = 0; i < buttons.Count; i++) { // Initial placement/instantiation GameObject newButton = Instantiate(buttonPrefab); newButton.transform.SetParent(transform); newButton.transform.localScale = Vector3.one; newButton.GetComponent().offsetMax = Vector2.zero; newButton.GetComponent().offsetMin = Vector2.zero; //Setup button arc UICircle circle = newButton.GetComponent(); if (buttonThickness == 1f) { circle.fill = true; } else { circle.thickness = (int)(buttonThickness * (GetComponent().rect.width / 2f)); } int fillPerc = (int)(100f / buttons.Count); circle.fillPercent = fillPerc; circle.color = buttonColor; //Final placement/rotation float angle = ((360f / buttons.Count) * i) + offsetRotation; newButton.transform.localEulerAngles = new Vector3(0, 0, angle); newButton.layer = 4; //UI Layer newButton.transform.localPosition = Vector3.zero; if (circle.fillPercent < 55) { float angleRad = (angle * Mathf.PI) / 180f; Vector2 angleVector = new Vector2(-Mathf.Cos(angleRad), -Mathf.Sin(angleRad)); newButton.transform.localPosition += (Vector3)angleVector * offsetDistance; } //Place and populate Button Icon GameObject buttonIcon = newButton.GetComponentInChildren().gameObject; if (buttons[i].ButtonIcon == null) { buttonIcon.SetActive(false); } else { buttonIcon.GetComponent().sprite = buttons[i].ButtonIcon; buttonIcon.transform.localPosition = new Vector2(-1 * ((newButton.GetComponent().rect.width / 2f) - (circle.thickness / 2f)), 0); //Min icon size from thickness and arc float scale1 = Mathf.Abs(circle.thickness); float absButtonIconXPos = Mathf.Abs(buttonIcon.transform.localPosition.x); float bAngle = (359f * circle.fillPercent * 0.01f * Mathf.PI) / 180f; float scale2 = (absButtonIconXPos * 2f * Mathf.Sin(bAngle / 2f)); if (circle.fillPercent > 24) //Scale calc doesn't work for > 90 degrees { scale2 = float.MaxValue; } float iconScale = Mathf.Min(scale1, scale2) - iconMargin; buttonIcon.GetComponent().sizeDelta = new Vector2(iconScale, iconScale); //Rotate icons all vertically if desired if (!rotateIcons) { buttonIcon.transform.eulerAngles = GetComponentInParent().transform.eulerAngles; } } VRTK_SharedMethods.AddListValue(menuButtons, newButton, true); } } /// /// The AddButton method is used to add a new button to the menu. /// /// The button to add. public void AddButton(RadialMenuButton newButton) { VRTK_SharedMethods.AddListValue(buttons, newButton, true); RegenerateButtons(); } protected virtual void Awake() { if (Application.isPlaying) { if (!isShown) { transform.localScale = Vector3.zero; } if (generateOnAwake) { RegenerateButtons(); } } } protected virtual void Update() { //Keep track of pressed button and constantly invoke Hold event if (currentPress != -1) { buttons[currentPress].OnHold.Invoke(); } } //Turns and Angle and Event type into a button action protected virtual void InteractButton(TouchAngleDeflection givenTouchAngleDeflection, ButtonEvent evt) //Can't pass ExecuteEvents as parameter? Unity gives error { //Get button ID from angle float buttonAngle = 360f / buttons.Count; //Each button is an arc with this angle givenTouchAngleDeflection.angle = VRTK_SharedMethods.Mod((givenTouchAngleDeflection.angle + -offsetRotation), 360f); //Offset the touch coordinate with our offset int buttonID = (int)VRTK_SharedMethods.Mod(((givenTouchAngleDeflection.angle + (buttonAngle / 2f)) / buttonAngle), buttons.Count); //Convert angle into ButtonID (This is the magic) PointerEventData pointer = new PointerEventData(EventSystem.current); //Create a new EventSystem (UI) Event if (givenTouchAngleDeflection.deflection <= deadZone) { //No button selected. Use -1 to represent this buttonID = -1; } //If we changed buttons while moving, un-hover and un-click the last button we were on if (currentHover != buttonID && currentHover != -1) { ExecuteEvents.Execute(menuButtons[currentHover], pointer, ExecuteEvents.pointerUpHandler); ExecuteEvents.Execute(menuButtons[currentHover], pointer, ExecuteEvents.pointerExitHandler); buttons[currentHover].OnHoverExit.Invoke(); if (executeOnUnclick && currentPress != -1 && buttonID != -1) { ExecuteEvents.Execute(menuButtons[buttonID], pointer, ExecuteEvents.pointerDownHandler); AttempHapticPulse(baseHapticStrength * 1.666f); } } if (evt == ButtonEvent.click) //Click button if click, and keep track of current press (executes button action) { if (buttonID != -1) { ExecuteEvents.Execute(menuButtons[buttonID], pointer, ExecuteEvents.pointerDownHandler); } currentPress = buttonID; if (!executeOnUnclick && buttonID != -1) { buttons[buttonID].OnClick.Invoke(); AttempHapticPulse(baseHapticStrength * 2.5f); } } else if (evt == ButtonEvent.unclick) //Clear press id to stop invoking OnHold method (hide menu) { if (buttonID != -1) { ExecuteEvents.Execute(menuButtons[buttonID], pointer, ExecuteEvents.pointerUpHandler); } currentPress = -1; if (executeOnUnclick && buttonID != -1) { AttempHapticPulse(baseHapticStrength * 2.5f); buttons[buttonID].OnClick.Invoke(); } } else if (evt == ButtonEvent.hoverOn && currentHover != buttonID && buttonID != -1) // Show hover UI event (darken button etc). Show menu { ExecuteEvents.Execute(menuButtons[buttonID], pointer, ExecuteEvents.pointerEnterHandler); buttons[buttonID].OnHoverEnter.Invoke(); AttempHapticPulse(baseHapticStrength); } currentHover = buttonID; //Set current hover ID, need this to un-hover if selected button changes } protected virtual void InitTweenMenuScale(bool isShown) { if (tweenMenuScaleRoutine != null) { StopCoroutine(tweenMenuScaleRoutine); } tweenMenuScaleRoutine = StartCoroutine(TweenMenuScale(isShown)); } //Simple tweening for menu, scales linearly from 0 to 1 and 1 to 0 protected virtual IEnumerator TweenMenuScale(bool show) { float targetScale = 0f; Vector3 Dir = -1 * Vector3.one; if (show) { targetScale = 1; Dir = Vector3.one; } int i = 0; //Sanity check for infinite loops while (i < 250 && ((show && transform.localScale.x < targetScale) || (!show && transform.localScale.x > targetScale))) { transform.localScale += Dir * Time.deltaTime * 4f; //Tweening function - currently 0.25 second linear yield return true; i++; } transform.localScale = Dir * targetScale; } protected virtual void AttempHapticPulse(float strength) { if (strength > 0f && FireHapticPulse != null) { FireHapticPulse(strength); } } protected virtual void RemoveAllButtons() { if (menuButtons != null) { for (int i = 0; i < menuButtons.Count; i++) { DestroyImmediate(menuButtons[i]); } menuButtons.Clear(); } } } }