UGUI--进阶_常规案例_上

目录

[TOC]

一、UI遮挡3D物体响应

1.UI元素在3D物体之上时,只响应UI元素

picture0

  • Step1:在MainCamera上添加Physics Raycaster物理射线组件
  • Step2:3D物体的点击实现方式同UI元素一样:实现点击接口IPointerClickHandler

代码:

    public class ClickThreeD : MonoBehaviour, IPointerClickHandler
    {
        private int mIndex;
        private void Start()
        {
            mIndex = 0;
        }
        public void OnPointerClick(PointerEventData eventData)
        {
            ChangeColor();
        }
        private void ChangeColor()
        {
            if (mIndex == 0)
            {
                GetComponent<MeshRenderer>().material.SetColor("_Color", Color.white);
            }
            else
            {
                GetComponent<MeshRenderer>().material.SetColor("_Color", Color.black);
            }
            mIndex = mIndex == 0 ? 1 : 0;
        }
    }

2.UI元素在3D物体之上时,同时响应UI元素和3D物体

延续1中的方法,只需要在响应UI元素时增加对3D物体的响应即可。

注意:

  • UI元素(继承自Graphic)接收Graphic Raycaster图形射线的检测。
  • 3D物体接收Physics Raycaster物理射线的检测。
  • EventSystem.current.RaycastAll(...)会获取上述所有的射线。

修改后的UI元素的代码

    public class ClickUI : MonoBehaviour, IPointerClickHandler
    {
        private int mIndex;
        private void Start()
        {
            mIndex = 0;
        }
        public void OnPointerClick(PointerEventData eventData)
        {
            ChangeColor();
            //获取除当前UI元素以外的所有接受到射线检测的物体(包括图形射线、物理射线)
            //执行物体身上的点击事件pointerClickHandler
            ExecuteAll(eventData, ExecuteEvents.pointerClickHandler);
        }
        private void ExecuteAll<T>(PointerEventData eventData, ExecuteEvents.EventFunction<T> eventFunction)
            where T : IEventSystemHandler
        {
            //获取所有射线(包括图形射线、物理射线)
            List<RaycastResult> results = new List<RaycastResult>();
            EventSystem.current.RaycastAll(eventData, results);
            for (int i = 0; i < results.Count; i++)
            {
                if (results[i].gameObject != gameObject)//获取非当前物体
                {
                    //执行指定的事件eventFunction
                    ExecuteEvents.Execute(results[i].gameObject, eventData, eventFunction);
                }
            }
        }
        private void ChangeColor()
        {
            var img = GetComponent<Image>();
            if (mIndex == 0)
            {
                img.color = Color.red;
            }
            else
            {
                img.color = Color.blue;
            }
            mIndex = mIndex == 0 ? 1 : 0;
        }
    }

3.判断鼠标是否点击在UI上

UI元素接收Graphic Raycaster图形射线检测。

    public class MouseClick : MonoBehaviour
    {
        private GraphicRaycaster mGraphicRaycaster;
        private void Start()
        {
            mGraphicRaycaster = FindObjectOfType<GraphicRaycaster>();
        }
        private bool IsUI()
        {
            //创建当前鼠标点击位置的点击事件参数PointerEventData
            PointerEventData eventData = new PointerEventData(EventSystem
                .current)
            {
                //pressPosition = Input.mousePosition,
                position = Input.mousePosition,
            };
            //获取图形射线响应的射线结果
            List<RaycastResult> results = new List<RaycastResult>();
            mGraphicRaycaster.Raycast(eventData, results);
            //存在图形射线响应的结果说明点击位置存在UI元素
            return results.Count > 0;
        }
    }

二、用顶点描绘圆形图片–制作技能图标(精确点击响应)

技能图标一般为圆形,UGUI没有现成的圆形Image组件来制作技能图标。所以做圆形技能图标需要用到Mask组件,使用圆形图片作为遮罩,但使用Mask组件会带来很大的性能开销,增加2个Draw Call。为了避免使用Mask组件,所以可以自己制作一个圆形Image组件。

圆形可以看做是由多个三角形组合而成,为此可以设置参数mSegements(圆分割的份数)

为了制作技能CD效果,需要记录技能CD的时间百分比,用圆形中小三角形填充的百分比代替mFillPercent。技能CD状态图标为灰色,可以定义CD状态图标颜色:private readonly Color32 Gray = new Color32(60, 60, 60, 255);

