GAS 作为管理技能和属性相关的灵活插件,使得开发制作角色扮演游戏、多人在线战术竞技游戏不再那么繁琐。另一方面,虚幻引擎将增强输入系统作为了默认输入组件,这不仅使得按下、按下并按住等常见功能逻辑作为了内置功能,还引入了上下文和优先级机制,使得我们可以将同一个按键在不同的上下文中发挥不同的效果。

如果有机会通过增强输入系统中的输入动作映射到 GAS 的技能激活,那么就可以以很优雅的方式在不同上下文中激活不同的技能。例如鼠标左键在未装备武器时激活挥拳技能、装备枪械时变为开火技能;空格键可以先作为跳跃技能的触发按钮,后期又可以灵活地替换成翻滚技能的触发按钮。本博文将讨论以下几点内容:

  • 何时应该赋予角色技能,以及输入事件的绑定;
  • 如何保证所有技能任务都正确工作,尤其是等待输入 ID 按下和释放的技能任务;
  • 有些技能不需要按键来触发,怎么维护需要按键和不需要按键的技能。

赋予角色技能的时机

根据个人过去游戏开发的经历,赋予角色技能的时机通常分为两种情况:1. 所有的技能在初始化技能系统组件后,一次性全部获得;2. 部分技能在初始化技能系统组件后获得,剩余技能在游戏期间中逐渐获得。通俗点儿说,前者更像是玩家已经化身为 “大师”,只是需要根据游戏的进程来明白自己并非 “凡夫俗子”;后者通常像是一件物品对玩家说:“想弄懂我就装备我吧”,例如只有装备枪械时才会得到开火和换子弹的技能,卸下装备时就失去了这两个技能。

从开发的角度来说,一次性赋予的方式最为直接,但有着十分苛刻的技能激活的判断条件,要确保玩家激活了预期的技能。而部分赋予的方式间接引入了上下文的概念,只有满足特定条件和身处特定环境时才能得到激活技能的机会,在 GAS 框架下还有机会为技能提供 SourceObject 信息,从而在技能内部获取物品的信息,比如说开火技能可以找到武器网格中枪口 Socket、开火间隔的信息。但部分赋予的方式还可能会引发频繁地技能赋予和取消,比如游戏为玩家提供了 Q 键或鼠标滚轮来切换武器的机制,而包括在我内的一些玩家非常喜欢频繁切换它们,结果上是玩家频繁地获取和失去相同的技能。最后是部分赋予的方式还会使得 InputID 难以维护,该值在赋予技能的时候显式地提供,且通常是被前言中提到的技能任务有关,而我们更愿意使设计师可以用到的节点都能正常工作。

从上面的描述中看来,一次性赋予的方式会使得生活更加容易一些,我们需要做好就是确保判断技能激活的条件,而虚幻引擎增强输入系统的上下文机制可以辅助我们解决这个问题。当玩家获得装备时(通常在 BeginPlay),将玩家的输入上下文中增加开火上下文,在卸下时去掉开火上下文(通常在 EndPlay)。在我的实现版本中,我将技能定义为了结构体,角色类中有存储该结构体的数组,然后在初始化技能系统函数中赋予玩家这些能力:

USTRUCT(BlueprintType)
struct FBota_AbilityInputActionBinding
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TSubclassOf<UGameplayAbility> AbilityClass;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TObjectPtr<UInputAction> InputAction;
};

// ABota_Character 中有 TArray<FBota_AbilityInputActionBinding> AbilityBindings
void ABota_Character::OnInitAbilityActorInfo()
{
	const auto& ASC = GetBotaAbilitySystemComponent();
	if (!ASC)
		return;

	AActor* InOwner = nullptr, *InAvatar = nullptr;
	GetAbilitySystemAvatarAndOwner(InOwner, InAvatar);
	ASC->InitAbilityActorInfo(InOwner, InAvatar);

	if (!HasAuthority())
		return;

	for (const auto& Binding : AbilityBindings)
	{
		if(Binding.AbilityClass)
			ASC->GiveAbilityAndAssignInputID(FGameplayAbilitySpec(Binding.AbilityClass));
	}

}

在代码中可以看到,在服务器端使用了自定义的函数 GiveAbilityAndAssignInputID,它在 GiveAbility 的基础上还会为技能提供一个有效的 InputID。它的实现如下:

// UBota_AbilitySystemComponent 中有 int32 ValidInputID = 0
FGameplayAbilitySpecHandle UBota_AbilitySystemComponent::GiveAbilityAndAssignInputID(const FGameplayAbilitySpec& AbilitySpec)
{
	auto TempAbilitySpec = AbilitySpec;
	TempAbilitySpec.InputID = ValidInputID;
	
	const auto& SpecHandle = GiveAbility(TempAbilitySpec);
	if (SpecHandle.IsValid())
	{
		ValidInputID++;
	}
	return SpecHandle;
}

