第十九章:存档系统
一、 本章核心目标
本章的核心任务是构建一个全面的、能在服务器本地持久化关键游戏数据的存档系统。此目标可分解为显性与隐性两个层面:
-
显性目标 (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 Slot
和Load Game from Slot
是执行实际文件I/O操作的核心蓝图节点。 -
唯一存档槽位 (Slot Name): 使用
Get Unique Net ID from Player State
函数为每个玩家生成一个唯一的标识符,作为其个人存档文件的名称,解决了多人服务器中玩家存档相互覆盖的问题。 -
数据结构选择: 灵活运用了数组(Array)来存储物品和建筑列表,以及字典(Map)来高效地存储和查询部落数据。
-
-
技术难点与解决方案:
-
难点1:加载时序(Loading Timing):
PlayerController
在BeginPlay
时可能早于PlayerState
从服务器加载完数据,此时更新UI会导致显示不正确(如部落窗口显示未加入部落)。- 解决方案: 在
PlayerController
的BeginPlay
事件中,加入一个5秒的Delay
节点,作为一种简单的同步机制,等待其他模块完成数据加载后再执行UI更新逻辑。
- 解决方案: 在
-
难点2:数据解耦与通信: 不同模块(
Character
,GameState
,GameMode
)中保存的数据需要被正确地关联和恢复。例如,玩家的部落信息保存在PlayerState
,但完整的部落详情则在GameState
中。- 解决方案: 通过接口和ID进行解耦通信。
PlayerState
加载时只恢复部落ID,PlayerController
需要更新UI时,使用这个ID去查询GameState
中的完整部落数据。
- 解决方案: 通过接口和ID进行解耦通信。
-
难点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_SavePlayerData
和F_LoadPlayerData
函数中加入了对这些数组的读写逻辑,从而补全了功能。 -
预置建筑处理: 通过在游戏开始前直接删除关卡编辑器中预置的建筑来解决此问题。一个更优雅的方案是在建筑基类中添加一个布尔变量
bDoNotSave
,并在存档时过滤掉这些建筑。
-
-
重构优化建议 (如果重来一次):
-
引入中心化存档管理器 (SaveManager): 当前的存档逻辑分散在
GameMode
、GameState
、PlayerCharacter
和PlayerState
中,增加了系统的复杂性。可以设计一个单例的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。 |