【UE源码精读-ActionRPG】存档系统02:异步写盘与读档链路

[TOC]

导读

上一篇 SaveInventory 把背包从"指针态"翻译成"货号态",塞进了 GameInstance.CurrentSaveGame 的内存缓存,最后甩出一句 WriteSaveGame() 就收工了。这一篇接着走完两段路:

  1. 写盘WriteSaveGame 怎么把内存缓存异步刷到磁盘,又怎么用一个三态状态机做"节流",让你连续改 10 次背包最多只写 2 次盘。
  2. 读档:反方向的 LoadInventory——把存档里的货号一个个 ForceLoadItem 回指针,重建运行时两张表,以及它和 GameInstance 之间那条容易绕晕的启动时序。

读完你应该能回答:

  • 为什么写盘要异步?同步写会怎样?
  • bSavingEnabled / bCurrentlySaving / bPendingSaveRequested 三个 bool 各管什么,怎么合起来"节流"?
  • 读档时那些空槽是怎么冒出来的?老存档没记槽位怎么办?
  • BeginPlayLoadInventoryHandleSaveGameLoaded 这几个入口是什么关系?

阅读前提:读过存档 01,知道运行时态↔存档态的指针↔货号翻译,知道 CurrentSaveGame 挂在 GameInstance 上。

源码范围RPGGameInstanceBase.cppWriteSaveGame / HandleAsyncSave / LoadOrCreateSaveGame / HandleSaveGameLoaded)、RPGPlayerControllerBase.cppLoadInventory)。引擎版本 4.27.2。


一、为什么写盘必须异步

磁盘 IO 是慢操作。如果在游戏线程同步写盘,玩家每捡一件物品,主线程就得卡在那儿等磁盘写完——轻则掉帧,重则卡顿一下。移动端存储更慢,一次同步写可能卡掉好几帧,体验直接崩。

所以 ActionRPG 用 UGameplayStatics::AsyncSaveGameToSlot——在后台线程写盘,游戏线程立刻返回继续跑,写完之后通过回调在游戏线程通知你。但异步带来一个新问题:写盘期间,玩家又改了背包怎么办? 这就需要"节流"。


二、WriteSaveGame:三态节流状态机

先认识三个 bool 成员(RPGGameInstanceBase.h:94-104):

bool bSavingEnabled;          // 总开关:关掉则永远不存(演示模式/新角色)
bool bCurrentlySaving;        // 当前是否正有一个异步写盘在飞
bool bPendingSaveRequested;   // 写盘期间是否又来了新请求(只排一个)

WriteSaveGame 的实现(RPGGameInstanceBase.cpp:107-126):

bool URPGGameInstanceBase::WriteSaveGame()
{
    if (bSavingEnabled)
    {
        if (bCurrentlySaving)
        {
            // 正在写盘 → 不再发起新的,只把"待办"标记立起来(只排一个)
            bPendingSaveRequested = true;
            return true;
        }

        // 否则:占用"正在写"标记,发起一次后台写盘
        bCurrentlySaving = true;

        // 后台线程写盘,完成后回调 HandleAsyncSave
        UGameplayStatics::AsyncSaveGameToSlot(
            GetCurrentSaveGame(), SaveSlot, SaveUserIndex,
            FAsyncSaveGameToSlotDelegate::CreateUObject(this, &URPGGameInstanceBase::HandleAsyncSave));
        return true;
    }
    return false;
}

WriteSaveGame 的三态节流逻辑

写盘完成后的回调 HandleAsyncSavecpp:134-145):

void URPGGameInstanceBase::HandleAsyncSave(const FString& SlotName, const int32 UserIndex, bool bSuccess)
{
    ensure(bCurrentlySaving);
    bCurrentlySaving = false;          // 先把"正在写"放掉

    if (bPendingSaveRequested)         // 写盘期间有人来过 → 补写一次
    {
        bPendingSaveRequested = false;
        WriteSaveGame();               // 递归再发起一次,带上最新数据
    }
}

HandleAsyncSave:写盘完成后检查待办并补写

