Unity 套圈捕捉 UI 实现分享:椭圆环 Shader + 动态进度

Unity 套圈捕捉 UI 实现分享

期望表现效果

《拼贴冒险传 / PatchQuest》 捕捉进度 动态UI

实现效果

  • 目标:角色 A 套圈怪物 B,进度环显示围绕角度。
  • 技术点:Shader 绘制椭圆环,支持描边、顺/逆时针,需要对两个切口也进行描边。

技术需求 & 准备

  • Unity
  • RawImage + 自定义 Shader
  • Canvas 设置为 World Space,UI 跟随敌人
  • C# 脚本控制进度和方向

UI预制体的层级结构


捕捉逻辑

  1. 玩家位置与敌人位置计算方向向量。
  2. 计算 DeltaAngle,累积角度。
  3. 正负值表示顺/逆时针。
  4. LassoUI GameObject 始终对齐敌人位置。

`

PlayerController.cs捕捉逻辑实现

核心变量定义

csharp 复制代码
// 角度计算相关变量
float totalAngle;           // 累计角度
Vector2 lastDir;           // 上一帧的玩家->猎物方向
Vector2 startDir;          // 初始方向 玩家->猎物方向
Role prey;                 // 猎物对象

进入捕捉状态初始化

csharp 复制代码
private void Catching_Enter()
{
    
    // UI跟随猎物位置
    lassoUI.transform.position = prey.transform.position;
    lassoUI.SetRequiredAngle(prey.NeedAngle);
    
    // 初始化方向向量
    startDir = (transform.position - prey.transform.position).normalized;
    lassoUI.InitStartDir(startDir); 
    lastDir = startDir;
    totalAngle = 0f;

    // 绑定满圈事件
    lassoUI.OnFullRotation += HandleLassoFullRotation;
    lassoUI.Show();
}

核心角度计算逻辑

csharp 复制代码
private void Catching_Update()
{
    // 让LassoUI跟随猎物位置
    if (lassoUI != null && prey != null)
    {
        lassoUI.transform.position = prey.transform.position;
    }
    
    // 计算当前方向向量
    Vector2 currentDir = (transform.position - prey.transform.position).normalized;
    
    // 计算角度变化(相对上一次)
    float delta = Mathf.DeltaAngle(
        Mathf.Atan2(lastDir.y, lastDir.x) * Mathf.Rad2Deg,
        Mathf.Atan2(currentDir.y, currentDir.x) * Mathf.Rad2Deg
    );

    totalAngle += delta; // 累计总角度(正负都可以)
    
    lastDir = currentDir;
    lassoUI.UpdateProgress(totalAngle);

    // 检查是否满圈
    if (Mathf.Abs(totalAngle) >= prey.NeedAngle)
    {
        HandleLassoFullRotation();
        lassoUI.ResetProgress();
    }
}

抓捕成功处理

csharp 复制代码
void HandleLassoFullRotation()
{
    // 满圈了,执行抓捕成功逻辑
    Debug.Log("执行抓捕成功");
    
    // 调用UI弹出动画
    UIManager.instance.GetPanel<BattleMainPanel>().ShowImagePopUp();
    
    // 销毁猎物
    if (prey != null)
    {
        prey.Dead();
    }
    
    // 退出抓捕状态,回到射击模式
    fsm.ChangeState(PlayerControllerStates.Shooting);
}

关键技术点说明

1. 角度计算原理

  • 使用 Mathf.Atan2() 将方向向量转换为角度
  • 使用 Mathf.DeltaAngle() 计算相对角度变化,自动处理角度跨越问题
  • 支持顺时针和逆时针旋转,正负值自动处理

2. UI跟随机制

  • 每帧更新 lassoUI.transform.position = prey.transform.position
  • 确保UI始终跟随猎物位置

3. 状态管理

  • 使用状态机管理不同游戏状态(射击、狩猎、捕捉)
  • 进入捕捉状态时初始化角度计算
  • 退出时清理事件绑定

4. 事件驱动

  • 通过 OnFullRotation 事件触发抓捕成功逻辑
  • 实现UI和游戏逻辑的解耦

UI 进度计算

LassoUI.cs

ringMaterial 为shader材质的引用

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System;

namespace Gameplay.Battle
{
    public class LassoUI : MonoBehaviour
    {
        // [SerializeField] private Image progressCircle; // 圆环Image
        private CanvasGroup canvasGroup; // 控制显示隐藏的透明度
        private float accumulatedAngle = 0f; // 累计角度
        private float requiredAngle = 360f;  // 默认1圈
        public Material ringMaterial;
        public Vector2  startDir = Vector2.up; // 初始方向 玩家->猎物方向

        
        
        public event Action OnFullRotation; // 触发满圈事件
        
        // Start is called before the first frame update
        void Start()
        {
            canvasGroup = GetComponent<CanvasGroup>();
            Hide();
        }

        public void Show()
        {
            canvasGroup.alpha = 1;
            canvasGroup.blocksRaycasts = true;
            canvasGroup.interactable = true;
        }

        public void Hide()
        {
            canvasGroup.alpha = 0;
            canvasGroup.blocksRaycasts = false;
            canvasGroup.interactable = false;
        }

        public void InitStartDir(Vector2 dir)
        {
            startDir = dir;
            float startAngle = Mathf.Atan2(startDir.y, startDir.x) * Mathf.Rad2Deg;
            
            // 只设置起始角度,不设置进度
            ringMaterial.SetFloat("_StartAngle", startAngle);
            ringMaterial.SetFloat("_Progress", 0f); // 进度从0开始
            
            Debug.Log($"LassoUI: 初始化角度 = {startAngle}°");
        }


        public void SetRequiredAngle(float angle)
        {
            requiredAngle = angle;
            Debug.Log($"LassoUI: 设置所需角度 = {requiredAngle}°");
        }
       public void ResetProgress()
        {
            accumulatedAngle = 0f;
        }

        public void UpdateProgress(float angle)
        {

            var Progress = Mathf.Clamp(angle / requiredAngle,-1f,1f);
            ringMaterial.SetFloat("_Progress", Progress);
        }
    }
}

Shader 实现

参数调整

c 复制代码
Shader "Unlit/EllipseRingProgress"
{
    Properties
    {
        _MainColor ("Fill Color", Color) = (1,0.5,0,1)           // 内圈填充颜色
        _EdgeColor ("Edge Color", Color) = (0,0,0,1)             // 描边颜色
        _Progress ("Progress", Range(-1,1)) = 0                  // 进度,负数顺时针,正数逆时针
        _Thickness ("Ring Thickness", Range(0.01,2)) = 1        // 环宽
        _EdgeWidth ("Edge Width", Range(0.001,0.1)) = 0.02      // 内外描边宽度
        _CapEdgeAngle ("Cap Edge Width (Degrees)", Range(0,5)) = 1.0 // 封口两端描边角度
        _EllipseA ("Ellipse Semi-major Axis", Float) = 1        // 椭圆长轴
        _EllipseB ("Ellipse Semi-minor Axis", Float) = 1        // 椭圆短轴
        _StartAngle ("Start Angle Offset (Degrees)", Range(-180,180)) = 0 // 起始角度
    }
    SubShader
    {
        Tags { "Queue"="Transparent" "RenderType"="Transparent" }
        LOD 100

        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha
            Cull Off
            ZWrite Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            fixed4 _MainColor;
            fixed4 _EdgeColor;
            float _Progress;
            float _Thickness;
            float _EdgeWidth;
            float _CapEdgeAngle;
            float _EllipseA;
            float _EllipseB;
            float _StartAngle;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            // 顶点程序
            v2f vert(appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                // 将 UV 从 [0,1] 映射到 [-1,1],中心在 (0,0)
                o.uv = v.uv * 2 - 1;
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float2 pos = i.uv;

                // 1️⃣ 计算椭圆归一化距离
                float ellipseDist = (pos.x * pos.x) / (_EllipseA * _EllipseA) +
                                    (pos.y * pos.y) / (_EllipseB * _EllipseB);

                float halfThickness = _Thickness * 0.5;
                float innerBoundary = 1.0 - halfThickness;
                float outerBoundary = 1.0 + halfThickness;

                // 不在环内的点直接丢弃
                if (ellipseDist < innerBoundary || ellipseDist > outerBoundary)
                    discard;

                // 2️⃣ 计算极角 (0~360)
                float angleRad = atan2(pos.y / _EllipseB, pos.x / _EllipseA);
                float angleDeg = degrees(angleRad);
                if (angleDeg < 0) angleDeg += 360;
                float relativeAngle = fmod(angleDeg - _StartAngle + 360, 360);

                // 3️⃣ 处理顺/逆时针显示
                float absProgress = abs(_Progress); // 进度长度
                bool clockwise = (_Progress < 0);   // 顺时针方向
                float progressAngle = absProgress * 360;

                if (clockwise)
                {
                    // 顺时针:从起点往回走
                    if (relativeAngle < (360 - progressAngle) && relativeAngle > 0)
                        discard;
                }
                else
                {
                    // 逆时针:原逻辑
                    if (relativeAngle > progressAngle)
                        discard;
                }

                // 4️⃣ 内外描边
                bool radialEdge = abs(ellipseDist - (1.0 - halfThickness)) < _EdgeWidth ||
                                  abs(ellipseDist - (1.0 + halfThickness)) < _EdgeWidth;

                // 5️⃣ 封口描边计算
                float startCap = 0;
                float endCap = progressAngle;
                if (clockwise)
                {
                    startCap = 360 - progressAngle;
                    endCap = 360;
                }

                bool capEdge = (relativeAngle < _CapEdgeAngle) ||
                               (abs(relativeAngle - startCap) < _CapEdgeAngle) ||
                               (abs(relativeAngle - endCap) < _CapEdgeAngle);

                // 6️⃣ 返回颜色
                if (radialEdge || capEdge)
                    return _EdgeColor; // 描边
                return _MainColor;    // 填充
            }
            ENDCG
        }
    }
}

说明

  • _Progress

    • 负值 → 顺时针
    • 正值 → 逆时针
  • _StartAngle

    • 控制环起点位置
  • _EdgeWidth

    • 调整环内外描边粗细
  • _CapEdgeAngle

    • 调整封口角度宽度
  • _EllipseA/B

    • 控制椭圆比例,可实现圆形或拉长效果
  • _Thickness

    • 环宽

📌 总结

  1. 通过 Shader 对椭圆环的归一化计算,实现动态进度显示。
  2. 支持顺/逆时针显示。
  3. 封口描边、内外描边,增强视觉效果。
  4. C# 控制 _Progress_StartAngle,UI 可随角色位置和方向实时更新。