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}
通过代码,我们可以了解到工作流程由三个阶段构成:
- 获取相对角度
- 根据相对角度获取区域的索引
- 更新菜单显示
获取相对角度
获取相对角度的步骤如下。
- 获取指针坐标和锚点坐标
- 获取指针坐标到锚点坐标的向量
- 通过Atan2 获得弧度制角度
- 将弧度制角度转换为度数制角度
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轴正方向绕顺时针方向达到射线旋转的角的角度。
如何获取当前指针所在位置: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}
根据角度获取扇区索引
根据角度获取扇区索引的流程如下
- 360f / 扇区元素数量 得到每个扇区的的度数
- 当前角度 / 扇区度数 得到索引
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}