节流是怎么生效的

把两段连起来看,状态机只有三种情形:

当前状态来了个 WriteSaveGame结果
空闲(没在写)发起后台写盘,bCurrentlySaving=true真写一次
正在写只置 bPendingSaveRequested=true不写,排一个待办
正在写、待办已置还是只置 bPendingSaveRequested=true合并,不累加

关键在第三行:待办只有一个 bool,不是队列。所以无论写盘期间你又改了多少次背包,最多只排一个"还需要再写一次"。写盘完成时 HandleAsyncSave 看到待办,就用当时最新的 CurrentSaveGame 再写一遍。

效果:连续改 10 次背包,最多只发生 2 次磁盘 IO——正在飞的那一次 + 收尾补的那一次。中间 8 次请求全被合并掉了。这就是用三个 bool 实现的极简节流。

bSavingEnabled 的用途:它是总开关。演示模式、或者想让某次游玩"永远算新角色不留档",把它设 falseWriteSaveGame 直接返回、什么都不写。读档侧(下一节)也会看它:关掉时连已存在的存档都当不存在。


三、LoadInventory:货号 → 指针

写盘讲完,看反方向。LoadInventoryRPGPlayerControllerBase.cpp:266-338,把存档态翻译回运行时态:

bool ARPGPlayerControllerBase::LoadInventory()
{
    // ── 第 1 步:清空运行时两张表 ──
    InventoryData.Reset();
    SlottedItems.Reset();

    UWorld* World = GetWorld();
    URPGGameInstanceBase* GameInstance = World ? World->GetGameInstance<URPGGameInstanceBase>() : nullptr;
    if (!GameInstance) { return false; }

    // ── 第 2 步:订阅"存档被重载"事件(只订一次)──
    if (!GameInstance->OnSaveGameLoadedNative.IsBoundToObject(this))
    {
        GameInstance->OnSaveGameLoadedNative.AddUObject(this, &ARPGPlayerControllerBase::HandleSaveGameLoaded);
    }

    // ── 第 3 步:按 ItemSlotsPerType 预建所有空槽 ──
    for (const TPair<FPrimaryAssetType, int32>& Pair : GameInstance->ItemSlotsPerType)
    {
        for (int32 SlotNumber = 0; SlotNumber < Pair.Value; SlotNumber++)
        {
            SlottedItems.Add(FRPGItemSlot(Pair.Key, SlotNumber), nullptr);   // 先全填 null
        }
    }

    URPGSaveGame* CurrentSaveGame = GameInstance->GetCurrentSaveGame();
    URPGAssetManager& AssetManager = URPGAssetManager::Get();
    if (CurrentSaveGame)
    {
        // ── 第 4 步:翻译"拥有的物品" 货号→指针 ──
        bool bFoundAnySlots = false;
        for (const TPair<FPrimaryAssetId, FRPGItemData>& ItemPair : CurrentSaveGame->InventoryData)
        {
            URPGItem* LoadedItem = AssetManager.ForceLoadItem(ItemPair.Key);  // 货号 → 指针(同步加载)
            if (LoadedItem != nullptr)
            {
                InventoryData.Add(LoadedItem, ItemPair.Value);
            }
        }

        // ── 第 5 步:翻译"槽位" 货号→指针,带合法性校验 ──
        for (const TPair<FRPGItemSlot, FPrimaryAssetId>& SlotPair : CurrentSaveGame->SlottedItems)
        {
            if (SlotPair.Value.IsValid())
            {
                URPGItem* LoadedItem = AssetManager.ForceLoadItem(SlotPair.Value);
                if (GameInstance->IsValidItemSlot(SlotPair.Key) && LoadedItem)   // 防止老存档的非法槽位
                {
                    SlottedItems.Add(SlotPair.Key, LoadedItem);
                    bFoundAnySlots = true;
                }
            }
        }

        // ── 第 6 步:老存档兜底——没记任何槽位就自动装备 ──
        if (!bFoundAnySlots)
        {
            FillEmptySlots();
        }

        // ── 第 7 步:广播"背包整体重载" ──
        NotifyInventoryLoaded();
        return true;
    }

    // 读档失败也要广播,否则 UI 停在旧状态
    NotifyInventoryLoaded();
    return false;
}

