三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity Input System虚拟摇杆开发:5分钟实现力反馈与动态摇杆

Unity Input System虚拟摇杆开发:5分钟实现力反馈与动态摇杆

1. 项目概述:为什么我们需要一个“现代化”的虚拟摇杆?

在移动端游戏或PC端需要模拟手柄操作的项目里,虚拟摇杆几乎是标配。但很多开发者,尤其是刚接触Unity的朋友,可能还在沿用老旧的Input.GetAxis配合EventTrigger手搓摇杆逻辑。这种方式虽然直接,但问题一大堆:代码耦合度高、难以处理多平台输入(比如同时支持触屏和手柄)、力反馈实现麻烦、摇杆手感“僵硬”。

Unity全新的Input System就是为了解决这些痛点而生的。它提供了一个统一、强大且可扩展的输入处理框架。今天要聊的On-Screen Stick,就是Input System为触屏设备量身定做的虚拟摇杆控件。它的核心优势在于,你无需从零开始写拖拽逻辑和坐标转换,Input System已经帮你封装好了标准化的摇杆行为,包括我们梦寐以求的**力反馈(Haptic)**支持。这意味着,你可以在5分钟内,就获得一个手感顺滑、行为标准、且能与游戏手柄震动联动的高级摇杆。

这个摇杆能做什么?简单说,它就是一个显示在屏幕上的虚拟手柄摇杆。玩家触摸并拖动摇杆中心点,即可产生一个二维向量输入(比如Vector2),直接映射到角色的移动、视角旋转等操作上。而“力反馈”则可以在玩家拖拽到边缘、或发生特定事件(如撞击)时,触发设备的震动,极大提升操作沉浸感。无论你是做移动平台的动作游戏、RPG,还是需要触屏控制的模拟器,这个方案都值得你花5分钟了解一下。

2. Input System与On-Screen Stick核心机制解析

在动手之前,我们必须理解Input System处理触屏输入的基本原理,以及On-Screen Stick在这个框架中的位置。这能帮你避开很多“为什么没反应”的坑。

2.1 Input System的触屏输入管线

传统的Unity输入管理比较分散。Input System则将所有输入抽象为“Input Actions”(输入动作)。你可以创建一个Input Action Asset,在里面定义诸如“Move”(移动)、“Jump”(跳跃)这样的逻辑动作。然后,再为每个动作绑定具体的物理输入源,比如“Move”可以同时绑定“键盘WASD”、“手柄左摇杆”和“虚拟摇杆”。

对于触屏,Input System将其识别为一种Pointer(指针)设备。On-Screen Stick控件本质上是一个UI组件,它监听在这个特定UI区域内的指针事件(按下、拖拽、释放),并将指针移动的相对位移,经过处理后,转换成一个标准化的Vector2输出值。

这里的关键在于“标准化”。无论玩家的手机屏幕分辨率是1080p还是1440p,无论他手指拖拽的实际像素距离是多少,On-Screen Stick的输出范围通常被限制在[-1, 1]之间。这个向量(x, y)就是你的摇杆输入,(0,0)代表中心,(1,0)代表完全向右,(0.5, 0.5)代表右上方45度方向,力度为一半。

2.2 On-Screen Stick的三大核心属性

在Inspector窗口中,一个On-Screen Stick组件有几个关键属性,理解它们就掌握了调校手感的钥匙:

  1. Movement Range(移动范围):这是摇杆中心点可以拖离其初始位置的最大半径(以像素为单位)。这个值直接影响了摇杆的“活动区域”大小。设置太小,操作不跟手;设置太大,手指需要移动很长距离才能达到最大输入,可能影响操作效率。通常需要根据你的摇杆背景图大小和屏幕DPI来调整。

  2. Deadzone(死区):这是一个极其重要的概念。它定义了摇杆中心的一个小圆形区域,在这个区域内拖动摇杆,输出值将被视为(0,0)。为什么需要死区?为了避免玩家手指轻微颤抖或误触导致的角色微小抖动(俗称“摇杆漂移”)。合理的死区能让操作更稳定。

  3. Haptic Feedback(力反馈):这是On-Screen Stick的“杀手级”功能。它可以配置当摇杆被拖拽到边缘(magnitude >= 1)时,触发设备震动。你甚至可以定义震动的强度、持续时间、波形。对于支持高级触觉反馈的设备(如某些高端手机),这能提供非常细腻的手感。

注意:On-Screen Stick的力反馈依赖于IHaptics接口的实现。在iOS和Android的现代设备上,Unity可以通过底层API调用系统的震动功能。但在编辑器模式下或某些平台可能无效,编写代码时一定要做平台兼容性判断。

