[New Input System] Mouse double tap doesn't work properly

Hi. I’m using the new Input System (Unity 2021.3.33) and have a problem with mouse double click.My action looks like:
9638246--1370114--Screenshot 2024-02-12 105200.png

And the problem is that the double click applies even if I click two different elements, despite the different mouse position between the clicks. Press point param dosn’t affect at all.
9638246--1370111--mouseDoubleClickProblem.gif

Is it a bug or correct behaviour? How can I limit my mouse double click to a radius of a few pixels?

You should change the Max Tap Spacing value. That should limit the double tap distance between two taps if they considered double tap or two separate taps.
Also always should go for .performed event if you are using events, because those fire once the parameters satisfy the interaction. The .started and .canceled may be called independently in order to keep tab on clicks and potential interactions.
If you share code we can help with that too.

According to the hint, this is the delay between clicks. If I reduce it, double-clicking won’t be convenient either.
9638591--1370168--upload_2024-2-12_14-7-48.png

The code is simple:

if (_input.Inventory.TakeOrUse.WasPerformedThisFrame())
{
    TryToUseFocusedItem();
}

Ouch, checked the source code, you’re right, someone on the InputSystem team felt funny and renamed the tapDelay to tapSpacing. I’m afraid in this case you will need to code the distance restriction yourself. Probably a good idea if you create your own, working multitap interaction. And maybe submit a bug report about this craziness.

Thank you for explanation! So now I know that it wasn’t my mistake, the problem is in the Engine). Will try to make custome one.

According to Unity source code, there is no any distance check at all…

                case TapPhase.None:
                    if (context.ControlIsActuated(pressPointOrDefault))
                    {
                        m_CurrentTapPhase = TapPhase.WaitingForNextRelease;
                        m_CurrentTapStartTime = context.time;
                        context.Started();

                        var maxTapTime = tapTimeOrDefault;
                        var maxDelayInBetween = tapDelayOrDefault;
                        context.SetTimeout(maxTapTime);

                        // We'll be using multiple timeouts so set a total completion time that
                        // effects the result of InputAction.GetTimeoutCompletionPercentage()
                        // such that it accounts for the total time we allocate for the interaction
                        // rather than only the time of one single timeout.
                        context.SetTotalTimeoutCompletionTime(maxTapTime * tapCount + (tapCount - 1) * maxDelayInBetween);
                    }
                    break;

                case TapPhase.WaitingForNextRelease:
                    if (!context.ControlIsActuated(releasePointOrDefault))
                    {
                        if (context.time - m_CurrentTapStartTime <= tapTimeOrDefault)
                        {
                            ++m_CurrentTapCount;
                            if (m_CurrentTapCount >= tapCount)
                            {
                                context.Performed();
                            }
                            else
                            {
                                m_CurrentTapPhase = TapPhase.WaitingForNextPress;
                                m_LastTapReleaseTime = context.time;
                                context.SetTimeout(tapDelayOrDefault);
                            }
                        }
                        else
                        {
                            context.Canceled();
                        }
                    }
                    break;

                case TapPhase.WaitingForNextPress:
                    if (context.ControlIsActuated(pressPointOrDefault))
                    {
                        if (context.time - m_LastTapReleaseTime <= tapDelayOrDefault)
                        {
                            m_CurrentTapPhase = TapPhase.WaitingForNextRelease;
                            m_CurrentTapStartTime = context.time;
                            context.SetTimeout(tapTimeOrDefault);
                        }
                        else
                        {
                            context.Canceled();
                        }
                    }
                    break;

Yep, that’s why you were told to create your own that is based on that file but add a distance check (for our stuff, I believe that instead of distance we check if both taps / raycasts we do hit the same collider, and if they do then we consider that a double click / tap, worked well for us, but depends on how your game is set up).

Why Unity’s doesn’t have a distance check by default is anyone’s guess…

1 Like

Sure, I already did it based on Unity sources. But I had to use Mouse.current.position.value to store a click position.
The next code just doesn’t work, throws an exception:

context.action.ReadValue<Vector2>();
context.ReadValue<Vector2>();

Maybe because it’s a button action type. But I don’t need Value/Vector2 type for double click checking…

The final code is tested in the editor and the Win build. I hope this will be useful to someone. And I will be grateful if anyone can tell me how to get rid of Input.mousePosition.

namespace UnityEngine.InputSystem.Interactions
{
#if UNITY_EDITOR
    using UnityEditor;
    //Allow for the interaction to be utilized outside of Play Mode and so that it will actually show up as an option in the Input Manager
    [InitializeOnLoad]
#endif
    [Scripting.Preserve, System.ComponentModel.DisplayName("StableMultiTap"), System.Serializable]
    public class StableMultiTapInteraction : IInputInteraction<float>
    {
/// <summary>
        /// The time in seconds within which the control needs to be pressed and released to perform the interaction.
        /// </summary>
        /// <remarks>
        /// If this value is equal to or smaller than zero, the input system will use (<see cref="InputSettings.defaultTapTime"/>) instead.
        /// </remarks>
        [Tooltip("The maximum time (in seconds) allowed to elapse between pressing and releasing a control for it to register as a tap.")]
        public float tapTime;

