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

67 KiB
Raw Permalink Blame History

31 · SaveData 统一 Schemav2

命名空间 BaseGames.Core.Save
所属文档集 ← 返回索引 · 总览
依赖系统 03/08/16/17/21/25/28/29/30/32/34/37/38/39/45/46 + DLC 扩展位
取代 31_SaveDataSchema_Unified.md v1.x完整重写


目录

  1. 设计原则
  2. 系统架构图
  3. 完整 JSON Schema
  4. SaveData C# 数据结构(全系统)
  5. ISaveStorage — 平台存储抽象
  6. SaveManager — 异步读写流程
  7. 脏标记系统Dirty Flag
  8. 应急存档与退出处理
  9. 版本迁移SaveMigrator
  10. 存档验证SaveValidator
  11. 存档完整性保护Checksum + Backup
  12. 设置数据分离GlobalSettings
  13. DLC 扩展模型
  14. NG+ 重置 Schema
  15. 多存档槽管理
  16. 云存档冲突解决
  17. 性能预算
  18. 各子系统贡献字段索引
  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.Jsoncom.unity.nuget.newtonsoft-jsonNullValueHandling.Ignore保留未知字段
存档路径 {persistentDataPath}/saves/slot_{n}.jsonPC/移动端);平台专属路径由 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

{
  "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# 数据结构(全系统)

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

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 — 异步读写流程

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

成就解锁、剧情标志等关键事件需立即落盘,不等存档点:

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. 应急存档与退出处理

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.formsequipment.upgradedCharmIdsabilities.wallGrab
1.3 新增 world.switches
2.0 大版本重构Meta 分离顶层playtime float → double;新增 quests/achievements/tools/eventChains/stats/dlc 桶settings 迁移至独立 GlobalSettings 文件

SaveMigrator.cs

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

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

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
[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 游戏代码零修改:

/// <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 检查并升级:

"dlc": {
  "DLC_VoidWings": {
    "version": "1.0",
    ...
  }
}

14. NG+ 重置 Schema

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

┌─ 选择存档 ────────────────────────────────────────────────────────┐
│  ┌────────────────────────────────────────────────────────────┐  │
│  │  [存档点背景图  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. 云存档冲突解决

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.Ignorenull 字段不序列化
  2. 简单集合优先 List 而非 Dictionary(序列化更小)
  3. stats.distanceTraveledint(米级精度)
  4. 枚举用 string(可读性);需压缩时改 int 可减 5070%
  5. discoveredRooms 存房间 ID 整数列表,不存完整房间数据

18. 各子系统贡献字段索引

字段路径 来源文档
player.currentHP / maxHP 03_PlayerSystem
player.currentGeo / lifetimeGeo 03_PlayerSystem
player.abilities.* 03_PlayerSystem §能力解锁
player.shield.* 30_ShieldMechanicsSystem §9
player.forms.* 21_SpellSystem §7
player.deathPos 08_WorldSystem §5
equipment.* 17_EquipmentSystem §9
world.visitedScenes / activatedSavePoints 08_WorldSystem §5
world.collectibles.* 08_WorldSystem §5
world.defeatedBossIds 08_WorldSystem §5
world.switches 08_WorldSystem §5
world.revealedFalseWalls 08_WorldSystem §9.8
world.npcRelations 38_QuestSystem §7RelationshipManager
map.* 16_MapSystem §7
quests.* 38_QuestSystem §9
achievements.* 32_AchievementSystem §7
tools.* 37_ToolSystem §SaveData
challengeRooms.* 39_ChallengeRoomSystem §SaveData
eventChains.* 34_EventChainSystem §SaveData
shops.* 28_ShopSystem §9
stats.* 本文档 §3统计桶无专属文档
ngplus.* 52_NewGamePlus(待创建)
dlc.* 各 DLC 包(IDlcSaveExtension
meta._checksum 本文档 §11
GlobalSettings.audio / graphics UI 设置菜单
GlobalSettings.input.bindingOverrides 25_InputRebindingUI
GlobalSettings.gameplay.difficulty 29_DifficultyModesGuide §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 所有版本)