3. 5分钟快速搭建:从零到可拖拽摇杆

理论说完,我们开始实战。目标是5分钟内,在场景中看到一个可以拖拽并输出日志的摇杆。

3.1 第一步:导入Input System并创建控件

如果你的项目还没有安装Input System,通过Package Manager安装“Input System”包。安装后,Unity可能会提示你切换激活的输入管理器,点击确认即可。

  1. 在Hierarchy中,右键 -> UI -> Panel,创建一个画布下的面板,作为摇杆的容器。调整其大小和位置到屏幕左下角(通常的摇杆位置)。
  2. 在这个Panel下,创建一个Image作为摇杆的背景(Joystick Background),再创建一个子Image作为摇杆的柄(Joystick Handle)。为它们配上不同的颜色或精灵图片以便区分。
  3. 选中作为“柄”的Image对象,在Inspector中点击“Add Component”,搜索并添加On-Screen Stick组件。

完成这一步,其实一个最基础的摇杆已经可以工作了!你可以运行游戏,在Game视图点击并拖拽那个“柄”,它应该会跟着你的鼠标(或触屏手指)移动了。但我们现在还无法在代码中获取到输入值。

3.2 第二步:配置Input Action Asset

这是Input System的核心配置,我们将摇杆的输入集成到统一的输入系统中。

  1. 在Project窗口中右键 -> Create -> Input Actions,创建一个新的输入动作资源文件,命名为PlayerControls
  2. 双击打开该资源的编辑窗口。默认有一个“Action Maps”(动作映射集),我们可以创建一个名为Player的映射集。
  3. Player映射集下,创建一个新的Action(动作),命名为Move,Action Type(动作类型)设置为Value(因为它输出的是连续的二维向量值),Control Type(控件类型)设置为Vector2
  4. 选中Move动作,在右侧看到“Bindings”(绑定)。点击“+”号,选择Path->On-Screen Stick。这样,我们就将Move这个逻辑动作,绑定到了屏幕上可能存在的任何一个On-Screen Stick控件上。

3.3 第三步:将控件与Action关联并编写读取代码

现在我们需要告诉刚才创建的On-Screen Stick组件:“你的输出,应该对应到PlayerControls资源里的Move动作”。

  1. 回到场景中,选中之前添加了On-Screen Stick组件的摇杆柄对象。
  2. On-Screen Stick组件的Inspector里,你会看到一个“Control Path”或“Action Reference”字段。将我们创建的PlayerControls资源拖拽进去,并在下拉菜单中选择Player/Move这个动作。
  3. 创建一个新的C#脚本,命名为PlayerController,挂载到玩家角色或控制器对象上。
