UE5.8 鼠标、键盘与手柄输入切换

📑 目录

小林团队的独立项目《裂谷行者》在 Steam 上线三个月后,发行商突然要求追加 PS5 与 Xbox Series X 版本。QA 第一天就甩过来一段录像:玩家插上手柄,屏幕上的按键提示还是白底黑字的 E 键;他按下手柄的 A 键倒是能交互,可提示图标死活不换。反过来,有些测试员用键鼠玩游戏,进了背包界面,WASD 却还在控制角色在背景里溜达。小林盯着 Enhanced Input 那一堆 Mapping Context,忽然意识到问题不在能不能读到手柄信号,而在于引擎怎么知道现在该听哪一种设备,以及 UI 怎么把这条消息告诉玩家。这篇文章记录的就是他当时踩过的坑。

一张图看清输入切换的全貌

输入切换这件事,本质上把三层东西串起来:硬件层把原始信号送进来,中间层判断当前激活的设备类型并派发事件,表现层根据设备类型换图标、换提示、换 Input Mapping Context。三层任何一层断开,玩家看到的就不是设备对应的那套语言。

architecture-beta
    group api(软件接口层)
        service common_input[CommonInputSubsystem] in api
        service enhanced_input[EnhancedInputLocalPlayerSubsystem] in api
        service viewport[CommonGameViewportClient] in api

    group runtime(运行时判断层)
        service last_input[LastInputType 状态机] in runtime
        service imc_stack[IMC 优先级栈] in runtime
        service focus_mgr[Focus Manager] in runtime

    group render(表现层)
        service icon_db[Input Icon Database] in render
        service key_hint[按键提示控件] in render
        service nav_sys[UI 导航系统] in render

    common_input:T --> last_input:B
    last_input:T --> enhanced_input:B
    last_input:T --> viewport:B
    enhanced_input:T --> imc_stack:B
    viewport:T --> focus_mgr:B
    last_input:T --> icon_db:B
    icon_db:T --> key_hint:B
    focus_mgr:T --> nav_sys:B

上图里 CommonInputSubsystem 是 UE5 Common UI 提供的入口。它维护着一个 LastInputType,记录最近一次被识别的输入是键盘鼠标、手柄还是触摸屏。这个值一变,表现层就要跟着动。Enhanced Input 的 IMC 栈则决定游戏内哪些 Action 当前生效。Focus Manager 和 UI 导航系统负责在菜单打开时把输入从角色身上抢走。三层不是互相替代,而是分工:谁该响、谁该静、图标该长什么样,各自有主。

Common UI 输入映射:先让引擎认识你的设备

Common UI 是 UE5 官方推荐的 UI 框架,做跨平台菜单时绕不开。它把输入抽象成三种类型:MouseAndKeyboardGamepadTouch。项目设置里需要先指定默认输入类型,通常 PC 选 MouseAndKeyboard,主机选 Gamepad。

真正关键的是 CommonInputBaseTypes 里的 ECommonInputType。Common UI 的控件、按钮、提示都会读这个枚举。你的按钮提示控件 CommonInputActionWidget 挂在 Action 上之后,它会问 CommonInputSubsystem:当前输入类型是什么?然后到 CommonInputActionDataBase 里找对应设备的图标。

flowchart LR
    A[玩家按下按键] --> B{CommonInputSubsystem}
    B -->|解析输入类型| C[MouseAndKeyboard]
    B -->|解析输入类型| D[Gamepad]
    B -->|解析输入类型| E[Touch]
    C --> F[查询 Key Icon 表]
    D --> G[查询 Gamepad Icon 表]
    E --> H[查询 Touch 提示]
    F --> I[刷新 UI 提示]
    G --> I
    H --> I

很多团队在这里栽跟头,是因为只把 Enhanced Input 的 Action 配好了,却忘了 Common UI 也需要一份输入类型到图标的映射。结果游戏能响应手柄,但 UI 不知道手柄图标该长什么样。小林的团队就吃过这个亏:他们的交互 Action 叫 IA_Interact,IMC 里同时绑了键盘 E 和手柄 Face Button Bottom,可 CommonInputActionDataBase 里只有 E 的图标,没有 Xbox A 或 PS Cross 的图标。插上手柄后,功能正常,提示残疾。