制作圆形Image步骤如下:

  • Step1:创建脚本并继承自Image,重写Image自带的方法OnPopulateMesh(填充网格)
  • Step2:清空VertexHelper
  • Step3:向VertexHelper中添加顶点(注意消除移动轴心点pivot所带来的顶点坐标的差异)
  • Step4:向VertexHelper中添加三角形

此时已经有了一个圆形的可以表现技能CD效果的Image组件,接着需要处理图标精确点击的问题。

精确点击算法原理:判断一点是否在图形内部,方法:以该点为起点向某一方向引出一条射线,射线与图形边界交点若为奇数=>点在内部

代码:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Sprites;
using UnityEngine.UI;

namespace StudyUGUIExample
{
    public class CircleImage : Image
    {
        #region Config
        private readonly Color32 Gray = new Color32(60, 60, 60, 255);
        #endregion

        /// <summary>
        /// 填充百分比
        /// </summary>
        [SerializeField]
        private float mFillPercent = default;
        /// <summary>
        /// 圆分割的份数
        /// </summary>
        [SerializeField]
        private int mSegements = default;

        private List<Vector3> mVertexList;

        /// <summary>
        /// 使用顶点填充网格
        /// </summary>
        /// <param 待填充的="toFill"></param>
        protected override void OnPopulateMesh(VertexHelper toFill)
        {
            toFill.Clear();
            AddVertex(toFill);
            AddTriangle(toFill);
        }
        private void AddVertex(VertexHelper toFill)
        {
            //当前的小三角形份数
            mVertexList = new List<Vector3>();
            int currentSegements = (int)(mFillPercent * mSegements);
            float width = GetComponent<RectTransform>().rect.width;
            float height = GetComponent<RectTransform>().rect.height;
            float radius = width * 0.5f;//圆半径
            float radian = 2f * Mathf.PI / mSegements;//每一块的弧度
            Vector4 uv = overrideSprite != null ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero;
            float uvWidth = uv.z - uv.x;
            float uvHeight = uv.w - uv.y;
            //转换系数=uv/实际宽高
            Vector2 convertRatio = new Vector2(uvWidth / width, uvHeight / height);
            Vector2 uvCenter = new Vector2(0.5f * (uv.x + uv.z), 0.5f * (uv.y + uv.w));
            //Vector2 uvCenter = new Vector2(0.5f * uvWidth, 0.5f * uvHeight);
            //圆心
            UIVertex origin = new UIVertex();
            origin.color = GetColorFromFillPercent(Gray, mFillPercent);
            origin.position = new Vector3((0.5f - rectTransform.pivot.x) * width, (0.5f - rectTransform.pivot.y) * height);//图形中心不随轴心点改变
            origin.uv0 = new Vector2(uvCenter.x, uvCenter.y);
            toFill.AddVert(origin);//添加顶点--圆心
            //添加顶点--圆周
            for (int i = 1; i <= mSegements + 1; i++)
            {
                float x = Mathf.Cos(radian * (i - 1)) * radius;
                float y = Mathf.Sin(radian * (i - 1)) * radius;
                UIVertex temp = new UIVertex();
                if (i <= currentSegements || mFillPercent >= 1f)
                {
                    temp.color = color;
                }
                else
                {
                    temp.color = Gray;
                }
                temp.position = new Vector3(x + (0.5f - rectTransform.pivot.x) * width, y + (0.5f - rectTransform.pivot.y) * height);
                temp.uv0 = new Vector2(x * convertRatio.x + uvCenter.x, y * convertRatio.y + uvCenter.y);
                toFill.AddVert(temp);
                mVertexList.Add(temp.position);
            }
        }
        private void AddTriangle(VertexHelper toFill)
        {
            //添加三角形
            for (int i = 1; i <= mSegements; i++)
            {
                toFill.AddTriangle(i, 0, i + 1);
            }
        }
        private Color32 GetColorFromFillPercent(Color32 filledColor, float fillPercent)
        {
            return new Color32
            {
                r = (byte)(filledColor.r + (255 - filledColor.r) * fillPercent),
                g = (byte)(filledColor.g + (255 - filledColor.g) * fillPercent),
                b = (byte)(filledColor.b + (255 - filledColor.b) * fillPercent),
                a = 255,
            };
        }

