【UE源码精读-ActionRPG】背包系统01:数据结构与增删

TOC

导读

背包系统是 ActionRPG 里最适合上手源码精读的模块------它不碰 GAS 那套绕脑子的东西,逻辑全是你熟悉的"增删查改 + 哈希表",但又踩中了好几个 UE 特有的设计取舍。这一篇我们只干两件事:搞清背包数据长什么样、挂在哪 ,然后把"加物品 / 删物品 / 装备物品"这三个写操作逐行读穿

读完你应该能自己回答这几个问题:

  • 为什么背包数据是两张 TMap,而不是一个物品列表?
  • 为什么它挂在 PlayerController 上,而不是角色身上?
  • 调一次 AddInventoryItem,内部到底惊动了哪些东西?
  • "拥有一把剑"和"把剑装在武器槽里"在数据上是怎么分开表达的?

阅读前提 :你已经看过阶段四,知道物品是 URPGItem 这种 Primary Data Asset,靠 FPrimaryAssetId(如 Weapon:Weapon_Sword)唯一标识。本篇会反复用到"物品指针"和"货号"这两个词。

源码范围Source/ActionRPG/Public/RPGTypes.hSource/ActionRPG/Public/RPGPlayerControllerBase.hSource/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 里看到的就是这两行:InventoryDataSlottedItems 并排躺在类声明里,它俩就是整个背包的"真相之源"。

记住这组对应关系,后面所有逻辑都是围着它俩转:

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 里最关键的是堆叠函数 UpdateItemDataRPGTypes.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)。它被当成 TMapkey 用------而自定义 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 步 GetInventoryItemDatacpp:171-182)就是对 InventoryData.Find() 的封装:找到就拷出来返回 true,找不到就把 ItemData 置成 FRPGItemData(0, 0) 返回 false。注意这里 count=0,正好让"没有这件物品"的初始状态在第 3 步堆叠时表现正确。

↑ 背包的所有"读"操作(GetInventoryItemCountGetInventoryItemData)本质都是一次 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);   // 等级直接覆盖(不是叠加!)
}

两个细节很容易看漏:

  1. 数量是"加",等级是"盖"ItemCount + Other.ItemCount 是累加,而 ItemLevel 直接取新传入的值。所以"再捡一把同样的剑"会让数量 +1,但等级取这次传入的等级。
  2. 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(穿戴)。SetSlottedItemcpp: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 ifItem != nullptr 不成立,只会把目标槽设成 null,等于清空。
  • 目标槽不存在则原样返回 false 。槽位是 LoadInventory 阶段按 ItemSlotsPerType 预建好的,SetSlottedItem 不会凭空造槽。

六、自动装备:FillEmptySlotWithItem

回到 AddInventoryItem 第 5 步那个 bAutoSlot------它调的是 FillEmptySlotWithItemcpp: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,AddInventoryItembChanged |= 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。这就是这套设计想给你的体验:写操作自洽------改数据、发通知、存盘,一条龙内部包办。


八、动手与验收

动手任务

  1. AddInventoryItem 开头加日志,PIE 捡物品观察 Output Log;再去 World Outliner 的 PlayerController Details 看两张 map 的实时内容。
  2. FRPGItemSlot::GetTypeHash 注释掉编译,读报错,理解"自定义 struct 当 TMap key 的硬性要求"。
  3. 在 PIE 里对一件已装备的物品调 RemoveInventoryItem(..., 0)(全删),观察委托触发顺序:先若干 SlottedItemChanged,后一条 InventoryItemChanged

验收清单

  • 能说清 InventoryDataSlottedItems 各回答什么问题、key/value 分别是什么。
  • 能解释"背包为什么挂 PlayerController 而非 Character"(生命周期)。
  • 能复述 AddInventoryItem 的 6 个步骤,并指出"数量叠加、等级覆盖"这个易错点。
  • 能解释 RemoveInventoryItem 为什么要遍历槽表清槽、广播为什么是"先槽后背包"。
  • 能解释 SetSlottedItem / FillEmptySlotWithItem 如何共同保证"一件物品只占一个槽"。