1124 lines
43 KiB
Markdown
1124 lines
43 KiB
Markdown
# 12 · 存档模块
|
||
|
||
> **命名空间** `BaseGames.Core.Save`
|
||
> **程序集** `BaseGames.Core.Save`
|
||
> **路径** `Assets/Scripts/Core/Save/`
|
||
> **依赖** `Newtonsoft.Json`(`com.unity.nuget.newtonsoft-json`)
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [SaveData C# 数据结构](#1-savedata-c-数据结构)
|
||
2. [ISaveStorage 接口](#2-isavestorage-接口)
|
||
3. [ISaveable 接口](#3-isaveable-接口)
|
||
4. [SaveManager](#4-savemanager)
|
||
5. [SaveMigrator](#5-savemigrator)
|
||
6. [保存流程(详细时序)](#6-保存流程)
|
||
7. [加载流程(详细时序)](#7-加载流程)
|
||
8. [存档路径与文件规范](#8-存档路径与文件规范)
|
||
|
||
---
|
||
|
||
## 1. SaveData C# 数据结构
|
||
|
||
所有数据类使用 `[Serializable]`,由 Newtonsoft.Json 序列化为 JSON。
|
||
|
||
```csharp
|
||
namespace BaseGames.Core.Save
|
||
{
|
||
// ─── 顶层 ──────────────────────────────────────────────────
|
||
[Serializable]
|
||
public class SaveData
|
||
{
|
||
[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 QuestSaveData Quests = new();
|
||
public AchievementSaveData Achievements = new();
|
||
public ToolsSaveData Tools = new();
|
||
public ChallengeRoomsSaveData ChallengeRooms = new();
|
||
public EventChainsSaveData EventChains = new();
|
||
public ShopsSaveData Shops = new();
|
||
public StatsSaveData Stats = new();
|
||
public NGPlusSaveData NGPlus = null; // null = 非 NG+ 模式
|
||
public Dictionary<string, JObject> DLC = new();
|
||
}
|
||
|
||
// ─── Meta ───────────────────────────────────────────────────
|
||
[Serializable]
|
||
public class SaveMeta
|
||
{
|
||
public string Version = "2.1"; // 2.1: AbilityFlags uint 替换 Dictionary<string,bool>
|
||
public int SlotIndex;
|
||
public string LastSaved; // ISO 8601
|
||
public float Playtime;
|
||
public string SavePointId;
|
||
public int NGPlusCount;
|
||
public string Checksum; // SHA256 of 序列化后 JSON(不含 checksum 字段)
|
||
}
|
||
|
||
// ─── Player ─────────────────────────────────────────────────
|
||
[Serializable]
|
||
public class PlayerSaveData
|
||
{
|
||
public float PosX, PosY;
|
||
public string Scene;
|
||
public int CurrentHP, MaxHP;
|
||
public int CurrentGeo, LifetimeGeo;
|
||
|
||
// 能力解锁位掩码(AbilityType [Flags] uint bitmask,见 09_ProgressionModule §1)
|
||
// 存档版本 <2.1 的旧 Dictionary<string,bool> Abilities 由 SaveMigrator.MigrateV2ToV21 自动转换
|
||
public uint AbilityFlags = 0;
|
||
|
||
// 形态
|
||
public string ActiveFormId;
|
||
public List<string> UnlockedFormIds = new();
|
||
|
||
// 死亡遗骸
|
||
public DeathShadeSaveData DeathShade;
|
||
|
||
// 护盾状态(见 20_ShieldModule §9)
|
||
// -1 = 满护盾(默认),>=0 = 当前耐久值
|
||
public int ShieldHP = -1;
|
||
public bool ShieldIsBroken = false;
|
||
}
|
||
|
||
[Serializable]
|
||
public class DeathShadeSaveData
|
||
{
|
||
public float PosX, PosY;
|
||
public string SceneId;
|
||
public int GeoAmount;
|
||
}
|
||
|
||
// ─── Equipment ──────────────────────────────────────────────
|
||
[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();
|
||
// 注:工具槽数据在独立的 ToolsSaveData(顶层 SaveData.Tools),不在此处重复
|
||
}
|
||
|
||
// ─── World ──────────────────────────────────────────────────
|
||
[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 List<string> CollectedIds = new(); // Collectibles
|
||
public Dictionary<string, bool> Switches = new(); // Levers/Buttons
|
||
public Dictionary<string, int> NpcRelations = new();
|
||
public HashSet<string> ChallengeFirstClears = new(); // 已首次通关的挑战 ID
|
||
}
|
||
|
||
// ─── Map ────────────────────────────────────────────────────
|
||
[Serializable]
|
||
public class MapSaveData
|
||
{
|
||
// key = 场景名,value = 已探索房间 index 列表
|
||
public Dictionary<string, List<int>> DiscoveredRooms = new();
|
||
public Dictionary<string, bool> MapPurchased = new();
|
||
}
|
||
|
||
// ─── Quests ─────────────────────────────────────────────────
|
||
[Serializable]
|
||
public class QuestSaveData
|
||
{
|
||
public Dictionary<string, QuestState> QuestStates = new();
|
||
public List<string> AvailableQuestIds = new();
|
||
}
|
||
|
||
[Serializable]
|
||
public class QuestState
|
||
{
|
||
public string Status; // "NotStarted"|"Active"|"Completed"|"Failed"
|
||
public int ObjectiveIndex;
|
||
public List<int> ProgressCounts = new();
|
||
public string GiverNpcId;
|
||
}
|
||
|
||
// ─── Achievements ───────────────────────────────────────────
|
||
[Serializable]
|
||
public class AchievementSaveData
|
||
{
|
||
public List<string> Unlocked = new();
|
||
public Dictionary<string, AchievementProgress> Progress = new();
|
||
}
|
||
|
||
[Serializable]
|
||
public class AchievementProgress
|
||
{
|
||
public int Count;
|
||
public float Percent;
|
||
}
|
||
|
||
// ─── Stats ──────────────────────────────────────────────────
|
||
[Serializable]
|
||
public class StatsSaveData
|
||
{
|
||
public int EnemyKills, Deaths, ParrySuccess, ParryFail;
|
||
public int GeoEarned, GeoLost;
|
||
public float DistanceTraveled;
|
||
public int SaveCount;
|
||
public Dictionary<string, int> SkillUseCounts = new();
|
||
public Dictionary<string, int> DeathsByBoss = new();
|
||
}
|
||
|
||
// ─── ToolsSaveData ───────────────────────────────────────────
|
||
[Serializable]
|
||
public class ToolsSaveData
|
||
{
|
||
public string ToolSlot0;
|
||
public string ToolSlot1;
|
||
public List<string> OwnedToolIds = new();
|
||
public Dictionary<string, JObject> ToolStates = new();
|
||
}
|
||
|
||
// ─── ChallengeRoomsSaveData ──────────────────────────────────
|
||
[Serializable]
|
||
public class ChallengeRoomsSaveData
|
||
{
|
||
// key = 挑战房间 ID
|
||
public Dictionary<string, ChallengeRoomRecord> Records = new();
|
||
}
|
||
|
||
[Serializable]
|
||
public class ChallengeRoomRecord
|
||
{
|
||
public int BestScore;
|
||
public float BestTime;
|
||
public string BestRank; // "S"/"A"/"B"/"C"
|
||
public int CompletionCount;
|
||
}
|
||
|
||
// ─── EventChainsSaveData ─────────────────────────────────────
|
||
[Serializable]
|
||
public class EventChainsSaveData
|
||
{
|
||
// key = EventChain ID,value = 当前阶段名称(如 "Step2" / "Completed")
|
||
public Dictionary<string, string> ChainStates = new();
|
||
// 世界状态标志(跨系统布尔标志,如 "ForestBossDefeated")
|
||
public Dictionary<string, bool> WorldFlags = new();
|
||
}
|
||
|
||
// ─── ShopsSaveData ───────────────────────────────────────────
|
||
[Serializable]
|
||
public class ShopsSaveData
|
||
{
|
||
// key = 商店 ID(如 "Shop_Forest_Merchant")
|
||
public Dictionary<string, ShopRecord> ShopRecords = new();
|
||
}
|
||
|
||
[Serializable]
|
||
public class ShopRecord
|
||
{
|
||
public List<string> SoldUniqueItems = new(); // 已售出的唯一物品 ID
|
||
public Dictionary<string, int> PurchaseCounts = new(); // 消耗品购买次数
|
||
}
|
||
|
||
// ─── NGPlusSaveData ──────────────────────────────────────────
|
||
[Serializable]
|
||
public class NGPlusSaveData
|
||
{
|
||
public int NGPlusCount; // NG+ 轮次(1 = 第一次 NG+)
|
||
public bool SteelSoulMode; // 钢铁之魂(死亡即删档)
|
||
public Dictionary<string, bool> NGPlusFlags = new(); // 专属标志
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. ISaveStorage 接口
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Core/Save/ISaveStorage.cs
|
||
public interface ISaveStorage
|
||
{
|
||
Task WriteAsync(int slotIndex, string json);
|
||
Task<string> ReadAsync(int slotIndex);
|
||
Task DeleteAsync(int slotIndex);
|
||
bool Exists(int slotIndex);
|
||
IEnumerable<int> GetExistingSlots();
|
||
}
|
||
|
||
// 本地文件实现(PC / Console)
|
||
public class LocalFileStorage : ISaveStorage
|
||
{
|
||
private readonly string _saveDir; // Application.persistentDataPath/saves/
|
||
|
||
public LocalFileStorage()
|
||
{
|
||
_saveDir = Path.Combine(Application.persistentDataPath, "saves");
|
||
Directory.CreateDirectory(_saveDir);
|
||
}
|
||
|
||
public async Task WriteAsync(int slotIndex, string json)
|
||
{
|
||
var path = GetPath(slotIndex);
|
||
await File.WriteAllTextAsync(path, json);
|
||
}
|
||
|
||
public async Task<string> ReadAsync(int slotIndex)
|
||
=> await File.ReadAllTextAsync(GetPath(slotIndex));
|
||
|
||
public Task DeleteAsync(int slotIndex)
|
||
{
|
||
File.Delete(GetPath(slotIndex));
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public bool Exists(int slotIndex) => File.Exists(GetPath(slotIndex));
|
||
public IEnumerable<int> GetExistingSlots()
|
||
{
|
||
for (int i = 0; i < 3; i++)
|
||
if (Exists(i)) yield return i;
|
||
}
|
||
|
||
private string GetPath(int slot) => Path.Combine(_saveDir, $"save_{slot}.json");
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. ISaveable 接口
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Core/Save/ISaveable.cs
|
||
// 各系统组件实现,向 SaveManager 提供/接受自己的存档数据
|
||
public interface ISaveable
|
||
{
|
||
// 提取当前运行时状态 → 存入对应 SaveData 子结构
|
||
void OnSave(SaveData saveData);
|
||
|
||
// 从 saveData 恢复运行时状态
|
||
void OnLoad(SaveData saveData);
|
||
}
|
||
|
||
// 示例实现(PlayerStats)
|
||
// public class PlayerStats : MonoBehaviour, ISaveable
|
||
// {
|
||
// public void OnSave(SaveData d) => d.Player = GetSaveData();
|
||
// public void OnLoad(SaveData d) => LoadSaveData(d.Player);
|
||
// }
|
||
```
|
||
|
||
---
|
||
|
||
## 4. SaveManager
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Core/Save/SaveManager.cs
|
||
[DefaultExecutionOrder(-900)]
|
||
public class SaveManager : MonoBehaviour
|
||
{
|
||
[SerializeField] private ISaveStorage _storage; // Inspector 绑定实现类型
|
||
[SerializeField] private BoolEventChannelSO _onSaveIndicatorVisible; // → EVT_SaveIndicatorVisible(HUDController 订阅)
|
||
|
||
// ── Singleton ────────────────────────────────────────
|
||
public static SaveManager Instance { get; private set; }
|
||
|
||
// 最低兼容版本(SaveValidator 使用)
|
||
public const string MinCompatibleVersion = "1.0";
|
||
|
||
private void Awake()
|
||
{
|
||
if (Instance != null) { Destroy(gameObject); return; }
|
||
Instance = this;
|
||
}
|
||
|
||
// 注册的所有 ISaveable(各系统在 OnEnable 时注册)
|
||
private readonly List<ISaveable> _saveables = new();
|
||
|
||
// 当前加载的存档数据(运行时缓存)
|
||
private SaveData _current;
|
||
private int _currentSlot = 0;
|
||
|
||
// 用于 DeathScreen 的 Respawn 信息
|
||
public static string LastCheckpointScene { get; private set; }
|
||
public static string LastCheckpointSpawnId { get; private set; }
|
||
|
||
public void Register(ISaveable saveable) => _saveables.Add(saveable);
|
||
public void Unregister(ISaveable saveable) => _saveables.Remove(saveable);
|
||
|
||
// 存档(由 EVT_SavePointActivated 触发)
|
||
public async Task SaveAsync(int slot = -1)
|
||
{
|
||
int targetSlot = slot < 0 ? _currentSlot : slot;
|
||
_current ??= new SaveData();
|
||
|
||
foreach (var s in _saveables) s.OnSave(_current);
|
||
|
||
_current.Meta.LastSaved = DateTime.UtcNow.ToString("o");
|
||
_current.Meta.SlotIndex = targetSlot;
|
||
_current.Meta.SaveCount++;
|
||
|
||
string json = JsonConvert.SerializeObject(_current, Formatting.Indented);
|
||
_current.Meta.Checksum = ComputeChecksum(json);
|
||
json = JsonConvert.SerializeObject(_current, Formatting.Indented); // 含 checksum
|
||
|
||
await _storage.WriteAsync(targetSlot, json);
|
||
|
||
LastCheckpointScene = _current.Player.Scene;
|
||
LastCheckpointSpawnId = _current.Meta.SavePointId;
|
||
}
|
||
|
||
// 读取存档(游戏启动 / 重生)
|
||
public async Task<bool> LoadAsync(int slot)
|
||
{
|
||
if (!_storage.Exists(slot)) return false;
|
||
|
||
string json = await _storage.ReadAsync(slot);
|
||
var loaded = JsonConvert.DeserializeObject<SaveData>(json);
|
||
|
||
if (!ValidateChecksum(loaded, json)) return false; // 校验失败
|
||
|
||
loaded = SaveMigrator.Migrate(loaded); // 版本迁移
|
||
|
||
_current = loaded;
|
||
_currentSlot = slot;
|
||
|
||
foreach (var s in _saveables) s.OnLoad(_current);
|
||
|
||
LastCheckpointScene = _current.Player.Scene;
|
||
LastCheckpointSpawnId = _current.Meta.SavePointId;
|
||
return true;
|
||
}
|
||
|
||
public bool SlotExists(int slot) => _storage.Exists(slot);
|
||
public IEnumerable<int> GetExistingSlots() => _storage.GetExistingSlots();
|
||
|
||
// ── 挑战房间快速存读档 ────────────────────────────────────────────
|
||
private const int QuickSaveSlot = 98; // 专用快速存档槽(不覆盖普通存档)
|
||
|
||
/// <summary>挑战房间开始时调用,保存挑战入口状态(不阻塞)。</summary>
|
||
public void QuickSave() => _ = SaveAsync(QuickSaveSlot);
|
||
|
||
/// <summary>挑战失败时调用,读取快速存档回挑战入口(不阻塞)。</summary>
|
||
public void QuickLoad() => _ = LoadAsync(QuickSaveSlot);
|
||
|
||
/// <summary>
|
||
/// 判断指定挑战是否为首次通关,并将其标记为已通关(幂等:重复调用返回 false)。
|
||
/// 首次调用返回 true(触发首通奖励),后续调用返回 false(触发重复奖励)。
|
||
/// </summary>
|
||
public bool IsFirstClear(string challengeId)
|
||
{
|
||
if (_current == null) return false;
|
||
if (_current.World.ChallengeFirstClears.Contains(challengeId)) return false;
|
||
_current.World.ChallengeFirstClears.Add(challengeId);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断指定 Boss 是否已被击败(查询 World.DefeatedBossIds 列表)。
|
||
/// 由 ChallengeRoomTrigger 解锁条件校验使用。
|
||
/// </summary>
|
||
public bool IsBossDefeated(string bossId)
|
||
{
|
||
if (_current == null) return false;
|
||
return _current.World.DefeatedBossIds.Contains(bossId);
|
||
}
|
||
|
||
// ── EventChain / WorldFlag 集成(参见 14_NarrativeModule §6.1)────────────────────
|
||
|
||
/// <summary>返回所有已完成的 EventChain ID。</summary>
|
||
public IEnumerable<string> GetCompletedChains()
|
||
=> _current?.EventChains.ChainStates.Keys ?? Enumerable.Empty<string>();
|
||
|
||
/// <summary>将指定 EventChain 标记为 "Completed"。</summary>
|
||
public void SetChainCompleted(string chainId)
|
||
{
|
||
_current ??= new SaveData();
|
||
_current.EventChains.ChainStates[chainId] = "Completed";
|
||
}
|
||
|
||
/// <summary>读取世界状态布尔标志(EventChains.WorldFlags)。</summary>
|
||
public bool GetFlag(string flagId)
|
||
=> _current?.EventChains.WorldFlags.TryGetValue(flagId, out var v) == true && v;
|
||
|
||
/// <summary>写入世界状态布尔标志(EventChains.WorldFlags)。</summary>
|
||
public void SetFlag(string flagId, bool value)
|
||
{
|
||
_current ??= new SaveData();
|
||
_current.EventChains.WorldFlags[flagId] = value;
|
||
}
|
||
|
||
// ── 存档槽摘要(参见 10_UIModule §7.5)──────────────────────────────────────
|
||
|
||
/// <summary>异步读取指定槽的摘要信息,用于主菜单存档槽 UI 显示。槽不存在时返回 null。</summary>
|
||
public async Task<SlotSummary> GetSlotSummaryAsync(int slotIndex)
|
||
{
|
||
if (!_storage.Exists(slotIndex)) return null;
|
||
string json = await _storage.ReadAsync(slotIndex);
|
||
var data = JsonConvert.DeserializeObject<SaveData>(json);
|
||
return new SlotSummary
|
||
{
|
||
SlotIndex = slotIndex,
|
||
Playtime = data.Meta.Playtime,
|
||
LastSaved = data.Meta.LastSaved,
|
||
SceneName = data.Player.Scene,
|
||
ActiveFormId = data.Player.ActiveFormId,
|
||
};
|
||
}
|
||
|
||
/// <summary>创建新存档槽:重置运行时缓存并绑定槽索引。由主菜单"新建存档"调用。</summary>
|
||
public void CreateSlot(int slotIndex)
|
||
{
|
||
_currentSlot = slotIndex;
|
||
_current = new SaveData();
|
||
_current.Meta.SlotIndex = slotIndex;
|
||
}
|
||
|
||
private string ComputeChecksum(string json)
|
||
{
|
||
// HMAC-SHA256:密鑅 = 设备 GUID(PC)或 Steam UserId 绑定,防止将存档文件复制到其他设备后绕过 SteelSoul 限制
|
||
var keyBytes = System.Text.Encoding.UTF8.GetBytes(GetHmacKey());
|
||
using var hmac = new System.Security.Cryptography.HMACSHA256(keyBytes);
|
||
var dataBytes = System.Text.Encoding.UTF8.GetBytes(json);
|
||
return Convert.ToBase64String(hmac.ComputeHash(dataBytes));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取 HMAC 密鑅。密鑅由设备唯一标识符派生,绑定到设备,
|
||
/// 防止玩家把存档文件复制到其他设备来绕过 SteelSoul 。
|
||
/// </summary>
|
||
private string GetHmacKey()
|
||
{
|
||
// 优先使用 IPlatformService 提供的用户 ID(Steam UserId等)作为密酅的一部分
|
||
string platformId = ServiceLocator
|
||
.GetOrDefault<IPlatformService>(NullPlatformService.Instance)
|
||
.GetUserId(); // 平台无效时返回空字符串
|
||
|
||
// 回退层:使用设备 GUID(跨退出平台级绑定到设备)
|
||
string deviceId = string.IsNullOrEmpty(platformId)
|
||
? SystemInfo.deviceUniqueIdentifier
|
||
: platformId;
|
||
|
||
// 加盐:防止已知设备 ID 列表爆力
|
||
const string salt = "ZelingV2SaveIntegrity_v1";
|
||
return $"{deviceId}:{salt}";
|
||
}
|
||
|
||
private bool ValidateChecksum(SaveData data, string rawJson)
|
||
{
|
||
if (string.IsNullOrEmpty(data.Meta.Checksum)) return true; // 旧存档兄容
|
||
|
||
// 重新序列化时暂时置空 checksum 字段,再计算
|
||
var savedChecksum = data.Meta.Checksum;
|
||
data.Meta.Checksum = null;
|
||
string jsonWithoutChecksum = JsonConvert.SerializeObject(data, Formatting.Indented);
|
||
data.Meta.Checksum = savedChecksum; // 恢复
|
||
|
||
string expected = ComputeChecksum(jsonWithoutChecksum);
|
||
if (expected != savedChecksum)
|
||
{
|
||
Debug.LogWarning("[SaveManager] 存档校验失败——存档文件可能被篹改或来自不同设备。");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// ── 存档槽摘要(主菜单 UI 使用,参见 10_UIModule §7.5)──────────────────────────
|
||
// 路径: Assets/Scripts/Core/Save/SlotSummary.cs(或与 SaveManager 同文件)
|
||
/// <summary>存档槽摘要数据,由 SaveManager.GetSlotSummaryAsync 返回。null = 空槽(显示"新局")。</summary>
|
||
public class SlotSummary
|
||
{
|
||
public int SlotIndex;
|
||
public float Playtime;
|
||
public string LastSaved; // ISO 8601
|
||
public string SceneName; // 最后存档时的场景名
|
||
public string ActiveFormId; // 当前形态 ID(用于显示图标)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. SaveMigrator
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Core/Save/SaveMigrator.cs
|
||
// 处理存档版本升级(旧版 → 新版数据结构)
|
||
public static class SaveMigrator
|
||
{
|
||
private const string CurrentVersion = "2.0";
|
||
|
||
public static SaveData Migrate(SaveData data)
|
||
{
|
||
switch (data.Meta.Version)
|
||
{
|
||
case "1.0": data = MigrateFrom1_0(data); goto case "1.1";
|
||
case "1.1": data = MigrateFrom1_1(data); goto case "2.0";
|
||
case "2.0": break; // 当前版本,无需迁移
|
||
default: Debug.LogWarning($"Unknown save version: {data.Meta.Version}"); break;
|
||
}
|
||
data.Meta.Version = CurrentVersion;
|
||
return data;
|
||
}
|
||
|
||
private static SaveData MigrateFrom1_0(SaveData d)
|
||
{
|
||
// 防御性 null-check:若旧版本缺少整个子结构体,先补全对象
|
||
d.Equipment ??= new EquipmentSaveData();
|
||
d.Player ??= new PlayerSaveData();
|
||
d.World ??= new WorldSaveData();
|
||
// 1.0 → 1.1:新增 Equipment.UpgradedCharmIds 字段,旧存档初始化为空列表
|
||
d.Equipment.UpgradedCharmIds ??= new List<string>();
|
||
return d;
|
||
}
|
||
|
||
private static SaveData MigrateFrom1_1(SaveData d)
|
||
{
|
||
// 防御性 null-check:子结构体若为 null 先补全
|
||
d.Stats ??= new StatsSaveData();
|
||
// 1.1 → 2.0:Stats 新增 SkillUseCounts
|
||
d.Stats.SkillUseCounts ??= new Dictionary<string, int>();
|
||
// 1.1 → 2.0:Player 新增护盾字段(-1 = 满护盾)
|
||
// ShieldHP 为 int,默认 0(值类型),用 -1 作哨兵值表示"未记录"→恢复满护盾
|
||
if (d.Player.ShieldHP == 0 && !d.Player.ShieldIsBroken)
|
||
d.Player.ShieldHP = -1; // 旧存档没有护盾字段时恢复为满护盾
|
||
return d;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 保存流程
|
||
|
||
```
|
||
[玩家激活存档点]
|
||
↓ SavePoint.Interact()
|
||
↓ EVT_SavePointActivated.Raise(savePointId)
|
||
← GameManager 订阅
|
||
→ GameManager 调用 SaveManager.SaveAsync(currentSlot)
|
||
→ 遍历 _saveables,收集数据
|
||
→ 序列化 JSON
|
||
→ 计算 Checksum
|
||
→ ISaveStorage.WriteAsync(slot, json)
|
||
→ 写入磁盘(async,不阻塞主线程)
|
||
→ 更新 LastCheckpointScene / LastCheckpointSpawnId
|
||
→ EVT_SaveIndicatorVisible.Raise(true) → HUD 显示保存中图标
|
||
→ (完成后)EVT_SaveIndicatorVisible.Raise(false)
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 加载流程
|
||
|
||
```
|
||
[游戏启动 / 选择存档槽]
|
||
↓ GameManager.StartGame(slotIndex)
|
||
→ SaveManager.LoadAsync(slotIndex)
|
||
→ ISaveStorage.ReadAsync(slot)
|
||
→ JsonConvert.DeserializeObject<SaveData>(json)
|
||
→ ValidateChecksum → 失败则提示损坏
|
||
→ SaveMigrator.Migrate(data)
|
||
→ 遍历 _saveables,调用 OnLoad(data)
|
||
← PlayerStats.LoadSaveData(data.Player)
|
||
← EquipmentManager.LoadSaveData(data.Equipment)
|
||
← WorldStateRegistry.LoadSaveData(data.World)
|
||
← QuestManager.LoadSaveData(data.Quests)
|
||
← ...
|
||
→ SceneLoader.LoadScene(data.Player.Scene, respawnId)
|
||
|
||
[重生(死亡后)]
|
||
↓ DeathScreenController.Respawn()
|
||
→ 发送 SceneLoadRequest(IsRespawn = true)
|
||
→ SceneLoader 加载最后存档场景
|
||
→ 各系统从缓存的 _current(内存中最后存档)恢复
|
||
→ 无需重新读取磁盘
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 存档路径与文件规范
|
||
|
||
| 平台 | 存档目录 | 文件名 |
|
||
|-----|---------|-------|
|
||
| Windows / Mac / Linux | `Application.persistentDataPath/saves/` | `save_0.json`、`save_1.json`、`save_2.json` |
|
||
| Steam 云存档 | SteamRemoteStorage(`ISteamRemoteStorage.FileWrite`) | 同文件名 |
|
||
|
||
**设置文件**(独立于存档槽,全局唯一):
|
||
`Application.persistentDataPath/settings.json`
|
||
由 `SettingsManager` 管理,不经过 `SaveManager`。
|
||
|
||
**自动备份**:每次 `WriteAsync` 前将旧文件重命名为 `save_{slot}.bak`,提供一份备份。
|
||
|
||
---
|
||
|
||
## 9. EmergencySaveService 与 CrashReporter
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Core/Save/EmergencySaveService.cs
|
||
// 定期自动保存到独立的紧急存档槽(不覆盖玩家主存档)
|
||
// 用途:游戏崩溃后可提示玩家恢复进度
|
||
public class EmergencySaveService : MonoBehaviour
|
||
{
|
||
private const int EmergencySlot = 99; // 独立槽,不显示在存档选择界面
|
||
|
||
[SerializeField] private float _intervalSeconds = 120f; // 每 2 分钟
|
||
[SerializeField] private SaveManager _saveManager;
|
||
[SerializeField] private BoolEventChannelSO _onGameplayActive; // 仅 Gameplay 状态下才保存
|
||
|
||
private bool _gameplayActive;
|
||
private float _timer;
|
||
|
||
private void OnEnable() => _onGameplayActive.OnEventRaised += v => _gameplayActive = v;
|
||
private void OnDisable() => _onGameplayActive.OnEventRaised -= v => _gameplayActive = v;
|
||
|
||
private void Update()
|
||
{
|
||
if (!_gameplayActive) return;
|
||
_timer += Time.deltaTime;
|
||
if (_timer >= _intervalSeconds)
|
||
{
|
||
_timer = 0f;
|
||
// Fire-and-forget,不阻塞主线程
|
||
_ = _saveManager.SaveAsync(EmergencySlot);
|
||
}
|
||
}
|
||
|
||
// 判断是否存在未读的紧急存档(启动时检查)
|
||
public bool HasEmergencySave() => _saveManager.SlotExists(EmergencySlot);
|
||
|
||
// 将紧急存档提升为指定主存档槽(玩家确认恢复后调用)
|
||
public async Task PromoteToSlot(int targetSlot)
|
||
{
|
||
var json = await ((LocalFileStorage)GetComponent<ISaveStorage>())
|
||
.ReadAsync(EmergencySlot);
|
||
// 写入目标槽,删除紧急槽
|
||
await ((LocalFileStorage)GetComponent<ISaveStorage>()).WriteAsync(targetSlot, json);
|
||
await ((LocalFileStorage)GetComponent<ISaveStorage>()).DeleteAsync(EmergencySlot);
|
||
}
|
||
}
|
||
|
||
// 路径: Assets/Scripts/Core/Save/CrashReporter.cs
|
||
// 监听 Application.quitting 与 Application.logMessageReceived
|
||
// 崩溃时触发紧急存档并写入诊断日志
|
||
public class CrashReporter : MonoBehaviour
|
||
{
|
||
[SerializeField] private SaveManager _saveManager;
|
||
[SerializeField] private EmergencySaveService _emergencyService;
|
||
|
||
private bool _cleanExit; // OnApplicationQuit 正常退出时置 true
|
||
|
||
private void OnEnable()
|
||
{
|
||
Application.logMessageReceived += OnLogMessage;
|
||
Application.quitting += OnCleanQuit;
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
Application.logMessageReceived -= OnLogMessage;
|
||
Application.quitting -= OnCleanQuit;
|
||
}
|
||
|
||
private void OnCleanQuit() => _cleanExit = true;
|
||
|
||
private void OnLogMessage(string condition, string stackTrace, LogType type)
|
||
{
|
||
if (type == LogType.Exception || type == LogType.Error)
|
||
{
|
||
WriteDiagnosticLog(condition, stackTrace);
|
||
}
|
||
}
|
||
|
||
// 写入诊断日志文件(不含存档数据,仅 stacktrace + 时间戳)
|
||
private void WriteDiagnosticLog(string condition, string stackTrace)
|
||
{
|
||
var logPath = Path.Combine(
|
||
Application.persistentDataPath,
|
||
$"crash_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log");
|
||
|
||
var content = $"[{DateTime.UtcNow:o}] {condition}\n{stackTrace}";
|
||
File.WriteAllText(logPath, content); // 同步写,崩溃时 async 不可靠
|
||
}
|
||
|
||
// 程序退出前若为非正常退出则触发紧急存档
|
||
private void OnApplicationPause(bool pauseStatus)
|
||
{
|
||
if (pauseStatus && !_cleanExit)
|
||
_ = _saveManager.SaveAsync(99); // EmergencySlot
|
||
}
|
||
}
|
||
```
|
||
|
||
### 紧急存档启动流程
|
||
|
||
```
|
||
[游戏启动]
|
||
↓ MainMenuController.Start()
|
||
→ CrashReporter.HasEmergencySave()
|
||
→ true: 显示"检测到上次异常退出,是否恢复进度?"对话框
|
||
→ 玩家确认: EmergencySaveService.PromoteToSlot(lastUsedSlot)
|
||
→ 正常 LoadAsync(lastUsedSlot)
|
||
→ 玩家拒绝: DeleteAsync(EmergencySlot)
|
||
→ false: 正常主菜单流程
|
||
```
|
||
|
||
---
|
||
|
||
## 10. SaveValidator — 写入前数据校验
|
||
|
||
> **Design 来源**:[31_SaveLoadSystem](../Design/31_SaveLoadSystem.md) §10
|
||
> **P2 优化**:原 `Validate()` 同步执行 SHA256 + JSON 反序列化,大存档可阻塞主线程 >16ms。拆分为快速同步校验(字段范围检查)+ 异步完整性校验(SHA256 + 反序列化),加载时在后台线程运行。
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Save/SaveValidator.cs
|
||
namespace BaseGames.Save
|
||
{
|
||
public static class SaveValidator
|
||
{
|
||
public readonly struct Result
|
||
{
|
||
public readonly bool IsValid;
|
||
public readonly string Error;
|
||
|
||
public Result(bool isValid, string error = null)
|
||
{
|
||
IsValid = isValid;
|
||
Error = error;
|
||
}
|
||
}
|
||
|
||
// ── 同步快速校验(SaveAsync 序列化前调用,< 1ms)──────────────
|
||
/// <summary>
|
||
/// 轻量字段校验,不涉及 IO 或加密运算,可安全在主线程调用。
|
||
/// SaveManager.SaveAsync() 在序列化前调用此方法。
|
||
/// </summary>
|
||
public static Result ValidateFields(SaveData data)
|
||
{
|
||
if (data.Player.CurrentHP < 0 || data.Player.CurrentHP > data.Player.MaxHP)
|
||
return new Result(false, $"HP 越界: {data.Player.CurrentHP}/{data.Player.MaxHP}");
|
||
|
||
if (data.Player.CurrentGeo < 0)
|
||
return new Result(false, $"Geo 为负值: {data.Player.CurrentGeo}");
|
||
|
||
if (string.IsNullOrEmpty(data.Player.Scene))
|
||
return new Result(false, "场景名为空");
|
||
|
||
if (string.Compare(data.Meta.Version, SaveManager.MinCompatibleVersion,
|
||
StringComparison.Ordinal) < 0)
|
||
return new Result(false, $"存档版本过旧: {data.Meta.Version}");
|
||
|
||
return new Result(true);
|
||
}
|
||
|
||
// ── 异步完整性校验(LoadAsync 加载后调用,后台线程)─────────────
|
||
/// <summary>
|
||
/// 包含 SHA256 校验和 + JSON 反序列化健壮性验证。
|
||
/// 运行在后台线程(Task.Run),不阻塞主线程。
|
||
/// SaveManager.LoadAsync() 在反序列化后、应用存档前调用此方法。
|
||
/// </summary>
|
||
public static UniTask<Result> ValidateIntegrityAsync(string rawJson)
|
||
=> UniTask.RunOnThreadPool(() => ValidateIntegrityInternal(rawJson));
|
||
|
||
private static Result ValidateIntegrityInternal(string rawJson)
|
||
{
|
||
try
|
||
{
|
||
// 1. 反序列化健壮性:确保 JSON 完整可解析
|
||
var data = Newtonsoft.Json.JsonConvert.DeserializeObject<SaveData>(rawJson);
|
||
if (data == null)
|
||
return new Result(false, "JSON 反序列化结果为 null");
|
||
|
||
// 2. SHA256 校验和(保存时写入 data.Meta.Checksum)
|
||
if (!string.IsNullOrEmpty(data.Meta.Checksum))
|
||
{
|
||
string computed = ComputeChecksum(rawJson, data.Meta.Checksum);
|
||
if (computed != data.Meta.Checksum)
|
||
return new Result(false, "SHA256 校验失败:存档数据可能被篡改");
|
||
}
|
||
|
||
return new Result(true);
|
||
}
|
||
catch (System.Exception ex)
|
||
{
|
||
return new Result(false, $"完整性校验异常: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算 rawJson 去除 checksum 字段后的 SHA256 十六进制字符串。
|
||
/// </summary>
|
||
internal static string ComputeChecksum(string rawJson, string existingChecksum = null)
|
||
{
|
||
// 移除 checksum 字段再计算,避免循环依赖
|
||
var jo = Newtonsoft.Json.Linq.JObject.Parse(rawJson);
|
||
jo["Meta"]?["Checksum"]?.Parent?.Remove();
|
||
var canonical = jo.ToString(Newtonsoft.Json.Formatting.None);
|
||
|
||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical));
|
||
return System.BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**SaveManager 调用点**:
|
||
|
||
```csharp
|
||
// SaveManager.SaveAsync() — 序列化前快速校验
|
||
var fieldResult = SaveValidator.ValidateFields(data);
|
||
if (!fieldResult.IsValid)
|
||
{
|
||
Debug.LogError($"[SaveManager] 存档数据非法,中止写入:{fieldResult.Error}");
|
||
return;
|
||
}
|
||
// … 序列化 → 写入文件 …
|
||
|
||
// SaveManager.LoadAsync() — 读取文件后异步完整性校验(不阻塞)
|
||
string rawJson = await storage.ReadAsync(slot);
|
||
var integrityResult = await SaveValidator.ValidateIntegrityAsync(rawJson);
|
||
if (!integrityResult.IsValid)
|
||
{
|
||
Debug.LogError($"[SaveManager] 存档完整性校验失败:{integrityResult.Error}");
|
||
// 降级:提示用户存档损坏,加载 BackupSave
|
||
return;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. IDlcSaveExtension — DLC 存档扩展接口
|
||
|
||
> **Design 来源**:[31_SaveLoadSystem](../Design/31_SaveLoadSystem.md) §13
|
||
|
||
DLC 内容通过此接口挂载到 `SaveData.DLC`(`Dictionary<string, JObject>`),与核心存档完全解耦。
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Save/IDlcSaveExtension.cs
|
||
public interface IDlcSaveExtension
|
||
{
|
||
/// <summary>唯一标识符,对应 SaveData.DLC 字典的键</summary>
|
||
string DlcId { get; }
|
||
|
||
/// <summary>将 DLC 数据序列化写入 dlcData</summary>
|
||
void Serialize(ref JObject dlcData, SaveData fullData);
|
||
|
||
/// <summary>从 dlcData 读取并应用到游戏状态</summary>
|
||
void Deserialize(JObject dlcData, SaveData fullData);
|
||
|
||
/// <summary>版本迁移,fromVersion 为旧存档版本号</summary>
|
||
void MigrateIfNeeded(int fromVersion, ref JObject dlcData);
|
||
|
||
/// <summary>NG+ 开始时重置 DLC 存档数据</summary>
|
||
void OnNgPlusReset(ref JObject dlcData);
|
||
}
|
||
```
|
||
|
||
**SaveManager 集成**:
|
||
|
||
```csharp
|
||
// SaveManager 内新增字段与注册方法
|
||
private readonly List<IDlcSaveExtension> _dlcExtensions = new();
|
||
|
||
public void RegisterDlcExtension(IDlcSaveExtension ext)
|
||
=> _dlcExtensions.Add(ext);
|
||
|
||
// SaveAsync() 内调用:
|
||
foreach (var ext in _dlcExtensions)
|
||
{
|
||
var dlcData = new JObject();
|
||
ext.Serialize(ref dlcData, saveData);
|
||
saveData.DLC[ext.DlcId] = dlcData;
|
||
}
|
||
|
||
// LoadAsync() 内调用:
|
||
foreach (var ext in _dlcExtensions)
|
||
{
|
||
if (saveData.DLC.TryGetValue(ext.DlcId, out var dlcData))
|
||
ext.Deserialize(dlcData, saveData);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. SaveInspectorWindow — 运行时存档调试工具
|
||
|
||
> **痛点**:开发调试期间需要频繁查看/修改存档状态(如解锁所有能力、传送到指定场景、修改 HP/Geo)。若通过修改 JSON 文件来调试,需要:关游戏 → 找文件 → 编辑 → 重启,效率极低。`SaveInspectorWindow` 在 **Play Mode** 下提供实时存档数据浏览与热修改,减少调试迭代成本。
|
||
|
||
### 10.1 功能规格
|
||
|
||
| 功能 | 说明 |
|
||
|------|------|
|
||
| 实时显示 | 运行时读取 `SaveManager._current`(反射或接口)展示当前存档数据 |
|
||
| 分节折叠 | 按 Player / World / Achievements / EventChains / DLC 分节,各自可折叠 |
|
||
| 热写入 | 修改字段后点击 "应用" 按钮立即写入 `SaveManager._current` 并调用 `ISaveable.OnLoad` 广播 |
|
||
| 能力位掩码 | AbilityFlags 以复选框列表显示(每个 AbilityType 一个 Toggle),一键"解锁全部" |
|
||
| 快速存档/读档 | 一键 "立即存档(Slot 0)" / "立即读档(Slot 0)" 调用 `SaveManager.SaveAsync/LoadAsync` |
|
||
| 截图存档 | 将当前存档数据格式化为 JSON 并复制到剪贴板,方便粘贴到 Bug 报告 |
|
||
| 存档路径 | 显示当前平台存档目录路径,双击打开文件夹 |
|
||
|
||
### 10.2 实现规范
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Editor/Save/SaveInspectorWindow.cs
|
||
// 程序集: BaseGames.Editor(Editor Only)
|
||
#if UNITY_EDITOR
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using Newtonsoft.Json;
|
||
|
||
namespace BaseGames.Editor.Save
|
||
{
|
||
public class SaveInspectorWindow : EditorWindow
|
||
{
|
||
[MenuItem("BaseGames/Tools/Save Inspector")]
|
||
public static void Open() => GetWindow<SaveInspectorWindow>("存档检视器");
|
||
|
||
private Vector2 _scroll;
|
||
private bool _showPlayer = true;
|
||
private bool _showWorld = true;
|
||
private bool _showAch = false;
|
||
private bool _showChains = false;
|
||
|
||
private void OnGUI()
|
||
{
|
||
if (!Application.isPlaying)
|
||
{
|
||
EditorGUILayout.HelpBox("仅在 Play Mode 下可用", MessageType.Warning);
|
||
return;
|
||
}
|
||
|
||
var mgr = FindObjectOfType<SaveManager>();
|
||
if (mgr == null)
|
||
{
|
||
EditorGUILayout.HelpBox("场景中未找到 SaveManager", MessageType.Error);
|
||
return;
|
||
}
|
||
|
||
DrawToolbar(mgr);
|
||
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||
DrawPlayerSection(mgr);
|
||
DrawWorldSection(mgr);
|
||
DrawAchievementsSection(mgr);
|
||
DrawEventChainsSection(mgr);
|
||
EditorGUILayout.EndScrollView();
|
||
}
|
||
|
||
private void DrawToolbar(SaveManager mgr)
|
||
{
|
||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||
|
||
if (GUILayout.Button("💾 立即存档", EditorStyles.toolbarButton, GUILayout.Width(90)))
|
||
_ = mgr.SaveAsync(0);
|
||
|
||
if (GUILayout.Button("📂 读取 Slot0", EditorStyles.toolbarButton, GUILayout.Width(90)))
|
||
_ = mgr.LoadAsync(0);
|
||
|
||
if (GUILayout.Button("📋 复制 JSON", EditorStyles.toolbarButton, GUILayout.Width(90)))
|
||
{
|
||
var json = JsonConvert.SerializeObject(mgr.CurrentData, Formatting.Indented);
|
||
GUIUtility.systemCopyBuffer = json;
|
||
Debug.Log("[SaveInspector] 存档 JSON 已复制到剪贴板");
|
||
}
|
||
|
||
GUILayout.FlexibleSpace();
|
||
if (GUILayout.Button("📁 存档目录", EditorStyles.toolbarButton, GUILayout.Width(80)))
|
||
EditorUtility.RevealInFinder(
|
||
System.IO.Path.Combine(Application.persistentDataPath, "saves"));
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
|
||
private void DrawPlayerSection(SaveManager mgr)
|
||
{
|
||
_showPlayer = EditorGUILayout.Foldout(_showPlayer, "Player", true, EditorStyles.foldoutHeader);
|
||
if (!_showPlayer) return;
|
||
|
||
var p = mgr.CurrentData?.Player;
|
||
if (p == null) { EditorGUILayout.LabelField("(无存档数据)"); return; }
|
||
|
||
EditorGUI.indentLevel++;
|
||
p.CurrentHP = EditorGUILayout.IntField("Current HP", p.CurrentHP);
|
||
p.MaxHP = EditorGUILayout.IntField("Max HP", p.MaxHP);
|
||
p.CurrentGeo = EditorGUILayout.IntField("Geo", p.CurrentGeo);
|
||
p.Scene = EditorGUILayout.TextField("Scene", p.Scene);
|
||
p.ActiveFormId = EditorGUILayout.TextField("Form ID", p.ActiveFormId);
|
||
|
||
EditorGUILayout.Space(4);
|
||
EditorGUILayout.LabelField("AbilityFlags", EditorStyles.boldLabel);
|
||
foreach (AbilityType ability in System.Enum.GetValues(typeof(AbilityType)))
|
||
{
|
||
if (ability == AbilityType.None) continue;
|
||
bool has = (p.AbilityFlags & (uint)ability) != 0;
|
||
bool newHas = EditorGUILayout.Toggle(ability.ToString(), has);
|
||
if (newHas != has)
|
||
p.AbilityFlags = newHas
|
||
? p.AbilityFlags | (uint)ability
|
||
: p.AbilityFlags & ~(uint)ability;
|
||
}
|
||
if (GUILayout.Button("解锁全部能力"))
|
||
p.AbilityFlags = uint.MaxValue;
|
||
if (GUILayout.Button("重置全部能力"))
|
||
p.AbilityFlags = 0;
|
||
|
||
EditorGUI.indentLevel--;
|
||
}
|
||
|
||
private void DrawWorldSection(SaveManager mgr)
|
||
{
|
||
_showWorld = EditorGUILayout.Foldout(_showWorld, "World", true, EditorStyles.foldoutHeader);
|
||
if (!_showWorld) return;
|
||
var w = mgr.CurrentData?.World;
|
||
if (w == null) return;
|
||
EditorGUI.indentLevel++;
|
||
EditorGUILayout.LabelField($"已解锁地图节点数: {w.UnlockedMapNodes?.Count ?? 0}");
|
||
EditorGUILayout.LabelField($"已开启传送点数: {w.OpenedTeleports?.Count ?? 0}");
|
||
EditorGUILayout.LabelField($"已击败 Boss 数: {w.DefeatedBossIds?.Count ?? 0}");
|
||
EditorGUI.indentLevel--;
|
||
}
|
||
|
||
private void DrawAchievementsSection(SaveManager mgr)
|
||
{
|
||
_showAch = EditorGUILayout.Foldout(_showAch, "Achievements", true, EditorStyles.foldoutHeader);
|
||
if (!_showAch) return;
|
||
var ach = mgr.CurrentData?.Achievements;
|
||
if (ach == null) return;
|
||
EditorGUI.indentLevel++;
|
||
EditorGUILayout.LabelField($"已解锁: {ach.Unlocked?.Count ?? 0} 项");
|
||
if (ach.Unlocked != null)
|
||
foreach (var id in ach.Unlocked)
|
||
EditorGUILayout.LabelField(" ✓ " + id);
|
||
EditorGUI.indentLevel--;
|
||
}
|
||
|
||
private void DrawEventChainsSection(SaveManager mgr)
|
||
{
|
||
_showChains = EditorGUILayout.Foldout(_showChains, "EventChains / WorldFlags",
|
||
true, EditorStyles.foldoutHeader);
|
||
if (!_showChains) return;
|
||
var ec = mgr.CurrentData?.EventChains;
|
||
if (ec == null) return;
|
||
EditorGUI.indentLevel++;
|
||
EditorGUILayout.LabelField($"Chain 数: {ec.ChainStates?.Count ?? 0}");
|
||
EditorGUILayout.LabelField($"WorldFlag 数: {ec.WorldFlags?.Count ?? 0}");
|
||
EditorGUI.indentLevel--;
|
||
}
|
||
|
||
// 每帧刷新(数据可能在游戏逻辑中被修改)
|
||
private void OnInspectorUpdate() => Repaint();
|
||
}
|
||
}
|
||
#endif
|
||
```
|
||
|
||
> **注意**:`SaveManager` 需将 `_current` 暴露为只读属性 `public SaveData CurrentData => _current;` 以供 EditorWindow 访问。热修改后如需立即生效,可调用 `mgr.BroadcastLoad()`(向所有 `ISaveable` 广播 `OnLoad`,使游戏状态与修改后的 `_current` 保持一致)。
|
||
|