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脚本,这个脚本提供了编辑器编辑功能。

这个继承类还需要实现IPointerUpHandlerIPointerDownHandlerIPointerClickHandler三个接口,方便执行点击回调(可根据需求删减)。

[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脚本。在编辑器里对PolygonCollider2DCollider进行编辑,这个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脚本节省内存。

另外这个算法效率其实并不高,多边形的边数变多之后运算量会比较恐怖,实际运用中要控制多边形边数。

再一个可以忽略的问题,就是点下去的点如果刚好在多边形的边上,运算结果是不确定的。

很久没写博客了,今天怒写了一篇,我感觉到非常高兴。讲三句话!

第一,没想好;

第二,还是没想好;

第三,马上就要过年了,在这里祝大家春节遇快,阖家欢洛,万似如意!

很惭愧,就做了一点微小的工作,谢谢大家!

posted @ 2017-01-22 00:01  GuyaWeiren  阅读(6561)  评论(2编辑  收藏  举报