mixedreality/com.microsoft.mixedreality..../SDK/Features/UX/Interactable/Scripts/Interactable.cs

1900 lines
64 KiB
C#

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Microsoft.MixedReality.Toolkit.Input;
using Microsoft.MixedReality.Toolkit.Utilities;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.Serialization;
namespace Microsoft.MixedReality.Toolkit.UI
{
/// <summary>
/// Uses input and action data to declare a set of states
/// Maintains a collection of themes that react to state changes and provide sensory feedback
/// Passes state information and input data on to receivers that detect patterns and does stuff.
/// </summary>
[System.Serializable]
[HelpURL("https://docs.microsoft.com/windows/mixed-reality/mrtk-unity/features/ux-building-blocks/interactable")]
[AddComponentMenu("Scripts/MRTK/SDK/Interactable")]
public class Interactable :
MonoBehaviour,
IMixedRealityFocusChangedHandler,
IMixedRealityFocusHandler,
IMixedRealityInputHandler,
IMixedRealitySpeechHandler,
IMixedRealityTouchHandler,
IMixedRealityInputHandler<Vector2>,
IMixedRealityInputHandler<Vector3>,
IMixedRealityInputHandler<MixedRealityPose>
{
/// <summary>
/// Pointers that are focusing the interactable
/// </summary>
public List<IMixedRealityPointer> FocusingPointers => focusingPointers;
protected readonly List<IMixedRealityPointer> focusingPointers = new List<IMixedRealityPointer>();
/// <summary>
/// Input sources that are pressing the interactable
/// </summary>
public HashSet<IMixedRealityInputSource> PressingInputSources => pressingInputSources;
protected readonly HashSet<IMixedRealityInputSource> pressingInputSources = new HashSet<IMixedRealityInputSource>();
[FormerlySerializedAs("States")]
[SerializeField]
[Tooltip("ScriptableObject to reference for basic state logic to follow when interacting and transitioning between states. Should generally be \"DefaultInteractableStates\" object")]
private States states;
/// <summary>
/// ScriptableObject to reference for basic state logic to follow when interacting and transitioning between states. Should generally be "DefaultInteractableStates" object
/// </summary>
public States States
{
get => states;
set
{
states = value;
SetupStates();
}
}
/// <summary>
/// The state logic class for storing and comparing states which determines the current value.
/// </summary>
public InteractableStates StateManager { get; protected set; }
/// <summary>
/// The Interactable will only respond to input down events fired with the corresponding assigned Input Action.
/// Available input actions are populated via the Input Actions Profile under the MRTK Input System Profile assigned in the current scene
/// </summary>
public MixedRealityInputAction InputAction { get; set; }
/// <summary>
/// The id of the selected inputAction, for serialization
/// </summary>
[HideInInspector]
[SerializeField]
[Tooltip("The Interactable will only respond to input down events fired with the corresponding assigned Input Action." +
"Available input actions are populated via the Input Actions Profile under the MRTK Input System Profile assigned in the current scene.")]
private int InputActionId = 0;
[FormerlySerializedAs("IsGlobal")]
[SerializeField]
[Tooltip("If true, this Interactable will listen globally for any IMixedRealityInputHandler input events. These include general input up/down and clicks." +
"If false, this Interactable will only respond to general input click events if the pointer target is this GameObject's, or one of its children's, collider.")]
protected bool isGlobal = false;
/// <summary>
/// If true, this Interactable will listen globally for any IMixedRealityInputHandler input events. These include general input up/down and clicks.
/// If false, this Interactable will only respond to general input click events if the pointer target is this GameObject's, or one of its children's, collider.
/// </summary>
public bool IsGlobal
{
get => isGlobal;
set
{
if (isGlobal != value)
{
isGlobal = value;
// If we are active, then register or unregister our the global input handler with the InputSystem
// If we are disabled, then we will re-register OnEnable()
if (gameObject.activeInHierarchy)
{
RegisterHandler<IMixedRealityInputHandler>(isGlobal);
}
}
}
}
/// <summary>
/// A way of adding more layers of states for controls like toggles.
/// This is capitalized and doesn't match conventions for backwards compatibility
/// (to not break people using Interactable). We tried using FormerlySerializedAs("Dimensions)
/// and renaming to "dimensions", however Unity did not properly pick up the former serialization,
/// so we maintained the old value. See https://github.com/microsoft/MixedRealityToolkit-Unity/issues/6169
/// </summary>
[SerializeField]
protected int Dimensions = 1;
/// <summary>
/// A way of adding more layers of states for controls like toggles
/// </summary>
public int NumOfDimensions
{
get
{
EnsureInitialized();
return Dimensions;
}
set
{
EnsureInitialized();
if (Dimensions != value)
{
// Value cannot be negative or zero
if (value > 0)
{
// If we are currently in Toggle mode, we are about to not be
// Auto-turn off state
if (ButtonMode == SelectionModes.Toggle)
{
IsToggled = false;
}
Dimensions = value;
CurrentDimension = Mathf.Clamp(CurrentDimension, 0, Dimensions - 1);
}
else
{
Debug.LogWarning($"Value {value} for Dimensions property setter cannot be negative or zero.");
}
}
}
}
// cache of current dimension
[SerializeField]
protected int dimensionIndex = 0;
/// <summary>
/// Current Dimension index based zero and must be less than Dimensions
/// </summary>
public int CurrentDimension
{
get
{
EnsureInitialized();
return dimensionIndex;
}
set
{
EnsureInitialized();
if (dimensionIndex != value)
{
// If valid value and not our current value, then update
if (value >= 0 && value < NumOfDimensions)
{
dimensionIndex = value;
// If we are in toggle mode, update IsToggled state based on current dimension
// This needs to happen after updating dimensionIndex, since IsToggled.set will call CurrentDimension.set again
if (ButtonMode == SelectionModes.Toggle)
{
IsToggled = dimensionIndex > 0;
}
UpdateActiveThemes();
forceUpdate = true;
}
else
{
Debug.LogWarning($"On GameObject {gameObject.name}, value {value} for property setter CurrentDimension cannot be less than 0 and cannot be greater than or equal to Dimensions={NumOfDimensions}.");
}
}
}
}
/// <summary>
/// Returns the current selection mode of the Interactable based on the number of Dimensions available
/// </summary>
/// <remarks>
/// <para>Returns the following under the associated conditions:</para>
/// <para>SelectionModes.Invalid => Dimensions less than or equal to 0</para>
/// <para>SelectionModes.Button => Dimensions == 1</para>
/// <para>SelectionModes.Toggle => Dimensions == 2</para>
/// <para>SelectionModes.MultiDimension => Dimensions > 2</para>
/// </remarks>
public SelectionModes ButtonMode => ConvertToSelectionMode(NumOfDimensions);
/// <summary>
/// The Dimension value to set on start
/// </summary>
[FormerlySerializedAs("StartDimensionIndex")]
[SerializeField]
private int startDimensionIndex = 0;
/// <summary>
/// Is the interactive selectable?
/// When a multi-dimension button, can the user initiate switching dimensions?
/// </summary>
public bool CanSelect = true;
/// <summary>
/// Can the user deselect a toggle?
/// A radial button or tab should set this to false
/// </summary>
public bool CanDeselect = true;
[SpeechKeyword]
[SerializeField, FormerlySerializedAs("VoiceCommand")]
[Tooltip("This string keyword is the voice command that will fire a click on this Interactable.")]
private string voiceCommand = "";
/// <summary>
/// This string keyword is the voice command that will fire a click on this Interactable.
/// </summary>
public string VoiceCommand
{
get => voiceCommand;
set => voiceCommand = value;
}
[SerializeField, FormerlySerializedAs("RequiresFocus")]
[Tooltip("If true, then the voice command will only respond to voice commands while this Interactable has focus.")]
public bool voiceRequiresFocus = true;
/// <summary>
/// Does the voice command require this to have focus?
/// Registers as a global listener for speech commands, ignores input events
/// </summary>
public bool VoiceRequiresFocus
{
get => voiceRequiresFocus;
set
{
if (voiceRequiresFocus != value)
{
voiceRequiresFocus = value;
// If we are active, then change global speech registration.
// Register handle if we do not require focus, unregister otherwise
if (gameObject.activeInHierarchy)
{
RegisterHandler<IMixedRealitySpeechHandler>(!voiceRequiresFocus);
}
}
}
}
[FormerlySerializedAs("Profiles")]
[SerializeField]
private List<InteractableProfileItem> profiles = new List<InteractableProfileItem>();
/// <summary>
/// List of profile configurations that match Visual Themes with GameObjects targets
/// Setting at runtime will re-create the runtime Theme Engines (i.e ActiveThemes property) being used by this class
/// </summary>
public List<InteractableProfileItem> Profiles
{
get => profiles;
set
{
profiles = value;
SetupThemes();
}
}
/// <summary>
/// Base onclick event
/// </summary>
public UnityEvent OnClick = new UnityEvent();
[SerializeField]
private List<InteractableEvent> Events = new List<InteractableEvent>();
/// <summary>
/// List of events added to this interactable
/// </summary>
public List<InteractableEvent> InteractableEvents
{
get => Events;
set
{
Events = value;
SetupEvents();
}
}
[Tooltip("If true, when this component is destroyed, active themes will reset their modified properties to original values on the targeted GameObjects. If false, GameObject properties will remain as-is.")]
[SerializeField]
private bool resetOnDestroy = false;
/// <summary>
/// If true, when this component is destroyed, active themes will reset their modified properties to original values on the targeted GameObjects. If false, GameObject properties will remain as-is.
/// </summary>
public bool ResetOnDestroy
{
get => resetOnDestroy;
set => resetOnDestroy = value;
}
private List<InteractableThemeBase> activeThemes = new List<InteractableThemeBase>();
/// <summary>
/// The list of running theme instances to receive state changes
/// When the dimension index changes, activeThemes updates to those assigned to that dimension.
/// </summary>
public IReadOnlyList<InteractableThemeBase> ActiveThemes => activeThemes.AsReadOnly();
/// <summary>
/// List of (dimension index, InteractableThemeBase) pairs that describe all possible themes the
/// interactable can have. First element in the tuple represents dimension index for the theme.
/// This list gets initialized on startup, or whenever the profiles for the interactable changes.
/// The list of active themes inspects this list to determine which themes to use based on current dimension.
/// </summary>
private List<System.Tuple<int, InteractableThemeBase>> allThemeDimensionPairs = new List<System.Tuple<int, InteractableThemeBase>>();
/// <summary>
/// How many times this interactable was clicked
/// </summary>
/// <remarks>
/// Useful for checking when a click event occurs.
/// </remarks>
public int ClickCount { get; private set; }
#region States
// Field just used for serialization to save if the Interactable should start enabled or disabled
[FormerlySerializedAs("Enabled")]
[SerializeField]
[Tooltip("Defines whether the Interactable is enabled or not internally." +
"This is different than the enabled property at the GameObject/Component level." +
"When false, Interactable will continue to run in Unity but not respond to Input." +
"\n\nProperty is useful for disabling UX, such as greying out a button, until a user completes some pre-mandatory step such as fill out their name, etc")]
private bool enabledOnStart = true;
/// <summary>
/// Defines whether the Interactable is enabled or not internally
/// This is different than the Enabled property at the GameObject/Component level
/// When false, Interactable will continue to run in Unity but not respond to Input.
/// </summary>
/// <remarks>
/// Property is useful for disabling UX, such as greying out a button, until a user completes some pre-mandatory step such as fill out their name, etc
/// </remarks>
public virtual bool IsEnabled
{
// Note the inverse setting since targeting "Disable" state but property is concerning "Enabled"
get
{
return !(GetStateValue(InteractableStates.InteractableStateEnum.Disabled) > 0);
}
set
{
EnsureInitialized();
if (IsEnabled != value)
{
// If we are disabling input, we should reset our base input tracking states since we will not be responding to input while disabled
if (!value)
{
ResetInputTrackingStates();
}
SetState(InteractableStates.InteractableStateEnum.Disabled, !value);
}
}
}
/// <summary>
/// Has focus
/// </summary>
public virtual bool HasFocus
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Focus) > 0;
set
{
EnsureInitialized();
if (HasFocus != value)
{
if (!value && HasPress)
{
rollOffTimer = 0;
}
else
{
rollOffTimer = RollOffTime;
}
SetState(InteractableStates.InteractableStateEnum.Focus, value);
}
}
}
/// <summary>
/// Currently being pressed
/// </summary>
public virtual bool HasPress
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Pressed) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Pressed, value);
}
/// <summary>
/// Targeted means the item has focus and finger is up
/// Currently not controlled by Interactable directly
/// </summary>
public virtual bool IsTargeted
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Targeted) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Targeted, value);
}
/// <summary>
/// State that corresponds to no focus,and finger is up.
/// Currently not controlled by Interactable directly
/// </summary>
public virtual bool IsInteractive
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Interactive) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Interactive, value);
}
/// <summary>
/// State that corresponds to has focus,and finger down.
/// Currently not controlled by Interactable directly
/// </summary>
public virtual bool HasObservationTargeted
{
get => GetStateValue(InteractableStates.InteractableStateEnum.ObservationTargeted) > 0;
set => SetState(InteractableStates.InteractableStateEnum.ObservationTargeted, value);
}
/// <summary>
/// State that corresponds to no focus,and finger is down.
/// Currently not controlled by Interactable directly
/// </summary>
public virtual bool HasObservation
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Observation) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Observation, value);
}
/// <summary>
/// The Interactable has been clicked
/// </summary>
public virtual bool IsVisited
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Visited) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Visited, value);
}
/// <summary>
/// Determines whether Interactable is toggled or not. If true, CurrentDimension should be 1 and if false, CurrentDimension should be 0
/// </summary>
/// <remarks>
/// Only valid when ButtonMode == SelectionMode.Toggle (i.e Dimensions == 2)
/// </remarks>
public virtual bool IsToggled
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Toggled) > 0;
set
{
EnsureInitialized();
if (IsToggled != value)
{
// We can only change Toggle state if we are in Toggle mode
if (ButtonMode == SelectionModes.Toggle)
{
SetState(InteractableStates.InteractableStateEnum.Toggled, value);
CurrentDimension = value ? 1 : 0;
}
else
{
Debug.LogWarning($"SetToggled(bool) called, but SelectionMode is set to {ButtonMode}, so Current Dimension was unchanged.");
}
}
}
}
/// <summary>
/// Currently pressed and some movement has occurred
/// </summary>
public virtual bool HasGesture
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Gesture) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Gesture, value);
}
/// <summary>
/// State that corresponds to Gesture reaching max threshold or limits
/// </summary>
public virtual bool HasGestureMax
{
get => GetStateValue(InteractableStates.InteractableStateEnum.GestureMax) > 0;
set => SetState(InteractableStates.InteractableStateEnum.GestureMax, value);
}
/// <summary>
/// State that corresponds to Interactable is touching another object
/// </summary>
public virtual bool HasCollision
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Collision) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Collision, value);
}
/// <summary>
/// A voice command has just occurred
/// </summary>
public virtual bool HasVoiceCommand
{
get => GetStateValue(InteractableStates.InteractableStateEnum.VoiceCommand) > 0;
set => SetState(InteractableStates.InteractableStateEnum.VoiceCommand, value);
}
/// <summary>
/// A near interaction touchable is actively being touched
/// </summary>
public virtual bool HasPhysicalTouch
{
get => GetStateValue(InteractableStates.InteractableStateEnum.PhysicalTouch) > 0;
set => SetState(InteractableStates.InteractableStateEnum.PhysicalTouch, value);
}
/// <summary>
/// State that corresponds to miscellaneous/custom use by consumers
/// Currently not controlled by Interactable directly
/// </summary>
public virtual bool HasCustom
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Custom) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Custom, value);
}
/// <summary>
/// A near interaction grabbable is actively being grabbed
/// </summary>
public virtual bool HasGrab
{
get => GetStateValue(InteractableStates.InteractableStateEnum.Grab) > 0;
set => SetState(InteractableStates.InteractableStateEnum.Grab, value);
}
#endregion
protected State lastState;
// directly manipulate a theme value, skip blending
protected bool forceUpdate = false;
/// <summary>
/// Allows for switching colliders without firing a lose focus immediately for advanced controls like drop-downs
/// </summary>
public float RollOffTime { get; protected set; } = 0.25f;
protected float rollOffTimer = 0.25f;
protected List<IInteractableHandler> handlers = new List<IInteractableHandler>();
/// <summary>
/// A click must occur within this many seconds after an input down
/// </summary>
protected float clickTime = 1.5f;
protected Coroutine clickValidTimer;
/// <summary>
/// Amount of time to "simulate" press states for interactions that do not utilize input up/down such as voice command
/// This allows for visual feedbacks and other typical UX responsiveness and behavior to occur
/// </summary>
protected const float globalFeedbackClickTime = 0.3f;
protected Coroutine globalTimer;
#region Gesture State Variables
/// <summary>
/// The position of the controller when input down occurs.
/// Used to determine when controller has moved far enough to trigger gesture
/// </summary>
protected Vector3? dragStartPosition = null;
// Input must move at least this distance before a gesture is considered started, for 2D input like thumbstick
static readonly float gestureStartThresholdVector2 = 0.1f;
// Input must move at least this distance before a gesture is considered started, for 3D input
static readonly float gestureStartThresholdVector3 = 0.05f;
// Input must move at least this distance before a gesture is considered started, for
// mixed reality pose input. This is the distance and hand or controller needs to move
static readonly float gestureStartThresholdMixedRealityPose = 0.1f;
#endregion
// Track that the GameObject has been activated (i.e Awake() or Initialize() has been called)
private bool isInitialized = false;
#region MonoBehaviour Implementation
/// <inheritdoc/>
protected virtual void Awake()
{
EnsureInitialized();
}
/// <inheritdoc/>
protected virtual void OnEnable()
{
if (!VoiceRequiresFocus)
{
RegisterHandler<IMixedRealitySpeechHandler>(true);
}
if (IsGlobal)
{
RegisterHandler<IMixedRealityInputHandler>(true);
}
focusingPointers.RemoveAll((focusingPointer) => (focusingPointer.FocusTarget as Interactable) != this);
if (focusingPointers.Count == 0)
{
ResetInputTrackingStates();
}
}
/// <inheritdoc/>
protected virtual void OnDisable()
{
// If we registered to receive global events, remove ourselves when disabled
if (!VoiceRequiresFocus)
{
RegisterHandler<IMixedRealitySpeechHandler>(false);
}
if (IsGlobal)
{
RegisterHandler<IMixedRealityInputHandler>(false);
}
ResetInputTrackingStates();
}
/// <inheritdoc/>
protected virtual void Start()
{
InternalUpdate();
}
/// <inheritdoc/>
protected virtual void Update()
{
InternalUpdate();
}
/// <inheritdoc/>
protected virtual void OnDestroy()
{
if (ResetOnDestroy)
{
foreach (var theme in activeThemes)
{
theme.Reset();
}
}
}
private void InternalUpdate()
{
if (rollOffTimer < RollOffTime && HasPress)
{
rollOffTimer += Time.deltaTime;
if (rollOffTimer >= RollOffTime)
{
HasPress = false;
HasGesture = false;
}
}
int interactableEventsCount = InteractableEvents.Count;
for (int i = 0; i < interactableEventsCount; i++)
{
InteractableEvent interactableEvent = InteractableEvents[i];
if (interactableEvent.Receiver != null)
{
interactableEvent.Receiver.OnUpdate(StateManager, this);
}
}
int activeThemesCount = activeThemes.Count;
for (int i = 0; i < activeThemesCount; i++)
{
InteractableThemeBase interactableThemeBase = activeThemes[i];
if (interactableThemeBase.Loaded)
{
interactableThemeBase.OnUpdate(StateManager.CurrentState().ActiveIndex, forceUpdate);
}
}
if (lastState != StateManager.CurrentState())
{
int handlersCount = handlers.Count;
for (int i = 0; i < handlersCount; i++)
{
IInteractableHandler handler = handlers[i];
if (handler != null)
{
handler.OnStateChange(StateManager, this);
}
}
}
if (forceUpdate)
{
forceUpdate = false;
}
lastState = StateManager.CurrentState();
}
#endregion MonoBehaviour Implementation
#region Interactable Initiation
/// <summary>
/// Ensure this Interactable component has initialized. Must be called on Unity main thread.
/// Returns true if has already been initialized, false otherwise. If has not been initialized, then will call Initialize()
/// </summary>
private bool EnsureInitialized()
{
if (!isInitialized)
{
isInitialized = true;
Initialize();
return false;
}
return true;
}
protected virtual void Initialize()
{
if (States == null)
{
States = GetDefaultInteractableStates();
}
InputAction = ResolveInputAction(InputActionId);
RefreshSetup();
CurrentDimension = startDimensionIndex;
IsToggled = CurrentDimension > 0;
IsEnabled = enabledOnStart;
}
/// <summary>
/// Force re-initialization of Interactable from events, themes and state references
/// </summary>
/// <remarks>
/// This recreates the state machine inside Interactable and thus wipes any pre-existing state values held
/// </remarks>
public void RefreshSetup()
{
EnsureInitialized();
SetupEvents();
SetupThemes();
SetupStates();
}
/// <summary>
/// starts the StateManager
/// </summary>
protected virtual void SetupStates()
{
// Note that statemanager will clear states by allocating a new object
// But resetting states directly will call setters which may perform necessary steps to enter appropriate state
ResetAllStates();
Debug.Assert(typeof(InteractableStates).IsAssignableFrom(States.StateModelType), $"Invalid state model of type {States.StateModelType}. State model must extend from {typeof(InteractableStates)}");
StateManager = (InteractableStates)States.CreateStateModel();
}
/// <summary>
/// Creates the event receiver instances from the Events list
/// </summary>
protected virtual void SetupEvents()
{
for (int i = 0; i < InteractableEvents.Count; i++)
{
var receiver = InteractableEvent.CreateReceiver(InteractableEvents[i]);
if (receiver != null)
{
InteractableEvents[i].Receiver = receiver;
InteractableEvents[i].Receiver.Host = this;
}
else
{
Debug.LogWarning($"Empty event receiver found on {gameObject.name}, you may want to re-create this asset.");
}
}
}
/// <summary>
/// Updates the list of active themes based the current dimensions index
/// </summary>
protected virtual void UpdateActiveThemes()
{
activeThemes.Clear();
for (int i = 0; i < allThemeDimensionPairs.Count; i++)
{
if (allThemeDimensionPairs[i].Item1 == CurrentDimension)
{
activeThemes.Add(allThemeDimensionPairs[i].Item2);
}
}
}
/// <summary>
/// At startup or whenever a profile changes, creates all
/// possible themes that interactable can be in. We then update
/// the set of active themes by inspecting this list, looking for
/// only themes whose index matched CurrentDimensionIndex.
/// </summary>
private void SetupThemes()
{
allThemeDimensionPairs.Clear();
// Profiles are one per GameObject/ThemeContainer
// ThemeContainers are one per dimension
// ThemeDefinitions are one per desired effect (i.e theme)
foreach (var profile in Profiles)
{
if (profile.Target != null && profile.Themes != null)
{
for (int i = 0; i < profile.Themes.Count; i++)
{
var themeContainer = profile.Themes[i];
if (themeContainer.States.Equals(States))
{
foreach (var themeDefinition in themeContainer.Definitions)
{
allThemeDimensionPairs.Add(new System.Tuple<int, InteractableThemeBase>(
i,
InteractableThemeBase.CreateAndInitTheme(themeDefinition, profile.Target)));
}
}
else
{
Debug.LogWarning($"Could not use {themeContainer.name} in Interactable on {gameObject.name} because Theme's States does not match {States.name}");
}
}
}
}
UpdateActiveThemes();
}
#endregion Interactable Initiation
#region State Utilities
/// <summary>
/// Grabs the state value index, returns -1 if no StateManager available
/// </summary>
public int GetStateValue(InteractableStates.InteractableStateEnum state)
{
EnsureInitialized();
if (StateManager != null)
{
return StateManager.GetStateValue((int)state);
}
return -1;
}
/// <summary>
/// a public way to set state directly
/// </summary>
public void SetState(InteractableStates.InteractableStateEnum state, bool value)
{
EnsureInitialized();
if (StateManager != null)
{
StateManager.SetStateValue(state, value ? 1 : 0);
UpdateState();
}
}
/// <summary>
/// runs the state logic and sets state based on the current state values
/// </summary>
protected virtual void UpdateState()
{
StateManager.CompareStates();
}
/// <summary>
/// Reset the input tracking states directly managed by Interactable such as whether the component has focus or is being grabbed
/// Useful for when needing to reset input interactions
/// </summary>
public void ResetInputTrackingStates()
{
EnsureInitialized();
HasFocus = false;
HasPress = false;
HasPhysicalTouch = false;
HasGrab = false;
HasGesture = false;
HasGestureMax = false;
HasVoiceCommand = false;
if (globalTimer != null)
{
StopCoroutine(globalTimer);
globalTimer = null;
}
dragStartPosition = null;
}
/// <summary>
/// Reset all states in the Interactable and pointer information
/// </summary>
public void ResetAllStates()
{
EnsureInitialized();
focusingPointers.Clear();
pressingInputSources.Clear();
ResetInputTrackingStates();
IsEnabled = true;
HasObservation = false;
HasObservationTargeted = false;
IsInteractive = false;
IsTargeted = false;
IsToggled = false;
IsVisited = false;
HasCollision = false;
HasCustom = false;
}
#endregion State Utilities
#region Dimensions Utilities
/// <summary>
/// Increases the Current Dimension by 1. If at end (i.e Dimensions - 1), then loop around to beginning (i.e 0)
/// </summary>
public void IncreaseDimension()
{
if (CurrentDimension == NumOfDimensions - 1)
{
CurrentDimension = 0;
}
else
{
CurrentDimension++;
}
}
/// <summary>
/// Decreases the Current Dimension by 1. If at zero, then loop around to end (i.e Dimensions - 1)
/// </summary>
public void DecreaseDimension()
{
if (CurrentDimension == 0)
{
CurrentDimension = NumOfDimensions - 1;
}
else
{
CurrentDimension--;
}
}
/// <summary>
/// Helper method to convert number of dimensions to the appropriate SelectionModes
/// </summary>
/// <param name="dimensions">number of dimensions</param>
/// <returns>SelectionModes for corresponding number of dimensions</returns>
public static SelectionModes ConvertToSelectionMode(int dimensions)
{
if (dimensions <= 0)
{
return SelectionModes.Invalid;
}
else if (dimensions == 1)
{
return SelectionModes.Button;
}
else if (dimensions == 2)
{
return SelectionModes.Toggle;
}
else
{
return SelectionModes.MultiDimension;
}
}
#endregion Dimensions Utilities
#region Events
/// <summary>
/// Register OnClick extra handlers
/// </summary>
public void AddHandler(IInteractableHandler handler)
{
if (!handlers.Contains(handler))
{
handlers.Add(handler);
}
}
/// <summary>
/// Remove onClick handlers
/// </summary>
public void RemoveHandler(IInteractableHandler handler)
{
if (handlers.Contains(handler))
{
handlers.Remove(handler);
}
}
/// <summary>
/// Event receivers can be used to listen for different
/// events at runtime. This method allows receivers to be dynamically added at runtime.
/// </summary>
/// <returns>The new event receiver</returns>
public T AddReceiver<T>() where T : ReceiverBase, new()
{
var interactableEvent = new InteractableEvent();
var result = new T();
result.Event = interactableEvent.Event;
interactableEvent.Receiver = result;
InteractableEvents.Add(interactableEvent);
return result;
}
/// <summary>
/// Returns the first receiver of type T on the interactable,
/// or null if nothing is found.
/// </summary>
public T GetReceiver<T>() where T : ReceiverBase
{
for (int i = 0; i < InteractableEvents.Count; i++)
{
if (InteractableEvents[i] != null && InteractableEvents[i].Receiver is T receiverT)
{
return receiverT;
}
}
return null;
}
/// <summary>
/// Returns all receivers of type T on the interactable.
/// If nothing is found, returns empty list.
/// </summary>
public List<T> GetReceivers<T>() where T : ReceiverBase
{
List<T> result = new List<T>();
for (int i = 0; i < InteractableEvents.Count; i++)
{
if (InteractableEvents[i] != null && InteractableEvents[i].Receiver is T receiverT)
{
result.Add(receiverT);
}
}
return result;
}
#endregion
#region Input Timers
/// <summary>
/// Starts a timer to check if input is in progress
/// - Make sure global pointer events are not double firing
/// - Make sure Global Input events are not double firing
/// - Make sure pointer events are not duplicating an input event
/// </summary>
protected void StartClickTimer(bool isFromInputDown = false)
{
if (IsGlobal || isFromInputDown)
{
if (clickValidTimer != null)
{
StopClickTimer();
}
clickValidTimer = StartCoroutine(InputDownTimer(clickTime));
}
}
protected void StopClickTimer()
{
Debug.Assert(clickValidTimer != null, "StopClickTimer called but no click timer is running");
StopCoroutine(clickValidTimer);
clickValidTimer = null;
}
/// <summary>
/// A timer for the MixedRealityInputHandlers, clicks should occur within a certain time.
/// </summary>
protected IEnumerator InputDownTimer(float time)
{
yield return new WaitForSeconds(time);
clickValidTimer = null;
}
/// <summary>
/// Return true if the interactable can fire a click event.
/// Clicks can only occur within a short duration of an input down firing.
/// </summary>
private bool CanFireClick()
{
return clickValidTimer != null;
}
#endregion
#region Interactable Utilities
private void RegisterHandler<T>(bool enable) where T : IEventSystemHandler
{
if (enable)
{
CoreServices.InputSystem?.RegisterHandler<T>(this);
}
else
{
CoreServices.InputSystem?.UnregisterHandler<T>(this);
}
}
/// <summary>
/// Assigns the InputAction based on the InputActionId
/// </summary>
public static MixedRealityInputAction ResolveInputAction(int index)
{
if (CoreServices.InputSystem?.InputSystemProfile != null
&& CoreServices.InputSystem.InputSystemProfile.InputActionsProfile != null)
{
MixedRealityInputAction[] actions = CoreServices.InputSystem.InputSystemProfile.InputActionsProfile.InputActions;
if (actions?.Length > 0)
{
index = Mathf.Clamp(index, 0, actions.Length - 1);
return actions[index];
}
}
return default;
}
/// <summary>
/// Based on inputAction and state, should interactable listen to this up/down event.
/// </summary>
protected virtual bool ShouldListenToUpDownEvent(InputEventData data)
{
if ((HasFocus || IsGlobal) && data.MixedRealityInputAction == InputAction)
{
// Special case: Make sure that we are not being focused only by a PokePointer, since PokePointer
// dispatches touch events and should not be dispatching button presses like select, grip, menu, etc.
int focusingPointerCount = 0;
int focusingPokePointerCount = 0;
for (int i = 0; i < focusingPointers.Count; i++)
{
if (focusingPointers[i].InputSourceParent != null && focusingPointers[i].InputSourceParent.SourceId == data.SourceId)
{
focusingPointerCount++;
if (focusingPointers[i] is PokePointer)
{
focusingPokePointerCount++;
}
}
}
bool onlyFocusedByPokePointer = focusingPointerCount > 0 && focusingPointerCount == focusingPokePointerCount;
return !onlyFocusedByPokePointer;
}
return false;
}
/// <summary>
/// Returns true if the inputeventdata is being dispatched from a near pointer
/// </summary>
private bool IsInputFromNearInteraction(InputEventData eventData)
{
bool isAnyNearpointerFocusing = false;
for (int i = 0; i < focusingPointers.Count; i++)
{
if (focusingPointers[i].InputSourceParent != null &&
focusingPointers[i].InputSourceParent.SourceId == eventData.InputSource.SourceId &&
focusingPointers[i] is IMixedRealityNearPointer)
{
isAnyNearpointerFocusing = true;
break;
}
}
return isAnyNearpointerFocusing;
}
/// <summary>
/// Based on button settings and state, should this button listen to input?
/// </summary>
protected virtual bool CanInteract()
{
// Interactable can interact if we are enabled and we are not a toggle button
// If we are a toggle button, then we can only toggle if CanSelect (to turn on) or CanDeslect (to turn off)
return IsEnabled &&
(ButtonMode != SelectionModes.Toggle
|| (CurrentDimension == 0 && CanSelect)
|| (CurrentDimension == 1 && CanDeselect));
}
/// <summary>
/// A public way to trigger or route an onClick event from an external source, like PressableButton
/// </summary>
/// <param name="force">Force the click without checking CanInteract(). Does not override IsEnabled and only applies to toggle.</param>
public void TriggerOnClick(bool force = false)
{
if (!IsEnabled || (!force && !CanInteract()))
{
return;
}
IncreaseDimension();
SendOnClick(null);
IsVisited = true;
}
/// <summary>
/// Call onClick methods on receivers or IInteractableHandlers
/// </summary>
protected void SendOnClick(IMixedRealityPointer pointer)
{
OnClick.Invoke();
ClickCount++;
for (int i = 0; i < InteractableEvents.Count; i++)
{
if (InteractableEvents[i].Receiver != null)
{
InteractableEvents[i].Receiver.OnClick(StateManager, this, pointer);
}
}
for (int i = 0; i < handlers.Count; i++)
{
if (handlers[i] != null)
{
handlers[i].OnClick(StateManager, this, pointer);
}
}
}
/// <summary>
/// For input "clicks" that do not have corresponding input up/down tracking such as voice commands
/// Simulate pressed and start timer to reset states after some click time
/// </summary>
protected void StartGlobalVisual(bool voiceCommand = false)
{
if (voiceCommand)
{
HasVoiceCommand = true;
}
IsVisited = true;
HasFocus = true;
HasPress = true;
if (globalTimer != null)
{
StopCoroutine(globalTimer);
}
globalTimer = StartCoroutine(GlobalVisualReset(globalFeedbackClickTime));
}
/// <summary>
/// Clears up any automated visual states
/// </summary>
protected IEnumerator GlobalVisualReset(float time)
{
yield return new WaitForSeconds(time);
HasVoiceCommand = false;
if (HasFocus && focusingPointers.Count == 0)
{
HasFocus = false;
}
if (!HasPress)
{
HasPress = false;
}
globalTimer = null;
}
/// <summary>
/// Public method that can be used to set state of interactable
/// corresponding to an input going down (select button, menu button, touch)
/// </summary>
public void SetInputDown()
{
if (!CanInteract())
{
return;
}
dragStartPosition = null;
HasPress = true;
StartClickTimer(true);
}
/// <summary>
/// Public method that can be used to set state of interactable
/// corresponding to an input going up.
/// </summary>
public void SetInputUp()
{
if (!CanInteract())
{
return;
}
HasPress = false;
HasGesture = false;
if (CanFireClick())
{
StopClickTimer();
TriggerOnClick();
IsVisited = true;
}
}
private void OnInputChangedHelper<T>(InputEventData<T> eventData, Vector3 inputPosition, float gestureDeadzoneThreshold)
{
if (!CanInteract())
{
return;
}
if (ShouldListenToMoveEvent(eventData))
{
if (dragStartPosition == null)
{
dragStartPosition = inputPosition;
}
else if (!HasGesture)
{
if (Vector3.Distance(dragStartPosition.Value, inputPosition) > gestureStartThresholdVector2)
{
HasGesture = true;
}
}
}
}
private bool ShouldListenToMoveEvent<T>(InputEventData<T> eventData)
{
if ((HasFocus || IsGlobal) && HasPress)
{
// Ensure that this move event is from a pointer that is pressing the interactable
int matchingPointerCount = 0;
foreach (var pressingInputSource in pressingInputSources)
{
if (pressingInputSource == eventData.InputSource)
{
matchingPointerCount++;
}
}
return matchingPointerCount > 0;
}
return false;
}
/// <summary>
/// Creates the default States ScriptableObject configured for Interactable
/// </summary>
/// <returns>Default Interactable States asset</returns>
public static States GetDefaultInteractableStates()
{
States result = ScriptableObject.CreateInstance<States>();
InteractableStates allInteractableStates = new InteractableStates();
result.StateModelType = typeof(InteractableStates);
result.StateList = allInteractableStates.GetDefaultStates();
result.DefaultIndex = 0;
return result;
}
/// <summary>
/// Helper function to create a new Theme asset using Default Interactable States and provided theme definitions
/// </summary>
/// <param name="themeDefintions">List of Theme Definitions to associate with Theme asset</param>
/// <returns>Theme ScriptableObject instance</returns>
public static Theme GetDefaultThemeAsset(List<ThemeDefinition> themeDefintions)
{
// Create the Theme configuration asset
Theme newTheme = ScriptableObject.CreateInstance<Theme>();
newTheme.States = GetDefaultInteractableStates();
newTheme.Definitions = themeDefintions;
return newTheme;
}
#endregion
#region MixedRealityFocusChangedHandlers
/// <inheritdoc/>
public void OnBeforeFocusChange(FocusEventData eventData)
{
if (!IsEnabled)
{
return;
}
if (eventData.NewFocusedObject == null)
{
focusingPointers.Remove(eventData.Pointer);
}
else if (eventData.NewFocusedObject.transform.IsChildOf(gameObject.transform))
{
if (!focusingPointers.Contains(eventData.Pointer))
{
focusingPointers.Add(eventData.Pointer);
}
}
else if (eventData.OldFocusedObject != null
&& eventData.OldFocusedObject.transform.IsChildOf(gameObject.transform))
{
focusingPointers.Remove(eventData.Pointer);
}
}
/// <inheritdoc/>
public void OnFocusChanged(FocusEventData eventData) { }
#endregion MixedRealityFocusChangedHandlers
#region MixedRealityFocusHandlers
/// <inheritdoc/>
public void OnFocusEnter(FocusEventData eventData)
{
if (!IsEnabled)
{
return;
}
Debug.Assert(focusingPointers.Count > 0,
"OnFocusEnter called but focusingPointers == 0. Most likely caused by the presence of a child object " +
"that is handling IMixedRealityFocusChangedHandler");
HasFocus = true;
}
/// <inheritdoc/>
public void OnFocusExit(FocusEventData eventData)
{
if (!IsEnabled || !HasFocus)
{
return;
}
HasFocus = focusingPointers.Count > 0;
}
#endregion MixedRealityFocusHandlers
#region MixedRealityInputHandlers
/// <inheritdoc/>
public void OnPositionInputChanged(InputEventData<Vector2> eventData) { }
#endregion MixedRealityInputHandlers
#region MixedRealityVoiceCommands
/// <summary>
/// Voice commands from MixedRealitySpeechCommandProfile, keyword recognized
/// </summary>
public void OnSpeechKeywordRecognized(SpeechEventData eventData)
{
if (!IsEnabled)
{
return;
}
if (eventData.Command.Keyword == VoiceCommand && (!VoiceRequiresFocus || HasFocus) && CanInteract())
{
StartGlobalVisual(true);
HasVoiceCommand = true;
SendVoiceCommands(VoiceCommand, 0, 1);
TriggerOnClick();
eventData.Use();
}
}
/// <summary>
/// call OnVoinceCommand methods on receivers or IInteractableHandlers
/// </summary>
protected void SendVoiceCommands(string command, int index, int length)
{
for (int i = 0; i < InteractableEvents.Count; i++)
{
if (InteractableEvents[i].Receiver != null)
{
InteractableEvents[i].Receiver.OnVoiceCommand(StateManager, this, command, index, length);
}
}
for (int i = 0; i < handlers.Count; i++)
{
if (handlers[i] != null)
{
handlers[i].OnVoiceCommand(StateManager, this, command, index, length);
}
}
}
#endregion VoiceCommands
#region MixedRealityTouchHandlers
public void OnTouchStarted(HandTrackingInputEventData eventData)
{
if (!IsEnabled)
{
return;
}
HasPress = true;
HasPhysicalTouch = true;
eventData.Use();
}
public void OnTouchCompleted(HandTrackingInputEventData eventData)
{
if (!IsEnabled)
{
return;
}
HasPress = false;
HasPhysicalTouch = false;
eventData.Use();
}
public void OnTouchUpdated(HandTrackingInputEventData eventData) { }
#endregion TouchHandlers
#region MixedRealityInputHandlers
/// <inheritdoc/>
public void OnInputUp(InputEventData eventData)
{
if (!CanInteract() && !HasPress)
{
return;
}
if (ShouldListenToUpDownEvent(eventData))
{
SetInputUp();
if (IsInputFromNearInteraction(eventData))
{
HasGrab = false;
}
eventData.Use();
}
pressingInputSources.Remove(eventData.InputSource);
}
/// <inheritdoc/>
public void OnInputDown(InputEventData eventData)
{
if (!CanInteract())
{
return;
}
if (ShouldListenToUpDownEvent(eventData))
{
pressingInputSources.Add(eventData.InputSource);
SetInputDown();
HasGrab = IsInputFromNearInteraction(eventData);
eventData.Use();
}
}
/// <inheritdoc/>
public void OnInputChanged(InputEventData<Vector2> eventData)
{
OnInputChangedHelper(eventData, eventData.InputData, gestureStartThresholdVector2);
}
/// <inheritdoc/>
public void OnInputChanged(InputEventData<Vector3> eventData)
{
OnInputChangedHelper(eventData, eventData.InputData, gestureStartThresholdVector3);
}
/// <inheritdoc/>
public void OnInputChanged(InputEventData<MixedRealityPose> eventData)
{
OnInputChangedHelper(eventData, eventData.InputData.Position, gestureStartThresholdMixedRealityPose);
}
#endregion InputHandlers
#region Deprecated
/// <summary>
/// Resets input tracking states such as focus or grab that are directly controlled by Interactable
/// </summary>
[System.Obsolete("Use ResetInputTrackingStates property instead")]
public void ResetBaseStates()
{
ResetInputTrackingStates();
}
/// <summary>
/// A public way to access the current dimension
/// </summary>
[System.Obsolete("Use CurrentDimension property instead")]
public int GetDimensionIndex()
{
return CurrentDimension;
}
/// <summary>
/// a public way to set the dimension index
/// </summary>
[System.Obsolete("Use CurrentDimension property instead")]
public void SetDimensionIndex(int index)
{
CurrentDimension = index;
}
/// <summary>
/// Force re-initialization of Interactable from events, themes and state references
/// </summary>
[System.Obsolete("Use RefreshSetup() instead")]
public void ForceUpdateThemes()
{
RefreshSetup();
}
/// <summary>
/// Does this interactable require focus
/// </summary>
[System.Obsolete("Use IsGlobal instead")]
public bool FocusEnabled { get { return !IsGlobal; } set { IsGlobal = !value; } }
/// <summary>
/// True if Selection is "Toggle" (Dimensions == 2)
/// </summary>
[System.Obsolete("Use ButtonMode to test if equal to SelectionModes.Toggle instead")]
public bool IsToggleButton { get { return NumOfDimensions == 2; } }
/// <summary>
/// Is the interactable enabled?
/// </summary>
[System.Obsolete("Use IsEnabled instead")]
public bool Enabled
{
get => IsEnabled;
set => IsEnabled = value;
}
/// <summary>
/// Do voice commands require focus?
/// </summary>
[System.Obsolete("Use VoiceRequiresFocus instead")]
public bool RequiresFocus
{
get => VoiceRequiresFocus;
set => VoiceRequiresFocus = value;
}
/// <summary>
/// Is disabled
/// </summary>
[System.Obsolete("Use IsEnabled instead")]
public bool IsDisabled
{
get => !IsEnabled;
set => IsEnabled = !value;
}
/// <summary>
/// Returns a list of states assigned to the Interactable
/// </summary>
[System.Obsolete("Use States.StateList instead")]
public State[] GetStates()
{
if (States != null)
{
return States.StateList.ToArray();
}
return System.Array.Empty<State>();
}
/// <summary>
/// Handle focus state changes
/// </summary>
[System.Obsolete("Use Focus property instead")]
public virtual void SetFocus(bool focus)
{
HasFocus = focus;
}
/// <summary>
/// Change the press state
/// </summary>
[System.Obsolete("Use Press property instead")]
public virtual void SetPress(bool press)
{
HasPress = press;
}
/// <summary>
/// Change the disabled state, will override the Enabled property
/// </summary>
[System.Obsolete("Use IsEnabled property instead")]
public virtual void SetDisabled(bool disabled)
{
IsEnabled = !disabled;
}
/// <summary>
/// Change the targeted state
/// </summary>
[System.Obsolete("Use IsTargeted property instead")]
public virtual void SetTargeted(bool targeted)
{
IsTargeted = targeted;
}
/// <summary>
/// Change the Interactive state
/// </summary>
[System.Obsolete("Use IsInteractive property instead")]
public virtual void SetInteractive(bool interactive)
{
IsInteractive = interactive;
}
/// <summary>
/// Change the observation targeted state
/// </summary>
[System.Obsolete("Use HasObservationTargeted property instead")]
public virtual void SetObservationTargeted(bool targeted)
{
HasObservationTargeted = targeted;
}
/// <summary>
/// Change the observation state
/// </summary>
[System.Obsolete("Use HasObservation property instead")]
public virtual void SetObservation(bool observation)
{
HasObservation = observation;
}
/// <summary>
/// Change the visited state
/// </summary>
[System.Obsolete("Use IsVisited property instead")]
public virtual void SetVisited(bool visited)
{
IsVisited = visited;
}
/// <summary>
/// Change the toggled state
/// </summary>
[System.Obsolete("Use IsToggled property instead")]
public virtual void SetToggled(bool toggled)
{
IsToggled = toggled;
}
/// <summary>
/// Change the gesture state
/// </summary>
[System.Obsolete("Use HasGesture property instead")]
public virtual void SetGesture(bool gesture)
{
HasGesture = gesture;
}
/// <summary>
/// Change the gesture max state
/// </summary>
[System.Obsolete("Use HasGestureMax property instead")]
public virtual void SetGestureMax(bool gesture)
{
HasGestureMax = gesture;
}
/// <summary>
/// Change the collision state
/// </summary>
[System.Obsolete("Use HasCollision property instead")]
public virtual void SetCollision(bool collision)
{
HasCollision = collision;
}
/// <summary>
/// Change the custom state
/// </summary>
[System.Obsolete("Use HasCustom property instead")]
public virtual void SetCustom(bool custom)
{
HasCustom = custom;
}
/// <summary>
/// Change the voice command state
/// </summary>
[System.Obsolete("Use HasVoiceCommand property instead")]
public virtual void SetVoiceCommand(bool voice)
{
HasVoiceCommand = voice;
}
/// <summary>
/// Change the physical touch state
/// </summary>
[System.Obsolete("Use HasPhysicalTouch property instead")]
public virtual void SetPhysicalTouch(bool touch)
{
HasPhysicalTouch = touch;
}
/// <summary>
/// Change the grab state
/// </summary>
[System.Obsolete("Use HasGrab property instead")]
public virtual void SetGrab(bool grab)
{
HasGrab = grab;
}
#endregion
}
}