地图系统
This commit is contained in:
553
Docs/Guides/04_SaveSystem_DataPersistence_Guide.md
Normal file
553
Docs/Guides/04_SaveSystem_DataPersistence_Guide.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# 存档系统与数据持久化手册
|
||||
|
||||
> 文件位置:`Docs/Guides/04_SaveSystem_DataPersistence_Guide.md`
|
||||
> 版本:1.0 · 适用项目:zeling_v2
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [架构概览](#1-架构概览)
|
||||
2. [SaveData 数据模型](#2-savedata-数据模型)
|
||||
3. [存档读写完整时序](#3-存档读写完整时序)
|
||||
- 3.1 [保存流程(Save)](#31-保存流程save)
|
||||
- 3.2 [加载流程(Load)](#32-加载流程load)
|
||||
- 3.3 [场景切换时的世界状态恢复](#33-场景切换时的世界状态恢复)
|
||||
4. [ISaveable 接入模式](#4-isaveable-接入模式)
|
||||
5. [存档槽管理与 UI 集成](#5-存档槽管理与-ui-集成)
|
||||
6. [序列化与完整性校验](#6-序列化与完整性校验)
|
||||
7. [存档迁移(SaveMigrator)](#7-存档迁移savemigrator)
|
||||
8. [Inspector 配置指南](#8-inspector-配置指南)
|
||||
9. [扩展指南](#9-扩展指南)
|
||||
10. [商业对标评估](#10-商业对标评估)
|
||||
11. [常见问题排查](#11-常见问题排查)
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
存档系统由四个层次组成,严格单向依赖,Core 层不持有任何 Unity UI 引用:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ UI 层(SaveSlotController / SaveSlotUI) │
|
||||
│ 展示槽摘要、触发存档槽确认 │
|
||||
└────────────────────┬──────────────────────────────────────┘
|
||||
│ EVT_SlotConfirmed(IntEventChannelSO)
|
||||
┌────────────────────▼──────────────────────────────────────┐
|
||||
│ 服务接口层(ISaveService / ISaveableRegistry) │
|
||||
│ SaveServiceAdapter — 将 MonoBehaviour API 包装为接口 │
|
||||
└────────────────────┬──────────────────────────────────────┘
|
||||
│ ServiceLocator<ISaveService>.Get()
|
||||
┌────────────────────▼──────────────────────────────────────┐
|
||||
│ 管理器层(GameSaveManager) │
|
||||
│ 协调 ISaveable 集合、调用 ISaveStorage、管理内存状态 │
|
||||
└────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────▼──────────────────────────────────────┐
|
||||
│ 存储层(ISaveStorage / LocalFileStorage) │
|
||||
│ 负责实际的磁盘 IO(JSON 文件,可替换为云存档) │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
|
||||
横切关注点:
|
||||
ISaveable ← 各游戏组件(Player、WorldObject、Quest 等)实现此接口
|
||||
SaveData ← 唯一的内存数据容器,各 ISaveable 读写其子节点
|
||||
```
|
||||
|
||||
**核心设计原则:**
|
||||
- `GameSaveManager` 不感知任何具体业务数据——它只是一个调度者;业务数据由各 `ISaveable` 组件自行读写 `SaveData` 的对应字段。
|
||||
- `ISaveStorage` 是磁盘操作的唯一入口,方便在测试或平台适配时替换为内存存储或云存储。
|
||||
- 所有跨层通信通过 `ServiceLocator` 或 SO 事件频道进行,Core 层与 UI 层完全解耦。
|
||||
|
||||
---
|
||||
|
||||
## 2. SaveData 数据模型
|
||||
|
||||
`SaveData` 是游戏唯一的持久化数据容器,序列化为 JSON 写入磁盘。
|
||||
|
||||
### 顶层结构
|
||||
|
||||
```csharp
|
||||
public class SaveData
|
||||
{
|
||||
public SaveMeta Meta; // 元数据:版本、时间戳、校验和
|
||||
public PlayerSaveData Player; // 玩家:HP、零珠、形态、死亡影
|
||||
public EquipmentSaveData Equipment; // 装备:符文、凹槽
|
||||
public WorldSaveData World; // 世界:访问过的场景、已开门、已击败Boss、已拾取物品
|
||||
public MapSaveData Map; // 地图:已探索房间、标记、传送点
|
||||
public QuestSaveData Quests; // 任务:状态、目标进度
|
||||
public AchievementSaveData Achievements;
|
||||
public ToolsSaveData Tools; // 工具:已装备、已持有
|
||||
public ChallengeRoomsSaveData ChallengeRooms; // 挑战间:最高分、最佳时间
|
||||
public EventChainsSaveData EventChains; // 事件链:完成标记、世界旗标
|
||||
public ShopsSaveData Shops; // 商店:已售出独特道具、购买次数
|
||||
public InventorySaveData Inventory; // 背包:物品数量、新物品列表
|
||||
public JournalSaveData Journal; // 图鉴:已发现敌人、已解锁传说
|
||||
public StatsSaveData Stats; // 统计:击杀数、死亡数、移动距离
|
||||
public NGPlusSaveData NGPlus; // NG+(null = 非 NG+ 存档)
|
||||
public TutorialSaveData Tutorial; // 教程:已完成提示 ID
|
||||
public SettingsSaveData Settings; // 设置:语言偏好等
|
||||
public Dictionary<string, JObject> DLC; // DLC 扩展点(开放式)
|
||||
}
|
||||
```
|
||||
|
||||
### SaveMeta 元数据
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `Version` | `int` | 对应 `SaveMigrator.CurrentVersion`,用于向前迁移 |
|
||||
| `SlotIndex` | `int` | 0–2 为普通槽,98 = QuickSave |
|
||||
| `LastSaved` | `string` | ISO 8601 时间戳(如 `"2026-06-04T14:30:00Z"`)|
|
||||
| `Playtime` | `float` | 累计游戏秒数 |
|
||||
| `SavePointId` | `string` | 最后使用的检查点/存档点 ID |
|
||||
| `NGPlusCount` | `int` | NG+ 周目计数(0 = 初周目)|
|
||||
| `SaveCount` | `int` | 累计保存次数(用于存档槽 UI 徽章显示)|
|
||||
| `Checksum` | `string` | HMAC-SHA256(Base64),用于完整性校验 |
|
||||
| `IsSteelSoul` | `bool` | 钢铁之魂模式(一命通关)标志,锁定后不可修改 |
|
||||
|
||||
### 摘要数据(SlotSummary)
|
||||
|
||||
`GetSlotSummaryAsync(int slot)` 返回用于 UI 展示的轻量对象,**不加载完整 SaveData**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `Playtime` | 游戏时长(格式化后用于 UI 显示)|
|
||||
| `LocationName` | 上次存档的场景显示名(本地化 Key)|
|
||||
| `Currency` | 零珠数量 |
|
||||
| `MaxHP` | 当前最大血量 |
|
||||
| `IsSteelSoul` | 是否为钢铁之魂存档(UI 显示特殊徽章)|
|
||||
| `HasData` | 该槽是否有存档(空槽 = false)|
|
||||
|
||||
---
|
||||
|
||||
## 3. 存档读写完整时序
|
||||
|
||||
### 3.1 保存流程(Save)
|
||||
|
||||
```
|
||||
触发点:检查点交互 / 自动存档触发器 / 手动调用 ISaveService.SaveAsync(slot)
|
||||
│
|
||||
├─ GameSaveManager.SaveAsync(slot)
|
||||
│ │
|
||||
│ ├─ 1. await _saveLock.WaitAsync() ← 防止并发写入
|
||||
│ │
|
||||
│ ├─ 2. 更新 Meta
|
||||
│ │ └─ Meta.LastSaved = DateTime.UtcNow.ToString("o")
|
||||
│ │ Meta.Playtime += (Time.time - _sessionStartTime)
|
||||
│ │ Meta.SaveCount += 1
|
||||
│ │
|
||||
│ ├─ 3. 遍历 _saveables(HashSet<ISaveable>)
|
||||
│ │ └─ foreach ISaveable s → s.OnSave(_current)
|
||||
│ │ (各组件将自身状态写入 _current 对应字段)
|
||||
│ │
|
||||
│ ├─ 4. 序列化 + 注入校验和
|
||||
│ │ ├─ _current.Meta.Checksum = null ← 归零,确保 HMAC 计算一致
|
||||
│ │ ├─ json = JsonConvert.SerializeObject(_current)
|
||||
│ │ ├─ hmac = ComputeHMAC(json)
|
||||
│ │ └─ json = InjectChecksum(json, hmac) ← 字符串替换,避免二次序列化
|
||||
│ │
|
||||
│ └─ 5. _storage.WriteAsync(slot, json)
|
||||
│ └─ 写入本地文件(路径:{persistentDataPath}/saves/slot_{slot}.json)
|
||||
│
|
||||
└─ _saveLock.Release()
|
||||
|
||||
副作用:
|
||||
EVT_SaveCompleted(可选)─► 触发存档点 UI 动画(旋转图标 → 对号)
|
||||
```
|
||||
|
||||
### 3.2 加载流程(Load)
|
||||
|
||||
```
|
||||
触发点:主菜单「继续」选择存档 / 死亡后在检查点复活
|
||||
│
|
||||
├─ GameSaveManager.LoadAsync(slot)
|
||||
│ │
|
||||
│ ├─ 1. json = await _storage.ReadAsync(slot)
|
||||
│ │
|
||||
│ ├─ 2. data = JsonConvert.DeserializeObject<SaveData>(json)
|
||||
│ │
|
||||
│ ├─ 3. 校验和验证
|
||||
│ │ ├─ extractedHMAC = data.Meta.Checksum
|
||||
│ │ ├─ data.Meta.Checksum = null
|
||||
│ │ ├─ recomputedHMAC = ComputeHMAC(JsonConvert.SerializeObject(data))
|
||||
│ │ └─ if (extractedHMAC != recomputedHMAC)
|
||||
│ │ IsSteelSoul → 拒绝加载(防存档篡改作弊)
|
||||
│ │ Normal Mode → 记录警告,允许加载(兼容旧版本存档)
|
||||
│ │
|
||||
│ ├─ 4. SaveMigrator.Migrate(ref data)
|
||||
│ │ └─ 若 data.Meta.Version < CurrentVersion,执行版本迁移补丁
|
||||
│ │
|
||||
│ ├─ 5. _current = data;_currentSlot = slot
|
||||
│ │
|
||||
│ └─ 6. 遍历 _saveables(此时已注册的组件)
|
||||
│ └─ foreach ISaveable s → s.OnLoad(_current)
|
||||
│
|
||||
└─ 加载完成
|
||||
|
||||
注意:
|
||||
加载流程不触发 EVT_SceneLoadRequest。
|
||||
场景切换由调用方(MainMenuController)在加载前发起。
|
||||
OnLoad 在新场景的 OnEnable 阶段由 ISaveableRegistry 驱动。
|
||||
```
|
||||
|
||||
### 3.3 场景切换时的世界状态恢复
|
||||
|
||||
```
|
||||
SceneService.LoadSceneCoroutine()
|
||||
│
|
||||
├─ (省略)淡出 → 加载场景 → 等待一帧
|
||||
│
|
||||
├─ EVT_SceneWorldStateRestored.Raise()
|
||||
│ └─ 新场景中所有已 OnEnable 的 ISaveable 组件收到通知
|
||||
│ └─ 重新调用 ISaveableRegistry.OnSceneRestored()
|
||||
│ └─ foreach ISaveable → s.OnLoad(_current)
|
||||
│ (仅恢复场景相关状态:已拾取物品、已销毁对象、已开门等)
|
||||
│
|
||||
└─ EVT_FadeInRequest.Raise() ← 世界状态恢复完成后再淡入,避免闪烁
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. ISaveable 接入模式
|
||||
|
||||
### 接口定义
|
||||
|
||||
```csharp
|
||||
public interface ISaveable
|
||||
{
|
||||
void OnSave(SaveData saveData); // 将自身状态写入 saveData(保存前调用)
|
||||
void OnLoad(SaveData saveData); // 从 saveData 恢复自身状态(加载后调用)
|
||||
}
|
||||
```
|
||||
|
||||
### 标准接入模板
|
||||
|
||||
```csharp
|
||||
public class MyWorldObject : MonoBehaviour, ISaveable
|
||||
{
|
||||
[SerializeField] private string _uniqueId; // Inspector 中配置唯一 ID(场景内唯一)
|
||||
|
||||
private ISaveableRegistry _registry;
|
||||
private bool _isCollected;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_registry = ServiceLocator.Get<ISaveableRegistry>();
|
||||
_registry.Register(this); // 注册:若已加载则立即调用 OnLoad
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_registry?.Unregister(this); // 取消注册:防止悬空引用
|
||||
}
|
||||
|
||||
public void OnSave(SaveData saveData)
|
||||
{
|
||||
if (_isCollected)
|
||||
saveData.World.CollectedItemIds.Add(_uniqueId);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData saveData)
|
||||
{
|
||||
_isCollected = saveData.World.CollectedItemIds.Contains(_uniqueId);
|
||||
gameObject.SetActive(!_isCollected); // 已拾取则隐藏
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 接入规则
|
||||
|
||||
| 规则 | 原因 |
|
||||
|---|---|
|
||||
| **必须**在 `OnEnable` 注册,`OnDisable` 取消注册 | 防止场景卸载后 GameSaveManager 持有悬空组件引用 |
|
||||
| `OnSave` 只写,`OnLoad` 只读 | 避免在 OnLoad 中触发副作用(如粒子、音频)导致加载时闪烁 |
|
||||
| `_uniqueId` 必须在场景内唯一 | 存档数据以 ID 为键,重复 ID 会造成存档互相覆盖 |
|
||||
| 不要在 `OnSave/OnLoad` 中操作 Transform 或启动 Coroutine | 此时帧时序不稳定,应改在 `OnLoad` 后的首帧 Update 中执行 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 存档槽管理与 UI 集成
|
||||
|
||||
### 存档槽 UI 职责分离
|
||||
|
||||
```
|
||||
SaveSlotController(ScriptableObject 事件驱动)
|
||||
│
|
||||
├─ OnEnable()
|
||||
│ └─ RefreshAsync()
|
||||
│ └─ 并行调用 ISaveService.GetSlotSummaryAsync(0/1/2)
|
||||
│ └─ 更新 SaveSlotUI[i](卡片显示)
|
||||
│
|
||||
├─ 模式:NewGame
|
||||
│ ├─ 空槽点击 → 显示 NewGameModeController(普通 / 钢铁之魂选择)
|
||||
│ └─ 已占用槽点击 → 显示 ConfirmDialogController(覆盖确认)
|
||||
│ └─ 确认 → 显示 NewGameModeController
|
||||
│
|
||||
└─ 模式:Continue
|
||||
└─ 有存档槽点击 → ISaveService.LoadAsync(slot)
|
||||
→ Raise EVT_SlotConfirmed(slot)
|
||||
→ MainMenuController.HandleSlotConfirmed()
|
||||
→ Raise EVT_SceneLoadRequest
|
||||
```
|
||||
|
||||
### 新游戏创建流程
|
||||
|
||||
```
|
||||
NewGameModeController 玩家选择(普通 / 钢铁之魂)
|
||||
│
|
||||
└─ ISaveService.CreateSlot(slotIndex, steelSoul: bool)
|
||||
├─ new SaveData(),写入默认值
|
||||
├─ Meta.IsSteelSoul = steelSoul
|
||||
├─ Meta.SlotIndex = slotIndex
|
||||
└─ 存入内存 _current(不立即写磁盘)
|
||||
└─ Raise EVT_SlotConfirmed(slotIndex)
|
||||
└─ MainMenuController 触发场景加载
|
||||
└─ 首次进入 Gameplay 后的第一个检查点交互触发首次写盘
|
||||
```
|
||||
|
||||
### 存档槽 UI 元素规范
|
||||
|
||||
| UI 元素 | 数据来源 | 备注 |
|
||||
|---|---|---|
|
||||
| 存档时长 | `SlotSummary.Playtime` | 格式化为 `HH:MM:SS` |
|
||||
| 最后位置 | `SlotSummary.LocationName`(本地化 Key) | 通过 LocalizationService 解析 |
|
||||
| 零珠数量 | `SlotSummary.Currency` | 显示硬币图标 + 数值 |
|
||||
| 血量进度 | `SlotSummary.MaxHP` | 用于显示心形图标数 |
|
||||
| 钢铁之魂徽章 | `SlotSummary.IsSteelSoul` | 条件激活特殊 UI 装饰 |
|
||||
| 空槽占位 | `!SlotSummary.HasData` | 显示「空」或「点击开始」提示 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 序列化与完整性校验
|
||||
|
||||
### JSON 序列化配置
|
||||
|
||||
- 库:`Newtonsoft.Json`(`JsonConvert`)
|
||||
- 设置:`NullValueHandling.Ignore`(减少文件体积)
|
||||
- 日期格式:ISO 8601(`"o"` 格式)
|
||||
|
||||
### HMAC-SHA256 校验流程
|
||||
|
||||
```
|
||||
保存时:
|
||||
1. _current.Meta.Checksum = null
|
||||
2. json = Serialize(_current)
|
||||
3. hmac = HMACSHA256.ComputeHash(Encoding.UTF8.GetBytes(json), _secretKey)
|
||||
4. json = json.Replace("\"Checksum\":null", "\"Checksum\":\"" + base64hmac + "\"")
|
||||
写盘 → json(含校验和)
|
||||
|
||||
加载时:
|
||||
1. json = 从磁盘读取
|
||||
2. data = Deserialize(json)
|
||||
3. stored = data.Meta.Checksum
|
||||
4. data.Meta.Checksum = null
|
||||
5. expected = HMACSHA256.ComputeHash(Serialize(data))
|
||||
6. if stored != expected:
|
||||
IsSteelSoul → 拒绝(防篡改保护)
|
||||
Normal → 警告日志,允许(向后兼容)
|
||||
```
|
||||
|
||||
> **注意**:`_secretKey` 在 `GameSaveManager` Inspector 中配置(`[SerializeField] private string _hmacKey`)。
|
||||
> 不可将 Key 硬编码在代码中。生产发布前应使用随机生成的唯一 Key 替换默认值。
|
||||
|
||||
---
|
||||
|
||||
## 7. 存档迁移(SaveMigrator)
|
||||
|
||||
当代码层 `SaveMigrator.CurrentVersion` > 存档文件 `Meta.Version` 时,迁移器自动执行。
|
||||
|
||||
### 添加新迁移补丁
|
||||
|
||||
在 `SaveMigrator.cs` 的 `Migrate()` 方法中追加:
|
||||
|
||||
```csharp
|
||||
// 版本 2 → 3:为 Inventory 增加 FavoriteSlots 字段
|
||||
if (data.Meta.Version < 3)
|
||||
{
|
||||
data.Inventory ??= new InventorySaveData();
|
||||
data.Inventory.FavoriteSlots ??= new List<string>();
|
||||
data.Meta.Version = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### 版本历史规范
|
||||
|
||||
| 版本 | 变更说明 | 迁移操作 |
|
||||
|---|---|---|
|
||||
| 1 | 初始版本 | — |
|
||||
| 2 | 新增 `Stats.DistanceTraveled` | 默认 `0f`,无需迁移 |
|
||||
| 3 | 新增 `Inventory.FavoriteSlots` | 初始化为空列表 |
|
||||
|
||||
> 每次存档结构变动都**必须**递增版本号并编写迁移补丁,否则旧存档加载时会报 `NullReferenceException`。
|
||||
|
||||
---
|
||||
|
||||
## 8. Inspector 配置指南
|
||||
|
||||
### GameSaveManager(挂载在 Persistent 场景 `[SERVICES]` 下)
|
||||
|
||||
| Inspector 字段 | 赋值 | 说明 |
|
||||
|---|---|---|
|
||||
| `_storage` | `LocalFileStorage` 组件引用 | 磁盘 IO 实现;测试时可替换为 InMemorySaveStorage |
|
||||
| `_hmacKey` | 任意非空字符串(生产环境用随机 UUID)| 校验和计算密钥 |
|
||||
| `_onSaveCompleted`(可选) | `EVT_SaveCompleted`(VoidEventChannelSO)| 触发存档点 UI 反馈动画 |
|
||||
|
||||
### LocalFileStorage(与 GameSaveManager 同节点)
|
||||
|
||||
| Inspector 字段 | 赋值 | 说明 |
|
||||
|---|---|---|
|
||||
| `_saveDirectory` | 留空(默认 `Application.persistentDataPath/saves/`)| 可覆盖为绝对路径(仅测试用)|
|
||||
| `_fileExtension` | `".json"` | 存档文件后缀 |
|
||||
|
||||
### SaveServiceAdapter(与 GameSaveManager 同节点)
|
||||
|
||||
无需配置——它是一个纯粹的接口转发层,仅需保证与 `GameSaveManager` 在同一 GameObject 上。
|
||||
|
||||
### 事件频道速查
|
||||
|
||||
| SO 名称 | 类型 | 发布者 | 订阅者 |
|
||||
|---|---|---|---|
|
||||
| `EVT_SlotConfirmed` | IntEventChannelSO | SaveSlotController | MainMenuController |
|
||||
| `EVT_SaveCompleted`(可选) | VoidEventChannelSO | GameSaveManager | SavePointController(UI 动画)|
|
||||
| `EVT_SceneWorldStateRestored` | VoidEventChannelSO | SceneService | (所有 ISaveable 组件间接响应)|
|
||||
|
||||
---
|
||||
|
||||
## 9. 扩展指南
|
||||
|
||||
### 添加新的存档子数据节点
|
||||
|
||||
1. 新建 `[Serializable]` 数据类,如 `SkillsSaveData`。
|
||||
2. 在 `SaveData` 中添加公开字段:`public SkillsSaveData Skills;`。
|
||||
3. 递增 `SaveMigrator.CurrentVersion` 并添加迁移补丁(初始化默认值)。
|
||||
4. 在需要持久化的组件中实现 `ISaveable.OnSave` / `OnLoad` 读写 `saveData.Skills`。
|
||||
|
||||
### 接入云存档(iCloud / Google Play Games)
|
||||
|
||||
1. 实现 `ISaveStorage` 接口,新建 `CloudSaveStorage.cs`。
|
||||
2. 在 `GameServiceRegistrar.Awake()` 中根据平台选择注入:
|
||||
```csharp
|
||||
#if UNITY_IOS
|
||||
_saveStorage = GetComponent<iCloudSaveStorage>();
|
||||
#elif UNITY_ANDROID
|
||||
_saveStorage = GetComponent<GooglePlaySaveStorage>();
|
||||
#else
|
||||
_saveStorage = GetComponent<LocalFileStorage>();
|
||||
#endif
|
||||
ServiceLocator.Register<ISaveStorage>(_saveStorage);
|
||||
```
|
||||
3. 云存档冲突解决策略建议:以 `Meta.SaveCount` 最大者为准(总是取保存次数更多的版本)。
|
||||
|
||||
### 添加存档槽截图预览
|
||||
|
||||
在 `GameSaveManager.SaveAsync()` 末尾注入:
|
||||
|
||||
```csharp
|
||||
// 截图后以 Texture2D PNG 压缩存储到 Meta.PreviewImageBase64
|
||||
var tex = await ScreenCapture.CaptureScreenshotAsTextureAsync();
|
||||
_current.Meta.PreviewImageBase64 = Convert.ToBase64String(tex.EncodeToPNG());
|
||||
```
|
||||
|
||||
在 `SaveSlotUI` 中使用 `Meta.PreviewImageBase64` 创建 `Sprite` 并显示。
|
||||
|
||||
### 实现自动存档(定时 + 过图触发)
|
||||
|
||||
```csharp
|
||||
// 在 PlayerController 的场景加载完成回调中:
|
||||
private void HandleSceneWorldStateRestored()
|
||||
{
|
||||
ServiceLocator.Get<ISaveService>().SaveAsync(_currentSlot);
|
||||
}
|
||||
|
||||
// 在 GameManager 的定时协程中:
|
||||
private IEnumerator AutoSaveLoop()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
yield return new WaitForSeconds(300f); // 每 5 分钟
|
||||
if (_fsm.Current == GameStateId.Gameplay)
|
||||
await ServiceLocator.Get<ISaveService>().SaveAsync(_currentSlot);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 商业对标评估
|
||||
|
||||
以下评估以"丝之歌"级别商业 2D 动作游戏(Steam/主机发行标准)为基准,逐项审核当前存档系统的完备性与商业成熟度。
|
||||
|
||||
### ✅ 已达到商业标准
|
||||
|
||||
| 维度 | 当前实现 | 评价 |
|
||||
|---|---|---|
|
||||
| **三存档槽** | SlotIndex 0/1/2 | 符合行业惯例,足够主流平台需求 |
|
||||
| **钢铁之魂保护** | IsSteelSoul + HMAC 拒绝加载被篡改存档 | 与 Hollow Knight 同等保护级别 |
|
||||
| **存档摘要展示** | SlotSummary(时长/位置/零珠/血量)| 满足玩家快速辨识存档内容的需求 |
|
||||
| **存档版本迁移** | SaveMigrator 补丁链 | 支持无缝热更新存档结构 |
|
||||
| **DLC 扩展点** | `Dictionary<string, JObject>` | 未来 DLC 内容无需修改核心存档类 |
|
||||
| **NG+ 数据** | `NGPlusSaveData` 节点保留 | 为周目系统预留专用空间 |
|
||||
| **ISaveable 自注册** | OnEnable/OnDisable 生命周期驱动 | 无需手动在 Manager 中维护组件列表 |
|
||||
| **存储层抽象** | `ISaveStorage` 接口 | 云存档接入改动范围仅限适配层 |
|
||||
|
||||
### ⚠️ 建议改进项
|
||||
|
||||
| 维度 | 当前状态 | 建议 |
|
||||
|---|---|---|
|
||||
| **存档截图预览** | 未实现 | 主流商业游戏(如《原神》《空洞骑士》续作)普遍提供。建议在 Save 时截图并以 Base64 存入 Meta |
|
||||
| **自动存档策略** | 未见独立的 AutoSaveService 实现 | 建议以两种触发结合:场景加载完成(过图自动存)+ 定时(每 5 分钟)|
|
||||
| **存档文件加密** | 当前仅 HMAC 完整性校验,未加密 | PC 平台可接受,若有主机认证要求(PS/Xbox)需配合平台 SDK 云加密 |
|
||||
| **存档大小监控** | 未见大小上限检测 | 主机平台认证通常要求存档 ≤ 1–2 MB,建议在 SaveAsync 后记录日志文件大小 |
|
||||
| **QuickSave 槽编号** | 使用 `SlotIndex = 98`(魔法数字)| 建议用具名常量 `SaveSlots.QuickSave = 98` 替换,避免他处误用 |
|
||||
| **存档损坏降级恢复** | Normal 模式下校验失败仅记录警告 | 建议在校验失败时尝试加载同槽的 `.bak` 备份文件(每次存档前备份一次)|
|
||||
| **写入原子性** | 直接覆盖目标文件 | 建议先写 `.tmp` 文件,写入成功后再原子重命名,防止写入中断导致存档损坏 |
|
||||
|
||||
### ❌ 商业发行前必须补全
|
||||
|
||||
| 维度 | 当前状态 | 影响 |
|
||||
|---|---|---|
|
||||
| **HMAC 密钥管理** | `_hmacKey` 为 Inspector 可见字段 | 若使用默认值,钢铁之魂保护形同虚设;正式构建前必须在构建管线中注入随机密钥并从 Inspector 中移除 |
|
||||
| **平台存档路径合规** | 仅实现 `persistentDataPath` 本地存储 | PS/Xbox 认证要求使用平台 SDK 的存档 API(PS: `savedata://`,Xbox: `XboxStorage`)|
|
||||
|
||||
---
|
||||
|
||||
## 11. 常见问题排查
|
||||
|
||||
### ❌ 加载存档后部分对象状态未恢复(仍显示默认状态)
|
||||
|
||||
**原因 1:** 目标组件的 `OnEnable` 在 `EVT_SceneWorldStateRestored` 触发**之后**才执行(异步实例化的 Prefab)。
|
||||
**解决:** 在组件的 `OnEnable` 中检查 `_registry.IsLoaded`,若为 `true` 则立即调用 `OnLoad(_registry.CurrentData)`。
|
||||
|
||||
**原因 2:** 忘记在 `OnDisable` 调用 `_registry.Unregister(this)`,导致旧实例残留,新实例的 `OnLoad` 被跳过。
|
||||
**解决:** 确保每个 `ISaveable.OnEnable` 都对应一个 `OnDisable.Unregister`。
|
||||
|
||||
---
|
||||
|
||||
### ❌ 点击「继续」后游戏场景加载但世界对象全部重置
|
||||
|
||||
**原因:** `MainMenuController.HandleSlotConfirmed` 在调用 `ISaveService.LoadAsync` 之前就发起了 `EVT_SceneLoadRequest`,导致新场景的 `ISaveable` 在 `OnLoad` 之前已触发 `EVT_SceneWorldStateRestored`。
|
||||
**解决:** 调用顺序必须为:`LoadAsync(slot)` → `await` → `Raise(EVT_SceneLoadRequest)`。
|
||||
|
||||
---
|
||||
|
||||
### ❌ 存档校验失败:`Invalid checksum` 警告频繁出现
|
||||
|
||||
**原因:** 多个编辑器/构建版本使用了不同的 `_hmacKey` 值,旧存档无法通过当前 Key 验证。
|
||||
**解决:**
|
||||
1. 开发阶段:统一使用版本控制中记录的固定 Key(在 `ProjectSettings` 或 `StreamingAssets` 中管理)。
|
||||
2. 生产阶段:构建流水线注入唯一 Key,每个游戏版本的 Key 不变(只在大版本号更迭时更换)。
|
||||
|
||||
---
|
||||
|
||||
### ❌ 存档文件体积异常(> 500 KB)
|
||||
|
||||
**原因:** 某个 ISaveable 在 `OnSave` 中写入了图像数据、大型列表或未过滤的字典。
|
||||
**解决:** 在 `SaveAsync` 后添加日志:
|
||||
```csharp
|
||||
Debug.Log($"[SaveManager] Slot {slot} size: {Encoding.UTF8.GetByteCount(json)} bytes");
|
||||
```
|
||||
逐一排查各 `ISaveable.OnSave` 调用前后的数据差量,定位膨胀来源。
|
||||
|
||||
---
|
||||
|
||||
*文档最后更新:2026-06-04*
|
||||
Reference in New Issue
Block a user