UGUI实现不规则区域点击响应
UGUI实现不规则区域点击响应
前言
大家吼啊!最近工作上事情特别多,没怎么打理博客。今天无意打开cnblog才想起该写点东西了。今天给大家讲一个Unity中不规则区域点击响应的实现方法,使用UGUI。
本脚本编写时基于Unity 5.3,使用其他版本的Unity可能需要做一些小修改。
本文参考了这篇文章:http://alienryderflex.com/polygon/
为什么要这么做
大家都知道在UGUI中,响应点击通常是依附在一张图片上的,而图片不管美术怎么给你切,导进Unity之后都是一个矩形,如果要做其他形状,最多只能旋转一下。
可能有旁友会说,什么时候会用到这个功能呢?
开心农场这个页游,相信大家都玩过。里面的田地是一块一块的菱形。
美术提供给我们的每一块地的切片,肯定并且只能是这样的(格子表示背景透明)。
这样就会有一个问题:在Unity里面把田地拼出来,要拼成一块挨着一块的效果,图片与图片之间必然会有重叠。
如果像这样直接挂载Button
脚本运行,在点击的时候如果点到了图片相交的位置,Unity默认会根据图层先后来传递点击消息,就会造成想点A地结果点到了B地的错误效果。
于是这个时候就需要把图片的点击区域缩小一些,让它只包含田地本身的部分。
可能有旁友会说,这种需求太少见了啊,搞这么复杂干什么。
少你妹啊= =上上个月我做花千骨手游前端的门派药圃(类似开心农场)功能,特么不就遇到这种需求了吗!
好了不废话了,技多不压身,防范于未然嘛。
算法简介
一个思路是,点下去的坐标是已知的,可以把这个坐标转换为相对于图片本身的坐标,然后取出图片Texture
上这个点对应像素的Alpha值。如果这个点是全透明的,那么不拦截点击事件,否则响应点击。
这样做并不是一个坏思路。要实现取颜色的功能,需要在Unity里面对Texture
设置Read/Write Enabled
,但这样会增大一倍内存占用。手机应用内存寸土寸金,这种方法应当放弃。
另一个思路就是今天我们要使用的思路,根据多边形的顶点进行计算。
不规则多边形点击响应可以用一种更规范的说法来表示:已知一任意多边形,和其n个顶点的坐标,求任意一点是否被包含在这个多边形内部。
对于这个问题,计算方法有很多种,这里给出一个Crossing Number算法。
这个算法的思路是,从该点发射一条射线,依次与多边形的每条边相交,如果射线与多边形的交点数为奇数,则这个点在多边形内,否则在多边形外(我姿势水平不够不知道怎么证明它,只知道确实是这样的)。这个方法适用于凸多边形和凹多边形。
如图所示,点A在多边形内部,点B在多边形外部。从点A和点B分别做一条直线,可以看出点A的单边交点数都是奇数,而点B为偶数。
那么怎么判断一个点是否和一根线段有交点呢?在已知线段的两个端点的坐标的情况下,可以从点发出一条射线,当射线与线段相交时,根据点斜式需要满足:
实现
首先编写判定的方法。使用Unity自带的Vector2
结构用于存储坐标信息。这里是从这个点作出水平射线计算。
/// <summary>
/// 使用Crossing Number算法获取指定的点是否处于指定的多边形内
/// </summary>
private static bool _Contains(Vector2[] pVertexs, Vector2 pPoint)
{
var crossNumber = 0;
for (int i = 0, count = pVertexs.Length; i < count; i++)
{
var vec1 = pVertexs[i];
var vec2 = i == count - 1 // 如果当前已到最后一个顶点,则下一个顶点用第一个顶点的数据
? pVertexs[0]
: pVertexs[i + 1];
if (((vec1.y <= pPoint.y) && (vec2.y > pPoint.y))
|| ((vec1.y > pPoint.y) && (vec2.y <= pPoint.y)))
{
if (pPoint.x < vec1.x + (pPoint.y - vec1.y) / (vec2.y - vec1.y) * (vec2.x - vec1.x))
{
crossNumber += 1;
}
}
}
return (crossNumber & 1) == 1;
}
UGUI的Image
类有一个IsRaycastLocationValid
虚方法,重写后可以根据返回值决定该次点击消息是否会被该Image
吞噬。于是可以考虑创建一个继承于Image的类。
而多边形区域的设定,我们希望在编辑器里能直观地编辑,而不是通过设置数字。为了方便,可以直接在Image
上挂载一个自带的PolygonCollider2D
脚本,这个脚本提供了编辑器编辑功能。
这个继承类还需要实现IPointerUpHandler
、IPointerDownHandler
和IPointerClickHandler
三个接口,方便执行点击回调(可根据需求删减)。
[RequireComponent(typeof(PolygonCollider2D))]
public class PolygonClick : Image,
IPointerUpHandler,
IPointerDownHandler,
IPointerClickHandler
{
private PolygonClickedEvent m_OnPointerClick = new PolygonClickedEvent();
private PolygonClickedEvent m_OnPointerDown = new PolygonClickedEvent();
private PolygonClickedEvent m_OnPointerUp = new PolygonClickedEvent();
public class PolygonClickedEvent : UnityEvent<PolygonClick> { }
}
并且,我们需要在Start()
中将PolygonCollider2D
的顶点数据缓存下来,并且禁用它节省计算开销。别忘了在OnDestroy()
中将这些缓存置为null
,手动释放引用计数避免GC发生。
然后重写IsRaycastLocationValid
方法,对点击的点进行判定。这里需要将screenPoint
参数转换为UI的坐标。转换有很多种作法,这里我使用了自己写的一个脚本进行转换(代码放在最后面)。
/// <summary>
/// 重写方法,用于干涉点击射线有效性
/// </summary>
public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
if (this.m_Vertexs == null)
{
return base.IsRaycastLocationValid(screenPoint, eventCamera);
}
else
{
// 点击的坐标转换为相对于图片的坐标
//
UICanvasHelper.Instance.ScreenToUIPoint(ref screenPoint);
var selfPoint = UICanvasHelper.Instance.WorldToScreenPoint(this.m_RectTransform.position, UICanvasHelper.Instance.UICamera);
screenPoint.x -= selfPoint.x;
screenPoint.y -= selfPoint.y;
// 判断点击是否在区域内
//
return _Contains(this.m_Vertexs, screenPoint);
}
}
然后是对点击事件的响应:
public void OnPointerUp(PointerEventData eventData)
{
if (this.m_OnPointerUp != null)
{
this.m_OnPointerUp.Invoke(this);
}
}
public void OnPointerClick(PointerEventData eventData)
{
if (this.m_OnPointerClick != null)
{
this.m_OnPointerClick.Invoke(this);
}
}
public void OnPointerDown(PointerEventData eventData)
{
if (this.m_OnPointerDown != null)
{
this.m_OnPointerDown.Invoke(this);
}
}
使用方法
新建一个GameObject
,挂载PolygonCollider2D
脚本,再挂载PolygonClick
脚本。在编辑器里对PolygonCollider2D
的Collider
进行编辑,这个Collider
的区域就是点击有效的区域。如果点到区域外就穿透到下一个层级了。
添加点击回调的示例代码:
var pc = transform.GetComponent<PolygonClick>();
pc.PointerDown.AddListener(this._PointerDown);
pc.PointerClick.AddListener(this._PointerClick);
pc.PointerUp.AddListener(this._PointerUp);
使用还是非常简单的。
完整代码
最后放上完整代码。首先是PolygonClick
:
//————————————————————————————————————————————
// PolygonClick.cs
//
// Created by Chiyu Ren on 2016-08-16 11:21
//————————————————————————————————————————————
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using UnityEngine.Events;
using TooSimpleFramework.Components;
namespace TooSimpleFramework.UI
{
/// <summary>
/// 支持设置多边形区域作为点击判断的组件
/// 多边形区域编辑由PolygonCollider2D组件提供
/// </summary>
[RequireComponent(typeof(PolygonCollider2D))]
public class PolygonClick : Image,
IPointerUpHandler,
IPointerClickHandler,
IPointerDownHandler
{
private PolygonClickedEvent m_OnPointerClick = new PolygonClickedEvent();
private PolygonClickedEvent m_OnPointerDown = new PolygonClickedEvent();
private PolygonClickedEvent m_OnPointerUp = new PolygonClickedEvent();
private RectTransform m_RectTransform = null;
private Vector2[] m_Vertexs = null;
protected override void Start()
{
base.Start();
// 收集变量
this.m_RectTransform = base.GetComponent<RectTransform>();
var c = base.GetComponent<PolygonCollider2D>();
if (c != null)
{
this.m_Vertexs = c.points;
c.enabled = false;
}
}
protected override void OnDestroy()
{
base.OnDestroy();
this.m_RectTransform = null;
this.m_Vertexs = null;
this.m_OnPointerUp.RemoveAllListeners();
this.m_OnPointerClick.RemoveAllListeners();
this.m_OnPointerDown.RemoveAllListeners();
this.m_OnPointerUp = null;
this.m_OnPointerClick = null;
this.m_OnPointerDown = null;
}
/// <summary>
/// 点下时发生
/// </summary>
public PolygonClickedEvent PointerDown
{
get { return this.m_OnPointerDown; }
}
/// <summary>
/// 点击时发生
/// </summary>
public PolygonClickedEvent PointerClick
{
get { return this.m_OnPointerClick; }
}
/// <summary>
/// 点击松开时发生
/// </summary>
public PolygonClickedEvent PointerUp
{
get { return this.m_OnPointerUp; }
}
/// <summary>
/// 重写方法,用于干涉点击射线有效性
/// </summary>
public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
if (this.m_Vertexs == null)
{
return base.IsRaycastLocationValid(screenPoint, eventCamera);
}
else
{
// 点击的坐标转换为相对于图片的坐标
//
UICanvasHelper.Instance.ScreenToUIPoint(ref screenPoint);
var selfPoint = UICanvasHelper.Instance.WorldToScreenPoint(this.m_RectTransform.position, UICanvasHelper.Instance.UICamera);
screenPoint.x -= selfPoint.x;
screenPoint.y -= selfPoint.y;
// 判断点击是否在区域内
//
return _Contains(this.m_Vertexs, screenPoint);
}
}
public void OnPointerUp(PointerEventData eventData)
{
if (this.m_OnPointerUp != null)
{
this.m_OnPointerUp.Invoke(this);
}
}
public void OnPointerClick(PointerEventData eventData)
{
if (this.m_OnPointerClick != null)
{
this.m_OnPointerClick.Invoke(this);
}
}
public void OnPointerDown(PointerEventData eventData)
{
if (this.m_OnPointerDown != null)
{
this.m_OnPointerDown.Invoke(this);
}
}
/// <summary>
/// 使用Crossing Number算法获取指定的点是否处于指定的多边形内
/// </summary>
private static bool _Contains(Vector2[] pVertexs, Vector2 pPoint)
{
var crossNumber = 0;
for (int i = 0, count = pVertexs.Length; i < count; i++)
{
var vec1 = pVertexs[i];
var vec2 = i == count - 1 // 如果当前已到最后一个顶点,则下一个顶点用第一个顶点的数据
? pVertexs[0]
: pVertexs[i + 1];
if (((vec1.y <= pPoint.y) && (vec2.y > pPoint.y))
|| ((vec1.y > pPoint.y) && (vec2.y <= pPoint.y)))
{
if (pPoint.x < vec1.x + (pPoint.y - vec1.y) / (vec2.y - vec1.y) * (vec2.x - vec1.x))
{
crossNumber += 1;
}
}
}
return (crossNumber & 1) == 1;
}
public class PolygonClickedEvent : UnityEvent<PolygonClick> { }
}
}
这个UICanvasHelper
脚本,建议挂载在Canvas
上。它除了可以计算坐标转换,还可以进行分辨率自适配。需要注意的是Canvas
不能配置为World Space
模式。
//————————————————————————————————————————————
// UICanvasHelper.cs
//
// Created by Chiyu Ren on 2016-08-28 00:02
//————————————————————————————————————————————
using UnityEngine;
using UnityEngine.UI;
namespace TooSimpleFramework.Components
{
/// <summary>
/// UI画布助手
/// </summary>
public class UICanvasHelper : MonoBehaviour
{
#region Public Members
public CanvasScaler UICanvasScaler;
public Camera UICamera;
#endregion
#region Properties
public static UICanvasHelper Instance { get; private set; }
#endregion
#region Private Members
private float m_fWidthScale = -1;
private float m_fHeightScale = -1;
private float m_fMatchValue = -1;
#endregion
void Start()
{
Instance = this;
this._SetUIMatch();
this._SetSizeScale();
}
void OnDestroy()
{
Instance = null;
}
#region Public Methods
/// <summary>
/// 世界坐标转换为屏幕坐标
/// </summary>
public Vector2 WorldToScreenPoint(Vector3 pWorldPosition, Camera pCamera = null)
{
if (pCamera == null)
{
pCamera = Camera.main;
}
#if UNITY_EDITOR // 编辑器模式可能随时要调整画面大小
this._SetSizeScale();
#endif
Vector2 ret = pCamera.WorldToScreenPoint(pWorldPosition);
this._SetPositionScale(ref ret);
return ret;
}
/// <summary>
/// 屏幕坐标转换为UI坐标
/// </summary>
public void ScreenToUIPoint(ref Vector2 pPosition)
{
#if UNITY_EDITOR // 编辑器模式可能随时要调整画面大小
this._SetSizeScale();
#endif
this._SetPositionScale(ref pPosition);
}
#endregion
#region Private Methods
/// <summary>
/// 设置分辨率适配比例
/// </summary>
private void _SetUIMatch()
{
this.UICanvasScaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
var scale = Screen.width / (float)(Screen.height);
if (scale > 1.5f)
{
this.m_fMatchValue = 1;
}
else if (scale < 1.4f)
{
this.m_fMatchValue = 0;
}
else
{
this.m_fMatchValue = 0.5f;
}
this.UICanvasScaler.matchWidthOrHeight = this.m_fMatchValue;
}
/// <summary>
/// 设置尺寸缩放比例
/// </summary>
private void _SetSizeScale()
{
this.m_fWidthScale = this.UICanvasScaler.referenceResolution.x / Screen.width;
this.m_fHeightScale = this.UICanvasScaler.referenceResolution.y / Screen.height;
}
/// <summary>
/// 将传入的坐标转换为缩放后的值
/// </summary>
private void _SetPositionScale(ref Vector2 pPosition)
{
pPosition.x = (pPosition.x - Screen.width * 0.5f) * ((1 - this.m_fMatchValue) * this.m_fWidthScale + this.m_fMatchValue * m_fHeightScale);
pPosition.y = (pPosition.y - Screen.height * 0.5f) * ((1 - this.m_fMatchValue) * this.m_fWidthScale + this.m_fMatchValue * m_fHeightScale);
}
#endregion
}
}
后记
最后的最后,这个类还可以继续优化,把多边形区域编辑做成单独的编辑器扩展类,这样就可以少挂一个PolygonCollider2D
脚本节省内存。
另外这个算法效率其实并不高,多边形的边数变多之后运算量会比较恐怖,实际运用中要控制多边形边数。
再一个可以忽略的问题,就是点下去的点如果刚好在多边形的边上,运算结果是不确定的。
很久没写博客了,今天怒写了一篇,我感觉到非常高兴。讲三句话!
第一,没想好;
第二,还是没想好;
第三,马上就要过年了,在这里祝大家春节遇快,阖家欢洛,万似如意!
很惭愧,就做了一点微小的工作,谢谢大家!