unity3d改变物体的中心位置_【unity3d模块总结】-2D虚拟手柄

unity3d改变物体的中心位置_【unity3d模块总结】-2D虚拟手柄_第1张图片

【前序】

那么,首先各位2019新年快乐吧。跨年之际,一位学土木的业余游戏程序员,在这里为大家带来他的第一篇文章(并希望以后就把知乎当作自己博客算了)。知乎的App做的倒是挺不错的说,博客写在这里可以随时在手机上看到,而且突然发现自己竟然注册过(虽然也被推荐过去CSDN一类的专业IT站去写博客),嘛不过既然注册了知乎能用就用便是了,博客又不是非得写给谁看得,更多的是为自己写的就是了。而且其实我也不是专攻编程软件开发方面的,一方面是被分到了土木专业,更多的是有一个成为全栈独立游戏制作者的梦想(或者说努力挣扎的方向),比起那些专业的IT站或许知乎这里更适合分享各方面的知识。相信这里也有一些真正想学技术的人,嘛也不乏一些吃瓜的人,不过都欢迎评论就是了,大家一起笑着讨论知识便是

【2D虚拟手柄】

首先来说说2D虚拟手柄吧。就是你玩碧蓝航线的时候左下角的那个控制舰娘方向的手柄。也是你玩第五人格,手游吃鸡的时候左下角那个控制方向的手柄

2D虚拟手柄个人给予它的定义是:在手机游戏中,通过触摸并滑动,在以初始触摸点为中心参考,结合当前滑动的触摸点,以完成对于方向的控制的一种实现方式

它的原理基于实体手柄中的摇杆所设计

以下对于如何实现虚拟手柄进行泛型的大致方法总结,以及如何在unity3d中实现虚拟手柄进行详细的阐述

【定义与揭示】

以下对于【2D虚拟手柄】的一些相关概念进行个人的定义和解释,引入一些新的定义名词,以便更好的,详细的阐述【2d虚拟手柄】

----【锁定式】【非锁定式】:这是两种【2d虚拟手柄】的常见分类形式,区别在于【2d虚拟手柄】是否被固定在一个位置并处于始终可见的状态(【锁定式】),还是在一定的范围内,当你摁下时以你的初始触摸点为中心生成手柄(【非锁定式】)

unity3d改变物体的中心位置_【unity3d模块总结】-2D虚拟手柄_第2张图片

----【触摸点】:即用户手指触摸手机屏幕并滑动的过程中,手指的当前位置

----【参考中心】:通过与【触摸点】进行相应的运算,来完成【2d虚拟手柄】主要功能的一个重要的中心参考位置。无论何时【手柄圆】都应以【参考中心】为圆心。【锁定式】中的锁定也是【参考中心】的锁定,而【非锁定式】中,则以用户当前摁下时的初始触摸位置为参考中心来生成手柄

---- 【触点圆】:【2d虚拟手柄】中反应当前触摸位置的一个圆形,用于告知操作者当前的触摸相对位置。但【触点圆】的位置应被限制在【手柄圆】中,即当前若【触摸点】在【手柄圆】中,【触点圆】的位置就是【触摸点】的位置,若当前【触摸点】不在【手柄圆】中,【触点圆】的位置应是【触摸点】相对【参考中心】的向量与【手柄圆】边界相交的位置

----【手柄圆】:【2d虚拟手柄】中以【参考中心】为圆心,一定半径的一个大圆形。用于限制【触点圆】,也对于实现【2d虚拟手柄】的相关功能有一定的作用

----【启发域】:即【2d虚拟手柄】接受用户初始触摸输入的一个限制范围,由于不可能将整个触摸屏的范围都交由【2d虚拟手柄】来使用,因此【2d虚拟手柄】的触摸接收就需要设置一定的初始触摸接收范围。在捕捉到一个符合要求的【触摸点】后,将其传入【2d虚拟手柄】的接收方法中,并对其进行持续的“关注”,以其对应的当前【触摸点】结合【参考中心】进行相应的运算,以完成【2d虚拟手柄】相应功能的实现。在【锁定式】中【启发域】就是【手柄圆】,在【非锁定式】中【启发域】则是一个屏幕内的限制范围

【unity3d的实现】

那么终于到了万众期待的实现环节,且听我慢慢道来其结合【unity3d】引擎的实现方法