逐步看关键点:

第 3 步:空槽是这里"生"出来的。 上一篇说过 SaveInventory 会把空槽也写进存档,但即便存档没记槽,LoadInventory 也会先按 GameInstance 的 ItemSlotsPerType 配置,把所有合法槽位全建出来、初值 nullptr。也就是说槽位结构由配置决定,存档只负责往里填东西

GameInstance 的 DefaultInventory 与 ItemSlotsPerType 配置

第 4/5 步:ForceLoadItem 是翻译器。 它拿货号去 Asset Manager 同步加载出物品对象(指针)。这是上一篇 GetPrimaryAssetId() 的逆操作。注意它是同步加载——会阻塞,但读档时机通常藏在加载屏后面,可接受(这点和异步加载的取舍后面 UI 篇还会展开)。

第 5 步:IsValidItemSlot 防老存档脏数据。 玩家可能玩的是旧版本存档,里面记着一个现在已经不存在的槽位(比如老版本有 5 个武器槽,新版本砍到 3 个)。IsValidItemSlot 校验槽号是否还在合法范围内,把非法槽位丢弃,避免把脏数据填进运行时表。

第 6 步:老存档兼容兜底。 如果遍历完一个有效槽位都没找到(bFoundAnySlots 仍为 false),说明这是个"只记了拥有、没记装备"的老存档,调 FillEmptySlots() 把拥有的物品自动归位到空槽。

第 7 步:无论成败都要广播。 读档成功广播,失败(CurrentSaveGame 为空)也广播。因为 UI 是靠 NotifyInventoryLoaded 整体重刷的,不广播的话 UI 会卡在上一局的旧画面。


四、启动时序:三个入口怎么串起来

读档最容易绕晕的是"到底谁调谁"。理清三个角色:

  • ARPGPlayerControllerBase::BeginPlay:控制器开始游戏,第一件事就是 LoadInventory()cpp:404-410)。
  • URPGGameInstanceBase::LoadOrCreateSaveGame / HandleSaveGameLoaded:GameInstance 这边负责"把存档对象准备好",准备好后广播 OnSaveGameLoadedNative
  • ARPGPlayerControllerBase::HandleSaveGameLoaded:Controller 订阅了上面那个广播,存档一旦被换掉(读档/重置),它就LoadInventory() 一次重填背包。

GameInstance 侧的存档准备(RPGGameInstanceBase.cpp:68-99):

bool URPGGameInstanceBase::HandleSaveGameLoaded(USaveGame* SaveGameObject)
{
    // ...(bSavingEnabled 关掉则忽略传入对象)
    CurrentSaveGame = Cast<URPGSaveGame>(SaveGameObject);

    if (CurrentSaveGame)
    {
        AddDefaultInventory(CurrentSaveGame, false);   // 补上新增的默认物品
    }
    else
    {
        // 没有存档就现造一个,并塞入默认背包
        CurrentSaveGame = Cast<URPGSaveGame>(UGameplayStatics::CreateSaveGameObject(URPGSaveGame::StaticClass()));
        AddDefaultInventory(CurrentSaveGame, true);
    }

    // 通知所有订阅者:存档对象已就绪/已更换
    OnSaveGameLoaded.Broadcast(CurrentSaveGame);
    OnSaveGameLoadedNative.Broadcast(CurrentSaveGame);
    return /* 是否真的从磁盘读到 */;
}

把它和 Controller 串起来,完整时序是:

① Controller::BeginPlay
     └─ LoadInventory()                       ← 第一次填背包(此时存档可能还没就绪)
          └─ 订阅 GameInstance.OnSaveGameLoadedNative

