WECHAT

扫码添加微信

YoungFan 微信二维码
← 返回 《Steal An Egg》UE5
基于GAS实现玩家交互能力、拾取能力 封面
开发笔记撰写中

基于GAS实现玩家交互能力、拾取能力

游戏玩法中,需要玩家拾取“蛋”并逃跑。本文记录使用GAS实现通用的交互能力以及玩家“拾取”能力的方法。

UE5多人游戏C++Lyra
查看 GitHub

引言

游戏的核心玩法——偷蛋,涉及到玩家与关卡中物体之间的交互。基本流程是:玩家靠近可偷取的“蛋”,HUD上弹出交互键提示。玩家按住交互键2s后,拾取并手持“蛋”逃跑回安全区。回到安全区后,将手持的“蛋”销毁并转化道具实例进入道具快捷栏/背包中。

可以看到,这里的交互逻辑是“双向”的,所以可以分解为两个部分:

  • 可交互物的探测和HUD展示逻辑,即玩家的“交互能力”。可交互物可以是“蛋”或是其他类型物体,它们都需要能被玩家探测到。玩家自身的“交互能力”是非常基础、固定的,通常不会有太多变化。

  • 可交互物承载的“交互行为”,即可交互物的“交互能力”。每种可交互物体的交互行为并不一致:“蛋”的交互行为是被玩家拾取或者说“手持”;跑步机的交互行为是玩家站在上面“跑步”并提升速度属性;可交互物体的“交互能力”通常各不相同,所以需要可交互物来承载具体的交互方式,然后“告知”玩家。

考虑不同交互物的交互方式各不相同,我们不可能将所有具体交互代码全部由玩家对象承载,这样后期维护无疑是个大麻烦。我们需要让交互物体来决定玩家如何与它们交互、它们又会如何影响玩家。这样,交互代码只会跟随交互物体种类的增加而增加,不会让玩家的交互代码膨胀到难以理解和维护。

处于可方便维护和扩展的目标,我们考虑基于Gameplay Ability System (Gameplay技能系统, GAS)开发通用的交互(Interaction)系统,将“交互”视为一项“技能(Ability)”。GAS在Unreal Engine生态中很受欢迎,不少项目组在实际生产中使用它实现核心Gameplay逻辑,所以除了学习UnrealEngine本身,研究和学习GAS也非常有益。

由于本人对GAS完全是门外汉,所以交互系统的设计会参考Lyra官方案例中的交互系统,但会做适当的简化,不会完全照搬。

我们还会使用Lyra的GameplayMessageRouter/GameplayMessageSubsystem插件,作为本项目的事件子系统。

Gameplay Ability System

Gameplay Ability System 简称GAS,是一个管理角色技能(Abilities)、属性(Attributes)和效果(Effects)的框架,由Epic提供。它可用于实现复杂Gameplay交互和模块化技能设计。GAS提供对技能、效果和视觉表现(Cues)的抽象,允许开发者定义主动技能和被动技能、动态修改属性和整合视觉、音效表现。 GAS广泛应用于RPGs、动作冒险游戏和多人游戏中,为它们提供一致的、可扩展的游戏机制。 官方文档

Ability System Component

  • 附加到Character或Actor上,用于管理及能和属性。
  • 提供与GAS框架交互的接口。
  • 对于持久技能可附加到PlayerState上;临时附加到Character上;也可附加到AI控制的技能。

Abilities(技能)

  • 定义角色能够执行的动作,例如:攻击、释放法术或添加Buff。
  • 可由玩家输入、AI或事件触发。
  • 通过Ability Tasks可实现异步执行,用于实现复杂的技能流程和交互。

Attributes(属性) && Attribute Sets(属性集)

  • 表示角色状态的数据,例如:生命值、体力值或伤害值等。
  • 可通过Abilities或GameplayEffects修改。
  • 支持动态计算和修改器。

Gemeplay Effects

  • 封装对属性或状态的修改,例如:伤害值、治疗量或移动速度。
  • 可立即生效或随时间生效,以及可同时作用域多个属性。

Gameplay Tags

  • 用于对Abilities、Effects或States组织分类。
  • 启用Abilities之间的条件逻辑、过滤和交互规则。

职责划分

  • USAEAbilitySystemComponent: 派生自UAbilitySystemComponent,目前负责将玩家输入事件转为GAS可复制事件,让玩家输入能路由进Ability。
  • FSAEInteractionOption: 可交互物体提供的交互参数,描述玩家能够做什么、使用哪个Ability。
  • ISAEInteractableTarget & SAEInteractableTarget: 提供可交互物体“行为契约”的接口,所有可交互物体应实现该接口。
  • USAEGA_Interact: 玩家的Ability。扫描交互目标、触发交互物的交互Ability、通知/更新HUD、发送Gameplay Event。
  • ISAEPickupable & USAEPickupable: 提供可拾取物体“行为契约”的接口,所有可拾取物体应实现该接口。
  • FSAPickupInfo: 描述拾取信息,例如:道具ID。游戏中“蛋”也算作道具。
  • USAEGA_Pickup: 可拾取物的Ability。处理客户端按住按键进行拾取的操作,以及拾取成功后,服务器的侧验证和转化为道具实例进背包。
  • USAEQuickbarComponent: 道具快捷栏/背包,拾取成功的物体转化为道具实例后,存放在这里面。
  • ASAEWorldEgg: 关卡中表示“蛋”的Actor,实现ISAEInteractableTarget、ISAEPickupable接口。它会携带道具信息、用于交互的Ability Class引用。
  • StealAnEggUI: 客户端专用模块,有自己的.Build.cs文件,专门容纳客户端UI逻辑,不与Gameplay逻辑混放。

