引言
游戏的核心玩法——偷蛋,涉及到玩家与关卡中物体之间的交互。基本流程是:玩家靠近可偷取的“蛋”,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蓝图中监听Event_Interaction_UI_Hold事件,开始或停止播放进度条动画:

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

按住交互键

将“蛋”Attach到玩家正前方


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