
简介面向有一定Unity操作基础的开发者系统讲解如何利用UGUI实现摇杆控制游戏物体移动内容由浅入深适合希望快速上手UGUI交互功能的读者也可作为课堂演示或自学参考。资源包共1575个文件以info、bin、asset、meta等Unity工程常见类型为主其中包含C#脚本、材质、图片、场景与配置信息压缩包整体大小为27.28MB以zip格式提供解压后即可导入Unity使用。教程按照清晰步骤完整演示了创建摇杆背景与滑块、编写Joystick脚本、挂载MoveScript并控制物体移动的全过程直观展示UGUI中RectTransform、Image等核心组件的实际用法源码逻辑清晰便于二次开发还能为后续加入触摸屏支持、滑块边界限制等扩展保留清晰思路尤其适合Unity初学者理解UI交互与物体控制的衔接。目前已有1872人学习下载是实践UGUI摇杆控制功能时值得参考的资源。 做移动端 Unity 项目摇杆几乎是绕不开的基础组件。网上各种摇杆教程不少但很多一上来就是几十个脚本或者用插件、做各种复杂封装新手照着敲完还是一头雾水。这次我把自己实际项目中沉淀下来的一套 UGUI 摇杆做法整理出来代码量少、逻辑直白核心就是响应拖拽事件加计算偏移方向。不管你是刚开始学 Unity 的小白还是想找一套轻量方案直接塞进现有项目的开发者这套做法都能用。先说清楚几个前提用 UGUI 而不是新 Input System 的 OnScreenStick思路是在 UI 层放一个可拖拽的摇杆组件通过拖拽事件把摇杆头的偏移量转换成方向向量再把这个向量喂给场景里的物体做移动。整个过程不依赖第三方插件Unity 2019 及以上版本都适用。1. 摇杆的底层逻辑拖拽事件与方向换算1.1 UGUI 事件系统到底怎么工作很多人第一次做摇杆就被两个概念搞混一个是 EventSystem一个是 UI 上的 GraphicRaycaster。EventSystem 是 UGUI 事件分发的中枢负责把鼠标触摸、点击这类输入转换成 UGUI 能识别的事件比如点击、拖拽、悬停。GraphicRaycaster 则挂在 Canvas 上负责判断射线打到了哪个 UI 元素把输入精确传递到具体的控件上。我做摇杆时第一步就是在场景里确认有没有 EventSystem。新建场景时 Unity 通常会自带一个但如果你是从某个模板或者已有场景切入可能被误删了。没有 EventSystem摇杆的 OnDrag 永远不触发这是新手最容易踩的坑报错都不一定会有就是没反应。1.2 摇杆的物理模型圆底、圆头、半径限制一个标准摇杆由两部分构成背景底座和摇杆头。底座是一个大圆 Image摇杆头是一个小圆 Image。交互逻辑是这样的手指或鼠标在底座范围内按下并拖动时摇杆头跟随手指移动手指松开时摇杆头回到底座中心摇杆头相对底座中心的偏移向量就是控制方向的信号。这里有一个关键参数最大半径。摇杆头不能无限跟着手指跑超出底座视觉边界就没意义了。所以我们要设定一个 radius一般取底座 Image 宽度的一半左右。每次拖动时将摇杆头的局部坐标限制在这个半径内超出部分截断半径内的位置作为有效输入。1.3 为什么方向要归一化偏移向量offset天然带着两个信息方向和大小。方向用于决定物体往哪走大小可以控制速度或者决定阈值。但直接把二维 offset 发给移动脚本会有问题如果你按的力度大向量更长物体就走得快而且斜着拖时 x 和 y 同时达到最大值对角方向的向量长度会超过单轴方向。如果不归一化会出现斜向移动比横向移动快大约 1.414 倍的情况这在操作手感上是不可接受的。所以标准做法是把 offset 除以最大半径得到一个分量在 -1 到 1 之间的向量再对向量做归一化输出一个纯方向的单位向量。如果需要模拟模拟摇杆的线性力度可以保留归一化前的长度值0 到 1 之间作为速度倍率。我一般会同时输出两个值方向向量 direction 和力度因子 magnitude这样移动脚本可以自由控制是匀速还是变速。2. UGUI 摇杆场景搭建从零开始布局2.1 场景准备与 UI 层级设计打开 Unity 新建一个 3D 场景添加一个 Plane 或者 Cube 作为被控制的物体。我不会把物体搞复杂用一个胶囊体Capsule很方便观察移动效果。然后创建 Canvas在 Hierarchy 里右键 - UI - CanvasCanvas 的 Render Mode 保持 Overlay 即可摇杆这类 UI 用 Overlay 最简单不需要关心世界坐标和相机距离。接下来是 UI 树结构CanvasJoystick空物体用来挂脚本BackgroundImage摇杆底座HandleImage摇杆头用右键创建 UI - Image 会自动附带 Image 组件。我习惯给 Background 设置一个半透明的圆形 Sprite 或者直接在 Image 的 Source Image 里选择内置的 Knob 图Unity 内置资源包里有大小设为 200 x 200。Handle 大小设 100 x 100作为子物体放在 Background 正中心。2.2 RectTransform 锚点与坐标系的坑很多新手在拖拽计算时直接用 transform.position忽略 RectTransform 的锚点设置结果摇杆放左下角和放中心点时表现不一致。本质原因是 transform.position 是屏幕空间的世界坐标而 UI 元素的局部坐标localPosition才是相对于父物体的位置受锚点影响。我的做法是将 Background 的锚点设为中中0.5, 0.5以此中心作为计算基准。Handle 的锚点同样设为中中且初始 localPosition 为 Vector3.zero。这样在脚本里读取偏移时结果就是干净的以中心为原点的局部坐标后续换算省掉一堆麻烦。2.3 添加事件系统的条件检查如果场景里没有 EventSystem手动创建右键 - UI - EventSystem。它需要一个 Standalone Input Module 组件UGUI 默认自动添加。同时确认 Canvas 上有 GraphicRaycaster 组件。这两者少了任何一个拖拽事件都不会派发。我还遇到过一种情况Canvas 上叠加了多个全屏 Image 挡住了摇杆导致事件被截胡。有些次级 Image 的 Raycast Target 默认是开启的它们在全屏铺满时会阻断底层 UI 接收事件。解决办法是在这些遮挡物上关闭 Raycast Target只保留视觉显示作用。检查问题时的经验是用 EventSystem.current.IsPointerOverGameObject() 打日志快速定位当前指针命中的对象。3. 核心脚本实现摇杆拖拽组件编写3.1 实现 IDragHandler 接口的完整代码新建一个脚本 Joystick.cs挂在 Joystick 空物体上。核心是继承 MonoBehaviour 并实现 IBeginDragHandler、IDragHandler、IEndDragHandler 三个接口。这里我给出完整可运行的版本注释会解释每个关键步骤的原因。using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class Joystick : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { [Header(UI 引用)] [SerializeField] private RectTransform backgroundRect; // 摇杆底座 [SerializeField] private RectTransform handleRect; // 摇杆头 [Header(参数设置)] [SerializeField] private float maxRadius 100f; // 摇杆最大偏移半径 [Header(输出数据)] public Vector2 direction; // 归一化后的方向向量范围为 -1 到 1 public float magnitude; // 拉动幅度0 到 1 public bool isDragging; // 是否正在拖拽 private Canvas _parentCanvas; private void Start() { handleRect.localPosition Vector2.zero; _parentCanvas GetComponentInParentCanvas(); } public void OnBeginDrag(PointerEventData eventData) { isDragging true; UpdateHandlePosition(eventData); } public void OnDrag(PointerEventData eventData) { UpdateHandlePosition(eventData); } public void OnEndDrag(PointerEventData eventData) { isDragging false; handleRect.localPosition Vector2.zero; direction Vector2.zero; magnitude 0f; } private void UpdateHandlePosition(PointerEventData eventData) { Vector2 localPoint; // 将屏幕坐标转换为 Background 局部坐标 RectTransformUtility.ScreenPointToLocalPointInRectangle( backgroundRect, eventData.position, _parentCanvas.worldCamera, out localPoint ); // 限制在最大半径内 Vector2 clamped Vector2.ClampMagnitude(localPoint, maxRadius); handleRect.localPosition clamped; // 计算方向和力度 direction (localPoint / maxRadius).normalized; magnitude clamped.magnitude / maxRadius; // 注意如果 localPoint 长度小于 0.01使用 Vector2.zero 避免 NaN if (localPoint.sqrMagnitude 0.01f) { direction Vector2.zero; magnitude 0f; } } }3.2 ScreenPointToLocalPointInRectangle 底层原理这个 API 是整段代码的核心值得展开讲。它的作用是把屏幕坐标比如鼠标点击位置、触摸位置转换到指定 RectTransform 的局部坐标系里。参数里的 worldCamera 很关键当 Canvas 的 Render Mode 是 Overlay 时worldCamera 传 null是 Screen Space - Camera 或 World Space 时必须传对应的 UI 相机。这个转换本质上是在做矩阵逆变换先把屏幕坐标转化为世界坐标再把世界坐标转化为 RectTransform 局部坐标。如果我们不用这个 API就得自己写一大段坐标换算逻辑而且很容易在屏幕适配、相机模式切换时翻车。所以强烈建议直接用引擎提供的工具方法省心也准确。我见过有人用 handleRect.position eventData.position 直接赋值屏幕坐标那在 Overlay 下确实能显示对但只要 Canvas 的缩放比例不是 1摇杆头位置就会偏离手指。原因正是没有经过 RectTransform 的局部坐标换算。记住一句话凡是 UI 间的相对位置计算优先用 ScreenPointToLocalPointInRectangle 配合 RectTransform 的 localPosition。3.3 移动物体脚本拿到方向后怎么用摇杆本身只负责产出方向真正移动物体是另一个脚本。我通常会单独写一个 PlayerMovement.cs负责把摇杆的方向应用到目标物体的位移上。这样职责分离摇杆和移动逻辑互不耦合以后想换成键盘输入或虚拟摇杆改一个脚本就行。using UnityEngine; public class PlayerMovement : MonoBehaviour { [Header(引用)] [SerializeField] private Joystick joystick; [Header(移动参数)] [SerializeField] private float moveSpeed 5f; [SerializeField] private bool useMagnitudeToControlSpeed true; private void Update() { if (joystick null || joystick.direction Vector2.zero) { return; } // 将二维方向映射到三维空间X 轴对应摇杆 XZ 轴对应摇杆 Y Vector3 move new Vector3(joystick.direction.x, 0f, joystick.direction.y); // 如果启用力度控制则乘以 magnitude否则恒定速度 float speed useMagnitudeToControlSpeed ? moveSpeed * joystick.magnitude : moveSpeed; transform.position move * speed * Time.deltaTime; } }投射到三维空间时我把摇杆的 y 方向映射到世界坐标的 z 方向。这样做的原因是相机通常在斜上方俯视地面水平面的移动需要 x 和 z 两个轴。如果你的场景是 2D 游戏、UI 平面和游戏平面一致就把映射改成 x 对应 x、y 对应 y 即可。这是 2D 项目和 3D 项目的一个核心差异点调试前先确认坐标系。3.4 动感视觉效果让摇杆头跟随且回弹很多教程到这里就结束了但实际手感还差一块摇杆头跟随手指移动时要保持视觉平滑松开后要回弹到中心。我在 OnEndDrag 里直接设置 localPosition 为 zero效果是瞬移回弹如果你想要更柔和的手感可以加一个简单插值。用 DOTween 的话一行代码就搞定但自己写也很简单private void Update() { if (!isDragging) { handleRect.localPosition Vector2.Lerp(handleRect.localPosition, Vector2.zero, Time.deltaTime * 10f); } }把这段加进 Joystick 脚本的 Update 里同时把 OnEndDrag 里的 handleRect.localPosition 清零注释掉摇杆头就会以平滑的方式回到中心。这个细节看起来不起眼实际在手机上用手感差别很大尤其是快速操作时回弹过猛会给人很生硬的感觉。4. 摇杆手感调试从能用变成好用4.1 最大半径与手指遮挡问题maxRadius 是最直接影响手感的参数。我拉过很多项目的摇杆默认 100 在 1920x1080 的横屏 2D 项目里偏大手指会盖住整个摇杆头但在 2400x1080 的竖屏项目里又偏小操作精度不够。一个比较稳的起步值是 Base Image 宽度像素的 40% 左右比如 200 的底座半径设 100 即可。手指遮挡是个容易被忽略的问题实时操作时手指始终在摇杆头上方视觉上底座被手挡住玩家很难精确判断摇杆当前位置。通用的产品方案是把摇杆头做得比手指接触面积更大一些或者把摇杆整体向上偏移让手指不直接遮住核心视觉区域。另外可以给摇杆头加一个明显的颜色变化反馈拖动时高亮也能提升操作直觉。4.2 静态摇杆与动态摇杆的取舍教程做的是静态摇杆底座固定在一个位置手指可以任意起始位置拖动。另一种做法是动态摇杆手指按下时底座才在按下位置生成这适合操作频率高、没有固定按键布局的移动端游戏。动态摇杆实现起来多几步OnBeginDrag 时把 backgroundRect.position 移动到 eventData.position然后 OnEndDrag 时把 backgroundRect 隐藏或移动到初始位置。代码改动量不大核心计算逻辑完全一致。我的建议是项目初期先做静态摇杆因为逻辑简单、调试方便等验证游戏玩法有效果后再考虑动态摇杆。动态摇杆对玩家的认知负荷更低但要做好可见/隐藏切换的动画处理不然会显得突兀。4.3 多点触控与 UI 冲突处理摇杆在手机上有时会出问题按摇杆的同时点击其他按钮两个事件会冲突。UGUI 默认是单点事件处理多指操作时需要额外注意。EventSystem 默认的 Standalone Input Module 支持多点触控但多个 PointerEventData 之间的优先级、按下和抬起配对会有一些边缘情况。我自己常用的方案是为摇杆设置一个独立的 TouchZone 概念在 OnBeginDrag 时记录当前的 pointerId后续 OnDrag、OnEndDrag 只用这个 pointerId 对应的事件数据。如果多指同时按下仅第一个落在摇杆区域的手指会被识别其余忽略。代码里可以增加一个 int dragPointerId -1 的字段来实现。public void OnBeginDrag(PointerEventData eventData) { if (dragPointerId ! -1) return; // 已有摇杆在使用忽略后续手指 dragPointerId eventData.pointerId; isDragging true; UpdateHandlePosition(eventData); } public void OnDrag(PointerEventData eventData) { if (eventData.pointerId ! dragPointerId) return; UpdateHandlePosition(eventData); } public void OnEndDrag(PointerEventData eventData) { if (eventData.pointerId ! dragPointerId) return; dragPointerId -1; // 其余重置逻辑 }这是一个从能用升级到好用的关键细节如果不加双指操作时摇杆头会在两个触点之间疯狂跳变。5. 常见问题速查实测踩坑记录5.1 摇杆拖不动或者无响应先确认三件事场景中有 EventSystem 吗Canvas 上有 GraphicRaycaster 吗摇杆的 Image 组件有没有勾选 Raycast Target这三个是 UGUI 事件响应的基础链路。另外注意脚本挂载位置脚本应该挂在包含 RectTransform 的 GameObject 上我习惯挂在一个独立的空物体上并把 Background 和 Handle 拖进 Inspector 的引用槽位。5.2 摇杆头漂移、恢复不了中心这个问题通常是 RectTransform.anchoredPosition 和 localPosition 混用导致的。如果你用了 anchor 到不同锚点localPosition 和 anchoredPosition 的数值可能不同恢复中心时应该用 Vector2.zero而不是直接赋 transform.position。还有一个可能代码里用了 Update 插值回弹但是 OnEndDrag 之前某个地方对 handleRect.localPosition 做了额外修改导致方向向量永远不是零。建议在 OnEndDrag 里明确重置 direction 和 magnitude而不只是视觉位置。5.3 物体斜向移动速度异常斜向速度异常的根因就是没有归一化方向向量。我前面说过的 1.414 倍问题解决方案就是 direction (localPoint / maxRadius).normalized 这一行。注意有一个隐藏 Bug如果 localPoint 本身就是零向量normalized 会得到 NaN。我在代码里加了 sqrMagnitude 判断来兜底这个保护在手指按下瞬间或者极小的移动偏移时能救命。5.4 Canvas 世界空间模式下方向错乱当 Canvas 的 Render Mode 从 Overlay 切换到 World Space 时worldCamera 参数差异会直接影响坐标换算。如果出现摇杆在一个角度能控制物体换一个角度就失灵大概率是 worldCamera 传错或者没传。Screen Space - Camera 模式下一定要在 Canvas 的 Render Camera 赋值 UI 相机并把相机的 Clear Flags 设为 Depth Only。下面这个表可以快速对照排查现象可能原因解决方案摇杆无响应缺少 EventSystem 或 GraphicRaycaster创建 EventSystem确认 Canvas 挂载 GraphicRaycaster摇杆头偏移不准锚点未居中将 Background 和 Handle 锚点均设为中心斜向移动过快方向未归一化对方向向量做 normalizedUI 挡住摇杆遮挡物 Raycast Target 开启给遮挡 Image 取消 Raycast Target相机模式切换后失灵worldCamera 传参错误检查 Canvas 的 Render Mode 与代码中 worldCamera 参数摇杆头回弹生硬直接清零 localPosition改用 Lerp 插值回弹5.5 连 点 拖 拽 出现卡顿有些玩家会很快地连点摇杆每帧触发多次 BeginDrag 和 EndDrag如果 OnEndDrag 里用了大量计算或者启用了 DOTween 却没有 kill可能出现动画累积、表现卡顿。我的经验是在 OnBeginDrag 里先判断状态避免同一摇杆重复初始化如果在回弹动画过程中被再次拖拽先取消旧动画再启动新动画。这个细节能避免很多移动端硬件上概率性出现的表现 Bug。6. 扩展思路把一个摇杆变成一套输入方案6.1 摇杆和键盘输入的无缝切换既然摇杆组件只输出 direction 和 magnitude那我可以很轻松地做一个 InputManager根据当前平台选择输入源。PC 上测试时用键盘 WASD移动端用摇杆WebGL 上也可以保留摇杆但顺便支持键盘。因为这个设计已经解耦切换逻辑只在一个地方判断不会污染移动代码。一个简单示例private Vector2 GetInput() { Vector2 keyboardInput new Vector2(Input.GetAxisRaw(Horizontal), Input.GetAxisRaw(Vertical)); if (keyboardInput ! Vector2.zero) return keyboardInput.normalized; return joystick.direction; }这个技巧在开发阶段非常实用你可以不用连手机直接在 Editor 里用键盘测试物体移动逻辑等 UI 摇杆调整好后再切换成摇杆输入。6.2 摇杆输出力度和 UI 视觉反馈联动magnitude 除了控制速度还可以驱动视觉反馈。比如角色移动时播放走路动画跑起来时播放奔跑动画就是看 magnitude 是否超过 0.5。摇杆头的颜色也随拉力变化拉满时变亮松手时恢复原色。这类反馈看着是小事实际能让操作者非常直观地感知当前输入状态降低误操作率。6.3 用摇杆控制旋转而非位移有些场景需要摇杆控制角色旋转比如坦克或者第一人称视角。实现上只需要把方向向量映射到旋转角float targetAngle Mathf.Atan2(joystick.direction.x, joystick.direction.y) * Mathf.Rad2Deg; Quaternion targetRotation Quaternion.Euler(0f, targetAngle, 0f); transform.rotation Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 10f);Atan2 返回的是弧度需要转换成欧拉角。注意这里 x 和 y 传参的顺序决定旋转的轴向2D 和 3D 项目的旋转差异经常出现在这里。写这套摇杆逻辑是我项目里从零手搓 UI 组件时积累的经验本质上是把复杂问题拆成了三层UI 层视觉和拖拽、坐标换算层把屏幕输入变成可用方向、游戏逻辑层消费方向做移动或旋转。把这个分层的思路记住你就能在 Unity 里快速实现各种自定义输入方案而不是局限于某个插件。后面我还在探索一个重要方向同一套 UI 摇杆如何在 Split Screen 双人模式下互不干扰目前的方案是给每个 Player 分配独立的 Canvas 和 EventSystem实测在 iPad 上也能稳定支撑双摇杆操作。有兴趣的话这部分细节我们下次接着聊。本文还有配套的精品资源点击获取