UE5 Lyra Input 模块:从手柄摇杆到技能释放,一条链路如何做到零耦合

Lyra Input 模块:从手柄摇杆到技能释放,一条链路如何做到零耦合

写在前面:输入系统到底在解决什么问题?

不知道大家有没有留意过一件事------玩家按下键盘上的 W,角色往前走;扣下扳机键,子弹飞出去。在玩家眼里,这一切理所当然。但在引擎底层,"按键 → 角色行为"这段路其实走了很远。

如果只是简单地把按键直接绑定到函数调用------"W 键按下就调用 MoveForward"------那做到一半就会撞上几面墙:手柄怎么办?灵敏度怎么调?键位改了逻辑要不要跟着改?技能系统和输入怎么对接?

这些问题,随便一个处理不好,不是策划天天来找大家改代码,就是玩家在设置界面里找不到想要的那个选项。

Lyra 在 Input 模块里给出的方案,本质上是用 三层解耦 把"物理按键 → 游戏逻辑"这条链路切成几段,每一段之间有明确的接口,谁也不用知道对方内部在干什么。听起来有点抽象?没关系,下面从整体架构开始逐层拆开看。


1. 先看一眼全局:Lyra 是怎么搭输入这座桥的

在深入具体代码之前,先对 Input 模块的整体轮廓有个把握。Lyra 没有重新发明轮子------它站在 UE 的 Enhanced Input 系统之上,往上铺了一层自己的抽象。

1.1 模块全景图

复制代码
┌──────────────────────────────────────────────────────────────────────┐
│                          硬件层 (键盘 / 手柄 / 鼠标)                    │
└───────────────────────────────┬──────────────────────────────────────┘
                                │
                                ▼
┌──────────────────────────────────────────────────────────────────────┐
│            UEnhancedInputLocalPlayerSubsystem (引擎层)                │
│  ┌────────────────────┐  ┌────────────────────┐                      │
│  │  Input Mapping     │  │  Input Triggers    │                      │
│  │  Context           │  │  (按下/按住/轻按)   │                      │
│  └────────┬───────────┘  └────────┬───────────┘                      │
│           │                       │                                   │
│           └───────────┬───────────┘                                   │
│                       ▼                                               │
│  ┌─────────────────────────────────────────┐                        │
│  │       ULyraInputModifier 管道            │  ← 灵敏度/死区/反转     │
│  │  Scalar → DeadZone → Sensitivity → Invert│                        │
│  └─────────────────┬───────────────────────┘                        │
└────────────────────┼────────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────────────────────────┐
│                     ULyraInputComponent (Lyra层)                      │
│  ┌─────────────────────────┐  ┌─────────────────────────────┐       │
│  │  BindNativeAction()     │  │  BindAbilityActions()       │       │
│  │  W→移动, 鼠标→视角      │  │  左键→射击, 空格→跳跃       │       │
│  └───────────┬─────────────┘  └──────────────┬──────────────┘       │
└──────────────┼────────────────────────────────┼──────────────────────┘
               │                                │
               ▼                                ▼
     ┌─────────────────┐            ┌─────────────────────┐
     │  Pawn/Character │            │  AbilitySystem      │
     │  原生C++回调     │            │  Component          │
     │  Move/Look      │            │  通过 InputTag 路由   │
     └─────────────────┘            └─────────────────────┘

只要抓住一条主线就够了:硬件信号 → Input Modifier 加工 → InputComponent 分发 → 原生逻辑 或 技能系统

1.2 Input 模块的文件清单

整个模块 6 组文件、12 个源文件,每一组各管一摊事:

文件组 核心职责 一句话概括
LyraInputConfig 输入配置数据资产 "哪些按键算原生输入,哪些按键要转发给技能系统"
LyraInputComponent 输入组件 EnhancedInput 的子类,负责把键位绑定到回调
LyraInputModifiers 输入修饰器(4个) 灵敏度缩放、死区过滤、瞄准反转------在数据落到逻辑层之前先"预处理"一遍
LyraAimSensitivityData 灵敏度等级数据 一张"Slow→0.5, Normal→1.0, Insane→2.5"的表
LyraInputUserSettings 用户输入设置 存储玩家的自定义按键、灵敏度等偏好
LyraPlayerMappableKeyProfile 按键映射配置 多套按键方案(比如默认/自定义)的切换

1.3 依赖全景:谁依赖谁

