chore: initial commit
This commit is contained in:
0
Assets/Scripts/Core/Save/.gitkeep
Normal file
0
Assets/Scripts/Core/Save/.gitkeep
Normal file
22
Assets/Scripts/Core/Save/BaseGames.Core.Save.asmdef
Normal file
22
Assets/Scripts/Core/Save/BaseGames.Core.Save.asmdef
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "BaseGames.Core.Save",
|
||||
"rootNamespace": "BaseGames.Core.Save",
|
||||
"references": [
|
||||
"BaseGames.Core.Events"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [
|
||||
{
|
||||
"name": "com.unity.nuget.newtonsoft-json",
|
||||
"expression": "",
|
||||
"define": "NEWTONSOFT_JSON"
|
||||
}
|
||||
],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
7
Assets/Scripts/Core/Save/BaseGames.Core.Save.asmdef.meta
Normal file
7
Assets/Scripts/Core/Save/BaseGames.Core.Save.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d0538e003f3abea4782eb60e4d29f17d
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
51
Assets/Scripts/Core/Save/EmergencySaveService.cs
Normal file
51
Assets/Scripts/Core/Save/EmergencySaveService.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
public class EmergencySaveService : MonoBehaviour
|
||||
{
|
||||
private const int EmergencySlot = 99;
|
||||
|
||||
[SerializeField] private float _intervalSeconds = 120f;
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
[SerializeField] private BoolEventChannelSO _onGameplayActive;
|
||||
|
||||
private bool _gameplayActive;
|
||||
private float _timer;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onGameplayActive != null) _onGameplayActive.OnEventRaised += OnGameplayActiveChanged;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onGameplayActive != null) _onGameplayActive.OnEventRaised -= OnGameplayActiveChanged;
|
||||
}
|
||||
|
||||
private void OnGameplayActiveChanged(bool value) => _gameplayActive = value;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!_gameplayActive || _saveManager == null) return;
|
||||
_timer += Time.deltaTime;
|
||||
if (_timer >= _intervalSeconds)
|
||||
{
|
||||
_timer = 0f;
|
||||
_ = _saveManager.SaveAsync(EmergencySlot);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasEmergencySave()
|
||||
=> _saveManager != null && _saveManager.SlotExists(EmergencySlot);
|
||||
|
||||
public async Task PromoteToSlot(int targetSlot)
|
||||
{
|
||||
if (_saveManager == null) return;
|
||||
// Phase 1 stub:完整实现在 Phase 2 LocalFileStorage API
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/EmergencySaveService.cs.meta
Normal file
11
Assets/Scripts/Core/Save/EmergencySaveService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68ee29c1506209d44bf695a6bd1687b0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
15
Assets/Scripts/Core/Save/ISaveStorage.cs
Normal file
15
Assets/Scripts/Core/Save/ISaveStorage.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>存档 I/O 抽象接口。</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/ISaveStorage.cs.meta
Normal file
11
Assets/Scripts/Core/Save/ISaveStorage.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7502422be575ad648885a485811ae81f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
12
Assets/Scripts/Core/Save/ISaveable.cs
Normal file
12
Assets/Scripts/Core/Save/ISaveable.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 各游戏系统组件实现此接口,
|
||||
/// 向 SaveManager 提供/接受自己的存档数据。
|
||||
/// </summary>
|
||||
public interface ISaveable
|
||||
{
|
||||
void OnSave(SaveData saveData);
|
||||
void OnLoad(SaveData saveData);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/ISaveable.cs.meta
Normal file
11
Assets/Scripts/Core/Save/ISaveable.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 516b89521df95184ca457b57d81a7523
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
63
Assets/Scripts/Core/Save/LocalFileStorage.cs
Normal file
63
Assets/Scripts/Core/Save/LocalFileStorage.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 本地文件存档存储实现(PC / 主机)。
|
||||
/// 存档路径:<see cref="Application.persistentDataPath"/>/saves/save_{slot}.json
|
||||
/// </summary>
|
||||
public class LocalFileStorage : ISaveStorage
|
||||
{
|
||||
private readonly string _saveDir;
|
||||
|
||||
public LocalFileStorage()
|
||||
{
|
||||
_saveDir = Path.Combine(Application.persistentDataPath, "saves");
|
||||
Directory.CreateDirectory(_saveDir);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(int slotIndex, string json)
|
||||
{
|
||||
var path = GetPath(slotIndex);
|
||||
// 先写临时文件再原子性替换,防止写入中途断电损坏存档
|
||||
var tmp = path + ".tmp";
|
||||
await File.WriteAllTextAsync(tmp, json);
|
||||
File.Replace(tmp, path, path + ".bak", ignoreMetadataErrors: true);
|
||||
}
|
||||
|
||||
public async Task<string> ReadAsync(int slotIndex)
|
||||
{
|
||||
var path = GetPath(slotIndex);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
// 尝试恢复备份
|
||||
var bak = path + ".bak";
|
||||
if (File.Exists(bak)) File.Copy(bak, path);
|
||||
else return null;
|
||||
}
|
||||
return await File.ReadAllTextAsync(path);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(int slotIndex)
|
||||
{
|
||||
var path = GetPath(slotIndex);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
var bak = path + ".bak";
|
||||
if (File.Exists(bak)) File.Delete(bak);
|
||||
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");
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/LocalFileStorage.cs.meta
Normal file
11
Assets/Scripts/Core/Save/LocalFileStorage.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 880e1ba90110b4f44aed86f117361f1c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
218
Assets/Scripts/Core/Save/SaveData.cs
Normal file
218
Assets/Scripts/Core/Save/SaveData.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
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";
|
||||
public int SlotIndex;
|
||||
public string LastSaved; // ISO 8601
|
||||
public float Playtime;
|
||||
public string SavePointId;
|
||||
public int NGPlusCount;
|
||||
public int SaveCount;
|
||||
public string Checksum; // HMAC-SHA256
|
||||
}
|
||||
|
||||
// ─── Player ───────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class PlayerSaveData
|
||||
{
|
||||
public float PosX, PosY;
|
||||
public string Scene;
|
||||
public int CurrentHP, MaxHP;
|
||||
public int CurrentGeo, LifetimeGeo;
|
||||
|
||||
// 能力解锁位掩码(AbilityType [Flags] uint bitmask)
|
||||
public uint AbilityFlags = 0;
|
||||
|
||||
public string ActiveFormId;
|
||||
public List<string> UnlockedFormIds = new();
|
||||
|
||||
public DeathShadeSaveData DeathShade;
|
||||
|
||||
// 护盾:-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();
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
public Dictionary<string, bool> Switches = new();
|
||||
public Dictionary<string, int> NpcRelations = new();
|
||||
public HashSet<string> ChallengeFirstClears = new();
|
||||
}
|
||||
|
||||
// ─── Map ──────────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class MapSaveData
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
// ─── Tools ────────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class ToolsSaveData
|
||||
{
|
||||
public string ToolSlot0;
|
||||
public string ToolSlot1;
|
||||
public List<string> OwnedToolIds = new();
|
||||
public Dictionary<string, JObject> ToolStates = new();
|
||||
}
|
||||
|
||||
// ─── ChallengeRooms ───────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class ChallengeRoomsSaveData
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── EventChains ──────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class EventChainsSaveData
|
||||
{
|
||||
public Dictionary<string, string> ChainStates = new();
|
||||
public Dictionary<string, bool> WorldFlags = new();
|
||||
}
|
||||
|
||||
// ─── Shops ────────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class ShopsSaveData
|
||||
{
|
||||
public Dictionary<string, ShopRecord> ShopRecords = new();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ShopRecord
|
||||
{
|
||||
public List<string> SoldUniqueItems = new();
|
||||
public Dictionary<string, int> PurchaseCounts = new();
|
||||
}
|
||||
|
||||
// ─── NGPlus ───────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class NGPlusSaveData
|
||||
{
|
||||
public int NGPlusCount;
|
||||
public bool SteelSoulMode;
|
||||
public Dictionary<string, bool> NGPlusFlags = new();
|
||||
}
|
||||
|
||||
// ─── SlotSummary(主菜单 UI 用)────────────────────────────────────────────
|
||||
public class SlotSummary
|
||||
{
|
||||
public int SlotIndex;
|
||||
public float Playtime;
|
||||
public string LastSaved;
|
||||
public string SceneName;
|
||||
public string ActiveFormId;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/SaveData.cs.meta
Normal file
11
Assets/Scripts/Core/Save/SaveData.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2c86ec3fc854db54da8ce69990da0497
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
188
Assets/Scripts/Core/Save/SaveManager.cs
Normal file
188
Assets/Scripts/Core/Save/SaveManager.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档管理器(Phase 0 骨架)。
|
||||
/// 完整序列化/反序列化由 Newtonsoft.Json 驱动。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class SaveManager : MonoBehaviour
|
||||
{
|
||||
public static SaveManager Instance { get; private set; }
|
||||
|
||||
public const string MinCompatibleVersion = "1.0";
|
||||
private const int QuickSaveSlot = 98;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private BaseGames.Core.Events.BoolEventChannelSO _onSaveIndicatorVisible;
|
||||
|
||||
private ISaveStorage _storage;
|
||||
private readonly List<ISaveable> _saveables = new();
|
||||
|
||||
private SaveData _current;
|
||||
private int _currentSlot = 0;
|
||||
|
||||
public static string LastCheckpointScene { get; private set; }
|
||||
public static string LastCheckpointSpawnId { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
|
||||
_storage = new LocalFileStorage();
|
||||
}
|
||||
|
||||
// ── ISaveable 注册 ────────────────────────────────────────────────────
|
||||
public void Register(ISaveable s) => _saveables.Add(s);
|
||||
public void Unregister(ISaveable s) => _saveables.Remove(s);
|
||||
|
||||
// ── 存档 ──────────────────────────────────────────────────────────────
|
||||
public async Task SaveAsync(int slot = -1)
|
||||
{
|
||||
int targetSlot = slot < 0 ? _currentSlot : slot;
|
||||
_current ??= new SaveData();
|
||||
|
||||
_onSaveIndicatorVisible?.Raise(true);
|
||||
foreach (var s in _saveables) s.OnSave(_current);
|
||||
|
||||
_current.Meta.LastSaved = DateTime.UtcNow.ToString("o");
|
||||
_current.Meta.SlotIndex = targetSlot;
|
||||
_current.Meta.SaveCount++;
|
||||
|
||||
// 先清空 checksum,序列化并计算,再序列化含 checksum 的最终版本
|
||||
_current.Meta.Checksum = null;
|
||||
string jsonForChecksum = JsonConvert.SerializeObject(_current, Formatting.Indented);
|
||||
_current.Meta.Checksum = ComputeChecksum(jsonForChecksum);
|
||||
string finalJson = JsonConvert.SerializeObject(_current, Formatting.Indented);
|
||||
|
||||
await _storage.WriteAsync(targetSlot, finalJson);
|
||||
|
||||
LastCheckpointScene = _current.Player?.Scene;
|
||||
LastCheckpointSpawnId = _current.Meta?.SavePointId;
|
||||
|
||||
_onSaveIndicatorVisible?.Raise(false);
|
||||
}
|
||||
|
||||
// ── 读档 ──────────────────────────────────────────────────────────────
|
||||
public async Task<bool> LoadAsync(int slot)
|
||||
{
|
||||
if (!_storage.Exists(slot)) return false;
|
||||
|
||||
string json = await _storage.ReadAsync(slot);
|
||||
if (json == null) return false;
|
||||
|
||||
SaveData loaded;
|
||||
try { loaded = JsonConvert.DeserializeObject<SaveData>(json); }
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[SaveManager] 存档解析失败 slot={slot}: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ValidateChecksum(loaded, json))
|
||||
{
|
||||
Debug.LogWarning("[SaveManager] 存档 checksum 校验失败。");
|
||||
// 非 SteelSoul 模式仍允许加载(只是警告)
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// ── 快速存档(挑战房间用)────────────────────────────────────────────
|
||||
public void QuickSave() => _ = SaveAsync(QuickSaveSlot);
|
||||
public void QuickLoad() => _ = LoadAsync(QuickSaveSlot);
|
||||
|
||||
// ── 世界标志 / EventChain ─────────────────────────────────────────────
|
||||
public bool GetFlag(string flagId)
|
||||
=> _current?.EventChains.WorldFlags.TryGetValue(flagId, out var v) == true && v;
|
||||
public void SetFlag(string flagId, bool value)
|
||||
{ _current ??= new SaveData(); _current.EventChains.WorldFlags[flagId] = value; }
|
||||
|
||||
public void SetChainCompleted(string chainId)
|
||||
{ _current ??= new SaveData(); _current.EventChains.ChainStates[chainId] = "Completed"; }
|
||||
public IEnumerable<string> GetCompletedChains()
|
||||
=> _current?.EventChains.ChainStates.Keys ?? Enumerable.Empty<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
public bool IsBossDefeated(string bossId)
|
||||
=> _current?.World.DefeatedBossIds.Contains(bossId) == true;
|
||||
|
||||
// ── 存档槽摘要 ────────────────────────────────────────────────────────
|
||||
public async Task<SlotSummary> GetSlotSummaryAsync(int slotIndex)
|
||||
{
|
||||
if (!_storage.Exists(slotIndex)) return null;
|
||||
string json = await _storage.ReadAsync(slotIndex);
|
||||
if (json == null) return null;
|
||||
try
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public void CreateSlot(int slotIndex)
|
||||
{
|
||||
_currentSlot = slotIndex;
|
||||
_current = new SaveData();
|
||||
_current.Meta.SlotIndex = slotIndex;
|
||||
}
|
||||
|
||||
// ── 私有工具 ──────────────────────────────────────────────────────────
|
||||
private string ComputeChecksum(string json)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
private string GetHmacKey()
|
||||
{
|
||||
string deviceId = SystemInfo.deviceUniqueIdentifier;
|
||||
const string salt = "ZelingV2SaveIntegrity_v1";
|
||||
return $"{deviceId}:{salt}";
|
||||
}
|
||||
|
||||
private bool ValidateChecksum(SaveData data, string rawJson)
|
||||
{
|
||||
if (string.IsNullOrEmpty(data?.Meta?.Checksum)) return true;
|
||||
var saved = data.Meta.Checksum;
|
||||
data.Meta.Checksum = null;
|
||||
string jsonNoChecksum = JsonConvert.SerializeObject(data, Formatting.Indented);
|
||||
data.Meta.Checksum = saved;
|
||||
return ComputeChecksum(jsonNoChecksum) == saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/SaveManager.cs.meta
Normal file
11
Assets/Scripts/Core/Save/SaveManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 43469211fc3d9c24d879ddb3f6f5af5c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
60
Assets/Scripts/Core/Save/SaveMigrator.cs
Normal file
60
Assets/Scripts/Core/Save/SaveMigrator.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理存档版本升级(旧版 → 新版数据结构)。
|
||||
/// </summary>
|
||||
public static class SaveMigrator
|
||||
{
|
||||
private const string CurrentVersion = "2.1";
|
||||
|
||||
public static SaveData Migrate(SaveData data)
|
||||
{
|
||||
if (data?.Meta == null) return 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": data = MigrateFrom2_0(data); goto case "2.1";
|
||||
case "2.1": break;
|
||||
default:
|
||||
Debug.LogWarning($"[SaveMigrator] 未知存档版本 '{data.Meta.Version}',跳过迁移。");
|
||||
break;
|
||||
}
|
||||
data.Meta.Version = CurrentVersion;
|
||||
return data;
|
||||
}
|
||||
|
||||
private static SaveData MigrateFrom1_0(SaveData d)
|
||||
{
|
||||
d.Equipment ??= new EquipmentSaveData();
|
||||
d.Player ??= new PlayerSaveData();
|
||||
d.World ??= new WorldSaveData();
|
||||
d.Equipment.UpgradedCharmIds ??= new List<string>();
|
||||
return d;
|
||||
}
|
||||
|
||||
private static SaveData MigrateFrom1_1(SaveData d)
|
||||
{
|
||||
d.Stats ??= new StatsSaveData();
|
||||
d.Stats.SkillUseCounts ??= new Dictionary<string, int>();
|
||||
if (d.Player.ShieldHP == 0 && !d.Player.ShieldIsBroken)
|
||||
d.Player.ShieldHP = -1;
|
||||
return d;
|
||||
}
|
||||
|
||||
private static SaveData MigrateFrom2_0(SaveData d)
|
||||
{
|
||||
// 2.0 → 2.1:Player.AbilityFlags (uint bitmask) 替换旧版 Dictionary<string,bool> Abilities
|
||||
// 旧版若 AbilityFlags 为 0 且 ExtensionData 含 "Abilities" 字段,则转换
|
||||
if (d.Player.AbilityFlags == 0 && d.ExtensionData.ContainsKey("Abilities"))
|
||||
{
|
||||
// 此处仅清除旧字段,具体位掩码由 AbilitySystem 在 OnLoad 时根据 ExtrensionData 转换
|
||||
d.ExtensionData.Remove("Abilities");
|
||||
}
|
||||
return d;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/SaveMigrator.cs.meta
Normal file
11
Assets/Scripts/Core/Save/SaveMigrator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93959fd98c4359d4198062ec532c6025
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user