欢迎光临
我们一直在努力

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

[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):

/** 玩家拥有的所有物品:物品定义 -> 它的数量/等级数据 */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Inventory)
TMap<URPGItem*, FRPGItemData> InventoryData;

/** 装备槽:槽位(类型+编号) -> 装在里面的物品,槽位数量由 GameInstance 的 ItemSlotsPerType 初始化 */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Inventory)
TMap<FRPGItemSlot, URPGItem*> SlottedItems;

在这里插入图片描述

↑ 在 IDE 里看到的就是这两行:InventoryData 和 SlottedItems 并排躺在类声明里,它俩就是整个背包的"真相之源"。

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

InventoryDataSlottedItems
回答的问题 “我有什么、几个、几级” “哪个槽装了什么”
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:

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):

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 正好都提供了:

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 上:

UCLASS()
class ACTIONRPG_API ARPGPlayerControllerBase : public APlayerController, public IRPGInventoryInterface

要理解它,先得有 UE 的对象层级心智模型:

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 步看:

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

↑ 背包的所有"读"操作(GetInventoryItemCount、GetInventoryItemData)本质都是一次 InventoryData.Find(Item)——TMap 的哈希查找。

第 3 步 UpdateItemData 才是堆叠逻辑的核心(RPGTypes.h:111-125):

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:

    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)负责"把某件物品放进指定槽",同时保证它不会同时出现在两个槽里:

    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),作用是"捡到新物品时,自动塞进一个空槽":

    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):

    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 如何共同保证"一件物品只占一个槽"。
    赞(0)
    未经允许不得转载:171主机测评 » 【UE源码精读-ActionRPG】背包系统01:数据结构与增删
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址