搞清楚依赖关系,比看一堆代码注释有用得多。下面是继承链和调用链:

复制代码
UDataAsset
  └── ULyraInputConfig (输入配置表,纯数据)

UEnhancedInputComponent
  └── ULyraInputComponent (增强的输入组件,负责绑定与分发)

UInputModifier
  ├── ULyraSettingBasedScalar        (设置驱动的标量缩放)
  ├── ULyraInputModifierDeadZone     (设置驱动的死区)
  ├── ULyraInputModifierGamepadSensitivity (手柄灵敏度)
  └── ULyraInputModifierAimInversion (瞄准反转)

UPrimaryDataAsset
  └── ULyraAimSensitivityData (灵敏度映射表)

UEnhancedInputUserSettings
  └── ULyraInputUserSettings (用户级输入设置)

UEnhancedPlayerMappableKeyProfile
  └── ULyraPlayerMappableKeyProfile (按键配置方案)

UPlayerMappableKeySettings
  └── ULyraPlayerMappableKeySettings (单个按键的元数据)

可以看到 Lyra 没有自己发明基类,所有自定义类全部挂在 Enhanced Input 体系提供的扩展点上。这种设计的好处很直观:引擎版本升级时 Enhanced Input 的改进会被自动继承,兼容性成本极低。


2. 第一根柱子:LyraInputConfig ------ 用 DataAsset 把输入和逻辑解耦

这可能是整个输入模块里最"不起眼"却最关键的一个类。它的核心理念一句话就能讲清楚:策划不应该跑来问"W 键对应哪个函数",同理,程序员也不应该硬编码"射击一定是鼠标左键"。

2.1 把输入分成两路:Native 和 Ability

LyraInputConfig 用一个简单的结构体 FLyraInputAction 把每个输入拆成两部分:一个 UInputAction 对象(定义按键/手柄映射)和一个 FGameplayTag(定义逻辑标签):

cpp 复制代码
// LyraInputConfig.h - FLyraInputAction
USTRUCT(BlueprintType)
struct FLyraInputAction
{
    GENERATED_BODY()

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
    TObjectPtr<const UInputAction> InputAction = nullptr;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Meta = (Categories = "InputTag"))
    FGameplayTag InputTag;
};

然后在 ULyraInputConfig 里用两个数组分别装 Native 和 Ability 的输入:

cpp 复制代码
// LyraInputConfig.h - 两路输入
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FLyraInputAction> NativeInputActions;    // 移动、视角等------手动绑定

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FLyraInputAction> AbilityInputActions;   // 射击、跳跃等------自动批量绑定到GAS

为什么拆成两路?因为这两类输入的生命周期完全不同:

分类 绑定方式 数量 变动频率 绑定时机
NativeInputActions 逐个手动绑定 少(3~5个) 几乎不变 Pawn 初始化时
AbilityInputActions 遍历数组批量绑定 多(10~30个) 随技能配置变化 Pawn 初始化时

Native 输入(移动、视角)是每个角色都一样的,手动绑定一次即可。Ability 输入则取决于角色当前装备了哪些技能------一个拿着步枪的角色和开着坦克的角色,能用的技能完全不同。所以 Ability 这边要走"自动批量绑定"路线:遍历配置数组,每个都绑上,后期增删技能只需要改数据资产。

2.2 Tag → Action 的查找:简单但有效

查询方法写得非常直白------遍历数组找匹配的 Tag:

cpp 复制代码
// LyraInputConfig.cpp - 根据Tag查找InputAction
const UInputAction* ULyraInputConfig::FindNativeInputActionForTag(
    const FGameplayTag& InputTag, bool bLogNotFound) const
{
    for (const FLyraInputAction& Action : NativeInputActions)
    {
        if (Action.InputAction && (Action.InputTag == InputTag))
        {
            return Action.InputAction;
        }
    }

    if (bLogNotFound)
    {
        UE_LOG(LogLyra, Error,
            TEXT("Can't find NativeInputAction for InputTag [%s] on InputConfig [%s]."),
            *InputTag.ToString(), *GetNameSafe(this));
    }

    return nullptr;
}