好吧其实代码早就写好了,也被用在了其他许多项目中了,只是一直没有好好总结一下,并整理出一个可用的Package罢了,下面也是进行了一些总结和更正

以下是对于【非锁定式】的实现,完成后其实插入一个Flag变量并区分一下OnPointerDown中的触摸位置,以及在抬起和摁下时一些方法就可用自然的完成【锁定式】了

首先先让我们看看场景中需要的对象和的层级关系

>【启发域】用一个Panel来实现并自然作为画布的子对象

>>【手柄圆】用一张Image并作为【启发域】Panel的子对象

>>>【触点圆】用一张Image并作为【手柄圆】Image的子对象

之后是要求RectTransform中的锚点为x y 均拉开且Left Right Top Bottom数据均为0,如下图中的Panel锚点设置

unity3d改变物体的中心位置_【unity3d模块总结】-2D虚拟手柄_第3张图片

这里是要用到UnityEngine.EventSystems;中的一些接口,来完成对于【触摸点】的捕捉·,那么自然脚本就需要挂在【启发域】的那张Panel上,让我们创建一个名为Button_Handle的脚本并挂在Panel上吧

之后进入脚本环节

首先让我们先引入命名空间,并实现几个需要被使用到的接口

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
public class Button_Handle :  MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IDragHandler, IBeginDragHandler
{
      
//接口
	public void OnBeginDrag(PointerEventData eventData)//滑动开始
	{
      
		
	}

	public void OnDrag(PointerEventData eventData)//滑动中
	{
      
		
	}

	public void OnPointerDown(PointerEventData eventData)//触摸开始
	{
      
		
	}

	public void OnPointerUp(PointerEventData eventData)//触摸结束
	{
      
		
	}
//Base
	// Use this for initialization
	void Start () {
      
		
	}
	
	// Update is called once per frame
	void Update () {
      
		
	}
}

之后我们来声明以下需要被用到的变量,public变量通过场景中拖拽完成赋值。

这里public Camera mcma;//摄像机 获取了摄像机是由于eventData.position 是一个ScreenPoint 而不同分辨率下相同的相对位置的ScreenPoint是不同的,即800*600中屏幕的最右上角点是(800,600)而1920*1080中则是(1920,1080)。需要通过ViewPoint来进行统一,而unity中管理UI对象位置的RectTransform也是使用ViewPoint所以,需要利用摄像机的ScreenToViewportPoint方法进行转换

不过这里做一个更正,应使用Camera.main的方法来获取,而不是public Camera mcma;并在场景中拖拽,因为只是需要利用ScreenToViewportPoint方法罢了,画布也被设置为Screen Spack - Overlay 所以用Camera.main的方法来获取显然更好一些,所以之后的代码中都使用了Camera.main的方法

没有声明出【参考中心】是由于button_Img的中心,以及相对button_Img的ViewPoint坐标(0.5,0..5)代表就代表了【参考中心】的位置

  	//UI_Button 控制
	public GameObject button_Img;//手柄圆
	public GameObject button_Point;//触摸圆
	public Camera mcma;//摄像机

	bool isonCon = false;//是否正在控制

之后一步步按照【2d虚拟手柄】的设计输入操作步骤,和相应的实现功能来一步步完成这个【2d虚拟手柄】

首先,脚本被挂在Panel上,并实现相应的接口来完成输入,所以当被实现的接口方法被调用时,就已经保证了触摸点一定在【启发域】中了

那么当用户摁下时,我们希望显示【手柄圆】,抬起时则取消显示(【非锁定式】的特性)

public void OnPointerDown(PointerEventData eventData)//触摸开始
	{
      
		button_Img.SetActive(true);//显示
	}

	public void OnPointerUp(PointerEventData eventData)//触摸结束
	{
      
		button_Img.gameObject.SetActive(false);//取消显示
	}

ok这样就好了

之后我们希望在用户摁下时的初始触摸位置为中心设置【手柄圆】的位置,并将【触点圆】置于【手柄圆】的中心,也就是初始触摸位置

我们在OnPointerDown中来完成这两步操作