输入的绑定

在上一节定义的结构体中,可以看到 TObjectPtr<UInputAction> InputAction 成员,这是设计师可以为技能映射按键的位置。这个 InputAction 可以加入到角色默认的 InputMappingContext 中,这样玩家一开始就可以通过按下对应的按键来激活技能,也可以加入到其他的 InputMappingContext 中然后动态地添加角色的输入系统中。在这种的实现机制中,AbilityBindings 在运行时保持值不变,因此可以很安全地遍历数组。下面的代码演示了如何将 InputAction 进行绑定并且在回调函数中找到对应的技能并激活:

void ABota_Character::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);

	const auto& EIC = CastChecked<UEnhancedInputComponent>(PlayerInputComponent);
	if (!EIC)
		return;

	for (const auto& Binding : AbilityBindings)
	{
		if (!Binding.InputAction)
			continue;

		EIC->BindAction(Binding.InputAction, ETriggerEvent::Started, this, &ThisClass::InputActionPressed, Binding.AbilityClass);
		EIC->BindAction(Binding.InputAction, ETriggerEvent::Completed, this, &ThisClass::InputActionReleased, Binding.AbilityClass);
	}
}
void ABota_Character::InputActionPressed(TSubclassOf<UGameplayAbility> InAbilityClass)
{
	if (!InAbilityClass)
		return;

	if (const auto& ASC = GetBotaAbilitySystemComponent())
	{
		ASC->FindAbilityByClassAndPressedInputID(InAbilityClass);
	}
}

void ABota_Character::InputActionReleased(TSubclassOf<UGameplayAbility> InAbilityClass)
{
	if (!InAbilityClass)
		return;

	if (const auto& ASC = GetBotaAbilitySystemComponent())
	{
		ASC->FindAbilityByClassAndRelesedInputID(InAbilityClass);
	}
}

虚幻引擎的委托绑定函数允许我们额外传递函数形参,所以在上面的代码中在回调函数中将 InputAction 映射的 AbilityClass 传递了过来,然后调用了 AbilitySystemComponent 中自定义的函数 FindAbilityByClassAndPressedInputIDFindAbilityByClassAndRelesedInputID 来实现技能的触发。

寻找技能实例然后激活它

InputAction 的回调函数中调用了 AbilitySystemComponent 的函数 FindAbilityByClassAndPressedInputIDFindAbilityByClassAndRelesedInputID,我采取的实现是结合了 TryActivateAbilityByClassAbilityLocalInputPressedAbilityLocalInputReleased。因为这些函数会在调用时各自遍历一次可激活技能数组,所以修改为了只遍历一次:


void UBota_AbilitySystemComponent::FindAbilityByClassAndPressedInputID(TSubclassOf<UGameplayAbility> InAbilityToActivate)
{
	ABILITYLIST_SCOPE_LOCK();
	const UGameplayAbility* const InAbilityCDO = InAbilityToActivate.GetDefaultObject();
	for (auto& Spec : ActivatableAbilities.Items)
	{
		if (Spec.Ability == InAbilityCDO)
		{
			if (Spec.Ability)
			{
				Spec.InputPressed = true;
				if (Spec.IsActive())
				{
					if (Spec.Ability->bReplicateInputDirectly && IsOwnerActorAuthoritative() == false)
					{
						ServerSetInputPressed(Spec.Handle);
					}

					AbilitySpecInputPressed(Spec);
					InvokeReplicatedEvent(EAbilityGenericReplicatedEvent::InputPressed, Spec.Handle, Spec.ActivationInfo.GetActivationPredictionKey());
				}
				else
				{
					TryActivateAbility(Spec.Handle);
				}
			}
		}
	}
}

void UBota_AbilitySystemComponent::FindAbilityByClassAndRelesedInputID(TSubclassOf<UGameplayAbility> InAbilityToActivate)
{
	ABILITYLIST_SCOPE_LOCK();
	const UGameplayAbility* const InAbilityCDO = InAbilityToActivate.GetDefaultObject();
	for (auto& Spec : ActivatableAbilities.Items)
	{
		if (Spec.Ability == InAbilityCDO)
		{
			Spec.InputPressed = false;
			if (Spec.Ability && Spec.IsActive())
			{
				if (Spec.Ability->bReplicateInputDirectly && IsOwnerActorAuthoritative() == false)
				{
					ServerSetInputReleased(Spec.Handle);
				}

				AbilitySpecInputReleased(Spec);
				InvokeReplicatedEvent(EAbilityGenericReplicatedEvent::InputReleased, Spec.Handle, Spec.ActivationInfo.GetActivationPredictionKey());
			}
		}
	}
}