        #region 精确点击判断
        /*
         * 原理:判断一点是否在图形内部
         * 以该点为起点向某一方向引出一条射线,射线与图形边界交点若为奇数=>点在内部。
         */
        public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
        {
            Vector2 pos;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out pos);
            return IsValid(pos, mVertexList);
        }
        private bool IsValid(Vector3 pos, List<Vector3> vertexList)
        {
            return GetCrossPointNum(pos, vertexList) % 2 == 1;//交点为奇数则pos在内部
        }
        private int GetCrossPointNum(Vector3 pos, List<Vector3> vertexList)
        {
            int count = 0;
            for (int i = 0; i < vertexList.Count; i++)
            {
                Vector3 vertex1 = vertexList[i];
                Vector3 vertex2 = vertexList[(i + 1) % vertexList.Count];
                if ((pos.y < vertex2.y && pos.y > vertex1.y) || (pos.y > vertex2.y && pos.y < vertex1.y))
                {
                    if (GetX(vertex1, vertex2, pos.y) > pos.x) count++;
                }
            }
            return count;
        }
        private float GetX(Vector3 vertex1, Vector3 vertex2, float y)
        {
            //获取两顶点连线上的一点
            return (y - vertex1.y) * (vertex2.x - vertex1.x) / (vertex2.y - vertex1.y) + vertex1.x;
        }
        #endregion
    }
}

为了编辑方便,为该组件创建编辑扩展脚本:

using UnityEngine;
using UnityEditor;
using UnityEditor.UI;

namespace StudyUGUIExample
{
    [CustomEditor(typeof(CircleImage), true), CanEditMultipleObjects]
    public class CircleImageEditor : ImageEditor
    {
        private SerializedProperty mFillPercent;//填充百分比
        private SerializedProperty mSegements;//圆分割的份数

        protected override void OnEnable()
        {
            base.OnEnable();
            mFillPercent = serializedObject.FindProperty("mFillPercent");
            mSegements = serializedObject.FindProperty("mSegements");
        }

        public override void OnInspectorGUI()
        {
            base.OnInspectorGUI();
            //更新序列化物体
            serializedObject.Update();

            EditorGUILayout.Slider(mFillPercent, 0f, 1f, new GUIContent("显示百分比"));
            EditorGUILayout.PropertyField(mSegements, new GUIContent("圆分割的份数"));
            //应用更改
            serializedObject.ApplyModifiedProperties();
            if (GUI.changed)
            {
                EditorUtility.SetDirty(target);
            }
        }
    }
}

自定义的圆形Image组件编辑界面如下图:

picture1

测试效果:任意多边形+CD效果+精确点击

GIF1

后续还可进行更多扩展,此处略。

三、UGUI不规则响应区域

1.UGUI自带的响应不规则区域的方法(严重影响性能,不可使用)

    public class AlphaHitTest : MonoBehaviour
    {
        private void Start()
        {
            //设置透明度测试阈值
            //小于此值的点不响应点击
            //使用需要勾选图片设置中的“可读写”
            //此法严重增加内存,同时阻止使用图集、增加DrawCall,严重影响性能,不可使用
            GetComponent<Image>().alphaHitTestMinimumThreshold = 0.1f;
        }
    }

picture2

2.自定义Image组件,支持不规则区域响应。

picture3

picture4

主要依赖2D的不规则Collider组件:Polygon Collider 2D

代码:

    public class CustomImage : Image
    {
        private PolygonCollider2D mCollider2D;
        public PolygonCollider2D Collider2D
        {
            get
            {
                if (mCollider2D == null)
                {
                    mCollider2D = GetComponent<PolygonCollider2D>();
                }
                return mCollider2D;
            }
        }
        public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
        {
            Vector3 worldPos;
            RectTransformUtility.ScreenPointToWorldPointInRectangle(rectTransform, screenPoint, eventCamera, out worldPos);
            return Collider2D.OverlapPoint(worldPos);//返回:坐标是否在2D碰撞器区域内
        }
    }

编辑器扩展代码:

using UnityEngine;
using UnityEditor;
using UnityEngine.UI;