public void OnPointerDown(PointerEventData eventData)//触摸开始
	{
      
		button_Img.SetActive(true);//显示

		//根据初始点击点 设置【手柄圆】的位置
		Vector3 viewPoint = Camera.main.ScreenToViewportPoint(new Vector3(eventData.pressPosition.x,eventData.pressPosition.y, 0));
		setToViewPoint(viewPoint, gameObject, button_Img, true);
		//根据初始点击点 设置【触点圆】的位置
		setToViewPoint(new Vector2(0.5f, 0.5f), button_Img, button_Point, false);
	}

这里我们声明了一个setToViewPoint的方法,根据传入的ViewPoint来调整第三参数中传入的GameObjecr的RectTransform的位置,并根据第四参数,一个布尔型来判断是否考虑第二参数的【转入偏移量】

下面来说说【转入偏移量】吧

首先我们再回忆一下现在场景物体的层级关系(你可以翻到前面去看看,这里不再过多赘述)

ok,脚本是被挂在Panel上的,【手柄圆】是Panel的子对象,如果你知道RectTransform的特性,那么你就应该知道,此时【手柄圆】Image的RectTransform坐标和Panel的坐标已经不一样了

Panel是画布的子对象,对于它来说ViewPoint的点(1,1)就是画布的最右上角(也是屏幕的最右上角)

而【手柄圆】的Image是Panel的子对象,对于它来说ViewPoint的点(1,1)就是Panel的最右上角

在实际使用中由于Panel实现的是【启发域】,你可以再读一读我对【启发域】的定义和解释,就知道画布的最右上角和Panel的最右上角是不一样的(见下图,常见的以左1/4屏幕作为【启发域】时)

unity3d改变物体的中心位置_【unity3d模块总结】-2D虚拟手柄_第4张图片

而此时你使用ScreenToViewportPoint转换eventData.pressPosition的ScreenPoint数据后得到的ViewPoint数据是相对于画布中的ViewPoint数据,而不是相对于Panel的,即(1,1)点对应画布最右上角

而由于【手柄圆】的Image是【启发域】的那张Panel的子对象,所以你需要用对应Panel中的ViewPoint来设置【手柄圆】的Image的位置,所以需要考虑一个将高一级(画布中)的ViewPoint转入低一级(Panel中)的一个偏移转换问题

但在设置【触点圆】的Image时,【触点圆】一定需要被设置在【手柄圆】的中心,由于【触点圆】的Image是【手柄圆】的Image的子对象,所以显然ViewPoint数据应是(0.5,0.5)且不考虑偏移

之后的位置设置就是根据初始锚点的长宽,将锚点中心至于最终需要的位置坐标上

以下是setToViewPoint的代码

void setToViewPoint(Vector2 viewPoint, GameObject objbase, GameObject objset, bool model)
//根据base中view点 设置set RectTrans位置 model true:由点击触发考虑baseView偏移值(进行ViewPoint转入) false:不考虑baseView偏移值(硬性设置)
	{
      
		//获取RectTransform
		RectTransform objset_rt = objset.GetComponent();
		//获取Img Anchor占比距离 Panel Anchor占比距离
		float objset_x = objset_rt.anchorMax.x - objset_rt.anchorMin.x;
		float objset_y = objset_rt.anchorMax.y - objset_rt.anchorMin.y;
		Vector2 topanel_Point;//最终需要的坐标位置
		if (model)
		{
      
			topanel_Point = changeInBase(viewPoint, objbase);//将高一级的ViewPoint转入低一级中
		}
		else
		{
      
			topanel_Point = viewPoint;
		}
		objset_rt.anchorMin = new Vector2(topanel_Point.x - objset_x / 2, topanel_Point.y - objset_y / 2);
		objset_rt.anchorMax = new Vector2(topanel_Point.x + objset_x / 2, topanel_Point.y + objset_y / 2);
	}

这里在model为true时使用了一个changeInBase的转换方法来完成将高一级的ViewPoint转入低一级中

以下是changeInBase的代码

Vector2 changeInBase(Vector2 viewPoint, GameObject objbase)//将高一级的ViewPoint 转入低一级
	{
      
		//获取RectTransform
		RectTransform objbase_rt = objbase.GetComponent();

		float objbase_x = objbase_rt.anchorMax.x - objbase_rt.anchorMin.x;
		float objbase_y = objbase_rt.anchorMax.y - objbase_rt.anchorMin.y;
		//获取ViewPoint转换至objbase中的对应的View点

		return new Vector2((viewPoint.x - objbase_rt.anchorMin.x) / objbase_x, (viewPoint.y - objbase_rt.anchorMin.y) / objbase_y);

	}

