1. 模块概述

  • Inventory 模块是项目的物品/背包子系统,负责”任意 Actor(玩家、NPC、可拾取物)持有一组可堆叠物品”这一核心抽象。它不直接绑定 Pawn,而是作为一个 UActorComponent 挂在任意 Actor(PlayerController、PlayerState、Pickup 道具等)上,因此可同时支撑:玩家个人背包、世界拾取物的”暂存仓”、AI 单位的随身携带物品、以及未来可能出现的箱子/储物柜。
  • 模块在结构上分三层:

    • Definition(数据资产层):只读、Designer 在编辑器里配置,由 UInventoryItemDefinition + 一组 UInventoryItemFragment 组合而成(Composition over Inheritance)。
    • Instance(运行时实例层)UInventoryItemInstance 是真正存在于运行时的物品对象,承载该物品当前的”状态数据”(弹药数量、耐久、Buff 计数……),通过一个 Tag→Count 的 GameplayTagStack 容器统一表达。
    • Manager(容器管理层)UInventoryManagerComponent 持有一个 FInventoryList(基于 FastArraySerializer),负责增删查与全网络复制。
  • 模块对外提供:物品增删 API、按 Definition 聚合查询/消耗 API、IPickupable 拾取契约、OnStackChanged 全局消息事件,以及与装备系统对接的 InventoryFragment_EquippableItem

2. 网络同步方案

  • 模块完全采用服务器权威 + 增量复制模型:
  1. 背包列表用 FastArraySerializerFInventoryList 继承 FFastArraySerializerFInventoryEntry 继承 FFastArraySerializerItem,特化 TStructOpsTypeTraits 开启 WithNetDeltaSerializer。增删改只复制变化的元素,避免整数组同步。
  2. 物品状态用第二层 FastArraySerializerUInventoryItemInstance.StatTagsFGameplayTagStackContainer,本身又是一个 FastArray,因此”某把武器加 1 发子弹”只增量同步一个 Tag-Count 项,而不会重发整个物品。
  3. Item Instance 作为 SubObject 复制:因为 Instance 是 UObject 而非 USTRUCT,UInventoryManagerComponent 同时实现两套通道——
    • 旧管线:重写 ReplicateSubobjects(),遍历 Entries 调 Channel->ReplicateSubobject(Instance)
    • 新管线(Push Model / Iris):ReadyForReplication() 时若 IsUsingRegisteredSubObjectList(),把已有 Instance 调 AddReplicatedSubObject() 注册;后续 AddItemDefinition/AddItemInstance/RemoveItemInstance 也对应 Add/Remove。
    • UInventoryItemInstance::RegisterReplicationFragments() 调用 FReplicationFragmentUtil::CreateAndRegisterFragmentsForObject 兼容 Iris 复制系统。
  4. 客户端通知统一走 GameplayMessageSubsystem:FastArray 的 PreReplicatedRemove / PostReplicatedAdd / PostReplicatedChange 都会构造 FInventoryChangeMessage{ Owner, Instance, NewCount, Delta } 并以 GameplayTag Inventory.Message.StackChanged 广播——UI、QuickBar、HUD 各自订阅,完全解耦
  5. LastObservedCount 设计FInventoryEntry 本地缓存上次观察值(NotReplicated),PostReplicatedChange 用它和 StackCountDelta,使得”Stack 从 5→3 还是从 5→7”客户端能区分出来,UI 可以做”+2 / -2”飘字。
  6. 权威保护:所有写入接口都标注 BlueprintAuthorityOnlyConsumeItemsByDefinition 内部还做 OwningActor->HasAuthority() 二次校验;FInventoryList::AddEntrycheck(OwningActor->HasAuthority()) 在客户端误调用时直接断言。

3. 设计思路与架构决策

3.1 用 Fragment 而非继承来扩展物品定义

  • 问题:物品扩展性需求是发散的——一个枪需要”可装备”+”快捷栏图标”+”初始弹药”,一个药品只需要”快捷栏图标”+”初始堆叠数”,一个任务道具可能只是个”可拾取图标”。如果用继承,会很快出现 UWeaponItemDef/UConsumableItemDef/UQuestItemDef 多重继承爆炸或大量重复字段。
  • 方案UInventoryItemDefinition 持有 TArray<UInventoryItemFragment*> FragmentsInstanced, EditInlineNew),策划在编辑器里通过下拉菜单把所需 Fragment 拼装上去。每个 Fragment 是一个独立 UObject,只关心自己那一小块数据。
  • 理由
    1. 组合优于继承,避免菱形继承和 UE 的 SCS 限制;
    2. Fragment 自带虚函数 OnInstanceCreated(Instance),使得”创建实例时自动初始化某些状态”成为通用扩展点(SetStats 就靠这个把初始堆叠 Tag 写到 Instance 上);
    3. 查询走 FindFragmentByClass<T>() + 模板,编译期类型安全,调用方写起来跟 GAS 的 GameplayEffectComponent 一样自然。
  • 代价:每个 Fragment 都要自己一个 UCLASS 和小型 .cpp,文件数量稍多——但每个文件都是 30 行以内的纯数据,几乎没有维护成本。

