简介:这套Unity3D教程资源面向刚接触UGUI的开发者,讲解如何用UGUI快速制作摇杆,并驱动游戏物体移动。内容从Canvas、Image组件讲起,覆盖创建摇杆背景、滑块、编写Joystick.cs脚本到挂载控制的完整流程,适合需要为角色移动、摄像机操控等场景加入摇杆交互的入门与中级学习者。压缩包共1575个文件,大小27.28MB,包含Unity工程运行所需的asset场景资源、meta配置、dll及mdb脚本库文件,以及bin、info等编译与索引文件,解压后可配合教程对照使用,便于直接查看工程结构。目前已有1874人学习此资源。作为完整的UGUI摇杆工程包,读者可从中获得工程目录组织方式、UI层级搭建思路、输入向量计算逻辑以及物体移动控制的脚本写法,对理解UGUI事件交互和游戏输入处理都有参考价值。
1. UGUI 摇杆看似简单,但十个人里有六七个第一次都会在这里翻车
移动端游戏里,虚拟摇杆是存在感最高的操作方式之一。很多人看到“UGUI 超级简单的摇杆制作”这个说法,就以为拖两个 Image、挂一段复制来的代码就能跑起来。真上手会发现:OnDrag 根本没调、控制柄一拉就滑出背景、换了个分辨率坐标就漂。这些东西不是摇杆复杂,而是 UGUI 事件坐标转换的几个细节没打通。这篇会从 UI 结构搭起,讲到物体移动逻辑,再列一串排查记录,按“现象→原因→解决”的格式写,新手能一次跑通,老手也能对边界心里有数。
2. 先搭对摇杆的 UI 和环境:Canvas 配置与两个不能少的组件
2.1 摇杆的层级结构:为什么在 Canvas 和背景之间多夹一层面板
先动手搭层级。常见做法是让摇杆按这样的父子关系组织:
Canvas → JoystickPanel(空物体) → Background(Image) → Handle(Image)
JoystickPanel 的锚点设为屏幕中心,Background 和 Handle 作为它的子物体。很多教程不会专门解释这一层,但这一层直接决定了坐标计算准不准。原因在于,RectTransformUtility.ScreenPointToLocalPointInRectangle 需要一个参考系,如果直接拿 Canvas 当参考系,Background 的 anchoredPosition 会混入 Canvas 缩放和锚点距离,计算偏移时要额外做差值,很容易绕晕。多夹一层后,摇杆逻辑只需要关心背景相对于面板的坐标,背景初始位置也稳定,后面做动态摇杆时改造成本也低。
从操作上说,顺序是先创建 JoystickPanel,再在它下面创建两个 Image。这里有个新手容易迷糊的点:创建 Image 时 Unity 默认会把它放在当前选中的物体下,所以要先在 Hierarchy 里选中 JoystickPanel,再右键 → UI → Image,这样 Canvas 和层级结构才会按预期生成。后面控制柄的尺寸、颜色、图片都可以在 Inspector 里调,但层级结构如果搭错,脚本写对了也救不回来,坐标参考系会错。
2.2 Canvas 渲染模式:三种模式对摇杆坐标转换的影响
UGUI 的 Canvas 有三种渲染模式。做手游摇杆,最常用的是 Screen Space - Overlay,整个 UI 直接绘制在屏幕上,屏幕像素坐标和 UI 坐标一致,坐标转换最简单,传相机参数的位置直接传 null 就行。如果你做的是 HUD、主界面这类不跟随 3D 镜头的 UI,Overlay 完全够用。
当 Canvas 需要跟随某个相机或者做透视,会选 Screen Space - Camera,此时 Canvas 上要指定 Render Camera,而代码中的坐标转换必须传入 eventData.pressEventCamera,否则转换结果会偏得很明显。很多旧教程只在 Overlay 下验证过,你换成 Camera 模式后按原样复制,就会出现拖拽偏移。World Space 则把 UI 放进 3D 场景里,摇杆变成场景里的一块画板,适合 VR 或特殊交互,但不推荐从它入门。
我一般建议新手先用 Overlay 跑通,跑通后再去理解 Camera 模式的差异。因为核心的 RectTransformUtility 函数在这三种模式下都工作,只是最后一个 camera 参数不同,逻辑本身不用改。真正容易坑人的是,你从网上找到的代码如果写死了传 null,在 Camera 模式下就必然漂移;反过来,你传了 eventCamera 在 Overlay 模式下虽然多为 null,但 Unity 也能接受,所以代码里统一用 eventData.pressEventCamera 是更保险的写法。
2.3 响应事件的前提:GraphicRaycaster 和 EventSystem
摇杆要响应触摸事件,需要一条完整的事件链路。场景里必须有 EventSystem,它负责轮询输入并派发事件;每个承载 UI 的 Canvas 上必须有 GraphicRaycaster,它负责把触摸点投射到 UI 图形上。新建场景通过菜单创建 UI 时,Unity 默认会带上 EventSystem,但如果场景初期是空的,只加了摇杆 UI,很容易把 EventSystem 漏掉。
漏掉之后的现象非常误导:Button 不响应、摇杆不响应,但场景运行不报错,很多人会往脚本方向排查很久。这是个典型的血泪经验:先检查组件,再怀疑代码。GraphicRaycaster 同理,没有它 Raycast 结果为空,事件不会落到任何 UI 元素上。可以把摇杆脚本挂到 Background 上,因为手指按下时,接触目标很可能是背景而非控制柄。如果只挂在 Handle 上,手指按到控制柄周围但没按到控制柄时,摇杆就没有反馈,体验会断一截。
还有一点:如果 Canvas 下有多个 Image 叠在一起,比如背景里放了一张大图做装饰,事件焦点可能会被装饰图吸走。解决方法是给不需要响应事件的 Image 不挂任何事件接口,或者把 background 的 raycastTarget 关掉,但实际中如果需要背景响应,就保持它开启。这里没有绝对统一,取决于你的事件脚本挂在哪个物体上。
2.4 搭 UI 时把锚点和半径一次设好
实际操作时,我一般用如下方式初始化摇杆 UI。首先在 Canvas 下创建空物体 JoystickPanel,RectTransform 锚点设为全居中,尺寸随意,后面会由子物体撑开。然后创建 Background 子物体,使用 Image,调整尺寸为 200×200。Image 的 Source Image 可以用 Unity 内置的圆形贴图,找不到内置图时用一张纯色图也能跑,视觉上后续再替换。再创建 Handle 子物体,尺寸 80×80,锚点和枢轴都设为居中。
运行前把 Joystick 脚本挂到 Background 上,并拖好两个 RectTransform 引用。这套参数里最关键的是 joystickRadius,它应该等于 Background 视觉上可拖拽半径。如果直接用背景宽的一半,那么控制柄中心可以到圆形边缘,控制柄本身有半径,视觉上就会“溢出去”;如果你不想让控制柄完全到边缘,要留出控制柄半径,就取背景宽的一半减去 Handle 宽的一半,比如背景 200、Handle 80,半径取 60。很多人忽略这一点,做出来控制柄总有一种露出半截的粗糙感,这就是摇杆“看着不对劲”的直接原因。
另外还会涉及到 CanvasScaler。新建 Canvas 默认带 CanvasScaler,推荐把 UI Scale Mode 设为 Scale With Screen Size,参考分辨率按目标机型设置,比如 1080×1920。如果不设置,摇杆在不同屏幕上的物理尺寸会完全不同,在某台手机上正好,换一台就偏大或偏小。但要注意,CanvasScaler 只改变 UI 缩放,不改变触摸坐标转换,只要用了 RectTransformUtility,它自己会处理缩放,不需要手动乘系数。
2.5 用一个临时 Button 验证 UI 事件链路
在写摇杆逻辑前,先做一个 1 分钟健康检查:在 Canvas 下创建一个 Button,拖到角落,运行后点击,看有没有按下状态变化。如果按钮都点不动,那摇杆逻辑写得再对也没用。这个验证法能帮你把“UI 系统问题”和“摇杆代码问题”快速切分开。在很多次排查中我发现,所谓的摇杆触摸没反应,八成是 EventSystem 或 GraphicRaycaster 缺失,而不是代码问题。按钮验证通过后,再把临时按钮删掉,或者禁用,不影响摇杆。
这个临时 Button 还有一层作用:它可以帮你确认当前 Canvas 模式是否正常。如果你切成了 Screen Space - Camera 但没指定 Render Camera,点击按钮很可能没有高亮反馈,UI 虽然能看到但事件系统已经失效。这一步排查全部通过,再进入下一章的代码实现。
3. 写摇杆逻辑:拖拽跟随、边界钳制和归一化输出
3.1 事件接口的选择:为什么用 IPointerDownHandler 而不用自己监听鼠标
实现摇杆的核心是三个 UGUI 事件接口:IPointerDownHandler、IDragHandler、IPointerUpHandler。它们分别对应手指按下、按下后移动、抬起。为什么用这套接口而不是自己在 Update 里读取 Input.GetMouseButton?因为这套接口由 EventSystem 驱动,天然处理了多点触控、点击归属等细节。你的脚本只需要实现对应方法,比如 public void OnDrag(PointerEventData eventData)。
这里有个新手最容易无解的问题:如果类声明里漏掉了接口,或者方法参数类型写错,编译器不会报错,但运行时不回调。比如把 OnDrag 写成了 OnDragging,Unity 也不会主动告诉你。所以建议在脚本开头先写完整的接口声明,再写方法实现,写完检查编译器有没有提示“实现接口”相关的报错。事件接口的方法签名都是固定的,包括 OnPointerDown、OnDrag、OnPointerUp,参数类型都是 PointerEventData,一个字母都不能差。
3.2 核心代码:用 RectTransformUtility 把屏幕点转成 UI 局部坐标
直接给一份可以跑通的 Joystick 脚本:
using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; public class Joystick : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler { [Header("摇杆背景和控制柄")] [SerializeField] private RectTransform backgroundRect; // 背景 Image 的 RectTransform [SerializeField] private RectTransform handleRect; // 控制柄 Image 的 RectTransform [Header("摇杆可拖动半径,单位与 UI 坐标一致")] [SerializeField] private float joystickRadius = 100f; [Header("死区,输出小于这个值当作零")] [SerializeField] private float deadZone = 0.1f; // 对外暴露的输入向量,范围 -1 ~ 1 public Vector2 InputVector { get; private set; } // 背景在父物体参考系中的初始坐标 private Vector2 backgroundStartPos; private void Start() { // 背景初始位置要记录一次,因为后面所有偏移都相对它计算 backgroundStartPos = backgroundRect.anchoredPosition; } public void OnPointerDown(PointerEventData eventData) { // 按下时先做一次拖拽,让控制柄能快速跳到手指位置 OnDrag(eventData); } public void OnDrag(PointerEventData eventData) { // 把屏幕坐标转换成 UI 局部坐标,参考系是背景的父物体 RectTransform parentRect = backgroundRect.parent as RectTransform; bool success = RectTransformUtility.ScreenPointToLocalPointInRectangle( parentRect, eventData.position, eventData.pressEventCamera, out Vector2 localPoint); if (!success) return; // 相对背景初始位置的偏移 Vector2 offset = localPoint - backgroundStartPos; // 钳制偏移长度,让控制柄不超出半径范围 offset = Vector2.ClampMagnitude(offset, joystickRadius); // 应用控制柄位置 handleRect.anchoredPosition = offset; // 归一化到 -1 ~ 1,作为外部使用的输入值 Vector2 rawInput = offset / joystickRadius; // 死区处理,避免手指轻微抖动时继续输出小数值 InputVector = rawInput.magnitude < deadZone ? Vector2.zero : rawInput; } public void OnPointerUp(PointerEventData eventData) { // 松手后控制柄回到中心,输入归零 handleRect.anchoredPosition = Vector2.zero; InputVector = Vector2.zero; } }这段代码的核心在 OnDrag 里。RectTransformUtility.ScreenPointToLocalPointInRectangle 把屏幕上的触点坐标转换到指定 UI 物体的局部坐标系中,第一个参数必须传背景的父物体,因为 backgroundStartPos 也是用父物体坐标系记录的。两者相减,得到手指相对背景中心的偏移。接着用 Vector2.ClampMagnitude 把偏移长度限制在 joystickRadius 内,这样控制柄就不会跑出背景范围。最后用 offset 除以半径,得到一个范围在 -1 到 1 之间的方向向量。
参数 joystickRadius 可以在 Inspector 里改,我用 100 是因为背景 Image 宽度是 200,正好是视觉边界。如果你按上一章说的,背景宽 200 且控制柄宽 80,还想让控制柄边缘不超出去,把 joystickRadius 改成 60 会更美观。deadZone 设成 0.1 是常规手感,太小会抖动,太大会觉得方向响应迟钝。
3.3 坐标转换的关键细节:参考系必须统一
新手最容易翻车的地方就在 RectTransformUtility 第一个参数。很多人会传 backgroundRect 本身,然后发现控制柄要么只动一半,要么飞出去。原因是 backgroundStartPos 记录的是背景相对于父物体的坐标,而你把触点转换到了背景自身的局部坐标系里,两个坐标系的零点不一致,算出来的 offset 就带着一个固定误差。
我现在的习惯是,代码里写清楚一行注释:参考系必须是 backgroundRect.parent。如果你把 Background 拖到 JoystickPanel 下,parent 就是 JoystickPanel;如果你把 Background 直接放 Canvas 下,parent 就是 Canvas。代码写死用 parent,层级怎么变都不会错。
另一个细节是 eventData.pressEventCamera。在 Overlay 模式下,这个字段是 null,传进去没问题;在 Screen Space - Camera 模式下,它会有值。所以最稳的写法就是直接把它传给转换函数,不要自己判断 Canvas 模式,也不要手动去取 Camera.main。如果自己用 Camera.main 去转换,在 Overlay 模式下完全错乱,这也是很多人换 Canvas 模式后摇杆就失灵的原因。
3.4 死区与半径:摇杆手感的核心调参
死区这个概念在摇杆里非常重要。手指按下去并不可能绝对静止,总会有一点微小的抖动,如果没有死区,角色会一直朝某个方向慢慢蠕动,看起来像失控。死区不是越大越好,我见过有人把死区调到 0.5,结果控制柄已经推出去一截,角色还没动,操作体感非常糊。0.1 左右是一个比较通用的起点,具体还要看你的游戏类型,狙击瞄准类可以适当调大,动作格斗类尽量小。
半径的长度决定了摇杆的操控精细度。半径越大,手指需要移动更多距离才能达到最大输出,适合需要精确控制速度和方向的场景,比如赛车;半径越小,反应越快,适合追求快速转向的动作游戏。不要只改代码里的值,还要同步看 Background 的尺寸,两者要保持一个合理的比例。比较稳妥的做法是把 joystickRadius 设为 0.45 倍的背景宽度,也就是略小于半径,既保证控制柄不会“溢出”视觉范围,又留出边缘缓冲。手感这种东西没有绝对标准,最好在真机上跑一下,用不同的值反复试,这比在网上找参数靠谱得多。
4. 把摇杆输入变成物体移动:Transform 与刚体两套方案的取舍
4.1 用 Transform.Translate 做原型移动:最小代码但要注意局限
摇杆已经能输出归一化向量,接下来就是让物体动起来。最简单的方式是用 Transform 直接移动:
public class PlayerMover : MonoBehaviour { [Header("摇杆引用")] [SerializeField] private Joystick joystick; [Header("移动速度,单位:米/秒")] [SerializeField] private float moveSpeed = 5f; private void Update() { Vector2 dir = joystick.InputVector; transform.Translate(dir * moveSpeed * Time.deltaTime, Space.World); } }这里 dir 是归一化的输入向量,乘以速度得到每秒的位移,再乘以 Time.deltaTime 把帧率的影响消掉。Space.World 是关键,它让位移始终在世界坐标系方向上进行。如果你不传 Space.World,默认是本地坐标系,物体一旦经过旋转,摇杆方向就会跟着旋转偏移,表现就是“向左推却往斜上方走”。
这套写法很适合做原型验证、UI 演示以及不需要物理碰撞的 2D 角色。局限也很明显:Transform.Translate 直接改坐标,不会触发刚体碰撞检测,物体会穿墙,也不会和其他物体产生物理交互。如果你的场景里有墙壁、平台、推动物,就需要下面的方案。
4.2 用 Rigidbody2D 做平滑移动:正式项目更稳定
正式项目里,2D 角色移动我一般用 Rigidbody2D。它能让物体参与物理碰撞,而且移动更平滑,不会出现逐帧位移的锯齿感。
public class PlayerMover2D : MonoBehaviour { [Header("摇杆引用")] [SerializeField] private Joystick joystick; [Header("刚体组件")] [SerializeField] private Rigidbody2D rb; [Header("移动速度,单位:米/秒")] [SerializeField] private float moveSpeed = 8f; private Vector2 cachedDir; private void Update() { // 在 Update 里读取输入,因为 UI 事件在 Update 阶段派发 cachedDir = joystick.InputVector; } private void FixedUpdate() { // 在物理更新阶段设置速度,和 FixedUpdate 的节奏保持一致 rb.velocity = cachedDir * moveSpeed; } }这里我特意分成两个方法:Update 里读输入,FixedUpdate 里应用速度。原因在于 UGUI 事件是在 Update 周期内触发的,而物理更新是固定时间步长,两者节奏不同。如果直接在 Update 里每帧设置 rb.velocity,可能会在一帧物理更新之间被设置多次,取到的输入不是最新值,物体移动起来会有微小的延迟感。用 cachedDir 缓存一下,FixedUpdate 里取的就是最近一帧的输入,视觉上滞后几乎不可感知。
Rigidbody2D 的 Interpolate 参数建议设成 Interpolate。当物理更新频率低于渲染帧率时,这个选项会让刚体的渲染位置在两次物理更新之间插值,移动更顺滑。如果物体移动仍然抖动,优先检查这个参数,而不是去改代码逻辑。Collision Detection 建议用 Continuous,可以避免高速移动时穿过薄墙。
4.3 速度、插值和对角线速度修正
移动速度没有统一标准,取决于你做的游戏类型和角色尺寸。一个像素风横版游戏可能 3 到 5 就够,一个竖屏弹幕游戏可能要 10 以上。我通常先把速度设成角色高度每秒的 5 倍左右,然后真机上靠手感调。这里的核心原则是,速度不要让角色在 0.1 秒内就从屏幕一边冲到另一边,那会丧失操作精度。
如果你想要一种“惯性感”,可以对输入向量做指数平滑。常见做法是用 Vector2.Lerp 把当前方向逐步插值到目标方向:
private Vector2 currentDir; private void Update() { currentDir = Vector2.Lerp(currentDir, joystick.InputVector, Time.deltaTime * 10f); }插值系数 10f 可以理解成“跟随速度”。系数越大,角色越快跟上摇杆方向,操作越直接;系数越小,角色越容易“甩尾”。这个参数也值得多试,并不是越大越好,尤其是赛车类游戏,惯性本身就是乐趣的一部分。
还有一个很容易被忽略的对角线问题。如果你以后从键盘输入转摇杆,比如 Input.GetAxisRaw("Horizontal") 和 Input.GetAxisRaw("Vertical"),斜向移动时向量的模是根号 2,如果不做归一化,斜向速度会比直线快 1.41 倍。不过你用的是这个摇杆的 InputVector,它已经在 OnDrag 里做了钳制,斜向最大长度也是 1,所以不会出现这个问题。如果哪天你换了一处输入源,一定要记得先归一化再乘速度。
4.4 一个典型错误:直接把控制柄坐标当移动方向
很多人在做完摇杆 UI 后,会顺手写出这样的代码:transform.Translate(handleRect.localPosition * speed)。这个写法表面上能跑,但问题很大。控制柄的 localPosition 是像素偏移,单位是 UI 坐标的像素,和游戏世界里的单位根本不是一回事。同一个摇杆,在不同分辨率下输出的数值范围不一样,角色速度就会跟着屏幕尺寸变化;而且控制柄坐标没有归一化,推到底时输出的是半径值而不是 1,移动速度会比摇杆拉到一半时大很多,体感极不线性。
正确的做法永远是使用 Joystick 脚本对外暴露的 InputVector,它已经归一化到 -1 到 1,并且经过了死区处理。控制柄的 UI 坐标只用于显示,不参与业务计算。这也是为什么我在 Joystick 脚本里把 InputVector 封装成属性而不是让外部直接访问 handleRect。你如果自己写摇杆,记得保持这个思路:UI 显示和逻辑输入分离。
5. 摇杆避坑记录:触摸没反应、控制柄跑飞、移动抖动怎么排查
5.1 现象:OnDrag 完全不触发,摇杆像块静止的图片
原因基本出在事件链路上。最常见的是场景里没有 EventSystem,或者 Canvas 上没有 GraphicRaycaster。另一个容易忽略的是,脚本虽然写了接口方法,但类声明里没有继承对应的接口,导致方法根本没被注册。查的时候先看 Hierarchy 里有没有 EventSystem,再看 Canvas 组件列表里有没有 GraphicRaycaster,最后看脚本的类声明是否完整。如果这三个都没问题,就在 OnPointerDown 里打一条 Debug.Log,确认按下时有没有进入方法。日志没打出来,说明事件压根没派发到这个物体上,沿着链路继续往上查;日志打出来了但 OnDrag 没动静,那就是接口或方法签名的问题。
5.2 现象:控制柄被拖出背景圆,或者控制柄移动范围比背景大
原因基本都是 joystickRadius 设置过大,或者背景 Image 的 RectTransform 尺寸和你以为的不一致。很多人直接在 Inspector 里把背景宽设为 200,但运行时 CanvasScaler 会做缩放,实际占用的屏幕空间不一定是 200,坐标转换时用的是 UI 缩放后的坐标,代码里的 100 可能对应的是另一段距离。解决方法是打印 backgroundRect.rect.width / 2 看一下实际半径,然后以这个值为基准调整。如果要考虑控制柄本身不露出边缘,则再减去 handleRect.rect.width / 2。这个减法很多教程不提,实际做出来好不好看,关键就在这一下。
5.3 现象:在编辑器里正常,打包到手机上坐标漂移,手指按的位置和控制柄不对应
这种问题多半出在 Canvas 渲染模式或 CanvasScaler 配置上。如果 Canvas 是 Screen Space - Camera,而你的代码里用 null 代替了 eventData.pressEventCamera,坐标就会偏。解决方法是把转换函数的 camera 参数统一改成 eventData.pressEventCamera。另外,如果 CanvasScaler 的匹配模式没有按目标平台设置,屏幕宽高比差距较大时 UI 缩放会失真,触摸点跟随也会出现可以感知的偏差。设置 Scale With Screen Size 后,再跑一遍真机测试。
5.4 现象:物体移动有卡顿,或者一卡一卡地往前跳
原因通常是把物理移动写在了 Update 里,或者用了 Transform 位移但同时存在 Rigidbody2D。Rigidbody2D 的物理更新有自己的步长,如果你在 Update 里直接改 rb.velocity,物理插值可能跟不上渲染帧率,产生抖动。解决方法是把读输入放在 Update,把设置速度放在 FixedUpdate,并给 Rigidbody2D 打开 Interpolate。如果你做的是纯 Transform 移动,那么所有移动逻辑都放在 Update,不要混用 FixedUpdate 里的 Time.deltaTime,否则帧率低的机器上移动速度会变慢。
5.5 现象:手指已经松开,角色还在朝刚才的方向移动一小段
原因比较多。第一,OnPointerUp 没有执行,可能是手指滑出了 UI 响应区域后再抬起,EventSystem 在某些版本里会丢失这次的抬起事件。第二,OnPointerUp 执行了,但 InputVector 没有归零,比如你在外部又对这个值做了缓存和下一条赋值。第三,你用了刚体,但速度在 FixedUpdate 里被设置后,松开时刚好错过了一个物理更新,所以会多走一帧。
解决方法是三层保险:OnPointerUp 里把 handleRect 位置和 InputVector 都归零;在 PlayerMover 的 OnDisable 里也归零一次,防止切换场景或禁用对象时遗留速度;如果移动端仍然有偶发残留,可以在 Update 里判断 joystick.InputVector 的 magnitude 小于某个极小值时,主动缓存为零向量,保证下一次 FixedUpdate 用的是零。这种做法看似重复,但真机上的触摸事件并不可靠,多一层兜底总比线上角色漂移强。
6. 进阶技巧:动态摇杆与双摇杆扩展
6.1 动态摇杆:按下时生成摇杆,抬起后自动隐藏
很多动作游戏不想要固定摇杆,而是希望手指按到哪里,摇杆就在哪里出现。实现思路并不复杂:把 JoystickPanel 默认隐藏,在 Canvas 上放一个全屏透明的 Image 作为触摸接收层,监听 OnPointerDown。收到按下事件后,把 JoystickPanel 移动到手指位置,再显示出来,然后走正常的 OnDrag 逻辑。关键代码是把屏幕点转成 JoystickPanel 的父物体局部坐标,再赋值给面板的 anchoredPosition。
这里要注意一个顺序问题:必须先移动面板,再记录 backgroundStartPos,因为面板移动后,背景在父物体坐标系里的坐标也变了。如果你沿用固定摇杆的逻辑,在 Start 里记录一次 backgroundStartPos,动态摇杆就会错位。我一般会在 ShowAt 方法里重新记录 backgroundStartPos,并把当前触摸的 pointerId 保存下来。第一次做动态摇杆时,我漏了重设 backgroundStartPos,导致第一次按的位置是准的,第二次摇杆就偏到一边,找了大半天才想起是这个原因。
6.2 双摇杆:必须做手指 ID 分配,否则两个摇杆抢事件
双摇杆是动作手游的标配,左摇杆移动,右摇杆控制视角或攻击方向。实现方式很简单,场景里放两个 Joystick,PlayerMover 里引用两个实例,分别绑定移动和转向逻辑。真正的问题是多点触控时的事件归属:玩家一根手指按在左摇杆,另一根手指按下右摇杆,如果两个 Joystick 都接收所有事件,会出现方向冲突和跳动。
解决的思路是每个摇杆只认一个手指 ID。在 OnPointerDown 时记录 eventData.pointerId,OnDrag 和 OnPointerUp 开头判断当前事件的 pointerId 是否等于已记录的 ID,不相等就直接 return。这样左摇杆被第一个手指占用,右摇杆就不会被同一个手指干扰。这个逻辑对固定摇杆和动态摇杆都需要,而且要在 OnPointerUp 里把记录重置为 -1。我上次做双摇杆时就漏了重置,第二次按下时摇杆完全没反应,回看代码发现 currentPointerId 一直停留在上一次的手指 ID 上。从此我把“手指 ID 管理”写进了摇杆脚本的最低要求列表里。动态摇杆、双摇杆、多 UI 手势同时存在的场景,这个机制是基础中的基础,希望帮到你。
本文还有配套的精品资源,点击获取