转换原理是首先根据objbase的RectTransform最左下角锚点,将ViewPoint的原点先变换为objbase的最左下角,在将其位置坐标的x y分别对应除以objbase的RectTransfor的x y锚点长宽(相当于是根据objbase的锚点宽高进行了一定比例的收缩)

unity3d改变物体的中心位置_【unity3d模块总结】-2D虚拟手柄_第5张图片

ok,那么现在我们还需要在用户滑动过程中设置【触点圆】Image的位置

我们在OnDrag所实现的方法中,完成用户触摸后滑动的相关控制

那么我们首先需要把ScreenToViewportPoint转换eventData.pressPosition的ScreenPoint数据后得到的ViewPoint数据再做两次转换,先转入【启发域】的Panel中,再转入【手柄圆】的Image中,以设置作为【手柄圆】Image子对象的【触点圆】Image的位置

而根据【触摸点】是否在【手柄圆】中,【触点圆】的设置分为两种(如果不记得了,你可以往前翻一翻,看看我之前对【触点圆】的定义和解释)

下面是onDrag中的代码

public void OnDrag(PointerEventData eventData)//滑动中
	{
      
		//获取ViewPoint
		Vector3 viewPoint = Camera.main.ScreenToViewportPoint(new Vector3(eventData.position.x, eventData.position.y, 0));
		Vector2 panelViewPoint = changeInBase(viewPoint, gameObject);
		//
		Vector2 button_base_ViewPoint = changeInBase(panelViewPoint, button_Img.gameObject);

		Vector2 toMiddlePoint = new Vector2(button_base_ViewPoint.x - 0.5f, button_base_ViewPoint.y - 0.5f);
		float disToMiddlePoint = Mathf.Sqrt(toMiddlePoint.x * toMiddlePoint.x + toMiddlePoint.y * toMiddlePoint.y);
		if (disToMiddlePoint - 0.5 < 0.001)//在范围内
		{
      
			//按键设置
			setToViewPoint(panelViewPoint, button_Img.gameObject, button_Point.gameObject, true);
			
		}
		else//不在范围内
		{
      
			//按键设置
			Vector2 goToViewPoint = toMiddlePoint.normalized * 0.5f;
			setToViewPoint(new Vector2(goToViewPoint.x + 0.5f, goToViewPoint.y + 0.5f), button_Img.gameObject, button_Point.gameObject, false);
			
		}
	}

好的至此,我们的【2d虚拟手柄】的基础操作接收部分算是完成了

那么我们再来插入一个 isLock的布尔型变量,并进行相关的设置,来完成【锁定式】的【2d虚拟手柄】的实现

以下是更改过后的相关代码

相关设置讲解见注释

        public bool isLock = false;//是否是【锁定式】
	bool isStartInHandlePoint = false;//触摸点一开始是否在【手柄圆】中
