TOC
导读
背包系统是 ActionRPG 里最适合上手源码精读的模块------它不碰 GAS 那套绕脑子的东西,逻辑全是你熟悉的"增删查改 + 哈希表",但又踩中了好几个 UE 特有的设计取舍。这一篇我们只干两件事:搞清背包数据长什么样、挂在哪 ,然后把"加物品 / 删物品 / 装备物品"这三个写操作逐行读穿。
读完你应该能自己回答这几个问题:
- 为什么背包数据是两张
TMap,而不是一个物品列表? - 为什么它挂在
PlayerController上,而不是角色身上? - 调一次
AddInventoryItem,内部到底惊动了哪些东西? - "拥有一把剑"和"把剑装在武器槽里"在数据上是怎么分开表达的?
阅读前提 :你已经看过阶段四,知道物品是
URPGItem这种 Primary Data Asset,靠FPrimaryAssetId(如Weapon:Weapon_Sword)唯一标识。本篇会反复用到"物品指针"和"货号"这两个词。源码范围 :
Source/ActionRPG/Public/RPGTypes.h、Source/ActionRPG/Public/RPGPlayerControllerBase.h、Source/ActionRPG/Private/RPGPlayerControllerBase.cpp。引擎版本以 4.27.2 为准。
一、两张权威 Map:背包数据长什么样
打开 RPGPlayerControllerBase.h,背包的全部"权威数据"就两张表(RPGPlayerControllerBase.h:31-37):
cpp
/** 玩家拥有的所有物品:物品定义 -> 它的数量/等级数据 */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Inventory)
TMap<URPGItem*, FRPGItemData> InventoryData;
/** 装备槽:槽位(类型+编号) -> 装在里面的物品,槽位数量由 GameInstance 的 ItemSlotsPerType 初始化 */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Inventory)
TMap<FRPGItemSlot, URPGItem*> SlottedItems;

↑ 在 IDE 里看到的就是这两行:InventoryData 和 SlottedItems 并排躺在类声明里,它俩就是整个背包的"真相之源"。
记住这组对应关系,后面所有逻辑都是围着它俩转:
InventoryData |
SlottedItems |
|
|---|---|---|
| 回答的问题 | "我有什么、几个、几级" | "哪个槽装了什么" |
| Key | URPGItem*(物品定义指针) |
FRPGItemSlot(槽位) |
| Value | FRPGItemData(数量 + 等级) |
URPGItem*(装着的物品,可为 null) |
为什么要拆成两张?因为**"拥有" ≠ "装备"**。你背包里可能有三把剑,但武器槽只能装一把。如果硬塞进一张表,就得在每个物品上挂一个"我现在装在哪个槽"的字段,查询和一致性都会变得别扭。拆成两张,各管一件事,反而干净。
1.1 FRPGItemData:数量 + 等级
先认一眼"被装进背包的东西"长什么样------它们都是 Content/Items/ 下的数据资产(Primary Data Asset)。比如三瓶药水:

↑ InventoryData 的 key(URPGItem*)指向的,正是这种摆在 Content/Items/Potions 里的资产对象。
InventoryData 的 value 类型定义在 RPGTypes.h:70-126:
cpp
USTRUCT(BlueprintType)
struct ACTIONRPG_API FRPGItemData
{
GENERATED_BODY()
// 默认构造给 count=1, level=1:在蓝图里声明一个 ItemData 时,给的是符合直觉的默认值
FRPGItemData() : ItemCount(1), ItemLevel(1) {}
FRPGItemData(int32 InItemCount, int32 InItemLevel) : ItemCount(InItemCount), ItemLevel(InItemLevel) {}
UPROPERTY(...) int32 ItemCount; // 这件物品有几个,永远 >= 1
UPROPERTY(...) int32 ItemLevel; // 这件物品几级,所有实例共享一个等级
bool IsValid() const { return ItemCount > 0; } // 数量 > 0 才算"持有"
// ...
};
注意一个细节:物品的"身份"是 key(URPGItem*),不是存在 value 里 。FRPGItemData 只记"几个 + 几级"。这也是为什么同一把武器无论持有几个,等级只有一个(ItemLevel 是所有实例共享的)。
FRPGItemData 里最关键的是堆叠函数 UpdateItemData(RPGTypes.h:111-125),我们留到 AddInventoryItem 里现场看它怎么用。
1.2 FRPGItemSlot:类型 + 槽号
SlottedItems 的 key 类型(RPGTypes.h:18-66):
cpp
USTRUCT(BlueprintType)
struct ACTIONRPG_API FRPGItemSlot
{
GENERATED_BODY()
FRPGItemSlot() : SlotNumber(-1) {} // -1 = 无效槽
UPROPERTY(...) FPrimaryAssetType ItemType; // 这个槽能放哪类物品(Weapon / Potion / Skill...)
UPROPERTY(...) int32 SlotNumber; // 槽号,从 0 开始
bool IsValid() const { return ItemType.IsValid() && SlotNumber >= 0; }
};
一个槽 = "物品类型 + 第几号"。比如 (Weapon, 0)、(Potion, 1)。它被当成 TMap 的 key 用------而自定义 struct 想当 TMap 的 key,在 UE(和标准 C++)里都必须满足两个条件:能判等、能算哈希。FRPGItemSlot 正好都提供了:
cpp
bool operator==(const FRPGItemSlot& Other) const // ① 判等
{
return ItemType == Other.ItemType && SlotNumber == Other.SlotNumber;
}
friend inline uint32 GetTypeHash(const FRPGItemSlot& Key) // ② 算哈希
{
uint32 Hash = 0;
Hash = HashCombine(Hash, GetTypeHash(Key.ItemType));
Hash = HashCombine(Hash, (uint32)Key.SlotNumber);
return Hash;
}
UE 基础补给 :
TMap<K,V>是 UE 的哈希表,底层类似std::unordered_map。区别在于,标准库要你特化std::hash<K>,而 UE 约定俗成地要你提供一个自由函数friend uint32 GetTypeHash(const K&)。原理一样,语法不同。HashCombine是引擎提供的"把多个字段哈希混成一个"的工具函数。动手 :把
GetTypeHash整段注释掉再编译,看报错信息------你会直观理解"为什么 TMap 的 key 非要它不可"。
二、为什么挂在 PlayerController 上?
这是全篇最反直觉的设计决定,值得单独说。背包不挂角色、不挂全局单例,而是挂在 APlayerController 上:
cpp
UCLASS()
class ACTIONRPG_API ARPGPlayerControllerBase : public APlayerController, public IRPGInventoryInterface
要理解它,先得有 UE 的对象层级心智模型:
bash
UGameInstance ← 进程级单例,整个游戏活着它就活着,切关卡都不销毁
└─ APlayerController ← 每个玩家一个,代表"操控者",生命周期跨越角色死亡/重生
└─ APawn / ACharacter ← "躯壳",可以被 Possess / UnPossess 随时换掉
关键就在生命周期的差异:
- 角色(Character)是会死的、可替换的躯壳。角色一死,背包跟着没了显然不合理。
- PlayerController 代表"玩家"本人。它在角色死亡重生时依然存在,联机时每个玩家各有一份。背包是"玩家的财产",理应跟着玩家走,而不是跟着某具躯壳走。
所以把背包挂在 PlayerController 上,"换角色 / 重生 / 切武器" 都不会动到背包数据。角色需要读背包时(比如根据装备授予技能),它通过 IRPGInventoryInterface 接口去问 Controller 要,而不用关心数据具体存在哪------这个接口的设计我们下一篇细讲。
小坑提醒 :
APlayerController在切换关卡时默认会被重建 (除非开了bUseSeamlessTravel),而UGameInstance永远活着。所以真正"跨关卡不丢"的存档落点其实是 GameInstance,PlayerController 上的两张 map 只是"当前这局的运行时副本"。存档怎么把数据从 Controller 搬到 GameInstance 再落盘,是后面存档篇的主题。
三、AddInventoryItem 逐行精读
写操作里最典型的就是加物品。完整实现在 RPGPlayerControllerBase.cpp:12-56,我们拆成 6 步看:
cpp
bool ARPGPlayerControllerBase::AddInventoryItem(URPGItem* NewItem, int32 ItemCount, int32 ItemLevel, bool bAutoSlot)
{
bool bChanged = false;
// ── 第 1 步:参数校验 ──────────────────────────────
if (!NewItem)
{
UE_LOG(LogActionRPG, Warning, TEXT("AddInventoryItem: Failed trying to add null item!"));
return false;
}
if (ItemCount <= 0 || ItemLevel <= 0)
{
UE_LOG(LogActionRPG, Warning, TEXT("AddInventoryItem: Failed ... with negative count or level!"), *NewItem->GetName());
return false;
}
// ── 第 2 步:读旧值(可能为空)────────────────────
FRPGItemData OldData;
GetInventoryItemData(NewItem, OldData);
// ── 第 3 步:算新值(堆叠)─────────────────────────
FRPGItemData NewData = OldData;
NewData.UpdateItemData(FRPGItemData(ItemCount, ItemLevel), NewItem->MaxCount, NewItem->MaxLevel);
// ── 第 4 步:写 Map + 广播(仅当真的变了)──────────
if (OldData != NewData)
{
InventoryData.Add(NewItem, NewData);
NotifyInventoryItemChanged(true, NewItem);
bChanged = true;
}
// ── 第 5 步:自动装备 ──────────────────────────────
if (bAutoSlot)
{
bChanged |= FillEmptySlotWithItem(NewItem);
}
// ── 第 6 步:有变化就存盘 ──────────────────────────
if (bChanged)
{
SaveInventory();
return true;
}
return false;
}
逐步说几个值得停下来的点:
第 2 步 GetInventoryItemData (cpp:171-182)就是对 InventoryData.Find() 的封装:找到就拷出来返回 true,找不到就把 ItemData 置成 FRPGItemData(0, 0) 返回 false。注意这里 count=0,正好让"没有这件物品"的初始状态在第 3 步堆叠时表现正确。