这里有两个值得注意的细节:

  • 参数 bLogNotFound 默认是 true :开发阶段丢了配置会立刻打 Error 日志,逼迫修复;但运行时(比如从网络同步过来一个当前角色不认识的 Tag),可以传 false 悄悄跳过。
  • 线性遍历而非 TMap :Native 数组通常只有个位数,O(n) 已经足够。但对于 Ability 那边可能有几十上百项的场景,Epic 也没有用 Map,说明他们认为这个规模下遍历开销可以忽略。如果大家项目里的输入项特别多(比如有 200+ 个技能输入),可以考虑在初始化时构建一个 TMap<FGameplayTag, UInputAction*> 缓存。

2.3 在编辑器里的样子

策划或设计师在编辑器里看到的是这样的:

  • 一个 ULyraInputConfig 资产,里面分两栏:NativeInputActions 和 AbilityInputActions
  • 每个条目是一个 "InputAction + GameplayTag" 的组合
  • Meta = (TitleProperty = "InputAction") 让每个条目在列表里直接显示 InputAction 的名字,而不是默认的索引号

这套设计让"增加新技能输入"这件事彻底脱离了 C++ 编译------只需要在 Content Browser 里修改 DataAsset 就行了。


3. 第二根柱子:LyraInputComponent ------ 模板方法让绑定又安全又简洁

有了 InputConfig 定好"哪些键对应哪些 Tag",下一步就是把这些映射实际绑定到函数上。LyraInputComponent 就是干这个的。

3.1 为什么要自己写一个 InputComponent?

UE 的 UEnhancedInputComponent 已经提供了 BindAction 方法,功能足够。Lyra 在上面包了一层,不是为了炫技,而是解决两个实际问题:

  1. 不想让绑定代码到处散落 。每个 Pawn 子类如果各自手动写 BindAction(IA_Move, ETriggerEvent::Triggered, this, &AMyPawn::Move),等项目有五个 Pawn 子类就变成维护噩梦。
  2. Native 和 Ability 的绑定模式完全不同。Native 是按 Tag 单个查找 → 绑定;Ability 是把整个 AbilityInputActions 数组一股脑遍历绑定。与其让调用方每次写循环,不如把循环封装成模板方法。

3.2 BindNativeAction:按 Tag 查一个,绑定

cpp 复制代码
// LyraInputComponent.h - 单个原生输入绑定(模板方法)
template<class UserClass, typename FuncType>
void ULyraInputComponent::BindNativeAction(
    const ULyraInputConfig* InputConfig,
    const FGameplayTag& InputTag,
    ETriggerEvent TriggerEvent,
    UserClass* Object,
    FuncType Func,
    bool bLogIfNotFound)
{
    check(InputConfig);
    if (const UInputAction* IA = InputConfig->FindNativeInputActionForTag(InputTag, bLogIfNotFound))
    {
        BindAction(IA, TriggerEvent, Object, Func);
    }
}

这个方法做的事情:InputTag → 查 InputConfig → 找到对应的 UInputAction → 调用父类的 BindAction。代码量不到十行,但它把"查表"和"绑定"两个动作合并了。调用方只需要知道 InputTag,不需要知道 InputAction 的具体引用。

实际在 LyraCharacter 里的调用大概是这样的:

cpp 复制代码
// 在 SetupPlayerInputComponent 中
LyraInput->BindNativeAction(InputConfig, LyraGameplayTags::InputTag_Move,
    ETriggerEvent::Triggered, this, &ThisClass::Input_Move);
LyraInput->BindNativeAction(InputConfig, LyraGameplayTags::InputTag_Look,
    ETriggerEvent::Triggered, this, &ThisClass::Input_Look);

3.3 BindAbilityActions:遍历整组,批量绑定

Ability 这边就不一样了------可能有十几个技能,逐个写绑定不现实。所以 BindAbilityActions 的设计是"一口气全绑好":

cpp 复制代码
// LyraInputComponent.h - 批量绑定能力输入(模板方法)
template<class UserClass, typename PressedFuncType, typename ReleasedFuncType>
void ULyraInputComponent::BindAbilityActions(
    const ULyraInputConfig* InputConfig,
    UserClass* Object,
    PressedFuncType PressedFunc,
    ReleasedFuncType ReleasedFunc,
    TArray<uint32>& BindHandles)
{
    check(InputConfig);

    for (const FLyraInputAction& Action : InputConfig->AbilityInputActions)
    {
        if (Action.InputAction && Action.InputTag.IsValid())
        {
            if (PressedFunc)
            {
                BindHandles.Add(BindAction(Action.InputAction,
                    ETriggerEvent::Triggered, Object, PressedFunc, Action.InputTag).GetHandle());
            }

            if (ReleasedFunc)
            {
                BindHandles.Add(BindAction(Action.InputAction,
                    ETriggerEvent::Completed, Object, ReleasedFunc, Action.InputTag).GetHandle());
            }
        }
    }
}

