67 KiB
31 · SaveData 统一 Schema(v2)
命名空间
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(完整重写)
目录
- 设计原则
- 系统架构图
- 完整 JSON Schema
- SaveData C# 数据结构(全系统)
- ISaveStorage — 平台存储抽象
- SaveManager — 异步读写流程
- 脏标记系统(Dirty Flag)
- 应急存档与退出处理
- 版本迁移(SaveMigrator)
- 存档验证(SaveValidator)
- 存档完整性保护(Checksum + Backup)
- 设置数据分离(GlobalSettings)
- DLC 扩展模型
- NG+ 重置 Schema
- 多存档槽管理
- 云存档冲突解决
- 性能预算
- 各子系统贡献字段索引
- 编辑器工具
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
{
"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×405(16: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_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:
namespace BaseGames.Core.Save
{
/// <summary>
/// 平台存储后端抽象。
/// 实现由 PlatformBootstrapper 在游戏启动时注入至 SaveManager。
/// PC = LocalFileStorage;Steam = SteamCloudStorage;Switch/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>
/// 全量异步存档(存档点专用)。
/// 主线程开销 < 0.5 ms;I/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 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
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();
// playtime:float 精度已在 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 的重绑定 JSON(InputActionAsset.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>
/// 自动解决策略(后台/非交互场景)。
/// 规则:时差 < 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 | 超时放弃,防止阻塞退出流程 |
文件大小控制策略:
NullValueHandling.Ignore:null 字段不序列化- 简单集合优先
List而非Dictionary(序列化更小) stats.distanceTraveled用int(米级精度)- 枚举用
string(可读性);需压缩时改int可减 50–70% 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 §7(RelationshipManager) |
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 所有版本)