3.2 Definition / Instance 分离

  • 问题:玩家背包里 100 把同名手枪,弹药数各不相同;它们要共享”图标、模型、UI 名”,又要各自维护自己的”当前弹药”。
  • 方案Definition 是 CDO 级别的只读资产(UCLASS(Const, Abstract)),InstanceNewObject 出来的运行时对象,只保存状态,元数据通过 ItemDef.CDO 查到。
  • 理由
    1. 内存:N 个相同物品共享一份 Definition,只 new N 份轻量 Instance;
    2. 网络:Instance 同步 ItemDefTSubclassOf,UClass*)+ StatTags 即可,Definition 资产体积根本不上网;
    3. 设计师工作流:调整图标、调整初始数据,改 Asset 就行,无需改运行时数据。

3.3 用 GameplayTagStack 表达任意”可计数状态”

  • 问题:物品要存的状态是无限发散的——弹药、备用弹药、耐久、Buff 层数、Charge 数……如果每个 Instance 都为它们建一个 int32 字段,要么子类爆炸,要么字段冗余。
  • 方案:将所有可计数状态统一抽象为 FGameplayTag → int32,存在 FGameplayTagStackContainer 里。Instance 暴露 4 个通用接口:Add/Remove/GetStackHasStatTag
  • 理由
    1. 业务零侵入扩展:策划新加一种状态只需新加一个 Tag,代码无需改;
    2. 与 GAS 完美对接:FGameplayTag 是 GAS 通用语言,Cost / Cooldown / Effect 都可以直接读 Instance 的 StatTags;
    3. 网络高效:Tag-Count 增量是世界上最容易增量同步的结构(只 4-8 字节);
    4. TagToCountMap 加速查询:复制层用 TArray,运行时查询用 TMap,PreReplicatedRemove / PostReplicatedAdd / PostReplicatedChange 三个钩子里维护映射一致性,做到 O(1) 读 + 增量同步

3.4 跨系统通知用 GameplayMessageSubsystem 而不是 Delegate

  • 问题:背包变化要通知 UI、QuickBar、HUD、教程系统、成就系统……如果用直连 Delegate,每个监听方都要拿到背包组件指针,耦合极强。
  • 方案:FastArray 的 Replicated 钩子里 MessageSystem.BroadcastMessage(TAG_Inventory_Message_StackChanged, FInventoryChangeMessage{...})。任何系统可以订阅同一个 Tag。
  • 理由
    1. 完全解耦:UI 不知道也不关心背包组件挂在哪个 Actor 上;
    2. 蓝图友好:FInventoryChangeMessage 是 USTRUCT,UMG 可直接 BindEventByTag;
    3. 可观测性:所有变化走同一条总线,开发期可以一行代码挂全局 Logger 抓事件。

3.5 同时支持旧/新两套子对象复制管线

  • 问题:UE5 引入了 Registered SubObject List(Push Model)和 Iris 新复制系统,但项目可能未必全开。
  • 方案AddItemDefinition / RemoveItemInstance 都用 if (IsUsingRegisteredSubObjectList()) AddReplicatedSubObject(...) 同时兼容两条路径;ReadyForReplication() 把启动期已有 Instance 也注册一遍;UInventoryItemInstance::RegisterReplicationFragments 适配 Iris。
  • 理由:在不强制项目改造的前提下,新管线一开就能享受性能收益,老管线照样工作,对接成本为 0。

3.6 IPickupable 用 Templates + Instances 双轨

  • 问题:拾取场景两类——A:地上的”标准战利品”(用 Definition 现造一份就行);B:玩家死亡掉落(必须保留弹药数等运行时状态)。
  • 方案FInventoryPickup 同时携带 TArray<FPickupTemplate>(ItemDef + Count)与 TArray<FPickupInstance>(已有 Instance*),AddPickupToInventory 分别走两条路径。
  • 理由:用一套接口同时表达”造新的”和”接管已有”,避免拾取系统对场景做硬分类。