namespace StudyUGUIExample
{
    public class CustomImageEditor : Editor
    {
        private const int LAYER_UI = 5;
        [MenuItem("GameObject/UI/Custom Image", priority = 0)]
        private static void AddCustomImage()
        {
            CustomImage image;
            if (Selection.activeGameObject != null && Selection.activeGameObject.layer == LAYER_UI)
            {
                image = GenerateCustomImage();
                image.transform.SetParent(Selection.activeGameObject.transform);
            }
            else
            {
                Canvas canvas = GetCanvas();
                image = GenerateCustomImage();
                image.transform.SetParent(canvas.transform);
            }
            image.transform.localPosition = Vector3.zero;
        }
        private static Canvas GetCanvas()
        {
            Canvas canvas = FindObjectOfType<Canvas>();
            if (canvas == null)
            {
                GameObject canvasObj = new GameObject("Canvas");
                canvasObj.layer = LAYER_UI;
                canvas = canvasObj.AddComponent<Canvas>();
                canvasObj.AddComponent<CanvasScaler>();
                canvasObj.AddComponent<GraphicRaycaster>();
            }
            return canvas;
        }
        private static CustomImage GenerateCustomImage()
        {
            GameObject customImageObj = new GameObject("Custom Image");
            customImageObj.layer = LAYER_UI;
            customImageObj.AddComponent<CanvasRenderer>();
            CustomImage customImage = customImageObj.AddComponent<CustomImage>();
            customImageObj.AddComponent<PolygonCollider2D>();
            return customImage;
        }
    }
}

四、使用2DImage制作仿3D轮转图

3D轮转图常见实现方法有两种:1、使用3D模型制作真3D轮转图;2、使用2D来模拟3D效果来制作轮转图。

下面将实现第二种方案。

效果如下:

GIF2

设置如下:

picture5

实现原理:(以横向轮转为例,纵向类似)

  • Step1:2D模拟3D效果主要依靠设置图片合适的X轴坐标PosX和图片缩放值Scale
  • Step2:注意处理UI的层级,近处的需要遮挡远处的。
  • Step3:加入DoTween动画。

picture6

RotationDiagrams代码:

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;

namespace StudyUGUIExample
{
    public class RotationDiagrams : MonoBehaviour
    {
        private const int LAYER_UI = 5;
        private RectTransform mRect;
        private float mPerimeter;
        private List<DiagramData> mDiagramDatas;
        private List<DiagramItem> mDiagramItems;

        [Tooltip("子项大小")]
        public Vector2 ItemSize;
        [Tooltip("子项最大缩放")]
        public float MaxScale;
        [Tooltip("子项最小缩放")]
        public float MinScale;
        [Tooltip("轮转动画时间")]
        public float AnimTime;
        public List<Sprite> Sprites = new List<Sprite>();

        public RectTransform Rect
        {
            get
            {
                if (mRect == null)
                    mRect = GetComponent<RectTransform>();
                return mRect;
            }
        }
        public int Count => Sprites.Count;

