// Independent Radial Menu|Prefabs|0120 namespace VRTK { using UnityEngine; using System.Collections.Generic; using System.Collections; /// /// Allows the RadialMenu to be anchored to any object, not just a controller. /// /// /// **Prefab Usage:** /// * Place the `VRTK/Prefabs/RadialMenu/RadialMenu` prefab as a child of the GameObject to associate the Radial Menu with. /// * Position and scale the menu by adjusting the transform of the `RadialMenu` empty. /// * Replace `VRTK_RadialMenuController` with `VRTK_IndependentRadialMenuController` that is located on the `RadialMenu/RadialMenuUI/Panel` GameObject. /// * Ensure the parent object has the `VRTK_InteractableObject` script. /// * Verify that `Is Usable` and `Hold Button to Use` are both checked on the `VRTK_InteractableObject`. /// * Attach `VRTK_InteractTouch` and `VRTK_InteractUse` scripts to the objects that will activate the Radial Menu (e.g. the Controllers). /// /// /// `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. /// public class VRTK_IndependentRadialMenuController : VRTK_RadialMenuController { [Tooltip("If the RadialMenu is the child of an object with VRTK_InteractableObject attached, this will be automatically obtained. It can also be manually set.")] public VRTK_InteractableObject eventsManager; [Tooltip("Whether or not the script should dynamically add a SphereCollider to surround the menu.")] public bool addMenuCollider = true; [Tooltip("This times the size of the RadialMenu is the size of the collider.")] [Range(0, 10)] public float colliderRadiusMultiplier = 1.2f; [Tooltip("If true, after a button is clicked, the RadialMenu will hide.")] public bool hideAfterExecution = true; [Tooltip("How far away from the object the menu should be placed, relative to the size of the RadialMenu.")] [Range(-10, 10)] public float offsetMultiplier = 1.1f; [Tooltip("The object the RadialMenu should face towards. If left empty, it will automatically try to find the Headset Camera.")] public GameObject rotateTowards; protected List interactingObjects = new List(); // Objects (controllers) that are either colliding with the menu or clicking the menu protected HashSet collidingObjects = new HashSet(); // Just objects that are currently colliding with the menu or its parent protected SphereCollider menuCollider; protected Coroutine delayedSetColliderEnabledRoutine; protected Vector3 desiredColliderCenter; protected Quaternion initialRotation; protected bool isClicked = false; protected bool waitingToDisableCollider = false; protected int counter = 2; /// /// The UpdateEventsManager method is used to update the events within the menu controller. /// public virtual void UpdateEventsManager() { VRTK_InteractableObject newEventsManager = transform.GetComponentInParent(); if (newEventsManager == null) { VRTK_Logger.Error(VRTK_Logger.GetCommonMessage(VRTK_Logger.CommonMessageKeys.REQUIRED_COMPONENT_MISSING_NOT_INJECTED, "VRTK_IndependentRadialMenuController", "VRTK_InteractableObject", "eventsManager", "the parent")); return; } else if (newEventsManager != eventsManager) // Changed managers { if (eventsManager != null) { // Unsubscribe from the old events OnDisable(); } eventsManager = newEventsManager; // Subscribe to new events OnEnable(); Destroy(menuCollider); // Reset to initial state Initialize(); } } protected override void Initialize() { if (eventsManager == null) { initialRotation = transform.localRotation; UpdateEventsManager(); return; // If all goes well in updateEventsManager, it will then call Initialize again, skipping this if statement } // Reset variables interactingObjects.Clear(); collidingObjects.Clear(); if (delayedSetColliderEnabledRoutine != null) { StopCoroutine(delayedSetColliderEnabledRoutine); } isClicked = false; waitingToDisableCollider = false; counter = 2; if (transform.childCount == 0) // This means things haven't been properly initialized yet, will cause problems. { return; } float radius = (transform.GetChild(0).GetComponent().rect.width / 2) * offsetMultiplier; transform.localPosition = new Vector3(0, 0, radius); if (addMenuCollider) { gameObject.SetActive(false); // Just be sure it doesn't briefly flash transform.localScale = Vector3.one; // If this were left at zero it would ruin the transformations below Quaternion startingRot = transform.rotation; transform.rotation = Quaternion.Euler(new Vector3(0, 0, 0)); // Rotation can mess up the calculations below SphereCollider collider = eventsManager.gameObject.AddComponent(); // All of the transformVector's are to account for the scaling of the radial menu's 'panel' and the scaling of the eventsManager parent object collider.radius = (transform.GetChild(0).GetComponent().rect.width / 2) * colliderRadiusMultiplier * eventsManager.transform.InverseTransformVector(transform.GetChild(0).TransformVector(Vector3.one)).x; collider.center = eventsManager.transform.InverseTransformVector(transform.position - eventsManager.transform.position); collider.isTrigger = true; collider.enabled = false; // Want this to only activate when the menu is showing menuCollider = collider; desiredColliderCenter = collider.center; transform.rotation = startingRot; } if (!menu.isShown) { transform.localScale = Vector3.zero; } gameObject.SetActive(true); } protected override void Awake() { menu = GetComponent(); VRTK_SDKManager.AttemptAddBehaviourToToggleOnLoadedSetupChange(this); } protected virtual void Start() { Initialize(); } protected override void OnEnable() { if (eventsManager != null) { eventsManager.InteractableObjectUsed += ObjectClicked; eventsManager.InteractableObjectUnused += ObjectUnClicked; eventsManager.InteractableObjectTouched += ObjectTouched; eventsManager.InteractableObjectUntouched += ObjectUntouched; menu.FireHapticPulse += AttemptHapticPulse; } else { Initialize(); } } protected override void OnDisable() { if (eventsManager != null) { eventsManager.InteractableObjectUsed -= ObjectClicked; eventsManager.InteractableObjectUnused -= ObjectUnClicked; eventsManager.InteractableObjectTouched -= ObjectTouched; eventsManager.InteractableObjectUntouched -= ObjectUntouched; menu.FireHapticPulse -= AttemptHapticPulse; } } protected virtual void OnDestroy() { VRTK_SDKManager.AttemptRemoveBehaviourToToggleOnLoadedSetupChange(this); } protected virtual void Update() { if (rotateTowards == null) // Backup { Transform headset = VRTK_DeviceFinder.HeadsetTransform(); if (headset) { rotateTowards = headset.gameObject; } else { VRTK_Logger.Warn(VRTK_Logger.GetCommonMessage(VRTK_Logger.CommonMessageKeys.COULD_NOT_FIND_OBJECT_FOR_ACTION, "IndependentRadialMenu", "an object", "rotate towards")); } } if (menu.isShown) { if (interactingObjects.Count > 0) // There's not really an event for the controller moving, so just update the position every frame { DoChangeAngle(CalculateAngle(interactingObjects[0]), this); } if (rotateTowards != null) { transform.rotation = Quaternion.LookRotation((rotateTowards.transform.position - transform.position) * -1, Vector3.up) * initialRotation; // Face the target, but maintain initial rotation } } } protected virtual void FixedUpdate() { if (waitingToDisableCollider) { if (counter == 0) { menuCollider.enabled = false; waitingToDisableCollider = false; counter = 2; } else { counter--; } } } protected override void AttemptHapticPulse(float strength) { if (interactingObjects.Count > 0) { VRTK_ControllerHaptics.TriggerHapticPulse(VRTK_ControllerReference.GetControllerReference(interactingObjects[0]), strength); } } protected virtual void ObjectClicked(object sender, InteractableObjectEventArgs e) { DoClickButton(sender); isClicked = true; if (hideAfterExecution && !menu.executeOnUnclick) { ImmediatelyHideMenu(e); } } protected virtual void ObjectUnClicked(object sender, InteractableObjectEventArgs e) { DoUnClickButton(sender); isClicked = false; if ((hideAfterExecution || (collidingObjects.Count == 0 && menu.hideOnRelease)) && menu.executeOnUnclick) { ImmediatelyHideMenu(e); } } protected virtual void ObjectTouched(object sender, InteractableObjectEventArgs e) { DoShowMenu(CalculateAngle(e.interactingObject), sender); collidingObjects.Add(e.interactingObject); VRTK_SharedMethods.AddListValue(interactingObjects, e.interactingObject, true); if (addMenuCollider && menuCollider != null) { SetColliderState(true, e); if (delayedSetColliderEnabledRoutine != null) { StopCoroutine(delayedSetColliderEnabledRoutine); } } } protected virtual void ObjectUntouched(object sender, InteractableObjectEventArgs e) { collidingObjects.Remove(e.interactingObject); if (((!menu.executeOnUnclick || !isClicked) && menu.hideOnRelease) || (Object)sender == this) { DoHideMenu(hideAfterExecution, sender); interactingObjects.Remove(e.interactingObject); if (addMenuCollider && menuCollider != null) { // In case there's any gap between the normal collider and the menuCollider, delay a bit. Cancelled if collider is re-entered delayedSetColliderEnabledRoutine = StartCoroutine(DelayedSetColliderEnabled(false, 0.25f, e)); } } } protected virtual TouchAngleDeflection CalculateAngle(GameObject interactingObject) { Vector3 controllerPosition = interactingObject.transform.position; Vector3 toController = controllerPosition - transform.position; Vector3 projection = transform.position + Vector3.ProjectOnPlane(toController, transform.forward); float angle = 0; angle = AngleSigned(transform.right * -1, projection - transform.position, transform.forward); // Ensure angle is positive if (angle < 0) { angle += 360.0f; } return new TouchAngleDeflection(angle, 1); } protected virtual float AngleSigned(Vector3 v1, Vector3 v2, Vector3 n) { return Mathf.Atan2(Vector3.Dot(n, Vector3.Cross(v1, v2)), Vector3.Dot(v1, v2)) * Mathf.Rad2Deg; } protected virtual void ImmediatelyHideMenu(InteractableObjectEventArgs e) { ObjectUntouched(this, e); if (delayedSetColliderEnabledRoutine != null) { StopCoroutine(delayedSetColliderEnabledRoutine); } SetColliderState(false, e); // Don't want to wait for this } protected virtual void SetColliderState(bool state, InteractableObjectEventArgs e) { if (addMenuCollider && menuCollider != null) { if (state) { menuCollider.enabled = true; menuCollider.center = desiredColliderCenter; } else { bool should = true; Collider[] colliders = eventsManager.GetComponents(); Collider[] controllerColliders = e.interactingObject.GetComponent().ControllerColliders(); for (int i = 0; i < colliders.Length; i++) { Collider collider = colliders[i]; if (collider != menuCollider) { for (int j = 0; j < controllerColliders.Length; j++) { Collider controllerCollider = controllerColliders[j]; if (controllerCollider.bounds.Intersects(collider.bounds)) { should = false; } } } } if (should) { menuCollider.center = new Vector3(100000000.0f, 100000000.0f, 100000000.0f); // This needs to be done to get OnTriggerExit() to fire, unfortunately waitingToDisableCollider = true; // Need to give other things time to realize that they're not colliding with this anymore, so do it a couple FixedUpdates } else { menuCollider.enabled = false; } } } } protected virtual IEnumerator DelayedSetColliderEnabled(bool enabled, float delay, InteractableObjectEventArgs e) { yield return new WaitForSeconds(delay); SetColliderState(enabled, e); } } }