# 31 · SaveData 统一 Schema(v2) > **命名空间** `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 NAND(P2/TBD) │ │ └─ Ps5Storage ← PS5 SAVEDATA mount(P2/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 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 ChallengeRooms = new(); public EventChainsSaveData EventChains = new(); public Dictionary Shops = new(); public StatsSaveData Stats = new(); [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public NgPlusSaveData NgPlus = null; // null = 非 NG+ 局 // DLC 扩展桶:DLC 自行序列化/反序列化内部结构 [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public Dictionary 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) /// 本地化名称键,格式 "ui.savepoint.{id}";查 UI_Table 得到各语言显示名 public string SavePointLocKey; /// 存档背景图资产键(Addressable label: SavePointBG);存档选择界面展示 public string SavePointBgImageKey; public int NgPlusCount; // NG+ 轮次(0 = 初始) // 向前兼容:未来新增 meta 字段在旧版本加载时不会丢失 [JsonExtensionData] public Dictionary 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("标识")] /// 需与场景中 SavePoint 组件的 savePointId 字段完全匹配 public string savePointId; [Header("存档槽展示")] /// 本地化名称键,格式:ui.savepoint.{id},在 UI_Table 中定义各语言名称
/// 示例:ui.savepoint.fungalwastes_03 = "古菌荒废·黄昏壁炉"
public string locKey; /// /// 存档选择界面背景图 Sprite 的 Addressable 资产地址。
/// 建议分辨率:720×405(16:9),存档卡片按 UI 设计裁切。
/// Addressable Label: "SavePointBG" ///
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 UnlockedFormIds = new(); public Dictionary 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 EquippedCharmIds = new(); public int NotchesUsed; public int MaxNotches; public List OwnedCharmIds = new(); public List UpgradedCharmIds = new(); } // ───────────────────────────────────────────────────────── // World(见 08_WorldSystem) // ───────────────────────────────────────────────────────── [Serializable] public class WorldSaveData { public List VisitedScenes = new(); public List ActivatedSavePoints = new(); public List OpenedDoors = new(); public List DefeatedBossIds = new(); public CollectiblesSaveData Collectibles = new(); public Dictionary Switches = new(); public List RevealedFalseWalls = new(); /// NPC 好感度(npcId → affinity 值) public Dictionary NpcRelations = new(); } [Serializable] public class CollectiblesSaveData { public List PickedUpIds = new(); } // ───────────────────────────────────────────────────────── // Map(见 16_MapSystem) // ───────────────────────────────────────────────────────── [Serializable] public class MapSaveData { public Dictionary> DiscoveredRooms = new(); public Dictionary MapPurchased = new(); public bool CompassEnabled; public bool QuillEnabled; } // ───────────────────────────────────────────────────────── // Quests(见 38_QuestSystem §9) // ───────────────────────────────────────────────────────── [Serializable] public class QuestsSaveData { public Dictionary QuestStates = new(); public List AvailableQuestIds = new(); } [Serializable] public class QuestStateSaveData { public string Status; // "Active" / "Completed" / "Failed" / "Unavailable" public int ObjectiveIndex; public int[] ProgressCounts = Array.Empty(); public string GiverNpcId; } // ───────────────────────────────────────────────────────── // Achievements(见 32_AchievementSystem §7) // ───────────────────────────────────────────────────────── [Serializable] public class AchievementsSaveData { public List Unlocked = new(); /// 计数/百分比类成就进度 public Dictionary 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 OwnedToolIds = new(); /// 工具内部持久状态(如弹弓弹药数);工具自行序列化为 JObject public Dictionary 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 { /// 链状态(chainId → 当前步骤标识或 "Completed") public Dictionary ChainStates = new(); /// 全局世界标志(布尔变量) public Dictionary WorldFlags = new(); } // ───────────────────────────────────────────────────────── // Shop(见 28_ShopSystem §9) // ───────────────────────────────────────────────────────── [Serializable] public class ShopSaveData { public List SoldUniqueItems = new(); public Dictionary PurchaseCounts = new(); } // ───────────────────────────────────────────────────────── // Stats(游戏统计数据) // ───────────────────────────────────────────────────────── [Serializable] public class StatsSaveData { public int EnemyKills; public int Deaths; public Dictionary DeathsByBoss = new(); public int ParrySuccessCount; public int ParryFailCount; public Dictionary SkillUseCounts = new(); public long GeoEarned; public long GeoLost; public int DistanceTraveled; // 米(int 精度足够) public int SaveCount; } // ───────────────────────────────────────────────────────── // NG+(见 52_NewGamePlus,null = 非 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 { /// /// 平台存储后端抽象。 /// 实现由 PlatformBootstrapper 在游戏启动时注入至 SaveManager。 /// PC = LocalFileStorage;Steam = SteamCloudStorage;Switch/PS5 由各自实现。 /// public interface ISaveStorage { bool SupportsCloudSync { get; } Task ReadAsync(string key, CancellationToken ct = default); Task WriteAsync(string key, string data, CancellationToken ct = default); Task DeleteAsync(string key, CancellationToken ct = default); Task ExistsAsync(string key, CancellationToken ct = default); Task> ListKeysAsync(CancellationToken ct = default); Task 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 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 ExistsAsync(string key, CancellationToken ct = default) => Task.FromResult(File.Exists(Path.Combine(_baseDir, key))); public Task> ListKeysAsync(CancellationToken ct = default) { if (!Directory.Exists(_baseDir)) return Task.FromResult>(Array.Empty()); var keys = Directory.GetFiles(_baseDir) .Select(Path.GetFileName).ToList(); return Task.FromResult>(keys); } public Task GetCloudTimestampAsync(string key, CancellationToken ct = default) => Task.FromResult(null); } // ── Steam Cloud 实现(骨架,需接入 Steamworks.NET)──────────────────── public class SteamCloudStorage : ISaveStorage { public bool SupportsCloudSync => true; public async Task 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 ExistsAsync(string key, CancellationToken ct = default) => Task.FromResult(SteamRemoteStorage.FileExists(key)); public Task> ListKeysAsync(CancellationToken ct = default) { int count = SteamRemoteStorage.GetFileCount(); var keys = new List(count); for (int i = 0; i < count; i++) keys.Add(SteamRemoteStorage.GetFileNameAndSize(i, out _)); return Task.FromResult>(keys); } public Task GetCloudTimestampAsync(string key, CancellationToken ct = default) { if (!SteamRemoteStorage.FileExists(key)) return Task.FromResult(null); var ts = DateTimeOffset.FromUnixTimeSeconds(SteamRemoteStorage.GetFileTimestamp(key)); return Task.FromResult(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(); // ── 公开接口 ──────────────────────────────────────────────────────── /// 从指定槽异步加载。返回 null 表示槽不存在或损坏且无备份 public async Task 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; } /// /// 全量异步存档(存档点专用)。 /// 主线程开销 < 0.5 ms;I/O 在线程池执行。 /// public async Task 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; } /// /// WriteDirty:关键时机立即触发存档(成就解锁、剧情标志)。 /// Fire-and-forget,完全异步,不阻塞调用方。 /// public void WriteDirty(SaveDirtyCategory category) { _dirty.Mark(category); if (ActiveSlot >= 0) _ = SaveAsync(ActiveSlot); } /// /// EmergencySave:游戏退出时同步写入(允许阻塞,超时 500 ms 后放弃)。 /// 仅应在 Application.quitting 回调中使用。 /// 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 GetSlotSummaryAsync(int slot) { string json = await _storage.ReadAsync(SlotKey(slot)); if (json == null) return null; return JsonConvert.DeserializeObject(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 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 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(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(json); } // ── DLC 扩展桶 ────────────────────────────────────────────────────── readonly List _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 _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 suspend(SIGTERM → 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 { /// 链式逐版本升级,不跳跃 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; } /// 1.3 → 2.0 大版本迁移 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(); // playtime:float 精度已在 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(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 { /// InputSystem 的重绑定 JSON(InputActionAsset.SaveBindingOverridesAsJson()) 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(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 /// /// DLC 在游戏启动时向 SaveManager.RegisterDlcExtension() 注册。 /// SaveManager 序列化时将 DLC 数据存入 data.Dlc[DlcId]; /// 反序列化时调用 Deserialize 还原(仅在 DLC 已安装时)。 /// 未安装 DLC 时,旧存档中的 DLC 数据通过 JsonExtensionData 静默保留。 /// 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 _dlcExtensions; public static void SetDlcExtensions(IReadOnlyList exts) => _dlcExtensions = exts; /// /// 将已通关存档转换为 NG+ 存档。 /// 返回新的 SaveData,携带允许保留的字段,其余重置。 /// 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 } /// /// 自动解决策略(后台/非交互场景)。 /// 规则:时差 < 60 秒 → 保留本地;云端更新 → 使用云端;本地更新 → 使用本地。 /// 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; } /// 无法自动解决时弹 UI,由玩家选择 public static Task 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` 可减 50–70% 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) §7(RelationshipManager)| | `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 所有版本)*