        /// <summary>
        /// The time in seconds which is allowed to pass between taps.
        /// </summary>
        /// <remarks>
        /// If this time is exceeded, the multi-tap interaction is canceled.
        /// If this value is equal to or smaller than zero, the input system will use the duplicate value of <see cref="tapTime"/> instead.
        /// </remarks>
        [Tooltip("The maximum delay (in seconds) allowed between each tap. If this time is exceeded, the multi-tap is canceled.")]
        public float tapDelay;

        /// <summary>
        /// The number of taps required to perform the interaction.
        /// </summary>
        /// <remarks>
        /// How many taps need to be performed in succession. Two means double-tap, three means triple-tap, and so on.
        /// </remarks>
        [Tooltip("How many taps need to be performed in succession. Two means double-tap, three means triple-tap, and so on.")]
        public int tapCount = 2;

        /// <summary>
        /// Magnitude threshold that must be crossed by an actuated control for the control to
        /// be considered pressed.
        /// </summary>
        /// <seealso cref="InputControl.EvaluateMagnitude()"/>
        public float pressPoint;
       
        public float maxTapDistance = 5;

        private float tapTimeOrDefault => tapTime > 0.0 ? tapTime : InputSystem.settings.defaultTapTime;
        internal float tapDelayOrDefault => tapDelay > 0.0 ? tapDelay : InputSystem.settings.multiTapDelayTime;
        private float pressPointOrDefault => pressPoint;
        private float releasePointOrDefault => pressPointOrDefault;

        /// <inheritdoc />
        public void Process(ref InputInteractionContext context)
        {
            if (context.timerHasExpired)
            {
                // We use timers multiple times but no matter what, if they expire it means
                // that we didn't get input in time.
                context.Canceled();
                return;
            }

            switch (m_CurrentTapPhase)
            {
                case TapPhase.None:
                    if (context.ControlIsActuated(pressPointOrDefault))
                    {
                        m_CurrentTapPhase = TapPhase.WaitingForNextRelease;
                        m_CurrentTapStartTime = context.time;
                        context.Started();

                        var maxTapTime = tapTimeOrDefault;
                        var maxDelayInBetween = tapDelayOrDefault;
                        context.SetTimeout(maxTapTime);

                        // We'll be using multiple timeouts so set a total completion time that
                        // effects the result of InputAction.GetTimeoutCompletionPercentage()
                        // such that it accounts for the total time we allocate for the interaction
                        // rather than only the time of one single timeout.
                        context.SetTotalTimeoutCompletionTime(maxTapTime * tapCount + (tapCount - 1) * maxDelayInBetween);
                    }
                    break;

                case TapPhase.WaitingForNextRelease:
                    if (!context.ControlIsActuated(releasePointOrDefault))
                    {
                        if (context.time - m_CurrentTapStartTime <= tapTimeOrDefault)
                        {
                            ++m_CurrentTapCount;
                            if (m_CurrentTapCount >= tapCount && (Input.mousePosition - m_LastTapPosition).sqrMagnitude < maxTapDistance * maxTapDistance)
                            {
                                context.Performed();
                            }
                            else
                            {
                                m_CurrentTapPhase = TapPhase.WaitingForNextPress;
                                m_LastTapReleaseTime = context.time;
                                context.SetTimeout(tapDelayOrDefault);
                                m_LastTapPosition = Input.mousePosition;
                            }
                        }
                        else
                        {
                            context.Canceled();
                        }
                    }
                    break;

                case TapPhase.WaitingForNextPress:
                    if (context.ControlIsActuated(pressPointOrDefault))
                    {
                        if (context.time - m_LastTapReleaseTime <= tapDelayOrDefault)
                        {
                            m_CurrentTapPhase = TapPhase.WaitingForNextRelease;
                            m_CurrentTapStartTime = context.time;
                            context.SetTimeout(tapTimeOrDefault);
                        }
                        else
                        {
                            context.Canceled();
                        }
                    }
                    break;
            }
        }
       
        /// <inheritdoc />
        public void Reset()
        {
            m_CurrentTapPhase = TapPhase.None;
            m_CurrentTapCount = 0;
            m_CurrentTapStartTime = 0;
            m_LastTapReleaseTime = 0;
            m_LastTapPosition = Vector3.zero;
        }

        private TapPhase m_CurrentTapPhase;
        private int m_CurrentTapCount;
        private double m_CurrentTapStartTime;
        private double m_LastTapReleaseTime;
        private Vector3 m_LastTapPosition = Vector3.zero;

        private enum TapPhase
        {
            None,
            WaitingForNextRelease,
            WaitingForNextPress,
        }
       
        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
        private static void RegisterInteraction()
        {
            if (InputSystem.TryGetInteraction("StableMultiTap") == null)
            {
                //For some reason if this is called again when it already exists, it permanently removees it from the drop-down options... So have to check first
                InputSystem.RegisterInteraction<StableMultiTapInteraction>("StableMultiTap");
            }
        }

        //Constructor will be called by our Editor [InitializeOnLoad] attribute when outside Play Mode
        static StableMultiTapInteraction() => RegisterInteraction();
    }
}
1 Like

Wow, thank you for this! I just tried it out now and it works perfectly. I ran into the same issue of how to do a proper double-click in the new input system. This will definitely help me!