UE5 Lyra Inventory 物品系统模块深度分析——从数据定义到网络同步的完整链路

Lyra Inventory 物品系统模块深度分析------从数据定义到网络同步的完整链路

写在前面:一个好的物品系统长什么样?

做过多人在线游戏的开发者大概都有这种体会------物品系统是个表面上看起来简单,实际上极容易越做越乱的模块。一开始可能只是"这个Actor能捡起那把枪",但很快需求就变成了:枪有稀有度、枪要消耗弹药、不同角色拿同一把枪要有不同属性、DLC加新武器不能改代码......

如果一开始就只用"纯C++类 + 硬编码属性"的方式搭积木,需求一变,连锁反应往往让人想重写。

Lyra 作为 Epic 官方的 UE5 示例项目,在 Inventory 模块上展示了一套值得仔细研究的设计思路------把数据和逻辑彻底分开,用组合代替继承 。本文将对 Source/LyraGame/Inventory 目录下的全部代码做一次系统性拆解。


目录

  1. 模块概述
  2. 整体架构解析
  3. 核心类深度解析
  4. [Fragment 子系统详解](#Fragment 子系统详解)
  5. 跨模块集成机制
  6. 网络复制机制深度分析
  7. 拾取系统:从世界到背包的最后一公里
  8. 总结与设计启示

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 标记为 ConstAbstractBlueprintable,意味着它是一份只读的蓝图数据资产,在运行时不会被修改
  • 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;  // 指向静态定义
};

亮点分析:

  1. 属性用 GameplayTag + Stack 表示,而不是传统 int/float 字段

    这点很重要。传统的做法是:

    cpp 复制代码
    int32 AttackPower;
    float CritRate;
    int32 Durability;

    而 Lyra 的做法是:

    cpp 复制代码
    StatTags.AddStack(Tag_AttackPower, 10);
    StatTags.AddStack(Tag_CritRate, 5);

    前者的问题是:每加一种新属性就要改 Instance 的类定义,牵一发动全身。后者的 Tag 只需要在项目设置里注册一下就能用,Instance 的类定义永远不需要改。

  2. Instance 本身不持有 Fragment 数据,而是转发给 Definition

    cpp 复制代码
    const ULyraInventoryItemFragment* ULyraInventoryItemInstance::FindFragmentByClass(
        TSubclassOf<ULyraInventoryItemFragment> FragmentClass) const
    {
        if ((ItemDef != nullptr) && (FragmentClass != nullptr))
        {
            return GetDefault<ULyraInventoryItemDefinition>(ItemDef)->FindFragmentByClass(FragmentClass);
        }
        return nullptr;
    }

    这种转发模式意味着:一百个同类型 Instance 共享一份 Fragment 数据,内存开销极小。

  3. 支持网络复制

    Instance 通过 IsSupportedForNetworking() return true 声明自己是可复制的,ItemDefStatTags 都标记了 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 声明授权 FLyraInventoryListULyraInventoryManagerComponent 才能直接访问。这让 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;
}

逐步骤解读:

  1. 权限检查OwningActor->HasAuthority() 确保只在服务器添加物品。这是网络安全的第一道防线。
  2. 创建 Instance :使用 NewObject<> 并将 OwningActor 作为 Outer,便于 UE 的对象生命周期管理。
  3. 绑定 DefinitionSetItemDef(ItemDef) 建立了 Instance 到静态数据的引用链。
  4. 触发 Fragment 回调 :遍历 CDO(Class Default Object)上的所有 Fragment,调用 OnInstanceCreated。例如 InventoryFragment_SetStats 在这里给 Instance 写入初始属性值。
  5. 标记脏位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 → 15
  • Tag.CritRate → 5
  • Tag.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,标准流程是:

  1. 创建新类,继承 ULyraInventoryItemFragment
  2. 添加 UPROPERTY(EditDefaultsOnly) 属性字段
  3. 如需在实例创建时执行逻辑,重写 OnInstanceCreated
  4. 在 Definition 蓝图中把这个 Fragment 加进去即可

不需要修改 Instance、List、Manager 的任何代码。


5 跨模块集成机制

5.1 与装备系统的桥接

前面提到了 InventoryFragment_EquippableItem 通过 TSubclassOf<ULyraEquipmentDefinition> 桥接到装备系统。完整链路是:

复制代码
InventoryFragment_EquippableItem
    │ 持有 TSubclassOf<ULyraEquipmentDefinition>
    ▼
ULyraEquipmentDefinition
    │ 持有 AbilitySetsToGrant、ActorsToSpawn、InstanceType
    ▼
ULyraEquipmentManagerComponent(在装备时读取并应用)

当玩家装备一件物品时,装备管理器会:

  1. 从 Instance 的 Fragment 获取 EquipmentDefinition
  2. 根据 InstanceType 创建装备实例
  3. AbilitySetsToGrant 中的技能授予角色的 ASC(AbilitySystemComponent)
  4. 在角色身上生成 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 模块的复制涉及两层:

  1. FLyraInventoryList 本身的复制------通过 FastArraySerializer 处理 Entries 数组
  2. 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 可改进的方向

  1. 堆叠上限与唯一性检查CanAddItemDefinition 目前直接 return true,代码中有 @TODO: Add support for stack limit / uniqueness checks / etc...。实际项目中物品堆叠上限几乎一定会出现。
  2. ConsumeItemsByDefinition 的 O(N²) 问题 :代码中已标注 @TODO: N squared right now as there's no acceleration structure。当背包装满时,消耗物品的查找效率可能成为瓶颈。
  3. 物品过滤系统 :代码末尾有一段被注释掉的 ULyraInventoryFilter 类族,说明 Epic 内部已经构思过按标签/条件筛选物品的需求,但尚未完整实现。
  4. Fragment 的异步加载 :当前 Fragment 中引用的资源(如 SkeletalMesh)是硬引用,会在 Definition 加载时一起加载。对于大型项目,可以考虑将 Fragment 中的资源改为软引用 + 异步加载模式,配合 AssetManager 的 Bundle 机制来管理。

Inventory 模块的代码量不大,共约 500 行,但它串联了 Lyra 的装备系统、GAS 技能消耗、GameplayMessage 消息总线、FastArray 网络复制等多个核心子系统。它不是孤立的功能模块,而是整个游戏架构中一条承上启下的数据管道。

相关推荐
日月云棠2 天前
UE5 Lyra Weapons 模块深度分析——从装备实例到弹道散布的完整武器系统
ue5
清泓y3 天前
UE5程序化生成技术
ue5
清泓y4 天前
UE5--VR与AR开发技术
ue5·ar·vr
日月云棠4 天前
UE5 Lyra Messages 模块深度解析——从一条击杀消息看游戏事件系统的设计哲学
游戏·ue5
日月云棠5 天前
UE5 Lyra Input 模块:从手柄摇杆到技能释放,一条链路如何做到零耦合
ue5
日月云棠8 天前
UE5 Lyra源码分析——Audio_Analysis音频模块全面分析
ue5·音视频
成都渲染101云渲染66668 天前
2026云渲染平台哪个好?建筑、动画、UE5项目选择云渲染的3个关键标准
ue5
清泓y9 天前
UE 物理系统知识分享
面试·ue5·游戏程序
清泓y9 天前
UE移动开发技术面试题
android·面试·ue5·ue4·游戏程序