UE5.8 多平台输入适配

引子:一份移植需求

老陈的项目《碎星》在 Steam 上卖了四个月,评论里最常见的一句话是:什么时候上掌机和主机。发行上周把合同拍在桌上,要求三个月内完成 PlayStation 5、Xbox Series X|S、Switch 2 和 iOS 移植。老陈盯着合同看了半天,意识到最大的坑不是画面降级,而是输入。

PC 版一切围着键鼠设计。按住 W 冲刺,鼠标控制视角,数字键切枪,R 换弹。测试用 Xbox 手柄跑过几轮,但那是临时加的映射,UI 提示还是键盘图标。现在要把它改成一套能在沙发上用手柄玩、在地铁上用手机玩、在掌机上用陀螺仪瞄准的系统,输入层必须重新设计。

更麻烦的是平台认证。索尼和微软对输入有一堆硬性规定:游戏必须全程支持手柄操作,菜单不能用鼠标点击才能触发,按钮图标必须和平台一致,PS5 还要用 DualSense 的自适应扳机。老陈把团队叫进会议室,白板上画了四个圈:键鼠、手柄、触屏、体感。问题很直接:怎么让这四套东西在同一个项目里共存,而不把 Pawn 代码搞成蜘蛛网。

抽象层:把硬件事件变成意图

输入适配的第一步,是让游戏逻辑不再关心具体按键。UE5.8 的 Enhanced Input 已经做了这件事:Input Action 描述意图,Input Mapping Context 描述当前上下文下哪个硬件键对应这个意图。

老陈的做法是按平台拆 IMC。基础移动和射击这类核心 Action 不变,每个平台单独维护一份映射表。

  • IMC_PC_Default:WASD 移动、鼠标视角、空格跳跃、左键开火。
  • IMC_Gamepad_Default:左摇杆移动、右摇杆视角、A 跳跃、右扳机开火。
  • IMC_Mobile_Default:屏幕左侧虚拟摇杆移动、右侧滑动视角、UI 按钮跳跃和开火。

运行时根据平台加入对应的 IMC。Pawn 里的回调只读 IA_MoveIA_LookIA_JumpIA_Fire,从不问这些值从哪来。

输入数值在到达回调前还要经过 Modifier 处理。不同平台的 Modifier 也不一样。手柄左摇杆有机械死区,不加 DeadZone 角色会自己慢慢走。手机触屏的灵敏度需要比鼠标低,否则轻轻一动准星就飞出去。老陈给每个 IMC 配了不同的 Scalar 和 DeadZone。

死区处理可以用下面这个公式表示:

vout=max(0,vind)1dsgn(vin)v_{out} = \frac{\max(0, |v_{in}| - d)}{1 - d} \cdot \text{sgn}(v_{in})

其中 vinv_{in} 是原始轴值,dd 是死区阈值,voutv_{out} 是输出到移动逻辑的值。dd 在键鼠里可以设为 00,在手柄里通常取 0.080.080.150.15,在手机虚拟摇杆里可能更高,因为拇指复位时的抖动比摇杆大。

这个分层架构画出来大概是这样:

architecture-beta
    group hardware(cloud)[硬件输入]
    group engine(server)[引擎抽象层]
    group game(database)[游戏逻辑]
    service keyboard(server)[键鼠]
    service gamepad(server)[手柄]
    service touch(server)[触屏]
    service motion(server)[体感]
    service platform(server)[平台检测]
    service imc(database)[Input Mapping Context]
    service action(database)[Input Action]
    service modifier(database)[Modifier / Trigger]
    service pawn(database)[Pawn / Character]
    keyboard:R -- L:platform
    gamepad:R -- L:platform
    touch:R -- L:platform
    motion:R -- L:platform
    platform:R -- L:imc
    imc:R -- L:action
    action:R -- L:modifier
    modifier:R -- L:pawn

Common UI 输入映射与平台检测

Gameplay 输入只是问题的一半。另一半是 UI。菜单、背包、地图、设置这些界面在 PC 上可以用鼠标点,在主机上必须用手柄方向键和确认取消键导航。UE5.8 的 Common UI 提供了一套输入抽象层,把导航和确认从具体按键里解出来。