这里有几个值得拆开说的设计点:

为什么一个 Action 要绑两次?

每次绑定都对应一个 ETriggerEvent

  • Triggered:按键"按下并满足触发条件"的时刻(用于激活技能)
  • Completed:按键"松开"的时刻(用于结束技能或切换状态)

这是 Enhanced Input 的标准用法------把"按下"和"松开"当成两个独立事件,分别路由到不同的回调。Lyra 利用这个机制,按下时调用 AbilityInputTagPressed(InputTag) 尝试激活技能,松开时调用 AbilityInputTagReleased(InputTag) 通知技能系统按键已释放。

Action.InputTag 作为用户数据传入

BindAction 的第五个参数是一个可选的用户数据,在 Lyra 里被用来传递 FGameplayTag

cpp 复制代码
BindAction(Action.InputAction, ETriggerEvent::Triggered, Object, PressedFunc, Action.InputTag)

这意味着回调函数 PressedFunc 的签名必须是 void(FGameplayTag)------它接收到的不只是"有人按了某个键",而是精确地知道了"按下的是什么 Tag"。这个 Tag 随后被传入 AbilitySystemComponent,直接用来查找对应技能并尝试激活。

返回的 BindHandles 数组用于后续清理

每个 BindAction 返回一个 FInputBindingHandle,调用 GetHandle() 拿到 uint32 句柄。所有句柄被收集到 TArray<uint32>& BindHandles 里。当 Pawn 被销毁或技能配置变更时,调用 RemoveBinds(BindHandles) 可以一次性解绑全部:

cpp 复制代码
void ULyraInputComponent::RemoveBinds(TArray<uint32>& BindHandles)
{
    for (uint32 Handle : BindHandles)
    {
        RemoveBindingByHandle(Handle);
    }
    BindHandles.Reset();
}

这比逐个手动 RemoveBinding 要安全得多------不会因为漏掉某个绑定而导致幽灵回调(Pawn 已销毁但输入仍在尝试触发)。

3.4 AddInputMappings / RemoveInputMappings:预留的扩展口

这两个方法的实现体目前是空的,只有 check(InputConfig)check(InputSubsystem)

cpp 复制代码
void ULyraInputComponent::AddInputMappings(
    const ULyraInputConfig* InputConfig,
    UEnhancedInputLocalPlayerSubsystem* InputSubsystem) const
{
    check(InputConfig);
    check(InputSubsystem);
    // Here you can handle any custom logic to add something from your input config if required
}

这不是 bug,是预留的扩展点。如果大家需要在 InputComponent 层面做额外的映射管理(比如动态切换 InputMappingContext),在这里加逻辑就行,不会影响已有的绑定流程。


4. 第三根柱子:LyraInputModifiers ------ 可组合的输入处理管道

Enhanced Input 有一个非常强大的特性:输入值在被送到绑定函数之前,可以经过一个修饰器管道,逐层加工。就像 Photoshop 的滤镜叠加------原始像素 → 灰度 → 曲线调整 → 最终输出。

Lyra 在这一层实现了四个修饰器,每一个都恰好解决一个实际的手柄/鼠标输入处理问题。

4.1 修饰器一览

修饰器类 作用 驱动数据来源
ULyraSettingBasedScalar 根据任意设置属性缩放输入 ULyraSettingsShared 中的指定属性
ULyraInputModifierDeadZone 摇杆死区过滤 ULyraSettingsShared 中的死区设定
ULyraInputModifierGamepadSensitivity 手柄灵敏度等级 ULyraAimSensitivityData 数据表 + 用户设置
ULyraInputModifierAimInversion Y轴/X轴反转 ULyraSettingsShared 中的反转设定

这四种修饰器在 Enhanced Input 的 InputAction 资产里可以自由组合排列------比如同一个瞄准输入,先过 DeadZone 清除小幅度抖动,再过 Sensitivity 应用灵敏度倍率,最后过 AimInversion 决定要不要翻转。顺序不同,效果不同。

4.2 通用辅助函数:从 PlayerInput 回溯到 LyraLocalPlayer

四个修饰器都需要访问玩家设置,所以它们共享一个辅助函数:

cpp 复制代码
// LyraInputModifiers.cpp - 辅助函数
namespace LyraInputModifiersHelpers
{
    static ULyraLocalPlayer* GetLocalPlayer(const UEnhancedPlayerInput* PlayerInput)
    {
        if (PlayerInput)
        {
            if (APlayerController* PC = Cast<APlayerController>(PlayerInput->GetOuter()))
            {
                return Cast<ULyraLocalPlayer>(PC->GetLocalPlayer());
            }
        }
        return nullptr;
    }
}

这里利用了 UE 对象模型的 Outer 链:UEnhancedPlayerInput 的 Outer 是 APlayerController,而 APlayerController 又持有 ULocalPlayer。一旦拿到 ULyraLocalPlayer,就可以直接访问 GetSharedSettings() 读取所有用户偏好。

4.3 ULyraSettingBasedScalar:最灵活的缩放器

这个修饰器的设计思路非常巧妙------它不是硬编码"读取灵敏度的 X 属性",而是让配置者在 InputAction 资产里指定要读哪些属性名

cpp 复制代码
// LyraInputModifiers.h - 配置驱动的属性名
UPROPERTY(EditInstanceOnly, BlueprintReadWrite, Category=Settings)
FName XAxisScalarSettingName = NAME_None;

UPROPERTY(EditInstanceOnly, BlueprintReadWrite, Category=Settings)
FName YAxisScalarSettingName = NAME_None;

UPROPERTY(EditInstanceOnly, BlueprintReadWrite, Category=Settings)
FName ZAxisScalarSettingName = NAME_None;

也就是说,同一个 ULyraSettingBasedScalar 类可以用来缩放移动速度(读 MoveSpeed 属性),也可以用来缩放鼠标灵敏度(读 MouseSensitivity 属性)------全看配置了哪个属性名。

真正的性能亮点在这里:

cpp 复制代码
// LyraInputModifiers.cpp - 属性缓存
const bool bHasCachedProperty = PropertyCache.Num() == 3;

const FProperty* XAxisValue = bHasCachedProperty ? PropertyCache[0]
    : SettingsClass->FindPropertyByName(XAxisScalarSettingName);
// ... Y, Z 同理

if (PropertyCache.IsEmpty())
{
    PropertyCache.Emplace(XAxisValue);
    PropertyCache.Emplace(YAxisValue);
    PropertyCache.Emplace(ZAxisValue);
}

FindPropertyByName 是一个反射调用,每帧做一次的话性能开销不小。这里的策略是 "第一次调用时查找并缓存,后续帧直接用缓存"TArray<const FProperty*> PropertyCache 是成员变量,只需在修饰器初始化后查找一次。这个优化思路可以直接移植到其他需要反射查找的场景。

接着,根据输入值的维度决定缩放哪些轴:

cpp 复制代码
switch (CurrentValue.GetValueType())
{
case EInputActionValueType::Axis3D:
    ScalarToUse.Z = ZAxisValue ? *ZAxisValue->ContainerPtrToValuePtr<double>(SharedSettings) : 1.0;
    //[[fallthrough]];
case EInputActionValueType::Axis2D:
    ScalarToUse.Y = YAxisValue ? *YAxisValue->ContainerPtrToValuePtr<double>(SharedSettings) : 1.0;
    //[[fallthrough]];
case EInputActionValueType::Axis1D:
    ScalarToUse.X = XAxisValue ? *XAxisValue->ContainerPtrToValuePtr<double>(SharedSettings) : 1.0;
    break;
}

这里用了 fallthrough :如果输入是 3D(比如手柄摇杆的 X+Y+Z),三个轴都缩放;是 2D 的就只缩放 X 和 Y;是 1D 的就只缩放 X。最后再通过 MinValueClamp / MaxValueClamp 钳制结果。

4.4 ULyraInputModifierDeadZone:两种模式,按场景选

死区处理是手柄输入绕不过去的问题。Lyra 提供了两种模式:

cpp 复制代码
UENUM()
enum class EDeadzoneStick : uint8
{
    MoveStick = 0,   // 移动摇杆
    LookStick = 1,   // 视角摇杆
};

DeadzoneStick 决定了读取哪个设置值------不同摇杆的死区阈值通常不同(移动摇杆的死区一般比视角摇杆大一点)。

核心算法是一个 Lambda:

cpp 复制代码
auto DeadZoneLambda = [LowerThreshold, this](const float AxisVal)
{
    return FMath::Min(1.f,
        (FMath::Max(0.f, FMath::Abs(AxisVal) - LowerThreshold) / (UpperThreshold - LowerThreshold)))
        * FMath::Sign(AxisVal);
};

这个公式做的事:

  1. 取输入绝对值,减去死区下限阈值(低于阈值的部分直接归零)
  2. 除以 (UpperThreshold - LowerThreshold),把 LowerThreshold, UpperThreshold 重新映射到 0, 1
  3. 乘回符号位,保证方向不变

然后是两种死区类型的选择:

  • Axial(轴向死区):每个轴独立处理。适合移动摇杆------上下和左右的死区分别计算。
  • Radial(径向死区):先算向量长度,对长度应用死区,再乘回归一化方向。适合视角摇杆------能产生更自然的圆形死区。
cpp 复制代码
case EDeadZoneType::Radial:
    if (ValueType == EInputActionValueType::Axis2D)
    {
        NewValue = NewValue.GetSafeNormal2D() * DeadZoneLambda(NewValue.Size2D());
    }

另外,这个修饰器还重写了 GetVisualizationColor_Implementation,用于在 Enhanced Input 的调试面板里颜色可视化------被死区挡住的输入显示为红色,通过的显示为黑色。调试手柄手感的时候非常实用。

4.5 ULyraInputModifierGamepadSensitivity:等级制灵敏度

这个修饰器引入了 ULyraAimSensitivityData------一个 UPrimaryDataAsset,核心是一个 TMap<ELyraGamepadSensitivity, float>

cpp 复制代码
// LyraAimSensitivityData.cpp - 默认灵敏度等级
SensitivityMap =
{
    { ELyraGamepadSensitivity::Slow,          0.5f },
    { ELyraGamepadSensitivity::SlowPlus,      0.75f },
    { ELyraGamepadSensitivity::SlowPlusPlus,  0.9f },
    { ELyraGamepadSensitivity::Normal,        1.0f },
    { ELyraGamepadSensitivity::NormalPlus,    1.1f },
    { ELyraGamepadSensitivity::NormalPlusPlus, 1.25f },
    { ELyraGamepadSensitivity::Fast,          1.5f },
    { ELyraGamepadSensitivity::FastPlus,      1.75f },
    { ELyraGamepadSensitivity::FastPlusPlus,  2.0f },
    { ELyraGamepadSensitivity::Insane,        2.5f },
};

Normal = 1.0f 为基准,向两边对称展开:慢端有三级(Slow / SlowPlus / SlowPlusPlus),快端有四级。每个等级之间的步长大约在 0.1~0.25 之间,手感过渡平滑。

修饰器本身的逻辑很简单:

cpp 复制代码
FInputActionValue ULyraInputModifierGamepadSensitivity::ModifyRaw_Implementation(...)
{
    if (CurrentValue.GetValueType() == EInputActionValueType::Boolean || !LocalPlayer || !SensitivityLevelTable)
    {
        return CurrentValue;
    }

    ULyraSettingsShared* Settings = LocalPlayer->GetSharedSettings();
    const ELyraGamepadSensitivity Sensitivity =
        (TargetingType == ELyraTargetingType::Normal)
            ? Settings->GetGamepadLookSensitivityPreset()
            : Settings->GetGamepadTargetingSensitivityPreset();

    const float Scalar = SensitivityLevelTable->SensitivtyEnumToFloat(Sensitivity);
    return CurrentValue.Get<FVector>() * Scalar;
}

一个额外的设计是 ELyraTargetingType------区分"普通视角"和"ADS(瞄准镜)状态"的灵敏度。这意味着玩家可以设置"平时转视角快一点,开镜之后慢一点",两种状态对应不同的设置项和不同的灵敏度等级。

4.6 ULyraInputModifierAimInversion:小而美的反转

这个修饰器只有十几行逻辑,但解决了一个玩家群体中持续争论的需求------Y 轴反转:

cpp 复制代码
FInputActionValue ULyraInputModifierAimInversion::ModifyRaw_Implementation(...)
{
    ULyraLocalPlayer* LocalPlayer = LyraInputModifiersHelpers::GetLocalPlayer(PlayerInput);
    if (!LocalPlayer) { return CurrentValue; }

    ULyraSettingsShared* Settings = LocalPlayer->GetSharedSettings();
    FVector NewValue = CurrentValue.Get<FVector>();

    if (Settings->GetInvertVerticalAxis())   { NewValue.Y *= -1.0f; }
    if (Settings->GetInvertHorizontalAxis()) { NewValue.X *= -1.0f; }

    return NewValue;
}

