Files
zeling_v2/Docs/Design/31_SaveDataSchema_Unified.md
2026-05-08 11:04:00 +08:00

1623 lines
67 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 31 · SaveData 统一 Schemav2
> **命名空间** `BaseGames.Core.Save`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖系统** 03/08/16/17/21/25/28/29/30/32/34/37/38/39/45/46 + DLC 扩展位
> **取代** 31_SaveDataSchema_Unified.md v1.x完整重写
---
## 目录
1. [设计原则](#1-设计原则)
2. [系统架构图](#2-系统架构图)
3. [完整 JSON Schema](#3-完整-json-schema)
4. [SaveData C# 数据结构(全系统)](#4-savedata-c-数据结构全系统)
5. [ISaveStorage — 平台存储抽象](#5-isavestorage--平台存储抽象)
6. [SaveManager — 异步读写流程](#6-savemanager--异步读写流程)
7. [脏标记系统Dirty Flag](#7-脏标记系统dirty-flag)
8. [应急存档与退出处理](#8-应急存档与退出处理)
9. [版本迁移SaveMigrator](#9-版本迁移savemigrator)
10. [存档验证SaveValidator](#10-存档验证savevalidator)
11. [存档完整性保护Checksum + Backup](#11-存档完整性保护checksum--backup)
12. [设置数据分离GlobalSettings](#12-设置数据分离globalsettings)
13. [DLC 扩展模型](#13-dlc-扩展模型)
14. [NG+ 重置 Schema](#14-ng-重置-schema)
15. [多存档槽管理](#15-多存档槽管理)
16. [云存档冲突解决](#16-云存档冲突解决)
17. [性能预算](#17-性能预算)
18. [各子系统贡献字段索引](#18-各子系统贡献字段索引)
19. [编辑器工具](#19-编辑器工具)
---
## 1. 设计原则
| 原则 | 要求 |
|------|------|
| **异步 I/O** | 所有磁盘读写在线程池中执行,主线程零阻塞;存档操作不得超过 0.5 ms 主线程开销 |
| **原子写入** | 先写 `.tmp` 再 rename保证写入失败不会损坏现有存档 |
| **主备双份** | 每次成功写入后保留前一版本为 `_backup`,损坏时自动降级恢复 |
| **分层 Schema** | 顶层按系统分桶;新增字段只影响各自桶,不破坏其他系统 |
| **版本迁移链** | 版本号单调递增,`SaveMigrator` 逐步升级,不跳跃 |
| **向前兼容** | 新版本存档被旧版游戏加载时,未知字段被完整保留(`JsonExtensionData`),不丢弃 |
| **设置与游戏数据分离** | `GlobalSettings` 保存在独立文件,独立于存档槽,删槽不丢设置 |
| **脏标记批处理** | 运行时变更标记 Dirty由 SaveManager 在存档点统一落盘;关键事件(成就)立即 WriteDirty |
| **写时校验** | 写入前执行 `SaveValidator`,非法数据拒绝写盘 |
| **平台抽象** | 所有文件 I/O 经由 `ISaveStorage` 接口,支持 PC / Steam Cloud / Switch / PS5 后端切换 |
| **DLC 可扩展** | 顶层 `dlc` 字典允许任意 DLC 注入自己的存档桶Base 游戏不感知 |
| **完整性校验** | 每份存档均含 SHA-256 Checksum加载时自动验证 |
| **序列化库** | `Newtonsoft.Json``com.unity.nuget.newtonsoft-json`NullValueHandling.Ignore保留未知字段 |
| **存档路径** | `{persistentDataPath}/saves/slot_{n}.json`PC/移动端);平台专属路径由 `ISaveStorage` 提供 |
---
## 2. 系统架构图
```
┌──────────────────────────────────────────────────────────────────────┐
│ SaveManager单例
│ │
│ 公开接口: │
│ ├─ SaveAsync(slot) ← 存档点触发,全量写入(异步) │
│ ├─ LoadAsync(slot) ← 场景加载时(异步) │
│ ├─ WriteDirty(category) ← 关键事件(成就解锁/剧情标志)立即落盘 │
│ ├─ EmergencySave(slot) ← 退出/挂起时同步写入(最后手段) │
│ └─ GetSlotSummaryAsync ← 存档选择界面(仅反序列化顶层字段) │
│ │
│ 内部流程: │
│ SaveAsync → DeepClone → Validate → Checksum → ISaveStorage.Write │
│ LoadAsync → ISaveStorage.Read → VerifyChecksum → Migrate → Return │
│ │
│ ISaveStorage平台抽象
│ ├─ LocalFileStorage ← PC / Android / iOS │
│ ├─ SteamCloudStorage ← Steam PC / Deck自动同步
│ ├─ SwitchStorage ← Nintendo Switch NANDP2/TBD
│ └─ Ps5Storage ← PS5 SAVEDATA mountP2/TBD
└──────────────────────────────────────────────────────────────────────┘
SaveData顶层
├─ meta → 版本、时间、游玩时长、校验和
├─ player → HP、位置、货币、能力解锁、形态
├─ equipment → 护符、Notch 槽
├─ world → 场景、存档点、Boss、开关、收集品、NPC 好感度
├─ map → 房间探索状态
├─ quests → 任务链状态(见 38_QuestSystem
├─ achievements → 成就解锁 + 进度计数(见 32_AchievementSystem
├─ tools → 工具槽装备与持久状态(见 37_ToolSystem
├─ challengeRooms → 挑战房间分数/排名(见 39_ChallengeRoomSystem
├─ eventChains → 世界状态级联标志(见 34_EventChainSystem
├─ shops → 商店购买记录(见 28_ShopSystem
├─ stats → 游戏统计(击杀/死亡/弹反等)
├─ ngplus → NG+ 轮次与专属标志null = 非 NG+ 模式)
└─ dlc → DLC 自注册扩展桶Base 不感知内容)
GlobalSettings独立文件非 slot
├─ audio → 主音量/BGM/SFX
├─ graphics → 分辨率/全屏/VSync
├─ input → 输入重绑定 JSON
├─ accessibility → 色盲/字幕/振动
└─ gameplay → 难度 / 钢铁之魂 / NG+ HUD
```
---
## 3. 完整 JSON Schema
```json
{
"meta": {
"version": "2.0",
"slotIndex": 0,
"lastSaved": "2026-04-28T14:30:00Z",
"playtime": 7234.512345,
"savePointId": "SavePoint_FungalWastes_03",
"savePointLocKey": "ui.savepoint.fungalwastes_03",
"savePointBgImageKey": "SP_BG_FungalWastes_03",
"ngPlusCount": 0,
"_checksum": "a3f2c1d8e47b9f0623a1..."
},
"player": {
"posX": -12.5,
"posY": 3.0,
"scene": "Scene_ForgottenCrossroads",
"currentHP": 4,
"maxHP": 5,
"currentGeo": 350,
"lifetimeGeo": 12480,
"shield": { "currentHP": 60, "isBroken": false },
"abilities": {
"doubleJump": true,
"wallGrab": true,
"dash": true,
"airDash": false,
"groundSlam": false,
"swim": false
},
"forms": {
"activeFormId": "SkyForm",
"unlockedFormIds": ["SkyForm", "EarthForm"],
"skillCooldowns": {
"sky_soul_skill": 0.0,
"earth_spirit_skill1": 1.2
}
},
"deathPos": {
"x": -20.0,
"y": 1.5,
"scene": "Scene_FungalWastes",
"geoAmount": 150
}
},
"equipment": {
"equippedCharmIds": ["Charm_QuickSlash", "Charm_LongNail"],
"notchesUsed": 5,
"maxNotches": 11,
"ownedCharmIds": ["Charm_QuickSlash", "Charm_LongNail", "Charm_WanderingNotes"],
"upgradedCharmIds": ["Charm_QuickSlash"]
},
"world": {
"visitedScenes": ["Scene_ForgottenCrossroads", "Scene_FungalWastes"],
"activatedSavePoints": ["SavePoint_Crossroads_01", "SavePoint_FungalWastes_03"],
"openedDoors": ["Door_City_01"],
"defeatedBossIds": ["Boss_SpiderGuard"],
"collectibles": {
"pickedUpIds": ["GeoRock_Crossroads_01", "Grub_FungalWastes_02"]
},
"switches": {
"Lever_CrystalPeak_01": true
},
"revealedFalseWalls": ["FalseWall_Crossroads_Secret_01"],
"npcRelations": {
"NPC_Herbalist": 25,
"NPC_Merchant_Forest": 10
}
},
"map": {
"discoveredRooms": {
"Scene_ForgottenCrossroads": [0, 1, 2, 3, 5, 7],
"Scene_FungalWastes": [0, 1, 4]
},
"mapPurchased": {
"Scene_ForgottenCrossroads": true
},
"compassEnabled": true,
"quillEnabled": true
},
"quests": {
"questStates": {
"Quest_FindMushroom": {
"status": "Active",
"objectiveIndex": 1,
"progressCounts": [3, 0],
"giverNpcId": "NPC_Herbalist"
},
"Quest_EscortCaravan": {
"status": "Completed",
"objectiveIndex": 2,
"progressCounts": [1, 1],
"giverNpcId": "NPC_Merchant_Forest"
}
},
"availableQuestIds": ["Quest_SlayForestBoss"]
},
"achievements": {
"unlocked": ["Ach_Story_FirstBoss", "Ach_Parry_10"],
"progress": {
"Ach_Parry_100": { "count": 47 },
"Ach_MapExplore90": { "percent": 0.63 }
}
},
"tools": {
"slot0": "Tool_Slingshot",
"slot1": "Tool_BearTrap",
"ownedToolIds": ["Tool_Slingshot", "Tool_BearTrap", "Tool_Rope"],
"toolStates": {
"Tool_Slingshot": { "ammo": 8 }
}
},
"challengeRooms": {
"ChallengeRoom_Forest_01": {
"bestScore": 4200,
"bestTime": 87.3,
"bestRank": "A",
"completionCount": 3
}
},
"eventChains": {
"chainStates": {
"Chain_ForestGate": "Completed",
"Chain_CaveAncient": "Step2"
},
"worldFlags": {
"ForestBossDefeated": true,
"AncientSealBroken": false
}
},
"shops": {
"Shop_Forest_Merchant": {
"soldUniqueItems": ["Item_Charm_WanderingNotes"],
"purchaseCounts": { "Item_HealthPotion_Small": 2 }
}
},
"stats": {
"enemyKills": 284,
"deaths": 12,
"deathsByBoss": { "Boss_SpiderGuard": 3 },
"parrySuccessCount": 47,
"parryFailCount": 103,
"skillUseCounts": { "sky_soul_skill": 24 },
"geoEarned": 8420,
"geoLost": 310,
"distanceTraveled": 15234,
"saveCount": 38
},
"ngplus": null,
"dlc": {
"DLC_VoidWings": {
"version": "1.0",
"wingFormUnlocked": true,
"wingSkillCooldowns": { "wing_soul": 0.0 }
}
}
}
```
---
## 4. SaveData C# 数据结构(全系统)
```csharp
namespace BaseGames.Core.Save
{
// ─────────────────────────────────────────────────────────
// 顶层SaveData
// ─────────────────────────────────────────────────────────
[Serializable]
public class SaveData
{
// JSON 序列化时保留未知字段(向前兼容:旧版游戏不会丢弃新版 DLC 字段)
[JsonExtensionData]
public Dictionary<string, JToken> ExtensionData = new();
public SaveMeta Meta = new();
public PlayerSaveData Player = new();
public EquipmentSaveData Equipment = new();
public WorldSaveData World = new();
public MapSaveData Map = new();
public QuestsSaveData Quests = new();
public AchievementsSaveData Achievements = new();
public ToolsSaveData Tools = new();
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public Dictionary<string, ChallengeRoomRecord> ChallengeRooms = new();
public EventChainsSaveData EventChains = new();
public Dictionary<string, ShopSaveData> Shops = new();
public StatsSaveData Stats = new();
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public NgPlusSaveData NgPlus = null; // null = 非 NG+ 局
// DLC 扩展桶DLC 自行序列化/反序列化内部结构
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public Dictionary<string, JObject> Dlc = new();
}
// ─────────────────────────────────────────────────────────
// Meta文件头
// ─────────────────────────────────────────────────────────
[Serializable]
public class SaveMeta
{
public string Version = SaveManager.CurrentVersion;
public int SlotIndex;
public string LastSaved; // ISO 8601 UTC
public double Playtime; // 秒double 精度float 在 97 天后失真)
public string SavePointId; // 存档点 ID对应 SavePointSO.savePointId
/// <summary>本地化名称键,格式 "ui.savepoint.{id}";查 UI_Table 得到各语言显示名</summary>
public string SavePointLocKey;
/// <summary>存档背景图资产键Addressable label: SavePointBG存档选择界面展示</summary>
public string SavePointBgImageKey;
public int NgPlusCount; // NG+ 轮次0 = 初始)
// 向前兼容:未来新增 meta 字段在旧版本加载时不会丢失
[JsonExtensionData]
public Dictionary<string, JToken> ExtensionData = new();
// Checksum 必须放最后(计算时设为空字符串)
[JsonProperty("_checksum")]
public string Checksum = "";
}
// ─────────────────────────────────────────────────────────
// SavePointSO — 每个存档点的 UI 展示配置ScriptableObject
// 资产路径Assets/ScriptableObjects/World/SavePoints/
// ─────────────────────────────────────────────────────────
[CreateAssetMenu(menuName = "Save/SavePoint Config")]
public class SavePointSO : ScriptableObject
{
[Header("标识")]
/// <summary>需与场景中 SavePoint 组件的 savePointId 字段完全匹配</summary>
public string savePointId;
[Header("存档槽展示")]
/// <summary>本地化名称键格式ui.savepoint.{id},在 UI_Table 中定义各语言名称<br/>
/// 示例ui.savepoint.fungalwastes_03 = "古菌荒废·黄昏壁炉"</summary>
public string locKey;
/// <summary>
/// 存档选择界面背景图 Sprite 的 Addressable 资产地址。<br/>
/// 建议分辨率720×40516:9存档卡片按 UI 设计裁切。<br/>
/// Addressable Label: "SavePointBG"
/// </summary>
public string bgImageKey;
// 预留:未来可按需添加区域图标键、子区域名称键、专属 BGM 键等
// 扩展字段直接加在此处,已有存档数据(仅存键名)不受影响
}
// ─────────────────────────────────────────────────────────
// Player
// ─────────────────────────────────────────────────────────
[Serializable]
public class PlayerSaveData
{
public float PosX, PosY;
public string Scene;
public int CurrentHP;
public int MaxHP;
public int CurrentGeo;
public long LifetimeGeo; // long 避免溢出
public ShieldSaveData Shield = new();
public AbilitiesSaveData Abilities = new();
public FormsSaveData Forms = new();
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public DeathPosSaveData DeathPos = null; // null = 无遗骸
}
[Serializable]
public class ShieldSaveData
{
public int CurrentHP;
public bool IsBroken;
}
[Serializable]
public class AbilitiesSaveData
{
public bool DoubleJump, WallGrab, Dash, AirDash, GroundSlam, Swim;
}
[Serializable]
public class FormsSaveData
{
public string ActiveFormId;
public List<string> UnlockedFormIds = new();
public Dictionary<string, float> SkillCooldowns = new();
}
[Serializable]
public class DeathPosSaveData
{
public float X, Y;
public string Scene;
public int GeoAmount;
}
// ─────────────────────────────────────────────────────────
// Equipment见 17_EquipmentSystem
// ─────────────────────────────────────────────────────────
[Serializable]
public class EquipmentSaveData
{
public List<string> EquippedCharmIds = new();
public int NotchesUsed;
public int MaxNotches;
public List<string> OwnedCharmIds = new();
public List<string> UpgradedCharmIds = new();
}
// ─────────────────────────────────────────────────────────
// World见 08_WorldSystem
// ─────────────────────────────────────────────────────────
[Serializable]
public class WorldSaveData
{
public List<string> VisitedScenes = new();
public List<string> ActivatedSavePoints = new();
public List<string> OpenedDoors = new();
public List<string> DefeatedBossIds = new();
public CollectiblesSaveData Collectibles = new();
public Dictionary<string, bool> Switches = new();
public List<string> RevealedFalseWalls = new();
/// <summary>NPC 好感度npcId → affinity 值)</summary>
public Dictionary<string, int> NpcRelations = new();
}
[Serializable]
public class CollectiblesSaveData
{
public List<string> PickedUpIds = new();
}
// ─────────────────────────────────────────────────────────
// Map见 16_MapSystem
// ─────────────────────────────────────────────────────────
[Serializable]
public class MapSaveData
{
public Dictionary<string, List<int>> DiscoveredRooms = new();
public Dictionary<string, bool> MapPurchased = new();
public bool CompassEnabled;
public bool QuillEnabled;
}
// ─────────────────────────────────────────────────────────
// Quests见 38_QuestSystem §9
// ─────────────────────────────────────────────────────────
[Serializable]
public class QuestsSaveData
{
public Dictionary<string, QuestStateSaveData> QuestStates = new();
public List<string> AvailableQuestIds = new();
}
[Serializable]
public class QuestStateSaveData
{
public string Status; // "Active" / "Completed" / "Failed" / "Unavailable"
public int ObjectiveIndex;
public int[] ProgressCounts = Array.Empty<int>();
public string GiverNpcId;
}
// ─────────────────────────────────────────────────────────
// Achievements见 32_AchievementSystem §7
// ─────────────────────────────────────────────────────────
[Serializable]
public class AchievementsSaveData
{
public List<string> Unlocked = new();
/// <summary>计数/百分比类成就进度</summary>
public Dictionary<string, AchievementProgress> Progress = new();
}
[Serializable]
public class AchievementProgress
{
public int Count;
public float Percent;
}
// ─────────────────────────────────────────────────────────
// Tools见 37_ToolSystem
// ─────────────────────────────────────────────────────────
[Serializable]
public class ToolsSaveData
{
public string Slot0; // null = 槽位空
public string Slot1;
public List<string> OwnedToolIds = new();
/// <summary>工具内部持久状态(如弹弓弹药数);工具自行序列化为 JObject</summary>
public Dictionary<string, JObject> ToolStates = new();
}
// ─────────────────────────────────────────────────────────
// ChallengeRooms见 39_ChallengeRoomSystem
// ─────────────────────────────────────────────────────────
[Serializable]
public class ChallengeRoomRecord
{
public int BestScore;
public float BestTime;
public string BestRank; // "S" / "A" / "B" / "C" / "F"
public int CompletionCount;
}
// ─────────────────────────────────────────────────────────
// EventChains见 34_EventChainSystem
// ─────────────────────────────────────────────────────────
[Serializable]
public class EventChainsSaveData
{
/// <summary>链状态chainId → 当前步骤标识或 "Completed"</summary>
public Dictionary<string, string> ChainStates = new();
/// <summary>全局世界标志(布尔变量)</summary>
public Dictionary<string, bool> WorldFlags = new();
}
// ─────────────────────────────────────────────────────────
// Shop见 28_ShopSystem §9
// ─────────────────────────────────────────────────────────
[Serializable]
public class ShopSaveData
{
public List<string> SoldUniqueItems = new();
public Dictionary<string, int> PurchaseCounts = new();
}
// ─────────────────────────────────────────────────────────
// Stats游戏统计数据
// ─────────────────────────────────────────────────────────
[Serializable]
public class StatsSaveData
{
public int EnemyKills;
public int Deaths;
public Dictionary<string, int> DeathsByBoss = new();
public int ParrySuccessCount;
public int ParryFailCount;
public Dictionary<string, int> SkillUseCounts = new();
public long GeoEarned;
public long GeoLost;
public int DistanceTraveled; // 米int 精度足够)
public int SaveCount;
}
// ─────────────────────────────────────────────────────────
// NG+(见 52_NewGamePlusnull = 非 NG+
// ─────────────────────────────────────────────────────────
[Serializable]
public class NgPlusSaveData
{
public int Round; // 1 = 首次 NG+
public bool ExclusiveCharmUnlocked;
public bool TrueEndingUnlocked;
// 携带至 NG+ 的字段在 §14 中定义(通过 NgPlusResetter 从旧存档迁移而来)
}
// ─────────────────────────────────────────────────────────
// 存档选择界面摘要(仅反序列化顶层,不加载完整数据)
// ─────────────────────────────────────────────────────────
[Serializable]
public class SaveSlotSummary
{
[JsonProperty("meta")] public SaveMetaSummary Meta;
[JsonProperty("player")] public PlayerSlotSummary Player;
}
[Serializable]
public class SaveMetaSummary
{
[JsonProperty("version")] public string Version;
[JsonProperty("slotIndex")] public int SlotIndex;
[JsonProperty("lastSaved")] public string LastSaved;
[JsonProperty("playtime")] public double Playtime;
[JsonProperty("savePointId")] public string SavePointId;
[JsonProperty("savePointLocKey")] public string SavePointLocKey;
[JsonProperty("savePointBgImageKey")] public string SavePointBgImageKey;
[JsonProperty("ngPlusCount")] public int NgPlusCount;
}
[Serializable]
public class PlayerSlotSummary
{
[JsonProperty("scene")] public string Scene;
[JsonProperty("currentHP")] public int CurrentHP;
[JsonProperty("maxHP")] public int MaxHP;
}
}
```
---
## 5. ISaveStorage — 平台存储抽象
所有磁盘读写经由 `ISaveStorage` 接口。`SaveManager` 不直接调用 `System.IO.File` API
```csharp
namespace BaseGames.Core.Save
{
/// <summary>
/// 平台存储后端抽象。
/// 实现由 PlatformBootstrapper 在游戏启动时注入至 SaveManager。
/// PC = LocalFileStorageSteam = SteamCloudStorageSwitch/PS5 由各自实现。
/// </summary>
public interface ISaveStorage
{
bool SupportsCloudSync { get; }
Task<string> ReadAsync(string key, CancellationToken ct = default);
Task WriteAsync(string key, string data, CancellationToken ct = default);
Task DeleteAsync(string key, CancellationToken ct = default);
Task<bool> ExistsAsync(string key, CancellationToken ct = default);
Task<IReadOnlyList<string>> ListKeysAsync(CancellationToken ct = default);
Task<DateTimeOffset?> GetCloudTimestampAsync(string key, CancellationToken ct = default);
}
// ── PC / Mobile 本地实现 ──────────────────────────────────────────────
public class LocalFileStorage : ISaveStorage
{
readonly string _baseDir;
public bool SupportsCloudSync => false;
public LocalFileStorage()
=> _baseDir = Path.Combine(Application.persistentDataPath, "saves");
public async Task<string> ReadAsync(string key, CancellationToken ct = default)
{
string path = Path.Combine(_baseDir, key);
if (!File.Exists(path)) return null;
return await File.ReadAllTextAsync(path, Encoding.UTF8, ct);
}
public async Task WriteAsync(string key, string data, CancellationToken ct = default)
{
Directory.CreateDirectory(_baseDir);
string tmpPath = Path.Combine(_baseDir, key + ".tmp");
string mainPath = Path.Combine(_baseDir, key);
await File.WriteAllTextAsync(tmpPath, data, Encoding.UTF8, ct);
File.Move(tmpPath, mainPath, overwrite: true); // 原子替换
}
public async Task DeleteAsync(string key, CancellationToken ct = default)
=> await Task.Run(() =>
{
string path = Path.Combine(_baseDir, key);
if (File.Exists(path)) File.Delete(path);
}, ct);
public Task<bool> ExistsAsync(string key, CancellationToken ct = default)
=> Task.FromResult(File.Exists(Path.Combine(_baseDir, key)));
public Task<IReadOnlyList<string>> ListKeysAsync(CancellationToken ct = default)
{
if (!Directory.Exists(_baseDir))
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
var keys = Directory.GetFiles(_baseDir)
.Select(Path.GetFileName).ToList();
return Task.FromResult<IReadOnlyList<string>>(keys);
}
public Task<DateTimeOffset?> GetCloudTimestampAsync(string key, CancellationToken ct = default)
=> Task.FromResult<DateTimeOffset?>(null);
}
// ── Steam Cloud 实现(骨架,需接入 Steamworks.NET────────────────────
public class SteamCloudStorage : ISaveStorage
{
public bool SupportsCloudSync => true;
public async Task<string> ReadAsync(string key, CancellationToken ct = default)
{
if (!SteamRemoteStorage.FileExists(key)) return null;
int size = SteamRemoteStorage.GetFileSize(key);
byte[] buf = new byte[size];
SteamRemoteStorage.FileRead(key, buf, size);
return Encoding.UTF8.GetString(buf);
}
public async Task WriteAsync(string key, string data, CancellationToken ct = default)
{
byte[] bytes = Encoding.UTF8.GetBytes(data);
SteamRemoteStorage.FileWrite(key, bytes, bytes.Length);
}
public async Task DeleteAsync(string key, CancellationToken ct = default)
=> SteamRemoteStorage.FileDelete(key);
public Task<bool> ExistsAsync(string key, CancellationToken ct = default)
=> Task.FromResult(SteamRemoteStorage.FileExists(key));
public Task<IReadOnlyList<string>> ListKeysAsync(CancellationToken ct = default)
{
int count = SteamRemoteStorage.GetFileCount();
var keys = new List<string>(count);
for (int i = 0; i < count; i++)
keys.Add(SteamRemoteStorage.GetFileNameAndSize(i, out _));
return Task.FromResult<IReadOnlyList<string>>(keys);
}
public Task<DateTimeOffset?> GetCloudTimestampAsync(string key, CancellationToken ct = default)
{
if (!SteamRemoteStorage.FileExists(key)) return Task.FromResult<DateTimeOffset?>(null);
var ts = DateTimeOffset.FromUnixTimeSeconds(SteamRemoteStorage.GetFileTimestamp(key));
return Task.FromResult<DateTimeOffset?>(ts);
}
}
}
```
---
## 6. SaveManager — 异步读写流程
```csharp
namespace BaseGames.Core.Save
{
public class SaveManager : MonoBehaviour
{
public const string CurrentVersion = "2.0";
public const int MaxSlots = 3;
public int ActiveSlot { get; private set; } = -1;
ISaveStorage _storage;
SaveData _activeData;
readonly DirtyFlagSet _dirty = new();
CancellationTokenSource _cts;
readonly JsonSerializerSettings _jsonSettings = new()
{
MissingMemberHandling = MissingMemberHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore,
};
// ── 启动 ────────────────────────────────────────────────────────────
void Awake()
{
_storage = PlatformServiceLocator.GetSaveStorage();
_cts = new CancellationTokenSource();
_ = CleanupOrphanedTmpFilesAsync();
}
void OnDestroy() => _cts.Cancel();
// ── 公开接口 ────────────────────────────────────────────────────────
/// <summary>从指定槽异步加载。返回 null 表示槽不存在或损坏且无备份</summary>
public async Task<SaveData> LoadAsync(int slot)
{
SaveData data = await LoadWithIntegrityCheckAsync(SlotKey(slot), _cts.Token);
if (data != null && data.Meta.Version != CurrentVersion)
data = SaveMigrator.Migrate(data);
_activeData = data;
ActiveSlot = slot;
DeserializeDlcData(data);
return data;
}
/// <summary>
/// 全量异步存档(存档点专用)。
/// 主线程开销 &lt; 0.5 msI/O 在线程池执行。
/// </summary>
public async Task<bool> SaveAsync(int slot)
{
if (_activeData == null)
{
Debug.LogError("[SaveManager] activeData 为 null");
return false;
}
_activeData.Meta.LastSaved = DateTimeOffset.UtcNow.ToString("O");
_activeData.Meta.SlotIndex = slot;
_activeData.Stats.SaveCount++;
SerializeDlcData(_activeData);
var vr = SaveValidator.Validate(_activeData);
if (!vr.IsValid)
{
Debug.LogError($"[SaveManager] 存档校验失败: {vr.Error}");
return false;
}
// 深拷贝快照,防止异步写入期间主线程修改数据
SaveData snapshot = DeepClone(_activeData);
bool ok = await Task.Run(async () =>
{
try
{
string json = SaveChecksum.SerializeWithChecksum(snapshot);
string key = SlotKey(slot);
string backupKey = SlotKey(slot, "_backup");
if (await _storage.ExistsAsync(key))
{
string existing = await _storage.ReadAsync(key);
await _storage.WriteAsync(backupKey, existing);
}
await _storage.WriteAsync(key, json);
return true;
}
catch (Exception e)
{
Debug.LogError($"[SaveManager] 写入失败: {e.Message}");
return false;
}
}, _cts.Token);
if (ok) _dirty.ClearAll();
return ok;
}
/// <summary>
/// WriteDirty关键时机立即触发存档成就解锁、剧情标志
/// Fire-and-forget完全异步不阻塞调用方。
/// </summary>
public void WriteDirty(SaveDirtyCategory category)
{
_dirty.Mark(category);
if (ActiveSlot >= 0)
_ = SaveAsync(ActiveSlot);
}
/// <summary>
/// EmergencySave游戏退出时同步写入允许阻塞超时 500 ms 后放弃)。
/// 仅应在 Application.quitting 回调中使用。
/// </summary>
public bool EmergencySave()
{
if (_activeData == null || ActiveSlot < 0) return false;
try
{
_activeData.Meta.LastSaved = DateTimeOffset.UtcNow.ToString("O");
SerializeDlcData(_activeData);
string json = SaveChecksum.SerializeWithChecksum(DeepClone(_activeData));
return _storage.WriteAsync(SlotKey(ActiveSlot), json).Wait(500);
}
catch { return false; }
}
// ── 槽摘要UI 用)──────────────────────────────────────────────────
public async Task<SaveSlotSummary> GetSlotSummaryAsync(int slot)
{
string json = await _storage.ReadAsync(SlotKey(slot));
if (json == null) return null;
return JsonConvert.DeserializeObject<SaveSlotSummary>(json, _jsonSettings);
}
// ── 删除槽 ──────────────────────────────────────────────────────────
public async Task DeleteSlotAsync(int slot)
{
string key = SlotKey(slot);
if (await _storage.ExistsAsync(key))
{
string data = await _storage.ReadAsync(key);
await _storage.WriteAsync(key + ".dead", data); // 保留 .dead 备份供调试
await _storage.DeleteAsync(key);
}
await _storage.DeleteAsync(SlotKey(slot, "_backup"));
}
// ── 内部 ────────────────────────────────────────────────────────────
async Task<SaveData> LoadWithIntegrityCheckAsync(string key, CancellationToken ct)
{
if (await TryLoadFileAsync(key, ct, out var data)) return data;
string backupKey = key.Replace(".json", "_backup.json");
Debug.LogWarning($"[SaveManager] {key} Checksum 失败,尝试备份…");
if (await TryLoadFileAsync(backupKey, ct, out var backup))
{
UIManager.Instance?.ShowNotification("存档已从备份恢复");
return backup;
}
Debug.LogError($"[SaveManager] {key} 与备份均损坏");
return null;
}
async Task<bool> TryLoadFileAsync(string key, CancellationToken ct, out SaveData result)
{
result = null;
try
{
string json = await _storage.ReadAsync(key, ct);
if (json == null) return false;
if (!SaveChecksum.Verify(json)) return false;
result = JsonConvert.DeserializeObject<SaveData>(json, _jsonSettings);
return result != null;
}
catch (Exception e)
{
Debug.LogError($"[SaveManager] 读取异常 {key}: {e.Message}");
return false;
}
}
async Task CleanupOrphanedTmpFilesAsync()
{
var keys = await _storage.ListKeysAsync();
foreach (var key in keys.Where(k => k.EndsWith(".tmp")))
{
await _storage.DeleteAsync(key);
Debug.LogWarning($"[SaveManager] 清理孤儿 .tmp 文件: {key}");
}
}
static string SlotKey(int slot, string suffix = "")
=> $"slot_{slot}{suffix}.json";
static SaveData DeepClone(SaveData data)
{
// JSON 往返深拷贝,保证快照与运行时数据完全隔离
string json = JsonConvert.SerializeObject(data, Formatting.None);
return JsonConvert.DeserializeObject<SaveData>(json);
}
// ── DLC 扩展桶 ──────────────────────────────────────────────────────
readonly List<IDlcSaveExtension> _dlcExtensions = new();
public void RegisterDlcExtension(IDlcSaveExtension ext)
=> _dlcExtensions.Add(ext);
void SerializeDlcData(SaveData d)
{
foreach (var ext in _dlcExtensions)
d.Dlc[ext.DlcId] = ext.Serialize(d);
}
void DeserializeDlcData(SaveData d)
{
if (d == null) return;
foreach (var ext in _dlcExtensions)
{
if (d.Dlc.TryGetValue(ext.DlcId, out var jobj))
{
ext.MigrateIfNeeded(jobj);
ext.Deserialize(jobj, d);
}
}
}
}
}
```
---
## 7. 脏标记系统Dirty Flag
成就解锁、剧情标志等关键事件需立即落盘,不等存档点:
```csharp
public enum SaveDirtyCategory
{
Achievements, // 成就解锁(立即落盘,防止死亡丢失)
WorldFlags, // 世界状态标志Boss 击败等)
Quests, // 任务状态变化
Stats, // 统计计数(可容忍少量丢失,低优先级)
Full, // 存档点完整存档
}
public class DirtyFlagSet
{
readonly HashSet<SaveDirtyCategory> _dirty = new();
public void Mark(SaveDirtyCategory cat) => _dirty.Add(cat);
public bool IsDirty(SaveDirtyCategory cat) => _dirty.Contains(cat);
public void ClearAll() => _dirty.Clear();
}
```
**各系统使用约定**
| 系统 | 调用时机 | Category |
|------|---------|---------|
| `AchievementManager` | 解锁成就后立即 | `Achievements` |
| `QuestManager` | 任务状态/目标进度变化 | `Quests` |
| `EventChainSystem` | 世界标志变更 | `WorldFlags` |
| `SavePoint` | 玩家触发存档点 | `Full`(调用 SaveAsync|
| `StatsTracker` | 每场景结算时批量写 | `Stats` |
---
## 8. 应急存档与退出处理
```csharp
public class SaveOnExit : MonoBehaviour
{
[SerializeField] SaveManager _saveManager;
void OnEnable()
{
Application.quitting += OnQuit;
Application.focusChanged += OnFocusChanged;
}
void OnDisable()
{
Application.quitting -= OnQuit;
Application.focusChanged -= OnFocusChanged;
}
// PC / Steam Deck suspendSIGTERM → Application.quitting
void OnQuit()
{
bool ok = _saveManager.EmergencySave();
if (!ok) Debug.LogWarning("[SaveOnExit] 应急存档失败(超时或无活跃存档)");
}
// 移动端后台iOS/Android 切出应用)
void OnFocusChanged(bool hasFocus)
{
if (!hasFocus && _saveManager.ActiveSlot >= 0)
_ = _saveManager.SaveAsync(_saveManager.ActiveSlot);
}
}
```
**孤儿 `.tmp` 清理**:在 `SaveManager.Awake()` 调用 `CleanupOrphanedTmpFilesAsync()`(见 §6检测并删除因上次崩溃残留的 `.tmp` 文件。
---
## 9. 版本迁移SaveMigrator
### 版本历史
| 版本 | 变更内容 |
|------|---------|
| `1.0` | 初始版本 |
| `1.1` | 新增 `player.shield`、settings 字段 |
| `1.2` | 新增 `player.forms``equipment.upgradedCharmIds``abilities.wallGrab` |
| `1.3` | 新增 `world.switches` |
| `2.0` | **大版本重构**Meta 分离顶层playtime `float → double`;新增 quests/achievements/tools/eventChains/stats/dlc 桶settings 迁移至独立 GlobalSettings 文件 |
### SaveMigrator.cs
```csharp
namespace BaseGames.Core.Save
{
public static class SaveMigrator
{
/// <summary>链式逐版本升级,不跳跃</summary>
public static SaveData Migrate(SaveData data)
{
while (data.Meta.Version != SaveManager.CurrentVersion)
{
data = data.Meta.Version switch
{
"1.0" => V1_0_to_V1_1(data),
"1.1" => V1_1_to_V1_2(data),
"1.2" => V1_2_to_V1_3(data),
"1.3" => V1_3_to_V2_0(data),
_ => throw new InvalidOperationException(
$"无法迁移未知版本: {data.Meta.Version}")
};
}
return data;
}
static SaveData V1_0_to_V1_1(SaveData d)
{
d.Player.Shield = new ShieldSaveData { CurrentHP = 60, IsBroken = false };
d.Meta.Version = "1.1";
return d;
}
static SaveData V1_1_to_V1_2(SaveData d)
{
d.Player.Forms ??= new FormsSaveData { ActiveFormId = "SkyForm" };
if (!d.Player.Forms.UnlockedFormIds.Contains("SkyForm"))
d.Player.Forms.UnlockedFormIds.Add("SkyForm");
d.Equipment.UpgradedCharmIds ??= new();
d.Meta.Version = "1.2";
return d;
}
static SaveData V1_2_to_V1_3(SaveData d)
{
d.World.Switches ??= new();
d.Meta.Version = "1.3";
return d;
}
/// <summary>1.3 → 2.0 大版本迁移</summary>
static SaveData V1_3_to_V2_0(SaveData d)
{
// 新桶补全(构造函数已初始化,此处确保非 null
d.Quests ??= new();
d.Achievements ??= new();
d.Tools ??= new();
d.EventChains ??= new();
d.Stats ??= new();
d.Dlc ??= new();
// playtimefloat 精度已在 JSON 解析时由 JsonConvert 自动转 double无需处理
// settings 迁移至 GlobalSettings 由 PlatformBootstrapper.MigrateSettingsIfNeeded() 完成
d.Meta.Version = "2.0";
return d;
}
}
}
```
---
## 10. 存档验证SaveValidator
```csharp
namespace BaseGames.Core.Save
{
public static class SaveValidator
{
public readonly struct Result
{
public bool IsValid { get; init; }
public string? Error { get; init; }
}
public static Result Validate(SaveData data)
{
if (data == null) return Fail("SaveData 为 null");
// ── Meta ──────────────────────────────────────────────────────
if (string.IsNullOrEmpty(data.Meta.Version)) return Fail("meta.version 为空");
if (data.Meta.Playtime < 0) return Fail("playtime 为负数");
if (data.Meta.NgPlusCount < 0) return Fail("ngPlusCount < 0");
// ── Player ────────────────────────────────────────────────────
var p = data.Player;
if (string.IsNullOrEmpty(p.Scene)) return Fail("player.scene 为空");
if (p.MaxHP <= 0) return Fail("player.maxHP <= 0");
if (p.CurrentHP < 0 || p.CurrentHP > p.MaxHP) return Fail($"HP 越界: {p.CurrentHP}/{p.MaxHP}");
if (float.IsNaN(p.PosX) || float.IsNaN(p.PosY)) return Fail("玩家位置含 NaN");
if (p.CurrentGeo < 0) return Fail("currentGeo 为负数");
if (p.Shield != null && p.Shield.CurrentHP < 0) return Fail("shield.currentHP < 0");
if (p.Forms != null && string.IsNullOrEmpty(p.Forms.ActiveFormId))
return Fail("forms.activeFormId 为空");
// ── Equipment ─────────────────────────────────────────────────
var e = data.Equipment;
if (e.MaxNotches <= 0) return Fail("equipment.maxNotches <= 0");
if (e.NotchesUsed < 0 || e.NotchesUsed > e.MaxNotches)
return Fail($"Notch 越界: {e.NotchesUsed}/{e.MaxNotches}");
// ── Stats ─────────────────────────────────────────────────────
var s = data.Stats;
if (s.EnemyKills < 0 || s.Deaths < 0 || s.ParrySuccessCount < 0)
return Fail("stats 包含负数计数");
return new Result { IsValid = true };
}
static Result Fail(string error)
{
Debug.LogWarning($"[SaveValidator] 校验失败: {error}");
return new Result { IsValid = false, Error = error };
}
}
}
```
---
## 11. 存档完整性保护Checksum + Backup
### SHA-256 Checksum
```csharp
public static class SaveChecksum
{
public static string SerializeWithChecksum(SaveData data)
{
// 第一次序列化checksum 置空,生成用于计算 hash 的 JSON
data.Meta.Checksum = "";
var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore };
string json = JsonConvert.SerializeObject(data, Formatting.None, settings);
// 计算并回填 checksum
data.Meta.Checksum = Compute(json);
// 第二次序列化:含 checksum 的最终 JSON
return JsonConvert.SerializeObject(data, Formatting.None, settings);
}
public static bool Verify(string rawJson)
{
try
{
var data = JsonConvert.DeserializeObject<SaveData>(rawJson);
if (data == null) return false;
string stored = data.Meta.Checksum;
data.Meta.Checksum = "";
var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore };
string reJson = JsonConvert.SerializeObject(data, Formatting.None, settings);
return string.Equals(stored, Compute(reJson), StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
static string Compute(string json)
{
using var sha = SHA256.Create();
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(json));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
```
### 文件结构与恢复策略
| 文件 | 说明 |
|------|------|
| `slot_{n}.json` | 当前存档(每次存档点原子覆盖)|
| `slot_{n}_backup.json` | 上一次成功写入的副本(自动覆盖)|
| `slot_{n}.json.dead` | 删除时的保留拷贝(供调试,不影响游戏)|
| `slot_{n}.json.tmp` | 写入中间态(正常情况不会存在;启动时自动清理)|
**恢复优先级**:主存档 → 备份存档 → 提示开始新游戏
---
## 12. 设置数据分离GlobalSettings
设置存储在独立文件,**不属于任何存档槽**,删槽/覆盖槽不影响玩家的设置:
```
{persistentDataPath}/settings.json ← 全局用户设置
{persistentDataPath}/saves/slot_0.json ← 存档槽(不含设置)
{persistentDataPath}/saves/slot_1.json
{persistentDataPath}/saves/slot_2.json
```
```csharp
[Serializable]
public class GlobalSettings
{
public string Version = "1.0";
public AudioSettings Audio = new();
public GraphicsSettings Graphics = new();
public InputSettings Input = new();
public AccessSettings Access = new();
public GameplaySettings Gameplay = new();
[Serializable]
public class AudioSettings
{
public float MasterVolume = 1f;
public float BgmVolume = 0.8f;
public float SfxVolume = 1f;
}
[Serializable]
public class GraphicsSettings
{
public int ResolutionIndex = -1; // -1 = 系统默认
public bool IsFullscreen = true;
public int TargetFps = 60;
public bool VSync = false;
public float BrightnessBias = 0f;
}
[Serializable]
public class InputSettings
{
/// <summary>InputSystem 的重绑定 JSONInputActionAsset.SaveBindingOverridesAsJson()</summary>
public string BindingOverrides = "";
}
[Serializable]
public class AccessSettings
{
public bool ColorBlindMode = false;
public int ColorBlindType = 0; // 0=关 1=红绿 2=蓝黄 3=全色盲
public bool Subtitles = false;
public bool ControllerVibration = true;
}
[Serializable]
public class GameplaySettings
{
public string Difficulty = "Normal";
public bool IsSteelSoul = false;
public bool NgPlusHudEnabled = false;
}
}
public class GlobalSettingsManager : MonoBehaviour
{
const string FileName = "settings.json";
static string FilePath => Path.Combine(Application.persistentDataPath, FileName);
public GlobalSettings Current { get; private set; } = new();
public async Task LoadAsync()
{
if (!File.Exists(FilePath)) { Current = new(); return; }
string json = await File.ReadAllTextAsync(FilePath, Encoding.UTF8);
Current = JsonConvert.DeserializeObject<GlobalSettings>(json) ?? new GlobalSettings();
}
public async Task SaveAsync()
{
string json = JsonConvert.SerializeObject(Current, Formatting.None,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
string tmp = FilePath + ".tmp";
await File.WriteAllTextAsync(tmp, json, Encoding.UTF8);
File.Move(tmp, FilePath, overwrite: true);
}
}
```
> **1.x → 2.0 迁移**:首次以 2.0 版本启动时,`PlatformBootstrapper.MigrateSettingsIfNeeded()` 检测旧存档槽中的 `settings` 字段,将其内容写入 `settings.json`,随后标记迁移完成(写入 `settings.json` 中的迁移标志字段)。迁移为一次性操作。
---
## 13. DLC 扩展模型
DLC 通过顶层 `dlc` 字典注入自己的存档桶Base 游戏代码零修改:
```csharp
/// <summary>
/// DLC 在游戏启动时向 SaveManager.RegisterDlcExtension() 注册。
/// SaveManager 序列化时将 DLC 数据存入 data.Dlc[DlcId]
/// 反序列化时调用 Deserialize 还原(仅在 DLC 已安装时)。
/// 未安装 DLC 时,旧存档中的 DLC 数据通过 JsonExtensionData 静默保留。
/// </summary>
public interface IDlcSaveExtension
{
string DlcId { get; } // 格式DLC_{Name},如 "DLC_VoidWings"
string SchemaVersion { get; } // DLC 自己的 Schema 版本
JObject Serialize(SaveData saveData); // 运行时状态 → JObject
void Deserialize(JObject data, SaveData saveData); // JObject → 运行时状态还原
void MigrateIfNeeded(JObject data); // DLC 自行处理版本升级
void OnNgPlusReset(SaveData old, SaveData next, int round); // NG+ 重置回调
}
```
**DLC 版本字段**DLC 数据中必须包含 `"version"` 字段,由 `MigrateIfNeeded` 检查并升级:
```json
"dlc": {
"DLC_VoidWings": {
"version": "1.0",
...
}
}
```
---
## 14. NG+ 重置 Schema
```csharp
public static class NgPlusResetter
{
static IReadOnlyList<IDlcSaveExtension> _dlcExtensions;
public static void SetDlcExtensions(IReadOnlyList<IDlcSaveExtension> exts)
=> _dlcExtensions = exts;
/// <summary>
/// 将已通关存档转换为 NG+ 存档。
/// 返回新的 SaveData携带允许保留的字段其余重置。
/// </summary>
public static SaveData Reset(SaveData cleared, int nextRound)
{
var ng = new SaveData();
ng.Meta.NgPlusCount = nextRound;
ng.Meta.Playtime = cleared.Meta.Playtime; // 累加游玩时长
// ── 携带 ──────────────────────────────────────────────────────────
ng.Equipment.OwnedCharmIds = new(cleared.Equipment.OwnedCharmIds);
ng.Equipment.UpgradedCharmIds = new(cleared.Equipment.UpgradedCharmIds);
ng.Equipment.MaxNotches = cleared.Equipment.MaxNotches;
// equippedCharmIds / notchesUsed → 重置,到存档点重新装备
ng.Player.Forms.UnlockedFormIds = new(cleared.Player.Forms.UnlockedFormIds);
ng.Player.Forms.ActiveFormId = cleared.Player.Forms.ActiveFormId;
ng.Tools.OwnedToolIds = new(cleared.Tools.OwnedToolIds);
ng.Achievements = cleared.Achievements; // 完整保留
ng.Stats = cleared.Stats; // 累加统计
// ── NG+ 标志 ─────────────────────────────────────────────────────
ng.NgPlus = new NgPlusSaveData { Round = nextRound };
// ── DLC 回调 ─────────────────────────────────────────────────────
if (_dlcExtensions != null)
foreach (var ext in _dlcExtensions)
ext.OnNgPlusReset(cleared, ng, nextRound);
// 未列出的字段player.pos/hp/geo/abilities、world、map、quests、
// eventChains、shops维持新建默认值即视为重置。
return ng;
}
}
```
### 携带 vs 重置一览表
| 数据 | 携带至 NG+ | 说明 |
|------|:--------:|------|
| 已拥有护符 | ✅ | 体现收集成果 |
| 护符升级状态 | ✅ | |
| Notch 槽最大值 | ✅ | |
| 已装备护符 | ❌ | 需重新装备 |
| 形态解锁 | ✅ | NG+ 保留形态;剧情仍播放获取动画(剧情标志控制)|
| 技能(随形态)| ✅ | |
| 工具(拥有)| ✅ | |
| 成就 | ✅ | |
| 游玩时长 | ✅(累加)| |
| 统计数据 | ✅(累加)| |
| 货币 | ❌ | 从 0 开始 |
| HP 上限 | ❌ | 从 1 节重新升级 |
| 能力解锁 | ❌ | 双跳/冲刺等需重新获取 |
| 世界状态 | ❌ | Boss 复活,大门重新关闭 |
| 地图探索 | ❌ | |
| 任务 / 事件链 | ❌ | |
| 商店购买记录 | ❌ | |
---
## 15. 多存档槽管理
| 槽 | 主文件 | 备份文件 |
|----|--------|---------|
| 0 | `slot_0.json` | `slot_0_backup.json` |
| 1 | `slot_1.json` | `slot_1_backup.json` |
| 2 | `slot_2.json` | `slot_2_backup.json` |
**最大槽数**`SaveManager.MaxSlots = 3`(架构无限制,常量可调整)。
**存档槽选择界面**(利用 `SaveSlotSummary` 摘要,不反序列化完整存档;界面详细设计见 [10_UISystem §16](./10_UISystem.md#16-存档点交互--存档槽选择界面)
```
┌─ 选择存档 ────────────────────────────────────────────────────────┐
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ [存档点背景图 SP_BG_FungalWastes_03] │ │
│ │ 古菌荒废·黄昏壁炉 ♥♥♥♥○ 游玩 2h 12m [NG+1] │ │
│ │ 最后存档2026-04-28 22:30 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ [存档点背景图 SP_BG_Crossroads_01] │ │
│ │ 被遗忘的十字路口·路口壁炉 ♥♥♥○○ 游玩 0h 45m │ │
│ │ 最后存档2026-04-27 20:15 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ── (空槽) [开始新游戏] │
└───────────────────────────────────────────────────────────────────┘
```
**摘要字段一览**`SaveSlotSummary` 在选择界面展示的全部数据):
| 字段 | 来源 | 用途 |
|------|------|------|
| `meta.savePointBgImageKey` | `SavePointSO.bgImageKey` | 槽卡背景图Addressable 异步加载)|
| `meta.savePointLocKey` | `SavePointSO.locKey` | 存档点显示名称(查本地化表)|
| `meta.lastSaved` | 存档时系统时间UTC| 格式化为本地时区显示 |
| `meta.playtime` | 累加游玩时长(秒)| 格式化为 `Xh Xm` 显示 |
| `meta.ngPlusCount` | 0 = 非 NG+ | 显示 `[NG+N]` 标记 |
| `player.currentHP / maxHP` | 玩家当前状态 | 心形容器列 |
---
## 16. 云存档冲突解决
```csharp
public static class CloudSaveConflictResolver
{
public enum Resolution { UseLocal, UseCloud, AskUser }
/// <summary>
/// 自动解决策略(后台/非交互场景)。
/// 规则:时差 &lt; 60 秒 → 保留本地;云端更新 → 使用云端;本地更新 → 使用本地。
/// </summary>
public static Resolution ResolveAutomatic(
DateTimeOffset localTimestamp,
DateTimeOffset cloudTimestamp)
{
double diff = Math.Abs((cloudTimestamp - localTimestamp).TotalSeconds);
if (diff < 60) return Resolution.UseLocal;
return cloudTimestamp > localTimestamp ? Resolution.UseCloud : Resolution.UseLocal;
}
/// <summary>无法自动解决时弹 UI由玩家选择</summary>
public static Task<Resolution> ResolveInteractiveAsync(
SaveSlotSummary local, SaveSlotSummary cloud)
=> ConflictDialogUI.ShowAsync(local, cloud);
}
```
**冲突检测时机**:游戏启动时,`ISaveStorage.SupportsCloudSync == true` 的后端在 `LoadAsync` 之前调用 `GetCloudTimestampAsync` 检查云端时间戳。
---
## 17. 性能预算
| 指标 | 目标 | 测量方法 |
|------|------|---------|
| 存档文件大小100% 收集)| < 128 KB | 实测 JSON 序列化大小 |
| 主线程序列化+校验耗时 | < 0.5 ms | Profiler 采样(深拷贝 + Validate|
| 异步写入总耗时 | < 200 ms | Task.Run 内部,不阻塞主线程 |
| 加载反序列化耗时 | < 50 ms | 线程池,不影响帧率 |
| WriteDirty 主线程开销 | < 0.1 ms | 仅标记 Dirty不做 I/O |
| EmergencySave 超时阈值 | 500 ms | 超时放弃,防止阻塞退出流程 |
**文件大小控制策略**
1. `NullValueHandling.Ignore`null 字段不序列化
2. 简单集合优先 `List` 而非 `Dictionary`(序列化更小)
3. `stats.distanceTraveled``int`(米级精度)
4. 枚举用 `string`(可读性);需压缩时改 `int` 可减 5070%
5. `discoveredRooms` 存房间 ID 整数列表,不存完整房间数据
---
## 18. 各子系统贡献字段索引
| 字段路径 | 来源文档 |
|---------|---------|
| `player.currentHP / maxHP` | [03_PlayerSystem](./03_PlayerSystem.md) |
| `player.currentGeo / lifetimeGeo` | [03_PlayerSystem](./03_PlayerSystem.md) |
| `player.abilities.*` | [03_PlayerSystem](./03_PlayerSystem.md) §能力解锁 |
| `player.shield.*` | [30_ShieldMechanicsSystem](./30_ShieldMechanicsSystem.md) §9 |
| `player.forms.*` | [21_SpellSystem](./21_SpellSystem.md) §7 |
| `player.deathPos` | [08_WorldSystem](./08_WorldSystem.md) §5 |
| `equipment.*` | [17_EquipmentSystem](./17_EquipmentSystem.md) §9 |
| `world.visitedScenes / activatedSavePoints` | [08_WorldSystem](./08_WorldSystem.md) §5 |
| `world.collectibles.*` | [08_WorldSystem](./08_WorldSystem.md) §5 |
| `world.defeatedBossIds` | [08_WorldSystem](./08_WorldSystem.md) §5 |
| `world.switches` | [08_WorldSystem](./08_WorldSystem.md) §5 |
| `world.revealedFalseWalls` | [08_WorldSystem](./08_WorldSystem.md) §9.8 |
| `world.npcRelations` | [38_QuestSystem](./38_QuestSystem.md) §7RelationshipManager|
| `map.*` | [16_MapSystem](./16_MapSystem.md) §7 |
| `quests.*` | [38_QuestSystem](./38_QuestSystem.md) §9 |
| `achievements.*` | [32_AchievementSystem](./32_AchievementSystem.md) §7 |
| `tools.*` | [37_ToolSystem](./37_ToolSystem.md) §SaveData |
| `challengeRooms.*` | [39_ChallengeRoomSystem](./39_ChallengeRoomSystem.md) §SaveData |
| `eventChains.*` | [34_EventChainSystem](./34_EventChainSystem.md) §SaveData |
| `shops.*` | [28_ShopSystem](./28_ShopSystem.md) §9 |
| `stats.*` | 本文档 §3统计桶无专属文档|
| `ngplus.*` | [52_NewGamePlus](./52_NewGamePlus.md)(待创建)|
| `dlc.*` | 各 DLC 包(`IDlcSaveExtension`|
| `meta._checksum` | 本文档 §11 |
| `GlobalSettings.audio / graphics` | UI 设置菜单 |
| `GlobalSettings.input.bindingOverrides` | [25_InputRebindingUI](./25_InputRebindingUI.md) |
| `GlobalSettings.gameplay.difficulty` | [29_DifficultyModesGuide](./29_DifficultyModesGuide.md) §8 |
---
## 19. 编辑器工具
### SaveDataEditor 调试窗口
`Tools → Zeling → 存档调试器`
```
┌─ 存档调试器 ─────────────────────────────────────────────────────┐
│ 存档槽: [0 ▼] [加载] [保存] [删除(保留备份)] [验证] │
│ ───────────────────────────────────────────────────────────── │
│ 版本: 2.0 | 游玩: 2h 12m | 最后存档: 2026-04-28 14:30 │
│ 存档点: SavePoint_FungalWastes_03 | NG+: 0 │
│ │
│ [Player ▼] [Equipment ▼] [World ▼] [Quests ▼] [Stats ▼] │
│ │
│ Raw JSON: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { "meta": { ... }, "player": { ... }, ... } │ │
│ └────────────────────────────────────────────────────────┘ │
│ [导出 JSON] [从文件导入] [修复 Checksum] [强制迁移至 2.0] │
└──────────────────────────────────────────────────────────────────┘
```
**功能清单**
| 功能 | 说明 |
|------|------|
| 加载/保存/删除槽 | 调用 SaveManager 正式流程,验证 Checksum |
| 字段校验 | 调用 SaveValidator高亮失败字段 |
| 版本迁移 | 强制运行 SaveMigrator 至最新版本 |
| 导出 / 导入 JSON | QA 复现问题存档 |
| 修复 Checksum | 手动编辑 JSON 后重新计算 Checksum |
| 清零统计数据 | 调试专用,仅在 Editor + Development Build 可用 |
### 存档 Diff 工具
`Tools → Zeling → 存档 Diff`:选择两份存档(或同一槽的主档/备档),逐字段对比差异。用于调试 NG+ 迁移与版本升级结果。
---
*文档版本 2.0 · 2026-04-28完整重写取代 v1.x 所有版本)*