还有一些模块不算通用交互系统的核心部分,但与“偷蛋”这一具体的交互行为相关,也列出来:

  • USAEHandComponent: 存放和管理当前玩家正在“手持的道具”的数据,挂载PlayerState上。
  • USAEStolenEggComponent:存储玩家正在手持的“蛋”的Actor引用、判断玩家状态释放能持有蛋挂载PlayerState上。
  • USAEHeldItemPresentationComponent:玩家的USAEHandComponent和USAEStolenEggComponent组件保存的是手持道具的数据,而视觉表现仅限客户端。因此我们单独创建一个组件集中管理视觉表现逻辑,在Dedicated Server网络模式下时,这个组件不做任何事。该组件挂载Character上。

交互、拾取简要流程

USAEGA_Interact 激活并运行,周期性扫描玩家视线方向
    ↓
对命中的ISAEInteractableTarget调用GatherInteractionOptions(),获取交互选项信息FSAInteractionOption
    ↓
玩家按下交换键,USAEGA_Interact激活FSAInteractionOption提供的Ability,本案例中为USAEGA_Pickup
    ↓
USAEGA_Pickup激活运行,监听按住交互键的时间进度
    ↓
达到时间阈值,调用ISAEPickupable::CommitPickup(),即ASAEWorldEgg::CommitPickup()
    ↓
调用USAEStolenEggComponent::TryCarryEgg(),更新当前玩家手持的ASAEWorldEgg的引用
    ↓
调用USAEHandComponent::TryOccupyWithStolenEgg(),更新当前玩家手持的道具的ID
    ↓
调用USAEHeldItemPresentationComponent::RefreshPresentation(),更新角色手持物体的视觉表现。对于DS,这一步应跳过。

GameplayTags定义

/*标记Ability需要玩家输入*/
UE_DEFINE_GAMEPLAY_TAG(Input_Interact,"Input.Interact");

/*SAEGA_Interact用来激活交互目标Ability的事件*/
UE_DEFINE_GAMEPLAY_TAG(Event_Interaction_Activate,"Event.Interaction.Activate");

/*当前需要执行拾取/偷蛋的交互能力,用于填充FSAEInteractionOption。*/
UE_DEFINE_GAMEPLAY_TAG(Interaction_Pickup_StolenEgg,"Interaction.Pickup.StolenEgg");

/*表示当前的拾取行为是“偷蛋”*/
UE_DEFINE_GAMEPLAY_TAG(Pickup_StolenEgg,"Pickup.StolenEgg");

/*标记玩家当前正在偷蛋*/
UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Carrying_StolenEgg);

/*标记玩家当前以双手的姿态持有道具*/
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Pose_Carry_TwoHanded);

/*UI事件。通知UI侧,当前玩家扫描到的可交互目标数量发生变化。数量为0时表示没有可交互目标。*/
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Event_Interaction_UI_OptionsChanged);

/*UI事件。通知UI侧,当前玩家正在按住交互键 */
UE_DECLARE_GAMEPLAY_TAG_EXTERN(Event_Interaction_UI_Hold);

为什么不照搬Lyra

Lyra的IPickupable主要描述要加入仓库(Inventory)的物品,因为Lyra的收集物最终都会走向统一的Inventory流程。 但我们项目至少有两种不同交互结果:

普通道具
    世界Actor -> ItemInstance -> 销毁世界Actor

偷蛋
    世界Actor -> USAEStolenEggComponent组件保存Actor引用,即玩家手持“蛋” -> 玩家移动到达安全区 -> 为“蛋”创建ItemInstance -> 销毁世界Actor

因此IPickupable除了提供拾取物信息,还需要提供那个一个服务器提交入口。通用Ability将调用这个接口,由可拾取物决定该怎么“入库”。

USAEAbilitySystemComponent

交互能力需要由玩家按键输入触发或取消,我们在USAEAbilitySystemComponent中将玩家的输入事件转换为GAS的ReplicatedEvent。

UCLASS()
class STEALANEGG_API USAEAbilitySystemComponent : public UAbilitySystemComponent
{
	GENERATED_BODY()
public:
    //玩家按下和释放按键的输入,根据InputTag筛选感兴趣的、已激活的Ability
	void AbilityInputTagPressed(const FGameplayTag& InputTag);
	void AbilityInputTagReleased(const FGameplayTag& InputTag);
    //重载输入按下和释放
	virtual void AbilitySpecInputPressed(FGameplayAbilitySpec& Spec) override;
	virtual void AbilitySpecInputReleased(FGameplayAbilitySpec& Spec) override;
};

玩家输入映射

玩家输入我们用的是EnhancedInput,在Character内监听InputAction——IA_Interaction后,在回调内调用ASC:

void ASAEPlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);
	UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(PlayerInputComponent);
    // ...
	EnhancedInputComponent->BindAction(InteractionAction,ETriggerEvent::Started,this,&ThisClass::HandleInteractionStarted);
	EnhancedInputComponent->BindAction(InteractionAction,ETriggerEvent::Completed,this,&ThisClass::HandleInteractionReleased);
	EnhancedInputComponent->BindAction(InteractionAction,ETriggerEvent::Canceled,this,&ThisClass::HandleInteractionReleased);
}
void ASAEPlayerCharacter::HandleInteractionStarted()
{
	//输入事件转发给AbilitySystem组件
	if(USAEAbilitySystemComponent* ASC = Cast<USAEAbilitySystemComponent>(GetAbilitySystemComponent()))
	{
		ASC->AbilityInputTagPressed(SAEGameplayTags::Input_Interact);
	}
}
void ASAEPlayerCharacter::HandleInteractionReleased()
{
	//输入事件转发给AbilitySystem组件
	if(USAEAbilitySystemComponent* ASC = Cast<USAEAbilitySystemComponent>(GetAbilitySystemComponent()))
	{
		ASC->AbilityInputTagReleased(SAEGameplayTags::Input_Interact);
	}
}

玩家输入转化为GAS事件

以按下按钮为例

从可激活Abilities列表中筛选我们需要的且已经激活的:

void USAEAbilitySystemComponent::AbilityInputTagPressed(const FGameplayTag& InputTag)
{
	if(!InputTag.IsValid())
	{
		return;
	}
    //遍历可激活
	TArray<FGameplayAbilitySpecHandle> Handles;
	for(const FGameplayAbilitySpec& Spec : GetActivatableAbilities())
	{
        //收集需要的能力
		if(Spec.Ability && Spec.GetDynamicSpecSourceTags().HasTag(InputTag))
		{
			Handles.Add(Spec.Handle);
		}
	}

	for (const FGameplayAbilitySpecHandle& Handle : Handles)
	{
		FGameplayAbilitySpec* Spec = FindAbilitySpecFromHandle(Handle);
		if(!Spec || !Spec->IsActive())
		{
			continue;
		}
        //对已激活的Ability调用AbilitySpecInputPressed
		AbilitySpecInputPressed(*Spec);
	}
}

然后执行InvokeReplicatedEvent()

void USAEAbilitySystemComponent::AbilitySpecInputPressed(FGameplayAbilitySpec& Spec)
{
	Super::AbilitySpecInputPressed(Spec);
	if(Spec.IsActive())
	{
        //遍历该类型Ability所有实例
		for(const UGameplayAbility* Instance : Spec.GetAbilityInstances())
		{
			if(!IsValid(Instance) || !Instance->IsActive())
			{
				continue;
			}
            //执行复制事件。EAbilityGenericReplicatedEvent::InputPressed将执行到后面的USAEGA_Interact::InputPressed
			InvokeReplicatedEvent(
				EAbilityGenericReplicatedEvent::InputPressed,
				Spec.Handle,
				Instance->GetCurrentActivationInfo().GetActivationPredictionKey()
				);
		}
	}
}

FSAEInteractionOption

FSAEInteractionOption描述一个交互行为,不描述具体的交互物。

USTRUCT(BlueprintType)
struct STEALANEGG_API FSAEInteractionOption
{
	GENERATED_BODY()

    //交互目标
	UPROPERTY(BlueprintReadOnly)
	TScriptInterface<ISAEInteractableTarget> Target;

    //交互AbilityClass引用
	UPROPERTY(EditAnywhere,BlueprintReadOnly)
	TSubclassOf<UGameplayAbility> AbilityClass;

    //交互行为标识,例如Interaction.Pickup.StolenEgg
	UPROPERTY(EditAnywhere,BlueprintReadOnly)
	FGameplayTag InteractionTag;

    //HUD提示文本
	UPROPERTY(EditAnywhere,BlueprintReadOnly)
	FText Text;

    //HUD提示文本
	UPROPERTY(EditAnywhere,BlueprintReadOnly)
	FText SubText;

    //交互键持续时间。为0时表示按下交互键立即执行交互,不需要按住交互键一段时间。
	UPROPERTY(EditAnywhere,BlueprintReadOnly)
	float Duration = 0;

    //UI无关的显示样式标识。StealAnEggUI负责将它映射到具体的USerWidget、图标、布局
	UPROPERTY(EditAnywhere,BlueprintReadOnly)
	FGameplayTag InteractionStyleTag;
};

核心玩法模块不直接保存UUserWidget类型或具体的Widget资源。依赖方向必须保持:

StealAnEggUI(客户端) -> StealAnEgg(核心玩法)

两个Tag职责不同:

InteractionTag
    表示交互行为语义,例如:Interaction.Pickup.StolenEgg

InteractionStyleTag
    表示客户端显示方式,例如:UI.Interaction.Hold

ISAEInteractableTarget

可交互目标接口,只需要回答“当前能否交互”和“交互持续时间”。让交互扫描器只依赖“可交互”契约,而不直接依赖ASAEWorldEgg。