        private void Awake()
        {
            mDiagramDatas = new List<DiagramData>();
            mDiagramItems = new List<DiagramItem>();
            mPerimeter = CalculatePerimeter(Rect.sizeDelta.x, Count, ItemSize.x, MaxScale, MinScale);
        }
        private void Start()
        {
            GenerateDiagrams();
            UpdataOrder();
        }
        private void UpdataOrder()
        {
            //自然层级更新原理:缩放值越小自然层级越小(越靠上)
            //依据缩放值排序
            List<DiagramData> datas = mDiagramDatas.OrderBy((data) => data.Scale).ToList();//升序
            for (int i = 0; i < datas.Count; i++)
            {
                datas[i].OrderIndex = i;//设置自然层级
            }
            for (int i = 0; i < mDiagramItems.Count; i++)
            {
                //更新子项的自然层级
                mDiagramItems[i].transform.SetSiblingIndex(mDiagramDatas[i].OrderIndex);
            }
        }
        private void GenerateDiagrams()
        {
            for (int i = 0; i < Sprites.Count; i++)
            {
                DiagramData data = GetDiagramData(i);
                mDiagramDatas.Add(data);
                GenerateDiagramItem(Sprites[i], data);
            }
        }
        private DiagramData GetDiagramData(int index)
        {
            return new DiagramData
            {
                CurrentId = index,
                PosX = GetX(GetRatio(index), mPerimeter),
                Scale = GetScale(GetRatio(index), MaxScale, MinScale),
            };
        }
        private GameObject GetTemplateItem()
        {
            GameObject template = new GameObject("Diagram Item");
            template.layer = LAYER_UI;
            template.AddComponent<RectTransform>();
            template.AddComponent<CanvasRenderer>();
            template.AddComponent<Image>();
            template.AddComponent<DiagramItem>();
            return template;
        }
        private void GenerateDiagramItem(Sprite sprite, DiagramData data)
        {
            GameObject itemGo = GetTemplateItem();
            DiagramItem item = Instantiate(itemGo).GetComponent<DiagramItem>();
            item.SetSprite(sprite);
            item.SetParent(transform);
            item.SetData(data);
            item.SetSize(ItemSize);
            item.AddMoveListener(Change);
            mDiagramItems.Add(item);
            Destroy(itemGo);
        }
        private float GetRatio(int index)
        {
            return index / (float)Count;
        }
        private float CalculatePerimeter(float totalWidth, int count, float itemWidth, float maxScale, float minScale)
        {
            return 2f * (totalWidth - itemWidth * GetScale(GetRatio((count + 1) / 4), maxScale, minScale));
        }
        private float GetX(float ratio, float perimeter)
        {
            if (ratio < 0 || ratio > 1)
            {
                Debug.LogError("GetX出错,比率ratio超出范围!ratio:" + ratio);
                return 0f;
            }
            else if (ratio >= 0 && ratio < 0.25)
                return perimeter * ratio;
            else if (ratio >= 0.25 && ratio < 0.75)
                return perimeter * (0.5f - ratio);
            else
                return perimeter * (ratio - 1f);
        }
        private float GetScale(float ratio, float max, float min)
        {
            if (ratio < 0 || ratio > 1)
            {
                Debug.LogError("GetScale出错,比率ratio超出范围!ratio:" + ratio);
                return 0f;
            }
            else if (ratio >= 0 && ratio < 0.5)
                return max - (max - min) * ratio * 2f;
            else
                return 2f * min - max + (max - min) * ratio * 2f;
        }
        private void Change(float deltaX)
        {
            Change(deltaX > 0 ? 1 : -1);
        }
        private void Change(int symbol)
        {
            for (int i = 0; i < mDiagramDatas.Count; i++)
            {
                int id = mDiagramDatas[i].CurrentId;
                id += symbol;
                if (id < 0)
                    id += Count;
                else if (id >= Count)
                    id %= Count;
                mDiagramDatas[i].CurrentId = id;
            }
            for (int i = 0; i < mDiagramItems.Count; i++)
            {
                DiagramData data = mDiagramDatas[i];
                data.PosX = GetX(GetRatio(data.CurrentId), mPerimeter);
                data.Scale = GetScale(GetRatio(data.CurrentId), MaxScale, MinScale);
                mDiagramItems[i].SetData(data, AnimTime);
            }
            StartCoroutine(DelayUpdateOrder(AnimTime * 0.5f));
        }
        private IEnumerator DelayUpdateOrder(float delayTime)
        {
            WaitForSeconds delay = new WaitForSeconds(delayTime);
            yield return delay;
            UpdataOrder();
        }
    }
}

轮转图的单个图形项脚本DiagramItem

using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using DG.Tweening;

namespace StudyUGUIExample
{
    public class DiagramItem : MonoBehaviour, IDragHandler, IEndDragHandler
    {
        private Image mImage;
        private RectTransform mRect;
        private float mDeltaX = 0f;
        private Action<float> mMoveHandler;

        public Image Image
        {
            get
            {
                if (mImage == null)
                    mImage = GetComponent<Image>();
                return mImage;
            }
        }
        public RectTransform Rect
        {
            get
            {
                if (mRect == null)
                    mRect = GetComponent<RectTransform>();
                return mRect;
            }
        }

        public void SetSize(Vector2 size)
        {
            Rect.sizeDelta = size;
        }
        public void SetSprite(Sprite sprite)
        {
            Image.sprite = sprite;
        }
        public void SetParent(Transform parent)
        {
            transform.SetParent(parent);
        }
        public void SetData(DiagramData data, float animTime = 0f)
        {
            if (animTime == 0f)
            {
                Rect.anchoredPosition = Vector3.right * data.PosX;
                Rect.localScale = Vector3.one * data.Scale;
            }
            else
            {
                Rect.DOAnchorPos(Vector2.right * data.PosX, animTime);
                Rect.DOScale(data.Scale, animTime);
            }
        }
        public void OnDrag(PointerEventData eventData)
        {
            mDeltaX += eventData.delta.x;//累计X轴偏移
        }
        public void OnEndDrag(PointerEventData eventData)
        {
            mMoveHandler(mDeltaX);
            mDeltaX = 0;//重置
        }
        public void AddMoveListener(Action<float> onMove)
        {
            mMoveHandler = onMove;
        }
    }
}

轮转图图形项数据DiagramData