注意这里处理的是 FVector,所以 X 是水平轴、Y 是垂直轴。这是 UE Enhanced Input 的约定,不是屏幕坐标的 X/Y。


5. 设置层:LyraInputUserSettings 与 LyraPlayerMappableKeyProfile

这两组文件虽然代码量很少(加起来不到 40 行逻辑),但在 Lyra 的输入体系中占据着"最终配置层"的位置。

5.1 ULyraInputUserSettings:留好扩展口的设置容器

cpp 复制代码
UCLASS()
class LYRAGAME_API ULyraInputUserSettings : public UEnhancedInputUserSettings
{
    GENERATED_BODY()
public:
    virtual void ApplySettings() override;
};

实现体只调用了 Super::ApplySettings()。但这恰恰是它的价值所在------它留下了清晰的注释告诉后来的开发者:

cpp 复制代码
// Add any additional Input Settings here!
// Some ideas could be:
// - "toggle vs. hold" to trigger in game actions
// - aim sensitivity should go here

// Make sure to mark your properties with the "SaveGame" metadata
// UPROPERTY(SaveGame, BlueprintReadWrite)
// bool bSomeExampleProperty;

SaveGame 元数据意味着这些属性会自动序列化到存档文件,与云存档兼容。这一点对在线游戏尤为重要------玩家换了台机器登录,灵敏度、反转、死区设置要能跟着账号走。

5.2 ULyraPlayerMappableKeyProfile:多套按键方案

cpp 复制代码
UCLASS()
class ULyraPlayerMappableKeyProfile : public UEnhancedPlayerMappableKeyProfile
{
    virtual void EquipProfile() override;
    virtual void UnEquipProfile() override;
};

EquipProfile / UnEquipProfile 是两个事件钩子,分别在切换按键方案时触发。比如玩家从"默认方案"切换到"自定义方案"时,可以在这里刷新 UI 或重新应用某些修饰器。

5.3 ULyraPlayerMappableKeySettings:按键提示文本

cpp 复制代码
UCLASS()
class ULyraPlayerMappableKeySettings : public UPlayerMappableKeySettings
{
    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FText Tooltip = FText::GetEmpty();
};

这个类只在 Enhanced Input 的每个按键映射条目上附加了一个 Tooltip 文本。看起来很小,但在设置界面里这就是玩家看到的提示信息------"按下此键进行跳跃"、"长按此键进行冲刺"。信息密度不高但用户体验差距巨大。


6. 深入一个完整链路:按下扳机键到底发生了什么

前面拆开看了每个组件,现在把它们拼回去,追踪一次完整的"玩家按下右扳机(RT)→ 角色开枪"的数据流:

复制代码
┌──────────────────────────────────────────────────────────┐
│ 1. 玩家按下 Xbox 手柄 RT                                  │
│    硬件信号 → XInput / SDL → 操作系统 → UE 输入栈        │
└────────────────────────┬─────────────────────────────────┘
                         │
                         ▼
┌──────────────────────────────────────────────────────────┐
│ 2. EnhancedInputSubsystem                                │
│    - InputMappingContext 识别 RT 对应 "IA_Shoot"         │
│    - InputTrigger 判断满足触发条件(按下)                 │
│    - 产出原始输入值(Axis1D: 1.0)                        │
└────────────────────────┬─────────────────────────────────┘
                         │
                         ▼
┌──────────────────────────────────────────────────────────┐
│ 3. InputModifier 管道(如果 IA_Shoot 配置了修饰器)       │
│    - DeadZone(如果配置了,过滤微小的误触)                │
│    - 修饰后输出值 → 交给 InputComponent                   │
└────────────────────────┬─────────────────────────────────┘
                         │
                         ▼
┌──────────────────────────────────────────────────────────┐
│ 4. ULyraInputComponent::BindAbilityActions               │
│    - 遍历 AbilityInputActions 时已绑定 IA_Shoot          │
│    - Triggered 事件触发                                   │
│    - 回调:InputTagPressed(FGameplayTag("InputTag.Shoot"))│
└────────────────────────┬─────────────────────────────────┘
                         │
                         ▼
