Unity UGUI 手搓虚拟摇杆:最小可用骨架与多点触控实践
简介:《Unity UGUI摇杆制作与物体移动控制》配套工程面向Unity初学者及需要快速实现移动端虚拟摇杆的开发者,围绕UGUI中Canvas、RectTransform与Image的组合使用,提供可直接运行的摇杆控制示例,解决触控输入到物体移动之间的基础交互问题。压缩包共1575个文件,约27.28MB,以info、bin、asset、meta及dll等工程与缓存文件为主,另含场景资源、脚本、贴图与项目配置,完整保留Unity项目的目录结构和依赖关系,便于在编辑器中打开对照学习。已有1874人学习下载。工程内含摇杆背景与滑块的搭建结果、Joystick与移动控制脚本示例,可帮助读者理解输入向量计算、滑块位置映射以及刚体物体移动的实现方式,也为后续限制滑块范围、适配触摸屏和优化交互手感提供可扩展的基础。
1. 手指按歪了还能走直线:UGUI 摇杆的最小可用骨架
Unity3D 摇杆 UGUI 这件事,说穿了就是两张 Image 加一个几十行的脚本,但真正放进真机跑,麻烦就来了:按在底盘边缘角色直接全速冲出去,手指滑出屏幕手柄卡在圈外不回弹,右手按开火键时左手走位突然失灵。我见过不少项目一上来就引第三方虚拟摇杆插件,结果打包后跟 Canvas 缩放模式打架,调两天还不如自己撸一个。原生 UGUI 的好处是所有逻辑都摊在明面上,节点层级、锚点、事件接口、射线检测顺序,出了问题能定位到具体哪一行、哪一个 RectTransform。这套骨架适合 2D 俯视角、3D 第三人称手游的移动输入,也适合在 PC 上模拟移动端输入做调试。下面按“搭节点、收输入、算方向、推角色”的顺序走一遍,每一段都能直接抄进工程跑起来。
2. 节点结构与组件配置:两张 Image 撑起一个输入源
2.1 为什么手搓而不是直接上插件
第三方虚拟摇杆插件的核心卖点通常是“拖进去就能用”,代价是把事件系统整个接管了。它一般会自己挂一个 EventSystem 监听、自己算屏幕坐标,遇到项目里已经有 UI 框架、有自定义 Canvas 模式、有多个 Canvas 分组时,就容易出现“摇杆能拖但按钮点不动”这类怪异现象。更常见的翻车点是 Canvas Scaler 的 Match 值一变,插件的偏移量就算错,手柄位置和手指位置对不上。
原生 UGUI 方案的核心只有三件事:底盘 Image 负责接收 Pointer 事件、手柄 Image 负责视觉反馈、一个脚本把屏幕坐标换算成以底盘中心为原点的方向向量。它的边界也很清楚——单摇杆加几个按钮绝对够用;要做双摇杆、动态摇杆、区域触摸,只需要改坐标换算那一段,不用跟插件的内部实现较劲。我的习惯是先用原生搭一版最小可用,等确认玩法需要更复杂的输入形态,再考虑替换,而不是反过来。
2.2 层级结构与组件清单
下面这套层级是我在多个项目里复用过的最小结构,节点名字可以换,但父子关系和组件挂载位置不要乱:
| 层级 | 节点名 | 挂载组件 | 关键设置 |
|---|---|---|---|
| 1 | UI Canvas | Canvas、CanvasScaler、GraphicRaycaster | Render Mode 选 Screen Space - Overlay |
| 2 | JoystickArea | RectTransform | 固定在屏幕左下角的容器 |
| 3 | RockerBg | Image、SimpleJoystick | Raycast Target 必须勾选 |
| 4 | RockerHandle | Image | Raycast Target 取消勾选 |
有两个地方新手容易搞反。第一,事件脚本SimpleJoystick挂在RockerBg上而不是 Canvas 上,因为 Pointer 事件的命中判定是基于“射线打到哪个 Graphic”,挂在 Canvas 上它收不到子节点的点击。第二,RockerHandle 的 Raycast Target 一定要取消,否则手柄滑到哪,哪一块区域就会挡住底盘的射线,拖动过程中事件源会从底盘切到手柄,表现就是“拖到一半摇杆失灵”。
2.3 RectTransform 的关键参数
RockerBg 的锚点建议压在左下角,这样不同分辨率下它相对屏幕角落的位置是稳定的:
| 参数 | RockerBg | RockerHandle |
|---|---|---|
| Anchor Min | (0, 0) | (0.5, 0.5) |
| Anchor Max | (0, 0) | (0.5, 0.5) |
| Pivot | (0.5, 0.5) | (0.5, 0.5) |
| Size Delta | (240, 240) | (100, 100) |
| Anchored Position | (180, 180) | (0, 0) |
这里 Anchored Position 的 (180, 180) 是相对左下角锚点的偏移,意思是底盘中心距离屏幕左边缘和下边缘各 180 像素。Size Delta 240 是直径,半径 120,手柄直径 100,这样手柄贴边时视觉上还能露出一半,不会完全盖住底盘轮廓。手柄的 Anchor 必须设成 (0.5, 0.5) 居中,因为它后续要被脚本用anchoredPosition移动,锚点不在中心的话,位移方向会跟着歪。
2.4 用脚本生成一份可验证的骨架
手搭完想快速验证逻辑对不对,可以用下面这段代码在运行时直接生成一套摇杆,省得每次改参数都要重进编辑器:
using UnityEngine; using UnityEngine.UI; public static class JoystickBuilder { // canvasRoot: 摇杆要挂到的 Canvas 节点 // anchoredPos: 相对左下角锚点的位置 public static SimpleJoystick Build(Transform canvasRoot, Vector2 anchoredPos, float bgSize, float handleSize) { // 1. 底盘:承载 Image 与事件脚本 var bgGo = new GameObject("RockerBg", typeof(RectTransform), typeof(Image), typeof(SimpleJoystick)); bgGo.transform.SetParent(canvasRoot, false); var bgRect = bgGo.GetComponent<RectTransform>(); bgRect.anchorMin = Vector2.zero; bgRect.anchorMax = Vector2.zero; bgRect.pivot = new Vector2(0.5f, 0.5f); bgRect.sizeDelta = new Vector2(bgSize, bgSize); bgRect.anchoredPosition = anchoredPos; var bgImage = bgGo.GetComponent<Image>(); bgImage.color = new Color(1f, 1f, 1f, 0.35f); bgImage.raycastTarget = true; // 必须接收射线 // 2. 手柄:只做视觉,不接收射线 var handleGo = new GameObject("RockerHandle", typeof(RectTransform), typeof(Image)); handleGo.transform.SetParent(bgGo.transform, false); var handleRect = handleGo.GetComponent<RectTransform>(); handleRect.anchorMin = new Vector2(0.5f, 0.5f); handleRect.anchorMax = new Vector2(0.5f, 0.5f); handleRect.pivot = new Vector2(0.5f, 0.5f); handleRect.sizeDelta = new Vector2(handleSize, handleSize); handleRect.anchoredPosition = Vector2.zero; var handleImage = handleGo.GetComponent<Image>(); handleImage.color = new Color(1f, 1f, 1f, 0.85f); handleImage.raycastTarget = false; // 关键:避免挡住底盘 // 3. 把引用注入脚本 var joystick = bgGo.GetComponent<SimpleJoystick>(); joystick.Setup(bgRect, handleRect); return joystick; } }逻辑说明:整段代码只做三件事——创建底盘并挂上SimpleJoystick、创建手柄并把 Raycast Target 关掉、通过Setup把两个 RectTransform 注入脚本。参数bgSize和handleSize决定视觉比例,anchoredPos决定摇杆在屏幕上的落点。这段代码不进正式工程也行,拿来做一次参数预演很省事。要注意的是它假设 Canvas 是 Overlay 模式,如果是 Camera 模式,事件换算里的相机参数还得另外传,后面第 3 章会讲。真正的工程里我还是推荐手搭节点,因为美术要换图、策划要挪位置,代码生成的节点不方便在 Inspector 里继续调。
3. 输入采集与方向计算:Pointer 接口、RectTransformUtility 和死区
3.1 三种事件写法的取舍
UGUI 上接收拖拽事件至少有三条路,选错了后面全是维护成本。
第一条是给节点挂EventTrigger组件,在面板里把 PointerDown、Drag、PointerUp 三个事件拖进去。它零代码,适合做原型,但每个事件都会在内部实例化一个 Entry 对象,节点多了以后 GC 压力肉眼可见;更要命的是它不记录pointerId,多指同时按下的场景无法区分是哪根手指。
第二条是实现IPointerDownHandler / IDragHandler / IPointerUpHandler三个接口。事件由 EventSystem 分发,代码可控,能拿到完整的PointerEventData,里面带pointerId、position、pressEventCamera这些字段。这是我最常用的写法。
第三条是在Update里自己做GraphicRaycaster.Raycast,灵活度最高,但要自己处理结果排序、自己判断按下与抬起,稍不注意就会和 EventSystem 的内置处理打架。除非要做“整个屏幕左半边任意位置都能起摇杆”这种动态摇杆,否则不建议。
3.2 屏幕坐标转局部坐标:RectTransformUtility 的正确调用
拖拽回调里的eventData.position是屏幕像素坐标,原点在屏幕左下角。摇杆需要的是“手指相对底盘中心的方向和距离”,所以要做一次坐标变换。UGUI 提供了现成方法:
// rect: 参照物,传底盘 RockerBg 的 RectTransform // screenPoint: eventData.position // cam: Overlay 模式下传 null;Camera 模式下传 canvas.worldCamera // localPoint: 输出结果,原点在 rect 的 pivot 处 RectTransformUtility.ScreenPointToLocalPointInRectangle( rect, screenPoint, cam, out Vector2 localPoint);这里两个参数最容易错。cam在Screen Space - Overlay下必须传null,传了相机反而会算出偏移;在Screen Space - Camera和World Space下必须传canvas.worldCamera,传 null 会得到一堆无意义的值。判断方式很简单:
private Camera uiCamera; private void Awake() { var canvas = GetComponentInParent<Canvas>(); // Overlay 模式下 worldCamera 为 null,其余模式取 Canvas 的渲染相机 uiCamera = canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera; }另一个坑是rect.sizeDelta不等于rect.rect.size。前者是“相对锚点的尺寸差”,在锚点被拉伸(Anchor Min ≠ Anchor Max)时它是不准的;后者永远是实际渲染尺寸。摇杆半径这种关键参数一律用rect.rect.size,别用 Size Delta。
3.3 完整的摇杆脚本
把事件采集、坐标换算、半径约束、死区处理都塞进一个脚本,结构会清晰一些:
using UnityEngine; using UnityEngine.EventSystems; public class SimpleJoystick : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler { [SerializeField] private RectTransform background; // 底盘 [SerializeField] private RectTransform handle; // 手柄 [SerializeField] private float handleRange = 0.9f; // 手柄活动半径占底盘半径的比例 [SerializeField] private float deadZone = 0.1f; // 死区,0~1 // 对外只暴露方向与按下状态,外部不需要知道内部怎么算的 public Vector2 InputDirection { get; private set; } public bool IsPressed { get; private set; } private Camera uiCamera; private int activePointerId = -1; // 记录当前由哪根手指操作 private void Awake() { var canvas = GetComponentInParent<Canvas>(); uiCamera = canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera; // 兜底:如果没在 Inspector 里拖引用,按层级顺序自动取 if (background == null) background = (RectTransform)transform; if (handle == null && transform.childCount > 0) handle = (RectTransform)transform.GetChild(0); } public void Setup(RectTransform bg, RectTransform hd) { background = bg; handle = hd; } public void OnPointerDown(PointerEventData eventData) { // 只接受第一根按下的手指,后续手指一律忽略 if (activePointerId != -1 && activePointerId != eventData.pointerId) return; activePointerId = eventData.pointerId; IsPressed = true; // 按下时先不输出方向,避免手指落在边缘直接触发全速 InputDirection = Vector2.zero; OnDrag(eventData); } public void OnDrag(PointerEventData eventData) { if (eventData.pointerId != activePointerId) return; Vector2 localPoint; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( background, eventData.position, uiCamera, out localPoint)) return; // 用 rect.size 而不是 sizeDelta,避免锚点拉伸时半径算错 Vector2 radius = background.rect.size * 0.5f; Vector2 normalized = new Vector2( localPoint.x / radius.x, localPoint.y / radius.y); // 限制在手柄活动范围内 normalized = Vector2.ClampMagnitude(normalized, handleRange); // 手柄视觉位移 handle.anchoredPosition = normalized * radius; // 死区处理:小于死区输出 0,大于死区做线性重映射 float magnitude = normalized.magnitude / handleRange; if (magnitude < deadZone) { InputDirection = Vector2.zero; } else { float mapped = (magnitude - deadZone) / (1f - deadZone); InputDirection = normalized.normalized * mapped; } } public void OnPointerUp(PointerEventData eventData) { if (eventData.pointerId != activePointerId) return; activePointerId = -1; IsPressed = false; InputDirection = Vector2.zero; handle.anchoredPosition = Vector2.zero; // 手柄回弹 } private void OnDisable() { // UI 被隐藏或切场景时事件可能丢失,这里强制复位 activePointerId = -1; IsPressed = false; InputDirection = Vector2.zero; if (handle != null) handle.anchoredPosition = Vector2.zero; } }逐段说明。Awake里先判断 Canvas 渲染模式取相机,再对引用做兜底,这样脚本可以两种方式初始化:Inspector 拖引用,或者用第 2 章的JoystickBuilder代码生成。OnPointerDown记录pointerId是为了多点触控——右手开火那根手指按在按钮上时,不应该把摇杆的方向清掉;反过来说,第二根手指按到摇杆上也不应该抢走第一根手指的控制权。OnDrag里的三次运算各司其职:先除以半径得到归一化偏移,再用ClampMagnitude把长度压在handleRange以内(保持方向不变),最后用normalized.normalized * mapped输出一个长度在 0 到 1 之间的方向向量。OnPointerUp必须复位手柄的anchoredPosition,只清InputDirection的话手柄会停在原地,视觉上就像“卡住了”。
参数方面,handleRange建议 0.85 到 0.95,留一点边距让手柄贴边时不至于超出底盘轮廓。deadZone建议 0.1 到 0.2,太小会飘,太大会让微操变得迟钝,具体数值要拿真机试。第 5 章会专门讲这两个参数调错时的表现。
4. 驱动角色移动:Transform、Rigidbody 和朝向的三条路子
4.1 输入层和运动层必须解耦
摇杆脚本只负责输出InputDirection和IsPressed,谁去消费这两个值由另一个脚本决定,这样同一套输入可以接玩家角色、接载具、接 UI 上的小预览,甚至接一个键盘映射的模拟输入做 PC 调试。如果摇杆脚本里直接写transform.position += ...,那这个摇杆就绑死在某个角色上了,做载具或者做观战镜头时只能复制粘贴,改一处漏三处。
下面所有移动脚本都只做一件事:Update或FixedUpdate里读joystick.InputDirection,换算成位移或者速度。
4.2 Transform.Translate 版本:简单、够用、有边界
using UnityEngine; public class JoystickMover : MonoBehaviour { [SerializeField] private SimpleJoystick joystick; [SerializeField] private float moveSpeed = 5f; // 米/秒 [SerializeField] private float rotateSpeed = 720f; // 度/秒 private void Update() { Vector2 input = joystick.InputDirection; if (input.sqrMagnitude < 0.0001f) return; // 死区外才动 // 把摇杆的 2D 方向映射到世界空间的 XZ 平面 Vector3 dir = new Vector3(input.x, 0f, input.y); transform.position += dir * moveSpeed * Time.deltaTime; // 朝向:只转 Y 轴,且用 RotateTowards 做平滑 Quaternion target = Quaternion.LookRotation(dir, Vector3.up); transform.rotation = Quaternion.RotateTowards( transform.rotation, target, rotateSpeed * Time.deltaTime); } }逻辑上没难度,关键是input的长度已经包含了“推杆幅度”,所以轻推和全推速度不同,这也是摇杆相比键盘输入的优势。sqrMagnitude比较是为了避免开平方,属于可选的微优化,真正重要的是那行注释:死区外才动。Quaternion.LookRotation要求传入的向量不能为零向量,所以前面的提前返回是必须的,否则角色会在原地产生 NaN 旋转,到时候找半天。
这个版本的边界也很明确:直接改transform.position会绕过物理系统,角色穿墙、穿过其他碰撞体时没有任何阻挡。如果项目里有物理交互、有斜坡、有推力,就应该换下一个版本。另外就是相机跟随如果在LateUpdate里做,可能会看到轻微的抖动,通常把相机跟随也挪到LateUpdate或者加一个插值就够。
4.3 Rigidbody 版本:FixedUpdate 里的速度赋值
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class JoystickRigidbodyMover : MonoBehaviour { [SerializeField] private SimpleJoystick joystick; [SerializeField] private float moveSpeed = 5f; [SerializeField] private float rotateSpeed = 720f; private Rigidbody rb; private void Awake() { rb = GetComponent<Rigidbody>(); rb.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationZ; } private void FixedUpdate() { Vector2 input = joystick.InputDirection; if (input.sqrMagnitude < 0.0001f) { // 松手时衰减速度,避免惯性滑出去 rb.velocity = new Vector3(0f, rb.velocity.y, 0f); return; } Vector3 dir = new Vector3(input.x, 0f, input.y); // 保留 Y 轴速度,只覆盖水平分量 rb.velocity = new Vector3(dir.x * moveSpeed, rb.velocity.y, dir.z * moveSpeed); Quaternion target = Quaternion.LookRotation(dir, Vector3.up); rb.MoveRotation(Quaternion.RotateTowards( rb.rotation, target, rotateSpeed * Time.fixedDeltaTime)); } }两个细节。第一,物理计算放FixedUpdate,不要放Update,否则在不同帧率下速度会忽快忽慢,这是新手最常见的“玄学抖动”来源之一。第二,赋值rb.velocity时要把 Y 轴分量接回来,否则重力会被每帧覆盖,角色永远落不了地。如果用的是CharacterController,思路类似,但改成在Update里调Move(dir * speed * Time.deltaTime)并配合isGrounded处理 Y 轴,代码会稍微长一些。
4.4 朝向:3D 用 LookRotation,2D 用 right 赋值
3D 场景下的朝向在上一段已经写过了,Quaternion.LookRotation加RotateTowards的组合能平滑转过去。2D 场景更简单,因为物体的“前方”通常就是 transform 的 right 方向:
// 2D 版本:摇杆方向直接决定右方向 Vector3 dir = new Vector3(input.x, input.y, 0f); transform.right = dir; // 如果需要限制旋转速度,用下面这行替代上面那行 float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg; transform.rotation = Quaternion.RotateTowards( transform.rotation, Quaternion.Euler(0f, 0f, angle), rotateSpeed * Time.deltaTime);注意transform.right = dir这一步要求dir不能是零向量,同样是靠前面死区判断挡住。另外 2D 里如果美术资源本身朝上,那“前方”应该是 transform 的 up 而不是 right,这个取决于素材朝向,没有统一答案,看图调一次就记住了。
5. 常见问题与排查:跑偏、不回弹、多点触控失灵
5.1 手柄跑到圈外或者贴边后不回来
现象:手指从摇杆中心一直往外拖,手柄先是贴着底盘边缘,然后忽然跳出去一块,松手后也不回中间。
原因:两个地方可能翻车。一是ClampMagnitude的限值传的是1f,但手柄实际偏移量是normalized * radius,一旦normalized的长度超过 1,视觉上就出圈了;二是如果在OnDrag里用eventData.delta做累加,误差会越积越大,拖久之后整个坐标系都偏了。
解决:限值统一用handleRange,并且每帧都用“当前屏幕坐标减去底盘中心”重新算,绝不累加增量。手柄回弹的问题则是OnPointerUp里没有复位anchoredPosition,补上那行即可。如果用的是OnDisable隐藏摇杆,别忘了也做一次复位,否则下次显示出来手柄还在原位。
5.2 按在摇杆边缘,角色直接全速冲出去
现象:手指落在底盘最外侧准备起手,角色立刻往那个方向猛跑,松手才停。
原因:OnPointerDown里直接调用了一次OnDrag,而这一帧手指已经在边缘,算出来的归一化长度就是接近 1,于是第一帧就输出接近满幅的方向。如果没有死区判断,这个冲量会非常突兀。
解决:按下时先把InputDirection置零,然后才调OnDrag做手柄位置同步;同时确保死区判断在输出之前执行。这样按下的第一帧最多只是手柄跳到手指位置,输出仍然为零,要等手指真正开始移动、超过死区,角色才动。
5.3 右手按开火,左手摇杆失灵
现象:单手操作一切正常,两只手同时操作时,摇杆要么完全没反应,要么一松开开火键角色就自己跑一段。
原因:摇杆没有记录pointerId,EventSystem 会把多根手指的拖拽事件都分发给同一个IDragHandler,后按下的那根手指在OnPointerUp里把方向清掉,或者先按下的手指在拖动时被后按的干扰。另一种情况是开火按钮的 Image 设置了 Raycast Target 且层级在摇杆之上,手指穿过按钮区域时射线被按钮拦住。
解决:在OnPointerDown里记录activePointerId,后续所有回调里先判断eventData.pointerId是否等于它,不等就直接返回。按钮那边则是调整层级顺序,让按钮和摇杆处于互不重叠的区域,或者用Canvas Group的blocksRaycasts做动态开关。
5.4 换分辨率后摇杆灵敏度完全不同
现象:编辑器里 1920×1080 推一格刚好是半速,打包到 2340×1080 的机器上推同样的物理距离,速度明显不对。
原因:半径算错。用了sizeDelta而不是rect.size,或者直接拿屏幕像素当半径用。sizeDelta在锚点被拉伸时会变化,rect.size才是实际渲染尺寸。另外如果底盘图被做成非正方形(比如 260×200),除以radius.x和radius.y会得到椭圆约束,推右方向的行程比推上方向长,手感会变得很奇怪。
解决:半径一律用background.rect.size * 0.5f;如果底盘是椭圆形,取Mathf.Min(rect.width, rect.height) * 0.5f做统一半径,宁可牺牲一点视觉范围也要保证四个方向手感一致。Canvas Scaler 的 Match 值建议固定在 0.5 左右,让宽高按同一比例缩放。
5.5 摇杆区域下方的按钮点不到
现象:摇杆放在左下角,左下角原来有一个“背包”按钮,现在按下去毫无反应。
原因:底盘的 Image 尺寸远大于可见的圆形范围。一张 240×240 的半透明图,矩形覆盖面积是圆的四倍多,四个角的透明区域照样接收射线,把下面的按钮全挡住了。
解决:要么把按钮挪出摇杆矩形范围,要么在底盘下面加一个同样大小的空 Image 作为“射线接收层”,底盘本身关掉 Raycast Target,由接收层统一转发事件。更彻底的做法是用ICanvasRaycastFilter实现自定义命中判定,把圆形外的点击放行,但这属于进阶用法,普通项目挪按钮更快。
6. 进阶技巧与验证:动态摇杆、输入平滑和一次手动回归
动态摇杆指的是“手指按在屏幕左半区任意位置,摇杆就在哪里出现”,手游里很常见。它不需要预先摆好节点,而是在OnPointerDown时把底盘的anchoredPosition设成手指位置,再把底盘设为可见。改造点在事件层:底盘 Image 要铺满整个左半屏且颜色透明,OnPointerUp时隐藏。要注意的是ScreenPointToLocalPointInRectangle的参照物必须换成 Canvas 的 RectTransform,否则坐标原点会跟着底盘跑。
输入平滑是另一个容易被忽略的点。摇杆输出的是瞬时值,直接喂给角色移动在低速时会有轻微台阶感。我一般做两步处理:输出端用Vector2.Lerp对上一帧和当前帧做插值,系数取1 - Mathf.Exp(-k * Time.deltaTime),其中 k 取 15 到 25 之间;移动端则改用速度而非位置直接赋值,让物理引擎去处理惯性,手感会更接近商业手游。
PC 端调试时,我会加一个键盘映射脚本,在不接摇杆的情况下用 WASD 生成同样的InputDirection,方便在编辑器里快速验证移动逻辑。写法就是读Input.GetAxisRaw后归一化,长度限制在 1 以内,输出到和摇杆相同的接口上。注意这个脚本只放在调试分支里,正式包要裁掉。
验证环节我固定跑一份检查清单,每次改完摇杆参数都过一遍:
| 检查项 | 预期结果 |
|---|---|
| 手指从中心推到边缘 | 手柄平滑跟手,停在底盘内圈,不越界 |
| 手指推到底盘外再松开 | 手柄立即回到中心,InputDirection为 0 |
| 手指轻推(约 30% 幅度) | 角色缓慢移动,速度明显小于全推 |
| 两根手指同时操作摇杆和按钮 | 互不干扰,各自响应 |
| 切换分辨率(16:9 与 20:9) | 摇杆位置和灵敏度无肉眼可见差异 |
| 隐藏摇杆 UI 再显示 | 手柄在中心,没有残留方向 |
有几个参数没有标准值,必须在真机上反复试:deadZone我一般从 0.1 起调,如果角色在手不按的时候有轻微漂移,就加到 0.15;handleRange从 0.9 起调,如果手柄贴边时看起来像被切掉一块,就降到 0.85;rotateSpeed在第三人称里 540 到 900 度每秒比较常见,太低转身拖泥带水,太高转向像瞬移。
我最早做摇杆时吃过一次亏:手柄回弹逻辑写在OnPointerUp里,但测试同学在 UI 动画播放中快速切界面,OnPointerUp根本没触发,角色一直朝一个方向走,直到撞墙。从那以后我每次写输入脚本都会强制在OnDisable里做全量复位,不管事件有没有正常走完,状态都保证能回到初始值。希望帮到你。
本文还有配套的精品资源,点击获取