解决办法不复杂。在 Project Settings -> Game -> Common Input Settings 里指定 Data Table,给每个 Input Action 填三种输入类型对应的 CommonInputActionData:键盘用 Key Icon、手柄用 Gamepad Icon、触屏用自定义提示。图标资产可以是纹理、材质或者 Common Input 的 Sprite,取决于项目美术风格。

平台检测:IsGamepad 与 IsKeyboardMouse 到底在问谁

Common UI 提供了几个常用判断:IsGamepad(), IsKeyboardMouse(), IsTouch(). 它们不是去扫 USB 口,而是读 CommonInputSubsystem 里的 LastInputType。换句话说,它回答的是玩家最近用了哪种设备,而不是当前插着哪种设备。

这个区别很重要。主机玩家可能一直插着手柄,但中途插上 USB 键盘打字,按了一下空格,LastInputType 立刻切到键盘鼠标,UI 提示全部变成空格键图标。等他再拿起手柄按任意键,又切回去。UE5.8 的 CommonInputSubsystem 里有一个 OnInputMethodChanged 事件,订阅它就能在输入类型变化时收到通知。

输入检测的响应延迟可以用一个简单公式描述。设从物理按键到 UI 刷新完成总时间为 TswitchT_{switch},则:

Tswitch=Tdetect+Tnotify+Trebind+TrenderT_{switch} = T_{detect} + T_{notify} + T_{rebind} + T_{render}

其中 TdetectT_{detect} 是引擎轮询到输入变化的时间,TnotifyT_{notify} 是事件派发到 UI 框架的时间,TrebindT_{rebind} 是切换 IMC 或图标映射的耗时,TrenderT_{render} 是 Slate/UMG 重绘一帧的时间。Common UI 把 TnotifyT_{notify}TrenderT_{render} 压得很低,真正拖后腿的往往是项目里在 OnInputMethodChanged 回调里塞了太多同步逻辑。

代码层面,在 C++ 里可以这样监听:

// 来源:PlayerController.cpp(示例)
void AMyPlayerController::BeginPlay()
{
    Super::BeginPlay();

    if (UCommonInputSubsystem* InputSubsystem = 
        UCommonInputSubsystem::Get(GetLocalPlayer()))
    {
        InputSubsystem->OnInputMethodChangedNative.AddUObject(
            this, &AMyPlayerController::HandleInputMethodChanged);
    }
}

void AMyPlayerController::HandleInputMethodChanged(ECommonInputType NewInputType)
{
    const bool bIsGamepad = (NewInputType == ECommonInputType::Gamepad);
    const bool bIsKBM = (NewInputType == ECommonInputType::MouseAndKeyboard);

    // 通知 UI 层刷新提示
    OnInputDeviceChanged.Broadcast(bIsGamepad);

    // 切换游戏内 IMC
    SwitchGameplayIMC(NewInputType);
}

这段代码故意拆成两层:UI 层用广播通知所有关心的控件,游戏内逻辑走 SwitchGameplayIMC。不要把 UI 刷新和 IMC 切换写在一个大函数里,否则后期加平台、加设备时,函数会膨胀到不敢改。

图标与提示自动切换:把图标当成数据而不是硬编码

图标自动切换的关键在于"图标跟着数据走,不跟着代码走"。小林早期的做法是蓝图里写死:如果当前是手柄,把 Image 的 Brush 换成 PS_Cross。后来 Xbox 版本一加,这些分支全要重写。更头疼的是 Steam Deck,它既是手柄输入,又带触屏,玩家可能两种设备交替使用,硬编码直接爆炸。

Common UI 的推荐做法是使用 CommonInputActionDataBase。它是一个 Data Asset,里面每一行对应一个 Input Action,列是不同输入类型下的显示数据。运行时 CommonInputActionWidget 根据当前 LastInputType 自动拉对应列的图标和文本。

sequenceDiagram
    participant Player as 玩家
    participant SubSys as CommonInputSubsystem
    participant Widget as CommonInputActionWidget
    participant DB as CommonInputActionDataBase
    participant HUD as 屏幕提示

    Player->>SubSys: 按下手柄 A 键
    SubSys->>SubSys: 更新 LastInputType = Gamepad
    SubSys->>Widget: 触发 OnInputMethodChanged
    Widget->>DB: 查询 IA_Interact / Gamepad
    DB-->>Widget: 返回 A 图标 + "交互"
    Widget->>HUD: 刷新 Brush 与文本

    Player->>SubSys: 切换回键鼠,按 E
    SubSys->>SubSys: 更新 LastInputType = MouseAndKeyboard
    SubSys->>Widget: 触发 OnInputMethodChanged
    Widget->>DB: 查询 IA_Interact / MouseAndKeyboard
    DB-->>Widget: 返回 E 图标 + "交互"
    Widget->>HUD: 刷新 Brush 与文本

