Danta1ion
Danta1ion
发布于 2024-06-20 / 45 阅读
0
0

UE Enhanced Input 如何自定义Trigger

我在学习UE的输入输出的过程中因为找不到相关教程和资料,在此将我一个下午的探索过程整理成文档和笔记,希望能够给同样在这方面困扰的大家一些帮助,与各位初学者共勉。

本文默认读者有基础的 Enhanced Input 使用经验,明白如何使用UE5的 Input Mapping Context (IMC) 和 Input Action (IA) 。

我在做的FPS游戏里,有这么一个简单的需求:点按Shift闪避,长按Shift冲刺。

将这个功能逻辑化表达,可以描述成:在按下 Shift 0.3秒或更短时间时将闪避,否则冲刺。

由于动作游戏中一个按钮通常负责多种动作的输入,而且为了同时适配键盘鼠标和手柄,以及支持玩家自定义按键,这些都需要通过UE官方的组件 Input Mapping Context 来实现。在这当中,Trigger起到了关键的作用。

使用 Trigger

对于冲刺这种长按需求,它要求按键在持续按下一段时间后完成触发。

2023-05-14

官方提供的配置类 Hold 已经可以满足需求。将类的实例化变量 Hold Time Threshold 更改为 0.3 (秒) 即可实现需求。同时需要勾选 Is One Shot,从字面意思可以看出,在长按后只会触发一次按键。

深入探讨 Hold 类

这个 Hold 类在C++源码中全名 UInputTriggerHold,继承自 UInputTriggerTimedBase,这个基类带有按钮按下时间的属性,很适合实现这种长按的需求。

由于纯蓝图的项目看不到C++的对应代码,因此我在这里另外开了一个C++项目去通过源码比较。

UInputTriggerHold 类的声明(已简化):

UCLASS(NotBlueprintable, MinimalAPI, meta = (DisplayName = "Hold"))  
class UInputTriggerHold final : public UInputTriggerTimedBase  
{  
    bool bTriggered = false;  
  
protected:  
    virtual ETriggerState UpdateState_Implementation(
        const UEnhancedPlayerInput* PlayerInput, 
        FInputActionValue ModifiedValue, 
        float DeltaTime) override;  

public:  
    
    UPROPERTY()
    float HoldTimeThreshold = 1.0f;  
    
    UPROPERTY()  
    bool bIsOneShot = false;  
};

可以看到在蓝图中显示的 HoldTimeThreshold 变量和 bIsOneShot 变量都定义在这里了。由于 UInputTriggerHold 被声明为 final,我们不能直接继承它来实现自己的 Trigger

最重要的就是这个 UpdateState_Implementation 方法。它来自 UInputTriggerTimedBase 基类并 override 了原有的实现。

ETriggerState UInputTriggerHold::UpdateState_Implementation(
    const UEnhancedPlayerInput* PlayerInput, 
    FInputActionValue ModifiedValue, 
    float DeltaTime)  
{  
    // Update HeldDuration and derive base state  
    ETriggerState State = Super::UpdateState_Implementation(
        PlayerInput, ModifiedValue, DeltaTime);  
      
    // Trigger when HeldDuration reaches the threshold  
    bool bIsFirstTrigger = !bTriggered;  
    bTriggered = HeldDuration >= HoldTimeThreshold;  
    
    if (bTriggered)  
    {  
        return (bIsFirstTrigger || !bIsOneShot) ? 
            ETriggerState::Triggered : 
            ETriggerState::None;  
    }  
      
    return State;  
}

可以看到有两处 return 语句,默认状态下直接返回基类的处理状态,当且仅当 bTriggered 确定用户按住按钮的时间达到要求了才返回 Triggered 的结果。

为什么这么说呢?我们再来看下基类 Super::UpdateState_Implementation 的返回值有哪些:

ETriggerState UInputTriggerTimedBase::UpdateState_Implementation(
    const UEnhancedPlayerInput* PlayerInput, 
    FInputActionValue ModifiedValue, 
    float DeltaTime)  
{  
    ETriggerState State = ETriggerState::None;  
      
    // Transition to Ongoing on actuation. Update the held duration.  
    if (IsActuated(ModifiedValue))  
    {  
        State = ETriggerState::Ongoing;  
        HeldDuration = CalculateHeldDuration(PlayerInput, DeltaTime);  
    }  
    else  
    {  
        // Reset duration  
        HeldDuration = 0.0f;  
    }  
      
    return State;  
}