Common UI 的核心资产是 Common Input Action Data。每个数据资产定义一个抽象的 UI 操作,比如 ConfirmCancelTabNextTabPrevious。这些数据资产再绑定到不同平台的实际按键。PC 上 Confirm 是鼠标左键和回车,主机上是手柄 A 键,掌机上可能是触屏点击。

平台检测在 UCommonInputSubsystem 里完成。启动时它会根据当前运行的平台设置一个 ECommonInputType,例如 MouseAndKeyboard、Gamepad、Touch。这个类型会影响 Common UI 的输入路由和默认焦点行为。

老陈在 GameInstance 初始化阶段就请求当前输入类型:

UCommonInputSubsystem* InputSubsystem = UCommonInputSubsystem::GetFromLocalPlayer(LocalPlayer);
ECommonInputType CurrentType = InputSubsystem->GetCurrentInputType();

拿到类型后,选择对应的 IMC 加入 Enhanced Input Local Player Subsystem:

UEnhancedInputLocalPlayerSubsystem* EIS = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(LocalPlayer);
if (CurrentType == ECommonInputType::Gamepad)
{
    EIS->AddMappingContext(GamepadIMC, 0);
}
else if (CurrentType == ECommonInputType::Touch)
{
    EIS->AddMappingContext(MobileIMC, 0);
}
else
{
    EIS->AddMappingContext(PCIMC, 0);
}

选择流程本身也可以画成一张图:

flowchart TD
    A[游戏启动] --> B{当前平台}
    B -->|PC / Mac| C[加入 PCIMC]
    B -->|PS5 / Xbox / Switch| D[加入 GamepadIMC]
    B -->|iOS / Android| E[加入 MobileIMC]
    C --> F[设置 CommonInputType 为 MouseAndKeyboard]
    D --> G[设置 CommonInputType 为 Gamepad]
    E --> H[设置 CommonInputType 为 Touch]
    F --> I[UI 显示键鼠图标]
    G --> I
    H --> I
    I --> J[玩家开始操作]

这里有个细节:平台检测不等于输入设备检测。启动在 PC 上时,CommonInputType 默认是 MouseAndKeyboard,但玩家可能插上手柄。下一节专门处理这种切换。

输入设备切换事件

玩家会在游戏过程中换设备。PC 玩家放下鼠标拿起手柄,主机玩家把手柄放在桌上用手机串流,掌机玩家从底座上拿起来切换成触屏。系统必须感知到这种变化,否则 UI 还在显示空格键提示,玩家却已经按了手柄 A 键。

UE5.8 的 UCommonInputSubsystem 提供了 OnInputMethodChanged 委托。当最后输入事件的来源变化时,这个委托会广播新的输入类型。老陈在 PlayerController 里监听这个事件,收到后做三件事。

第一件事是切换 IMC。把旧平台的 IMC 移除,加入新平台的 IMC。如果优先级设计得当,两个 IMC 甚至可以短暂共存,避免切换过程中输入丢失。

第二件事是更新 UI 图标。Common UI 的 CommonTextBlockCommonButton 会根据当前输入类型自动换图标,前提是图标资产已经按平台配置好。

第三件事是处理焦点。手柄切键鼠时,应该把虚拟焦点隐藏;键鼠切手柄时,要把焦点放到当前菜单的第一个可交互元素上,否则玩家不知道自己在哪。

下面是一个简化的热插拔处理流程:

sequenceDiagram
    participant P as 玩家
    participant D as 输入设备
    participant EIS as Enhanced Input Subsystem
    participant CIS as Common Input Subsystem
    participant UI as Common UI
    P->>D: 按下手柄 A 键
    D->>EIS: 手柄事件
    EIS->>CIS: 报告输入源变化
    CIS->>CIS: 更新 CurrentInputType
    CIS->>EIS: 触发 OnInputMethodChanged
    EIS->>EIS: 移除 PCIMC,加入 GamepadIMC
    CIS->>UI: 通知刷新输入图标
    UI->>UI: 按钮提示从 Enter 换成 A
    CIS->>UI: 设置默认焦点到首个按钮

实际代码里老陈把 IMC 切换写成一个集中函数。所有入口都调用它,避免分散在 PlayerController、Settings Widget、Possess 事件各处。切换时还要记录玩家偏好,下次启动优先用上次选择的输入类型。

UI 导航与游戏内输入的分离

