Skip to content

Instantly share code, notes, and snippets.

@Vonflaken
Last active June 1, 2021 02:36
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save Vonflaken/c5acd43bfed76dd16ceb5dea31b21ea3 to your computer and use it in GitHub Desktop.
Save Vonflaken/c5acd43bfed76dd16ceb5dea31b21ea3 to your computer and use it in GitHub Desktop.
Virtual joystick component for Unity mobile projects.
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);
}
}
}
@Vonflaken
Copy link
Author

Vonflaken commented Feb 12, 2019

How to use

  1. Drop the script on desired game object.
  2. Link the stick sprite and background sprite in their fields in component (the widgets required to set pivot at center).
  3. Feed the joystick script with input events: onpointerdown, onpointerup and onpointermove.
  4. Set desired radius of the joystick zone and define dead zone if you want one.
  5. Use AxisValues for getting normalized values of joystick axes.
  6. Comes with some nice default values but you can config as you like. You're done!

Available behaviours

  • Sticky.
  • Spawn at pointer.
  • Follow pointer.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment