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 这些空方法体存在的意义,是告诉后来的开发者"这里可以加东西",而不是"这里没写完"。

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

相关推荐
日月云棠4 天前
UE5 Lyra源码分析——Audio_Analysis音频模块全面分析
ue5·音视频
成都渲染101云渲染66664 天前
2026云渲染平台哪个好?建筑、动画、UE5项目选择云渲染的3个关键标准
ue5
清泓y5 天前
UE 物理系统知识分享
面试·ue5·游戏程序
清泓y5 天前
UE移动开发技术面试题
android·面试·ue5·ue4·游戏程序
清泓y5 天前
UE基础知识与引擎架构面试题
面试·架构·ue5·ue4·游戏程序
mengzhi啊7 天前
UE5关卡切换 传统加载(阻塞)vs流式加载(非阻塞)。还有加载关卡3种办法
ue5
电子云与长程纠缠7 天前
UE中使用TGuardValue与TInlineComponentArray数据结构
开发语言·数据结构·学习·ue5·游戏引擎
Duo1J9 天前
【UE】Slate 编辑器工具开发03 - 节点编辑器 (EdGraph)
ue5·编辑器·游戏引擎·ue4
directx3d_beginner10 天前
26,怪物受击接口改为C++
ue5