1623 lines
67 KiB
Markdown
1623 lines
67 KiB
Markdown
# 31 · SaveData 统一 Schema(v2)
|
||
|
||
> **命名空间** `BaseGames.Core.Save`
|
||
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
|
||
> **依赖系统** 03/08/16/17/21/25/28/29/30/32/34/37/38/39/45/46 + DLC 扩展位
|
||
> **取代** 31_SaveDataSchema_Unified.md v1.x(完整重写)
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [设计原则](#1-设计原则)
|
||
2. [系统架构图](#2-系统架构图)
|
||
3. [完整 JSON Schema](#3-完整-json-schema)
|
||
4. [SaveData C# 数据结构(全系统)](#4-savedata-c-数据结构全系统)
|
||
5. [ISaveStorage — 平台存储抽象](#5-isavestorage--平台存储抽象)
|
||
6. [SaveManager — 异步读写流程](#6-savemanager--异步读写流程)
|
||
7. [脏标记系统(Dirty Flag)](#7-脏标记系统dirty-flag)
|
||
8. [应急存档与退出处理](#8-应急存档与退出处理)
|
||
9. [版本迁移(SaveMigrator)](#9-版本迁移savemigrator)
|
||
10. [存档验证(SaveValidator)](#10-存档验证savevalidator)
|
||
11. [存档完整性保护(Checksum + Backup)](#11-存档完整性保护checksum--backup)
|
||
12. [设置数据分离(GlobalSettings)](#12-设置数据分离globalsettings)
|
||
13. [DLC 扩展模型](#13-dlc-扩展模型)
|
||
14. [NG+ 重置 Schema](#14-ng-重置-schema)
|
||
15. [多存档槽管理](#15-多存档槽管理)
|
||
16. [云存档冲突解决](#16-云存档冲突解决)
|
||
17. [性能预算](#17-性能预算)
|
||
18. [各子系统贡献字段索引](#18-各子系统贡献字段索引)
|
||
19. [编辑器工具](#19-编辑器工具)
|
||
|
||
---
|
||
|
||
## 1. 设计原则
|
||
|
||
| 原则 | 要求 |
|
||
|------|------|
|
||
| **异步 I/O** | 所有磁盘读写在线程池中执行,主线程零阻塞;存档操作不得超过 0.5 ms 主线程开销 |
|
||
| **原子写入** | 先写 `.tmp` 再 rename,保证写入失败不会损坏现有存档 |
|
||
| **主备双份** | 每次成功写入后保留前一版本为 `_backup`,损坏时自动降级恢复 |
|
||
| **分层 Schema** | 顶层按系统分桶;新增字段只影响各自桶,不破坏其他系统 |
|
||
| **版本迁移链** | 版本号单调递增,`SaveMigrator` 逐步升级,不跳跃 |
|
||
| **向前兼容** | 新版本存档被旧版游戏加载时,未知字段被完整保留(`JsonExtensionData`),不丢弃 |
|
||
| **设置与游戏数据分离** | `GlobalSettings` 保存在独立文件,独立于存档槽,删槽不丢设置 |
|
||
| **脏标记批处理** | 运行时变更标记 Dirty,由 SaveManager 在存档点统一落盘;关键事件(成就)立即 WriteDirty |
|
||
| **写时校验** | 写入前执行 `SaveValidator`,非法数据拒绝写盘 |
|
||
| **平台抽象** | 所有文件 I/O 经由 `ISaveStorage` 接口,支持 PC / Steam Cloud / Switch / PS5 后端切换 |
|
||
| **DLC 可扩展** | 顶层 `dlc` 字典允许任意 DLC 注入自己的存档桶,Base 游戏不感知 |
|
||
| **完整性校验** | 每份存档均含 SHA-256 Checksum,加载时自动验证 |
|
||
| **序列化库** | `Newtonsoft.Json`(`com.unity.nuget.newtonsoft-json`),NullValueHandling.Ignore,保留未知字段 |
|
||
| **存档路径** | `{persistentDataPath}/saves/slot_{n}.json`(PC/移动端);平台专属路径由 `ISaveStorage` 提供 |
|
||
|
||
---
|
||
|
||
## 2. 系统架构图
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────────┐
|
||
│ SaveManager(单例) │
|
||
│ │
|
||
│ 公开接口: │
|
||
│ ├─ SaveAsync(slot) ← 存档点触发,全量写入(异步) │
|
||
│ ├─ LoadAsync(slot) ← 场景加载时(异步) │
|
||
│ ├─ WriteDirty(category) ← 关键事件(成就解锁/剧情标志)立即落盘 │
|
||
│ ├─ EmergencySave(slot) ← 退出/挂起时同步写入(最后手段) │
|
||
│ └─ GetSlotSummaryAsync ← 存档选择界面(仅反序列化顶层字段) │
|
||
│ │
|
||
│ 内部流程: │
|
||
│ SaveAsync → DeepClone → Validate → Checksum → ISaveStorage.Write │
|
||
│ LoadAsync → ISaveStorage.Read → VerifyChecksum → Migrate → Return │
|
||
│ │
|
||
│ ISaveStorage(平台抽象) │
|
||
│ ├─ LocalFileStorage ← PC / Android / iOS │
|
||
│ ├─ SteamCloudStorage ← Steam PC / Deck(自动同步) │
|
||
│ ├─ SwitchStorage ← Nintendo Switch NAND(P2/TBD) │
|
||
│ └─ Ps5Storage ← PS5 SAVEDATA mount(P2/TBD) │
|
||
└──────────────────────────────────────────────────────────────────────┘
|
||
|
||
SaveData(顶层)
|
||
├─ meta → 版本、时间、游玩时长、校验和
|
||
├─ player → HP、位置、货币、能力解锁、形态
|
||
├─ equipment → 护符、Notch 槽
|
||
├─ world → 场景、存档点、Boss、开关、收集品、NPC 好感度
|
||
├─ map → 房间探索状态
|
||
├─ quests → 任务链状态(见 38_QuestSystem)
|
||
├─ achievements → 成就解锁 + 进度计数(见 32_AchievementSystem)
|
||
├─ tools → 工具槽装备与持久状态(见 37_ToolSystem)
|
||
├─ challengeRooms → 挑战房间分数/排名(见 39_ChallengeRoomSystem)
|
||
├─ eventChains → 世界状态级联标志(见 34_EventChainSystem)
|
||
├─ shops → 商店购买记录(见 28_ShopSystem)
|
||
├─ stats → 游戏统计(击杀/死亡/弹反等)
|
||
├─ ngplus → NG+ 轮次与专属标志(null = 非 NG+ 模式)
|
||
└─ dlc → DLC 自注册扩展桶(Base 不感知内容)
|
||
|
||
GlobalSettings(独立文件,非 slot)
|
||
├─ audio → 主音量/BGM/SFX
|
||
├─ graphics → 分辨率/全屏/VSync
|
||
├─ input → 输入重绑定 JSON
|
||
├─ accessibility → 色盲/字幕/振动
|
||
└─ gameplay → 难度 / 钢铁之魂 / NG+ HUD
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 完整 JSON Schema
|
||
|
||
```json
|
||
{
|
||
"meta": {
|
||
"version": "2.0",
|
||
"slotIndex": 0,
|
||
"lastSaved": "2026-04-28T14:30:00Z",
|
||
"playtime": 7234.512345,
|
||
"savePointId": "SavePoint_FungalWastes_03",
|
||
"savePointLocKey": "ui.savepoint.fungalwastes_03",
|
||
"savePointBgImageKey": "SP_BG_FungalWastes_03",
|
||
"ngPlusCount": 0,
|
||
"_checksum": "a3f2c1d8e47b9f0623a1..."
|
||
},
|
||
|
||
"player": {
|
||
"posX": -12.5,
|
||
"posY": 3.0,
|
||
"scene": "Scene_ForgottenCrossroads",
|
||
"currentHP": 4,
|
||
"maxHP": 5,
|
||
"currentGeo": 350,
|
||
"lifetimeGeo": 12480,
|
||
"shield": { "currentHP": 60, "isBroken": false },
|
||
"abilities": {
|
||
"doubleJump": true,
|
||
"wallGrab": true,
|
||
"dash": true,
|
||
"airDash": false,
|
||
"groundSlam": false,
|
||
"swim": false
|
||
},
|
||
"forms": {
|
||
"activeFormId": "SkyForm",
|
||
"unlockedFormIds": ["SkyForm", "EarthForm"],
|
||
"skillCooldowns": {
|
||
"sky_soul_skill": 0.0,
|
||
"earth_spirit_skill1": 1.2
|
||
}
|
||
},
|
||
"deathPos": {
|
||
"x": -20.0,
|
||
"y": 1.5,
|
||
"scene": "Scene_FungalWastes",
|
||
"geoAmount": 150
|
||
}
|
||
},
|
||
|
||
"equipment": {
|
||
"equippedCharmIds": ["Charm_QuickSlash", "Charm_LongNail"],
|
||
"notchesUsed": 5,
|
||
"maxNotches": 11,
|
||
"ownedCharmIds": ["Charm_QuickSlash", "Charm_LongNail", "Charm_WanderingNotes"],
|
||
"upgradedCharmIds": ["Charm_QuickSlash"]
|
||
},
|
||
|
||
"world": {
|
||
"visitedScenes": ["Scene_ForgottenCrossroads", "Scene_FungalWastes"],
|
||
"activatedSavePoints": ["SavePoint_Crossroads_01", "SavePoint_FungalWastes_03"],
|
||
"openedDoors": ["Door_City_01"],
|
||
"defeatedBossIds": ["Boss_SpiderGuard"],
|
||
"collectibles": {
|
||
"pickedUpIds": ["GeoRock_Crossroads_01", "Grub_FungalWastes_02"]
|
||
},
|
||
"switches": {
|
||
"Lever_CrystalPeak_01": true
|
||
},
|
||
"revealedFalseWalls": ["FalseWall_Crossroads_Secret_01"],
|
||
"npcRelations": {
|
||
"NPC_Herbalist": 25,
|
||
"NPC_Merchant_Forest": 10
|
||
}
|
||
},
|
||
|
||
"map": {
|
||
"discoveredRooms": {
|
||
"Scene_ForgottenCrossroads": [0, 1, 2, 3, 5, 7],
|
||
"Scene_FungalWastes": [0, 1, 4]
|
||
},
|
||
"mapPurchased": {
|
||
"Scene_ForgottenCrossroads": true
|
||
},
|
||
"compassEnabled": true,
|
||
"quillEnabled": true
|
||
},
|
||
|
||
"quests": {
|
||
"questStates": {
|
||
"Quest_FindMushroom": {
|
||
"status": "Active",
|
||
"objectiveIndex": 1,
|
||
"progressCounts": [3, 0],
|
||
"giverNpcId": "NPC_Herbalist"
|
||
},
|
||
"Quest_EscortCaravan": {
|
||
"status": "Completed",
|
||
"objectiveIndex": 2,
|
||
"progressCounts": [1, 1],
|
||
"giverNpcId": "NPC_Merchant_Forest"
|
||
}
|
||
},
|
||
"availableQuestIds": ["Quest_SlayForestBoss"]
|
||
},
|
||
|
||
"achievements": {
|
||
"unlocked": ["Ach_Story_FirstBoss", "Ach_Parry_10"],
|
||
"progress": {
|
||
"Ach_Parry_100": { "count": 47 },
|
||
"Ach_MapExplore90": { "percent": 0.63 }
|
||
}
|
||
},
|
||
|
||
"tools": {
|
||
"slot0": "Tool_Slingshot",
|
||
"slot1": "Tool_BearTrap",
|
||
"ownedToolIds": ["Tool_Slingshot", "Tool_BearTrap", "Tool_Rope"],
|
||
"toolStates": {
|
||
"Tool_Slingshot": { "ammo": 8 }
|
||
}
|
||
},
|
||
|
||
"challengeRooms": {
|
||
"ChallengeRoom_Forest_01": {
|
||
"bestScore": 4200,
|
||
"bestTime": 87.3,
|
||
"bestRank": "A",
|
||
"completionCount": 3
|
||
}
|
||
},
|
||
|
||
"eventChains": {
|
||
"chainStates": {
|
||
"Chain_ForestGate": "Completed",
|
||
"Chain_CaveAncient": "Step2"
|
||
},
|
||
"worldFlags": {
|
||
"ForestBossDefeated": true,
|
||
"AncientSealBroken": false
|
||
}
|
||
},
|
||
|
||
"shops": {
|
||
"Shop_Forest_Merchant": {
|
||
"soldUniqueItems": ["Item_Charm_WanderingNotes"],
|
||
"purchaseCounts": { "Item_HealthPotion_Small": 2 }
|
||
}
|
||
},
|
||
|
||
"stats": {
|
||
"enemyKills": 284,
|
||
"deaths": 12,
|
||
"deathsByBoss": { "Boss_SpiderGuard": 3 },
|
||
"parrySuccessCount": 47,
|
||
"parryFailCount": 103,
|
||
"skillUseCounts": { "sky_soul_skill": 24 },
|
||
"geoEarned": 8420,
|
||
"geoLost": 310,
|
||
"distanceTraveled": 15234,
|
||
"saveCount": 38
|
||
},
|
||
|
||
"ngplus": null,
|
||
|
||
"dlc": {
|
||
"DLC_VoidWings": {
|
||
"version": "1.0",
|
||
"wingFormUnlocked": true,
|
||
"wingSkillCooldowns": { "wing_soul": 0.0 }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. SaveData C# 数据结构(全系统)
|
||
|
||
```csharp
|
||
namespace BaseGames.Core.Save
|
||
{
|
||
// ─────────────────────────────────────────────────────────
|
||
// 顶层(SaveData)
|
||
// ─────────────────────────────────────────────────────────
|
||
[Serializable]
|
||
public class SaveData
|
||
{
|
||
// JSON 序列化时保留未知字段(向前兼容:旧版游戏不会丢弃新版 DLC 字段)
|
||
[JsonExtensionData]
|
||
public Dictionary<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:
|
||
|
||
```csharp
|
||
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 — 异步读写流程
|
||
|
||
```csharp
|
||
namespace BaseGames.Core.Save
|
||
{
|
||
public class SaveManager : MonoBehaviour
|
||
{
|
||
public const string CurrentVersion = "2.0";
|
||
public const int MaxSlots = 3;
|
||
|
||
public int ActiveSlot { get; private set; } = -1;
|
||
|
||
ISaveStorage _storage;
|
||
SaveData _activeData;
|
||
readonly DirtyFlagSet _dirty = new();
|
||
CancellationTokenSource _cts;
|
||
|
||
readonly JsonSerializerSettings _jsonSettings = new()
|
||
{
|
||
MissingMemberHandling = MissingMemberHandling.Ignore,
|
||
NullValueHandling = NullValueHandling.Ignore,
|
||
};
|
||
|
||
// ── 启动 ────────────────────────────────────────────────────────────
|
||
|
||
void Awake()
|
||
{
|
||
_storage = PlatformServiceLocator.GetSaveStorage();
|
||
_cts = new CancellationTokenSource();
|
||
_ = CleanupOrphanedTmpFilesAsync();
|
||
}
|
||
|
||
void OnDestroy() => _cts.Cancel();
|
||
|
||
// ── 公开接口 ────────────────────────────────────────────────────────
|
||
|
||
/// <summary>从指定槽异步加载。返回 null 表示槽不存在或损坏且无备份</summary>
|
||
public async Task<SaveData> LoadAsync(int slot)
|
||
{
|
||
SaveData data = await LoadWithIntegrityCheckAsync(SlotKey(slot), _cts.Token);
|
||
if (data != null && data.Meta.Version != CurrentVersion)
|
||
data = SaveMigrator.Migrate(data);
|
||
_activeData = data;
|
||
ActiveSlot = slot;
|
||
DeserializeDlcData(data);
|
||
return data;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 全量异步存档(存档点专用)。
|
||
/// 主线程开销 < 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)
|
||
|
||
成就解锁、剧情标志等关键事件需立即落盘,不等存档点:
|
||
|
||
```csharp
|
||
public enum SaveDirtyCategory
|
||
{
|
||
Achievements, // 成就解锁(立即落盘,防止死亡丢失)
|
||
WorldFlags, // 世界状态标志(Boss 击败等)
|
||
Quests, // 任务状态变化
|
||
Stats, // 统计计数(可容忍少量丢失,低优先级)
|
||
Full, // 存档点完整存档
|
||
}
|
||
|
||
public class DirtyFlagSet
|
||
{
|
||
readonly HashSet<SaveDirtyCategory> _dirty = new();
|
||
public void Mark(SaveDirtyCategory cat) => _dirty.Add(cat);
|
||
public bool IsDirty(SaveDirtyCategory cat) => _dirty.Contains(cat);
|
||
public void ClearAll() => _dirty.Clear();
|
||
}
|
||
```
|
||
|
||
**各系统使用约定**:
|
||
|
||
| 系统 | 调用时机 | Category |
|
||
|------|---------|---------|
|
||
| `AchievementManager` | 解锁成就后立即 | `Achievements` |
|
||
| `QuestManager` | 任务状态/目标进度变化 | `Quests` |
|
||
| `EventChainSystem` | 世界标志变更 | `WorldFlags` |
|
||
| `SavePoint` | 玩家触发存档点 | `Full`(调用 SaveAsync)|
|
||
| `StatsTracker` | 每场景结算时批量写 | `Stats` |
|
||
|
||
---
|
||
|
||
## 8. 应急存档与退出处理
|
||
|
||
```csharp
|
||
public class SaveOnExit : MonoBehaviour
|
||
{
|
||
[SerializeField] SaveManager _saveManager;
|
||
|
||
void OnEnable()
|
||
{
|
||
Application.quitting += OnQuit;
|
||
Application.focusChanged += OnFocusChanged;
|
||
}
|
||
|
||
void OnDisable()
|
||
{
|
||
Application.quitting -= OnQuit;
|
||
Application.focusChanged -= OnFocusChanged;
|
||
}
|
||
|
||
// PC / Steam Deck suspend(SIGTERM → Application.quitting)
|
||
void OnQuit()
|
||
{
|
||
bool ok = _saveManager.EmergencySave();
|
||
if (!ok) Debug.LogWarning("[SaveOnExit] 应急存档失败(超时或无活跃存档)");
|
||
}
|
||
|
||
// 移动端后台(iOS/Android 切出应用)
|
||
void OnFocusChanged(bool hasFocus)
|
||
{
|
||
if (!hasFocus && _saveManager.ActiveSlot >= 0)
|
||
_ = _saveManager.SaveAsync(_saveManager.ActiveSlot);
|
||
}
|
||
}
|
||
```
|
||
|
||
**孤儿 `.tmp` 清理**:在 `SaveManager.Awake()` 调用 `CleanupOrphanedTmpFilesAsync()`(见 §6),检测并删除因上次崩溃残留的 `.tmp` 文件。
|
||
|
||
---
|
||
|
||
## 9. 版本迁移(SaveMigrator)
|
||
|
||
### 版本历史
|
||
|
||
| 版本 | 变更内容 |
|
||
|------|---------|
|
||
| `1.0` | 初始版本 |
|
||
| `1.1` | 新增 `player.shield`、settings 字段 |
|
||
| `1.2` | 新增 `player.forms`、`equipment.upgradedCharmIds`、`abilities.wallGrab` |
|
||
| `1.3` | 新增 `world.switches` |
|
||
| `2.0` | **大版本重构**:Meta 分离顶层;playtime `float → double`;新增 quests/achievements/tools/eventChains/stats/dlc 桶;settings 迁移至独立 GlobalSettings 文件 |
|
||
|
||
### SaveMigrator.cs
|
||
|
||
```csharp
|
||
namespace BaseGames.Core.Save
|
||
{
|
||
public static class SaveMigrator
|
||
{
|
||
/// <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)
|
||
|
||
```csharp
|
||
namespace BaseGames.Core.Save
|
||
{
|
||
public static class SaveValidator
|
||
{
|
||
public readonly struct Result
|
||
{
|
||
public bool IsValid { get; init; }
|
||
public string? Error { get; init; }
|
||
}
|
||
|
||
public static Result Validate(SaveData data)
|
||
{
|
||
if (data == null) return Fail("SaveData 为 null");
|
||
|
||
// ── Meta ──────────────────────────────────────────────────────
|
||
if (string.IsNullOrEmpty(data.Meta.Version)) return Fail("meta.version 为空");
|
||
if (data.Meta.Playtime < 0) return Fail("playtime 为负数");
|
||
if (data.Meta.NgPlusCount < 0) return Fail("ngPlusCount < 0");
|
||
|
||
// ── Player ────────────────────────────────────────────────────
|
||
var p = data.Player;
|
||
if (string.IsNullOrEmpty(p.Scene)) return Fail("player.scene 为空");
|
||
if (p.MaxHP <= 0) return Fail("player.maxHP <= 0");
|
||
if (p.CurrentHP < 0 || p.CurrentHP > p.MaxHP) return Fail($"HP 越界: {p.CurrentHP}/{p.MaxHP}");
|
||
if (float.IsNaN(p.PosX) || float.IsNaN(p.PosY)) return Fail("玩家位置含 NaN");
|
||
if (p.CurrentGeo < 0) return Fail("currentGeo 为负数");
|
||
if (p.Shield != null && p.Shield.CurrentHP < 0) return Fail("shield.currentHP < 0");
|
||
if (p.Forms != null && string.IsNullOrEmpty(p.Forms.ActiveFormId))
|
||
return Fail("forms.activeFormId 为空");
|
||
|
||
// ── Equipment ─────────────────────────────────────────────────
|
||
var e = data.Equipment;
|
||
if (e.MaxNotches <= 0) return Fail("equipment.maxNotches <= 0");
|
||
if (e.NotchesUsed < 0 || e.NotchesUsed > e.MaxNotches)
|
||
return Fail($"Notch 越界: {e.NotchesUsed}/{e.MaxNotches}");
|
||
|
||
// ── Stats ─────────────────────────────────────────────────────
|
||
var s = data.Stats;
|
||
if (s.EnemyKills < 0 || s.Deaths < 0 || s.ParrySuccessCount < 0)
|
||
return Fail("stats 包含负数计数");
|
||
|
||
return new Result { IsValid = true };
|
||
}
|
||
|
||
static Result Fail(string error)
|
||
{
|
||
Debug.LogWarning($"[SaveValidator] 校验失败: {error}");
|
||
return new Result { IsValid = false, Error = error };
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 存档完整性保护(Checksum + Backup)
|
||
|
||
### SHA-256 Checksum
|
||
|
||
```csharp
|
||
public static class SaveChecksum
|
||
{
|
||
public static string SerializeWithChecksum(SaveData data)
|
||
{
|
||
// 第一次序列化:checksum 置空,生成用于计算 hash 的 JSON
|
||
data.Meta.Checksum = "";
|
||
var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore };
|
||
string json = JsonConvert.SerializeObject(data, Formatting.None, settings);
|
||
|
||
// 计算并回填 checksum
|
||
data.Meta.Checksum = Compute(json);
|
||
|
||
// 第二次序列化:含 checksum 的最终 JSON
|
||
return JsonConvert.SerializeObject(data, Formatting.None, settings);
|
||
}
|
||
|
||
public static bool Verify(string rawJson)
|
||
{
|
||
try
|
||
{
|
||
var data = JsonConvert.DeserializeObject<SaveData>(rawJson);
|
||
if (data == null) return false;
|
||
string stored = data.Meta.Checksum;
|
||
data.Meta.Checksum = "";
|
||
var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore };
|
||
string reJson = JsonConvert.SerializeObject(data, Formatting.None, settings);
|
||
return string.Equals(stored, Compute(reJson), StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
catch { return false; }
|
||
}
|
||
|
||
static string Compute(string json)
|
||
{
|
||
using var sha = SHA256.Create();
|
||
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(json));
|
||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||
}
|
||
}
|
||
```
|
||
|
||
### 文件结构与恢复策略
|
||
|
||
| 文件 | 说明 |
|
||
|------|------|
|
||
| `slot_{n}.json` | 当前存档(每次存档点原子覆盖)|
|
||
| `slot_{n}_backup.json` | 上一次成功写入的副本(自动覆盖)|
|
||
| `slot_{n}.json.dead` | 删除时的保留拷贝(供调试,不影响游戏)|
|
||
| `slot_{n}.json.tmp` | 写入中间态(正常情况不会存在;启动时自动清理)|
|
||
|
||
**恢复优先级**:主存档 → 备份存档 → 提示开始新游戏
|
||
|
||
---
|
||
|
||
## 12. 设置数据分离(GlobalSettings)
|
||
|
||
设置存储在独立文件,**不属于任何存档槽**,删槽/覆盖槽不影响玩家的设置:
|
||
|
||
```
|
||
{persistentDataPath}/settings.json ← 全局用户设置
|
||
{persistentDataPath}/saves/slot_0.json ← 存档槽(不含设置)
|
||
{persistentDataPath}/saves/slot_1.json
|
||
{persistentDataPath}/saves/slot_2.json
|
||
```
|
||
|
||
```csharp
|
||
[Serializable]
|
||
public class GlobalSettings
|
||
{
|
||
public string Version = "1.0";
|
||
|
||
public AudioSettings Audio = new();
|
||
public GraphicsSettings Graphics = new();
|
||
public InputSettings Input = new();
|
||
public AccessSettings Access = new();
|
||
public GameplaySettings Gameplay = new();
|
||
|
||
[Serializable]
|
||
public class AudioSettings
|
||
{
|
||
public float MasterVolume = 1f;
|
||
public float BgmVolume = 0.8f;
|
||
public float SfxVolume = 1f;
|
||
}
|
||
|
||
[Serializable]
|
||
public class GraphicsSettings
|
||
{
|
||
public int ResolutionIndex = -1; // -1 = 系统默认
|
||
public bool IsFullscreen = true;
|
||
public int TargetFps = 60;
|
||
public bool VSync = false;
|
||
public float BrightnessBias = 0f;
|
||
}
|
||
|
||
[Serializable]
|
||
public class InputSettings
|
||
{
|
||
/// <summary>InputSystem 的重绑定 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 游戏代码零修改:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// DLC 在游戏启动时向 SaveManager.RegisterDlcExtension() 注册。
|
||
/// SaveManager 序列化时将 DLC 数据存入 data.Dlc[DlcId];
|
||
/// 反序列化时调用 Deserialize 还原(仅在 DLC 已安装时)。
|
||
/// 未安装 DLC 时,旧存档中的 DLC 数据通过 JsonExtensionData 静默保留。
|
||
/// </summary>
|
||
public interface IDlcSaveExtension
|
||
{
|
||
string DlcId { get; } // 格式:DLC_{Name},如 "DLC_VoidWings"
|
||
string SchemaVersion { get; } // DLC 自己的 Schema 版本
|
||
|
||
JObject Serialize(SaveData saveData); // 运行时状态 → JObject
|
||
void Deserialize(JObject data, SaveData saveData); // JObject → 运行时状态还原
|
||
void MigrateIfNeeded(JObject data); // DLC 自行处理版本升级
|
||
void OnNgPlusReset(SaveData old, SaveData next, int round); // NG+ 重置回调
|
||
}
|
||
```
|
||
|
||
**DLC 版本字段**:DLC 数据中必须包含 `"version"` 字段,由 `MigrateIfNeeded` 检查并升级:
|
||
|
||
```json
|
||
"dlc": {
|
||
"DLC_VoidWings": {
|
||
"version": "1.0",
|
||
...
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 14. NG+ 重置 Schema
|
||
|
||
```csharp
|
||
public static class NgPlusResetter
|
||
{
|
||
static IReadOnlyList<IDlcSaveExtension> _dlcExtensions;
|
||
|
||
public static void SetDlcExtensions(IReadOnlyList<IDlcSaveExtension> exts)
|
||
=> _dlcExtensions = exts;
|
||
|
||
/// <summary>
|
||
/// 将已通关存档转换为 NG+ 存档。
|
||
/// 返回新的 SaveData,携带允许保留的字段,其余重置。
|
||
/// </summary>
|
||
public static SaveData Reset(SaveData cleared, int nextRound)
|
||
{
|
||
var ng = new SaveData();
|
||
ng.Meta.NgPlusCount = nextRound;
|
||
ng.Meta.Playtime = cleared.Meta.Playtime; // 累加游玩时长
|
||
|
||
// ── 携带 ──────────────────────────────────────────────────────────
|
||
ng.Equipment.OwnedCharmIds = new(cleared.Equipment.OwnedCharmIds);
|
||
ng.Equipment.UpgradedCharmIds = new(cleared.Equipment.UpgradedCharmIds);
|
||
ng.Equipment.MaxNotches = cleared.Equipment.MaxNotches;
|
||
// equippedCharmIds / notchesUsed → 重置,到存档点重新装备
|
||
|
||
ng.Player.Forms.UnlockedFormIds = new(cleared.Player.Forms.UnlockedFormIds);
|
||
ng.Player.Forms.ActiveFormId = cleared.Player.Forms.ActiveFormId;
|
||
|
||
ng.Tools.OwnedToolIds = new(cleared.Tools.OwnedToolIds);
|
||
|
||
ng.Achievements = cleared.Achievements; // 完整保留
|
||
ng.Stats = cleared.Stats; // 累加统计
|
||
|
||
// ── NG+ 标志 ─────────────────────────────────────────────────────
|
||
ng.NgPlus = new NgPlusSaveData { Round = nextRound };
|
||
|
||
// ── DLC 回调 ─────────────────────────────────────────────────────
|
||
if (_dlcExtensions != null)
|
||
foreach (var ext in _dlcExtensions)
|
||
ext.OnNgPlusReset(cleared, ng, nextRound);
|
||
|
||
// 未列出的字段(player.pos/hp/geo/abilities、world、map、quests、
|
||
// eventChains、shops)维持新建默认值,即视为重置。
|
||
return ng;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 携带 vs 重置一览表
|
||
|
||
| 数据 | 携带至 NG+ | 说明 |
|
||
|------|:--------:|------|
|
||
| 已拥有护符 | ✅ | 体现收集成果 |
|
||
| 护符升级状态 | ✅ | |
|
||
| Notch 槽最大值 | ✅ | |
|
||
| 已装备护符 | ❌ | 需重新装备 |
|
||
| 形态解锁 | ✅ | NG+ 保留形态;剧情仍播放获取动画(剧情标志控制)|
|
||
| 技能(随形态)| ✅ | |
|
||
| 工具(拥有)| ✅ | |
|
||
| 成就 | ✅ | |
|
||
| 游玩时长 | ✅(累加)| |
|
||
| 统计数据 | ✅(累加)| |
|
||
| 货币 | ❌ | 从 0 开始 |
|
||
| HP 上限 | ❌ | 从 1 节重新升级 |
|
||
| 能力解锁 | ❌ | 双跳/冲刺等需重新获取 |
|
||
| 世界状态 | ❌ | Boss 复活,大门重新关闭 |
|
||
| 地图探索 | ❌ | |
|
||
| 任务 / 事件链 | ❌ | |
|
||
| 商店购买记录 | ❌ | |
|
||
|
||
---
|
||
|
||
## 15. 多存档槽管理
|
||
|
||
| 槽 | 主文件 | 备份文件 |
|
||
|----|--------|---------|
|
||
| 0 | `slot_0.json` | `slot_0_backup.json` |
|
||
| 1 | `slot_1.json` | `slot_1_backup.json` |
|
||
| 2 | `slot_2.json` | `slot_2_backup.json` |
|
||
|
||
**最大槽数**:`SaveManager.MaxSlots = 3`(架构无限制,常量可调整)。
|
||
|
||
**存档槽选择界面**(利用 `SaveSlotSummary` 摘要,不反序列化完整存档;界面详细设计见 [10_UISystem §16](./10_UISystem.md#16-存档点交互--存档槽选择界面)):
|
||
|
||
```
|
||
┌─ 选择存档 ────────────────────────────────────────────────────────┐
|
||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||
│ │ [存档点背景图 SP_BG_FungalWastes_03] │ │
|
||
│ │ 古菌荒废·黄昏壁炉 ♥♥♥♥○ 游玩 2h 12m [NG+1] │ │
|
||
│ │ 最后存档:2026-04-28 22:30 │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||
│ │ [存档点背景图 SP_BG_Crossroads_01] │ │
|
||
│ │ 被遗忘的十字路口·路口壁炉 ♥♥♥○○ 游玩 0h 45m │ │
|
||
│ │ 最后存档:2026-04-27 20:15 │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
│ ── (空槽) [开始新游戏] │
|
||
└───────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**摘要字段一览**(`SaveSlotSummary` 在选择界面展示的全部数据):
|
||
|
||
| 字段 | 来源 | 用途 |
|
||
|------|------|------|
|
||
| `meta.savePointBgImageKey` | `SavePointSO.bgImageKey` | 槽卡背景图(Addressable 异步加载)|
|
||
| `meta.savePointLocKey` | `SavePointSO.locKey` | 存档点显示名称(查本地化表)|
|
||
| `meta.lastSaved` | 存档时系统时间(UTC)| 格式化为本地时区显示 |
|
||
| `meta.playtime` | 累加游玩时长(秒)| 格式化为 `Xh Xm` 显示 |
|
||
| `meta.ngPlusCount` | 0 = 非 NG+ | 显示 `[NG+N]` 标记 |
|
||
| `player.currentHP / maxHP` | 玩家当前状态 | 心形容器列 |
|
||
|
||
---
|
||
|
||
## 16. 云存档冲突解决
|
||
|
||
```csharp
|
||
public static class CloudSaveConflictResolver
|
||
{
|
||
public enum Resolution { UseLocal, UseCloud, AskUser }
|
||
|
||
/// <summary>
|
||
/// 自动解决策略(后台/非交互场景)。
|
||
/// 规则:时差 < 60 秒 → 保留本地;云端更新 → 使用云端;本地更新 → 使用本地。
|
||
/// </summary>
|
||
public static Resolution ResolveAutomatic(
|
||
DateTimeOffset localTimestamp,
|
||
DateTimeOffset cloudTimestamp)
|
||
{
|
||
double diff = Math.Abs((cloudTimestamp - localTimestamp).TotalSeconds);
|
||
if (diff < 60) return Resolution.UseLocal;
|
||
return cloudTimestamp > localTimestamp ? Resolution.UseCloud : Resolution.UseLocal;
|
||
}
|
||
|
||
/// <summary>无法自动解决时弹 UI,由玩家选择</summary>
|
||
public static Task<Resolution> ResolveInteractiveAsync(
|
||
SaveSlotSummary local, SaveSlotSummary cloud)
|
||
=> ConflictDialogUI.ShowAsync(local, cloud);
|
||
}
|
||
```
|
||
|
||
**冲突检测时机**:游戏启动时,`ISaveStorage.SupportsCloudSync == true` 的后端在 `LoadAsync` 之前调用 `GetCloudTimestampAsync` 检查云端时间戳。
|
||
|
||
---
|
||
|
||
## 17. 性能预算
|
||
|
||
| 指标 | 目标 | 测量方法 |
|
||
|------|------|---------|
|
||
| 存档文件大小(100% 收集)| < 128 KB | 实测 JSON 序列化大小 |
|
||
| 主线程序列化+校验耗时 | < 0.5 ms | Profiler 采样(深拷贝 + Validate)|
|
||
| 异步写入总耗时 | < 200 ms | Task.Run 内部,不阻塞主线程 |
|
||
| 加载反序列化耗时 | < 50 ms | 线程池,不影响帧率 |
|
||
| WriteDirty 主线程开销 | < 0.1 ms | 仅标记 Dirty,不做 I/O |
|
||
| EmergencySave 超时阈值 | 500 ms | 超时放弃,防止阻塞退出流程 |
|
||
|
||
**文件大小控制策略**:
|
||
|
||
1. `NullValueHandling.Ignore`:null 字段不序列化
|
||
2. 简单集合优先 `List` 而非 `Dictionary`(序列化更小)
|
||
3. `stats.distanceTraveled` 用 `int`(米级精度)
|
||
4. 枚举用 `string`(可读性);需压缩时改 `int` 可减 50–70%
|
||
5. `discoveredRooms` 存房间 ID 整数列表,不存完整房间数据
|
||
|
||
---
|
||
|
||
## 18. 各子系统贡献字段索引
|
||
|
||
| 字段路径 | 来源文档 |
|
||
|---------|---------|
|
||
| `player.currentHP / maxHP` | [03_PlayerSystem](./03_PlayerSystem.md) |
|
||
| `player.currentGeo / lifetimeGeo` | [03_PlayerSystem](./03_PlayerSystem.md) |
|
||
| `player.abilities.*` | [03_PlayerSystem](./03_PlayerSystem.md) §能力解锁 |
|
||
| `player.shield.*` | [30_ShieldMechanicsSystem](./30_ShieldMechanicsSystem.md) §9 |
|
||
| `player.forms.*` | [21_SpellSystem](./21_SpellSystem.md) §7 |
|
||
| `player.deathPos` | [08_WorldSystem](./08_WorldSystem.md) §5 |
|
||
| `equipment.*` | [17_EquipmentSystem](./17_EquipmentSystem.md) §9 |
|
||
| `world.visitedScenes / activatedSavePoints` | [08_WorldSystem](./08_WorldSystem.md) §5 |
|
||
| `world.collectibles.*` | [08_WorldSystem](./08_WorldSystem.md) §5 |
|
||
| `world.defeatedBossIds` | [08_WorldSystem](./08_WorldSystem.md) §5 |
|
||
| `world.switches` | [08_WorldSystem](./08_WorldSystem.md) §5 |
|
||
| `world.revealedFalseWalls` | [08_WorldSystem](./08_WorldSystem.md) §9.8 |
|
||
| `world.npcRelations` | [38_QuestSystem](./38_QuestSystem.md) §7(RelationshipManager)|
|
||
| `map.*` | [16_MapSystem](./16_MapSystem.md) §7 |
|
||
| `quests.*` | [38_QuestSystem](./38_QuestSystem.md) §9 |
|
||
| `achievements.*` | [32_AchievementSystem](./32_AchievementSystem.md) §7 |
|
||
| `tools.*` | [37_ToolSystem](./37_ToolSystem.md) §SaveData |
|
||
| `challengeRooms.*` | [39_ChallengeRoomSystem](./39_ChallengeRoomSystem.md) §SaveData |
|
||
| `eventChains.*` | [34_EventChainSystem](./34_EventChainSystem.md) §SaveData |
|
||
| `shops.*` | [28_ShopSystem](./28_ShopSystem.md) §9 |
|
||
| `stats.*` | 本文档 §3(统计桶,无专属文档)|
|
||
| `ngplus.*` | [52_NewGamePlus](./52_NewGamePlus.md)(待创建)|
|
||
| `dlc.*` | 各 DLC 包(`IDlcSaveExtension`)|
|
||
| `meta._checksum` | 本文档 §11 |
|
||
| `GlobalSettings.audio / graphics` | UI 设置菜单 |
|
||
| `GlobalSettings.input.bindingOverrides` | [25_InputRebindingUI](./25_InputRebindingUI.md) |
|
||
| `GlobalSettings.gameplay.difficulty` | [29_DifficultyModesGuide](./29_DifficultyModesGuide.md) §8 |
|
||
|
||
---
|
||
|
||
## 19. 编辑器工具
|
||
|
||
### SaveDataEditor 调试窗口
|
||
|
||
`Tools → Zeling → 存档调试器`:
|
||
|
||
```
|
||
┌─ 存档调试器 ─────────────────────────────────────────────────────┐
|
||
│ 存档槽: [0 ▼] [加载] [保存] [删除(保留备份)] [验证] │
|
||
│ ───────────────────────────────────────────────────────────── │
|
||
│ 版本: 2.0 | 游玩: 2h 12m | 最后存档: 2026-04-28 14:30 │
|
||
│ 存档点: SavePoint_FungalWastes_03 | NG+: 0 │
|
||
│ │
|
||
│ [Player ▼] [Equipment ▼] [World ▼] [Quests ▼] [Stats ▼] │
|
||
│ │
|
||
│ Raw JSON: │
|
||
│ ┌────────────────────────────────────────────────────────┐ │
|
||
│ │ { "meta": { ... }, "player": { ... }, ... } │ │
|
||
│ └────────────────────────────────────────────────────────┘ │
|
||
│ [导出 JSON] [从文件导入] [修复 Checksum] [强制迁移至 2.0] │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**功能清单**:
|
||
|
||
| 功能 | 说明 |
|
||
|------|------|
|
||
| 加载/保存/删除槽 | 调用 SaveManager 正式流程,验证 Checksum |
|
||
| 字段校验 | 调用 SaveValidator,高亮失败字段 |
|
||
| 版本迁移 | 强制运行 SaveMigrator 至最新版本 |
|
||
| 导出 / 导入 JSON | QA 复现问题存档 |
|
||
| 修复 Checksum | 手动编辑 JSON 后重新计算 Checksum |
|
||
| 清零统计数据 | 调试专用,仅在 Editor + Development Build 可用 |
|
||
|
||
### 存档 Diff 工具
|
||
|
||
`Tools → Zeling → 存档 Diff`:选择两份存档(或同一槽的主档/备档),逐字段对比差异。用于调试 NG+ 迁移与版本升级结果。
|
||
|
||
---
|
||
|
||
*文档版本 2.0 · 2026-04-28(完整重写,取代 v1.x 所有版本)*
|