② GameInstance::LoadOrCreateSaveGame
     └─ 从磁盘读 / 现造一个 URPGSaveGame
          └─ HandleSaveGameLoaded
               └─ CurrentSaveGame 就绪 + AddDefaultInventory
                    └─ OnSaveGameLoadedNative.Broadcast()
                         └─ ③ Controller::HandleSaveGameLoaded
                              └─ LoadInventory()   ← 再填一次,这次数据齐了

为什么要填两次?因为 BeginPlay 和"存档就绪"谁先谁后并不固定。Controller 先 LoadInventory 一遍(可能扑空),同时订阅好事件;等 GameInstance 把存档准备好并广播,Controller 再被回调 LoadInventory 一遍,这次一定能拿到就绪的 CurrentSaveGame用"先订阅 + 事件回调"消除时序依赖,这是 UE 里非常典型的解耦手法。

AddDefaultInventory 的作用:游戏更新后可能新增了默认物品(比如送所有玩家一把新武器)。AddDefaultInventory(SaveGame, false) 会把 GameInstance.DefaultInventory 里"存档还没有的"补进去——老玩家也能拿到新福利,且不覆盖他已有的数据。


五、动手与验收

动手任务

  1. WriteSaveGameHandleAsyncSave 各加一行 UE_LOG,PIE 里快速连续捡 3 件物品,数日志里实际写盘次数,验证"节流"。
  2. LoadInventory 第 4 步加日志打印 ForceLoadItem 加载出的物品名,退出重进,观察货号是怎么变回指针的。
  3. 把 GameInstance 的 ItemSlotsPerType 某个类型槽数改小,造一个"超界槽位"的旧存档,验证第 5 步 IsValidItemSlot 把它丢弃。

验收清单

  • 能解释"为什么写盘要异步",以及同步写盘的后果。
  • 能用三个 bool 复述节流状态机,并说清"连续改 10 次最多写 2 次"是怎么来的。
  • 能复述 LoadInventory 的 7 个步骤,重点说清"空槽从 ItemSlotsPerType 预建"。
  • 能解释第 5 步 IsValidItemSlot 和第 6 步 FillEmptySlots 各自防的是什么情况。
  • 能画出"BeginPlay / LoadOrCreateSaveGame / HandleSaveGameLoaded"的启动时序,并解释为什么要 LoadInventory 两次。
