需求:
1.一个类似背包的界面,鼠标悬浮时可以选中,wasd可以切换选中,选中时出现悬浮面板。
2.有滚动条,可以使用鼠标滚轮、wasd进行滚动。使用wasd切换选中道具时,若道具是界面最下面/最上面一排,则自动下滚/上滚。
实现
1.界面基础
1.1.类似背包的界面
使用Scroll Rect和Grid Layout Group等组件制作,网上有大量相关教程,这里就先跳过。需要注意的是,这里介绍的实现方式中,scroll rect只有纵向滚动,并且需要把scroll rect的movement type设置为clamped,将scrollbar的visiblity设置为。Auto Hide And Expand View。
1.2按钮的选中(select)
需要满足以下需求:
1.鼠标悬浮时选中
2.wasd切换选中
3.选中时播放音效、按键样式改变
其实以上需求都比较简单,最重要的是第一点需求,鼠标悬浮选中,我通常使用两个方法来解决:
public void OverSelected(Button btn)
{
btn.Select();
//注意,需要引用 UnityEngine.UI 命名空间才可声明Button变量
}
public void OnSelected()
{
AudioManager.Instance.PlaySFX(btnAudioClip);
}
其中,这个AudioManager.Instance.PlaySFX(btnAudioClip);是播放按钮选中音效的方法,使用了单例模式,详情可以看:unity全局音量管理/全局音量设置与音量设置界面(含静音功能)
按键对象创建一个Event Trigger组件,将这个方法所在的脚本挂载到Event Trigger组件的PointerEnter和Select事件下面,分别选择调用这两个方法,再将这个按钮组件作为参数赋给第一个方法即可,
至于wasd切换选中,创建button组件时button的Navigation(导航)是默认为Automatic,即使用wasd自动切换至最近的按钮,你也可以将Navigation设定为Explicit,此时就可以自定义wasd切换到的按钮。
最后,按键样式改变也是只使用button组件就可以解决的,只需要对transition进行设置就行。
1.3悬浮面板
基本上还是通过EventTrigger的select事件,将悬浮面板激活/取消激活,在此基础上可进一步实现悬浮面板激活位置的设置,使用scriptablie object即可实现悬浮面板内容设置等,这里就先跳过了。
1.4鼠标点击背景时不取消其他按键的选择
在按键被选择时点击背景,此时会选中背景,按键会被取消选择。虽然土豆兄弟中的实现方式是:点击背景会取消按键的选择,而点击wasd时会自动选中一按键;但如果你想不取消其他按键的选择,可以:
1.安装免费的输入系统 Input System,在安装后,在EventSystem对象的Input System UI Input Module脚本中,取消勾选Deselect On Background Click。
2.注释掉默认EventSystem对象的StandaloneInputModule中的第591行,
DeselectIfSelectionChanged(currentOverGo, pointerEvent);
建议将这个脚本整个复制再注释掉,而不是直接改动原脚本。随后再将这个脚本挂载到 Event System上,并取消原脚本的激活即可。以上内容来自http://t.csdn.cn/XthOc
2.实现土豆兄弟的wasd操控面板滚动
这也是这篇文章的大头部分,因为网上好像对这个功能的实现几乎没有讨论,可能是太好实现?也有可能是在闷声发大财吧wwww
我的实现方式说不上是最好的、最完美的,如果有更好的实现方法欢迎讨论。
直接上代码:
using UnityEngine;
using UnityEngine.UI;
public class ScrollRectT : MonoBehaviour
{
[Header("基本对象:滚动板、滚动条、排布")]
[SerializeField] ScrollRect scrollRect;//操控上下滚动
[SerializeField] Scrollbar ChoosingBar;//设置步数
[SerializeField] GameObject GridLayerPanel;//GridLayoutGroup的对象,获取子物体、动态增长
[Header("显示步数")]
[SerializeField] int ViewableLength;//原显示行数
private int ChoosingLengthTemp;//额外增长的格数=子物体/单行物体-原显示行数
[Header("原排布板尺寸与单个步长")]
[SerializeField] Vector2 PanelSize;//用于动态增长
[SerializeField] float stepLength;//一格步长
[Header("滚轮适配")]
[SerializeField] CanvasGroup LeftCanvasGroup;//使用canvas group解决滚轮操控问题
[SerializeField] float scrollwheelDelayTime;//滚轮延迟时间
[Header("mask上下边界")]//需要在编辑模式下获取
[SerializeField] float TopBoarder;
[SerializeField] float BottomBoarder;
[Header("编辑模式")]
[SerializeField] bool editing;
[SerializeField] GameObject topBtn;
[SerializeField] GameObject btmBtn;
private float stepLengthTemp=0;
void Awake()
{
//ChoosingLengthTemp:额外增长的格数=子物体/单行物体-原显示行数
//另外,gridlayoutgroup需要设置单行子物体数量
ChoosingLengthTemp = Mathf.CeilToInt(1f * GridLayerPanel.transform.childCount / GridLayerPanel.GetComponent<GridLayoutGroup>().constraintCount) - ViewableLength;
if (ChoosingLengthTemp <= 0) ChoosingLengthTemp = 0;
else //有额外增长格数,需要对GridLayerPanel进行向下动态增长
{
GridLayerPanel.GetComponent<RectTransform>().sizeDelta = new Vector2(PanelSize.x, PanelSize.y + ChoosingLengthTemp * stepLength);
GridLayerPanel.GetComponent<RectTransform>().anchoredPosition = new Vector2(0, ChoosingLengthTemp * stepLength / 2);
}
//基于scrollbar的步数实现滚动功能,但需要注意的是步数numberOfSteps<2时步数将重新自动设定
ChoosingBar.numberOfSteps = (ChoosingLengthTemp + 1 > 2) ? ChoosingLengthTemp + 1 : 2;
//将面板置顶
scrollRect.verticalNormalizedPosition = 1f;
#if UNITY_EDITOR
//仅编辑器中调用,使用InvokeRepeating每秒调用一次编辑函数
if (editing) if (editing) InvokeRepeating("Editing", 0f, 1f);
#endif
}
void Update()
{
//适配鼠标滚轮
if (Input.GetAxis("Mouse ScrollWheel") != 0)
{
LeftCanvasGroup.blocksRaycasts = false;
Invoke("ReTrueRaycast", scrollwheelDelayTime);//延迟重新激活blocksRaycasts
}
}
#if UNITY_EDITOR
void Editing()
{
if (editing)
{
Debug.Log("top: " + topBtn.transform.position.y);
Debug.Log("bottom: " + btmBtn.transform.position.y);
if (stepLengthTemp == 0)
{
//通过数学方法直接求出步长
stepLength = (topBtn.transform.position.y - btmBtn.transform.position.y) / ViewableLength;
Debug.Log(stepLength);
}
}
}
#endif
public void ScrollBTNOnSelected(GameObject gameObject)
{
//获取按键gameObject的世界坐标,并由此判断其相对于Mask的位置,以此实现滚动
if (gameObject.transform.position.y < BottomBoarder)//在mask以下,下滚一步
{
scrollRect.verticalNormalizedPosition -= 1f / (ChoosingBar.numberOfSteps-1);
//用ChoosingBar.value-= 1f / (ChoosingBar.numberOfSteps-1);也行
}
else if (gameObject.transform.position.y > TopBoarder)//在mask以上,上滚一步
{
scrollRect.verticalNormalizedPosition += 1f / (ChoosingBar.numberOfSteps-1);
}
}
private void ReTrueRaycast()
{
LeftCanvasGroup.blocksRaycasts = true;
}
}
代码部分可以看注释,这里就先不做详细解释了。
使用方法:
1.如图配置红框部分
2.创建子物体
创建累计 单行子物体数*显示行数+1个子物体,如图我创建了26个子物体,用以获取mask上下界大概位置。
3.开始编辑模式
勾选editing,将第一行的一个子物体与最后一行的一个子物体分别挂进top button和btm button,运行并将面板拖动到上、下极限位置,获取数据。
可以看到,Mask上边界略大于796,小于946,下边界略小于196,大于46。因此我们只需要将上下边界分别取一个与区间左右均有一定距离的数即可,以减少误差的产生。毕竟这个实现方法看起来就非常的土法...
4.基本完成
这个时候就会有小天才发问了:啊?这就完了吗?那你的滚轮适配是用来干嘛的?
中肯的,下面开始说滚轮适配。
在完成以上步骤之后,各位可能会发现一个很蛋疼的问题:当鼠标放在按键上的时候,由于我自己上面说过的鼠标悬浮选中,此时选中的是按键而非scrollrect,那么滚动滚轮就无法滚动scrollrect。
为此,需要在GridLayoutGroup的对象上挂多一个CanvasGroup。
Canvas Group可集中控制整组 UI 元素的某些方面,而无需单独处理每个元素。Canvas Group的属性会影响所在的游戏对象以及所有子对象。
Canvas Group属性:
Alpha | 此组中的 UI 元素的不透明度。该值介于 0 和 1 之间,其中 0 表示完全透明,1 表示完全不透明。 |
Interactable | 确定此组件是否接受输入。当设置为 false 时,禁用交互 |
Blcok Raycasts | 是否可以接收图形射线的检测,当设置为 false 时,不可接受图形射线检测 |
Ignore Parent Group | 忽略并覆盖父级对象中的CanvasGroup的设置,勾选则忽略 |
当禁用block raycast时,button ui无法接收鼠标的图形射线检测,此时鼠标即可自动选中背后的scrollrect,滚轮可以正常工作。因此,我们就可以通过动态禁用block raycast的方式来使滚轮正常工作。
void Update()
{
//适配鼠标滚轮
if (Input.GetAxis("Mouse ScrollWheel") != 0)
{
LeftCanvasGroup.blocksRaycasts = false;
Invoke("ReTrueRaycast", scrollwheelDelayTime);//延迟重新激活blocksRaycasts
}
}
private void ReTrueRaycast()
{
LeftCanvasGroup.blocksRaycasts = true;
}
配置好Canvas Group和延迟激活时间(我设置0.15s),即可使鼠标滚轮正常工作。
虽然这么实现效果并不完美,但这种实现方式还是能用的。
结尾:
可能有些朋友会发现一点瑕疵:明明实现滚动的函数就已经起到了限制滚动步数的作用了,为什么还要设置scrollbar的步数?
正确的,我也是写这篇文章的时候才意识到。
实际上,设置scrollbar的步数还是可以起到一定的约束作用的,防止在使用鼠标滚轮后再使用wasd会出现错位的bug
但是土豆兄弟的scrollbar是没有严格步数限制的(虽然人家用的是godot而非unity),鼠标滚轮可以使按键卡在mask边界上。
在发现这个问题后,我尝试了一下,将上下界设置为800和190(略大于/小于获取的值,即只要稍微偏离就会回正),再限制scrollbar的size,就做到如同土豆兄弟一般的效果了。