using System; using Unity.Collections.LowLevel.Unsafe; using UnityEngine.InputSystem.Controls; using UnityEngine.InputSystem.LowLevel; using UnityEngine.InputSystem.Utilities; #if UNITY_EDITOR using UnityEditor; using UnityEngine.InputSystem.Editor; #endif ////TODO: add pressure support ////REVIEW: extend this beyond simulating from Pointers only? theoretically, we could simulate from any means of generating positions and presses ////REVIEW: I think this is a workable first attempt but overall, not a sufficient take on input simulation. ATM this uses InputState.Change //// to shove input directly into Touchscreen. Also, it uses state change notifications to set off the simulation. The latter leads //// to touch input potentially changing multiple times in response to a single pointer event. And the former leads to the simulated //// touch input not being visible at the event level -- which leaves Touch and Finger slightly unhappy, for example. //// I think being able to cycle simulated input fully through the event loop would result in a setup that is both simpler and more robust. //// Also, it would allow *disabling* the source devices as long as we don't disable them in the backend, too. //// Finally, the fact that we spin off input *from* events here and feed that into InputState.Change() by passing the event along //// means that places that make sure we process input only once (e.g. binding composites which will remember the event ID they have //// been triggered from) may reject the simulated input when they have already seen the non-simulated input (which may be okay //// behavior). namespace UnityEngine.InputSystem.EnhancedTouch { /// /// Adds a with input simulated from other types of devices (e.g. /// or ). /// [AddComponentMenu("Input/Debug/Touch Simulation")] [ExecuteInEditMode] [HelpURL(InputSystem.kDocUrl + "/manual/Touch.html#touch-simulation")] #if UNITY_EDITOR [InitializeOnLoad] #endif public class TouchSimulation : MonoBehaviour, IInputStateChangeMonitor { public Touchscreen simulatedTouchscreen { get; private set; } public static TouchSimulation instance => s_Instance; public static void Enable() { if (instance == null) { ////TODO: find instance var hiddenGO = new GameObject(); hiddenGO.SetActive(false); hiddenGO.hideFlags = HideFlags.HideAndDontSave; s_Instance = hiddenGO.AddComponent(); instance.gameObject.SetActive(true); } instance.enabled = true; } public static void Disable() { if (instance != null) instance.enabled = false; } public static void Destroy() { Disable(); if (s_Instance != null) { Destroy(s_Instance.gameObject); s_Instance = null; } } protected void AddPointer(Pointer pointer) { if (pointer == null) throw new ArgumentNullException(nameof(pointer)); // Ignore if already added. if (m_Pointers.ContainsReference(m_NumPointers, pointer)) return; // Add to list. var numPointers = m_NumPointers; ArrayHelpers.AppendWithCapacity(ref m_Pointers, ref m_NumPointers, pointer); ArrayHelpers.AppendWithCapacity(ref m_CurrentPositions, ref numPointers, default); InputSystem.DisableDevice(pointer, keepSendingEvents: true); } protected void RemovePointer(Pointer pointer) { if (pointer == null) throw new ArgumentNullException(nameof(pointer)); // Ignore if not added. var pointerIndex = m_Pointers.IndexOfReference(pointer, m_NumPointers); if (pointerIndex == -1) return; // Cancel all ongoing touches from the pointer. for (var i = 0; i < m_Touches.Length; ++i) { var button = m_Touches[i]; if (button != null && button.device != pointer) continue; UpdateTouch(i, pointerIndex, TouchPhase.Canceled); } // Remove from list. var numPointers = m_NumPointers; m_Pointers.EraseAtWithCapacity(ref m_NumPointers, pointerIndex); m_CurrentPositions.EraseAtWithCapacity(ref numPointers, pointerIndex); // Re-enable the device (only in case it's still added to the system). if (pointer.added) InputSystem.EnableDevice(pointer); } private unsafe void OnEvent(InputEventPtr eventPtr, InputDevice device) { var pointerIndex = m_Pointers.IndexOfReference(device, m_NumPointers); if (pointerIndex < 0) return; var eventType = eventPtr.type; if (eventType != StateEvent.Type && eventType != DeltaStateEvent.Type) return; ////TODO: this can be simplified if we use events instead of InputState.Change() but doing so requires work on buffering events while processing; also //// needs extra handling to not lag into the next frame ////REVIEW: should we have specialized paths for MouseState and PenState here? (probably can only use for StateEvents) // Read pointer position. var positionControl = m_Pointers[pointerIndex].position; var positionStatePtr = positionControl.GetStatePtrFromStateEventUnchecked(eventPtr, eventType); if (positionStatePtr != null) m_CurrentPositions[pointerIndex] = positionControl.ReadValueFromState(positionStatePtr); // End touches for which buttons are no longer pressed. ////REVIEW: There must be a better way to do this for (var i = 0; i < m_Touches.Length; ++i) { var button = m_Touches[i]; if (button == null || button.device != device) continue; var buttonStatePtr = button.GetStatePtrFromStateEventUnchecked(eventPtr, eventType); if (buttonStatePtr == null) { // Button is not contained in event. If we do have a position update, issue // a move on the button's corresponding touch. This makes us deal with delta // events that only update pointer positions. if (positionStatePtr != null) UpdateTouch(i, pointerIndex, TouchPhase.Moved, eventPtr); } else if (button.ReadValueFromState(buttonStatePtr) < (ButtonControl.s_GlobalDefaultButtonPressPoint * ButtonControl.s_GlobalDefaultButtonReleaseThreshold)) UpdateTouch(i, pointerIndex, TouchPhase.Ended, eventPtr); } // Add/update touches for buttons that are pressed. foreach (var control in eventPtr.EnumerateControls(InputControlExtensions.Enumerate.IgnoreControlsInDefaultState, device)) { if (!control.isButton) continue; // Check if it's pressed. var buttonStatePtr = control.GetStatePtrFromStateEventUnchecked(eventPtr, eventType); Debug.Assert(buttonStatePtr != null, "Button returned from EnumerateControls() must be found in event"); var value = 0f; control.ReadValueFromStateIntoBuffer(buttonStatePtr, UnsafeUtility.AddressOf(ref value), 4); if (value <= ButtonControl.s_GlobalDefaultButtonPressPoint) continue; // Not in default state but also not pressed. // See if we have an ongoing touch for the button. var touchIndex = m_Touches.IndexOfReference(control); if (touchIndex < 0) { // No, so add it. touchIndex = m_Touches.IndexOfReference((ButtonControl)null); if (touchIndex >= 0) // If negative, we're at max touch count and can't add more. { m_Touches[touchIndex] = (ButtonControl)control; UpdateTouch(touchIndex, pointerIndex, TouchPhase.Began, eventPtr); } } else { // Yes, so update it. UpdateTouch(touchIndex, pointerIndex, TouchPhase.Moved, eventPtr); } } eventPtr.handled = true; } private void OnDeviceChange(InputDevice device, InputDeviceChange change) { // If someone removed our simulated touchscreen, disable touch simulation. if (device == simulatedTouchscreen && change == InputDeviceChange.Removed) { Disable(); return; } switch (change) { case InputDeviceChange.Added: { if (device is Pointer pointer) { if (device is Touchscreen) return; ////TODO: decide what to do AddPointer(pointer); } break; } case InputDeviceChange.Removed: { if (device is Pointer pointer) RemovePointer(pointer); break; } } } protected void OnEnable() { if (simulatedTouchscreen != null) { if (!simulatedTouchscreen.added) InputSystem.AddDevice(simulatedTouchscreen); } else { simulatedTouchscreen = InputSystem.GetDevice("Simulated Touchscreen") as Touchscreen; if (simulatedTouchscreen == null) simulatedTouchscreen = InputSystem.AddDevice("Simulated Touchscreen"); } if (m_Touches == null) m_Touches = new ButtonControl[simulatedTouchscreen.touches.Count]; foreach (var device in InputSystem.devices) OnDeviceChange(device, InputDeviceChange.Added); if (m_OnDeviceChange == null) m_OnDeviceChange = OnDeviceChange; if (m_OnEvent == null) m_OnEvent = OnEvent; InputSystem.onDeviceChange += m_OnDeviceChange; InputSystem.onEvent += m_OnEvent; } protected void OnDisable() { if (simulatedTouchscreen != null && simulatedTouchscreen.added) InputSystem.RemoveDevice(simulatedTouchscreen); // Re-enable all pointers we disabled. for (var i = 0; i < m_NumPointers; ++i) InputSystem.EnableDevice(m_Pointers[i]); m_Pointers.Clear(m_NumPointers); m_Touches.Clear(); m_NumPointers = 0; m_LastTouchId = 0; m_PrimaryTouchIndex = -1; InputSystem.onDeviceChange -= m_OnDeviceChange; InputSystem.onEvent -= m_OnEvent; } private unsafe void UpdateTouch(int touchIndex, int pointerIndex, TouchPhase phase, InputEventPtr eventPtr = default) { var position = m_CurrentPositions[pointerIndex]; var touch = new TouchState { phase = phase, position = position }; var time = eventPtr.valid ? eventPtr.time : InputState.currentTime; var oldTouchState = (TouchState*)((byte*)simulatedTouchscreen.currentStatePtr + simulatedTouchscreen.touches[touchIndex].stateBlock.byteOffset); if (phase == TouchPhase.Began) { touch.isPrimaryTouch = m_PrimaryTouchIndex < 0; touch.startTime = time; touch.startPosition = position; touch.touchId = ++m_LastTouchId; touch.tapCount = oldTouchState->tapCount; // Get reset automatically by Touchscreen. if (touch.isPrimaryTouch) m_PrimaryTouchIndex = touchIndex; } else { touch.touchId = oldTouchState->touchId; touch.isPrimaryTouch = m_PrimaryTouchIndex == touchIndex; touch.delta = position - oldTouchState->position; touch.startPosition = oldTouchState->startPosition; touch.startTime = oldTouchState->startTime; touch.tapCount = oldTouchState->tapCount; if (phase == TouchPhase.Ended) { touch.isTap = time - oldTouchState->startTime <= Touchscreen.s_TapTime && (position - oldTouchState->startPosition).sqrMagnitude <= Touchscreen.s_TapRadiusSquared; if (touch.isTap) ++touch.tapCount; } } if (touch.isPrimaryTouch) InputState.Change(simulatedTouchscreen.primaryTouch, touch, eventPtr: eventPtr); InputState.Change(simulatedTouchscreen.touches[touchIndex], touch, eventPtr: eventPtr); if (phase.IsEndedOrCanceled()) { m_Touches[touchIndex] = null; if (m_PrimaryTouchIndex == touchIndex) m_PrimaryTouchIndex = -1; } } [NonSerialized] private int m_NumPointers; [NonSerialized] private Pointer[] m_Pointers; [NonSerialized] private Vector2[] m_CurrentPositions; [NonSerialized] private ButtonControl[] m_Touches; [NonSerialized] private int m_LastTouchId; [NonSerialized] private int m_PrimaryTouchIndex = -1; [NonSerialized] private Action m_OnDeviceChange; [NonSerialized] private Action m_OnEvent; internal static TouchSimulation s_Instance; #if UNITY_EDITOR static TouchSimulation() { // We're a MonoBehaviour so our cctor may get called as part of the MonoBehaviour being // created. We don't want to trigger InputSystem initialization from there so delay-execute // the code here. EditorApplication.delayCall += () => { InputSystem.onSettingsChange += OnSettingsChanged; InputSystem.onBeforeUpdate += ReEnableAfterDomainReload; }; } private static void ReEnableAfterDomainReload() { OnSettingsChanged(); InputSystem.onBeforeUpdate -= ReEnableAfterDomainReload; } private static void OnSettingsChanged() { if (InputEditorUserSettings.simulateTouch) Enable(); else Disable(); } #endif ////TODO: Remove IInputStateChangeMonitor from this class when we can break the API void IInputStateChangeMonitor.NotifyControlStateChanged(InputControl control, double time, InputEventPtr eventPtr, long monitorIndex) { } void IInputStateChangeMonitor.NotifyTimerExpired(InputControl control, double time, long monitorIndex, int timerIndex) { } // Disable warnings about unused parameters. #pragma warning disable CA1801 ////TODO: [Obsolete] protected void InstallStateChangeMonitors(int startIndex = 0) { } ////TODO: [Obsolete] protected void OnSourceControlChangedValue(InputControl control, double time, InputEventPtr eventPtr, long sourceDeviceAndButtonIndex) { } ////TODO: [Obsolete] protected void UninstallStateChangeMonitors(int startIndex = 0) { } } }