菜单打开时,玩家按方向键应该移动菜单焦点,而不是角色。这个需求看似简单,但很容易在边界情况出错。老陈见过最诡异的 bug:打开背包时角色还在原地转圈,因为视角输入没有被 UI 吃掉。

UE5.8 推荐的做法是给 UI 单独建一个 IMC,例如 IMC_UI_Navigation,优先级高于 gameplay IMC。打开菜单时加入这个 IMC,关闭时移除。高优先级会让 UI 相关的 Action 先处理,低优先级的移动和视角 Action 被阻塞。

除了 IMC 优先级,还要配合 Set Input Mode 系列函数。打开菜单时调用 SetInputModeUIOnlySetInputModeGameAndUI,关闭菜单时切回 SetInputModeGameOnly。这个函数决定鼠标能不能点击 UI、键盘事件走 UI 还是 gameplay。

老陈把菜单状态做成了枚举:

  • Gameplay:只有 gameplay IMC 激活。
  • ModalUI:UI IMC 激活,gameplay IMC 保留但低优先级,背景音乐和动画继续,角色不接收输入。
  • FullScreenUI:UI IMC 激活,gameplay IMC 完全移除,常见于主菜单和暂停菜单。

输入路由可以用这张图表示:

graph LR
    A[硬件事件] --> B{当前菜单状态}
    B -->|Gameplay| C[Gameplay IMC]
    B -->|ModalUI| D[UI IMC 高优先级]
    D --> E[Common UI 导航]
    C --> F[Pawn 回调]
    E --> G[按钮焦点 / 确认取消]
    B -->|FullScreenUI| H[仅 UI IMC]
    H --> E

ModalUI 和 FullScreenUI 的区别很重要。如果是背包这种半透明覆盖层,玩家可能还想看到角色和世界,此时保留 gameplay IMC 但压低优先级更自然。如果是暂停菜单,直接移除 gameplay IMC 更安全。

平台专属输入

主机平台有不少 PC 上没有的输入能力。PS5 的 DualSense 手柄支持自适应扳机和触觉反馈,Switch 有 HD 震动和体感,iOS 有触屏手势和陀螺仪。这些能力不能直接移植到 PC 键鼠上,但如果完全不用,平台方会认为游戏没有充分利用硬件特性。

老陈给每种平台专属能力建了插件隔离层。核心逻辑里只调用抽象接口,具体实现放在平台插件里。这样 PC 构建时不会链接 PS5 SDK,PS5 构建时又能启用全部特性。

以 DualSense 自适应扳机为例。拉弓射箭时,扳机阻力应该随着拉弓时间增加。开火时,半自动武器的扳机应该有点击感。UE5.8 通过 UDualSensePlatformInputDeviceMapperIPlatformInputDeviceMapper 暴露这些接口。

老陈在 Pawn 里定义了一个 IWeaponHaptics 接口:

UFUNCTION(BlueprintNativeEvent, Category = "Input")
void SetTriggerResistance(float StartPosition, float Force);

PS5 平台插件实现这个接口时调用 DualSense SDK。其他平台实现为空函数。游戏逻辑里只在需要时调用接口,不关心底下有没有手柄。

陀螺仪瞄准的处理思路类似。Switch 和 PS5 手柄都有六轴陀螺仪,玩家可以倾斜手柄微调准星。老陈把陀螺仪输出映射到一个额外的 Axis2D Action IA_GyroAim,再叠加到右摇杆的视角输入上。

叠加公式可以写成:

vaim=sstickvstick+sgyrovgyro\vec{v}_{aim} = s_{stick} \cdot \vec{v}_{stick} + s_{gyro} \cdot \vec{v}_{gyro}

ssticks_{stick} 是右摇杆灵敏度系数,sgyros_{gyro} 是陀螺仪灵敏度系数。两个系数都可以进设置菜单让玩家调整。主机玩家通常喜欢把 ssticks_{stick} 调低、sgyros_{gyro} 调高,用大范围移动靠摇杆,精细瞄准靠体感。

掌机模式下,陀螺仪还能用来检测设备姿态。比如把 Switch 从 TV 模式拆下来,自动把输入方案从 Pro 手柄切换成掌机按键加触屏。

触摸输入与虚拟摇杆

手机端输入是最特殊的一类。屏幕没有实体按键,所有操作都要靠虚拟控件。虚拟摇杆、按钮、滑动区域会挡住画面,布局必须在不同屏幕比例下都能用。