↑ 背包的所有"读"操作(GetInventoryItemCount、GetInventoryItemData)本质都是一次 InventoryData.Find(Item)------TMap 的哈希查找。
第 3 步 UpdateItemData 才是堆叠逻辑的核心 (RPGTypes.h:111-125):
cpp
void UpdateItemData(const FRPGItemData& Other, int32 MaxCount, int32 MaxLevel)
{
if (MaxCount <= 0) { MaxCount = MAX_int32; } // <=0 视为"无上限"(消耗品常这么配)
if (MaxLevel <= 0) { MaxLevel = MAX_int32; }
ItemCount = FMath::Clamp(ItemCount + Other.ItemCount, 1, MaxCount); // 数量叠加,夹到 [1, MaxCount]
ItemLevel = FMath::Clamp(Other.ItemLevel, 1, MaxLevel); // 等级直接覆盖(不是叠加!)
}
两个细节很容易看漏:
- 数量是"加",等级是"盖" 。
ItemCount + Other.ItemCount是累加,而ItemLevel直接取新传入的值。所以"再捡一把同样的剑"会让数量 +1,但等级取这次传入的等级。 MaxCount <= 0当成无上限 。药水这类可无限堆叠的消耗品,数据里把MaxCount配成 0 即可,代码用MAX_int32兜底。
第 4 步只在 OldData != NewData 时才写 + 广播。这是个朴素但重要的优化:如果加进来的东西没让数据发生任何变化(例如数量已经顶到 MaxCount),就不写 map、不发通知,避免触发一连串无意义的 UI 刷新。
第 4 步的 NotifyInventoryItemChanged 是背包"对外广播"的统一出口(cpp:372-380)。它内部会按"先 Native 后蓝图"的顺序连发三个通知。这套委托广播机制是背包系统的精髓,但它属于下一篇的主题,这里只需知道:数据一变,这里负责喊一嗓子,UI 和角色系统才能收到。
第 6 步:调用方永远不用操心存盘 。只要这次调用真的改了东西,函数内部就会自动 SaveInventory()。这是个贯穿所有写操作的约定------Add / Remove / SetSlotted 改完都自己存盘,外部代码不需要、也不应该手动调存盘。
动手 :在
AddInventoryItem开头加一行UE_LOG(LogActionRPG, Display, TEXT("AddInventoryItem: %s x%d (Lv%d)"), *NewItem->GetName(), ItemCount, ItemLevel);编译后在 PIE 里捡物品,看 Output Log。再去 World Outliner 找到 PlayerController,展开 Details 面板,能直接看到InventoryData/SlottedItems的实时内容。
四、RemoveInventoryItem 逐行精读
删物品比加物品多一件麻烦事:删掉的物品如果正装在某个槽里,槽也得一起清空 。看 cpp:58-111:
cpp
bool ARPGPlayerControllerBase::RemoveInventoryItem(URPGItem* RemovedItem, int32 RemoveCount)
{
if (!RemovedItem) { /* 日志 + return false */ }
// 读当前数据
FRPGItemData NewData;
GetInventoryItemData(RemovedItem, NewData);
if (!NewData.IsValid()) // 压根没有这件物品(count<=0)
{
return false;
}
// RemoveCount <= 0 约定为"全部删除"
if (RemoveCount <= 0) { NewData.ItemCount = 0; }
else { NewData.ItemCount -= RemoveCount; }
if (NewData.ItemCount > 0)
{
// 还剩,更新数量即可
InventoryData.Add(RemovedItem, NewData);
}
else
{
// 删光了:从 InventoryData 移除,并保证它从所有槽里下架
InventoryData.Remove(RemovedItem);
for (TPair<FRPGItemSlot, URPGItem*>& Pair : SlottedItems)
{
if (Pair.Value == RemovedItem)
{
Pair.Value = nullptr;
NotifySlottedItemChanged(Pair.Key, Pair.Value); // 每清一个槽,单独广播一次
}
}
}
NotifyInventoryItemChanged(false, RemovedItem); // 再广播一次"背包变了"(bAdded=false)
SaveInventory();
return true;
}
几个要点:
RemoveCount <= 0是"全删"的约定 。这是个常见的 API 习惯:用一个边界值表达"全部",省得再加一个bool bRemoveAll参数。- 数量减到 >0 就只更新,减到 <=0 才真正移除 。物品在
InventoryData里彻底消失时,才需要去清槽。 - 清槽用了遍历 。逻辑上一件物品最多占一个槽(见下一节
SetSlottedItem保证的"单占"语义),但代码这里做了安全遍历:把所有 value 指向它的槽全清掉。多一层保险,不依赖"只占一个槽"这个隐含前提。 - 广播顺序:先清各个槽(每个槽一次
NotifySlottedItemChanged),最后才发一次NotifyInventoryItemChanged(false, ...)。这个顺序让 UI 能先把槽位画空、再处理背包整体变化。
动手 :在 PIE 里装备一件物品,然后用蓝图调
RemoveInventoryItem把它删光,观察 Output Log 里委托触发的先后顺序------你会看到先来若干条 SlottedItemChanged,最后一条 InventoryItemChanged。
五、SetSlottedItem:装备槽的"单占"语义
加 / 删管的是 InventoryData(拥有),装备管的是 SlottedItems(穿戴)。SetSlottedItem(cpp:130-158)负责"把某件物品放进指定槽",同时保证它不会同时出现在两个槽里:
cpp
bool ARPGPlayerControllerBase::SetSlottedItem(FRPGItemSlot ItemSlot, URPGItem* Item)
{
bool bFound = false;
// 必须遍历整张槽表,因为要把这件物品从"旧槽"里摘掉
for (TPair<FRPGItemSlot, URPGItem*>& Pair : SlottedItems)
{
if (Pair.Key == ItemSlot)
{
// 目标槽:放入新物品
bFound = true;
Pair.Value = Item;
NotifySlottedItemChanged(Pair.Key, Pair.Value);
}
else if (Item != nullptr && Pair.Value == Item)
{
// 其它槽里若也装着同一件物品,清空它(保证"一件物品只占一个槽")
Pair.Value = nullptr;
NotifySlottedItemChanged(Pair.Key, Pair.Value);
}
}
if (bFound) { SaveInventory(); return true; }
return false; // 传进来的槽不存在,什么也没做
}
设计要点:
- 遍历是必须的,因为你要做两件事:往目标槽塞值、把这件物品从可能存在的旧槽里摘掉。这两件事天然要扫一遍整张表。
- "单占"语义 :
Item非空时,只要在别的槽发现它,就清空那个槽。结果是一件物品在SlottedItems里最多出现一次。这正是上一节RemoveInventoryItem敢用"安全遍历"兜底、却又不会真清出多个槽的原因。 - 传
Item == nullptr就是"卸下该槽" :此时else if的Item != nullptr不成立,只会把目标槽设成 null,等于清空。 - 目标槽不存在则原样返回 false 。槽位是
LoadInventory阶段按ItemSlotsPerType预建好的,SetSlottedItem不会凭空造槽。
六、自动装备:FillEmptySlotWithItem
回到 AddInventoryItem 第 5 步那个 bAutoSlot------它调的是 FillEmptySlotWithItem(cpp:340-370),作用是"捡到新物品时,自动塞进一个空槽":
cpp
bool ARPGPlayerControllerBase::FillEmptySlotWithItem(URPGItem* NewItem)
{
FPrimaryAssetType NewItemType = NewItem->GetPrimaryAssetId().PrimaryAssetType;
FRPGItemSlot EmptySlot;
for (TPair<FRPGItemSlot, URPGItem*>& Pair : SlottedItems)
{
if (Pair.Key.ItemType == NewItemType) // 只看同类型的槽
{
if (Pair.Value == NewItem)
{
return false; // 已经装着了,别重复装
}
else if (Pair.Value == nullptr &&
(!EmptySlot.IsValid() || EmptySlot.SlotNumber > Pair.Key.SlotNumber))
{
EmptySlot = Pair.Key; // 记下"编号最小的空槽"
}
}
}
if (EmptySlot.IsValid())
{
SlottedItems[EmptySlot] = NewItem;
NotifySlottedItemChanged(EmptySlot, NewItem);
return true;
}
return false;
}
它的策略很克制:
- 只在同类型槽里找(武器进武器槽,药水进药水槽)。
- 挑编号最小的空槽 :那一长串
(!EmptySlot.IsValid() || EmptySlot.SlotNumber > Pair.Key.SlotNumber)就是在求"目前见过的最小空槽号"。 - 已经装着就直接返回 false:不重复占第二个槽,呼应"单占"语义。
- 注意它返回 bool,
AddInventoryItem用bChanged |= FillEmptySlotWithItem(NewItem)把"是否动了槽"并进总的变更标志------这样即便InventoryData没变(数量顶格),只要自动装备动了槽,也会触发存盘。
七、把一次"捡剑"的数据流串起来
用一张图收束本篇。假设玩家捡到一把全新的剑,调用 AddInventoryItem(剑, 1, 1, bAutoSlot=true):
bash
AddInventoryItem(剑)
├─ 校验通过
├─ GetInventoryItemData(剑) → 没有,OldData = {0,0}
├─ UpdateItemData → NewData = {count:1, level:1}
├─ OldData != NewData ✓
│ ├─ InventoryData.Add(剑, {1,1}) ← 写"拥有"表
│ └─ NotifyInventoryItemChanged(true, 剑) ← 广播"背包变了"
├─ bAutoSlot ✓ → FillEmptySlotWithItem(剑)
│ ├─ 找到 (Weapon,0) 是空槽
│ ├─ SlottedItems[(Weapon,0)] = 剑 ← 写"穿戴"表
│ └─ NotifySlottedItemChanged((Weapon,0), 剑)← 广播"某槽变了"
└─ bChanged ✓ → SaveInventory() ← 自动落盘
一次"捡剑",动了两张表 、发了两类广播 、自动存了一次盘 。而调用方只写了一行 AddInventoryItem。这就是这套设计想给你的体验:写操作自洽------改数据、发通知、存盘,一条龙内部包办。
八、动手与验收
动手任务
- 在
AddInventoryItem开头加日志,PIE 捡物品观察 Output Log;再去 World Outliner 的 PlayerController Details 看两张 map 的实时内容。 - 把
FRPGItemSlot::GetTypeHash注释掉编译,读报错,理解"自定义 struct 当 TMap key 的硬性要求"。 - 在 PIE 里对一件已装备的物品调
RemoveInventoryItem(..., 0)(全删),观察委托触发顺序:先若干SlottedItemChanged,后一条InventoryItemChanged。
验收清单
- 能说清
InventoryData和SlottedItems各回答什么问题、key/value 分别是什么。 - 能解释"背包为什么挂 PlayerController 而非 Character"(生命周期)。
- 能复述
AddInventoryItem的 6 个步骤,并指出"数量叠加、等级覆盖"这个易错点。 - 能解释
RemoveInventoryItem为什么要遍历槽表清槽、广播为什么是"先槽后背包"。 - 能解释
SetSlottedItem/FillEmptySlotWithItem如何共同保证"一件物品只占一个槽"。