【UE源码精读-ActionRPG】存档系统01:运行时态与存档态

[TOC]

导读

背包那两篇我们一直在内存里折腾:两张 TMap 挂在 PlayerController 上,增删改查、委托广播。但内存里的东西关机即焚——指针在下次启动时全部失效。这一篇开始进入存档系统,第一个、也是最核心的认知就是:

同一份背包数据,在"运行时"和"存档里"是两种完全不同的形态,存档的本质就是这两种形态之间的双向翻译。

读完你应该能回答:

  • 为什么运行时用 URPGItem*(指针),存档里却用 FPrimaryAssetId(字符串)?
  • "存档"这个动作,到底是把指针怎么变成能落盘的东西的?
  • 为什么 SaveInventory 每次都把存档数据全量清空重写,而不是增量更新?
  • 数据从 Controller 到磁盘,中间为什么要经过 GameInstance 这一道?

阅读前提:读过本系列背包两篇,知道 InventoryDataTMap<URPGItem*, FRPGItemData>)和 SlottedItemsTMap<FRPGItemSlot, URPGItem*>)是挂在 ARPGPlayerControllerBase 上的两张权威表。

源码范围RPGSaveGame.hRPGGameInstanceBase.hRPGPlayerControllerBase.cppSaveInventory)。引擎版本 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 逐行精读:指针 → 货号

现在看翻译的主角。SaveInventoryRPGPlayerControllerBase.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.Valuenullptr(空槽),也会 Add(SlotPair.Key, 无效货号) 把这个槽写进去。为什么不跳过空槽? 因为槽位的"结构"本身是信息——玩家有几个武器槽、几个药水槽,这个布局要保留。读档时正是靠这些记录把空槽一个个重建出来(下一篇细讲)。如果跳过空槽,读档就分不清"这个槽空着"和"压根没这个槽"。

第 4 步:只更新内存,落盘另说。 SaveInventory 干完翻译,数据进了 CurrentSaveGame 的内存表,但还没写进文件。真正写磁盘是 WriteSaveGame() 的事——它在后台线程异步落盘,是下一篇的主题。这里只要记住:SaveInventory 负责"翻译 + 更新内存缓存",WriteSaveGame 负责"把缓存刷到磁盘"。


五、回顾:背包写操作为什么"自动存盘"

把这一篇和背包篇接起来,有个细节现在能闭环了。背包 01 讲过,AddInventoryItem / RemoveInventoryItem / SetSlottedItem 改完数据都会在末尾自动调 SaveInventory()。现在你知道这一行背后发生了什么:

AddInventoryItem()
  └─ 改完运行时 InventoryData
       └─ SaveInventory()
            ├─ CurrentSaveGame 两张表 Reset()
            ├─ 遍历运行时表,指针 → 货号,重新填 CurrentSaveGame
            └─ WriteSaveGame() → 异步落盘

所以"调用方永远不用操心存盘"这个体验,代价是每次改背包都全量翻译一遍并请求落盘。配合下一篇的异步节流(连续改 10 次最多写 2 次盘),这个代价被压到可接受——这正是两篇要拼起来才完整的设计。


六、动手与验收

动手任务

  1. SaveInventory 的第 2 步循环里加一行 UE_LOG,打印每个 AssetId.ToString(),PIE 中捡物品,观察"指针被翻译成了什么货号"。
  2. 在 World Outliner 找到 GameInstance(或用蓝图调 GetCurrentSaveGame),对比它的 InventoryData(货号态)和 Controller 的 InventoryData(指针态),确认是同一批物品的两种形态。
  3. 想一想:如果第 3 步跳过空槽不存,读档时会丢失什么信息?

验收清单

  • 能画出"运行时态 vs 存档态"的对照表,并说清差异只在物品身份(指针 vs 货号)。
  • 能解释"为什么不能直接存指针"(关机失效、不可序列化)。
  • 能复述 SaveInventory 的 4 个步骤,并解释"为什么全量清空重写"。
  • 能解释"为什么空槽也要写进存档"。
  • 能说清存档对象为什么挂在 GameInstance 而非 PlayerController(生命周期)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夜猫逐梦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值