namespace StudyUGUIExample
{
    public class DiagramData
    {
        public int CurrentId;//当前序号
        public float PosX;
        public float Scale;
        public int OrderIndex;//自然层级序号
    }
}

使用的时候只需要挂载RotationDiagram脚本到UI空物体上,然后设置该物体RectTransformWidth宽度值和RotationDiagram脚本相关参数。

可继续扩展,增加竖向轮转的支持、动态改变等。

五、雷达图

picture7

雷达图生成原理:

  • 雷达图背景需要现有的图片
  • 雷达图中间填充颜色部分是在Image组件中的OnPopulateMesh方法中填充顶点和三角形获得的。
  • 如果需要在运行时实时修改中间颜色填充部分,则需要在Update()中添加SetVerticesDirty();以标记顶点信息需要实时刷新。

步骤:

  • Step1:雷达图上的蓝色圆点为RadarHandler,使用单个脚本来处理相关逻辑。
  • Step2:雷达图主要生成脚本为RadarGraph,脚本继承自Image,为了相关参数方便在编辑器中实时修改,所以添加其对应的编辑器扩展脚本RadarGraphEditor
  • Step3:注意Handler在运行模式下实时拖动时,需要限制其拖动的方向和最大最小位置,保证其合理性。

编辑器下的参数设置:

picture8

使用方法:

picture9

  • Step1:拖拽雷达图背景到Image中,给其重命名为RadarGraphBackGround

    雷达图背景:

    picture9

  • Step2:设置RadarGraph组件的相关参数

  • Step3:点击初始化雷达图按钮,此时会生成一个Radar Probe,移动它,使其与雷达图背景的边缘任意一个顶点重合,其作用在于获取雷达图半径。

  • Step4:点击生成内部可移动顶点,之后会生成雷达图顶点。顶点的生成位置与Ratio参数有关,如果设置有Ratio参数,则顶点依据参数生成,未设置则生成在边缘顶点位置。

  • 注意Ratio参数的含义:比例值,例如竖直向上方向的为HP值,则Ratio表示占最大HP的比例值,取值范围在0到1之间。

使用效果:

GIF3

代码:

RadarGraph脚本:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

namespace StudyUGUIExample
{
    /*
     * 默认 雷达图中心点12点钟方向存在一个边缘顶点
     */
    public class RadarGraph : Image
    {
        [SerializeField, Tooltip("雷达图的顶点数")]
        private int mVertexCount = 5;
        [SerializeField, Tooltip("内部可移动顶点的图片")]
        private Sprite mHandlerSprite = default;
        [SerializeField, Tooltip("内部可移动顶点的颜色")]
        private Color mHandlerColor = Color.black;
        [SerializeField, Tooltip("填充的颜色")]
        private Color mFilledColor = Color.red;
        [SerializeField, Tooltip("内部可移动顶点的大小")]
        private Vector2 mHandlerSize = Vector2.zero;
        [SerializeField, Tooltip("各边上的占比")]
        private List<float> mRatios = default;

        [SerializeField]
        private GameObject mProbe;//探头=>获取半径
        [SerializeField]
        private float mRadius;
        [SerializeField]
        private List<Vector2> mDirs;//标准化的方向向量List(中心点指向边缘顶点)
        [SerializeField]
        private List<RadarHandler> mHandlers;

