1900 lines
64 KiB
C#
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
|
|
}
|
|
}
|