老陈用 Common UI 的 CommonActivatableWidget 做触摸 HUD。虚拟摇杆不是一个简单的贴图,而是一个接收触摸事件的 Widget。UE5.8 在 UCommonInputSubsystem 里对触控做了增强,可以识别多指手势,比如双指缩放地图、双指旋转视角。

虚拟摇杆的实现要点有几个。

第一,触摸事件要区分开始、移动、结束。手指按下时记录摇杆中心,移动时计算偏移向量,抬起时归零。这个偏移向量经过死区和缩放后,作为 IA_Move 的 Axis2D 值。

第二,摇杆区域不能太小。拇指在紧张时会按偏,如果触摸检测区只有贴图大小,玩家会频繁脱手。老陈把实际检测区做到贴图的两倍,只在视觉上显示小圆。

第三,多指操作要防冲突。左手拇指控制移动摇杆,右手拇指控制视角滑动,右手食指可能按开火按钮。系统要根据触摸位置把每个手指分配到对应的控件,避免摇杆和视角滑动抢同一个触摸索引。

虚拟摇杆输出可以建模为:

vmove=pfingerpcenterrmaxstouch\vec{v}_{move} = \frac{\vec{p}_{finger} - \vec{p}_{center}}{r_{max}} \cdot s_{touch}

pfinger\vec{p}_{finger} 是手指当前位置,pcenter\vec{p}_{center} 是摇杆中心,rmaxr_{max} 是摇杆最大半径,stouchs_{touch} 是触屏灵敏度缩放。结果还要再经过死区处理,否则手指轻微抖动会让角色移动。

老陈在 iPad 上测试时发现,虚拟摇杆和物理摇杆的手感差距比想象中大。物理摇杆回弹有触觉反馈,虚拟摇杆没有。于是他给摇杆中心加了轻微震动,手指按下时触发一次短震动,让玩家感知到摇杆已经激活。

输入图标资产组织

同一个按钮在不同平台显示不同图标。PC 上显示空格键图标,PS5 上显示方块,Xbox 上显示 X,Switch 上显示 Y。如果这些图标散落在项目各处,换平台构建时很容易漏改。

UE5.8 的 Common UI 提供了 CommonInputActionDataBase 和图标绑定机制。每个抽象的输入操作可以关联一组平台相关图标。Common UI 在渲染按钮提示时,会根据当前 ECommonInputType 自动挑选对应图标。

老陈的图标资产按目录组织:

/Content/UI/InputIcons/
    Keyboard/
        Spacebar.png
        Enter.png
        LMB.png
    Gamepad_DualShock/
        Cross.png
        Circle.png
        Square.png
        Triangle.png
    Gamepad_Xbox/
        A.png
        B.png
        X.png
        Y.png
    Gamepad_Switch/
        A.png
        B.png
        X.png
        Y.png
    Touch/
        Tap.png
        VirtualStick.png

Common UI 的 CommonTextBlock 支持富文本标签,可以在文本里插入输入图标。例如 "按 [IA_Confirm] 确认",运行时 [IA_Confirm] 会被替换成当前平台的确认键图标。

老陈还在设置菜单里加了一个图标预览区。玩家切换输入类型时,预览区会刷新所有常用操作的图标。这个功能帮助测试人员快速发现某个平台下图标缺失的问题。

图标查询可以简化成下面这段代码:

UCommonInputSubsystem* Subsystem = UCommonInputSubsystem::GetFromLocalPlayer(LocalPlayer);
ECommonInputType Type = Subsystem->GetCurrentInputType();

const FSlateBrush* IconBrush = nullptr;
if (UCommonInputBase* InputSettings = UCommonInputBase::GetInputSettings())
{
    IconBrush = InputSettings->GetIconForInputAction(Type, ConfirmActionData);
}

代码很短,但前提是图标资产已经在 UCommonInputBase 的配置里按平台绑定好。否则返回空,UI 上只会显示文字。

平台认证中的输入要求

多平台移植绕不开平台方的认证。索尼、微软、任天堂对输入都有详细要求,测试人员会拿着 checklist 逐条核对。老陈把常见要求列了一份备忘录,每次打包前逐条检查。