除了静态图标,动态提示也要考虑。比如一个技能提示可能显示"按住 RholdR_{hold} 秒蓄力",这里的 RholdR_{hold} 来自技能配置,不是写死的 1.5。手柄版本要显示按住 RT,同时数字不变。把文本模板化,设备名用占位符替换,数字从数据表读,这样既支持多语言,也支持多设备。

图标资产组织上,建议按平台分目录:/Game/UI/InputIcons/Keyboard//Game/UI/InputIcons/Xbox//Game/UI/InputIcons/PS//Game/UI/InputIcons/Switch/。每个目录下再按 Action 命名,例如 IA_Interact.png。Common Input 的 Data Table 里引用这些纹理,而不是把纹理拖进控件蓝图。这样加 Switch 版本时,只需要新增一个图标目录和一张映射表,控件完全不用动。

键鼠与手柄 IMC 切换:不同设备用不同的 Mapping Context

Enhanced Input 的 Input Mapping Context(IMC)本来是按情境分的:探索情境一张表、战斗情境一张表、菜单情境一张表。到了跨设备切换这里,思路可以稍微扩展:同一张 Action 可以在不同 IMC 里绑定不同设备的键位,也可以为不同设备准备独立的 IMC。

小林团队的做法是维护三套 IMC:

  • IMC_Player_KBM:键盘鼠标下的移动、视角、战斗。
  • IMC_Player_Gamepad:手柄下的移动、视角、战斗。
  • IMC_UI_Navigation:菜单导航,键鼠和手柄共用,但键位不同。

LastInputType 变化时,把旧的设备 IMC 弹栈,新的压栈。注意不是把键鼠 IMC 和手柄 IMC 同时压进栈,否则一个 Action 可能同时被两个设备的键位触发,行为变得不可预测。

flowchart TD
    A[输入类型变化] --> B{当前激活的 IMC}
    B -->|KeyboardMouse| C[移除 IMC_Player_Gamepad]
    C --> D[压入 IMC_Player_KBM]
    D --> E[刷新 UI 提示]
    B -->|Gamepad| F[移除 IMC_Player_KBM]
    F --> G[压入 IMC_Player_Gamepad]
    G --> E
    B -->|Touch| H[移除 KBM/Gamepad IMC]
    H --> I[启用 Touch Interface]
    I --> E

这里有个细节:切换 IMC 时最好把当前正在触发的 Action 清掉。比如玩家一边按住手柄 RT 开火,一边触发了输入类型切换,如果不做清理,RT 的 Ongoing 事件可能卡在一个没有 Mapping 的状态里,导致开火动作停不下来。UE5.8 的 UEnhancedInputLocalPlayerSubsystem 提供了 RemoveMappingContext,移除时会自动清理该 Context 下的活跃 Action。保险起见,切换前先调用一次 ClearAllMappings,再把需要的 Context 加回来。

IMC 的优先级也要用心设计。假设菜单打开时压入了 IMC_UI_Navigation,优先级 10;游戏内 IMC_Player_Gamepad 优先级 5。这样菜单里的方向键和确认键会优先被 UI 吃掉,角色不会乱动。菜单关闭时弹出 IMC_UI_Navigation,游戏内 IMC 自动恢复。优先级的差值不用太大,够用就行,否则调试时很难看出谁在抢输入。

触屏输入简介:Steam Deck 和移动端的特殊考

掌机和移动平台让问题再复杂一层。Steam Deck 可以当成手柄玩,也可以触屏点 UI;手机和平板通常只有触屏。Common UI 对触屏的支持主要靠两件事:虚拟摇杆/按钮和控件点击区域。

虚拟摇杆用 TouchInterface 或者第三方的 VirtualJoystickRegion 实现。UE5 自带的 VirtualJoystick 在移动项目里默认开启,PC 上需要手动打开 Project Settings -> Engine -> Input -> Mobile -> Default Touch Interface。Steam Deck 虽然硬件上有摇杆,但有些玩家喜欢躺着用触屏玩,所以最好同时保留虚拟摇杆和物理手柄两种方案。

