第十九章:存档系统

一、 本章核心目标

本章的核心任务是构建一个全面的、能在服务器本地持久化关键游戏数据的存档系统。此目标可分解为显性与隐性两个层面:

  • 显性目标 (Player-Facing):

    • 为玩家实现无缝的游戏进度保存与加载体验。玩家的角色属性、物品、位置、部落信息以及他们建造的建筑,在退出服务器后能够被保存,并在下次加入时恢复。
  • 隐性/战略目标 (Technical & Project):

    • 数据模块化管理: 将不同类型的数据(如玩家、建筑、部落)分离到独立的SaveGame对象中,确保系统的高内聚、低耦合,便于未来扩展与维护。

    • 服务器权威性: 所有存档与读档操作的逻辑均在服务器端执行,保证了数据的安全性和一致性,防止客户端作弊。

    • 非阻塞式加载时序处理: 解决游戏启动时各模块(PlayerState, PlayerController, GameState)加载数据的时序依赖问题,确保UI和游戏逻辑能正确反映已加载的数据。

目标关系图:

目标层级 目标内容 实现策略
显性目标 玩家的游戏进度可以被保存和加载。 实现玩家角色、建筑、部落等核心数据的存档功能。
支撑与实现
隐性目标 1. 数据模块化 创建三个独立的 SaveGame 对象:PlayerInfo_SaveGame, Structures_SaveGame, Tribes_SaveGame
2. 服务器权威 存档和读档的核心逻辑通过 Switch Has Authority 节点确保仅在服务器上运行。
3. 加载时序 PlayerController 中使用 Delay 节点,等待 PlayerState 完成数据加载后,再更新部落UI。

二、 系统与功能实现

本章共实现了三个核心的子系统,它们共同构成了完整的游戏存档功能。

  • 1. 玩家数据存档系统 (PlayerInfo_SaveGame)

    • 描述: 该系统负责保存与单个玩家直接相关的全部信息。每个玩家的存档通过其唯一的 Net ID 作为文件名进行区分。

    • 保存内容: 角色位置、核心属性(生命、体力等)、等级属性(经验、技能点)、背包与快捷栏物品、已装备的护甲、死亡状态、部落信息(ID, 名称, 等级)以及解锁的蓝图(Engrams)。

    • 实现位置: 逻辑主要位于 FirstPersonCharacter(保存/加载物品、位置)和 PlayerState(保存/加载部落身份信息)中。

  • 2. 建筑数据存档系统 (Structures_SaveGame)

    • 描述: 该系统负责保存服务器世界中所有由玩家放置的建筑。它使用一个固定的文件名(如 StructureSaveData.sav)来存储整个服务器的建筑数据。

    • 保存内容: 为每个建筑保存其Actor类、世界坐标变换(Transform)、当前生命值(HP)、所有者信息(玩家或部落),以及(如果是存储容器)内部存放的物品列表。

    • 实现位置: 逻辑完全位于 GameMode 中。服务器启动时加载 (Event BeginPlay),服务器关闭时保存 (Event EndPlay)。

  • 3. 部落数据存档系统 (Tribes_SaveGame)

    • 描述: 负责保存服务器上所有部落的详细信息。与建筑系统类似,它也使用一个固定的文件名 (TribesSaveData.sav)。

    • 保存内容: 一个以部落ID为键(Key)、部落信息结构体 S_TribeInfo 为值(Value)的Map(字典)。

    • 实现位置: 逻辑位于 GameState 中,因为它管理着所有部落的全局状态。

系统交互与依赖关系:

flowchart TD
    subgraph Server Shutdown
        A[GameMode: Event EndPlay] --> B{Save All Structures};
        C[GameState: Event EndPlay] --> D{Save All Tribes};
    end

    subgraph Player Leaves
        E[PlayerState: Event EndPlay] --> F{Save Player's Tribe Info};
        G[Character: Event EndPlay] --> H{Save Player's Stats, Inventory, Location};
    end
    
    subgraph Server Startup
        I[GameMode: Event BeginPlay] --> J{Load All Structures};
        K[GameState: Event BeginPlay] --> L{Load All Tribes};
    end
    
    subgraph Player Joins
        M[Character: Event BeginPlay] --> N{Load Stats, Inventory, Location};
        O[PlayerState: Event BeginPlay] --> P{Load Player's Tribe Info};
        P --> Q[PlayerController: Event BeginPlay];
        L --> Q;
        Q -- 5s Delay --> R{Update Tribe UI};
    end

    H -- Writes to --> S[PlayerInfo_SaveGame.sav];
    F -- Writes to --> S;
    N -- Reads from --> S;
    P -- Reads from --> S;
    B -- Writes to --> T[Structures_SaveGame.sav];
    J -- Reads from --> T;
    D -- Writes to --> U[Tribes_SaveGame.sav];
    K -- Reads from --> U;

三、 关键设计思想

本章的设计体现了清晰的软件工程原则和模式,旨在构建一个健壮且可维护的系统。

  • 设计原则:

    • 单一职责原则 (Single Responsibility Principle):

      • 应用: 将存档逻辑分散到三个不同的SaveGame对象中。PlayerInfo_SaveGame只关心玩家数据,Structures_SaveGame只关心建筑,Tribes_SaveGame只关心部落。这种分离使得修改其中任一系统的存档逻辑不会影响到其他系统。
    • 开闭原则 (Open/Closed Principle):

      • 应用: 通过蓝图接口(如BPI_StorageBuildable)进行交互。例如,在加载建筑时,系统通过检查建筑是否实现该接口来决定是否需要加载其内部物品。未来如果需要添加新的可存储物品的建筑类型,只需让其实现该接口,而无需修改GameMode中的核心加载逻辑。
  • 设计模式:

    • 接口模式 (Interface Pattern):

      • 应用: 广泛使用蓝图接口(BPI)来解耦。

        • BPI_...SaveGame接口允许任何Actor获取对应的SaveGame对象引用,而无需知道其具体类。

        • BPI_StorageBuildable接口提供了一个标准方法 LoadItemsInStorage,用于向任何实现了该接口的建筑加载物品,实现了行为的抽象。

设计思想应用实例:

思想 类别 具体应用 带来的优势
单一职责原则 设计原则 将玩家、建筑、部落数据分别存入三个独立的SaveGame对象。 易于理解、维护和扩展。修改部落存档不会影响玩家存档。
接口模式 设计模式 使用BPI_StorageBuildable接口来加载存储容器的物品。 实现了“依赖倒置”,高层模块(GameMode)不依赖于底层模块(具体存储箱),而是依赖于抽象(接口),扩展性强。
开闭原则 设计原则 当需要为新的建筑类型(如冰箱)添加物品存档时,只需让新建筑实现接口,无需修改GameMode。 系统对扩展开放,对修改关闭,稳定性更高。

四、 核心技术点与难点

  • 关键技术点:

    • SaveGame 对象: 虚幻引擎提供的核心存档类,用于创建可序列化到磁盘的蓝图对象。

    • 存档/读档节点: Save Game to SlotLoad Game from Slot 是执行实际文件I/O操作的核心蓝图节点。

    • 唯一存档槽位 (Slot Name): 使用 Get Unique Net ID from Player State 函数为每个玩家生成一个唯一的标识符,作为其个人存档文件的名称,解决了多人服务器中玩家存档相互覆盖的问题。

    • 数据结构选择: 灵活运用了数组(Array)来存储物品和建筑列表,以及字典(Map)来高效地存储和查询部落数据。

  • 技术难点与解决方案:

    • 难点1:加载时序(Loading Timing): PlayerControllerBeginPlay 时可能早于 PlayerState 从服务器加载完数据,此时更新UI会导致显示不正确(如部落窗口显示未加入部落)。

      • 解决方案:PlayerControllerBeginPlay事件中,加入一个5秒的Delay节点,作为一种简单的同步机制,等待其他模块完成数据加载后再执行UI更新逻辑。
    • 难点2:数据解耦与通信: 不同模块(Character, GameState, GameMode)中保存的数据需要被正确地关联和恢复。例如,玩家的部落信息保存在PlayerState,但完整的部落详情则在GameState中。

      • 解决方案: 通过接口和ID进行解耦通信。PlayerState加载时只恢复部落ID,PlayerController需要更新UI时,使用这个ID去查询GameState中的完整部落数据。
    • 难点3:避免冗余数据保存: 玩家的死亡背包(Death Bag)也是一种BuildableMaster,但不应作为永久建筑被保存。

      • 解决方案: 在保存建筑的循环中,增加一个判断逻辑,通过接口调用检查该建筑是否为“背包”,如果是则跳过保存。

技术方案对比:

问题 临时/简单方案 潜在的更优方案
加载时序 PlayerController中使用硬编码的Delay节点。 事件驱动系统: PlayerState在成功加载数据后,派发一个事件。PlayerController监听此事件,接收到事件后再更新UI。
唯一ID 在编辑器测试时,使用硬编码的 “test ID”。 动态Net ID 在线上环境中,使用 Get Unique Net ID,该ID与玩家的Steam ID等平台ID关联,确保全局唯一。

五、 自我批判与重构

  • 遇到的“坑”或关键问题:

    • 遗漏存档内容: 在完成了核心的玩家数据存档后,发现遗漏了对玩家已解锁蓝图(Engrams)的保存。这导致玩家每次重新登录后,已学会的制作配方全部丢失。

    • 预置建筑重复加载: 设计师在地图中预先放置的建筑,在没有特殊处理的情况下,会被存档系统当作玩家建筑再次保存,并在下次加载时被重复生成一次。

  • 对前期设计的反思与修正:

    • Engram存档补充: 迅速进行了修正。在PlayerInfo_SaveGame中添加了存储Engram ID的数组变量,并在F_SavePlayerDataF_LoadPlayerData函数中加入了对这些数组的读写逻辑,从而补全了功能。

    • 预置建筑处理: 通过在游戏开始前直接删除关卡编辑器中预置的建筑来解决此问题。一个更优雅的方案是在建筑基类中添加一个布尔变量bDoNotSave,并在存档时过滤掉这些建筑。

  • 重构优化建议 (如果重来一次):

    • 引入中心化存档管理器 (SaveManager): 当前的存档逻辑分散在GameModeGameStatePlayerCharacterPlayerState中,增加了系统的复杂性。可以设计一个单例的SaveManager,由它统一负责触发所有子系统的保存和加载事件,并管理存档文件的读写,使逻辑更集中、清晰。

    • 实现异步存档/读档: 当前的Save Game to Slot是同步操作,在数据量巨大时可能导致游戏线程卡顿。应将其封装到异步任务中,在独立的线程中执行文件I/O,并通过回调或事件通知主线程操作完成。

    • 替换硬编码延迟: PlayerController中5秒的Delay是脆弱的,在低性能机器或网络延迟高的情况下可能失效。应重构为事件驱动机制,例如,由PlayerState在加载完成后广播一个“PlayerDataReady”事件,PlayerController订阅该事件来触发UI更新。

问题与优化方案对比:

问题点 当前实现/遇到的问题 优化/重构方案
Engram存档 初期设计遗漏,导致玩家进度丢失。 SaveGame对象和存取函数中补充相应逻辑。
加载时序 使用Delay(5.0)硬等待,不稳定。 重构为事件驱动模型 (OnPlayerDataReady事件)。
逻辑分散 存档逻辑分散于GameMode, GameState, PlayerState等多个类中。 引入一个SaveManager单例,集中管理所有存档操作。
同步卡顿 SaveGameToSlot是同步操作,可能导致游戏卡顿。 封装为异步任务,在后台线程执行I/O。