简介:围绕 Unity UGUI 制作可控制物体移动的摇杆,这份工程包面向已有一定 Unity 基础、希望快速掌握 UI 交互与角色移动输入的开发者。内容对应完整项目工程,包含摇杆背景与滑块的 UI 搭建、Joystick 脚本处理输入方向、MoveScript 接收向量并驱动带 Rigidbody 的物体等模块,适合用来拆解 RectTransform、Image 与 C# 脚本的配合方式,也可作为移动端虚拟摇杆原型参考。压缩包共 1575 个文件,约 27.28MB,以 info、bin、asset、meta 及 dll 等 Unity 工程缓存、资源与依赖文件为主,另含少量 png、xml、txt 等素材和说明文件,目录保留了较完整的工程结构,便于在编辑器中打开对照学习。已有 1874 人学习,适合按摇杆搭建、脚本绑定到物体控制三个环节逐步复现,并在此基础上扩展滑块范围限制与触摸屏适配。
1. UGUI 摇杆真正的难点在坐标换算
做 Unity3D 摇杆,代码量其实很小,真正容易出错的地方在坐标换算。很多人第一次上手:拖两个 Image、挂个脚本、写三行 OnDrag,运行起来手柄满屏乱飞,回头怀疑 UGUI 有 bug。其实就一句话没弄明白:PointerEventData.position是屏幕像素坐标,而anchoredPosition是父节点局部坐标,中间必须过一道RectTransformUtility,否则一切都会偏移。
这篇拆的是一个零插件、纯 UGUI、单脚本的摇杆做法:背景圈 + 手柄两个 Image,实现按下、拖拽、抬起、圆形限位、死区重映射、归一化方向输出,再接上物体移动。刚接触 UGUI 事件系统的人能照抄跑通,做过几版的人可以对着限位和缩放适配那几节,把手感调到自己满意。
2. 结构先搭对:三个 RectTransform 和它们的锚点
2.1 最小层级:根节点、背景圈、手柄
别图省事把脚本直接挂在背景圈上。背景圈要负责接收射线,手柄要在它上面移动(手柄是它的子节点),如果背景圈的锚点被改成拉伸,手柄的anchoredPosition零点就不在圆心了,后面所有偏移量都得重算。稳一点的做法是三层:
| 节点 | 组件 | 锚点 / 轴心 | 尺寸 | Raycast Target |
|---|---|---|---|---|
| JoystickRoot | RectTransform、Image(半透明圆)、摇杆脚本 | 居中,(0.5, 0.5) | 240 × 240 | 勾选 |
| Background | Image(圆环贴图) | 居中,撑满父节点 | 跟随父节点 | 不勾 |
| Handle | Image(圆点) | 居中,(0.5, 0.5) | 100 × 100 | 不勾 |
根节点那层 Image 可以是一张几乎全透明的圆,它的唯一职责是把射线接住。手柄和背景圈都不勾 Raycast Target,射线只会打到根节点,事件目标从头到尾不变,OnPointerUp才不会丢。
锚点这一项特别容易忽略。手柄要动,动的是anchoredPosition,而anchoredPosition的原点是「锚点重合位置」。锚点设成居中,原点就是父节点中心,手柄归位就是Vector2.zero;锚点要是设成左下角,原点跑到左下,归位时手柄会贴在角上,你怎么调数值都对不齐。轴心(Pivot)则是自身旋转和缩放的中心,圆点手柄保持 0.5 就行。
手柄的层级顺序也要注意:UGUI 里同级节点后添加的绘制在上层,手柄必须是背景圈之后创建的子物体,或者手动handle.SetAsLastSibling()。否则手柄被背景图盖住,运行时看起来就是「拖了没反应」。
2.2 半径从哪来:rect.width 而不是 Screen.width
摇杆的行程半径决定了「推到底」的判定边界,它的取值必须和anchoredPosition用同一套单位。
background.rect.width是本地单位,anchoredPosition也是本地单位,两者相除得到比例,天然不会错。而Screen.width是屏幕像素,中间隔着 CanvasScaler 的scaleFactor,在 1080p 手机上勉强对得上,换到平板或者改了 Reference Resolution 就全乱。
// 半径用背景圈的本地宽度算,和 anchoredPosition 同单位 radius = background.rect.width * 0.5f;另一个时机问题:rect.width要等布局算完才有值。挂在普通 Canvas 下的固定尺寸节点,Awake里读通常没问题;但如果你给根节点套了ContentSizeFitter、LayoutGroup,或者摇杆是运行时实例化的,Awake阶段可能读到 0。常见做法是挪到Start,或者干脆把半径做成[SerializeField] float radius = 120f手动填,彻底摆脱对布局时机的依赖。我一般选后者,省事,也方便在 Inspector 里直接调手感。
顺带提一句 CanvasScaler。Scale With Screen Size 改变的是 Canvas 的scaleFactor,它影响最终渲染到屏幕上的像素大小,但不改rect的本地单位。这意味着你用本地单位算出来的半径,在 16:9 手机和 4:3 平板上对应的「屏幕占比」会不一样,但摇杆本身的比例关系永远正确——这正是我们想要的。
2.3 Canvas 与 EventSystem 的必要配置
摇杆不出问题,一半靠这两块配好。逐项对一遍:
| 检查项 | 正确取值 | 出错表现 |
|---|---|---|
| Canvas 上挂 GraphicRaycaster | 必须有 | 所有 UI 事件都收不到 |
| Render Mode | Overlay 或 Screen Space - Camera 均可 | 影响pressEventCamera是否为 null |
| 场景内 EventSystem 数量 | 有且只有一个 | 多个会互相抢事件,拖拽断续 |
| Input Module | 旧输入用 StandaloneInputModule,新输入用 InputSystemUIInputModule | 混用直接抛异常 |
| 根节点 Image 的 Raycast Target | 勾选 | 点了没反应 |
| 手柄、背景的 Raycast Target | 不勾 | 拖到一半事件目标被换掉 |
Render Mode 这一项不决定能不能用,但决定eventData.pressEventCamera的取值:Overlay 画布下它是null,Screen Space - Camera 和 World Space 下它是渲染该 Canvas 的相机。所以换算坐标时别硬编码Camera.main,一律传eventData.pressEventCamera,让 UGUI 自己告诉你用哪个相机,两种模式都能跑。
Input Module 是新人踩得最多的一项。项目切到新 Input System 之后,EventSystem 上若还挂着 StandaloneInputModule,运行会直接报「You are trying to read Input using the UnityEngine.Input class, but you have switched active Input handling to Input System package」,摇杆一动不动。这时候把 StandaloneInputModule 换成 InputSystemUIInputModule 即可,摇杆脚本一行都不用改——因为它只依赖 UGUI 事件接口,不碰底层输入 API。
3. 核心脚本:从屏幕点算到归一化方向
3.1 三个事件接口,比自己轮询 Input 稳
UGUI 的事件接口比在Update里读Input.mousePosition判断距离靠谱得多:它天然处理了「按下时命中的是哪个物体」,手指滑出 UI 区域后事件依然跟着走,多点触控下也能按 pointerId 分开。摇杆只需要实现三个。
using UnityEngine; using UnityEngine.EventSystems; // 挂在摇杆根节点上,根节点必须有 Image 且勾选 Raycast Target public class SimpleJoystick : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler { [Header("引用")] [SerializeField] private RectTransform background; // 背景圈,用来算半径 [SerializeField] private RectTransform handle; // 手柄,实际移动的那个圆点 [Header("手感")] [Range(0.1f, 1f)] [SerializeField] private float handleRange = 0.8f; // 手柄最多走到背景半径的 80% [Range(0f, 0.5f)] [SerializeField] private float deadZone = 0.15f; // 死区比例,防止手指微抖就触发 /// <summary>归一化输入方向,长度 0~1,供外部读取</summary> public Vector2 Direction { get; private set; } /// <summary>当前是否被按住</summary> public bool IsPressed { get; private set; } private float radius; // 背景圈半径,本地单位 private Vector2 handleStart; // 手柄初始锚点位置,通常是 Vector2.zero private void Awake() { // 和 anchoredPosition 同一套坐标系,直接相除不会引入缩放误差 radius = background.rect.width * 0.5f; handleStart = handle.anchoredPosition; } public void OnPointerDown(PointerEventData eventData) { IsPressed = true; UpdateJoystick(eventData); // 按下瞬间就结算一次,轻点也能出方向 } public void OnDrag(PointerEventData eventData) { UpdateJoystick(eventData); } public void OnPointerUp(PointerEventData eventData) { ResetStick(); } private void OnDisable() { // 手指出屏、切后台、被别的 UI 遮挡时,OnPointerUp 有可能收不到 ResetStick(); } private void ResetStick() { IsPressed = false; Direction = Vector2.zero; handle.anchoredPosition = handleStart; } private void UpdateJoystick(PointerEventData eventData) { // 屏幕坐标 -> background 的本地坐标 // pressEventCamera:Overlay 画布传 null,Screen Space - Camera 传对应相机 if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( background, eventData.position, eventData.pressEventCamera, out Vector2 local)) return; // local 以 background 轴心为原点,除以半径得到 -1~1 的比例 Vector2 raw = local / radius; // 先按圆形限位,再乘可动比例,手柄不会跑到圈外 Vector2 clamped = Vector2.ClampMagnitude(raw, 1f) * handleRange; handle.anchoredPosition = handleStart + clamped * radius; // 输出前过一遍死区,外部拿到的是「已经处理干净」的方向 Direction = ApplyDeadZone(clamped / handleRange); } private Vector2 ApplyDeadZone(Vector2 v) { float mag = v.magnitude; if (mag <= deadZone) return Vector2.zero; // 死区外重新映射到 0~1,避免刚出死区就突然跳到 0.15 造成顿挫 float t = Mathf.InverseLerp(deadZone, 1f, mag); return v.normalized * t; } }几个参数说明一下。handleRange控制手柄的视觉行程,0.8 意味着手柄中心最多走到背景圈半径的 80%,留一圈边,看着才有「推到底」的余量;它不影响输出范围,输出永远归一化到 0~1。deadZone是防抖的核心,手机屏幕上的手指静止时也会有毫米级抖动,没有死区的话角色会一直小幅漂移。InverseLerp那行的作用是让输出连续:在死区边界是 0,推到满行程是 1,中间线性过渡,不会出现「不动 → 突然 0.15 速度」的断层。
OnDisable里那次重置,是后来修 bug 才加的。有次测试反馈「按着摇杆切出去再回来,角色自己往前冲」,查了半天是系统弹窗吃掉了OnPointerUp,方向变量停在最后一次的值上。
3.2 限位的两种写法,差别在「圆」还是「方」
Vector2.ClampMagnitude(raw, 1f)把向量长度压到 1 以内,得到的是一个圆。另一种常见写法是分别 clamp:
// 不推荐:x、y 各自夹取,限位形状是正方形 float x = Mathf.Clamp(raw.x, -1f, 1f); float y = Mathf.Clamp(raw.y, -1f, 1f); Vector2 clamped = new Vector2(x, y);这段代码在正上、正下、正左、正右四个方向看不出问题,但斜着拖就露馅了:手柄会跑到正方形的角上,离圆心距离是 1.41 倍,视觉上明显冲出背景圈。而且此时clamped.magnitude大于 1,再把方向交给角色移动,斜向速度就比直行快 40%——竞速类游戏里这是致命的。
如果你确实想要方形摇杆(八向格斗游戏的手感),那就老老实实把输出归一化:Direction = clamped.normalized * Mathf.Min(1f, clamped.magnitude),或者干脆不做长度压缩,只把方向单位化交给下游。总之「限位形状」和「输出长度」这两件事要分开想清楚。
3.3 接到物体移动:三种载体的写法
摇杆本身只产出Vector2,怎么用是下游的事。3D 场景里第一个要决定的是:输入方向是相对世界坐标,还是相对相机。固定的俯视视角无所谓,但只要相机能转,就必须转成相机相对,否则「往上推」在斜视角下会变成往屏幕外跑。
using UnityEngine; public class JoystickMover : MonoBehaviour { [SerializeField] private SimpleJoystick joystick; [SerializeField] private float moveSpeed = 5f; // 满推速度,米/秒 [SerializeField] private float turnSpeed = 720f; // 转向角速度,度/秒 [SerializeField] private bool cameraRelative = true; // 3D 斜视角一般要开 private void Update() { Vector2 input = joystick.Direction; // 死区内直接返回,省掉后面所有计算和旋转 if (input.sqrMagnitude < 0.0001f) return; Vector3 dir = new Vector3(input.x, 0f, input.y); if (cameraRelative && Camera.main != null) { // 把相机的前向和右向投影到水平面,斜视角下输入才符合直觉 Vector3 fwd = Vector3.ProjectOnPlane(Camera.main.transform.forward, Vector3.up).normalized; Vector3 right = Vector3.ProjectOnPlane(Camera.main.transform.right, Vector3.up).normalized; dir = right * input.x + fwd * input.y; } // 用 magnitude 做速度缩放:轻推慢走,推满快跑 transform.position += dir.normalized * (moveSpeed * input.magnitude) * Time.deltaTime; if (dir.sqrMagnitude > 0.0001f) transform.rotation = Quaternion.RotateTowards( transform.rotation, Quaternion.LookRotation(dir), turnSpeed * Time.deltaTime); } }dir.normalized * moveSpeed * input.magnitude这一行是手感的关键。如果直接写dir * moveSpeed,只要出了死区就是满速,摇杆退化成八向键盘;乘上magnitude之后,摇杆行程和速度线性对应,才叫「模拟量」。
三种载体接法的差异整理成一张表:
| 载体 | 写在哪里 | 关键点 |
|---|---|---|
| Transform | Update | 简单直接;穿墙、爬坡、物理碰撞一概不管 |
| Rigidbody | FixedUpdate | 改速度而不是改位置;保留 Y 轴分量别把重力写没 |
| CharacterController | Update | 用controller.Move();重力要自己累加velocity.y |
Rigidbody 版本的核心只有一行,但有个版本兼容的坑:
private void FixedUpdate() { Vector2 input = joystick.Direction; // Unity 6 及之后是 linearVelocity,2022 及更早是 velocity,按工程版本改 Vector3 v = rb.linearVelocity; rb.linearVelocity = new Vector3(input.x * moveSpeed, v.y, input.y * moveSpeed); }直接给velocity整体赋值会把重力方向的速度抹掉,角色会浮在空中匀速下落甚至卡住。永远只改水平两个分量,Y 轴留给物理引擎。CharacterController 同理,需要用controller.isGrounded判断是否清零velocity.y,再加一个velocity.y += gravity * Time.deltaTime的累加。
4. 避坑与排查:摇杆调不动的五个现场
4.1 现象:拖拽完全没反应,断点一行不进
原因通常是射线链路断了。按可能性从高到低排:Canvas 上没有 GraphicRaycaster;根节点 Image 的 Raycast Target 没勾;根节点被一个全屏的透明 Image 盖住(常见于「Loading 遮罩忘了关」);场景里 EventSystem 缺失或被禁用。
解决:运行时选中 EventSystem 看 Inspector 里有没有 Input Module;在根节点上挂一个临时的IPointerClickHandler打日志,能打出来就说明射线是通的,问题在下游的方向计算。另外检查 Canvas 的overrideSorting和 sortingOrder,摇杆层级比遮罩低的话,点上去命中的是遮罩。
4.2 现象:手柄追不上手指,越拖越偏
原因几乎都是把eventData.position直接赋给了anchoredPosition。前者是屏幕像素坐标,后者是父节点本地坐标,中间差了 Canvas 缩放、父节点偏移、Canvas 位置三层变换。表现就是「手指在屏幕中心,手柄跑到角落」。
解决:老老实实走RectTransformUtility.ScreenPointToLocalPointInRectangle,并且第一个参数传background而不是handle——传handle的话,参照系跟着手柄一起动,会形成正反馈,手柄直接飞出去。第二个参数传eventData.position,第三个传eventData.pressEventCamera。
4.3 现象:手柄能飞出背景圈,斜向飞得最远
原因是用Mathf.Clamp分别夹了 x 和 y,限位形状变成正方形而不是圆。斜向拖拽时magnitude能到 1.41,手柄冲出画好的圆环,角色斜向速度也偏快。
解决:换成Vector2.ClampMagnitude(raw, 1f)。同时检查一下输出是不是又乘了一次半径——clamped已经是「半径倍数」,handle.anchoredPosition = clamped * radius就够,别再叠加。
4.4 现象:手指按着摇杆,角色纹丝不动
如果是新 Input System 工程,先看 EventSystem 是不是还挂着 StandaloneInputModule,运行时会抛输入 API 冲突的异常,事件根本进不来。
如果是旧的 Input Manager 工程,多半是输入源打架:角色脚本里还留着Input.GetAxis("Horizontal"),每帧把摇杆算出来的方向覆盖成 0。解决办法是把所有键盘输入集中到一个 InputProvider 里,由它决定当前生效的是摇杆还是键盘,不要在两处各读各的。
还有一种更隐蔽的情况:摇杆根节点被CanvasGroup包着,interactable是 false 或者blocksRaycasts是 false。这种状态下 Image 看着正常,事件全被吞掉。
4.5 现象:换分辨率或机型后,半径不对、归位偏移
原因是在Awake里读了background.rect.width,但那一刻布局还没算完,读到 0,于是local / radius直接除零得到Infinity或NaN,手柄瞬间消失。这类问题在编辑器里往往复现不了,打包到真机上才炸。
解决:半径改用[SerializeField]手动填,或者延到Start里取并在取到 0 时兜底;同时在UpdateJoystick开头加一句if (radius <= 0.01f) return;挡住除零。归位偏移则是锚点被改过——检查根节点和 background 的anchorMin == anchorMax,只要不是重合状态,handleStart就不该写死为零。
5. 手感与适配:动态摇杆、多指与换分辨率
5.1 动态摇杆:按下才出现,位置跟着手指走
固定摇杆在手机上有个天然缺陷:拇指够不着左下角,只能低头看。动态摇杆把「按下」和「定位」合并——手指落在屏幕任意位置,摇杆就在那儿出现,松手后隐藏。
做法是在全屏透明 Image 上做一层「感应区」,它的OnPointerDown把摇杆根节点的anchoredPosition挪到手指位置,然后后续拖拽交给摇杆自己的脚本处理。
using UnityEngine; using UnityEngine.EventSystems; // 挂在全屏透明 Image 上,作为摇杆的感应区 public class DynamicJoystickArea : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler { [SerializeField] private RectTransform stickRoot; // 摇杆根节点,锚点必须是 (0.5, 0.5) [SerializeField] private RectTransform background; [SerializeField] private RectTransform handle; private RectTransform area; // 感应区自身 private float radius; private Vector2 direction; public Vector2 Direction => direction; private void Awake() { area = (RectTransform)transform; radius = background.rect.width * 0.5f; stickRoot.gameObject.SetActive(false); } public void OnPointerDown(PointerEventData e) { // 屏幕点 -> 感应区的本地坐标;锚点居中时,本地坐标就等于 anchoredPosition if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( area, e.position, e.pressEventCamera, out Vector2 local)) return; stickRoot.gameObject.SetActive(true); stickRoot.anchoredPosition = local; // 前提:stickRoot 的 anchorMin == anchorMax == (0.5, 0.5) background.anchoredPosition = Vector2.zero; handle.anchoredPosition = Vector2.zero; direction = Vector2.zero; } public void OnDrag(PointerEventData e) { if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( background, e.position, e.pressEventCamera, out Vector2 local)) return; Vector2 clamped = Vector2.ClampMagnitude(local / radius, 1f); handle.anchoredPosition = clamped * radius * 0.8f; direction = clamped; } public void OnPointerUp(PointerEventData e) { direction = Vector2.zero; stickRoot.gameObject.SetActive(false); } }stickRoot.anchoredPosition = local这行的前提写在了注释里,但值得再强调:只有当stickRoot的锚点重合于父节点中心时,本地坐标才等价于anchoredPosition。锚点是拉伸状态的话,得先算父节点尺寸的一半做补偿。
动态摇杆有个副作用:全屏感应区会挡住屏幕上的其他 Button。常见做法是把感应区限制在屏幕下半部分(anchorMin = (0, 0)、anchorMax = (1, 0.5)),给按钮区让出空间;或者把非摇杆区域的按钮层级提到感应区之上,靠 UGUI 的射线优先级解决。
5.2 死区、灵敏度与回中动画
死区的大小没有标准答案,和屏幕尺寸、手指粗细、游戏类型都相关。我的经验表:
| 场景 | deadZone 建议 | 说明 |
|---|---|---|
| 手机动作类 | 0.12 ~ 0.18 | 兼顾响应和防抖 |
| 精确瞄准类 | 0.05 ~ 0.10 | 宁可抖一点,不要丢微操 |
| 大屏平板 | 0.20 左右 | 手指移动幅度大,抖动也大 |
| 固定八向 | 0.25 以上 | 顺带做方向吸附 |
回中动画不要用Mathf.Lerp直接插值到目标位置,每帧插值会导致手感「越接近越慢」。想要干脆的弹性回中,用Vector2.MoveTowards(handle.anchoredPosition, handleStart, returnSpeed * Time.unscaledDeltaTime),速度恒定,松手后固定用时归位,比 Lerp 好调。
Time.unscaledDeltaTime这个细节也别漏。如果游戏有子弹时间或暂停(Time.timeScale = 0),摇杆 UI 却应该照常响应,用Time.deltaTime回中会卡住不动,看起来像界面冻了。
5.3 缩放模式适配与多指同时操作
CanvasScaler 推荐 Scale With Screen Size + Match 0.5,参考分辨率按项目主力的机型定。摇杆这种固定比例的控件,在这个模式下跨分辨率基本不用额外处理,因为rect的本地单位不变。真正要调的是「屏幕占比」——竖屏手机上 240 单位的摇杆看着刚好,横屏平板上可能就偏小,可以用一个按Screen.dpi或宽高比微调localScale的小脚本兜住,别指望一套尺寸通吃。
多指这块,UGUI 的 EventSystem 本身支持多个 pointerId,两个手指分别按左摇杆和右摇杆是能同时工作的,前提是:两个摇杆各自是独立的根节点,各自勾 Raycast Target,脚本内部不要用任何静态变量或EventSystem.current.IsPointerOverGameObject()做全局判断。后者在不传 pointerId 的情况下会返回「任意指针是否在 UI 上」,多指时行为不可预测,非常容易翻车。
还有Input.multiTouchEnabled,旧输入体系下它默认是 true,但如果在别处被代码关掉过,所有第二根手指都会被忽略。排查多指问题时把它打出来看一眼,比读十遍代码快。
6. 验证:把摇杆的输出可视化出来
摇杆最难调的地方是「看不见」。方向对不对、死区生效没有、松手是否真的归零,光看角色移动很难判断到底是摇杆的锅还是移动脚本的锅。我的习惯是加一个几十行的调试层,把Direction直接画在屏幕上。
using UnityEngine; // 调试用:挂在任意物体上,运行时显示摇杆原始输出 public class JoystickDebugView : MonoBehaviour { [SerializeField] private SimpleJoystick joystick; private void OnGUI() { if (joystick == null) return; Vector2 d = joystick.Direction; GUILayout.Label($"按下: {joystick.IsPressed}"); GUILayout.Label($"方向: ({d.x:F2}, {d.y:F2})"); GUILayout.Label($"长度: {d.magnitude:F2}"); // 应该始终落在 0~1 } // 在场景视图里画出方向射线,配合移动脚本一起看 private void OnDrawGizmosSelected() { if (joystick == null) return; Gizmos.color = Color.cyan; Vector2 d = joystick.Direction; Gizmos.DrawRay(transform.position, new Vector3(d.x, 0f, d.y) * 3f); } }OnGUI简陋但零依赖,GUILayout.Label每帧刷新,比刷Debug.Log强得多——日志会淹没在输出窗口里,而且影响帧率。OnDrawGizmosSelected那条射线用来对照「摇杆方向」和「角色实际移动方向」是否一致,如果两者不重合,问题就出在相机相对变换那一段,而不是摇杆本身。
验收我一般跑一张固定清单:
| 检查项 | 期望表现 |
|---|---|
| 按下但不拖 | Direction为零,IsPressed为真 |
| 轻推(小于死区) | Direction保持零,手柄有位移 |
| 推到边缘 | Direction.magnitude稳定在 1.00,不再增长 |
| 斜向拖拽 | 手柄不越出圆环,长度仍为 1.00 |
| 松手 | 手柄回到原点,Direction立刻归零 |
| 按住摇杆时切后台再回来 | 手柄已归位,角色不再自行移动 |
| 1080p 与 720p 各跑一遍 | 摇杆屏幕占比一致,无偏移 |
最后一行是血泪经验。早年我图省事,半径直接用Screen.width * 0.1f算,编辑器里一切正常,打包到一台低分辨率设备上摇杆变成一个小点,手柄一推就飞出去,排查了两个小时才发现是单位问题。从那以后,凡是涉及 UI 坐标的数值,我都强制走一遍「本地单位换算 + 在目标分辨率下实测」这两步,不再凭编辑器里的观感下判断。上面那段调试代码也是那时留下的,一直没删——摇杆这种东西,半年后回来改手感,全靠它省时间。希望帮到你。
本文还有配套的精品资源,点击获取