可以看到,返回值有 OngoingNone 两种,不包含 Triggered。从这里我们可以看出 UInputTriggerTimedBase 和普通的 UInputTrigger 最大的一点不同:它默认情况下是不会返回 Triggered 结果的。

补充:以下是 UInputTrigger 的默认返回情况,注意,UInputTriggerTimedBase 继承自 UInputTrigger

ETriggerState UInputTrigger::UpdateState_Implementation(
    const UEnhancedPlayerInput* PlayerInput, 
    FInputActionValue ModifiedValue, 
    float DeltaTime)  
{  
    return IsActuated(ModifiedValue) ? 
        ETriggerState::Triggered : ETriggerState::None;  
};

显然,只有 TriggeredNone 的两种状态,并没有 Ongoing,换句话说,Ongoing 状态是 UInputTriggerTimedBase 的独有状态。

理解 Enhanced Input 的判断过程

为什么 Hold 类可以不做修改直接调用,我依然在上面花了大量时间进行讲解?这是因为通过UE官方提供的代码我们可以更好地学习并理解整个底层。现在,我们从 Input Action 的调用处看看,这三种状态分别有什么用。

2023-05-14-1

在 Input Action 的调用位置,我们有 Triggered, Started, Ongoing, Canceled, 和 Completed 逻辑可以选择。

我们分别给他们加上 Print 函数,看看在一次冲刺 (Dash) 中这些属性是按什么样的顺序触发的。

2023-05-14-2

运行程序后,按下 Shift 键冲刺,可以看到按照 Started->Ongoing->Triggered->Completed 流程执行。

2023-05-14-3

如果按 Shift 不到 0.3秒就取消让它不能成功冲刺呢?

2023-05-14-4

运行结果是 Started->Ongoing->Canceled

对于计算机来说,我们按下一个按钮是 1,松开一个按钮是 0(这里只讨论键盘按键这种最简单的 bool 类型输入)。那么在这 0.3秒内,程序收到这样一串数字:

000000011111111110000000

其中,从 0 转化成 1 的那一刻为 Started,之后的 1 的过程为 Ongoing

如果我们按照要求完成了输入,触发了 Triggered 状态,那么此时我们应该在程序里让下一 tick 的输出状态为 None,此时对应了一个从 1 到 0 的过程,称为 Completed。因此你可以看到,TriggeredCompleted 是紧紧挨在一起的,因为它们在程序逻辑上前后只相差了 1 个 tick。

这里涉及一个稍微有些抽象的概念,就是键盘的 0-1 输入流和程序实际接收到的 Trigger-None 流可以不是同一个。在默认的 UInputTrigger 类中,这两者是一一映射的,很好理解。如果你设置了 Input Action 并不作任何 Trigger 的调整就是这样的结果。我们使用UE提供的 Trigger 类或者自行继承 InputTrigger,都是为了改变这两个流的映射关系

因此UE的 Hold 类可以实现“按住”的效果,当达到阈值触发 Trigger 后,即使玩家仍然按着 Shift 键,UInputTriggerHold 类将这些 1 的输入全部处理成了 None。对于程序来说就相当于没有按下按键一样,也就不会重复触发“冲刺”功能了。

键盘:00000011111111111111111111111111111 (T 后的 1 都被处理成了 None)
程序:NNNNNNOOOOOOOOOOTNNNNNNNNNNNNNNNNNN (N:None, O:Ongoing, T:Triggered)
           ^         ^
               触发 Triggered
      触发 Started

这时候我们再看触发 Canceled 的情况:

键盘:00000011111000000000000000000000000 (未到达 Trigger 就变为0导致 Canceled)
程序:NNNNNNOOOOOCNNNNNNNNNNNNNNNNNNNNNNN (N:None, O:Ongoing, C:Canceled)
           ^    ^
           触发 Canceled
      触发 Started

理解 Triggered 状态是如何触发的,在自己写 InputTrigger 派生类时非常重要。

自定义 BP_DodgeTrigger 蓝图类

(由于我用蓝图做了这部分逻辑,所以这里用蓝图进行演示。用C++效果是一样的)。