//接口
	public void OnDrag(PointerEventData eventData)//滑动中
	{
      
		if (isLock&&!isStartInHandlePoint)//【锁定式】且初始触摸点不在【手柄圆】中,不进行onDrag
		{
      
			return;
		}
		//获取ViewPoint
		Vector3 viewPoint = Camera.main.ScreenToViewportPoint(new Vector3(eventData.position.x, eventData.position.y, 0));
		Vector2 panelViewPoint = changeInBase(viewPoint, gameObject);
		//
		Vector2 button_base_ViewPoint = changeInBase(panelViewPoint, button_Img.gameObject);

		Vector2 toMiddlePoint = new Vector2(button_base_ViewPoint.x - 0.5f, button_base_ViewPoint.y - 0.5f);
		float disToMiddlePoint = toMiddlePoint.magnitude;
		if (disToMiddlePoint - 0.5 < 0.001)//在范围内
		{
      
			//按键设置
			setToViewPoint(panelViewPoint, button_Img.gameObject, button_Point.gameObject, true);
			
		}
		else//不在范围内
		{
      
			//按键设置
			Vector2 goToViewPoint = toMiddlePoint.normalized * 0.5f;
			setToViewPoint(new Vector2(goToViewPoint.x + 0.5f, goToViewPoint.y + 0.5f), button_Img.gameObject, button_Point.gameObject, false);
			
		}
	}
        public void OnPointerDown(PointerEventData eventData)//触摸开始
	{
      
		if (!isLock)//【非锁定式】
		{
      
			button_Img.SetActive(true);//显示

			//根据初始点击点 设置【手柄圆】的位置
			Vector3 viewPoint = Camera.main.ScreenToViewportPoint(new Vector3(eventData.pressPosition.x, eventData.pressPosition.y, 0));
			setToViewPoint(viewPoint, gameObject, button_Img, true);
			//根据初始点击点 设置【触点圆】的位置
			setToViewPoint(new Vector2(0.5f, 0.5f), button_Img, button_Point, false);
		}
		else
		//【锁定式】需要根据初始触摸点是否在【手柄圆】中设置flag告知onDrag是否需要触发,以及在范围内时立刻完成对于【触摸点】位置的设置
		{
      
			Vector3 viewPoint = Camera.main.ScreenToViewportPoint(new Vector3(eventData.position.x, eventData.position.y, 0));
			Vector2 panelViewPoint = changeInBase(viewPoint, gameObject);
			//
			Vector2 button_base_ViewPoint = changeInBase(panelViewPoint, button_Img.gameObject);

			Vector2 toMiddlePoint = new Vector2(button_base_ViewPoint.x - 0.5f, button_base_ViewPoint.y - 0.5f);
			float disToMiddlePoint = toMiddlePoint.magnitude;
			if (disToMiddlePoint - 0.5 < 0.001)//在范围内
			{
      
				isStartInHandlePoint = true;
				//按键设置
				setToViewPoint(panelViewPoint, button_Img.gameObject, button_Point.gameObject, true);

			}
			else
			{
      
				isStartInHandlePoint = false;
			}
		}

	}

	public void OnPointerUp(PointerEventData eventData)//触摸结束
	{
      
		if (isLock)//【锁定式】抬起时【触点圆】回归中心,而不是取消显示
		{
      
			setToViewPoint(new Vector2(0.5f, 0.5f), button_Img, button_Point, false);
			return;
		}
		button_Img.gameObject.SetActive(false);//取消显示
	}
	void Start () {
       
		if (isLock)//【锁定式】一开始就需要被设置在中心
		{
      
			setToViewPoint(new Vector2(0.5f, 0.5f), button_Img, button_Point, false);
		}
		else//【非锁定式】一开始保证不现实【手柄圆】
		{
      
			button_Img.SetActive(false);
		}
	}

下面是一个实际中使用的例子:

1.控制角色的移动:

这里是之前写好的使用了【非锁定式】的代码,所以没有呼应上面的【锁定式】代码的更正

onDrage代码更改为:

public void OnDrag(PointerEventData eventData)
	{
      
		
		//获取ViewPoint
		Vector3 viewPoint = mcma.ScreenToViewportPoint(new Vector3(eventData.position.x, eventData.position.y, 0));
		Vector2 panelViewPoint = changeInBase(viewPoint, gameObject);
		//
		Vector2 button_base_ViewPoint = changeInBase(panelViewPoint, button_Img.gameObject);

		Vector2 toMiddlePoint = new Vector2(button_base_ViewPoint.x - 0.5f, button_base_ViewPoint.y - 0.5f);
		float disToMiddlePoint = toMiddlePoint.magnitude;
		if (disToMiddlePoint - 0.5 < 0.001)//在范围内 比例速度
		{
      		
			//按键设置
			setToViewPoint(panelViewPoint, button_Img.gameObject, button_Point.gameObject, true);
			//速度设置
			nowSpeed= maxSpeed * (disToMiddlePoint / 0.5f);
			movetoPos = toMiddlePoint;
			//float onConSpeed = maxSpeed * (disToMiddlePoint / 0.5f);
			//setPlayerSpeed(toMiddlePoint, onConSpeed);
		}
		else//最大速度
		{
      

			//setPlayerSpeed(toMiddlePoint, maxSpeed);
			//按键设置
			Vector2 goToViewPoint = toMiddlePoint.normalized * 0.5f;
            setToViewPoint(new Vector2(goToViewPoint.x+0.5f,goToViewPoint.y+0.5f), button_Img.gameObject, button_Point.gameObject, false);
			//速度设置
			nowSpeed = maxSpeed;
			movetoPos = toMiddlePoint;
		}
	}

