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
脚本节省内存。
另外这个算法效率其实并不高,多边形的边数变多之后运算量会比较恐怖,实际运用中要控制多边形边数。
再一个可以忽略的问题,就是点下去的点如果刚好在多边形的边上,运算结果是不确定的。
很久没写博客了,今天怒写了一篇,我感觉到非常高兴。讲三句话!
第一,没想好;
第二,还是没想好;
第三,马上就要过年了,在这里祝大家春节遇快,阖家欢洛,万似如意!
很惭愧,就做了一点微小的工作,谢谢大家!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!