触屏与手柄的切换逻辑和键鼠手柄切换没有本质区别,还是看 LastInputType。真正麻烦的是同时存在。玩家在 Steam Deck 上可能左手拇指推物理摇杆,右手去点屏幕上的背包按钮。这时候输入类型到底算 Gamepad 还是 Touch?UE5.8 的策略是"最后一次有效输入说了算",但这会导致图标频繁闪烁。

一个实用的折中方案是:游戏内操作优先用手柄/键鼠映射,UI 操作允许触屏直接点击,触屏点击时不强制切换 LastInputType。只有当玩家使用触屏虚拟摇杆或虚拟按钮时,才把 LastInputType 切到 Touch。这个策略需要在 CommonGameViewportClient 里做一点定制,把触屏事件分成点击 UI 和虚拟摇杆两类处理。

UI 导航与游戏内输入分离:别让菜单抢走路

这是小林踩的第二个大坑。游戏运行时,角色移动和菜单导航都依赖方向输入,但二者不能同时生效。Common UI 的 Focus Manager 就是用来解决这个问题的。当菜单打开,调用 UCommonActivatableWidget::Activate(),它会自动把输入焦点切到 UI;关闭菜单时调用 Deactivate(),焦点还给游戏视口。

flowchart LR
    A[游戏进行中] --> B{菜单是否打开}
    B -->|否| C[输入交给 Enhanced Input
控制角色] B -->|是| D[输入交给 Focus Manager
导航 UI] D --> E[方向键 / 摇杆导航] D --> F[确认键触发按钮] D --> G[取消键关闭菜单] G --> A

分离的关键是两套系统不要互相监听。游戏逻辑只监听 Enhanced Input 的 Action 事件,UI 导航只走 Common UI 的导航事件。如果有一个蓝图里同时读了 IA_MoveUCommonButtonBase::OnClicked,等于把两条河接在了一起,菜单打开时角色会偷偷移动。

Focus 的管理还有一个容易忽略的点:子菜单的嵌套。打开背包,再点开物品详情,再点开分解确认框,每一层都应该是一个 CommonActivatableWidget。关闭最上层时,Focus 要回到上一层,而不是直接回到游戏。Common UI 的 Activatable Widget Stack 已经帮你做了这件事,前提是你没有用普通 UMG UserWidget 硬堆。

输入图标资产组织:让美术和策划都能看懂

图标资产数量会随着平台增加而爆炸。PC 键盘一套、Xbox 一套、PS 一套、Switch 一套,再加上 Steam Deck 的触摸提示,轻松几百张图。没有组织规范,后期找一张图标能浪费十分钟。

建议用 Data Table 做中心索引,而不是让控件直接引用纹理。表的结构大致如下:

InputActionKeyboardXboxPSSwitchTouch
IA_InteractEACrossBTap
IA_JumpSpaceACrossBTap
IA_FireLMBRTR2ZRTap

每一列引用对应平台的图标资产。新增平台时,加一列、补一套图,代码和控件几乎不用改。美术出图时按目录放,命名和 Data Table 行名保持一致,策划在编辑器里拖引用就能配完。

除了静态图标,颜色编码也能减少玩家的认知负担。比如所有确认相关的 Action 用绿色调图标,取消用红色调,菜单用蓝色调。这不是平台要求,但对手柄玩家特别友好,因为他们不像键鼠玩家那样随时盯着文字提示看。

图标数量 NiconsN_{icons} 和平台数 PP、Action 数 AA 的关系大致是:

NiconsP×A×kN_{icons} \approx P \times A \times k

kk 是每种 Action 每个平台需要的变体数,通常 1k31 \leq k \leq 3。比如一个按住提示可能需要普通、按下、禁用三张状态图。做计划时按这个公式估算美术工作量,比拍脑袋靠谱。

平台认证中的输入要求:主机版本不能绕过的 checklist

把游戏搬上主机,输入这块有硬门槛。索尼、微软、任天堂的认证文档里都会明确写:游戏必须支持对应手柄的全部基础按键,提示图标必须使用官方素材或获得授权的图标,菜单必须能用方向键和确认键完整操作,不能要求玩家必须使用键鼠。

