[TOC]
导读
背包那两篇我们一直在内存里折腾:两张 TMap 挂在 PlayerController 上,增删改查、委托广播。但内存里的东西关机即焚——指针在下次启动时全部失效。这一篇开始进入存档系统,第一个、也是最核心的认知就是:
同一份背包数据,在"运行时"和"存档里"是两种完全不同的形态,存档的本质就是这两种形态之间的双向翻译。
读完你应该能回答:
- 为什么运行时用
URPGItem*(指针),存档里却用FPrimaryAssetId(字符串)? - "存档"这个动作,到底是把指针怎么变成能落盘的东西的?
- 为什么
SaveInventory每次都把存档数据全量清空重写,而不是增量更新? - 数据从 Controller 到磁盘,中间为什么要经过 GameInstance 这一道?
阅读前提:读过本系列背包两篇,知道
InventoryData(TMap<URPGItem*, FRPGItemData>)和SlottedItems(TMap<FRPGItemSlot, URPGItem*>)是挂在ARPGPlayerControllerBase上的两张权威表。源码范围:
RPGSaveGame.h、RPGGameInstanceBase.h、RPGPlayerControllerBase.cpp(SaveInventory)。引擎版本 4.27.2。
一、同一份数据,两种形态
先把"运行时态"和"存档态"并排放在一起看,差异一目了然:
| 运行时态(PlayerController 上) | 存档态(URPGSaveGame 里) | |
|---|---|---|
| 物品身份 | URPGItem*(对象指针) | FPrimaryAssetId(货号字符串) |
| 拥有的物品 | TMap<URPGItem*, FRPGItemData> | TMap<FPrimaryAssetId, FRPGItemData> |
| 槽位内容 | TMap<FRPGItemSlot, URPGItem*> | TMap<FRPGItemSlot, FPrimaryAssetId> |
看出规律了吗?两张表的"骨架"完全一样,只是把物品的身份从"指针"换成了"货号"。 FRPGItemData(数量+等级)和 FRPGItemSlot(槽位)这两个纯数据结构,两边通用、原样保留。
为什么不能直接存指针?
URPGItem* 是一个内存地址。这个地址:
- 关机即失效:下次启动,同一个物品资产会被加载到一个全新的、完全不同的地址。把这次的地址写进文件,下次读出来就是一个野指针。
- 不可序列化:你没法把"内存地址"这个概念有意义地写进磁盘文件。
而 FPrimaryAssetId(比如 "Weapon:Weapon_Sword_2")是一个稳定的字符串货号:
- 它由阶段四的 Asset Manager 体系保证——同一个物品资产,无论哪次启动、什么平台,货号永远是这一串。
- 它能原样写进文件,下次读出来还能靠它反查回对应的物品对象。
所以存档的两个核心动作就是:
- 存 = 指针 → 货号:
URPGItem*调GetPrimaryAssetId()得到货号写盘。(本篇主题) - 读 = 货号 → 指针:货号交给
ForceLoadItem()同步加载回对象。(下一篇主题)
二、URPGSaveGame:存档态长什么样
存档态的数据定义在 RPGSaveGame.h:
UCLASS(BlueprintType)
class ACTIONRPG_API URPGSaveGame : public USaveGame
{
GENERATED_BODY()
public:
URPGSaveGame()
{
// 构造时设为当前版本,读档反序列化时会被存档里的值覆盖
SavedDataVersion = ERPGSaveGameVersion::LatestVersion;
}
/** 拥有的物品:货号 -> 数量/等级 */
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = SaveGame)
TMap<FPrimaryAssetId, FRPGItemData> InventoryData;
/** 槽位内容:槽位 -> 货号 */
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = SaveGame)
TMap<FRPGItemSlot, FPrimaryAssetId> SlottedItems;
/** 玩家唯一 id */
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = SaveGame)
FString UserId;
// ... SavedDataVersion / Serialize 等,留到版本兼容篇讲
};
注意它继承自 USaveGame——这是 UE 提供的存档基类。规则很简单:带 UPROPERTY 的字段会被引擎自动序列化进 .sav 文件。所以你只要把要存的东西声明成 UPROPERTY,剩下的读写引擎全包。
把它和 Controller 上的运行时表对照,InventoryData / SlottedItems 字段名一模一样,只是 value/key 里的 URPGItem* 全换成了 FPrimaryAssetId。这就是第一节那张表的代码实证。
UE 基础补给:
USaveGame+UGameplayStatics::SaveGameToSlot/LoadGameFromSlot是引擎自带的一套存档方案。你定义一个USaveGame子类、往里塞UPROPERTY,引擎负责把它序列化到Saved/SaveGames/<槽位名>.sav。本项目在此之上加了"指针↔货号翻译"和"异步节流",但底座就是这套。
三、谁持有存档对象?GameInstance 这道中转
有个容易忽略的问题:运行时数据在 PlayerController 上,但 URPGSaveGame 对象并不挂在 Controller 上——它挂在 GameInstance 上。看 RPGGameInstanceBase.h:
UCLASS()
class ACTIONRPG_API URPGGameInstanceBase : public UGameInstance
{
// ...
protected:
/** 当前存档对象 */
UPROPERTY()
URPGSaveGame* CurrentSaveGame;
};
为什么是 GameInstance 而不是 Controller?回忆背包 01 讲的生命周期:
UGameInstance ← 进程级单例,切关卡都不销毁 ← 存档对象挂这儿
└─ APlayerController ← 切关卡默认重建 ← 运行时背包挂这儿
APlayerController 切关卡会被重建,而存档必须"跨关卡、跨角色、跨整局都不丢",所以承载存档的 CurrentSaveGame 必须挂在永远活着的 GameInstance 上。
于是数据的流向是这样一条链:
PlayerController(运行时两张表)
│ SaveInventory:指针→货号,写进 CurrentSaveGame 的内存缓存
▼
GameInstance.CurrentSaveGame(存档态两张表,纯内存)
│ WriteSaveGame:异步落盘
▼
磁盘 Saved/SaveGames/SaveGame.sav
记住这条链——本篇只走到第一段(Controller → CurrentSaveGame 内存缓存),第二段(内存缓存 → 磁盘)是下一篇的异步写盘。
四、SaveInventory 逐行精读:指针 → 货号
现在看翻译的主角。SaveInventory 在 RPGPlayerControllerBase.cpp:220-264:
bool ARPGPlayerControllerBase::SaveInventory()
{
// ── 拿到 GameInstance 上的当前存档对象 ──
UWorld* World = GetWorld();
URPGGameInstanceBase* GameInstance = World ? World->GetGameInstance<URPGGameInstanceBase>() : nullptr;
if (!GameInstance) { return false; }
URPGSaveGame* CurrentSaveGame = GameInstance->GetCurrentSaveGame();
if (CurrentSaveGame)
{
// ── 第 1 步:清空旧快照(全量覆盖,不做增量)──
CurrentSaveGame->InventoryData.Reset();
CurrentSaveGame->SlottedItems.Reset();
// ── 第 2 步:翻译"拥有的物品" 指针→货号 ──
for (const TPair<URPGItem*, FRPGItemData>& ItemPair : InventoryData)
{
FPrimaryAssetId AssetId;
if (ItemPair.Key)
{
AssetId = ItemPair.Key->GetPrimaryAssetId(); // 指针 → 货号
CurrentSaveGame->InventoryData.Add(AssetId, ItemPair.Value);
}
}
// ── 第 3 步:翻译"槽位内容" 指针→货号(空槽存无效货号)──
for (const TPair<FRPGItemSlot, URPGItem*>& SlotPair : SlottedItems)
{
FPrimaryAssetId AssetId;
if (SlotPair.Value)
{
AssetId = SlotPair.Value->GetPrimaryAssetId(); // 指针 → 货号
}
CurrentSaveGame->SlottedItems.Add(SlotPair.Key, AssetId); // 注意:空槽也写进去
}
// ── 第 4 步:请 GameInstance 落盘 ──
GameInstance->WriteSaveGame();
return true;
}
return false;
}
在这里插入图片描述
逐步看几个设计点:
第 1 步:为什么 Reset() 全量清空? SaveInventory 不做增量 diff,而是把存档里的两张表整个清掉、按当前运行时状态重写一遍。理由是简单且不会出错——运行时状态才是唯一真相,存档只是它的一份快照,每次"重新拍一张"比"对着旧快照打补丁"可靠得多。背包数据量很小(几十个物品),全量重写的开销可以忽略。
第 2 步:GetPrimaryAssetId() 就是翻译器。 每个 URPGItem* 调一次这个虚函数,得到它稳定的货号字符串。这是阶段四 Asset Manager 体系埋下的接口——物品类重写了 GetPrimaryAssetId(),让自己能被货号唯一标识。存档在这里"收割"了那套设计的成果。
第 3 步:空槽也要存。 注意槽位循环里,即使 SlotPair.Value 是 nullptr(空槽),也会 Add(SlotPair.Key, 无效货号) 把这个槽写进去。为什么不跳过空槽? 因为槽位的"结构"本身是信息——玩家有几个武器槽、几个药水槽,这个布局要保留。读档时正是靠这些记录把空槽一个个重建出来(下一篇细讲)。如果跳过空槽,读档就分不清"这个槽空着"和"压根没这个槽"。
第 4 步:只更新内存,落盘另说。 SaveInventory 干完翻译,数据进了 CurrentSaveGame 的内存表,但还没写进文件。真正写磁盘是 WriteSaveGame() 的事——它在后台线程异步落盘,是下一篇的主题。这里只要记住:SaveInventory 负责"翻译 + 更新内存缓存",WriteSaveGame 负责"把缓存刷到磁盘"。
五、回顾:背包写操作为什么"自动存盘"
把这一篇和背包篇接起来,有个细节现在能闭环了。背包 01 讲过,AddInventoryItem / RemoveInventoryItem / SetSlottedItem 改完数据都会在末尾自动调 SaveInventory()。现在你知道这一行背后发生了什么:
AddInventoryItem(剑)
└─ 改完运行时 InventoryData
└─ SaveInventory()
├─ CurrentSaveGame 两张表 Reset()
├─ 遍历运行时表,指针 → 货号,重新填 CurrentSaveGame
└─ WriteSaveGame() → 异步落盘
所以"调用方永远不用操心存盘"这个体验,代价是每次改背包都全量翻译一遍并请求落盘。配合下一篇的异步节流(连续改 10 次最多写 2 次盘),这个代价被压到可接受——这正是两篇要拼起来才完整的设计。
六、动手与验收
动手任务
- 在
SaveInventory的第 2 步循环里加一行UE_LOG,打印每个AssetId.ToString(),PIE 中捡物品,观察"指针被翻译成了什么货号"。 - 在 World Outliner 找到 GameInstance(或用蓝图调
GetCurrentSaveGame),对比它的InventoryData(货号态)和 Controller 的InventoryData(指针态),确认是同一批物品的两种形态。 - 想一想:如果第 3 步跳过空槽不存,读档时会丢失什么信息?
验收清单
- 能画出"运行时态 vs 存档态"的对照表,并说清差异只在物品身份(指针 vs 货号)。
- 能解释"为什么不能直接存指针"(关机失效、不可序列化)。
- 能复述
SaveInventory的 4 个步骤,并解释"为什么全量清空重写"。 - 能解释"为什么空槽也要写进存档"。
- 能说清存档对象为什么挂在 GameInstance 而非 PlayerController(生命周期)。

被折叠的 条评论
为什么被折叠?