using UnityEngine; using UnityEngine.InputSystem; public class PlayerController : MonoBehaviour { // 引用我们在Asset中创建的Input Action Asset public InputActionAsset playerControlsAsset; // 移动输入动作 private InputAction moveAction; // 角色移动速度 public float moveSpeed = 5f; private void Awake() { // 从Asset中找到“Player”动作映射集下的“Move”动作 moveAction = playerControlsAsset.FindActionMap("Player").FindAction("Move"); } private void OnEnable() { // 启用动作,并注册一个回调函数,当输入值变化时被调用 moveAction.Enable(); moveAction.performed += OnMovePerformed; moveAction.canceled += OnMoveCanceled; // 当输入停止(如手指松开)时触发 } private void OnDisable() { // 禁用动作,并取消注册回调,这是良好习惯避免内存泄漏 moveAction.performed -= OnMovePerformed; moveAction.canceled -= OnMoveCanceled; moveAction.Disable(); } private void OnMovePerformed(InputAction.CallbackContext context) { // 从回调上下文中读取当前的Vector2输入值 Vector2 inputVector = context.ReadValue<Vector2>(); Debug.Log($"摇杆输入: {inputVector}"); // 将输入应用到角色移动(示例:在XZ平面移动) Vector3 movement = new Vector3(inputVector.x, 0, inputVector.y) * moveSpeed * Time.deltaTime; transform.Translate(movement, Space.World); } private void OnMoveCanceled(InputAction.CallbackContext context) { // 当摇杆回中或松开时,输入向量为0 Debug.Log("摇杆输入停止"); // 这里可以处理角色停止移动的逻辑,如果Translate是每帧调用,这里可以不写 } }
  1. PlayerControls资源拖拽到PlayerController脚本的playerControlsAsset字段上。

现在运行游戏。拖动摇杆,你不仅能看到摇杆柄移动,还能在Console窗口看到实时输出的向量值,并且角色应该会根据摇杆方向移动。一个基础的可拖拽摇杆功能,在5分钟内就实现了。

4. 高级调优:实现精准控制与沉浸式力反馈

基础功能有了,但手感可能还很粗糙。下面我们来深入调优,实现商业级的手感。

4.1 死区与轴向敏感度调校

默认设置可能并不适合你的游戏。我们需要精细调整:

  • 死区(Deadzone):在On-Screen Stick组件的Movement配置下,找到Deadzone。它是一个[0,1]的归一化值,表示相对于Movement Range的百分比。例如,移动范围是100像素,死区设为0.2,那么中心20像素内的拖拽都不会产生输入。对于需要精准操作的游戏(如射击游戏),死区可以设小(如0.05-0.1);对于防止误触的休闲游戏,可以设大(如0.2-0.3)。
  • 轴向敏感度与曲线:有时,你可能希望摇杆在某个方向(如前后)上更敏感,或者输入响应不是线性的。Input System的Action本身支持通过Processors(处理器)来修改原始值。你可以在PlayerControls资源中编辑Move动作,为其添加Stick Deadzone处理器(另一种死区设置方式),或者通过编写自定义的InputProcessor<float>来应用一个响应曲线(例如,让小幅度的输入更平缓,大幅度的输入更激进)。

4.2 配置与触发力反馈

力反馈是提升体验的关键。我们配置当摇杆被推到边缘时触发一个短促震动。

  1. 在场景中,选中摇杆柄对象。
  2. On-Screen Stick组件中,找到Haptic Feedback折叠栏并展开。
  3. 勾选Enable Haptic Feedback
  4. 配置参数:
    • Haptic Intensity: 震动强度,范围[0,1]。可以从0.5开始尝试。
    • Haptic Duration: 震动持续时间(秒)。对于边缘反馈,0.05到0.1秒的短脉冲通常效果很好。
    • Haptic Frequency(可选):在一些平台上可以指定震动频率。
  5. 默认情况下,反馈会在摇杆输入向量的幅度(magnitude)大于等于1时触发,也就是拖到最大范围时。

实操心得:安卓设备上,震动效果取决于设备硬件和Unity的适配。iOS的效果一般比较统一。在编辑器里测试时,可以连接真机通过Unity Remote来体验,或者在脚本中调用Gamepad.current?.SetMotorSpeeds(0.5f, 0.5f)来模拟测试(如果有连接手柄)。

4.3 动态摇杆与视觉反馈

一个高级的摇杆往往不是固定显示在屏幕上的,而是“动态摇杆”——即玩家在屏幕任意位置按下,摇杆才在那个位置出现。用Input System实现这个功能非常优雅。

思路是:我们不在场景中预先放置摇杆,而是通过代码监听整个屏幕的触控开始事件,在触控点实例化一个摇杆Prefab。

  1. 将之前做好的摇杆(包含背景、柄和On-Screen Stick组件)做成一个Prefab。
  2. 创建一个新的脚本DynamicJoystickManager
using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.Controls; using UnityEngine.EventSystems; public class DynamicJoystickManager : MonoBehaviour { public GameObject joystickPrefab; // 摇杆Prefab public Canvas parentCanvas; // UI画布 private GameObject currentJoystick; private RectTransform joystickRect; private InputAction touchPositionAction; private void Start() { // 创建一个监听触屏位置的Action touchPositionAction = new InputAction(binding: "<Touchscreen>/position"); touchPositionAction.Enable(); // 监听触屏按下事件 InputSystem.onActionChange += OnActionChange; } private void OnActionChange(object obj, InputActionChange change) { // 这是一个简化的监听方式,更健壮的做法是使用InputAction来监听<Touchscreen>/press if (change == InputActionChange.ActionPerformed) { var action = obj as InputAction; if (action != null && action.activeControl != null) { // 判断是否是触屏按下控制 if (action.activeControl.device is Touchscreen && action.activeControl.name == "press") { var touchControl = action.activeControl as ButtonControl; if (touchControl != null && touchControl.wasPressedThisFrame) { // 检查是否点击在UI上,避免与UI按钮冲突 if (EventSystem.current.IsPointerOverGameObject(GetTouchId())) { return; } // 获取触控位置 Vector2 touchPos = touchPositionAction.ReadValue<Vector2>(); CreateJoystick(touchPos); } } } } else if (change == InputActionChange.ActionCanceled) { // 当触屏抬起时,销毁摇杆 if (currentJoystick != null) { Destroy(currentJoystick); currentJoystick = null; } } } private int GetTouchId() { // 简化处理,获取第一个触控的ID。实际项目可能需要处理多点触控。 if (Touchscreen.current != null) { foreach (var touch in Touchscreen.current.touches) { if (touch.press.isPressed) { return touch.touchId.ReadValue(); } } } return 0; } private void CreateJoystick(Vector2 screenPosition) { if (currentJoystick != null) return; // 将屏幕坐标转换为UI画布下的局部坐标 RectTransformUtility.ScreenPointToLocalPointInRectangle( parentCanvas.GetComponent<RectTransform>(), screenPosition, parentCanvas.worldCamera, out Vector2 localPoint); currentJoystick = Instantiate(joystickPrefab, parentCanvas.transform); joystickRect = currentJoystick.GetComponent<RectTransform>(); joystickRect.anchoredPosition = localPoint; // 这里可以添加一些出现动画 } private void OnDestroy() { InputSystem.onActionChange -= OnActionChange; touchPositionAction?.Disable(); } }

这个脚本提供了动态创建摇杆的基本框架。它监听触屏按下事件,在非UI区域点击时,在点击位置生成摇杆。当触屏抬起时,销毁摇杆。这比固定摇杆更灵活,不遮挡屏幕固定区域。

5. 避坑指南与性能优化实录

在实际项目中,我踩过不少坑。这里总结几个最常见的问题和解决方案。

5.1 摇杆输入“卡顿”或“跳变”

  • 问题描述:拖动摇杆时,输出值不是平滑变化,而是有卡顿或突然跳变。
  • 排查与解决
    1. 检查更新时机:确保你读取输入值的代码在Update()FixedUpdate()中,并且Input System的更新模式(Edit -> Project Settings -> Input System Package -> Update Mode)与你的逻辑匹配。通常使用Fixed UpdateDynamic Update
    2. 检查UI层级与射线阻挡:确保摇杆所在的UI Canvas没有其他透明或不可见的UI元素覆盖在上面,阻挡了射线检测。检查Canvas的Graphic Raycaster组件是否启用。
    3. 关闭VSync测试:有时显示器垂直同步会导致输入采样不连贯。可以在Quality Settings中暂时关闭VSync进行测试。
    4. On-Screen Stick组件配置:确认Movement Range设置合理,如果设置过小,手指轻微移动就可能超出范围,导致输入值在边界和内部跳变。

5.2 多指触控与摇杆冲突

  • 问题描述:当屏幕上有多根手指时,摇杆的响应混乱,或者另一个手指的触控干扰了摇杆。
  • 解决方案:Input System的On-Screen Stick默认会响应任何在它RectTransform区域内的触控。在动态摇杆方案中,我们通过EventSystem.current.IsPointerOverGameObject()来过滤。但对于固定摇杆,如果你希望它只响应第一个触控点,需要在自定义逻辑中管理触控ID。一个更简单的方法是使用Input System的PlayerInput组件和Input User来分离不同玩家的输入,但对于单玩家多指触控,通常需要自己记录激活摇杆的触控ID,并忽略其他触控点的事件。

5.3 力反馈在部分安卓设备上无效

  • 问题描述:配置了力反馈,在iOS和部分安卓机上有效,但在某些特定品牌或型号的安卓机上无震动。
  • 排查步骤
    1. 检查权限:确保AndroidManifest.xml文件中已添加震动权限:<uses-permission android:name="android.permission.VIBRATE" />
    2. 检查系统设置:提醒玩家检查手机是否开启了静音模式或关闭了游戏震动功能。
    3. 降级方案:在代码中做平台和能力检测。可以尝试调用Android原生API(通过AndroidJavaClass)来触发震动,作为Unity接口的备用方案。同时,一定要在无法触发震动时静默失败,不要报错影响游戏流程。

5.4 性能优化要点

  • 避免每帧查找Action:不要在Update里通过字符串名称(如playerControls.FindAction("Move"))查找动作,这会产生GC Alloc。应该在AwakeStart中缓存InputAction引用。
  • 合理使用回调与轮询:对于连续输入(如摇杆),使用action.performed回调是高效且实时的。对于单次动作(如按钮),两种方式均可。回调方式更事件驱动。
  • UI Canvas合并:确保你的摇杆UI位于一个合适的Canvas中。如果场景中有大量动态UI,考虑使用Screen Space - CameraWorld Space渲染模式,并注意将频繁更新的UI与静态UI分开到不同的Canvas,以减少重绘范围。
  • 预制体与对象池:对于动态摇杆,一定要使用对象池来管理摇杆Prefab的生成与销毁,避免频繁的Instantiate和Destroy操作引发GC(垃圾回收)卡顿。

6. 超越基础:摇杆输入的高级处理与扩展应用

掌握了基础实现和问题排查后,我们可以看看如何让摇杆输入更好地服务于游戏逻辑。

6.1 输入平滑与加速度模拟

直接从摇杆读取的Vector2输入是即时的,直接用于移动可能会显得生硬。我们可以对输入值进行平滑处理,模拟出加速和减速的过程,让操作手感更接近真实手柄。

public class SmoothedJoystickInput : MonoBehaviour { public float acceleration = 5f; // 输入加速率 public float deceleration = 10f; // 输入减速率 private Vector2 currentInput; // 当前平滑后的输入 private Vector2 targetInput; // 目标输入(原始摇杆输入) private void Update() { // 假设rawInput是从Input System读取的原始值 Vector2 rawInput = moveAction.ReadValue<Vector2>(); targetInput = rawInput; // 平滑处理:向目标输入加速或减速 if (targetInput.magnitude > 0.01f) { // 有输入时,加速朝向目标 currentInput = Vector2.MoveTowards(currentInput, targetInput, acceleration * Time.deltaTime); } else { // 无输入时,减速回零 currentInput = Vector2.MoveTowards(currentInput, Vector2.zero, deceleration * Time.deltaTime); } // 使用平滑后的currentInput来控制角色 // ... } }

6.2 八方向锁定与扇形区域判定

在一些复古风格或格子战棋游戏中,我们可能需要将连续的摇杆输入“锁定”到8个或4个主要方向上。

public Vector2 GetLockedEightDirection(Vector2 input) { if (input.magnitude < deadzone) return Vector2.zero; // 计算输入向量的角度(弧度) float angle = Mathf.Atan2(input.y, input.x); // 将弧度转换为角度 angle *= Mathf.Rad2Deg; // 将角度归一化到[0, 360) if (angle < 0) angle += 360; // 每45度一个方向,共8个方向 int directionIndex = Mathf.RoundToInt(angle / 45f) % 8; // 将索引映射回标准向量 switch (directionIndex) { case 0: return Vector2.right; // 0度 case 1: return new Vector2(1, 1).normalized; // 45度 case 2: return Vector2.up; // 90度 case 3: return new Vector2(-1, 1).normalized; // 135度 case 4: return Vector2.left; // 180度 case 5: return new Vector2(-1, -1).normalized; // 225度 case 6: return Vector2.down; // 270度 case 7: return new Vector2(1, -1).normalized; // 315度 default: return Vector2.zero; } }

对于扇形区域(例如技能方向选择),可以判断输入向量与某个中心方向的角度差是否在阈值内。

6.3 与动画系统(Animator)的集成

摇杆输入可以很好地驱动角色的移动动画。通常,我们将摇杆向量的magnitude(大小)作为混合树(Blend Tree)的Speed参数,将向量的Angle(角度)或直接使用xy作为DirectionXDirectionY参数。

在Animator Controller中创建一个2D自由方向(Freeform Cartesian)的混合树,将两个参数(如MoveX,MoveY)绑定到混合树上。然后在脚本中:

private Animator animator; private void Update() { Vector2 joystickInput = moveAction.ReadValue<Vector2>(); animator.SetFloat("MoveX", joystickInput.x); animator.SetFloat("MoveY", joystickInput.y); // 或者设置Speed参数 animator.SetFloat("Speed", joystickInput.magnitude); }

6.4 多摇杆协作:双摇杆射击游戏

对于双摇杆射击游戏,左摇杆控制移动,右摇杆控制射击方向。实现原理完全一样,只需要创建两个On-Screen Stick控件,并分别绑定到不同的Input Action上,例如MoveAim。在PlayerControls资源中创建两个Vector2类型的Action即可。在代码中分别读取和处理这两个输入,一个用于控制角色位置,另一个用于控制武器准星或角色朝向。

我个人在实际项目中的体会是,Unity Input System的On-Screen Stick极大地标准化和简化了虚拟摇杆的开发流程。它把开发者从繁琐的坐标转换、事件处理和平台适配中解放出来,让我们能更专注于调校游戏手感和实现游戏逻辑。尤其是力反馈的集成,几乎是“开箱即用”,这在以前需要自己写不少平台特定代码。最后一个小技巧是,记得在不同分辨率和屏幕比例的设备上充分测试你的摇杆布局和Movement Range,确保所有玩家都能舒适操作。你可以使用Unity的Device Simulator包来快速模拟各种手机屏幕。

← 返回列表