小林团队第一次送审 PS5 时被打回来,原因听起来很傻:设置界面有一个滑块只能用鼠标拖动,手柄右摇杆移过去没反应。Common UI 的 Slider 默认支持手柄,但他们自己写了一个自定义 SSlider,忘了处理 Gamepad 输入。修复办法是给滑块加上方向键左右调整,并把增量调得足够明显,让玩家不用像微雕一样抠摇杆。

另一个常见 fail 是图标不一致。A 键在某些界面是确认,在另一些界面变成了取消;或者 Xbox 版本里混进了 PS 的 Cross 图标。认证人员会一个一个界面对。最稳妥的做法是在项目里建一份输入规范检查清单,每次打包前由 QA 对照所有界面过一遍。

Xbox 还有一条容易被忽略的要求:如果游戏检测到键鼠插入,必须允许玩家用键鼠继续操作,同时手柄输入也不能失效。也就是说键鼠和手柄在某种程度上要同时可用。这对 LastInputType 的切换逻辑提出了更高要求,不能简单地把一方完全禁用。Common UI 的 bAllowOutOfFocusDeviceInput 和相关设置可以帮忙,但具体行为要针对 Xbox 测一遍。

主机平台的按键命名也要留意。Xbox 是 A/B/X/Y,PS 是 Cross/Circle/Square/Triangle,Switch 是 A/B/X/Y 但布局与 Xbox 相反。如果你用通用名称比如确认键代替具体图标,本地化团队会感谢你,因为不同语言的按钮顺序可能不同。但 UI 上最终还是要显示设备图标,不能只有文字。

调试输入切换时的几个线索

输入切换出问题,排查思路可以按三层分开。

第一层看 LastInputType 到底变没变。在编辑器里开 stat CommonUI 相关命令,或者直接订阅 OnInputMethodChanged,把当前类型打印到屏幕。如果切了设备类型没变,说明事件没进 CommonInputSubsystem,检查 CommonGameViewportClient 是不是吞了输入。

第二层看图标换没换。如果类型变了但图标不变,先查 CommonInputActionDataBase 里有没有当前类型的条目,再查 CommonInputActionWidget 是否正确绑定了 Action。很多蓝图里控件绑的是 Key 而不是 Input Action,这样设备切换时自然不会刷新。

第三层看 IMC 栈。运行时用控制台命令 showdebug enhancedinput 可以看到当前激活的 Mapping Context 列表。如果菜单打开时角色还能移动,说明游戏内 IMC 的优先级高于或等于 UI IMC,或者菜单没有正确拿到 Focus。如果手柄和键鼠 IMC 同时存在,说明切换逻辑里漏了 Remove。

还有一个容易被忽略的细节是输入死区。手柄摇杆漂移会让引擎误判为持续输入,LastInputType 一直卡在 Gamepad 回不到键鼠。给摇杆加一个合适的 DeadZone,不仅手感更好,也能避免设备类型乱跳。死区值 DD 和摇杆物理精度 SS 之间大致满足:

D>3SD > 3S

SS 是手柄静止时摇杆读数的标准差。测一下硬件底噪,把死区设在三倍底噪以上,基本能杜绝漂移导致的误触发。

收尾:输入切换是一门关于语言的工夫

小林后来把输入系统重做了一遍。他并没有写什么高深算法,只是让设备类型、图标资产、IMC 三层各归其位。玩家插上手柄,屏幕出现手柄图标;拔出后按键盘,提示变回 E 键;打开菜单,角色停止移动;关闭菜单,摇杆立刻拿回控制权。

这套东西的价值不在代码复杂度,而在它让玩家感觉不到自己在切换输入设备。好的输入系统像一门通顺的翻译:玩家用自己的设备和游戏说话,游戏听得懂,也回得对。UE5.8 把 Common UI 和 Enhanced Input 整合得很紧,大部分功能开箱即用,但开箱不等于能直接发货。图标资产、IMC 优先级、Focus 管理、平台认证 checklist,这些细节才是决定你能不能顺利登陆多平台的关键。

如果项目计划同时上 PC 和主机,我建议从第一天就把输入切换纳入架构,而不是等移植阶段再补。补的成本通常是原生设计的三到五倍,而且会在 UI、动画、音效各个环节留下补丁。提前把输入当成多平台问题处理,后面每个平台的移植都会轻松很多。