..

Nova ButtonRing 的设计与实现

🌴 Intro

ButtonRing 是 Nova 框架提供的一个环形按钮菜单。

环形菜单是一种特殊的用户界面设计,在视觉上呈现为一个圆形或环形的菜单结构。

它通常围绕中心布局,选项按照圆环状排列。这种设计使得菜单项均匀分布在中心点周围,用户可以通过旋转或滑动手指来选择不同的选项。

环形菜单的主要优点是直观、节省空间、并且适合于触摸屏设备,尤其在一些需要单手操作的情境下更为实用。

ButtonRing 源码位置为 Assets\Nova\Sources\Scripts\UI\ButtonRing\

🛠️ Implementation

ButtonRing 的工作流程

我们可以通过 ButtonRing 的 Update 方法来了解环形按钮菜单的工作流程。

 1// file: ButtonRing.cs
 2
 3private void Update()
 4{
 5    // 1. 获取当前锚点与鼠标的相对角度
 6    var pointerRelativeAngle = CalculatePointerRelative(out var distance);
 7    // 判断值是否有效
 8    if (float.IsNaN(pointerRelativeAngle))
 9    {
10        return;
11    }
12
13		// 2. 获取扇区索引
14		//     判断当前是否处于圆盘中心区域,如果是,则索引为 -1
15		//     否则获取当前角度的扇区索引,以及该索引所代表的功能名称
16    if (distance < innerRatio * _sectorRadius)
17    {
18        selectedSectorIndex = -1;
19        actionNameText.text = "";
20    }
21    else
22    {
23        selectedSectorIndex = GetSectorIndexAtAngle(pointerRelativeAngle + angleOffset);
24        actionNameText.text = I18n.__(_sectors[selectedSectorIndex].actionI18nName);
25    }
26
27		// 3. 更新菜单显示
28    for (int i = 0; i < sectorObjects.Count; i++)
29    {
30        sectorObjects[i].SetActive(selectedSectorIndex == i - 1);
31    }
32}

通过代码,我们可以了解到工作流程由三个阶段构成:

  1. 获取相对角度
  2. 根据相对角度获取区域的索引
  3. 更新菜单显示

获取相对角度

获取相对角度的步骤如下。

  1. 获取指针坐标和锚点坐标
  2. 获取指针坐标到锚点坐标的向量
  3. 通过Atan2 获得弧度制角度
  4. 将弧度制角度转换为度数制角度
 1// file: ButtonRing.cs
 2
 3private float CalculatePointerRelative(out float distance)
 4{
 5		// 获取指针在当前画布下的坐标系
 6    var pointerPos = currentCanvas.ScreenToCanvasPosition(RealInput.pointerPosition);
 7    // 获取锚点坐标
 8    var anchorPos = preCalculatedAnchorPos;
 9    // 获取锚点到指针的向量
10    var diff = pointerPos - anchorPos;
11    // 获取向量的长度
12    distance = diff.magnitude;
13    // 计算从锚点到指针的向量与正 x 轴之间的夹角
14    var angle = Mathf.Atan2(diff.y, diff.x);
15
16		// Mathf.Atan2 的结果范围是 [-π, π],但是为了保持角度在 [0, 2π) 之间,负角度被调整为正值
17		// 例如,-π/4 会变成 7π/4。
18    if (angle < 0f)
19    {
20        angle = Mathf.PI * 2f + angle;
21    }
22
23		// 将弧度制角度转换为度数制,以便返回的 angle 在 [0, 360) 范围内。
24    angle *= Mathf.Rad2Deg;
25
26    return angle;
27}

$atan2(y,x)$ 所表达的意思是坐标原点为起点,指向 $(x,y)$ 的射线在坐标平面上与x轴正方向之间的角的角度。

当 $y > 0$ 时,射线与x轴正方向的所得的角的角度指的是x轴正方向绕逆时针方向到达射线旋转的角的角度;而当 $y < 0$ 时,射线与x轴正方向所得的角的角度指的是x轴正方向绕顺时针方向达到射线旋转的角的角度。

Atan2

如何获取当前指针所在位置:RealInput 的实现

ButtonRing 类中,我们通过 RealInput 来获取当前的

ButtonRing 使用了 RealInput 类传递指针坐标,RealInput 的实现如下。

看着虽然很有些复杂,但实际上所做的事情是相当简单的,就是从新版输入系统中拿到当前点击的坐标点的坐标。

 1[RequireComponent(typeof(InputSystemUIInputModule))]
 2public class RealInput : MonoBehaviour
 3{
 4    private static RealInput Current;
 5    
 6    private InputAction action;
 7
 8    private void Awake()
 9    {
10        Current = this;
11        Focused = true;
12        action = GetComponent<InputSystemUIInputModule>().point.action;
13    }
14
15    public static Vector2 pointerPosition
16    {
17        get
18        {
19            if (Current?.action == null)
20            {
21                return Vector2.positiveInfinity;
22            }
23
24            return Current.action.ReadValue<Vector2>();
25        }
26    }
27}

如何获取当前指针所在位置:获取 RectTransform 坐标

通过 RealInput.pointerPosition 获取到当前指针的坐标点之后,再通过 ScreenToCanvasPosition 方法将 Transform 坐标转换为 RectTransform 坐标。