前面提到,按住按键时长在0.3秒以内视为躲避(Dodge)。这个功能UE没有提供,我只能自己实现了。

BP_DodgeTrigger 继承自 InputTriggerTimedBase

2023-05-14-5

这里我直接贴出完整的蓝图,可以试试看能否靠自己直接看懂。Is Released Pressing 是我封装的函数,它的目的就是检测玩家是否松开了按键。

2023-05-14-6

这里涉及到一个新概念,Actuation Threshold。在 Hold 类的配置中你也能看到它。

2023-05-14-7

这个值的意思是,小于0.5视为未触发,大于0.5视为触发。我们键盘是一种只有两个状态的输入方式,所以分别对应了0 和 1。正常情况下留作默认即可,无需改动。

因此,Is Actuated 函数的 Boolean 类型返回值就很好理解了,返回 false 代表 0,返回 true 代表 1。分别对应了按钮松开和按下的两种状态。

我们先传入当前 tick 的 Input Action Value, 判断它是否为 0(已松开),再判断前一个 tick 是否为 1(已按下),这种从 1 到 0 的转换就是 Triggered 的体现。

把连续的两个 tick 的输入使用01表示。如果此处判断为 false, 有 00, 01, 11 三种情况。我们都把它直接传给父类的方法去处理,并返回状态。

  • 00 对应 None,
  • 01 对应 Ongoing (Started),
  • 11 对应 Ongoing (Ongoing)。

如果是 10,那么 Is Released Pressing 函数就返回 true 了。此时我们检查从开始到现在,键盘按下的时间是否少于 0.3 秒。

此处 Hold Time Less Than 变量为 BlueprintReadOnly, InstanceEditable。方便在 Input Mapping Context 页面直接修改且直接在编译期暴露错误。

2023-05-14-8

如果按下时间超过了 0.3 秒,也直接调用父类方法返回结果。上面提到,UInputTriggerTimedBase 的方法是不会返回 Triggered 状态的,这里确保了能够正确触发需要的功能(超过0.3秒调用冲刺)。

那么为什么不直接 return 一个 Ongoing 或者 None 状态,而是需要调用父类方法呢?

这是因为 Held Duration 变量由父类 UInputTriggerTimedBase 提供,它如果检测到当前处于 Ongoing 状态,会把每 tick 的时间加到 Held Duration 变量上,以此来计算键盘按下后经过的时间。

又或者是当按键松开后,将 Held Duration 变量的值归零。

UE源码如下(已简化):

ETriggerState UInputTriggerTimedBase::UpdateState_Implementation(
    const UEnhancedPlayerInput* PlayerInput, 
    FInputActionValue ModifiedValue, 
    float DeltaTime)  
{
    if (IsActuated(ModifiedValue))  
    {  
        HeldDuration = CalculateHeldDuration(PlayerInput, DeltaTime);  
    }  
    else  
    {  
        HeldDuration = 0.0f;  
    }  
}  
  
float UInputTriggerTimedBase::CalculateHeldDuration(
    const UEnhancedPlayerInput* const PlayerInput, 
    const float DeltaTime) const  
{  
    const float TimeDilation = PlayerInput->GetEffectiveTimeDilation();   
    return HeldDuration + (!bAffectedByTimeDilation ? 
        DeltaTime : DeltaTime * TimeDilation);  
}

因此,我们才能在上文的蓝图中,在松开按钮时判断 HeldDuration 是否符合要求。

至此,有关如何自定义 Trigger 的简要方法和背后的原理就说明白了。需要自己写的代码只有上面那个蓝图(或者翻译成对应的C++),UE源码部分仅用于帮助核心内容使用。

总结

总而言之,Input Mapping Context 的设计思想是让每个 Input Action 直接对应并触发相关的动作。你对按键的所有配置都应该放在 Input Trigger 类中处理,而在蓝图中触发按键的部分只需要写对应的 Gameplay 逻辑即可。

2023-05-14-9

感谢您看到这里,本人初学UE数周,如果有讲的不够周到还请各位大佬指正。

#UE

UE 的坐标和方向体系

  • Vector3
  • Rotator
  • Location

以角色中心为原点,右方为x轴,前方为y轴,上方为z轴建立的相对直角坐标系。

而Rotator则是基于一个特定角度的偏角。(待补充)


评论