在本文,筆者將簡單絮叨絮叨如何做一個代碼極簡但功能完善的基于 UGUI 的搖桿組件。
前言:
筆者需要一個搖桿,找了幾個別人寫好的輪子,感覺不怎么好用,那就練練手寫一個唄。
需求:
- 在一定范圍內都能觸發(fā)搖桿。
- 在觸發(fā)區(qū)域按下后,搖桿 (方位盤+搖柄) 展示出來。
- 拖拽鼠標,搖桿跟隨,且驅動方位指示器。
- 要支持設置搖桿可用的軸(僅激活 x/y 軸 OR 全部激活)。
- 要有搖桿底盤固定/動態(tài)一鍵切換的功能。(2019.11新增的需求)
分析:
-
根據需求,我們使用 UGUI搭建一個這樣的UI架構:
- Joystick 用來監(jiān)聽 UGUI 光標事件,也就限制了搖桿范圍。
- Backgroud 作為Handle 和 Direction的父節(jié)點(容器),使得 Handle 和 Direction 更方便運算和控制狀態(tài)。
- Direction 切圖切成了這樣,所以將 Pivot 手動拖到了 BackGround 中心。如切圖到位就不用設置。
-
UI搭好了,該如何驅動它們呢?
答: 很簡單,只需要繼承以下幾個接口就好啦:- IPointerDownHandler - 當鼠標按下時,更新搖桿 (BackGround 游戲對象) 的位置
- IDragHandler - 當鼠標拖拽時,更新 Handle 位置(其實挺有意思的,拖拽到某一刻的世界坐標減去按下時的坐標就是 Handle 本地坐標)
- IPointerUpHandler - 當鼠標釋放時,復位 BackGround 和 Handle。
-
搖柄動起來了,可是我們怎么驅動其他游戲對象運動呢?
答:在每一幀,通過OnValueChanged事件向注冊了該事件的游戲對象分發(fā)搖桿相對于 BackGround 的偏移量以驅動這些游戲對象運動。
這個偏移量實際上也就是 Handle 在 BackGround 游戲對象中的局部坐標,如下圖藍色向量:
告訴你什么叫靈魂畫手- BackGround位置由 紅色向量表示。
- Handle 位置由綠色向量表示。
- Handle 偏移量由藍色向量表示。
-
那怎么限制搖柄被拖拽的最遠距離呢?
答: 上面已經分析了,藍色向量就是搖桿 Handle 的局部坐標。我們把藍色向量的長度限制住,然后賦值回去不就OK啦。
-
上圖中有一個 黃色的方向指示器,怎么同步它呢 ?
答: 在本例做好 Pivot 設置,然后設置 localEulerAngles 的 z 軸就好啦。
如果指示器切圖的中心與 BackGround 重合,Pivot 都不需要設置。
演示:

代碼:
以下代碼已經不是最新的了,請挪步文末 Github 獲取工程體驗更佳!
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Events;
namespace zFrame.UI
{
public class Joystick : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
{
public float maxRadius = 100; //Handle 移動最大半徑
public JoystickEvent OnValueChanged = new JoystickEvent(); //事件
[System.Serializable] public class JoystickEvent : UnityEvent<Vector2> { }
private RectTransform backGround, handle,direction; //搖桿背景、搖桿手柄、方向指引
private Vector2 joysticValue = Vector2.zero;
public bool IsDraging { get; private set; }
private void Awake()
{
backGround = transform.Find("BackGround") as RectTransform;
handle = transform.Find("BackGround/Handle") as RectTransform;
direction = transform.Find("BackGround/Direction") as RectTransform;
direction.gameObject.SetActive(false);
}
void Update()
{
if (IsDraging) //搖桿拖拽進行時驅動事件
{
joysticValue.x = handle.anchoredPosition.x / maxRadius;
joysticValue.y = handle.anchoredPosition.y / maxRadius;
OnValueChanged.Invoke(joysticValue);
}
}
//按下時同步搖桿位置
void IPointerDownHandler.OnPointerDown(PointerEventData eventData)
{
Vector3 backGroundPos = new Vector3() // As it is too long for trinocular operation so I create Vector3 like this.
{
x = eventData.position.x,
y = eventData.position.y,
z = (null == eventData.pressEventCamera) ? backGround.position.z :
eventData.pressEventCamera.WorldToScreenPoint(backGround.position).z //無奈,這個坐標轉換不得不做啊,就算來來回回的折騰。
};
backGround.position = (null == eventData.pressEventCamera)?backGroundPos : eventData.pressEventCamera.ScreenToWorldPoint(backGroundPos);
//Vector3 vector;
//if (RectTransformUtility.ScreenPointToWorldPointInRectangle(transform as RectTransform, eventData.position, eventData.pressEventCamera, out vector))
//{
// backGround.position = vector;
//}
IsDraging = true;
}
// 當鼠標拖拽時
void IDragHandler.OnDrag(PointerEventData eventData)
{
Vector2 backGroundPos = (null == eventData.pressEventCamera) ?
backGround.position : eventData.pressEventCamera.WorldToScreenPoint(backGround.position);
Vector2 direction = eventData.position - backGroundPos; //得到方位盤中心指向光標的向量
float distance = Vector3.Magnitude(direction); //獲取向量的長度
float radius = Mathf.Clamp(distance, 0, maxRadius); //鎖定 Handle 半徑
handle.localPosition = direction.normalized * radius; //更新 Handle 位置
UpdateDirectionArrow(direction);
//Vector2 vector;
//if (RectTransformUtility.ScreenPointToLocalPointInRectangle(backGround, eventData.position, eventData.pressEventCamera, out vector))
//{
//float distance = Vector3.Magnitude(vector); //獲取向量的長度
//float radius = Mathf.Clamp(distance, 0, maxRadius); //鎖定 Handle 半徑
//handle.localPosition = vector.normalized * radius; //更新 Handle 位置
//UpdateDirectionArrow(vector);
//}
}
//更新指向器的朝向
private void UpdateDirectionArrow(Vector2 position)
{
if (position.x!=0||position.y!=0)
{
direction.gameObject.SetActive(true);
direction.localEulerAngles= new Vector3 (0,0,Vector2.Angle(Vector2.right,position)*(position.y>0?1:-1));
}
}
// 當鼠標停止拖拽時
void IPointerUpHandler.OnPointerUp(PointerEventData eventData)
{
direction.gameObject.SetActive(false);
backGround.localPosition = Vector3.zero;
handle.localPosition = Vector3.zero;
IsDraging = false;
}
}
}
- 需要注意的是 RectTransform.positon/localPositon 是受 Pivot 影響的。所以本例中的 BackGround 、Handle 的 Pivot 均為 (0.5,0.5).
- 因為 Canvas 有 3 個渲染模式,所以為了適配這三個模式,在非 Overlay 模式下,必須進行坐標轉換。
-
借助 RectTransformUtility.ScreenPointToLocalPoint(WorldPoint)InRectangle(...) 可以做到不需要我們自己寫坐標轉換,但注釋掉了利用Utility 處理的那部分代碼。因為其效率相對低,原由如下:
用在本例則太多不必要的射線檢測
更新:
- 修復坐標轉換時z軸未正確換算的問題。
- 不會被多個手指誤觸。
- 新增驅動小玩偶示例,使用三種方法控制其運動
- 整理目錄,完善第一人稱Demo,剝離指向器為可選組件,新增動態(tài)/靜態(tài)搖桿功能。 - 更新 2019年11月28日
鏈接:
結語:
- 由于是使用 UGUI 做的,所以直接能在移動端/觸控屏上使用。
- 觸屏設備支持多個搖桿同時搞事情。
- Canvas 所有的Render Mode下均能正常使用。
- 轉載請注明出處,謝謝!
希望對大家有所幫助,喜歡本文記得給個贊喲!謝謝~