UINTERFACE(BlueprintType,meta=(CannotImplementInterfaceInBlueprint))
class STEALANEGG_API USAEInteractableTarget : public UInterface
{
	GENERATED_BODY()
};
//
class STEALANEGG_API ISAEInteractableTarget
{
	GENERATED_BODY()
public:
    //获取交互信息结构体
	virtual void GatherInteractionOptions(const ASAEPlayerCharacter* Character,FSAEInteractionOptionBuilder& Builder) const = 0;
	//判断是否可交互
	virtual bool CanInteract(const ASAEPlayerCharacter* Character,FText& OutFailureReason) const = 0;//纯虚函数
    //交互持续时间,即按住交互键的时间
	virtual float GetInteractionDuration() const
	{
		return 0.0f;
	}
    //供派生类修改FGameplayEventData
	virtual void CustomizeInteractionEventData(const FGameplayTag& EventTag,FGameplayEventData& InOutEventData) const
	{
		
	}
};

USAEGA_Interact

FSAInteractionOption只是描述一个可交互选项,具体的扫描、选项缓存、输入响应和执行交互选项由长驻Ability——USAEGA_Interact负责:

USAEGA_Interact 激活并运行
    ↓
AbilityTask周期性扫描玩家视线方向
    ↓
对命中的ISAEInteractableTarget调用GatherInteractionOptions
    ↓
更新本地CurrentOptions,并通过事件通知UI
    ↓
玩家按下交换键,USAEGA_Interact激活FSAInteractionOption提供的Ability

为USAEGA_Interact创建一个长驻的AbilityTask——USAEAbilityTask_WaitForInteractableTargets,负责周期性扫描和搜集交互目标ISAEInteractableTarget。 对每个目标调用GatherInteractionOptions()获得并缓存Option,然后通知USAEGA_Interact。

USAEAbilityTask_WaitForInteractableTargets部分定义:

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FSAEInteractionOptionsChanged,
	const TArray<FSAEInteractionOption>&, Options);

UCLASS()
class STEALANEGG_API USAEAbilityTask_WaitForInteractableTargets : public UAbilityTask
{
	GENERATED_BODY()
public:
	//创建Task对象
	static USAEAbilityTask_WaitForInteractableTargets* WaitForTargets(
		UGameplayAbility* OwningAbility,
		float ScanRange,
		float ScanRate,
		float TraceRadius,
		ECollisionChannel TraceChannel);
    //可交互目标数量放生变化时,广播事件
	UPROPERTY(BlueprintAssignable)
	FSAEInteractionOptionsChanged OnOptionsChanged;
private:
    //使用GetWorld()->SweepSingleByChannel()执行扫描操作
	void PerformScan();
    //扫描到的可交互目标添加到缓存里
	void AppendTarget(UObject* Object,TArray<TScriptInterface<ISAEInteractableTarget>>& OutTargets,TSet<UObject*>& SeenObjects) const;
    /* 扫描参数 */
	float ScanRange = 500.0f;
	float ScanRate = 0.1f;
	float TraceRadius = 20.0f;
	ECollisionChannel TraceChannel = ECC_Visibility;
    //定时器
	FTimerHandle ScanTimerHandle;
    //可交互目标缓存
	UPROPERTY()
	TArray<FSAEInteractionOption> CachedOptions;
};

USAEGA_Interact类定义片段

UCLASS(Abstract,Blueprintable)
class STEALANEGG_API USAEGA_Interact : public UGameplayAbility
{
	GENERATED_BODY()
public:
	USAEGA_Interact();
    // ...

	virtual void OnAvatarSet(
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilitySpec& Spec) override;
	
	virtual void ActivateAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		const FGameplayEventData* TriggerEventData) override;

	virtual void EndAbility(const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		bool bReplicateEndAbility,
		bool bWasCancelled) override;

    /* 玩家输入 */
	virtual void InputPressed(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo) override;

	virtual void InputReleased(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo) override;

    // ...
protected:
    // ...

    //监听WaitForInteractableTargets::OnOptionsChanged
	UFUNCTION()
	void HandleOptionsChanged(const TArray<FSAEInteractionOption>& Options);
    //监听Ability结束
	void HandleAbilityEnded(const FAbilityEndedData& EndedData);
    //尝试触发FSAEInteractionOption携带的Ability
	bool TriggerInteraction();
    //玩家松开交互键,尝试取消正在进行的Ability
	void ReleaseInteraction();
	//缓存WaitForInteractableTargets扫描到的目标
	UPROPERTY(BlueprintReadOnly,Category="Interaction")
	TArray<FSAEInteractionOption> CurrentOptions;

    // ...
private:
    // ...

    //扫描器
	UPROPERTY()
	TObjectPtr<USAEAbilityTask_WaitForInteractableTargets> ScanTasks;
    //当前已激活的交互物体提供的交互Ability
	FGameplayAbilitySpecHandle ActiveInteractionAbilityHandle;

    // ...
};

UCLASS()中使用了Abstract,是因为我们不希望直接实例化USAEGA_Interact类,应该派生一个子蓝图分配给交互目标Actor。

由于USAEGA_Interact的核心功能————探测,主要更客户端相关,服务端不需要周期性探测,所以我们将该Ability的NetExecutionPolicy 设置设置为LocalOnly。

此外,我们希望每个玩家都有自己的“探测器”,即各自拥有自己的USAEGA_Interact实例,不共享,所以InstancingPolicy 设为InstancedPerActor。

USAEGA_Interact::USAEGA_Interact()
{
	InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
	NetExecutionPolicy =  EGameplayAbilityNetExecutionPolicy::LocalOnly;
}

玩家输入通过ASC的AbilitySpecInputPressed和AbilitySpecInputReleased路由进已激活的USAEGA_Interact。

在可交互物数量变化变化时,广播事件。UI侧根据可交互物数量决定是否显示交互键提示。

void USAEGA_Interact::HandleOptionsChanged(const TArray<FSAEInteractionOption>& Options)
{
	// ...

    //将当前扫描到的可交互物CurrentOptions通过事件广播出去
	FSAEInteractionOptionsChangedMessage Message(CurrentOptions,SelectedIndex);
    //事件系统使用Lyra的Gameplay Message Subsystem
	UGameplayMessageSubsystem::Get(this).BroadcastMessage(
		SAEGameplayTags::Event_Interaction_UI_OptionsChanged,
		Message
		);
}

FSAEInteractionOptionsChangedMessage事件定义:

USTRUCT(BlueprintType)
struct STEALANEGG_API FSAEInteractionOptionsChangedMessage
{
	GENERATED_BODY()

	UPROPERTY(BlueprintReadOnly)
	TArray<FSAEInteractionOption> Options;

	UPROPERTY(BlueprintReadOnly)
	int32 SelectedIndex;
};

FSAEPickupInfo

FSAEPickInfo描述准备拾取的定义:

USTRUCT(BlueprintType)
struct STEALANEGG_API FSAEPickupInfo
{
	GENERATED_BODY()

    //蛋的道具ID
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	int32 ItemDefinitionId = INDEX_NONE;

    //数量
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	int32 Quantity = 0;

    //拾取行为标识Tag
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	FGameplayTagContainer PickupTags;
	
    //是否有效
	bool IsValid() const
	{
		return ItemDefinitionId > INDEX_NONE && Quantity > 0;		
	}
};

ISAEPickupable

可拾取物接口

UINTERFACE(BlueprintType,meta=(CannotImplementInterfaceInBlueprint))
class STEALANEGG_API USAEPickupable : public UInterface
{
	GENERATED_BODY()
};
//可拾取对象抽象接口
class ISAEPickupable
{
	GENERATED_BODY()
public:
	//只读查询,不改变世界或库存状态
	virtual FSAEPickupInfo GetPickupInfo() const = 0;

	//客户端预检查和服务器最终检查都会调用
	virtual bool CanBePickedUpBy(const ASAEPlayerCharacter* Character,FText& OutFailureReason) const = 0;

	//只能由服务器调用;成功后由拾取目标改变状态或销毁自己
	virtual bool CommitPickup(ASAEPlayerCharacter* Character, FText& OutFailureReason) = 0;
};

接口用了CannotImplementInterfaceInBlueprint禁止纯蓝图直接实现,目的是保证服务器提交路劲存在可靠的C++实现。

USAEGA_Pickup

USAEGA_Pickup提供“拾取”的交互能力,与USAEGA_Interact一样不允许直接实例化,需要派生蓝图子类。 当前案例中,ASAEWorldEgg会携带拾取Ability类。当USAEGA_Interact扫描到ASAEWorldEgg时,Egg会告诉它当玩家套进行交互时,应激活拾取Ability。

USAEGA_Pickup部分定义:

UCLASS(Abstract, Blueprintable)
class STEALANEGG_API USAEGA_Pickup : public UGameplayAbility
{
	GENERATED_BODY()
public:
	USAEGA_Pickup();
protected:
    /*激活Ability*/
	virtual void ActivateAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		const FGameplayEventData* TriggerEventData) override;
    /*Ability结束*/
	virtual void EndAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		bool bReplicateEndAbility,
		bool bWasCancelled) override;
	/*预测客户端读条完成,不代表服务器已经接受拾取*/
	UFUNCTION(BlueprintImplementableEvent,Category="SAE|Pickup")
	void OnPickupPredictedComplete(AActor* PickupActor,const FSAEPickupInfo& PickupInfo);
	/* Standalone或ListenServer主机提交成功时调用*/
	UFUNCTION(BlueprintImplementableEvent,Category="SAE|Pickup")
	void OnPickupCommitted(AActor* PickupActor,const FSAEPickupInfo& PickupInfo);
	
private:
    /*监听UAbilityTask_WaitDelay结束事件*/
	UFUNCTION()
	void HandleDurationFinished();
    /*监听UAbilityTask_WaitInputRelease的Release事件*/
	UFUNCTION()
	void HandleInputRelease(float TimeHeld);
    /*当前有效的拾取目标的Actor*/
	UPROPERTY()
	TObjectPtr<AActor> PickupTargetActor;
    /*当前有效的拾取目标*/
	UPROPERTY()
	TScriptInterface<ISAEPickupable> PickupTarget;
    /*等待并计时玩家按住交互键的时间*/
	UPROPERTY()
	TObjectPtr<UAbilityTask_WaitDelay> WaitDurationTask;
    /*等待玩家释放交互键*/
	UPROPERTY()
	TObjectPtr<UAbilityTask_WaitInputRelease> WaitInputReleaseTask;
    /*当前拾取目标提供的拾取信息*/
	FSAEPickupInfo CachedPickupInfo;
};

由于客户端需要表现出玩家按住交互键的时间进度,所以前后端都需要执行这个Ability。但客户端只是预测,执行具体拾取逻辑应有服务端完成,代码中需要做出区分。 因此我们需要将NetExecutionPolicy设置为LocalPredicted。

USAEGA_Pickup::USAEGA_Pickup()
{
    /*同样是每个玩家有各自的实例*/
	InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
    /*设置为本地预测*/
	NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
}

当USAEGA_Pickup被激活时, 表示玩家已经按住了交互键,我们需要开始监听交互持续时间、玩家释放交互键的事件。

当持续时间达到阈值,则执行拾取————通过USAEStolenEggComponent持有ASAEWorldEgg,同时附加到玩家的Character上。

如果未达到时间阈值玩家释放了交互键,则直接取消。

虽然我们的案例中选择在Pickup中做计时,但在USAEGA_Interact做通用的交互时间逻辑也是可以的。

只是要注意扫描交互目标的逻辑应该只在客户端进行,需要做好条件判断。

我们还要广播Holding事件出去,好让UI侧展示进度条。

void USAEGA_Pickup::ActivateAbility(
	const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo,
	const FGameplayAbilityActivationInfo ActivationInfo,
	const FGameplayEventData* TriggerEventData)
{
	Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);

    //...

    //提取交互目标
	ResolveAndValidateTarget(TriggerEventData,FailureReason)

    //...

    //获取持续时间
	float Duration = 0.0f;
	if(const ISAEInteractableTarget* Interactable = Cast<ISAEInteractableTarget>(PickupTargetActor))
	{
		Duration = FMath::Max(0.0f,Interactable->GetInteractionDuration());
	}

    //...

    //创建等待玩家释放交互键的Task
	WaitInputReleaseTask = UAbilityTask_WaitInputRelease::WaitInputRelease(this,true);
	WaitInputReleaseTask->OnRelease.AddDynamic(this,&ThisClass::HandleInputRelease);
	WaitInputReleaseTask->ReadyForActivation();

    //如果持续时间为0,表示不需要玩家持续按住交互键
	if(Duration <= 0.0f)
	{
		HandleDurationFinished();
		return;
	}

    //创建计时Task
	WaitDurationTask = UAbilityTask_WaitDelay::WaitDelay(this,Duration);
	WaitDurationTask->OnFinish.AddDynamic(this,&ThisClass::HandleDurationFinished);
	WaitDurationTask->ReadyForActivation();

    //广播事件出去
    FSAEPickupHoldMessage HoldEvent;
	HoldEvent.Duration = Duration;
	HoldEvent.PickupInfo = CachedPickupInfo;
	HoldEvent.bIsHolding = true;//按住交互键
	UGameplayMessageSubsystem::Get(this).BroadcastMessage(
		SAEGameplayTags::Event_Interaction_UI_Hold,
		HoldEvent
		);
}

FSAEPickupHoldMessage事件定义:

USTRUCT(BlueprintType)
struct STEALANEGG_API FSAEPickupHoldMessage
{
	GENERATED_BODY()
    //当前是否正在按住交换键
	UPROPERTY(BlueprintReadOnly)
	bool bIsHolding = false;

	UPROPERTY(BlueprintReadOnly)
	float Duration = 0;

	UPROPERTY(BlueprintReadOnly)
	FSAEPickupInfo PickupInfo;
};

一旦玩家按住交互键的时间达到阈值,我们就调用交互目标ISAEPickupable的CommitPickup()函数,让交互目标自己决定自己“该如何被拾取”。 客户端只做预测,不能抢先结束Ability,需要在时间进度完成的回调HandleDurationFinished()里判断一下:

void USAEGA_Pickup::HandleDurationFinished()
{
    //...

    bDurationFinished = true;

    //...

    //客户端不调用EndAbility,直接return。
	if(!IsAuthorityAbility())
	{
		if(IsLocallyControlledAbility())
		{
			OnPickupPredictedComplete(PickupTargetActor,CachedPickupInfo);
		}
		return;
	}

    /* 调用交互目标的拾取接口 */
	ASAEPlayerCharacter* Character = GetSAECharacter();
	FText FailureReason;
    //服务器需要再次检查可否拾取
	if(!PickupTarget->CanBePickedUpBy(Character,FailureReason)
    //执行拾取
		|| !PickupTarget->CommitPickup(Character,FailureReason))
	{
		FailAndEnd(
			FailureReason.IsEmpty()?
			NSLOCTEXT("Pickup", "CommitFailed","拾取失败"):
			FailureReason
			);
		return;
	}

    //...

    //服务端执行完拾取逻辑后,调用EndAbility
    //EndAbility内还要广播Event_Interaction_UI_Hold事件,让UI侧隐藏进度条
    EndAbility(CurrentSpecHandle,CurrentActorInfo,CurrentActivationInfo,true,false);
}

玩家释放交互键时,取消Ability:

void USAEGA_Pickup::HandleInputRelease(float TimeHeld)
{
	if(!IsActive() || bDurationFinished)
	{
		return;//已经完成了,不需要Cancel
	}
    //取消当前Ability,最终会调用EndAbility结束技能。
	CancelAbility(CurrentSpecHandle,CurrentActorInfo,CurrentActivationInfo,true);
}

ASAEWorldEgg

在本案例中ASAEWorldEgg作为具体的交互目标,它最重要职责是提供具体的拾取逻辑。

前面的模块和代码提供的是抽象的交互流程、拾取流程,它们并不关心真正的交互对象是谁、被拾取的是谁以及具体拾取方式要怎么做。

ASAEWorldEgg将实现ISAEInteractableTarget和ISAEPickupable两个接口,实现具体的拾取逻辑。

拾取逻辑的大致调用链如下:

ASAEWorldEgg::CommitPickup() 已符合所有前置条件,执行拾取
    ↓
//通过玩家的USAEStolenEggComponent组件,保存玩家当前拾取的Egg的引用。
PlayerState::GetStolenEggComponent()::TryCarryEgg() 
    ↓
//StolenEggComponent内部最终调用的是USAEHandComponent的方法
//USAEHandComponent内部持有ASAEWorldEgg对应的道具ID,它不持有Actor引用。
PlayerState::GetHandComponent()::TryOccupyWithStolenEgg()
    ↓
//通知Character上“视觉表现”组件
Character::GetHeldItemPresentationComponent()::RefreshPresentation()

ASAEWorldEgg部分定义

UCLASS()
class STEALANEGG_API ASAEWorldEgg : public AActor,public ISAEInteractableTarget,public ISAEPickupable
{
	GENERATED_BODY()
public:
	ASAEWorldEgg();
    // ...
    
    /*交互信息*/
	virtual void GatherInteractionOptions(const ASAEPlayerCharacter* Character, FSAEInteractionOptionBuilder& Builder) const override;

    /*能否交互*/
	UFUNCTION(BlueprintCallable)
	virtual bool CanInteract(const ASAEPlayerCharacter* Character, FText& OutFailureReason) const override;

	UFUNCTION(BlueprintPure)
	virtual float GetInteractionDuration() const override
	{
		return PickDuration;
	}

    /*构建拾取信息*/
	virtual  FSAEPickupInfo GetPickupInfo() const override;

    /*能否被拾取*/
	virtual bool CanBePickedUpBy(const ASAEPlayerCharacter* Character, FText& OutFailureReason) const override;

    /*拾取操作*/
	virtual bool CommitPickup(ASAEPlayerCharacter* Character, FText& OutFailureReason) override;

    /*获取道具ID*/
	UFUNCTION(BlueprintPure)
	int32 GetItemDefinitionId() const
	{
		return ItemDefinitionId;
	}

    // ...
protected:
    // ...

	//道具配置表ID-> DT_InventoryConfigs
	UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Egg")
	int32 ItemDefinitionId = INDEX_NONE;

	// 必须配置为实际授予玩家的 USAEGA_Pickup 蓝图子类
	UPROPERTY(EditDefaultsOnly,BlueprintReadWrite,Category="Egg|Interaction")
	TSubclassOf<USAEGA_Pickup> PickupAbilityClass;
    
    // ...
private:
    // ...

    //拾取当前Egg的玩家
	UPROPERTY()
	TObjectPtr<APlayerState> CarryingPlayer;

    // ...
};

ASAEWorldEgg::CommitPickup()

在CommitPickup()函数中调用USAEStolenEggComponent来让玩家“持有”Egg。

bool ASAEWorldEgg::CommitPickup(ASAEPlayerCharacter* Character, FText& OutFailureReason)
{
	if(!HasAuthority())//只允许在服务端执行
	{
		return false;
	}

	//...

	ASAEPlayerState* PlayerState = Character ? Character->GetPlayerState<ASAEPlayerState>() : nullptr;
    //获取USAEStolenEggComponent组件
	USAEStolenEggComponent* StolenEggComponent = PlayerState->GetStolenEggComponent();
    //调用CarryEgg
	if(!StolenEggComponent || !StolenEggComponent->TryCarryEgg(this))
	{
		return false;
	}

	//...
	return true;
}

USAEStolenEggComponent::TryCarryEgg()

USAEStolenEggComponent::TryCarryEgg尝试保存ASAEWorldEgg引用,然后将道具ID传给USAEHandComponent。

同样,TryCarryEgg()只允许服务端调用:

bool USAEStolenEggComponent::TryCarryEgg(ASAEWorldEgg* Egg)
{
	ASAEPlayerState* PlayerState = Cast<ASAEPlayerState>(GetOwner());
	if(!PlayerState 
        || !PlayerState->HasAuthority() //只能在服务端调用
        || !Egg 
        || CarriedEgg)
	{
		return false;
	}
	if(CarriedEgg == Egg)
	{
		return false;
	}
	USAEHandComponent* HandComp = PlayerState->GetHandComponent();
    //调用USAEHandComponent::TryOccupyWithStolenEgg(),传递道具ID。
	if(!HandComp || !HandComp->TryOccupyWithStolenEgg(Egg->GetItemDefinitionId()))
	{
		return false;
	}

    //保存Actor引用
	CarriedEgg = Egg;
    
    // ...

	return true;
}