        private void Update()
        {
            SetVerticesDirty();
        }
        protected override void OnPopulateMesh(VertexHelper toFill)
        {
            if (mHandlers == null || mHandlers.Count == 0) return;
            toFill.Clear();
            AddVertex(toFill);
            AddTriangle(toFill);
        }
        private void AddVertex(VertexHelper toFill)
        {
            //此处由于不需要贴图,所以不用管UV
            toFill.AddVert(Vector3.zero, mFilledColor, Vector2.zero);
            for (int i = 0; i < mVertexCount; i++)
            {
                if (mHandlers[i] == null)
                    Debug.LogError("mHandlers列表中的RadarHandler丢失,请在RadarGraph的检视面板中检查!");
                toFill.AddVert(mHandlers[i].transform.localPosition, mFilledColor, Vector2.zero);
            }
        }
        private void AddTriangle(VertexHelper toFill)
        {
            for (int i = 0; i < mVertexCount; i++)
            {
                //顺时针=>三角形正面朝上(左手法则)
                toFill.AddTriangle(0, (i + 2) == (mVertexCount + 1) ? 1 : (i + 2), i + 1);
            }
        }
        public void InitRadarGraph()
        {
            ClearDirs();
            CalculateDirs(mDirs, mVertexCount);
            ResetProbe();
            GenerateProbe();
        }
        public void InitHandlers()
        {
            ClearHandlers();
            CalculateRadius(mProbe);
            GenerateHandlers();
        }
        private void GenerateProbe()//生成探头=>获取半径
        {
            mProbe = CreatePoint("Radar Probe");
            mProbe.transform.SetParent(transform);
            mProbe.GetComponent<RectTransform>().anchoredPosition = Vector2.zero;
        }
        private void ResetProbe()
        {
            if (mProbe != null)
            {
                DestroyImmediate(mProbe);
                mProbe = null;
            }
        }
        private void ClearDirs()
        {
            if (mDirs == null)
                mDirs = new List<Vector2>();
            mDirs.Clear();//清除标准化的方向向量List
        }
        private void CalculateDirs(List<Vector2> dirs, int vertexCount)
        {
            if (dirs == null) dirs = new List<Vector2>();
            float radius = 1f;//标准化
            float perRadian = 2f * Mathf.PI / vertexCount;
            float currentRadian;
            float x, y;
            for (int i = 0; i < vertexCount; i++)
            {
                currentRadian = Mathf.PI * 0.5f + i * perRadian;
                x = radius * Mathf.Cos(currentRadian);
                y = radius * Mathf.Sin(currentRadian);
                dirs.Add(new Vector2(x, y));
            }
        }
        private void CalculateRadius(GameObject probe)
        {
            if (probe == null)
            {
                Debug.LogError("计算雷达图半径出错,请先生成probe!");
            }
            mRadius = Vector3.Distance(Vector3.zero, probe.transform.localPosition);
        }
        private void GenerateHandlers()
        {
            if (mDirs == null || mDirs.Count != mVertexCount)
            {
                Debug.LogError("标准化的方向向量List出错!"); return;
            }
            for (int i = 0; i < mVertexCount; i++)
            {
                RadarHandler handler = CreateHandler("Handler" + (i + 1), mHandlerSprite, mHandlerColor, mHandlerSize, mDirs[i]);
                if (mRatios == null || mRatios.Count != mVertexCount)
                    SetHandlerPos(handler, mDirs[i], mRadius);
                else
                    SetHandlerPos(handler, mDirs[i], mRadius, mRatios[i]);
                mHandlers.Add(handler);
            }
        }
        private void ClearHandlers()
        {
            if (mHandlers == null)
                mHandlers = new List<RadarHandler>();
            if (mHandlers.Count == 0) return;
            for (int i = 0; i < mHandlers.Count; i++)
            {
                DestroyImmediate(mHandlers[i].gameObject);
            }
            mHandlers.Clear();
        }
        private void SetHandlerPos(RadarHandler handler, Vector2 dir, float radius, float ratio = 1f)
        {
            handler.SetPos(new Vector2(dir.x * radius * ratio, dir.y * radius * ratio));
        }

        private GameObject CreatePoint(string name)
        {
            GameObject point = new GameObject(name);
            point.AddComponent<RectTransform>();
            return point;
        }
        private RadarHandler CreateHandler(string name, Sprite sprite, Color color, Vector2 size, Vector2 dir)
        {
            GameObject insideVertex = CreatePoint(name);
            insideVertex.AddComponent<CanvasRenderer>();
            insideVertex.AddComponent<Image>();
            RadarHandler handler = insideVertex.AddComponent<RadarHandler>();
            handler.SetParent(transform);
            handler.SetSprite(sprite);
            handler.SetColor(color);
            handler.SetSize(size);
            handler.SetDir(dir);
            handler.SetRadius(mRadius);
            return handler;
        }
    }
}

RadarHandler脚本:

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;

namespace StudyUGUIExample
{
    public class RadarHandler : MonoBehaviour, IDragHandler
    {
        [SerializeField]
        private Vector2 mDir;
        [SerializeField]
        private float mRadius;
        private RectTransform mRect;
        private Image mImage;
        public RectTransform Rect
        {
            get
            {
                if (mRect == null)
                    mRect = GetComponent<RectTransform>();
                return mRect;
            }
        }
        public Image Image
        {
            get
            {
                if (mImage == null)
                    mImage = GetComponent<Image>();
                return mImage;
            }
        }