┌──────────────────────────────────────────────────────────┐
│ 5. LyraHeroComponent::InputTagPressed                    │
│    - 将 InputTag 传入 AbilitySystemComponent             │
│    - ASC::AbilityInputTagPressed(InputTag)               │
└────────────────────────┬─────────────────────────────────┘
                         │
                         ▼
┌──────────────────────────────────────────────────────────┐
│ 6. ULyraAbilitySystemComponent                           │
│    - 根据 InputTag 查找匹配的 GameplayAbility             │
│    - 检查 Cost / Cooldown / Tags 是否允许激活             │
│    - 激活 GA_Shoot(射击技能)                            │
└──────────────────────────────────────────────────────────┘

这条链路里,没有一段代码硬编码了"右扳机 = 射击"。每一步都是通过数据配置连接的:

  • 第 2 步:InputMappingContext 决定 RT → IA_Shoot
  • 第 3 步:IA_Shoot 资产上配置了哪些修饰器
  • 第 4 步:LyraInputConfig 的 AbilityInputActions 数组里配了 IA_Shoot → InputTag.Shoot
  • 第 5~6 步:GameplayAbility 身上打了 InputTag.Shoot 标签

如果要把射击改成左扳机(LT),只需要在 InputMappingContext 里把 RT 换成 LT。如果要把射击绑定到一个完全不同的技能(比如扔手雷),只需要在 LyraInputConfig 里把 IA_Shoot 的 InputTag 从 InputTag.Shoot 改成 InputTag.Grenade。C++ 代码一行都不需要动。


写在最后

Lyra 的 Input 模块给我最大的感受是------克制。它没有试图把 Enhanced Input 全部重写一遍,也没有发明一套"通用输入框架"来适配所有项目。它只是在 Enhanced Input 的肩膀上,轻轻地垫了三个关键抽象层:

  1. LyraInputConfig:把"什么按键"和"什么游戏逻辑"用 GameplayTag 隔开
  2. LyraInputComponent:把 Native 和 Ability 两种绑定模式封装成模板方法,消除调用方的模板代码
  3. LyraInputModifiers:在输入值管道上插入四个实用过滤器,且全部由玩家设置驱动

几个值得带走的思路:

  • DataAsset 做配置载体,GameplayTag 做逻辑标识。不要把路径写死在代码里,也不要把按键写死在代码里。这个原则在输入系统的收益和在 AssetManager 那边是一样的。
  • 修饰器管道是"关注点分离"的典范。每个修饰器只做一个事------标量缩放、死区、灵敏度、反转------组合使用比写一个巨大的"手柄输入处理函数"要清晰得多,也更容易单元测试。
  • 模板方法的边界要清楚BindNativeActionBindAbilityActions 之所以适合用模板,是因为它们的调用方类型在 Pawn 初始化时就已经确定。如果绑定逻辑需要运行时动态决策,模板就不合适了。
  • PropertyCache 这种小优化不能省。反射查找在编辑器里无所谓,在运行时每帧调用就是性能杀手。第一次查完就缓存起来,这个习惯值得在项目各处推广。
  • 预留扩展口比写满逻辑更友好AddInputMappingsApplySettingsEquipProfile 这些空方法体存在的意义,是告诉后来的开发者"这里可以加东西",而不是"这里没写完"。

输入是玩家与游戏世界之间唯一的桥梁。在这座桥上多花一点设计心思,玩家的操作就会多一分流畅。

相关推荐
远离UE42 天前
UE5 Niagara LightWeight Emitter
ue5
StarTechnology2 天前
UE5云渲染管理平台-支持私有化部署Linux-Windows国产化适配
ue5·webui·webbrowser
平行云2 天前
实时云渲染信创架构解析:从GPU池化到全栈适配的技术演进
linux·unity·docker·ue5·webgl·数字孪生·实时云渲染
远离UE44 天前
UE5 高性能 实时 玻璃破碎系统
ue5
远离UE44 天前
UE5 多线程 与 异步 并行
ue5
日月云棠8 天前
UE5源码分析之Editor——ConfigEditor模块全面分析
ue5
日月云棠8 天前
UE5源码分析之Editor——CommonMenuExtensions插件全面分析
ue5
日月云棠8 天前
UE5源码分析之Editor——ClothingSystemEditorInterface模块全面分析
ue5
日月云棠8 天前
UE5源码分析之Editor——ClothPainter插件全面分析
ue5
[苦行僧]9 天前
ue5 异步 Task
ue5