chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: d0538e003f3abea4782eb60e4d29f17d
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 68ee29c1506209d44bf695a6bd1687b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7502422be575ad648885a485811ae81f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,12 @@
namespace BaseGames.Core.Save
{
/// <summary>
/// 各游戏系统组件实现此接口,
/// 向 SaveManager 提供/接受自己的存档数据。
/// </summary>
public interface ISaveable
{
void OnSave(SaveData saveData);
void OnLoad(SaveData saveData);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 516b89521df95184ca457b57d81a7523
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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");
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 880e1ba90110b4f44aed86f117361f1c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2c86ec3fc854db54da8ce69990da0497
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 43469211fc3d9c24d879ddb3f6f5af5c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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.1Player.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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 93959fd98c4359d4198062ec532c6025
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: