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 在上面包了一层,不是为了炫技,而是解决两个实际问题:
- 不想让绑定代码到处散落 。每个 Pawn 子类如果各自手动写
BindAction(IA_Move, ETriggerEvent::Triggered, this, &AMyPawn::Move),等项目有五个 Pawn 子类就变成维护噩梦。 - 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);
};
这个公式做的事:
- 取输入绝对值,减去死区下限阈值(低于阈值的部分直接归零)
- 除以
(UpperThreshold - LowerThreshold),把 LowerThreshold, UpperThreshold 重新映射到 0, 1 - 乘回符号位,保证方向不变
然后是两种死区类型的选择:
- 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 的肩膀上,轻轻地垫了三个关键抽象层:
- LyraInputConfig:把"什么按键"和"什么游戏逻辑"用 GameplayTag 隔开
- LyraInputComponent:把 Native 和 Ability 两种绑定模式封装成模板方法,消除调用方的模板代码
- LyraInputModifiers:在输入值管道上插入四个实用过滤器,且全部由玩家设置驱动
几个值得带走的思路:
- DataAsset 做配置载体,GameplayTag 做逻辑标识。不要把路径写死在代码里,也不要把按键写死在代码里。这个原则在输入系统的收益和在 AssetManager 那边是一样的。
- 修饰器管道是"关注点分离"的典范。每个修饰器只做一个事------标量缩放、死区、灵敏度、反转------组合使用比写一个巨大的"手柄输入处理函数"要清晰得多,也更容易单元测试。
- 模板方法的边界要清楚 。
BindNativeAction和BindAbilityActions之所以适合用模板,是因为它们的调用方类型在 Pawn 初始化时就已经确定。如果绑定逻辑需要运行时动态决策,模板就不合适了。 PropertyCache这种小优化不能省。反射查找在编辑器里无所谓,在运行时每帧调用就是性能杀手。第一次查完就缓存起来,这个习惯值得在项目各处推广。- 预留扩展口比写满逻辑更友好 。
AddInputMappings、ApplySettings、EquipProfile这些空方法体存在的意义,是告诉后来的开发者"这里可以加东西",而不是"这里没写完"。
输入是玩家与游戏世界之间唯一的桥梁。在这座桥上多花一点设计心思,玩家的操作就会多一分流畅。