4. Q&A

4.1 你这个背包同步用的是什么方案?为什么不用直接的 UPROPERTY(Replicated) TArray<...>

参考回答:用的是 UE5 的 FastArraySerializer,而且做了两层嵌套——外层 FInventoryList(继承 FFastArraySerializer、Entry 继承 FFastArraySerializerItem)负责物品增删,内层 FGameplayTagStackContainer 负责单个物品内部的状态(弹药、耐久这些)。
直接用 UPROPERTY(Replicated) TArray<> 的问题是数组变化时整组重发——背包里 30 件物品,加一发子弹就要重发 30 个 entry。FastArray 通过 MarkItemDirty 只复制变化的元素,单次操作流量是常量级。
另外因为物品 Instance 是 UObject,它本身还要作为 SubObject 复制,我同时实现了旧的 ReplicateSubobjects 和新的 Registered SubObject List 路径,并通过 RegisterReplicationFragments 适配 Iris。

4.2 Definition 和 Instance 为什么要分开?这跟传统的物品类继承比有什么好处?

参考回答:好处主要三点:

  1. 内存:Definition 是 CDO,整个项目就一份;100 把同型号手枪只需要 100 个轻量 Instance + 1 份 Definition 资产,不是 100 份完整数据。
  2. 网络:Instance 上网时只发 TSubclassOf<Definition>(其实就是 UClass*)+ 自己的状态 StatTags,Definition 资产体积本身不上网。
  3. 设计师工作流:图标、模型、UI 名这些”长得怎么样”全在 Asset 里,调整时不需要改运行时代码也不需要重启服。
    而 Fragment 本质是将”什么样的物品有什么扩展数据”从继承换成了组合——一把可装备 + 出现在快捷栏 + 有初始弹药的武器,就是挂三个 Fragment,不需要 UWeaponDef : UEquippableDef : UQuickBarItemDef 这种深继承。

4.3 Stat 状态用 GameplayTag → int32 表达,相比给 Instance 加字段,有什么取舍?

参考回答:好处:

  • 业务无侵入扩展——加一种状态只要新加 Tag,C++ 不动;
  • 与 GAS 同语言——能力系统的 Cost、Cooldown、Effect 都直接读 Tag-Count;
  • 网络高效——一个 (Tag, int32) 是世界上最容易增量同步的结构;
  • 容器内部 TArray + TMap 双索引:网络层用 TArray 顺序复制,运行时 TMap 做 O(1) 查询,两份数据通过 PreReplicated/PostReplicated 钩子保持一致。

代价:

  • 比直接成员字段稍重(每条状态一个 USTRUCT 元素 + 一个 Map 项),但相比换来的扩展性完全可以接受;
  • 调试时需要看 Tag 名而不是字段名——配合 GetDebugString()Tag x Count 打出来基本不构成问题。

4.4 拾取怎么做的?比如玩家死亡掉落要保留剩余弹药,怎么处理?

参考回答:定义了 IPickupable 接口和一个 FInventoryPickup 结构,里面有两个数组——

  • Templates{ItemDef, StackCount} 列表,用于”标准战利品”,调用方再 AddItemDefinition 现造一份新实例;
  • InstancesUInventoryItemInstance* 列表,用于”接管已有实例”,调 AddItemInstance 直接把那个对象的所有权交给目标背包,StatTags 状态(包括剩余弹药)原封不动保留

UPickupableStatics::GetFirstPickupableFromActor 还做了一件事:先看 Actor 自己实现了接口没,没有就回退到 GetComponentsByInterface——这样设计师可以选择把 Pickup 逻辑放 Actor 上还是 Component 上,调用方无感知。

4.5 Iris 兼容是怎么做的?

参考回答:两件事:

  1. UInventoryItemInstance::RegisterReplicationFragments 里调 FReplicationFragmentUtil::CreateAndRegisterFragmentsForObject(this, Context, RegistrationFlags),让 Instance 的 Replicated 属性自动注册成 Iris 的 Replication Fragment。
  2. Manager 里所有 Add/Remove ItemInstance 都判断 IsUsingRegisteredSubObjectList(),是的话调 AddReplicatedSubObject / RemoveReplicatedSubObject;否则走旧的 ReplicateSubobjects 路径。ReadyForReplication 里把启动时已经存在的 Instance 也补充注册一遍。