关于速度设置

这里根据触摸点是否在【手柄圆】中,还将速度的设置分为在【手柄圆】内的比例速度(按触摸点与【参考中心】的距离占【手柄圆】半径的百分比)和不在【手柄圆】内时的最大速度。

变量toMiddlePoint就是触摸点到【参考中心】的相对ViewPoint向量

变量disToMiddlePoint则是toMiddlePoint的向量模长

之后在开始滑动时设置一个flag变量为在控制,抬起时设置为不在控制并将角色速度归0(停下),不断更新nowSpeed速度和movetoPos方向并根据flag在Update中根据当前nowSpeed和movetoPos的来设定角色的速度

这里不在onDrag中设置速度的原因是由于滑动中停下时,onDrag就不会再调用了,会出现控制时速度设定的中断,因而需要转到Update中在检测到进行了滑动后每一帧都绝对的去设置速度

以下是其他相关的代码更改

        public void OnPointerUp(PointerEventData eventData)
	{
      
		button_Img.gameObject.SetActive(false);
		isonCon = false;
		//ClearSpeed
		onControlPlayer.velocity = new Vector3(0f, onControlPlayer.velocity.y, 0f);
	}
        Vector2 speedDir;
	Vector3 baseSpeedDir;
	Vector3 basesDir;
	public Transform cmabaseTrans;
	//受控Player
	void setPlayerSpeed(Vector2 dir,float speed)
	{
      

		speedDir = dir.normalized * speed;
		if (playerMod == PlayerMod.ThirdView_UnLock)
		{
      
			basesDir = new Vector3(speedDir.x, 0f, speedDir.y);
			basesDir = cmabaseTrans.TransformVector(basesDir);
			speedDir = new Vector2(basesDir.x, basesDir.z);

		}

		baseSpeedDir= new Vector3(speedDir.x, onControlPlayer.velocity.y, speedDir.y);
		onControlPlayer.velocity = onControlPlayer.transform.TransformVector(baseSpeedDir);
		
	}
	// Use this for initialization
	void Start()
	{
      

	}
	Vector3 lookDir;
	Quaternion now;
	Quaternion lookto;
	// Update is called once per frame
	void Update()
	{
      
		
		if (isonCon)
		{
      

			if (playerMod == PlayerMod.ThirdView_UnLock)
			{
      
				cmabaseTrans.position = new Vector3(mcma.transform.position.x, mcma.transform.position.y - mcma.transform.localPosition.y, mcma.transform.position.z);
				cmabaseTrans.LookAt(onControlPlayer.gameObject.transform);

				
				

				Vector3 lookDir = basesDir;
				Quaternion now = onControlPlayer.transform.Find("Body").rotation;
				Quaternion lookto = Quaternion.LookRotation(lookDir, Vector3.up);

				onControlPlayer.transform.Find("Body").rotation = Quaternion.RotateTowards(now, lookto,270* Time.deltaTime);

				
			}



			setPlayerSpeed(movetoPos, nowSpeed);
		}
	}

	public void OnBeginDrag(PointerEventData eventData)
	{
      
		isonCon = true;

	}

关于人物朝向的设置

这里还可以看到上面的代码Update中有一个

if(playerMod == PlayerMod.ThirdView_UnLock)

的判定,并根据这个判定(当前是【第三人称非锁定旋转型】的角色控制模式,就是第五人格的那种人物控制模式)还需要在检测【2d虚拟手柄】的输入控制时进行人物朝向的一些相关设定操作

这里关于【第三人称非锁定旋转型】以及包括这种控制模式的总共三种3D游戏角色控制模式,我将在接下来的“【unity3d模块总结】-3D游戏角色控制 ”一文中进行详细的归纳、阐述和总结。此处不再赘述。

ok,那么打错字的地方见谅,技术尚浅,不对的地方欢迎评论指正,欢迎评论交流

这里是Randy ---一位学土木的游戏程序员

再次祝各位新年快乐

你可能感兴趣的:(unity3d改变物体的中心位置_【unity3d模块总结】-2D虚拟手柄)