索尼 PS5 的要求包括:

  • 游戏必须全程可用 DualSense 手柄操作,不能有必须用键鼠或触屏才能完成的环节。
  • 菜单导航要支持方向键和摇杆,确认键是 Cross,取消键是 Circle。日区版本可以反转,但必须跟随系统设置。
  • 自适应扳机和触觉反馈至少要在核心玩法里有所体现,不能完全没有。
  • PS 按钮、Share 按钮、触摸板有系统级保留行为,游戏不能占用。

微软 Xbox 的要求类似:

  • 必须支持 Xbox 无线手柄的全部基础按键。
  • 游戏过程中拔掉手柄再插回,不能导致输入失效或崩溃。
  • 菜单可以用方向键导航,也可以用左摇杆导航。
  • 系统菜单弹出的同时游戏要暂停,返回后输入要恢复正常。

任天堂 Switch 2 额外要求:

  • TV 模式、桌面模式、掌机模式下的按键布局都要测试。
  • 拆卸 Joy-Con 后,每个 Joy-Con 单独使用时的按键映射要合理。
  • 体感瞄准不能成为必需操作,必须可以用摇杆完成同样目标。

移动端虽然认证不如主机严格,但 Apple 和 Google 也有指南。比如 iOS 游戏如果支持手柄,必须正确显示 MFi 手柄图标;安卓游戏要适配返回键和主页键,不能拦截系统手势。

老陈把这些要求整理成测试用例,每次构建后让 QA 按清单跑。最容易出问题的是拔插手柄和输入法切换。PC 上拔掉手柄再插回,Enhanced Input 通常能自动恢复,但如果 PlayerController 里缓存了旧的输入设备指针,就会收不到事件。老陈在 OnInputDeviceConnectionChange 事件里做了清理和重新绑定。

几个容易踩的坑

多平台输入适配里有些问题不是架构设计能完全避免的,老陈在测试阶段挨个撞上了一遍。

第一个坑是 IMC 切换时的 Completed 事件。移除一个 IMC 时,正在触发的 Action 会收到 Canceled 或 Completed。如果回调里把 Completed 当成「玩家主动松开」处理,可能会在切换输入方案时误触发一次技能。老陈的做法是给 Canceled 单独处理,或者在切换 IMC 前先把相关 Action 手动置零。

第二个坑是鼠标可见性和输入模式的绑定。打开菜单时把输入模式设成 UIOnly,鼠标出现了,但关闭菜单后忘了设回 GameOnly,结果玩家能看到鼠标在屏幕中央晃,视角却转不动。老陈把所有切换输入模式的地方集中到两个函数:EnterMenuStateLeaveMenuState,避免遗漏。

第三个坑是触屏 UI 的点击穿透。玩家点击屏幕上的按钮,事件被按钮吃掉,但如果按钮下面还有一个接收滑动的区域,滑动事件可能会继续往下传,导致角色在按按钮时意外移动。老陈给所有按钮加了 FReply::Handled(),并在 Common UI 的输入路由里开启了事件消费。

第四个坑是图标字体 fallback。PC 上显示键鼠图标用的是一套字体,主机上用的是另一套。如果某个图标在目标平台字体里缺失,Common UI 不会报错,只会显示一个空白方块。老陈做了一张图标对照表,每个抽象操作在每个平台都要有对应图标,构建前跑脚本检查缺失。

结语

老陈用六周时间把《碎星》的输入层重新梳理了一遍。键鼠、手柄、触屏、体感被拆成独立的 IMC,Pawn 代码只读抽象 Action。Common UI 接管了菜单导航和图标显示,输入设备热插拔有统一事件处理。DualSense 自适应扳机和陀螺仪通过平台插件接入,不影响 PC 构建。

移植后的第一次全平台测试,QA 在 PS5 上用手柄通关第一章,在 iPhone 上用虚拟摇杆打完新手教程,在 PC 上插拔手柄三次没有掉输入。老陈在白板上把四个圈连在了一起,中间写上了一个词:意图。玩家按什么键不重要,重要的是游戏能不能正确理解玩家想做什么。

UE5.8 在多平台输入上给了不少现成工具,但最终还是要项目自己定好分层。IMC 按平台拆分、UI 和 gameplay 输入分离、图标资产集中管理、认证 checklist 提前准备,这些工作做在前面,后面修 bug 的时间会少很多。