using System; using System.Collections.Generic; using Unity.Collections; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.Layouts; using UnityEngine.InputSystem.LowLevel; using UnityEngine.InputSystem.Utilities; using UnityEngine.UIElements; namespace BITKit.UX { public abstract class OnScreenControl:VisualElement { public new class UxmlTraits : VisualElement.UxmlTraits { private readonly UxmlStringAttributeDescription m_ControlPathAttribute = new () { name = "ControlPath" }; public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc) { base.Init(ve, bag, cc); var table = (OnScreenControl)ve; table.ControlPath = m_ControlPathAttribute.GetValueFromBag(bag, cc); } } protected OnScreenControl() { RegisterCallback(OnGeometryChanged); RegisterCallback(OnAttachToPanel); RegisterCallback(OnDetachFromPanel); IsEnabled.AddDisableElements(this); IsEnabled.AddListener(x => { if (x) OnEnable(); else OnDisable(); }); } private void OnDetachFromPanel(DetachFromPanelEvent evt) { IsEnabled.AddDisableElements(32); } private void OnAttachToPanel(AttachToPanelEvent evt) { IsEnabled.RemoveDisableElements(32); } private void OnGeometryChanged(GeometryChangedEvent evt) { IsEnabled.SetDisableElements(this, !visible); } protected readonly ValidHandle IsEnabled = new(); /// /// The control path (see ) for the control that the on-screen /// control will feed input into. /// /// /// A device will be created from the device layout referenced by the control path (see /// ). The path is then used to look up /// on the device. The resulting control will be fed values from /// the on-screen control. /// /// Multiple on-screen controls sharing the same device layout will together create a single /// virtual device. If, for example, one component uses "<Gamepad>/buttonSouth" /// and another uses "<Gamepad>/leftStick" as the control path, a single /// will be created and the first component will feed data to /// and the second component will feed data to /// . /// /// public string ControlPath { get => ControlPathInternal; set { IsEnabled.SetElements(1, string.IsNullOrEmpty(value) is false); ControlPathInternal = value; IsEnabled.AddDisableElements("Force"); IsEnabled.RemoveDisableElements("Force"); } } /// /// The actual control that is fed input from the on-screen control. /// /// /// This is only valid while the on-screen control is enabled. Otherwise, it is null. Also, /// if no has been set, this will remain null even if the component is enabled. /// public InputControl Control => _mControl; private InputControl _mControl; private OnScreenControl _mNextControlOnDevice; private InputEventPtr _mInputEventPtr; /// /// Accessor for the of the component. Must be implemented by subclasses. /// /// /// Moving the definition of how the control path is stored into subclasses allows them to /// apply their own attributes to them and thus set their /// own layout filters. /// protected abstract string ControlPathInternal { get; set; } private void SetupInputControl() { Debug.Assert(_mControl == null, "InputControl already initialized"); Debug.Assert(_mNextControlOnDevice == null, "Previous InputControl has not been properly uninitialized (m_NextControlOnDevice still set)"); Debug.Assert(!_mInputEventPtr.valid, "Previous InputControl has not been properly uninitialized (m_InputEventPtr still set)"); // Nothing to do if we don't have a control path. var path = ControlPathInternal; if (string.IsNullOrEmpty(path)) return; // Determine what type of device to work with. var layoutName = InputControlPath.TryGetDeviceLayout(path); if (layoutName == null) { Debug.LogError( $"Cannot determine device layout to use based on control path '{path}' used in {GetType().Name} component with {name}"); return; } // Try to find existing on-screen device that matches. var internedLayoutName = new InternedString(layoutName); var deviceInfoIndex = -1; for (var i = 0; i < OnScreenDevices.Count; ++i) { ////FIXME: this does not take things such as different device usages into account if (OnScreenDevices[i].Device.layout != internedLayoutName) continue; deviceInfoIndex = i; break; } // If we don't have a matching one, create a new one. InputDevice device; if (deviceInfoIndex == -1) { // Try to create device. try { device = InputSystem.AddDevice(layoutName); } catch (Exception exception) { Debug.LogError( $"Could not create device with layout '{layoutName}' used in '{GetType().Name}' component"); Debug.LogException(exception); return; } InputSystem.AddDeviceUsage(device, "OnScreen"); // Create event buffer. var buffer = StateEvent.From(device, out var eventPtr, Allocator.Persistent); // Add to list. deviceInfoIndex = OnScreenDevices.Count; OnScreenDevices.Add(new OnScreenDeviceInfo { EventPtr = eventPtr, Buffer = buffer, Device = device, }); } else { device = OnScreenDevices[deviceInfoIndex].Device; } // Try to find control on device. _mControl = InputControlPath.TryFindControl(device, path); if (_mControl == null) { Debug.LogError( $"Cannot find control with path '{path}' on device of type '{layoutName}' referenced by component '{GetType().Name}' with {name}"); // Remove the device, if we just created one. if (OnScreenDevices[deviceInfoIndex].FirstControl == null) { OnScreenDevices[deviceInfoIndex].Destroy(); OnScreenDevices.RemoveAt(deviceInfoIndex); } return; } _mInputEventPtr = OnScreenDevices[deviceInfoIndex].EventPtr; // We have all we need. Permanently add us. OnScreenDevices[deviceInfoIndex] = OnScreenDevices[deviceInfoIndex].AddControl(this); } protected void SendValueToControl(TValue value) where TValue : struct { if (_mControl == null) return; if (!(_mControl is InputControl control)) throw new ArgumentException( $"The control path {ControlPath} yields a control of type {_mControl.GetType().Name} which is not an InputControl with value type {typeof(TValue).Name}", nameof(value)); ////FIXME: this gives us a one-frame lag (use InputState.Change instead?) _mInputEventPtr.time = InputState.currentTime; control.WriteValueIntoEvent(value, _mInputEventPtr); InputSystem.QueueEvent(_mInputEventPtr); } protected void SentDefaultValueToControl() { if (_mControl == null) return; ////FIXME: this gives us a one-frame lag (use InputState.Change instead?) _mInputEventPtr.time = InputState.currentTime; _mControl.ResetToDefaultStateInEvent(_mInputEventPtr); InputSystem.QueueEvent(_mInputEventPtr); } protected virtual void OnEnable() { SetupInputControl(); } protected virtual void OnDisable() { if (_mControl == null) return; var device = _mControl.device; for (var i = 0; i < OnScreenDevices.Count; ++i) { if (OnScreenDevices[i].Device != device) continue; var deviceInfo = OnScreenDevices[i].RemoveControl(this); if (deviceInfo.FirstControl == null) { // We're the last on-screen control on this device. Remove the device. OnScreenDevices[i].Destroy(); OnScreenDevices.RemoveAt(i); } else { OnScreenDevices[i] = deviceInfo; // We're keeping the device , but we're disabling the on-screen representation // for one of its controls. If the control isn't in default state, reset it // to that now. This is what ensures that if, for example, OnScreenButton is // disabled after OnPointerDown, we reset its button control to zero even // though we will not see an OnPointerUp. if (!_mControl.CheckStateIsAtDefault()) SentDefaultValueToControl(); } _mControl = null; _mInputEventPtr = new InputEventPtr(); Debug.Assert(_mNextControlOnDevice == null); break; } } private struct OnScreenDeviceInfo { public InputEventPtr EventPtr; public NativeArray Buffer; public InputDevice Device; public OnScreenControl FirstControl; public OnScreenDeviceInfo AddControl(OnScreenControl control) { control._mNextControlOnDevice = FirstControl; FirstControl = control; return this; } public OnScreenDeviceInfo RemoveControl(OnScreenControl control) { if (FirstControl == control) FirstControl = control._mNextControlOnDevice; else { for (OnScreenControl current = FirstControl._mNextControlOnDevice, previous = FirstControl; current != null; previous = current, current = current._mNextControlOnDevice) { if (current != control) continue; previous._mNextControlOnDevice = current._mNextControlOnDevice; break; } } control._mNextControlOnDevice = null; return this; } public void Destroy() { if (Buffer.IsCreated) Buffer.Dispose(); if (Device != null) InputSystem.RemoveDevice(Device); Device = null; Buffer = new NativeArray(); } } private static readonly List OnScreenDevices=new (); internal string GetWarningMessage() { return $"{GetType()} needs to be attached as a child to a UI Canvas and have a RectTransform component to function properly."; } } }