Last active
June 1, 2021 02:36
Virtual joystick component for Unity mobile projects.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Collections; | |
using System.Collections.Generic; | |
using UnityEngine; | |
using UnityEngine.UI; | |
#if UNITY_EDITOR && !UNITY_CLOUD_BUILD | |
using UnityEditor; | |
#endif | |
namespace Vonflaken | |
{ | |
#if UNITY_EDITOR && !UNITY_CLOUD_BUILD | |
[CustomPropertyDrawer(typeof(EnumFlagsAttribute))] | |
public class EnumFlagsAttributeDrawer : PropertyDrawer | |
{ | |
public override void OnGUI(Rect _position, SerializedProperty _property, GUIContent _label) | |
{ | |
_property.intValue = EditorGUI.MaskField(_position, _label, _property.intValue, _property.enumNames); | |
} | |
} | |
#endif | |
public class EnumFlagsAttribute : PropertyAttribute | |
{ | |
public EnumFlagsAttribute() { } | |
} | |
public class VirtualJoystick : MonoBehaviour | |
{ | |
[Header("Main")] | |
[SerializeField, Tooltip("Pivot at (0.5, 0.5) required.")] | |
private RectTransform _joystickKnob; | |
[SerializeField, Tooltip("Pivot at (0.5, 0.5) required.")] | |
private RectTransform _backgroundStencil; | |
[SerializeField] | |
private float _radiusBackground; | |
[SerializeField, Tooltip("Joystick will return zero for axis values while the stick is inside this area.")] | |
private float _radiusDeadZone; | |
[System.Flags] | |
enum JoystickSettings | |
{ | |
SpawnAtPointer = 1 << 0, | |
FollowPointer = 1 << 1, | |
} | |
[Header("Behaviour")] | |
[SerializeField, EnumFlags] | |
private JoystickSettings _settings; | |
[SerializeField, Range(0f, 1f), Tooltip("Strongness of the smooth finger following of the joystick.")] | |
public float _easeSharpness = 0.1f; | |
[SerializeField, Tooltip("Whereas or not the axis values are normalized from dead zone or center.")] | |
private bool _normalizeHappensAfterDeadZone = true; | |
[Header("Visual")] | |
[SerializeField, Tooltip("Alpha value of sprites when joystick is not being held.")] | |
private float _alphaInactive = 0.8f; | |
[SerializeField] | |
private float _scaleActive = 1.2f; | |
private Image _joystickKnobSprite; | |
private Image _backgroundStencilSprite; | |
private bool _pointerHeld = false; | |
private Vector2 _currPointerPos; | |
private Vector2 _initialPosition; | |
private float _initialScale; | |
/// <summary> | |
/// Normalized value of axis position at the moment. | |
/// </summary> | |
public Vector2 AxisValues { private set; get; } | |
private void Awake() | |
{ | |
_backgroundStencilSprite = _backgroundStencil.GetComponent<Image>(); | |
_joystickKnobSprite = _joystickKnob.GetComponent<Image>(); | |
_initialPosition = _backgroundStencil.anchoredPosition; | |
_initialScale = _backgroundStencil.localScale.x; | |
SetJoystickAlpha(_alphaInactive); | |
} | |
private void OnDrawGizmosSelected() | |
{ | |
// Draw hint for center of joystick knob | |
Gizmos.color = Color.green; | |
if (_joystickKnob != null) | |
Gizmos.DrawSphere(_joystickKnob.position, 1f); | |
if (_backgroundStencil != null) | |
{ | |
// Draw hint for checking if radius fits inside the circular sprite | |
Gizmos.color = Color.blue; | |
Vector3 worldFrom = _backgroundStencil.position; | |
Vector3 worldTo = _backgroundStencil.TransformPoint(Vector3.right * _radiusBackground); | |
Gizmos.DrawLine(worldFrom, worldTo); | |
#if UNITY_EDITOR && !UNITY_CLOUD_BUILD | |
// Add description text for being more understandable from editor | |
GUIStyle newStyle = new GUIStyle(); | |
newStyle.normal.textColor = Gizmos.color; | |
UnityEditor.Handles.Label(worldFrom, "Radius Background Stencil", newStyle); | |
#endif | |
// Draw hint for setting dead zone | |
Gizmos.color = Color.red; | |
worldTo = _backgroundStencil.TransformPoint(Vector3.left * _radiusDeadZone); | |
Gizmos.DrawLine(worldFrom, worldTo); | |
#if UNITY_EDITOR && !UNITY_CLOUD_BUILD | |
// Add description text for being more understandable from editor | |
newStyle.normal.textColor = Gizmos.color; | |
Camera guiCamera = Camera.current; | |
Vector3 screenFrom = guiCamera.WorldToScreenPoint(worldFrom); | |
Vector3 deadZoneWorldFrom = guiCamera.ScreenToWorldPoint(screenFrom + new Vector3(0f, -16f, 0f)); | |
UnityEditor.Handles.Label(deadZoneWorldFrom, "Radius Dead Zone", newStyle); | |
#endif | |
} | |
} | |
/// <summary> | |
/// Call for the joystick starts working. | |
/// </summary> | |
/// <param name="pointerPos"></param> | |
public void PointerDown(Vector2 pointerPos) | |
{ | |
if (_pointerHeld == false) | |
{ | |
_pointerHeld = true; | |
_currPointerPos = pointerPos; | |
if (IsSettingActive(JoystickSettings.SpawnAtPointer)) | |
_backgroundStencil.position = pointerPos; // Move joystick to pointer start pos | |
SetJoystickAlpha(1f); | |
SetJoystickScale(_scaleActive); | |
StartCoroutine(UpdateAxis()); | |
} | |
} | |
/// <summary> | |
/// Call for the joystick ends working. | |
/// </summary> | |
public void PointerUp() | |
{ | |
_pointerHeld = false; | |
// Reset values | |
AxisValues = Vector2.zero; | |
SetKnobPosition(Vector2.zero); | |
SetJoystickAlpha(_alphaInactive); | |
SetJoystickPosition(_initialPosition); | |
SetJoystickScale(_initialScale); | |
} | |
/// <summary> | |
/// Call for feeding the joystick with pointer position so it can update its state. | |
/// </summary> | |
/// <param name="newPointerPos"></param> | |
public void PointerMove(Vector2 newPointerPos) | |
{ | |
_currPointerPos = newPointerPos; | |
} | |
/// <summary> | |
/// Update axis value while pointer is on. | |
/// </summary> | |
/// <returns></returns> | |
private IEnumerator UpdateAxis() | |
{ | |
while (_pointerHeld) | |
{ | |
// Update axis value | |
Vector2 center = Vector2.zero; | |
Vector2 relativePointerPos = _backgroundStencil.InverseTransformPoint(_currPointerPos); // Make pointer position relative to background sprite | |
Vector2 heading = relativePointerPos; | |
float angle = Mathf.Atan2(heading.y, heading.x); // Angle between center and pointer | |
float cos = Mathf.Cos(angle); | |
float sin = Mathf.Sin(angle); | |
Vector2 maxExtent = new Vector2(cos * _radiusBackground, sin * _radiusBackground); // Outtest point in circular shape of background sprite at the direction of pointer | |
Vector2 clampedPointerPos = ClampPos(relativePointerPos, maxExtent); | |
if (IsPosInsideDeadZone(relativePointerPos, cos, sin)) | |
AxisValues = Vector2.zero; | |
else // Pointer is outside dead zone | |
{ | |
AxisValues = new Vector2(clampedPointerPos.x / _radiusBackground, clampedPointerPos.y / _radiusBackground); | |
if (_normalizeHappensAfterDeadZone) | |
{ | |
float normRadiusDeadZone = _radiusDeadZone / _radiusBackground; | |
float axisValuesLength = AxisValues.magnitude; | |
AxisValues = (AxisValues / axisValuesLength) * ((axisValuesLength - normRadiusDeadZone) / (1f - normRadiusDeadZone)); | |
} | |
} | |
if (IsSettingActive(JoystickSettings.FollowPointer)) | |
{ | |
if (!IsPointInsideJoystick(relativePointerPos)) | |
{ | |
Vector3 vectorToPointer = relativePointerPos - maxExtent; | |
float blend = 1f - Mathf.Pow(1f - _easeSharpness, Time.deltaTime * 60f); // Exponential ease-out | |
_backgroundStencil.position = Vector3.Lerp(_backgroundStencil.position, _backgroundStencil.position + vectorToPointer, blend); | |
} | |
} | |
// Update joystick knob's sprite position | |
SetKnobPosition(clampedPointerPos); | |
yield return null; | |
} | |
} | |
private void SetKnobPosition(Vector2 newPos) | |
{ | |
_joystickKnob.anchoredPosition = newPos; | |
} | |
private void SetJoystickPosition(Vector2 newPos) | |
{ | |
_backgroundStencil.anchoredPosition = newPos; | |
} | |
private void SetJoystickScale(float newScale) | |
{ | |
_backgroundStencil.localScale = new Vector3(newScale, newScale, newScale); | |
} | |
private Vector2 ClampPos(Vector2 pos, Vector2 extent) | |
{ | |
if ((pos.x > 0f && pos.x > extent.x) || (pos.x < 0f && pos.x < extent.x)) | |
pos.x = extent.x; // Clamp horizontal boundary | |
if ((pos.y > 0f && pos.y > extent.y) || (pos.y < 0f && pos.y < extent.y)) | |
pos.y = extent.y; // Clamp vertical boundary | |
return pos; | |
} | |
private bool IsSettingActive(JoystickSettings setting) | |
{ | |
return (_settings & setting) != 0; | |
} | |
private void SetJoystickAlpha(float newAlphaValue) | |
{ | |
Color newBackgroundSpriteColor = _backgroundStencilSprite.color; | |
Color newKnobSpriteColor = _joystickKnobSprite.color; | |
newBackgroundSpriteColor.a = newKnobSpriteColor.a = newAlphaValue; | |
_backgroundStencilSprite.color = newBackgroundSpriteColor; | |
_joystickKnobSprite.color = newKnobSpriteColor; | |
} | |
private bool IsPosInsideDeadZone(Vector2 pointerPos, float pointerCos, float pointerSin) | |
{ | |
Vector2 extent = new Vector2(pointerCos * _radiusDeadZone, pointerSin * _radiusDeadZone); | |
if ((pointerPos.x >= 0f && pointerPos.x <= extent.x) || (pointerPos.x <= 0f && pointerPos.x >= extent.x)) // Pointer is inside horizontal boundary? | |
if ((pointerPos.y >= 0f && pointerPos.y <= extent.y) || (pointerPos.y <= 0f && pointerPos.y >= extent.y)) // Pointer is inside vertical boundary? | |
return true; | |
return false; | |
} | |
private bool IsPointInsideJoystick(Vector2 relativePos) | |
{ | |
return relativePos.sqrMagnitude <= (_radiusBackground * _radiusBackground); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
How to use
Available behaviours
It means to be all the self-contained possible but I abstracted the input handling because I think probably your project already have some input solution that you can use so things are not duplicated.