方法的实现如

 1// file: Utils.cs
 2
 3// 1. 将 screenPosition 转换为视口坐标 viewportPosition。
 4// screenPosition.x / RealScreen.width
 5//     计算 screenPosition.x 占屏幕宽度的比例,将 x 坐标映射到 [0, 1] 之间。
 6// screenPosition.y / RealScreen.height
 7//     计算 screenPosition.y 占屏幕高度的比例,将 y 坐标映射到 [0, 1] 之间。
 8// 0.0f 作为 z 坐标(通常 UI 只需要二维坐标,因此 z 设为 0)。
 9// 
10// 2. 调用 ViewportToCanvasPosition 方法,将视口坐标 viewportPosition 转换为画布坐标。
11public static Vector3 ScreenToCanvasPosition(this Canvas canvas, Vector3 screenPosition)
12{
13    var viewportPosition = new Vector3(
14        screenPosition.x / RealScreen.width,
15        screenPosition.y / RealScreen.height,
16        0.0f
17    );
18    return canvas.ViewportToCanvasPosition(viewportPosition);
19}
20
21// 1. 中心调整:
22//     将 viewportPosition 减去 (0.5, 0.5, 0)
23//     得到相对于中心的视口坐标(centerBasedViewPortPosition)
24//     这样视口的 (0,0) 在画布中心而不是左下角。
25// 2. 获取画布大小:
26//     canvas.GetComponent<RectTransform>() 获取画布的 RectTransform
27//     其中 sizeDelta 是画布的宽度和高度。
28// 3. 缩放:
29//     使用 Vector3.Scale 将 centerBasedViewPortPosition 缩放到画布尺寸上
30//     最终得到相对于画布大小的实际坐标。
31public static Vector3 ViewportToCanvasPosition(this Canvas canvas,
32		Vector3 viewportPosition)
33{
34    var centerBasedViewPortPosition = viewportPosition - new Vector3(0.5f, 0.5f, 0.0f);
35    var canvasRect = canvas.GetComponent<RectTransform>();
36    var scale = canvasRect.sizeDelta;
37    return Vector3.Scale(centerBasedViewPortPosition, scale);
38}

根据角度获取扇区索引

根据角度获取扇区索引的流程如下

  1. 360f / 扇区元素数量 得到每个扇区的的度数
  2. 当前角度 / 扇区度数 得到索引
 1// file: ButtonRing.cs
 2
 3private int GetSectorIndexAtAngle(float angle)
 4{
 5    var sectorRange = 360f / _sectors.Count;
 6    var index = Mathf.FloorToInt(angle / sectorRange);
 7    if (index < 0)
 8    {
 9        index += _sectors.Count;
10    }
11
12    return index;
13}

更新 UI 显示

Nova 框架提前准备了若干扇形图标,根据索引进行 Sprite 替换即可。

ButtonRingTrigger 的工作流程

ButtonRingTrigger 用于控制环形按钮菜单的显示和隐藏。主要方法如下类图所示。

classDiagram

class ButtonRingTrigger {
    + Show(bool holdOpen) void
    + Hide(bool triggerAction) void
    - AdjustAnchorPosition() void
    + ShowIfPointerMoved() void
    + NoShowIfPointerMoved()
}

ButtonRing 的显示和隐藏方法都由 GameViewInput 类进行调用。这部分内容将放到游戏输入的章节进行介绍讲解,这里不进行过多赘述。

显示和隐藏的功能实现起来非常简单,无非是将指定的游戏对象进行显示和隐藏。

如果希望在菜单已经显示出来后,点击其他位置更改菜单位置,可以调用 ShowIfPointerMoved 方法实现。

LateUpdate 中的内容则主要是为了避免 Show 被无意义调用。

 1// file: ButtonRingTrigger.cs
 2
 3public void Show(bool holdOpen)
 4{
 5    if (buttonShowing)
 6    {
 7        return;
 8    }
 9
10    this.holdOpen = holdOpen;
11
12    AdjustAnchorPosition();
13    buttonShowing = true;
14    backgroundBlur.gameObject.SetActive(true);
15    buttonRing.gameObject.SetActive(true);
16
17    if (holdOpen)
18    {
19        buttonRing.BeginEntryAnimation();
20    }
21}
22
23public void Hide(bool triggerAction)
24{
25    if (!buttonShowing)
26    {
27        return;
28    }
29
30    holdOpen = false;
31
32    lastPointerPosition = null;
33    buttonShowing = false;
34    if (!triggerAction)
35    {
36        buttonRing.SuppressNextAction();
37    }
38
39    backgroundBlur.gameObject.SetActive(false);
40    buttonRing.gameObject.SetActive(false);
41}
42
43public void ShowIfPointerMoved()
44{
45    lastPointerPosition = RealInput.pointerPosition;
46}
47
48private void LateUpdate()
49{
50    if (!buttonShowing &&
51        lastPointerPosition != null &&
52        (RealInput.pointerPosition - lastPointerPosition.Value).magnitude > sectorRadius * 0.5f)
53    {
54        Show(false);
55    }
56
57    // Wait for all sectors to initialize
58    if (!isFirstCalled) return;
59    ForceHideChildren();
60    isFirstCalled = false;
61}
There is nothing new under the sun.