USAEHandComponent::TryOccupyWithStolenEgg()

USAEHandComponent组件更新道具ID和道具的持有方式,然后通知Character的USAEHeldItemPresentationComponent组件更新角色表现:

bool USAEHandComponent::TryOccupyWithStolenEgg(int32 ItemDefinitionId)
{
	ASAEPlayerState* PlayerState = Cast<ASAEPlayerState>(GetOwner());
	if(!PlayerState
		|| !PlayerState->HasAuthority()//只在服务端调用
		|| IsCarringStolenEgg()//已经持有蛋的状态下,不能持有其他蛋,必须先“放下”
		|| ItemDefinitionId <= INDEX_NONE)
	{
		return false;
	}

	// ...

	//如果当前手持快捷栏道具,道具仍然留在快捷栏,只是取消表现
	HandState = FSAEHandState();
	HandState.Source = ESAEHandItemSource::StolenEgg;
	HandState.ItemDefinitionId = ItemDefinitionId;

    //通知角色表现层
	ASAEPlayerCharacter* Character = Cast<ASAEPlayerCharacter>(PlayerState->GetPawn());
	if(USAEHeldItemPresentationComponent* Presentation = Character->GetHeldItemPresentationComponent())
	{
		Presentation->RefreshPresentation();
	}

    // ...
	return true;
}

USAEHeldItemPresentationComponent::RefreshPresentation()

对于玩家持有ASAEWorldEgg的表现,我们让Egg附加到Character指定的槽位上。

这里我们还要注意Dedicated Server上是不需要任何视觉表现的,判断GetNetMode()为NM_DedicatedServer时直接跳过。

更新表现大致逻辑如下:

void USAEHeldItemPresentationComponent::RefreshPresentation()
{
	ClearPresentation();
	//DS服务器不创建视觉Actor
	if(GetNetMode() == NM_DedicatedServer)
	{
		return;
	}

	const ASAEPlayerCharacter* Character = Cast<ASAEPlayerCharacter>(GetOwner());
	const ASAEPlayerState* PlayerState = Character ? Character->GetPlayerState<ASAEPlayerState>() : nullptr;
	const USAEHandComponent* Hand = PlayerState ? PlayerState->GetHandComponent() : nullptr;
	if(!Hand)
	{
		return;
	}
    //从USAEHandComponent组件获取持有的道具相关数据
	const FSAEHandState& HandState = Hand->GetHandStateRef();
	if(HandState.Source == ESAEHandItemSource::Quickbar)
	{
        //持有快捷栏内道具的表现
		// ...
	}
	else if(HandState.Source == ESAEHandItemSource::StolenEgg)
	{
		//StolenEgg不生成本地Actor,从USAEStolenEggComponent那里拿到ASAEWorldEgg指针。
		ASAEWorldEgg* CarriedEgg = PlayerState->GetStolenEggComponent()->GetCarriedEgg();
		if(CarriedEgg)
		{
            // ASAEWorldEgg 的服务端附加关系会复制到FrontCarryAnchor.
			CarriedEgg->AttachToComponent(Character->GetFrontCarryAnchor(),FAttachmentTransformRules::SnapToTargetNotIncludingScale);
		}
	}
}

玩家掉线

如果玩家在偷蛋逃跑过程中掉线了,就需要把持有的蛋“丢掉”,好让蛋复位或让其他玩家拾取。因此丢蛋的操作应该在服务第做。

GameMode::Logout()中的CleanupLeavingPlayers()虽然适合做掉线逻辑,但对于依赖角色位置的丢蛋操作,时机偏晚了。

我们选择在APlayerController::PawnLeavingGame()中处理掉蛋。因为在执行APlayerController::PawnLeavingGame()时Pawn还未销毁,刚好可以根据Pawn的位置计算掉落点。

代码如下:

void ASAEPlayerController::PawnLeavingGame()
{
	UWorld* World = GetWorld();
	if(HasAuthority() && World && !World->bIsTearingDown)//如果游戏世界正在销毁(World->bIsTearingDown),也就没有必要做掉落了。
	{
		ASAEPlayerState* SAEPlayerState = GetPlayerState();
		if (IsValid(SAEPlayerState))
		{
			USAEStolenEggComponent* StolenEggComponent =SAEPlayerState->GetStolenEggComponent();
			if (IsValid(StolenEggComponent))
			{
				StolenEggComponent->DropStolenEgg();//玩家掉线后,丢掉正在偷的蛋
			}
		}
	}
	Super::PawnLeavingGame();
}

客户端HUD

显隐交互按键

在HUD蓝图中监听Event_Interaction_UI_OptionsChanged事件,根据Options数量显示/隐藏交互按钮: HUD蓝图

播放按住按键进度条

在HUD蓝图中监听Event_Interaction_UI_Hold事件,开始或停止播放进度条动画: HUD蓝图

结果演示

扫描到“蛋”后弹出交互键提示

交互键提示

按住交互键

按住交互键

将“蛋”Attach到玩家正前方

置于玩家正前方

Discussion

留言讨论

无需注册账号
正在加载留言区…

发表留言时,评论服务会记录必要的 IP 与 User-Agent 信息,用于安全审核和防止滥用。