物品定义
有物品,才能把武器弄出来
先规划一下。
物品系统,lyra叫他库存 Inventory
首先一个物品要有它自己的配置,UE的配置喜欢起名 Definition
UCLASS(MinimalAPI, DefaultToInstanced, EditInlineNew, Abstract)
class UEqZeroInventoryItemFragment : public UObject
{
GENERATED_BODY()
public:
virtual void OnInstanceCreated(UEqZeroInventoryItemInstance* Instance) const {}
};
/**
* UEqZeroInventoryItemDefinition
*/
UCLASS(Blueprintable, Const, Abstract)
class UEqZeroInventoryItemDefinition : public UObject
{
GENERATED_BODY()
public:
UEqZeroInventoryItemDefinition(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category=Display)
FText DisplayName;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category=Display, Instanced)
TArray<TObjectPtr<UEqZeroInventoryItemFragment>> Fragments;
public:
const UEqZeroInventoryItemFragment* FindFragmentByClass(TSubclassOf<UEqZeroInventoryItemFragment> FragmentClass) const;
};
需要一个名字和片段。
比如有一个装备的片段,那么这个物品就是一个装备的物品。
UEqZeroInventoryItemFragment 这个就是片段,基类啥也没有。
片段的子类
UCLASS()
class UInventoryFragment_EquippableItem : public UEqZeroInventoryItemFragment
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category=EqZero)
TSubclassOf<UEqZeroEquipmentDefinition> EquipmentDefinition;
};
装备,关联的是装备的定义
UCLASS()
class UInventoryFragment_PickupIcon : public UEqZeroInventoryItemFragment
{
GENERATED_BODY()
public:
UInventoryFragment_PickupIcon();
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
TObjectPtr<USkeletalMesh> SkeletalMesh;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FText DisplayName;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FLinearColor PadColor;
};
一个能捡起来的东西,包括skeletal mesh,显示的名字和颜色。这是干嘛的我忘了。。。
UCLASS()
class UInventoryFragment_QuickBarIcon : public UEqZeroInventoryItemFragment
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FSlateBrush Brush;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FSlateBrush AmmoBrush;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FText DisplayNameWhenEquipped;
};
另一个物品,QuickBar 记得是右下角,手枪,步枪哪个武器切换那里的装备。代表装备栏的装备?
UCLASS()
class UInventoryFragment_SetStats : public UEqZeroInventoryItemFragment
{
GENERATED_BODY()
protected:
UPROPERTY(EditDefaultsOnly, Category=Equipment)
TMap<FGameplayTag, int32> InitialItemStats;
public:
virtual void OnInstanceCreated(UEqZeroInventoryItemInstance* Instance) const override;
int32 GetItemStatByTag(FGameplayTag Tag) const;
};
这个更通用了,直接一个TAG=>int32。直接拿来描述子弹,例如子弹30发。
物品实例
/**
* UEqZeroInventoryItemInstance
*/
UCLASS(BlueprintType)
class UEqZeroInventoryItemInstance : public UObject
{
GENERATED_BODY()
private:
UPROPERTY(Replicated)
FGameplayTagStackContainer StatTags;
UPROPERTY(Replicated)
TSubclassOf<UEqZeroInventoryItemDefinition> ItemDef;
};
前面的定义相当于配置表,在角色身上应该有具体的实例物品。包括一个定义,一个tag=>数量的维护。
这个tag到数量能维护很多信息。例如物品的数量,除此之外都是一些辅助接口。和IRIS目前我们暂不关心。
库存组件
UCLASS(MinimalAPI, BlueprintType)
class UEqZeroInventoryManagerComponent : public UActorComponent
{
GENERATED_BODY()
private:
UPROPERTY(Replicated)
FEqZeroInventoryList InventoryList;
};
InventoryList 是一个 Fast Array
USTRUCT(BlueprintType)
struct FEqZeroInventoryEntry : public FFastArraySerializerItem
{
GENERATED_BODY()
private:
UPROPERTY()
TObjectPtr<UEqZeroInventoryItemInstance> Instance = nullptr;
UPROPERTY()
int32 StackCount = 0;
UPROPERTY(NotReplicated)
int32 LastObservedCount = INDEX_NONE;
};
USTRUCT(BlueprintType)
struct FEqZeroInventoryList : public FFastArraySerializer
{
GENERATED_BODY()
private:
UPROPERTY()
TArray<FEqZeroInventoryEntry> Entries;
UPROPERTY(NotReplicated)
TObjectPtr<UActorComponent> OwnerComponent;
};
简单理解组件上有一个物品数组,数组的每一项是物品实例和数量的组合
我们看看相关接口
//~FFastArraySerializer contract
void PreReplicatedRemove(const TArrayView<int32> RemovedIndices, int32 FinalSize);
void PostReplicatedAdd(const TArrayView<int32> AddedIndices, int32 FinalSize);
void PostReplicatedChange(const TArrayView<int32> ChangedIndices, int32 FinalSize);
//~End of FFastArraySerializer contract
物品变化,广播物品数量变化,并更新 LastObservedCount。
需要关注的地方自己会监听这个消息。
UEqZeroInventoryItemInstance* AddEntry(const TSubclassOf<UEqZeroInventoryItemDefinition> &ItemClass, int32 StackCount);
void AddEntry(UEqZeroInventoryItemInstance* Instance);
void RemoveEntry(UEqZeroInventoryItemInstance* Instance);
几个操作,
根据物品定义添加对应物品 若干。
void AddEntry(UEqZeroInventoryItemInstance* Instance);
- 未实现
void RemoveEntry(UEqZeroInventoryItemInstance* Instance);
- 找到这个物品实例,然后全部删除
然后就是 UEqZeroInventoryManagerComponent 这个组件类。都是对于这个Array的CURD。但是这里有个网络同步的问题
物品网络同步
UE 的网络系统(Replication System)只认 AActor
- 如果你有一个 AActor,引擎会自动帮你同步。
- 如果你在 Actor 里有个 int32 Health,引擎也会通过 DOREPLIFETIME 同步。
痛点:如果你有一个 UObject* MyItem 指针:
- 引擎只能同步这个指针的地址(NetGUID)。
- 引擎不会自动把这个对象内部的数据(比如 Item 的 Level, XP)打包发给客户端。
- 如果客户端不存在这个对象,指针同步过来就是 NULL。
具体流程:
【1】
由于我们处理的是这个指针,
USTRUCT(BlueprintType)
struct FEqZeroInventoryEntry : public FFastArraySerializerItem
{
UPROPERTY()
TObjectPtr<UEqZeroInventoryItemInstance> Instance = nullptr;
}
所以这个UObject要实现 IsSupportedForNetworking
/**
* UEqZeroInventoryItemInstance
*/
UCLASS(BlueprintType)
class UEqZeroInventoryItemInstance : public UObject
{
GENERATED_BODY()
//~UObject interface
virtual bool IsSupportedForNetworking() const override { return true; }
//~End of UObject interface
}
【2】
实现UObject类的这个函数
void UEqZeroInventoryItemInstance::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, StatTags);
DOREPLIFETIME(ThisClass, ItemDef);
}
ps: 只要不是纯IRIS都是必须的
这里有个非必需的宏包裹的函数
#if UE_WITH_IRIS
void UEqZeroInventoryItemInstance::RegisterReplicationFragments(UE::Net::FFragmentRegistrationContext& Context, UE::Net::EFragmentRegistrationFlags RegistrationFlags)
{
using namespace UE::Net;
// Build descriptors and allocate PropertyReplicationFragments for this object
// 为该对象构建描述符并分配属性复制片段
FReplicationFragmentUtil::CreateAndRegisterFragmentsForObject(this, Context, RegistrationFlags);
}
#endif // UE_WITH_IRIS
- 传统方式(上面的 GetLifetimeReplicatedProps)是通过反射遍历属性,效率在海量物体时有瓶颈
- Iris 方式:RegisterReplicationFragments 将状态预先分块(Fragments),以获得极其高效的并发序列化性能。
在组件中实现
bool UEqZeroInventoryManagerComponent::ReplicateSubobjects(UActorChannel* Channel, class FOutBunch* Bunch, FReplicationFlags* RepFlags)
{
bool WroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);
for (FEqZeroInventoryEntry& Entry : InventoryList.Entries)
{
UEqZeroInventoryItemInstance* Instance = Entry.Instance;
if (Instance && IsValid(Instance))
{
WroteSomething |= Channel->ReplicateSubobject(Instance, *Bunch, *RepFlags);
}
}
return WroteSomething;
}
这是把每一个Fast Array 的 Item序列化的方法。
网络复制的时候就会直接跑进 ReplicateSubobjects 这个写法。
【这是老的写法】
但是,这种写法,每次网络更新(Tick)都要遍历整个数组。
所以:
UE5 引入了“注册列表(Registered SubObject List)”模式:
只需要在对象创建时调用一次 AddReplicatedSubObject(Item),销毁时调用 RemoveReplicatedSubObject。
引擎内部维护一个列表。网络更新时,引擎自己去检查这个列表里的对象状态,不需要你写那个 ReplicateSubobjects 函数了(或者说引擎替你做了)。
UEqZeroInventoryItemInstance* UEqZeroInventoryManagerComponent::AddItemDefinition(TSubclassOf<UEqZeroInventoryItemDefinition> ItemDef, int32 StackCount)
{
UEqZeroInventoryItemInstance* Result = nullptr;
if (ItemDef != nullptr)
{
Result = InventoryList.AddEntry(ItemDef, StackCount);
if (IsUsingRegisteredSubObjectList() && IsReadyForReplication() && Result)
{
AddReplicatedSubObject(Result);
}
}
return Result;
}
void UEqZeroInventoryManagerComponent::AddItemInstance(UEqZeroInventoryItemInstance* ItemInstance)
{
InventoryList.AddEntry(ItemInstance);
if (IsUsingRegisteredSubObjectList() && IsReadyForReplication() && ItemInstance)
{
AddReplicatedSubObject(ItemInstance);
}
}
void UEqZeroInventoryManagerComponent::RemoveItemInstance(UEqZeroInventoryItemInstance* ItemInstance)
{
InventoryList.RemoveEntry(ItemInstance);
if (ItemInstance && IsUsingRegisteredSubObjectList())
{
RemoveReplicatedSubObject(ItemInstance);
}
}
两种方式的使用和切换:
- ReadyForReplication:这是组件初始化网络时的回调。在这里根据 IsUsingRegisteredSubObjectList()(通常由项目设置决定是否开启)来决定是否把现有的物品注册进列表。
- 如果开启了这个特性,使用 AddReplicatedSubObject。
- 如果没开启,可能回退到传统的 ReplicateSubobjects 循环。
这两种方式切换的关键是
bReplicateUsingRegisteredSubObjectList = true;
可拾取接口
一个交互物Actor,他也同时可以被拾取。
身上挂一个这个,这里可以通过 物品定义+数量的 方式提供给 交互后转换为物品的信息
USTRUCT(BlueprintType)
struct FInventoryPickup
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FPickupInstance> Instances;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FPickupTemplate> Templates;
};
这个Actor通过实现可拾取接口,来提供 FInventoryPickup 给外部系统
class IEqZeroPickupable
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
virtual FInventoryPickup GetPickupInventory() const = 0;
};
然后可拾取就OK了
其余还有一个简单的辅助接口
这个是用在那里的呢?
有一个实例地图,里面有一个交互物Actor,他同时实现了交互和可拾取的接口。
交互
L_InventoryTestMap
/ShooterExplorer/Maps/L_InventoryTestMap.L_InventoryTestMap
物品测试地图,场景里的方块是 /ShooterExplorer/Items/B_InteractableRock.B_InteractableRock 是
UCLASS(Abstract, Blueprintable)
class AEqZeroWorldCollectable : public AActor, public IInteractableTarget, public IPickupable
{
protected:
UPROPERTY(EditAnywhere)
FInteractionOption Option;
UPROPERTY(EditAnywhere)
FInventoryPickup StaticInventory;
};
FInventoryPickup 前面提过,描述了交互成功后生成的是什么物品
FInteractionOption 描述了 交互的本身,例如交互的时候UI是什么,交互技能是什么

这个石头的配置,交互的时候显示文本 Collect,交互的时候获得 GA_..._Collect技能

跑起来是这样。按G获取物品。
I 打开背包,能看到刚刚获得的物品
M 打开地图,但是地图没东西。
B 表情技能,跳舞。
P 在目标位置创建一个检测的球
===
/ShooterExplorer/Input/Abilities/AbilitySet_InventoryTest.AbilitySet_InventoryTest
这个配置能找到四个技能
GA_Interact,GA_ToggleMap,GA_ToggleInventory,GA_ToggleMarkerInWorld
查一下快捷键【G, M, I, P】
找到这里技能
/ShooterExplorer/Input/Abilities/GA_Interact.GA_Interact
/ShooterExplorer/Interact/GA_Interaction_Collect.GA_Interaction_Collect
GA_Interact 一直是激活中。
GA_Interaction_Collect 未激活状态。查看引用是那个交互的石头上配置的。
GA_Interact
从GA_Interact 蓝图开始
ActivateAbility => Look For Interactables => Interact Press Scan
偷偷看下父类,重点OnSpawn,其他InstancedPerActor,LocalPredicted
ULyraGameplayAbility_Interact::ULyraGameplayAbility_Interact(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
ActivationPolicy = ELyraAbilityActivationPolicy::OnSpawn;
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
}
UAbilityTask_GrantNearbyInteraction
UAbilityTask_GrantNearbyInteraction 激活的时候服务器启动这个异步任务
void UEqZeroGameplayAbility_Interact::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
UAbilitySystemComponent* AbilitySystem = GetAbilitySystemComponentFromActorInfo();
if (AbilitySystem && AbilitySystem->GetOwnerRole() == ROLE_Authority)
{
UAbilityTask_GrantNearbyInteraction* Task = UAbilityTask_GrantNearbyInteraction::GrantAbilitiesForNearbyInteractors(this, InteractionScanRange, InteractionScanRate);
Task->ReadyForActivation();
}
}
可以看得出来,这个异步任务以一定的频率执行检测
void UAbilityTask_GrantNearbyInteraction::Activate()
{
SetWaitingOnAvatar();
UWorld* World = GetWorld();
World->GetTimerManager().SetTimer(QueryTimerHandle, this, &ThisClass::QueryInteractables, InteractionScanRate, true);
}
每次检测玩家一定范围内的交互物,查看是否能赋予玩家这个交互技能
void UAbilityTask_GrantNearbyInteraction::QueryInteractables()
{
UWorld* World = GetWorld();
AActor* ActorOwner = GetAvatarActor();
if (World && ActorOwner)
{
FCollisionQueryParams Params(SCENE_QUERY_STAT(UAbilityTask_GrantNearbyInteraction), false);
// 球形碰撞检测
TArray<FOverlapResult> OverlapResults;
World->OverlapMultiByChannel(OUT OverlapResults, ActorOwner->GetActorLocation(),
FQuat::Identity, EqZero_TraceChannel_Interaction, FCollisionShape::MakeSphere(InteractionScanRange), Params);
if (OverlapResults.Num() > 0)
{
// 从重叠结果中获取所有的可交互目标
TArray<TScriptInterface<IInteractableTarget>> InteractableTargets;
UInteractionStatics::AppendInteractableTargetsFromOverlapResults(OverlapResults, OUT InteractableTargets);
FInteractionQuery InteractionQuery;
InteractionQuery.RequestingAvatar = ActorOwner;
InteractionQuery.RequestingController = Cast<AController>(ActorOwner->GetOwner());
// 遍历可交互的目标
TArray<FInteractionOption> Options;
for (TScriptInterface<IInteractableTarget>& InteractiveTarget : InteractableTargets)
{
// InteractiveTarget 就是场景中实现了可交互接口的 Actor,从中收集 Options
FInteractionOptionBuilder InteractionBuilder(InteractiveTarget, Options);
InteractiveTarget->GatherInteractionOptions(InteractionQuery, InteractionBuilder);
}
// 根据收集的Options检查是否有交互限制
for (FInteractionOption& Option : Options)
{
if (Option.InteractionAbilityToGrant)
{
// 赋予玩家这个对应的交互技能
FObjectKey ObjectKey(Option.InteractionAbilityToGrant);
if (!InteractionAbilityCache.Find(ObjectKey))
{
FGameplayAbilitySpec Spec(Option.InteractionAbilityToGrant, 1, INDEX_NONE, this);
FGameplayAbilitySpecHandle Handle = AbilitySystemComponent->GiveAbility(Spec);
InteractionAbilityCache.Add(ObjectKey, Handle);
}
}
}
}
}
}
UAbilityTask_WaitForInteractableTargets_SingleLineTrace
另外,在蓝图中这个Look For Interactables
激活的时候调用 UAbilityTask_WaitForInteractableTargets_SingleLineTrace
父类是 UAbilityTask_WaitForInteractableTargets
void UAbilityTask_WaitForInteractableTargets_SingleLineTrace::Activate()
{
SetWaitingOnAvatar();
UWorld* World = GetWorld();
World->GetTimerManager().SetTimer(TimerHandle, this, &ThisClass::PerformTrace, InteractionScanRate, true);
}
主要还是tick执行 PerformTrace
void UAbilityTask_WaitForInteractableTargets_SingleLineTrace::PerformTrace()
{
AActor* AvatarActor = Ability->GetCurrentActorInfo()->AvatarActor.Get();
if (!AvatarActor)
{
return;
}
UWorld* World = GetWorld();
TArray<AActor*> ActorsToIgnore;
ActorsToIgnore.Add(AvatarActor);
const bool bTraceComplex = false;
FCollisionQueryParams Params(SCENE_QUERY_STAT(UAbilityTask_WaitForInteractableTargets_SingleLineTrace), bTraceComplex);
Params.AddIgnoredActors(ActorsToIgnore);
/*
* 利用玩家摄像机的朝向,结合 TraceStart 和 InteractionScanRange(交互距离)\
* 输出 TraceEnd
*/
FVector TraceStart = StartLocation.GetTargetingTransform().GetLocation();
FVector TraceEnd;
AimWithPlayerController(AvatarActor, Params, TraceStart, InteractionScanRange, OUT TraceEnd);
// 计算好的 Start 和 End 进行真正的物理射线检测
FHitResult OutHitResult;
LineTrace(OutHitResult, World, TraceStart, TraceEnd, TraceProfile.Name, Params);
// 从射线检测的结果中获取所有的可交互目标
TArray<TScriptInterface<IInteractableTarget>> InteractableTargets;
UInteractionStatics::AppendInteractableTargetsFromHitResult(OutHitResult, InteractableTargets);
// 更新交互选项
UpdateInteractableOptions(InteractionQuery, InteractableTargets);
}
void UAbilityTask_WaitForInteractableTargets::UpdateInteractableOptions(const FInteractionQuery& InteractQuery, const TArray<TScriptInterface<IInteractableTarget>>& InteractableTargets)
{
TArray<FInteractionOption> NewOptions;
/*
* 遍历所有交互物,从中获取能激活技能的Options
* 1. 如果Option里直接有技能了,就用这个技能
* 2. 如果Option里没有技能,但是有技能类,就先从玩家身上赋予这个技能,再用它
* 3. 如果Option里没有技能也没有技能类,那这个Option就没法用了,丢弃
* 4. 最后根据玩家当前的状态(例如,是否在CD中,是否满足激活条件等)来过滤掉一些Option
*/
for (const TScriptInterface<IInteractableTarget>& InteractiveTarget : InteractableTargets)
{
TArray<FInteractionOption> TempOptions;
FInteractionOptionBuilder InteractionBuilder(InteractiveTarget, TempOptions);
InteractiveTarget->GatherInteractionOptions(InteractQuery, InteractionBuilder);
for (FInteractionOption& Option : TempOptions)
{
FGameplayAbilitySpec* InteractionAbilitySpec = nullptr;
if (Option.TargetAbilitySystem && Option.TargetInteractionAbilityHandle.IsValid())
{
InteractionAbilitySpec = Option.TargetAbilitySystem->FindAbilitySpecFromHandle(Option.TargetInteractionAbilityHandle);
}
else if (Option.InteractionAbilityToGrant)
{
InteractionAbilitySpec = AbilitySystemComponent->FindAbilitySpecFromClass(Option.InteractionAbilityToGrant);
if (InteractionAbilitySpec)
{
Option.TargetAbilitySystem = AbilitySystemComponent.Get();
Option.TargetInteractionAbilityHandle = InteractionAbilitySpec->Handle;
}
}
if (InteractionAbilitySpec)
{
if (InteractionAbilitySpec->Ability->CanActivateAbility(InteractionAbilitySpec->Handle, AbilitySystemComponent->AbilityActorInfo.Get()))
{
NewOptions.Add(Option);
}
}
}
}
// 检查新旧 Options 是否有变化,如果有变化就广播事件
bool bOptionsChanged = false;
if (NewOptions.Num() == CurrentOptions.Num())
{
NewOptions.Sort();
for (int OptionIndex = 0; OptionIndex < NewOptions.Num(); OptionIndex++)
{
const FInteractionOption& NewOption = NewOptions[OptionIndex];
const FInteractionOption& CurrentOption = CurrentOptions[OptionIndex];
if (NewOption != CurrentOption)
{
bOptionsChanged = true;
break;
}
}
}
else
{
bOptionsChanged = true;
}
if (bOptionsChanged)
{
CurrentOptions = NewOptions;
InteractableObjectsChanged.Broadcast(CurrentOptions);
}
}
如果这个事件广播了,蓝图中就会调用 UpdateInteractions 这里面主要是一些UI上的更新
如果有案件激活就会触发 UEqZeroGameplayAbility_Interact::TriggerInteraction
这里面会具体的激活这个技能。
/ShooterExplorer/Maps/L_InteractionTestMap.L_InteractionTestMap
另一个地图怎么样呢?emmm 体验配置是一样的
区别貌似是,椅子交互技能无法拾取这一个区别。
获得物品
好像没看到获得的物品是怎么样的
/ShooterExplorer/Interact/GA_Interaction_Sit.GA_Interaction_Sit
/ShooterExplorer/Interact/GA_Interaction_Collect.GA_Interaction_Collect
这是需要获得这个交互物的具体技能,其中sit我们试过了拿不到。我们先看看Collect
- GA_Interaction_Collect
激活技能的时候
关闭物体碰撞,设置生命周期3s
激活一个Cue做效果,关闭碰撞,然后物体玩角色运动,缩小获得的效果。
如果权威:
调用加物品接口,播放蒙太奇
- GA_Interaction_Sit
有什么不同的呢?只是蓝图里面没写加物品那一句。
交互系统总结
通过体验配置的技能集合,为角色添加了 GA_Interact 这个技能。
这个技能会启动一个ability task - UAbilityTask_GrantNearbyInteraction
这个task会一直做范围内的碰撞检测,检测到交互物,就从交互物上获取交互技能加到角色身上。
角色激活这个交互技能,就可以做一些自定义逻辑,例如可以获得物品