内容概要:本文主要介绍了一个基于Matlab实现的无人机空中通信仿真项目,旨在通过数值仿真手段研究无人机在空中作为通信节点时的通信性能、信号传播特性和网络拓扑行为。该仿真涵盖了无人机飞行轨迹建模、无线信道建模(如路径损耗、多普勒效应、阴影衰落等)、通信链路建立中断判断、信号干扰分析以及网络性能评估(如吞吐量、延迟、连接可靠性等)。项目可能结合优化算法或智能控制策略,用于优化无人机位置部署或动态路径规划,以提升通信服务质量。整个仿真系统为研究人员提供了一套完整的工具链,用于验证新型无人机通信协议、协作机制和网络架构的有效性。; 适合人群:具备一定Matlab编程基础和通信原理基础知识,从事无人机、无线通信、网络优化等相关领域研究的研发人员和高校研究生。; 使用场景及目标:① 评估无人机作为空中基站或中继节点的通信覆盖能力和网络性能;② 设计和优化无人机集群的通信拓扑协同策略;③ 验证新型无线资源分配、移动性管理和抗干扰算法在动态空地网络中的有效性。; 阅读建议:使用者应结合Matlab代码深入理解仿真模型的构建逻辑,重点关注通信信道模块和无人机运动学模型的耦合关系,并可根据实际研究需求,对仿真参数(如环境噪声、飞行速度、天线增益)进行调整,以开展针对性的对比实验和性能分析。
内容概要:本文围绕微电网中光伏发电系统经逆变器带负载的完整仿真模型展开研究,利用Simulink平台构建了从光伏阵列建模、DC-AC逆变器控制(包括PWM调制电压电流双闭环控制)、并网策略到负载响应的全过程仿真系统。重点分析了系统在不同工况下的动态响应特性电能质量表现,并对并网控制策略、最大功率点跟踪(MPPT)技术及系统稳定性进行了深入探讨和验证。该模型不仅可用于教学演示微电网的基本架构运行机制,更为科研提供了可靠的仿真平台,支持对新型控制算法系统优化方案的有效验证评估。; 适合人群:具备一定电力电子技术、自动控制理论基础及Simulink/MATLAB操作经验的电气工程、自动化等相关专业的本科生、研究生及科研人员。; 使用场景及目标:①用于高校课程教学中微电网系统结构运行原理的直观演示;②为科研工作者提供光伏发电并网系统的仿真验证平台,支持开展逆变器控制算法(如双闭环控制、MPPT)、系统稳定性分析及电能质量管理等关键技术的研究优化。; 阅读建议:建议学习者结合Simulink仿真环境动手搭建模型,重点关注各功能模块间的信号传递关系关键参数设置,并通过调整光照强度、温度、负载大小等外部条件,观察系统动态响应过程,从而深化对微电网运行特性的理解掌握。
内容概要:本文围绕“多变量输入超前多步预测”的光伏功率预测问题,提出了一种基于CNN-BiLSTM混合深度学习模型的研究方法,并提供了完整的Matlab代码实现。该模型首先利用卷积神经网络(CNN)提取输入气象数据(如光照强度、温度、湿度等)中的局部关键特征,捕捉变量间的空间相关性;随后,通过双向长短期记忆网络(BiLSTM)充分挖掘时间序列数据中的长期依赖关系,既能利用历史信息,也能结合未来时刻的上下文信息,从而实现对未来多个时间步长的光伏功率进行高精度预测。研究重点在于处理多变量输入和满足超前多步预测的实际工程需求,有效提升了预测的准确性鲁棒性。; 适合人群:具备一定机器学习和深度学习理论基础,熟悉Matlab编程,从事新能源发电预测、电力系统调度、时间序列分析等相关领域的研究人员和工程技术人员。; 使用场景及目标:① 解决光伏出力受多重气象因素影响的复杂非线性预测问题;② 实现未来一段时间(如未来24小时)的功率超前多步预测,为电网调度、储能管理和电力市场交易提供决策依据;③ 学习和复现先进的CNNBiLSTM融合模型在能源预测领域的具体应用。; 阅读建议:使用者应重点关注模型的网络结构设计、多变量数据预处理流程以及多步预测的实现策略。建议结合提供的Matlab代码,自行准备或替换实际的光伏电站运行数据气象数据,通过调整模型超参数(如卷积核大小、LSTM隐藏层维度、训练周期等)进行实验,以深入理解模型性能并将其应用于具体的科研或工程项目中。
内容概要:本文介绍了一种基于Simulink的光伏储能单相逆变器并网仿真模型,系统性地实现了光伏储能系统电网之间的能量转换并网控制全过程。该模型涵盖逆变器的PWM调制、并网同步控制、功率调节策略以及储能单元的能量管理机制,能够精确模拟光照强度变化、负载波动及电网扰动等多种实际运行工况下的系统动态响应特性。通过模块化建模方法,模型具备良好的可扩展性灵活性,便于研究人员对并网电能质量、控制算法性能及系统稳定性进行深入分析优化设计。; 适合人群:具备电力电子、新能源发电或自动控制等相关专业背景的本科高年级学生、研究生,以及从事光伏并网系统研发的工程技术人员。; 使用场景及目标:①作为教学工具,帮助学生理解光伏并网逆变器的工作原理控制逻辑;②服务于科研项目,用于并网控制算法(如PI、PR、重复控制等)的设计、仿真验证性能对比;③辅助完成毕业设计或工程项目中的系统仿真环节;④为实际工程应用提供前期仿真验证技术预研支持。; 阅读建议:建议使用者在学习前巩固电力电子技术和可再生能源系统的基础理论,按照模型结构逐步搭建调试;可利用文中提供的仿真框图和参数设置进行复现,并尝试引入不同工况(如光照突变、电网电压波动等)以评估系统的鲁棒性适应性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

夜猫逐梦

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

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

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

打赏作者

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

抵扣说明:

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

余额充值