地图系统

This commit is contained in:
2026-06-05 18:41:33 +08:00
parent 613f2a4d13
commit fe4fd60083
234 changed files with 33090 additions and 4899 deletions

View 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_SlotConfirmedIntEventChannelSO
┌────────────────────▼──────────────────────────────────────┐
│ 服务接口层ISaveService / ISaveableRegistry
│ SaveServiceAdapter — 将 MonoBehaviour API 包装为接口 │
└────────────────────┬──────────────────────────────────────┘
│ ServiceLocator<ISaveService>.Get()
┌────────────────────▼──────────────────────────────────────┐
│ 管理器层GameSaveManager
│ 协调 ISaveable 集合、调用 ISaveStorage、管理内存状态 │
└────────────────────┬──────────────────────────────────────┘
┌────────────────────▼──────────────────────────────────────┐
│ 存储层ISaveStorage / LocalFileStorage
│ 负责实际的磁盘 IOJSON 文件,可替换为云存档) │
└───────────────────────────────────────────────────────────┘
横切关注点:
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` | 02 为普通槽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-SHA256Base64用于完整性校验 |
| `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. 遍历 _saveablesHashSet<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 职责分离
```
SaveSlotControllerScriptableObject 事件驱动)
├─ 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 | SavePointControllerUI 动画)|
| `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 云加密 |
| **存档大小监控** | 未见大小上限检测 | 主机平台认证通常要求存档 ≤ 12 MB建议在 SaveAsync 后记录日志文件大小 |
| **QuickSave 槽编号** | 使用 `SlotIndex = 98`(魔法数字)| 建议用具名常量 `SaveSlots.QuickSave = 98` 替换,避免他处误用 |
| **存档损坏降级恢复** | Normal 模式下校验失败仅记录警告 | 建议在校验失败时尝试加载同槽的 `.bak` 备份文件(每次存档前备份一次)|
| **写入原子性** | 直接覆盖目标文件 | 建议先写 `.tmp` 文件,写入成功后再原子重命名,防止写入中断导致存档损坏 |
### ❌ 商业发行前必须补全
| 维度 | 当前状态 | 影响 |
|---|---|---|
| **HMAC 密钥管理** | `_hmacKey` 为 Inspector 可见字段 | 若使用默认值,钢铁之魂保护形同虚设;正式构建前必须在构建管线中注入随机密钥并从 Inspector 中移除 |
| **平台存档路径合规** | 仅实现 `persistentDataPath` 本地存储 | PS/Xbox 认证要求使用平台 SDK 的存档 APIPS: `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*