        public void SetParent(Transform parent)
        {
            transform.SetParent(parent);
        }
        public void SetPos(Vector2 pos)
        {
            Rect.anchoredPosition = pos;
        }
        public void SetSize(Vector2 size)
        {
            Rect.sizeDelta = size;
        }
        public void SetSprite(Sprite sprite)
        {
            Image.sprite = sprite;
        }
        public void SetColor(Color color)
        {
            Image.color = color;
        }
        public void SetDir(Vector2 dir)
        {
            mDir = dir;
        }
        public void SetRadius(float radius)
        {
            mRadius = radius;
        }

        public void OnDrag(PointerEventData eventData)
        {
            Rect.anchoredPosition += DragDeltaOnDir(eventData, mDir);
            //雷达图中handler取值不可以为负数
            if (Vector2.Dot(Rect.anchoredPosition, mDir) / (Rect.anchoredPosition.magnitude * mDir.magnitude) < 0f)//若为负数,则夹角余弦=-1
            {
                Rect.anchoredPosition = Vector2.zero;
            }
            //雷达图中handler取值不可以超出上限
            if (Rect.anchoredPosition.magnitude > mRadius)
            {
                Rect.anchoredPosition = mDir * mRadius;
            }
        }
        private Vector2 DragDeltaOnDir(PointerEventData eventData, Vector2 dir)
        {
            float scale = Rect.lossyScale.x;//父物体的累计缩放倍数
            //evenData中的delta需要除去这个累计缩放倍数
            Vector2 delta = new Vector2(eventData.delta.x / scale, eventData.delta.y / scale);
            float projectionLength = CalculateProjectionLength(delta, dir);
            return new Vector2(dir.x * projectionLength, dir.y * projectionLength) / scale;
        }
        private float CalculateProjectionLength(Vector2 a, Vector2 b)
        {
            //求a在单位向量b上的投影
            return a.x * b.x + a.y * b.y;
        }
    }
}

RadarGraphEditor脚本:

using UnityEngine;
using UnityEditor;
using UnityEditor.UI;

namespace StudyUGUIExample
{
    [CustomEditor(typeof(RadarGraph), true), CanEditMultipleObjects]
    public class RadarGraphEditor : ImageEditor
    {
        private SerializedProperty mVertexCount;//雷达图的顶点数
        private SerializedProperty mHandlerSprite;//内部可移动顶点的图片
        private SerializedProperty mHandlerColor;//内部可移动顶点的颜色
        private SerializedProperty mFilledColor;//填充的颜色
        private SerializedProperty mHandlerSize;//内部可移动顶点的大小
        private SerializedProperty mHandlers;//
        private SerializedProperty mRatios;//各边上的占比

        protected override void OnEnable()
        {
            base.OnEnable();
            mVertexCount = serializedObject.FindProperty("mVertexCount");
            mHandlerSprite = serializedObject.FindProperty("mHandlerSprite");
            mHandlerColor = serializedObject.FindProperty("mHandlerColor");
            mFilledColor = serializedObject.FindProperty("mFilledColor");
            mHandlerSize = serializedObject.FindProperty("mHandlerSize");
            mHandlers = serializedObject.FindProperty("mHandlers");
            mRatios = serializedObject.FindProperty("mRatios");
        }

        public override void OnInspectorGUI()
        {
            base.OnInspectorGUI();
            //更新序列化物体
            serializedObject.Update();

            EditorGUILayout.PropertyField(mVertexCount, new GUIContent("雷达图的顶点数"));
            EditorGUILayout.PropertyField(mHandlerSprite, new GUIContent("内部可移动顶点的图片"));
            EditorGUILayout.PropertyField(mHandlerColor, new GUIContent("内部可移动顶点的颜色"));
            EditorGUILayout.PropertyField(mFilledColor, new GUIContent("填充的颜色"));
            EditorGUILayout.PropertyField(mHandlerSize, new GUIContent("内部可移动顶点的大小"));
            EditorGUILayout.PropertyField(mHandlers, true);
            EditorGUILayout.PropertyField(mRatios, true);
            RadarGraph radar = target as RadarGraph;
            if (radar != null)
            {
                if (GUILayout.Button("初始化雷达图"))
                {
                    radar.InitRadarGraph();
                }
                if (GUILayout.Button("生成内部可移动顶点"))
                {
                    radar.InitHandlers();
                }
            }
            //应用更改
            serializedObject.ApplyModifiedProperties();
            if (GUI.changed)
            {
                EditorUtility.SetDirty(target);
            }
        }
    }
}