Lyra Inventory 物品系统模块深度分析------从数据定义到网络同步的完整链路
写在前面:一个好的物品系统长什么样?
做过多人在线游戏的开发者大概都有这种体会------物品系统是个表面上看起来简单,实际上极容易越做越乱的模块。一开始可能只是"这个Actor能捡起那把枪",但很快需求就变成了:枪有稀有度、枪要消耗弹药、不同角色拿同一把枪要有不同属性、DLC加新武器不能改代码......
如果一开始就只用"纯C++类 + 硬编码属性"的方式搭积木,需求一变,连锁反应往往让人想重写。
Lyra 作为 Epic 官方的 UE5 示例项目,在 Inventory 模块上展示了一套值得仔细研究的设计思路------把数据和逻辑彻底分开,用组合代替继承 。本文将对 Source/LyraGame/Inventory 目录下的全部代码做一次系统性拆解。
目录
- 模块概述
- 整体架构解析
- 核心类深度解析
- [Fragment 子系统详解](#Fragment 子系统详解)
- 跨模块集成机制
- 网络复制机制深度分析
- 拾取系统:从世界到背包的最后一公里
- 总结与设计启示
1 模块概述
1.1 基本信息
| 属性 | 值 |
|---|---|
| 模块位置 | Source/LyraGame/Inventory/ |
| 文件数量 | 12 个文件(6 对 .h/.cpp) |
| 核心类数量 | 8 个类 + 2 个结构体 + 1 个接口 |
| 依赖模块 | Core, CoreUObject, Engine, GameplayTags, GameplayMessageSubsystem |
文件清单:
Inventory/
├── IPickupable.h / .cpp ------ 拾取接口与辅助函数
├── LyraInventoryItemDefinition.h / .cpp ------ 物品定义(模板数据)
├── LyraInventoryItemInstance.h / .cpp ------ 物品运行时实例
├── LyraInventoryManagerComponent.h / .cpp ------ 背包管理器(核心)
├── InventoryFragment_EquippableItem.h / .cpp ------ 可装备Fragment
├── InventoryFragment_PickupIcon.h / .cpp ------ 拾取显示Fragment
├── InventoryFragment_QuickBarIcon.h / .cpp ------ 快捷栏显示Fragment
└── InventoryFragment_SetStats.h / .cpp ------ 初始属性Fragment
1.2 模块在 Lyra 中的定位
Inventory 模块不是孤立存在的。它往上对接了 GAS(Gameplay Ability System)的技能消耗系统,往旁侧桥接了装备系统(LyraEquipmentDefinition),往下则通过 GameplayMessageSubsystem 向其他系统广播物品变更。它是 Lyra 多模块架构中一条绕不开的"主干道"。
2 整体架构解析
2.1 三层架构图
Lyra Inventory 的架构可以用"三层分离"来概括:
┌─────────────────────────────────────────────────────────────────────┐
│ 第一层:数据定羲层 (Definition) │
│ ┌───────────────────────────────────┐ ┌─────────────────────────┐ │
│ │ ULyraInventoryItemDefinition │ │ ULyraInventoryItemFragment│ │
│ │ - DisplayName │ │ - 纯虚函数: │ │
│ │ - TArray<Fragment> │ │ OnInstanceCreated() │ │
│ │ - FindFragmentByClass() │ │ (子类重写实现) │ │
│ └───────────────┬───────────────────┘ └─────────────┬─────────────┘ │
│ │ │ │
│ │ ItemDef 是由 N 个 Fragment 组合而成 │
│ │ (类 ECS 组合模式) │
│ └──────────────────┬─────────────────┘ │
│ │ │
├─────────────────────────────────────┼─────────────────────────────────┤
│ 第二层:运行时实例层 (Instance) │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ ULyraInventoryItemInstance │ │
│ │ - ItemDef (TSubclassOf, Replicated) │ │
│ │ - StatTags (FGameplayTagStackContainer, Replicated) │ │
│ │ - FindFragmentByClass() - AddStatTagStack() │ │
│ │ - RemoveStatTagStack() - GetStatTagStackCount() │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
├───────────────────────────────────────────────────────────────────────┤
│ 第三层:管理调度层 (Manager) │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ ULyraInventoryManagerComponent (UActorComponent) │ │
│ │ - FLyraInventoryList InventoryList (Replicated) │ │
│ │ - AddItemDefinition() - RemoveItemInstance() │ │
│ │ - GetAllItems() - ConsumeItemsByDefinition()│ │
│ │ - FindFirstItemStackByDefinition() │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ FLyraInventoryList (FFastArraySerializer) │ │
│ │ - TArray<FLyraInventoryEntry> Entries (Replicated) │ │
│ │ - AddEntry() - RemoveEntry() - GetAllItems() │ │
│ │ - PreReplicatedRemove / PostReplicatedAdd / PostReplicatedChange│ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ FLyraInventoryEntry (FFastArraySerializerItem) │ │
│ │ - Instance (TObjectPtr<ULyraInventoryItemInstance>) │ │
│ │ - StackCount (int32) │ │
│ │ - LastObservedCount (NotReplicated, 客户端本地追踪用) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
2.2 模块间依赖关系
ULyraInventoryManagerComponent (管理者)
│ 持有
▼
FLyraInventoryList (复制列表)
│ 包含多个
▼
FLyraInventoryEntry (单个条目)
│ 指向
▼
ULyraInventoryItemInstance (运行时实例)
│ 引用
▼
ULyraInventoryItemDefinition (静态模板数据)
│ 包含多个
▼
ULyraInventoryItemFragment (功能碎片)
├─── UInventoryFragment_EquippableItem ──→ ULyraEquipmentDefinition (装备系统)
├─── UInventoryFragment_SetStats ──→ FGameplayTagStackContainer (属性系统)
├─── UInventoryFragment_PickupIcon (拾取UI)
└─── UInventoryFragment_QuickBarIcon (快捷栏UI)
初看上去,从 Definition 到 Fragment 再到 Instance 和 Manager 的层级有点多。但每一层都有明确且不可替代的职责:
- Definition 是"策划手里的 Excel 表"------它只描述"这件物品是什么",不参与任何运行时逻辑。
- Fragment 是 Definition 的扩展单元------每种功能(装备、属性、UI)挂一个 Fragment,新增功能不需要改原有代码。
- Instance 是"玩家背包里实际存在的那一件"------它有自己的状态(属性叠加数),知道自己属于哪种物品。
- Manager 负责复制、增删、查询------是网络同步的唯一入口。
这种层级最大的好处是:改 Definition 不需要动 Instance,加 Fragment 不需要动 Manager。各层之间的耦合只通过接口,不通过具体实现。
2.3 模块划分
1. ULyraInventoryItemFragment / ULyraInventoryItemDefinition ------ 数据定义层
职责:
- 为每件物品提供"静态模板数据"
- 通过 Fragment 数组实现功能组合
- 提供
FindFragmentByClass()按类型检索 Fragment
设计意图:
ULyraInventoryItemDefinition标记为Const、Abstract、Blueprintable,意味着它是一份只读的蓝图数据资产,在运行时不会被修改ULyraInventoryItemFragment标记为DefaultToInstanced+EditInlineNew,使得在编辑器中可以在 Definition 蓝图里直接"内联"创建和编辑 Fragment 子对象
2. ULyraInventoryItemInstance ------ 运行时实例层
职责:
- 持有对静态 Definition 的引用(
TSubclassOf<ItemDef>,网络复制) - 维护动态属性栈(
FGameplayTagStackContainer,网络复制) - 提供
FindFragmentByClass()桥接到 Definition 的 Fragment
3. FLyraInventoryList / FLyraInventoryEntry ------ 网络复制层
职责:
- 使用
FFastArraySerializer实现高效增量复制 - 每个 Entry 持有一个 Instance 指针和一个 StackCount
- 通过
PreReplicatedRemove/PostReplicatedAdd/PostReplicatedChange在客户端收到复制数据时广播变更消息
4. ULyraInventoryManagerComponent ------ 业务逻辑层
职责:
- 挂载在拥有背包的 Actor 上(通常是 Controller 或 Pawn)
- 提供添加/移除/查询/消耗物品的所有接口
- 管理子对象的复制注册
2.4 数据流走向
添加物品的完整流程:
1. 服务器调用 AddItemDefinition(ItemDef, StackCount)
│
▼
2. InventoryList.AddEntry(ItemDef, StackCount)
│ ├── NewObject<ULyraInventoryItemInstance>() 创建运行时实例
│ ├── Instance->SetItemDef(ItemDef) 绑定定义引用
│ ├── 遍历 Definition 的所有 Fragment,调用 OnInstanceCreated()
│ │ └── (例) UInventoryFragment_SetStats 向 Instance 写入初始属性
│ └── MarkItemDirty() 标记复制脏位
│
▼
3. 服务器侧自动复制到所有客户端
│ └── FastArraySerializer 增量序列化
│
▼
4. 客户端侧 PostReplicatedAdd() 触发
│ └── BroadcastChangeMessage() → GameplayMessageSubsystem 广播
│
▼
5. 其他系统(UI、GAS等)收到消息,更新显示
消耗物品的流程:
1. GAS 技能触发 ApplyCost → ULyraAbilityCost_InventoryItem
│
▼
2. ConsumeItemsByDefinition(ItemDef, NumToConsume)
│ └── 循环调用 FindFirstItemStackByDefinition + RemoveEntry
│
▼
3. FLyraInventoryList::RemoveEntry() 标记数组脏位
│
▼
4. 客户端 PreReplicatedRemove() → BroadcastChangeMessage(OldCount, 0)
3 核心类深度解析
3.1 ULyraInventoryItemFragment ------ 最小的功能单元
ULyraInventoryItemFragment 是整个 Inventory 模块的基因。它只有一个虚函数:
cpp
UCLASS(DefaultToInstanced, EditInlineNew, Abstract)
class LYRAGAME_API ULyraInventoryItemFragment : public UObject
{
GENERATED_BODY()
public:
virtual void OnInstanceCreated(ULyraInventoryItemInstance* Instance) const {}
};
三个关键标记的含义:
| 标记 | 作用 |
|---|---|
DefaultToInstanced |
当作为 UProperty 被包含时,引擎自动为每个实例创建独立的子对象(而非共享同一个 CDO) |
EditInlineNew |
在蓝图编辑器中,可以直接在父对象的 Details 面板里创建和编辑该类的子对象 |
Abstract |
不能直接实例化,必须派生使用 |
OnInstanceCreated 方法是一个回调钩子,在物品实例被创建出来时调用。默认是空实现------子类按需重写。比如 UInventoryFragment_SetStats 就在这个方法里把预配置的属性写入 Instance。
这种设计的意图是:每个 Fragment 代表物品的某一个"能力维度",Fragment 之间互不感知。加新维度(比如加个"交易定价Fragment")只需要新建一个派生类,不改任何已有代码。
3.2 ULyraInventoryItemDefinition ------ 物品的"身份证"
cpp
UCLASS(Blueprintable, Const, Abstract)
class ULyraInventoryItemDefinition : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category=Display)
FText DisplayName;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category=Display, Instanced)
TArray<TObjectPtr<ULyraInventoryItemFragment>> Fragments;
const ULyraInventoryItemFragment* FindFragmentByClass(
TSubclassOf<ULyraInventoryItemFragment> FragmentClass) const;
};
每个 Definition 蓝图就是一件"物品模板"。它持有:
- DisplayName:给玩家看的名字
- Fragments:一组内联的 Fragment 子对象,定义了这件物品的各种能力
FindFragmentByClass 的查找逻辑很简单------遍历 Fragments 数组,用 IsA() 判断类型是否匹配:
cpp
const ULyraInventoryItemFragment* ULyraInventoryItemDefinition::FindFragmentByClass(
TSubclassOf<ULyraInventoryItemFragment> FragmentClass) const
{
if (FragmentClass != nullptr)
{
for (ULyraInventoryItemFragment* Fragment : Fragments)
{
if (Fragment && Fragment->IsA(FragmentClass))
{
return Fragment;
}
}
}
return nullptr;
}
这里有一个容易被忽略的细节:FindFragmentByClass 返回的是 const 指针。因为 Definition 在设计上就是只读的,运行时如果需要可写状态,应该去 Instance 上操作。
3.3 ULyraInventoryItemInstance ------ 物品的"活体"
如果说 Definition 是物品的模具,那 Instance 就是模具浇铸出来的实物。同一个 Definition 可以被实例化多次(比如背包里有两把相同的剑),每个 Instance 独立维护自己的运行时状态:
cpp
UCLASS(BlueprintType)
class ULyraInventoryItemInstance : public UObject
{
GENERATED_BODY()
public:
// 获取所属的 Definition 类型
TSubclassOf<ULyraInventoryItemDefinition> GetItemDef() const { return ItemDef; }
// 属性操作(基于 GameplayTag 的栈系统)
UFUNCTION(BlueprintCallable, BlueprintAuthorityOnly)
void AddStatTagStack(FGameplayTag Tag, int32 StackCount);
void RemoveStatTagStack(FGameplayTag Tag, int32 StackCount);
int32 GetStatTagStackCount(FGameplayTag Tag) const;
bool HasStatTag(FGameplayTag Tag) const;
// 按类型查找 Fragment(实际转发到 Definition)
const ULyraInventoryItemFragment* FindFragmentByClass(
TSubclassOf<ULyraInventoryItemFragment> FragmentClass) const;
private:
UPROPERTY(Replicated)
FGameplayTagStackContainer StatTags; // 动态属性栈(可复制)
UPROPERTY(Replicated)
TSubclassOf<ULyraInventoryItemDefinition> ItemDef; // 指向静态定义
};
亮点分析:
-
属性用 GameplayTag + Stack 表示,而不是传统 int/float 字段
这点很重要。传统的做法是:
cppint32 AttackPower; float CritRate; int32 Durability;而 Lyra 的做法是:
cppStatTags.AddStack(Tag_AttackPower, 10); StatTags.AddStack(Tag_CritRate, 5);前者的问题是:每加一种新属性就要改 Instance 的类定义,牵一发动全身。后者的 Tag 只需要在项目设置里注册一下就能用,Instance 的类定义永远不需要改。
-
Instance 本身不持有 Fragment 数据,而是转发给 Definition
cppconst ULyraInventoryItemFragment* ULyraInventoryItemInstance::FindFragmentByClass( TSubclassOf<ULyraInventoryItemFragment> FragmentClass) const { if ((ItemDef != nullptr) && (FragmentClass != nullptr)) { return GetDefault<ULyraInventoryItemDefinition>(ItemDef)->FindFragmentByClass(FragmentClass); } return nullptr; }这种转发模式意味着:一百个同类型 Instance 共享一份 Fragment 数据,内存开销极小。
-
支持网络复制
Instance 通过
IsSupportedForNetworking() return true声明自己是可复制的,ItemDef和StatTags都标记了Replicated。它是作为 InventoryList 的子对象进行复制的(具体见第 6 节)。
3.4 FLyraInventoryEntry ------ 背包中的一个格子
cpp
USTRUCT(BlueprintType)
struct FLyraInventoryEntry : public FFastArraySerializerItem
{
GENERATED_BODY()
private:
friend FLyraInventoryList;
friend ULyraInventoryManagerComponent;
UPROPERTY()
TObjectPtr<ULyraInventoryItemInstance> Instance = nullptr;
UPROPERTY()
int32 StackCount = 0;
UPROPERTY(NotReplicated)
int32 LastObservedCount = INDEX_NONE;
};
所有字段都是 private,只有通过 friend 声明授权 FLyraInventoryList 和 ULyraInventoryManagerComponent 才能直接访问。这让 Entry 完全成为一个"内部数据结构",外部代码只能通过 Manager 的公开接口来操作。
LastObservedCount 是一个不与服务器同步的客户端本地变量。它的作用是:当服务器推送了某个 Entry 的 StackCount 变化后,客户端在 PostReplicatedChange 里用 LastObservedCount 与新的 StackCount 做比较,算出 Delta,然后广播变更消息。
3.5 FLyraInventoryList ------ 网络感知的物品列表
cpp
USTRUCT(BlueprintType)
struct FLyraInventoryList : public FFastArraySerializer
{
GENERATED_BODY()
// 公开方法
TArray<ULyraInventoryItemInstance*> GetAllItems() const;
ULyraInventoryItemInstance* AddEntry(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount);
void AddEntry(ULyraInventoryItemInstance* Instance);
void RemoveEntry(ULyraInventoryItemInstance* Instance);
// FFastArraySerializer 回调
void PreReplicatedRemove(const TArrayView<int32> RemovedIndices, int32 FinalSize);
void PostReplicatedAdd(const TArrayView<int32> AddedIndices, int32 FinalSize);
void PostReplicatedChange(const TArrayView<int32> ChangedIndices, int32 FinalSize);
bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
{
return FFastArraySerializer::FastArrayDeltaSerialize<
FLyraInventoryEntry, FLyraInventoryList>(Entries, DeltaParms, *this);
}
private:
UPROPERTY()
TArray<FLyraInventoryEntry> Entries;
UPROPERTY(NotReplicated)
TObjectPtr<UActorComponent> OwnerComponent;
};
AddEntry 的详细流程(这是整个模块最关键的方法之一):
cpp
ULyraInventoryItemInstance* FLyraInventoryList::AddEntry(
TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount)
{
check(ItemDef != nullptr);
check(OwnerComponent);
AActor* OwningActor = OwnerComponent->GetOwner();
check(OwningActor->HasAuthority()); // 只能在服务器调用
FLyraInventoryEntry& NewEntry = Entries.AddDefaulted_GetRef();
NewEntry.Instance = NewObject<ULyraInventoryItemInstance>(OwningActor);
NewEntry.Instance->SetItemDef(ItemDef);
// 遍历 Definition 的 Fragment,逐个触发 OnInstanceCreated
for (ULyraInventoryItemFragment* Fragment :
GetDefault<ULyraInventoryItemDefinition>(ItemDef)->Fragments)
{
if (Fragment != nullptr)
{
Fragment->OnInstanceCreated(NewEntry.Instance);
}
}
NewEntry.StackCount = StackCount;
MarkItemDirty(NewEntry); // 标记此项需要复制
return NewEntry.Instance;
}
逐步骤解读:
- 权限检查 :
OwningActor->HasAuthority()确保只在服务器添加物品。这是网络安全的第一道防线。 - 创建 Instance :使用
NewObject<>并将 OwningActor 作为 Outer,便于 UE 的对象生命周期管理。 - 绑定 Definition :
SetItemDef(ItemDef)建立了 Instance 到静态数据的引用链。 - 触发 Fragment 回调 :遍历 CDO(Class Default Object)上的所有 Fragment,调用
OnInstanceCreated。例如InventoryFragment_SetStats在这里给 Instance 写入初始属性值。 - 标记脏位 :
MarkItemDirty(NewEntry)通知 FastArraySerializer 此项已变更,需要在下一次复制周期中发送给客户端。
RemoveEntry 的逻辑更简单------遍历 Entries 找到匹配的 Instance,调用 RemoveCurrent() 并标记数组脏位:
cpp
void FLyraInventoryList::RemoveEntry(ULyraInventoryItemInstance* Instance)
{
for (auto EntryIt = Entries.CreateIterator(); EntryIt; ++EntryIt)
{
FLyraInventoryEntry& Entry = *EntryIt;
if (Entry.Instance == Instance)
{
EntryIt.RemoveCurrent();
MarkArrayDirty();
}
}
}
3.6 ULyraInventoryManagerComponent ------ 对外的统一接口
cpp
UCLASS(BlueprintType)
class LYRAGAME_API ULyraInventoryManagerComponent : public UActorComponent
{
GENERATED_BODY()
public:
// 核心API
bool CanAddItemDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount = 1);
ULyraInventoryItemInstance* AddItemDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount = 1);
void AddItemInstance(ULyraInventoryItemInstance* ItemInstance);
void RemoveItemInstance(ULyraInventoryItemInstance* ItemInstance);
TArray<ULyraInventoryItemInstance*> GetAllItems() const;
ULyraInventoryItemInstance* FindFirstItemStackByDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef) const;
int32 GetTotalItemCountByDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef) const;
bool ConsumeItemsByDefinition(TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 NumToConsume);
// 子对象复制
virtual bool ReplicateSubobjects(class UActorChannel* Channel, class FOutBunch* Bunch, FReplicationFlags* RepFlags) override;
virtual void ReadyForReplication() override;
private:
UPROPERTY(Replicated)
FLyraInventoryList InventoryList;
};
Manager 的构造函数中做了两件重要的事:
cpp
ULyraInventoryManagerComponent::ULyraInventoryManagerComponent(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
, InventoryList(this)
{
SetIsReplicatedByDefault(true);
}
InventoryList(this)--- 将自身作为 OwnerComponent 传给 InventoryList,使得 List 在广播变更消息时可以引用到 Manager。SetIsReplicatedByDefault(true)--- 声明此组件需要网络复制。
AddItemDefinition 方法额外处理的子对象复制注册:
cpp
ULyraInventoryItemInstance* ULyraInventoryManagerComponent::AddItemDefinition(
TSubclassOf<ULyraInventoryItemDefinition> ItemDef, int32 StackCount)
{
ULyraInventoryItemInstance* Result = nullptr;
if (ItemDef != nullptr)
{
Result = InventoryList.AddEntry(ItemDef, StackCount);
if (IsUsingRegisteredSubObjectList() && IsReadyForReplication() && Result)
{
AddReplicatedSubObject(Result);
}
}
return Result;
}
这里有一个 UE5 网络复制的重要机制:AddReplicatedSubObject。在 UE5 中,动态创建的 UObject 如果要参与网络复制,需要显式注册到所属 Actor 的子对象复制列表中。IsUsingRegisteredSubObjectList() 检查当前 Actor 是否使用注册式子对象列表(而非传统的遍历式),IsReadyForReplication() 确保 Actor 已经开始复制。
注意注释中的 @TODO: Using the actor instead of component as the outer due to UE-127172 ------ Instance 的 Outer 是 OwningActor 而非 Manager Component,这是引擎的一个已知限制。
4 Fragment 子系统详解
4.1 设计模式:为什么用 Fragment 而不是继承?
假设物品类型有:武器、防具、消耗品、任务道具。如果用继承:
ULyraInventoryItemDefinition
├── WeaponDefinition (攻击力、射程、弹药类型...)
├── ArmorDefinition (防御力、耐久度...)
├── ConsumableDefinition (使用效果、冷却时间...)
└── QuestItemDefinition (任务ID、不可丢弃...)
看起来还行。但需求来了:策划想做一把"能当任务道具的武器"------同时有攻击属性和任务ID。继承体系下,要么把 QuestItem 改成 Fragment 挂到 Weapon 上,要么搞多重继承。无论哪种都不优雅。
Fragment 方案下:
Pistol_Definition
├── Fragment_EquippableItem → 可装备
├── Fragment_SetStats → 初始属性
├── Fragment_PickupIcon → 拾取时显示的模型
└── Fragment_QuickBarIcon → 快捷栏图标
加个"任务标记"只需要新建一个 Fragment_QuestMarker,挂到 Pistol 上就行。
Fragment 本质上是一种"ECS 思想在 UE 数据资产层面的应用"------把"是什么"变成"有什么"。
4.2 UInventoryFragment_EquippableItem ------ 桥接装备系统
cpp
UCLASS()
class UInventoryFragment_EquippableItem : public ULyraInventoryItemFragment
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category=Lyra)
TSubclassOf<ULyraEquipmentDefinition> EquipmentDefinition;
};
这是 Inventory 模块与 Equipment 模块之间的唯一桥梁。它只做一件事:告诉装备系统"这件物品对应什么样的装备配置"。
ULyraEquipmentDefinition(在 Source/LyraGame/Equipment/ 目录下)包含:
InstanceType------ 装备实例类AbilitySetsToGrant------ 装备后授予哪些 GAS 技能集ActorsToSpawn------ 装备后在角色身上生成哪些 Actor(比如一把枪的 3D 模型)
一个 Fragment 只有 4 行有效代码,但它串联了两个大型子系统。这就是 Fragment 模式的威力------每个 Fragment 只需要知道自己跟哪个系统对接,完全不需要了解其他 Fragment 在做什么。
4.3 UInventoryFragment_SetStats ------ 初始属性注入
cpp
UCLASS()
class UInventoryFragment_SetStats : public ULyraInventoryItemFragment
{
GENERATED_BODY()
protected:
UPROPERTY(EditDefaultsOnly, Category=Equipment)
TMap<FGameplayTag, int32> InitialItemStats;
public:
virtual void OnInstanceCreated(ULyraInventoryItemInstance* Instance) const override;
int32 GetItemStatByTag(FGameplayTag Tag) const;
};
这个 Fragment 重写了 OnInstanceCreated:
cpp
void UInventoryFragment_SetStats::OnInstanceCreated(ULyraInventoryItemInstance* Instance) const
{
for (const auto& KVP : InitialItemStats)
{
Instance->AddStatTagStack(KVP.Key, KVP.Value);
}
}
策划在 Definition 蓝图里配置 InitialItemStats:
Tag.AttackPower→ 15Tag.CritRate→ 5Tag.FireRate→ 2
当物品被实例化时,这些 Tag Stack 会自动写入 Instance。后续 GAS 技能在计算伤害时,就可以通过 Instance->GetStatTagStackCount(Tag_AttackPower) 拿到这个值。
这里有一个精妙之处:属性是"推送"而非"拉取"的。Instance 创建时 Fragment 主动把属性写进去,之后 Instance 和 Fragment 之间没有任何持续依赖。即使 Definition 后来被修改或卸载了,已经创建的 Instance 仍然独立工作。
4.4 UInventoryFragment_PickupIcon ------ 拾取展示
cpp
UCLASS()
class UInventoryFragment_PickupIcon : public ULyraInventoryItemFragment
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
TObjectPtr<USkeletalMesh> SkeletalMesh;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FText DisplayName;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FLinearColor PadColor;
};
纯粹的数据容器------物品掉在地上时用哪个模型、显示什么名字、背景什么颜色。不包含任何逻辑,所有字段都是 EditAnywhere + BlueprintReadOnly。
4.5 UInventoryFragment_QuickBarIcon ------ 快捷栏展示
cpp
UCLASS()
class UInventoryFragment_QuickBarIcon : public ULyraInventoryItemFragment
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FSlateBrush Brush;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FSlateBrush AmmoBrush;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FText DisplayNameWhenEquipped;
};
同样是纯数据容器。值得注意的是它使用 FSlateBrush 而非 UTexture2D 软引用------这是 UI 层的直接数据结构,加载了就能塞进 Slate 控件里显示,少了一层转换。
4.6 扩展 Fragment 的指南
基于以上分析,如果要为本模块新增一个 Fragment,标准流程是:
- 创建新类,继承
ULyraInventoryItemFragment - 添加
UPROPERTY(EditDefaultsOnly)属性字段 - 如需在实例创建时执行逻辑,重写
OnInstanceCreated - 在 Definition 蓝图中把这个 Fragment 加进去即可
不需要修改 Instance、List、Manager 的任何代码。
5 跨模块集成机制
5.1 与装备系统的桥接
前面提到了 InventoryFragment_EquippableItem 通过 TSubclassOf<ULyraEquipmentDefinition> 桥接到装备系统。完整链路是:
InventoryFragment_EquippableItem
│ 持有 TSubclassOf<ULyraEquipmentDefinition>
▼
ULyraEquipmentDefinition
│ 持有 AbilitySetsToGrant、ActorsToSpawn、InstanceType
▼
ULyraEquipmentManagerComponent(在装备时读取并应用)
当玩家装备一件物品时,装备管理器会:
- 从 Instance 的 Fragment 获取
EquipmentDefinition - 根据
InstanceType创建装备实例 - 将
AbilitySetsToGrant中的技能授予角色的 ASC(AbilitySystemComponent) - 在角色身上生成
ActorsToSpawn中定义的 Actor(如武器模型)
5.2 与 GAS 的技能消耗集成
cpp
UCLASS(meta=(DisplayName="Inventory Item"))
class ULyraAbilityCost_InventoryItem : public ULyraAbilityCost
{
GENERATED_BODY()
protected:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=AbilityCost)
FScalableFloat Quantity; // 消耗数量(可按技能等级缩放)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=AbilityCost)
TSubclassOf<ULyraInventoryItemDefinition> ItemDefinition; // 消耗哪类物品
};
CheckCost 检查背包里是否有足够数量的指定物品:
cpp
bool ULyraAbilityCost_InventoryItem::CheckCost(...) const
{
if (ULyraInventoryManagerComponent* InventoryComponent =
PC->GetComponentByClass<ULyraInventoryManagerComponent>())
{
const int32 NumItemsToConsume = FMath::TruncToInt(
Quantity.GetValueAtLevel(AbilityLevel));
return InventoryComponent->GetTotalItemCountByDefinition(ItemDefinition)
>= NumItemsToConsume;
}
return false;
}
ApplyCost 则在技能实际执行时从背包中扣除物品:
cpp
void ULyraAbilityCost_InventoryItem::ApplyCost(...) const
{
if (ActorInfo->IsNetAuthority())
{
InventoryComponent->ConsumeItemsByDefinition(ItemDefinition, NumItemsToConsume);
}
}
注意 #if 0 包裹了实际代码------这是一个尚未完全启用的功能,但架构链路已经铺设完毕。这就是示例项目的典型做法:先把架子搭好,功能按需打开。
5.3 GameplayMessageSubsystem 消息广播
Inventory 模块没有使用传统的委托/事件系统,而是通过 UGameplayMessageSubsystem 广播变更:
cpp
UE_DEFINE_GAMEPLAY_TAG_STATIC(
TAG_Lyra_Inventory_Message_StackChanged,
"Lyra.Inventory.Message.StackChanged");
在 FLyraInventoryList 的三个复制回调中,都会调用 BroadcastChangeMessage:
cpp
void FLyraInventoryList::BroadcastChangeMessage(
FLyraInventoryEntry& Entry, int32 OldCount, int32 NewCount)
{
FLyraInventoryChangeMessage Message;
Message.InventoryOwner = OwnerComponent;
Message.Instance = Entry.Instance;
Message.NewCount = NewCount;
Message.Delta = NewCount - OldCount;
UGameplayMessageSubsystem& MessageSystem =
UGameplayMessageSubsystem::Get(OwnerComponent->GetWorld());
MessageSystem.BroadcastMessage(
TAG_Lyra_Inventory_Message_StackChanged, Message);
}
这个消息体(FLyraInventoryChangeMessage)包含了变更的全部上下文:
- 哪个背包发生了变化(
InventoryOwner) - 哪个物品变了(
Instance) - 当前数量是多少(
NewCount) - 变化量是多少(
Delta)
任何关心背包变化的系统(UI、音效、任务追踪等),只需要订阅 TAG_Lyra_Inventory_Message_StackChanged 这个 Tag 即可。订阅方可以拿到 FLyraInventoryChangeMessage 做任意处理,而且订阅方不需要知道 Inventory 模块的任何内部细节。
这种"Tag-based Message Bus"模式是 Lyra 最值得学习的设计之一:发布者和订阅者完全解耦,新系统接入时不需要修改 Inventory 的任何代码。
6 网络复制机制深度分析
6.1 为什么用 FastArraySerializer?
UE 提供了多种网络复制方案:
| 方案 | 适用场景 | 缺点 |
|---|---|---|
UPROPERTY(Replicated) 基础类型 |
单个值 | 不能处理数组的增量变更 |
TArray + Replicated |
小型数组 | 每次变更都全量发送 |
FFastArraySerializer |
频繁增删改的大型列表 | 需要自己实现序列化逻辑 |
Inventory 选择 FFastArraySerializer 的原因很明显:背包可能在一次战斗中频繁增删物品(捡弹药、用完消耗品),全量复制整个列表开销太大。FastArraySerializer 只传输"变更的条目",大幅减少网络带宽。
6.2 FastArray 的三层复制回调
服务器侧变更
│
├── 新增 Item → MarkItemDirty()
│
▼
网络复制周期
│
▼
客户端侧 FFastArraySerializer 反序列化
│
├── 检测到新增 → PostReplicatedAdd()
│ └── BroadcastChangeMessage(OldCount=0, NewCount=StackCount)
│
├── 检测到移除 → PreReplicatedRemove()
│ └── BroadcastChangeMessage(OldCount=StackCount, NewCount=0)
│
└── 检测到变更 → PostReplicatedChange()
└── BroadcastChangeMessage(OldCount=LastObservedCount, NewCount=StackCount)
PreReplicatedRemove 和 PostReplicatedAdd/Change 的调用时机不同:
PreReplicatedRemove在条目被实际删除之前调用,此时还可以访问条目的数据PostReplicatedAdd在新条目被添加之后调用PostReplicatedChange在条目数据更新之后调用
这个顺序很重要------PreReplicatedRemove 里需要拿到删除前的 StackCount 来广播正确的 Delta。
6.3 子对象复制机制
Inventory 模块的复制涉及两层:
- FLyraInventoryList 本身的复制------通过 FastArraySerializer 处理 Entries 数组
- ULyraInventoryItemInstance 的复制------作为子对象单独处理
Manager 重写了 ReplicateSubobjects 来手动复制每个 Instance:
cpp
bool ULyraInventoryManagerComponent::ReplicateSubobjects(
UActorChannel* Channel, FOutBunch* Bunch, FReplicationFlags* RepFlags)
{
bool WroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);
for (FLyraInventoryEntry& Entry : InventoryList.Entries)
{
ULyraInventoryItemInstance* Instance = Entry.Instance;
if (Instance && IsValid(Instance))
{
WroteSomething |= Channel->ReplicateSubobject(Instance, *Bunch, *RepFlags);
}
}
return WroteSomething;
}
ReadyForReplication 则在 Actor 开始复制时批量注册已有的 Instance 子对象:
cpp
void ULyraInventoryManagerComponent::ReadyForReplication()
{
Super::ReadyForReplication();
if (IsUsingRegisteredSubObjectList())
{
for (const FLyraInventoryEntry& Entry : InventoryList.Entries)
{
ULyraInventoryItemInstance* Instance = Entry.Instance;
if (IsValid(Instance))
{
AddReplicatedSubObject(Instance);
}
}
}
}
子对象注册 + FastArray 的组合使得 Inventory 的复制既高效又完整------FastArray 负责"背包列表结构"的增量同步,子对象注册负责"每个物品属性"的独立复制。两者各司其职。
7 拾取系统:从世界到背包的最后一公里
7.1 IPickupable 接口
cpp
UINTERFACE(MinimalAPI, BlueprintType, meta=(CannotImplementInterfaceInBlueprint))
class UPickupable : public UInterface
{
GENERATED_BODY()
};
class IPickupable
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
virtual FInventoryPickup GetPickupInventory() const = 0;
};
任何可以被捡起的 Actor 或 Component 实现这个接口,返回一个 FInventoryPickup 结构体:
cpp
USTRUCT(BlueprintType)
struct FInventoryPickup
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FPickupInstance> Instances; // 已实例化的物品
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FPickupTemplate> Templates; // 按模板创建的物品
};
两种拾取模式:
- FPickupTemplate :给一个
ItemDef+StackCount,由 Manager 自己创建 Instance。适用于地上刷新的常规掉落。 - FPickupInstance:直接给一个已经创建好的 Instance。适用于需要保留状态的物品(比如玩家丢弃了一把有附魔的剑,再捡回来附魔还在)。
meta=(CannotImplementInterfaceInBlueprint) 表明这个接口只能在 C++ 中实现,不能在蓝图中实现------涉及网络复制和复杂数据结构的接口通常需要 C++ 实现的确定性。
7.2 UPickupableStatics 辅助函数
cpp
void UPickupableStatics::AddPickupToInventory(
ULyraInventoryManagerComponent* InventoryComponent,
TScriptInterface<IPickupable> Pickup)
{
if (InventoryComponent && Pickup)
{
const FInventoryPickup& PickupInventory = Pickup->GetPickupInventory();
for (const FPickupTemplate& Template : PickupInventory.Templates)
{
InventoryComponent->AddItemDefinition(Template.ItemDef, Template.StackCount);
}
for (const FPickupInstance& Instance : PickupInventory.Instances)
{
InventoryComponent->AddItemInstance(Instance.Item);
}
}
}
整个"拾取"流程被封装成了一个静态工具函数:拿到 Pickup 接口 → 获取 PickupInventory → 遍历 Templates 和 Instances → 逐个添加到 Manager。无论是玩家走近按F键,还是AOE自动拾取,最终都调这个函数。
GetFirstPickupableFromActor 辅助函数先检查 Actor 本身是否实现了 IPickupable,如果没有就去找它的 Component:
cpp
TScriptInterface<IPickupable> UPickupableStatics::GetFirstPickupableFromActor(AActor* Actor)
{
TScriptInterface<IPickupable> PickupableActor(Actor);
if (PickupableActor)
{
return PickupableActor;
}
TArray<UActorComponent*> PickupableComponents =
Actor ? Actor->GetComponentsByInterface(UPickupable::StaticClass())
: TArray<UActorComponent*>();
if (PickupableComponents.Num() > 0)
{
return TScriptInterface<IPickupable>(PickupableComponents[0]);
}
return TScriptInterface<IPickupable>();
}
8 总结与设计启示
8.1 本模块的核心设计思想
把 Lyra Inventory 从头到尾捋一遍下来,有几个贯穿始终的设计原则:
1. "数据是组合出来的,不是继承出来的"
这是整个模块最核心的思想。ULyraInventoryItemDefinition 本身几乎不包含业务逻辑,所有能力都是通过 Fragment 数组拼接而成。新增物品类型不需要新类,只需要新的 Fragment 组合。
在开发者日常工作中这个思路同样适用------与其纠结"武器类和防具类的继承关系怎么画",不如先想想"每件物品需要哪些独立的功能维度"。
2. "用 Tag 替代硬编码字段"
物品的属性不存储在 int32 AttackPower 这样的固定字段里,而是 FGameplayTagStackContainer。Tag 可以在项目设置里随时新增,无需改 C++ 代码。这是一种"数据驱动的反射"------用字符串(Tag Name)代替编译期确定的字段,换取运行时的灵活性。
3. "发布-订阅优于直接调用"
Inventory 的状态变更通过 GameplayMessageSubsystem 广播,而不是直接调用 UI 或音效的某个方法。这让 Inventory 模块保持精简------它只管"我发生了什么",不管"谁在关心这些变化"。
4. "网络复制是设计的一等公民,而非后加的特性"
从 Instance 的 IsSupportedForNetworking 到 FastArray 的增量复制,再到子对象注册机制,网络复制贯穿了整个模块的底层设计。每个类的字段都明确标记了是否复制、如何复制,而不是写完了业务逻辑再去想"怎么同步"。
8.2 值得记住的几个点
- Fragment 模式不等于"把类拆小"。拆小的目的是让每个 Fragment 对应一个独立的系统集成点。如果两个系统总是同时存在,合并在一个 Fragment 里也许更好。
- Instance 和 Definition 分离是内存优化的关键。一百个同类型 Instance 共享一份 Definition 数据,避免属性重复存储。
- FastArraySerializer 的回调顺序很重要 ------
PreReplicatedRemove在删除前调用,PostReplicatedAdd/Change在操作后调用。业务逻辑需要这个顺序来正确计算 Delta。 ULyraAbilityCost_InventoryItem虽然当前被#if 0禁用了,但它的存在说明了一个设计意图:Inventory 模块的接口应该足够通用,允许其他系统(如 GAS)以统一的方式消耗物品。UPickupableStatics虽然只是几个静态函数,但它展示了正确的分层:拾取逻辑不应该写在 Manager 里,而应该是一个独立的桥接层。Manager 只管"背包里有没有",不管"东西从哪来"。
8.3 可改进的方向
- 堆叠上限与唯一性检查 :
CanAddItemDefinition目前直接return true,代码中有@TODO: Add support for stack limit / uniqueness checks / etc...。实际项目中物品堆叠上限几乎一定会出现。 ConsumeItemsByDefinition的 O(N²) 问题 :代码中已标注@TODO: N squared right now as there's no acceleration structure。当背包装满时,消耗物品的查找效率可能成为瓶颈。- 物品过滤系统 :代码末尾有一段被注释掉的
ULyraInventoryFilter类族,说明 Epic 内部已经构思过按标签/条件筛选物品的需求,但尚未完整实现。 - Fragment 的异步加载 :当前 Fragment 中引用的资源(如
SkeletalMesh)是硬引用,会在 Definition 加载时一起加载。对于大型项目,可以考虑将 Fragment 中的资源改为软引用 + 异步加载模式,配合 AssetManager 的 Bundle 机制来管理。
Inventory 模块的代码量不大,共约 500 行,但它串联了 Lyra 的装备系统、GAS 技能消耗、GameplayMessage 消息总线、FastArray 网络复制等多个核心子系统。它不是孤立的功能模块,而是整个游戏架构中一条承上启下的数据管道。