Unity 3D 从原理到实践:打造独特的游戏提示菜单系统
效果:
介绍
在当今游戏开发领域,用户体验已经成为吸引玩家并保持他们的关键因素之一。交互式提示菜单作为游戏界面设计的一部分,正日益受到开发者们的重视。本文将探索交互式提示菜单在游戏界面设计中的创新应用和重要性,以及如何通过它们提升游戏的用户体验。我们将深入了解交互式提示菜单的设计原理、技术实现和最佳实践,并探讨它们对游戏体验的影响。无论您是游戏开发者、界面设计师还是对游戏用户体验感兴趣的读者,本文都将为您揭示交互式提示菜单的奥秘,帮助您打造出色的游戏界面设计。
源码
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public enum WheelStateType
{
Expand,
Hide,
Single,
Close
}
public class HintWheel : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IDragHandler
{
[Header("外圈放大系数")]
public float enlargementFactor = 2;
[Header("内圈取消距离")]
public float minWheelDistance = 50;
[Header("选中节点放大系数")]
public float selectWheelSize = 1.2f;
[Header("节点信息距离中心偏移量")]
[Range(1f, 3)]
public float iconOffset = 2.5f;
private float lerpValue = 0;
private WheelStateType wheelStateType;
private Vector3 wheelChangeSizeValue;
private Vector3 wheelOriginSize;
private Vector3 wheelMaxSize;
private Vector3 iconOriginSize;
private Vector2 inputDirection = Vector2.zero;
private bool _isLerp = false;
private bool _isChangeSize = false;
private bool _isChangeSelectWheelSize = false;
private List<WheelNode> _selectInfos = new List<WheelNode>();
private List<WheelNode> _currentAllActivateNodes = new List<WheelNode>();
private GraphicRaycaster _ray;
private StandaloneInputModule _input;
private CanvasGroup _wheelAlpha;
private Transform _defaultHintInfo;
private WheelNode _currentSelectNode;
private Image _wheel, _bgWheel, _cursorIcon;
private bool IsLerp
{
get => _isLerp;
set
{
lerpValue = 0;
_isLerp = value;
}
}
private WheelNode CurrentSelectNode
{
get => _currentSelectNode;
set
{
if (_currentSelectNode != value)
{
IsChangeSelectWheelSize = true;
_currentSelectNode = value;
}
}
}
private bool IsChangeSize
{
get => _isChangeSize;
set
{
_isChangeSize = value;
IsLerp = value;
}
}
private bool IsChangeSelectWheelSize
{
get => _isChangeSelectWheelSize;
set
{
_isChangeSelectWheelSize = value;
IsLerp = value;
}
}
public WheelStateType WheelStateType
{
get => wheelStateType;
set
{
wheelStateType = value;
switch (wheelStateType)
{
case WheelStateType.Expand:
_defaultHintInfo.gameObject.SetActive(false);
CurrentSelectNode = null;
_cursorIcon.GetComponent<RectTransform>().anchoredPosition = Vector3.zero;
_cursorIcon.gameObject.SetActive(true);
break;
case WheelStateType.Hide:
_defaultHintInfo.gameObject.SetActive(true);
CurrentSelectNode = null;
_cursorIcon.GetComponent<RectTransform>().anchoredPosition = Vector3.zero;
_cursorIcon.gameObject.SetActive(false);
break;
case WheelStateType.Single:
_defaultHintInfo.gameObject.SetActive(false);
wheelChangeSizeValue = wheelOriginSize;
IsChangeSize = true;
CurrentSelectNode = _currentAllActivateNodes[0];
_currentAllActivateNodes[0].wheel.fillAmount = 0;
_currentAllActivateNodes[0].iconPoint.localPosition = Vector3.zero;
_currentAllActivateNodes[0].iconPoint.localScale = iconOriginSize;
_cursorIcon.GetComponent<RectTransform>().anchoredPosition = Vector3.zero;
_cursorIcon.gameObject.SetActive(false);
break;
case WheelStateType.Close:
wheelChangeSizeValue = Vector3.zero;
IsChangeSize = true;
CurrentSelectNode = null;
_cursorIcon.GetComponent<RectTransform>().anchoredPosition = Vector3.zero;
_cursorIcon.gameObject.SetActive(false);
break;
default:
break;
}
}
}
private void Start()
{
_ray = GetComponentInParent<GraphicRaycaster>();
_input = FindObjectOfType<StandaloneInputModule>();
_wheel = transform.Find("Wheel/WheelImage").GetComponent<Image>();
_wheelAlpha = transform.Find("Wheel").GetComponent<CanvasGroup>();
_bgWheel = transform.Find("BGImage").GetComponent<Image>();
_cursorIcon = transform.Find("Cursor").GetComponent<Image>();
_defaultHintInfo = transform.Find("SelectPointer/IconInfo");
Initialize();
}
void Initialize()
{
wheelOriginSize = transform.localScale;
wheelMaxSize = transform.localScale * enlargementFactor;
iconOriginSize = _defaultHintInfo.localScale;
_wheel.gameObject.SetActive(false);
_cursorIcon.gameObject.SetActive(false);
_defaultHintInfo.gameObject.SetActive(false);
transform.localScale = Vector3.zero;
WheelStateType = WheelStateType.Close;
#if UNITY_ANDROID
_defaultHintInfo.GetComponentInChildren<Text>().text = "按住展开";
#elif UNITY_STANDALONE || UNITY_EDITOR
defaultHintInfo.GetComponentInChildren<Text>().text = "按E展开";
#endif
}
private void LateUpdate()
{
if (IsLerp)
{
lerpValue += Time.deltaTime;
if (lerpValue > 1) lerpValue = 1;
if (IsChangeSize)
{
if (lerpValue == 1) IsChangeSize = false;
transform.localScale = Vector3.Lerp(transform.localScale, wheelChangeSizeValue, lerpValue);
Vector3 iconChangeSizeValue = Vector3.zero;
switch (WheelStateType)
{
case WheelStateType.Single:
iconChangeSizeValue = iconOriginSize;
break;
case WheelStateType.Expand:
iconChangeSizeValue = wheelChangeSizeValue * (1 / enlargementFactor) / enlargementFactor;
break;
case WheelStateType.Hide:
iconChangeSizeValue = Vector3.zero;
break;
}
foreach (var item in _currentAllActivateNodes)
{
item.iconPoint.localScale = Vector3.Lerp(item.iconPoint.localScale, iconChangeSizeValue, lerpValue);
}
_bgWheel.transform.localScale = Vector3.Lerp(_bgWheel.transform.localScale,
WheelStateType == WheelStateType.Expand ?
wheelChangeSizeValue / 2 * (1 / enlargementFactor) / enlargementFactor : wheelChangeSizeValue, lerpValue);
_wheelAlpha.alpha = Mathf.Lerp(_wheelAlpha.alpha, WheelStateType == WheelStateType.Expand ? 1 : 0, lerpValue);
}
if (IsChangeSelectWheelSize)
{
if (lerpValue == 1) IsChangeSelectWheelSize = false;
if (WheelStateType == WheelStateType.Expand)
{
foreach (var item in _currentAllActivateNodes)
{
if (item == CurrentSelectNode)
{
item.wheel.transform.localScale = Vector3.Lerp(item.wheel.transform.localScale,
wheelOriginSize * selectWheelSize, lerpValue);
item.iconPoint.localScale = Vector3.Lerp(item.iconPoint.localScale,
wheelChangeSizeValue * (1 / enlargementFactor) / enlargementFactor * selectWheelSize, lerpValue);
}
else
{
item.wheel.transform.localScale = Vector3.Lerp(item.wheel.transform.localScale, wheelOriginSize, lerpValue);
if (wheelChangeSizeValue == wheelMaxSize)
item.iconPoint.localScale = Vector3.Lerp(item.iconPoint.localScale,
wheelChangeSizeValue * (1 / enlargementFactor) / enlargementFactor, lerpValue);
}
}
}
}
}
if (UnityEngine.Input.GetKeyDown(KeyCode.E))
{
switch (WheelStateType)
{
case WheelStateType.Hide:
ChangeWheelState(true);
break;
case WheelStateType.Single:
CurrentSelectNode?.selectInfo.selectEvent?.Invoke();
break;
default:
break;
}
}
if (UnityEngine.Input.GetKeyUp(KeyCode.E))
{
if (WheelStateType == WheelStateType.Expand)
{
CurrentSelectNode?.selectInfo.selectEvent?.Invoke();
ChangeWheelState(false);
}
}
if (UnityEngine.Input.GetKey(KeyCode.E))
{
CheckSelectNode(UnityEngine.Input.mousePosition);
AnalogCursorPoint(new Vector2(UnityEngine.Input.GetAxis("Mouse X"), UnityEngine.Input.GetAxis("Mouse Y")));
}
}
public void OnPointerDown(PointerEventData eventData)
{
if (_ray == null || _input == null)
return;
switch (WheelStateType)
{
case WheelStateType.Hide:
ChangeWheelState(true);
break;
case WheelStateType.Single:
CurrentSelectNode?.selectInfo.selectEvent?.Invoke();
break;
default:
break;
}
}
public void OnPointerUp(PointerEventData eventData)
{
if (WheelStateType == WheelStateType.Expand)
{
CurrentSelectNode?.selectInfo.selectEvent?.Invoke();
ChangeWheelState(false);
}
}
public void OnDrag(PointerEventData eventData)
{
CheckSelectNode(eventData.position);
AnalogCursorPoint(eventData);
}
private void ChangeWheelState(bool state)
{
if (_currentAllActivateNodes.Count > 1)
{
WheelStateType = state ? WheelStateType.Expand : WheelStateType.Hide;
IsChangeSelectWheelSize = true;
inputDirection = Vector2.zero;
wheelChangeSizeValue = state ? wheelMaxSize : wheelOriginSize;
_defaultHintInfo.gameObject.SetActive(!state);
lerpValue = 0;
IsChangeSize = true;
}
}
private WheelNode CheckSelectNode(Vector2 checkPoint)
{
checkPoint = _cursorIcon.rectTransform.position;
if (WheelStateType == WheelStateType.Expand)
{
float distance = Vector2.Distance(checkPoint, _bgWheel.rectTransform.position);
if (distance < minWheelDistance)
{
CurrentSelectNode = null;
return null;
}
else
{
WheelNode wheelNode = null;
Vector2 localPos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(transform as RectTransform, checkPoint, _ray.eventCamera, out localPos);
float angle = Mathf.Atan2(localPos.x, -localPos.y) * Mathf.Rad2Deg;
angle = (angle < 0) ? angle + 360 : angle;
float angleAreaValue = 360 / _currentAllActivateNodes.Count;
float angleRange = 0;
for (int i = 1; i <= _currentAllActivateNodes.Count; i++)
{
if (angle > angleRange && angle < angleRange + angleAreaValue)
{
wheelNode = i == _currentAllActivateNodes.Count ? _currentAllActivateNodes[0] : _currentAllActivateNodes[i];
if (i == _currentAllActivateNodes.Count) break;
}
angleRange += angleAreaValue;
}
CurrentSelectNode = wheelNode;
return wheelNode;
}
}
else
{
return null;
}
}
private void AnalogCursorPoint(PointerEventData ped)
{
if (WheelStateType == WheelStateType.Close) return;
Vector2 position;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_wheel.rectTransform,
ped.position,
ped.pressEventCamera,
out position);
position = position + _wheel.rectTransform.sizeDelta / 2;
position.x = (position.x / _wheel.rectTransform.sizeDelta.x);
position.y = (position.y / _wheel.rectTransform.sizeDelta.y);
float x = (_wheel.rectTransform.pivot.x == 1f) ? position.x * 2 + 1 : position.x * 2 - 1;
float y = (_wheel.rectTransform.pivot.y == 1f) ? position.y * 2 + 1 : position.y * 2 - 1;
inputDirection = new Vector2(x, y);
inputDirection = (inputDirection.magnitude > 1) ? inputDirection.normalized : inputDirection;
float newX = inputDirection.x * (_wheel.rectTransform.sizeDelta.x / 2);
float newY = inputDirection.y * (_wheel.rectTransform.sizeDelta.y / 2);
_cursorIcon.rectTransform.anchoredPosition = new Vector2(newX, newY);
}
private void AnalogCursorPoint(Vector2 mouseOffset)
{
if (WheelStateType == WheelStateType.Close) return;
Vector2 newPosition = _cursorIcon.rectTransform.anchoredPosition + mouseOffset * 10;
float distanceToCenter = newPosition.magnitude;
if (distanceToCenter > _wheel.rectTransform.sizeDelta.x / 2)
{
newPosition = newPosition.normalized * _wheel.rectTransform.sizeDelta.x / 2;
}
_cursorIcon.rectTransform.anchoredPosition = newPosition;
}
public void ShowHintWheel(SelectInfo selectInfo)
{
WheelNode wheelNode = GetWheelNode(selectInfo);
if (wheelNode == null)
{
wheelNode = AddWheelNode(selectInfo);
}
wheelNode.SetNodeState(true);
UpdateAllActiveNodes();
UpdateWheel();
}
public void HideHintWheel(SelectInfo selectInfo)
{
GetWheelNode(selectInfo).SetNodeState(false);
UpdateAllActiveNodes();
UpdateWheel();
}
private void UpdateWheel()
{
if (_currentAllActivateNodes.Count == 1)
{
WheelStateType = WheelStateType.Single;
}
else if (_currentAllActivateNodes.Count == 0)
{
WheelStateType = WheelStateType.Close;
}
else
WheelStateType = WheelStateType == WheelStateType.Single ? WheelStateType.Hide : WheelStateType;
float angle = 0;
float iconDistance = _bgWheel.GetComponent<RectTransform>().sizeDelta.x / 2;
iconDistance -= iconDistance / iconOffset;
if (_currentAllActivateNodes.Count != 1)
{
foreach (var item in _currentAllActivateNodes)
{
item.wheel.transform.rotation = Quaternion.identity;
item.wheel.transform.Rotate(0, 0, angle);
Vector3 iconPosition = item.wheel.transform.localPosition +
Quaternion.Euler(0, 0, angle - 180 - (360 / _currentAllActivateNodes.Count) / 2) * Vector3.up * iconDistance;
item.iconPoint.localPosition = iconPosition;
item.iconPoint.localScale = Vector3.zero;
float value = (360f / _currentAllActivateNodes.Count) / 360f;
item.wheel.fillAmount = value;
angle += 360 / _currentAllActivateNodes.Count;
}
}
}
private void UpdateAllActiveNodes()
{
_currentAllActivateNodes.Clear();
foreach (var item in _selectInfos)
{
if (item.GetNodeState())
{
_currentAllActivateNodes.Add(item);
}
}
}
private WheelNode GetWheelNode(SelectInfo selectInfo)
{
foreach (var item in _selectInfos)
{
if (item.selectInfo == selectInfo)
{
return item;
}
}
return null;
}
private WheelNode AddWheelNode(SelectInfo selectInfo)
{
Image wheel = Instantiate(_wheel, _wheel.transform.parent);
wheel.transform.SetAsFirstSibling();
Transform iconInfo = Instantiate(_defaultHintInfo, _defaultHintInfo.parent);
iconInfo.transform.SetAsFirstSibling();
WheelNode wheelNode = new WheelNode(selectInfo, wheel, iconInfo, false);
_selectInfos.Add(wheelNode);
return wheelNode;
}
public class WheelNode
{
public SelectInfo selectInfo;
public Transform iconPoint;
public Image wheel;
public Image icon;
public Text info;
private bool activate = false;
public WheelNode(SelectInfo selectInfo, Image wheel, Transform iconInfo, bool activate)
{
this.selectInfo = selectInfo;
this.wheel = wheel;
this.activate = activate;
iconPoint = iconInfo;
icon = iconInfo.GetComponentInChildren<Image>(true);
info = iconInfo.GetComponentInChildren<Text>(true);
icon.sprite = selectInfo.sprite;
info.text = selectInfo.content;
}
public void SetNodeState(bool state)
{
if (activate != state)
{
activate = state;
wheel.gameObject.SetActive(state);
iconPoint.gameObject.SetActive(state);
}
}
public bool GetNodeState()
{
return activate;
}
}
}
public class SelectInfo
{
public Sprite sprite;
public string content;
public string details;
public Action selectEvent;
}
这段代码是一个用于实现提示菜单功能的脚本,主要用于在游戏中展示一个可拖拽的环形菜单,用于提供不同选项或功能的快捷访问。现在让我们逐步解释这个代码的各个部分以及它们的作用:
对外界开放的方法:
ShowHintWheel 和 HideHintWheel 方法
如需要在特定的时候展示一个按钮就调用ShowHintWheel传入参数SelectInfo类,同样关闭也是如此
SelectInfo 类:用于存储选项的相关信息,包括图标、内容、详情和自定义选择后执行的事件。
我们写一个例子来实现这个效果:
将此脚本挂载到场景中的一个物体上
using UnityEngine;
using UnityEngine.UI;
public class testSelect : MonoBehaviour
{
public HintWheel hintWheel;
public Sprite sprite;
public string content = "";
public Text text;
public SelectInfo selectInfo = new SelectInfo();
// Start is called before the first frame update
void Start()
{
selectInfo.sprite = sprite;
selectInfo.content = content;
selectInfo.selectEvent = SelectFunc;
}
public void ChangeToggleState(bool state)
{
if (state)
{
hintWheel.ShowHintWheel(selectInfo);
}
else
{
hintWheel.HideHintWheel(selectInfo);
}
}
public void SelectFunc()
{
if (text)
text.text = content;
}
}
我们使用Toggle来测试,当点击Toggle的时候便会出现按钮,按钮上显示的信息就是我们定义好的这两个字段
public Sprite sprite;
public string content = "";
当然,我们可以继续添加各种其他的功能,只要扩展SelectInfo类就可以了
接着我们解释一下HintWheel 的其他功能
HintWheel 类:实现了 IPointerDownHandler、IPointerUpHandler 和 IDragHandler 接口,用于处理指针事件(鼠标或触摸)。
字段和属性:
- 包括外圈放大系数、内圈取消距离、选中节点放大系数等参数,用于控制菜单的大小和行为。
- 一些私有字段用于跟踪菜单的状态、选中的节点等信息。
在Start方法中初始化一些变量和对象,包括获取场景中的 UI 元素,设置初始状态等。
LateUpdate 方法:在每帧更新时执行的逻辑,主要包括:
- 处理菜单的展开、关闭、选中等逻辑。
- 更新菜单的大小和状态。
- 根据用户的输入更新菜单的位置和状态。
- 检查选中的节点。
ChangeWheelState 方法:根据传入的状态展开或隐藏提示菜单,并更新菜单的状态和大小。
CheckSelectNode
此方法适应不同屏幕尺寸和分辨率: 使用了 RectTransformUtility.ScreenPointToLocalPointInRectangle 方法,可以将屏幕上的点转换为相对于 RectTransform 的本地坐标系中的点。这样可以确保在不同分辨率和屏幕尺寸下,提示菜单的交互功能仍然有效。使用了迭代方式处理多个节点的选择,使得可以轻松地添加、删除或修改提示菜单中的节点,从而实现不同需求下的菜单设计。代码使用了向量运算和角度计算等数学计算,以及简洁的迭代方式,保证了代码的执行效率和性能表现,能够在游戏中保持流畅的交互体验。
AnalogCursorPoint 方法
模拟光标在菜单上的移动,使用方法的重载处理Windows与移动平台双端的计算
UpdateWheel 和 UpdateAllActiveNodes 方法:更新菜单的显示状态和选项信息。
GetWheelNode 和 AddWheelNode 方法:根据选项信息获取或添加菜单节点。
WheelNode 类:表示提示菜单中的一个节点,包含选项信息、图标、文字等。
我们来看看UI是怎么做到像扇形一样只显示一部分的
首先我们创建一个Image
先在Image上添加图片,这是重点,不添加图片就不会有特定的属性
之后再调整Image的属性
那么调整FillAmount的值就可以了