From 5a0b7a047b0f25e1b34fc55291ecd088d181bf98 Mon Sep 17 00:00:00 2001 From: Ding Yuntian <1491671119@qq.com> Date: Wed, 24 Jun 2026 22:45:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(save-system):=20=E6=96=B0=E5=A2=9E=20Yarn?= =?UTF-8?q?=20<>=20=E6=98=BE=E5=BC=8F=E5=AD=98=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=BD=92=E6=A1=A3=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scripts/SaveSystem/SavePointEvaluator.cs | 58 +++-- .../SaveSystem/SaveRestoreOrchestrator.cs | 48 ++-- Assets/Scripts/SaveSystem/SaveYarnCommand.cs | 24 ++ .../SaveSystem/SaveYarnCommand.cs.meta | 11 + Assets/Scripts/SaveSystem/SnapshotCapture.cs | 44 ++-- Assets/Scripts/SaveSystem/SnapshotService.cs | 5 +- Assets/Scripts/SaveSystem/TestSaveArchive.cs | 218 ++++++++++++++++++ .../SaveSystem/TestSaveArchive.cs.meta | 11 + 8 files changed, 372 insertions(+), 47 deletions(-) create mode 100644 Assets/Scripts/SaveSystem/SaveYarnCommand.cs create mode 100644 Assets/Scripts/SaveSystem/SaveYarnCommand.cs.meta create mode 100644 Assets/Scripts/SaveSystem/TestSaveArchive.cs create mode 100644 Assets/Scripts/SaveSystem/TestSaveArchive.cs.meta diff --git a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs index 563986f9c..bb15684b3 100644 --- a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs +++ b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs @@ -61,26 +61,28 @@ namespace AibisDream.SaveSystem "end", // 维修结束/收尾 }; + /// + /// 当前是否可以执行 Yarn 显式 <<save>> 命令。 + /// 仅检查全局门控,不检查节点 tag / no_save。 + /// + public static bool CanExplicitSave(out SavePointRejectReason reason) + { + if (!TryPassGlobalSaveGuards(out reason)) + { + return false; + } + + reason = SavePointRejectReason.None; + return true; + } + /// /// 当前是否可以自动存档。 /// public static bool CanAutoSave(out SavePointRejectReason reason) { - if (SaveRestoreOrchestrator.IsAutoSaveSuppressed) + if (!TryPassGlobalSaveGuards(out reason)) { - reason = SavePointRejectReason.AutoSaveSuppressed; - return false; - } - - if (SaveRestoreOrchestrator.IsRestoring) - { - reason = SavePointRejectReason.Restoring; - return false; - } - - if (GameManager.Instance != null && GameManager.Instance.state.isInPause) - { - reason = SavePointRejectReason.GamePaused; return false; } @@ -143,11 +145,11 @@ namespace AibisDream.SaveSystem /// /// 当前是否可以手动存档。 - /// 手动档 = 复制最近自动档,因此需要当前可自动存档且自动档存在。 + /// 手动档 = 复制最近落盘的自动档(含 OnNodeStart 与显式 <<save>>)。 /// public static bool CanManualSave(out SavePointRejectReason reason) { - if (!CanAutoSave(out reason)) + if (!TryPassGlobalSaveGuards(out reason)) { return false; } @@ -162,6 +164,30 @@ namespace AibisDream.SaveSystem return true; } + private static bool TryPassGlobalSaveGuards(out SavePointRejectReason reason) + { + if (SaveRestoreOrchestrator.IsAutoSaveSuppressed) + { + reason = SavePointRejectReason.AutoSaveSuppressed; + return false; + } + + if (SaveRestoreOrchestrator.IsRestoring) + { + reason = SavePointRejectReason.Restoring; + return false; + } + + if (GameManager.Instance != null && GameManager.Instance.state.isInPause) + { + reason = SavePointRejectReason.GamePaused; + return false; + } + + reason = SavePointRejectReason.None; + return true; + } + private static bool IsTagPresent(IReadOnlyList tags, string tag) { if (tags == null) return false; diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs index 71350cd9b..9abdf4728 100644 --- a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs +++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs @@ -47,10 +47,13 @@ namespace AibisDream.SaveSystem } /// 自动存档协程:settle 一帧 → 主线程 Capture → 异步写盘。 - public static IEnumerator AutoSaveRoutine(string triggerNodeName = null) + /// ;显式 save 传 null。 + /// 为 true 时不写入 Yarn 节点 anchor。 + public static IEnumerator AutoSaveRoutine(string triggerNodeName = null, bool omitAnchor = false) { if (_isAutoSaving) { + Debug.LogWarning("[SaveRestoreOrchestrator] 存档进行中,跳过重复请求。"); yield break; } @@ -58,6 +61,7 @@ namespace AibisDream.SaveSystem if (_isAutoSaving) { + Debug.LogWarning("[SaveRestoreOrchestrator] 存档进行中,跳过重复请求。"); yield break; } @@ -72,26 +76,34 @@ namespace AibisDream.SaveSystem using (new CodeTimer("SaveSnapshot")) { - snapshot = SnapshotService.Capture(triggerNodeName); + snapshot = SnapshotService.Capture(triggerNodeName, omitAnchor); thumbnail = SlotThumbnailCapture.CapturePng(); } - var writeTask = SlotManager.SaveToAutoSlotAsync(snapshot, thumbnail); - while (!writeTask.IsCompleted) + if (TestSaveArchive.IsEnabled) { - yield return null; + TestSaveArchive.Archive(snapshot, thumbnail); + Debug.Log("[SaveRestoreOrchestrator] 测试存档模式:已写入 testsavs(不覆盖 slot_0)。"); + } + else + { + var writeTask = SlotManager.SaveToAutoSlotAsync(snapshot, thumbnail); + while (!writeTask.IsCompleted) + { + yield return null; + } + + if (writeTask.IsFaulted) + { + Debug.LogError( + $"[SaveRestoreOrchestrator] 自动存档失败: {writeTask.Exception?.GetBaseException()}"); + yield break; + } } - if (writeTask.IsFaulted) + if (omitAnchor || (snapshot?.anchor != null && string.IsNullOrEmpty(snapshot.anchor.nodeName))) { - Debug.LogError( - $"[SaveRestoreOrchestrator] 自动存档失败: {writeTask.Exception?.GetBaseException()}"); - yield break; - } - - if (snapshot?.anchor != null && string.IsNullOrEmpty(snapshot.anchor.nodeName)) - { - Debug.Log("[SaveRestoreOrchestrator] 已保存无 Yarn 节点活跃状态。"); + Debug.Log("[SaveRestoreOrchestrator] 已保存无 Yarn 节点 anchor 的状态。"); } } finally @@ -101,6 +113,14 @@ namespace AibisDream.SaveSystem } } + /// + /// Yarn <<save>> 显式存档:默认 omit anchor,调用方需已通过 。 + /// + public static IEnumerator ExplicitSaveRoutine() + { + yield return AutoSaveRoutine(triggerNodeName: null, omitAnchor: true); + } + /// 将当前自动档复制到指定手动档。 public static void CreateManualSlot(int slotIndex) { diff --git a/Assets/Scripts/SaveSystem/SaveYarnCommand.cs b/Assets/Scripts/SaveSystem/SaveYarnCommand.cs new file mode 100644 index 000000000..87c3fcd71 --- /dev/null +++ b/Assets/Scripts/SaveSystem/SaveYarnCommand.cs @@ -0,0 +1,24 @@ +using System.Collections; +using UnityEngine; +using Yarn.Unity; + +namespace AibisDream.SaveSystem +{ + /// + /// Yarn 显式存档命令。在节点末尾、状态命令执行完毕且即将进入无对话阶段时使用。 + /// + public static class SaveYarnCommand + { + [YarnCommand("save")] + public static IEnumerator Save() + { + if (!SavePointEvaluator.CanExplicitSave(out var reason)) + { + Debug.LogWarning($"[SaveYarnCommand] <> 被拒绝: {reason}"); + yield break; + } + + yield return SaveRestoreOrchestrator.ExplicitSaveRoutine(); + } + } +} diff --git a/Assets/Scripts/SaveSystem/SaveYarnCommand.cs.meta b/Assets/Scripts/SaveSystem/SaveYarnCommand.cs.meta new file mode 100644 index 000000000..f39329dcb --- /dev/null +++ b/Assets/Scripts/SaveSystem/SaveYarnCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1fc9f19f937b87d4d8fd6d2f057ca14c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/SaveSystem/SnapshotCapture.cs b/Assets/Scripts/SaveSystem/SnapshotCapture.cs index 908ad7ddd..37d949a13 100644 --- a/Assets/Scripts/SaveSystem/SnapshotCapture.cs +++ b/Assets/Scripts/SaveSystem/SnapshotCapture.cs @@ -18,7 +18,13 @@ namespace AibisDream.SaveSystem /// 触发存档时的 Yarn 节点名(通常为 OnNodeStart 传入值)。 /// 非 null 时作为 anchor,并与 Capture 时刻的当前节点比较;不一致时打 warning,不阻断写盘。 /// - public static SaveSnapshot Capture(YarnVariableStorage storage, string triggerNodeName = null) + /// + /// 为 true 时写入空 (显式 <<save>> 默认行为)。 + /// + public static SaveSnapshot Capture( + YarnVariableStorage storage, + string triggerNodeName = null, + bool omitAnchor = false) { SnapshotRegistry.ValidateRequiredProviders(); @@ -29,7 +35,7 @@ namespace AibisDream.SaveSystem }; CaptureScene(snapshot); - CaptureAnchor(snapshot, triggerNodeName); + CaptureAnchor(snapshot, triggerNodeName, omitAnchor); CaptureYarnVariables(storage, snapshot); foreach (var provider in SnapshotRegistry.GetOrderedProviders()) @@ -51,23 +57,31 @@ namespace AibisDream.SaveSystem }; } - private static void CaptureAnchor(SaveSnapshot snapshot, string triggerNodeName) + private static void CaptureAnchor(SaveSnapshot snapshot, string triggerNodeName, bool omitAnchor) { var dialog = DialogController.Instance; if (dialog == null) return; - var (currentNodeName, _) = dialog.GetCurrentNodeContext(); - var anchorNodeName = triggerNodeName ?? currentNodeName; - - if (triggerNodeName != null - && !string.Equals( - NormalizeNodeName(triggerNodeName), - NormalizeNodeName(currentNodeName), - StringComparison.Ordinal)) + string anchorNodeName; + if (omitAnchor) { - Debug.LogWarning( - $"[SnapshotCapture] 存档锚点漂移:触发时「{FormatNodeName(triggerNodeName)}」," + - $"Capture 时「{FormatNodeName(currentNodeName)}」。anchor 使用触发节点。"); + anchorNodeName = string.Empty; + } + else + { + var (currentNodeName, _) = dialog.GetCurrentNodeContext(); + anchorNodeName = triggerNodeName ?? currentNodeName ?? string.Empty; + + if (triggerNodeName != null + && !string.Equals( + NormalizeNodeName(triggerNodeName), + NormalizeNodeName(currentNodeName), + StringComparison.Ordinal)) + { + Debug.LogWarning( + $"[SnapshotCapture] 存档锚点漂移:触发时「{FormatNodeName(triggerNodeName)}」," + + $"Capture 时「{FormatNodeName(currentNodeName)}」。anchor 使用触发节点。"); + } } var yarnProject = dialog.DialogueRunner?.YarnProject; @@ -80,7 +94,7 @@ namespace AibisDream.SaveSystem { sceneSoName = sceneSoName, yarnProjectId = projectId, - nodeName = anchorNodeName ?? string.Empty + nodeName = anchorNodeName }; } diff --git a/Assets/Scripts/SaveSystem/SnapshotService.cs b/Assets/Scripts/SaveSystem/SnapshotService.cs index ac2481616..7bab9c7c9 100644 --- a/Assets/Scripts/SaveSystem/SnapshotService.cs +++ b/Assets/Scripts/SaveSystem/SnapshotService.cs @@ -10,10 +10,11 @@ namespace AibisDream.SaveSystem { /// 捕获当前运行时快照。 /// 。 - public static SaveSnapshot Capture(string triggerNodeName = null) + /// 。 + public static SaveSnapshot Capture(string triggerNodeName = null, bool omitAnchor = false) { SnapshotRegistry.EnsureInitialized(); - return SnapshotCapture.Capture(YarnVariableStorage.Instance, triggerNodeName); + return SnapshotCapture.Capture(YarnVariableStorage.Instance, triggerNodeName, omitAnchor); } /// diff --git a/Assets/Scripts/SaveSystem/TestSaveArchive.cs b/Assets/Scripts/SaveSystem/TestSaveArchive.cs new file mode 100644 index 000000000..a17642f4b --- /dev/null +++ b/Assets/Scripts/SaveSystem/TestSaveArchive.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AibisDream.Utility; +using Newtonsoft.Json; +using UnityEngine; + +namespace AibisDream.SaveSystem +{ + /// + /// 测试存档模式:每次自动存档写入 下的独立子目录。 + /// 开启后不写入 slot_0,存多少次就保留多少份,与正式 saves/ 完全隔离。 + /// + public static class TestSaveArchive + { + public const string EditorPrefsEnabledKey = "AibisDream.SaveSystem.TestArchiveEnabled"; + + /// 测试存档模式是否开启(Editor 下由 EditorPrefs 控制,构建产物恒为 false)。 + public static bool IsEnabled + { + get + { +#if UNITY_EDITOR + return UnityEditor.EditorPrefs.GetBool(EditorPrefsEnabledKey, true); +#else + return false; +#endif + } + } + + private static readonly object ArchiveWriteLock = new object(); + + /// 将快照写入 testsavs 新条目,返回文件夹名。每次调用均新建,不覆盖已有条目。 + public static string Archive(SaveSnapshot snapshot, byte[] thumbnailPng) + { + if (snapshot == null) + { + return null; + } + + var entryId = BuildUniqueEntryId(snapshot); + var entryDir = Path.Combine(ConstRef.TestAutoSaveArchivePath, entryId); + Directory.CreateDirectory(entryDir); + + var json = SnapshotPersistence.Serialize(snapshot); + var meta = BuildMeta(entryId, snapshot); + + lock (ArchiveWriteLock) + { + SlotAtomicWriter.WriteText(json, Path.Combine(entryDir, $"{ConstRef.SaveSnapshotFileName}.json")); + SlotAtomicWriter.WriteJson(meta, Path.Combine(entryDir, $"{ConstRef.SaveMetaFileName}.json")); + + if (thumbnailPng != null) + { + SlotAtomicWriter.WriteBytes( + thumbnailPng, + Path.Combine(entryDir, $"{ConstRef.SaveThumbnailFileName}.png")); + } + } + + Debug.Log($"[TestSaveArchive] 已归档测试存档: {entryId}"); + return entryId; + } + + public static List ListEntries() + { + var result = new List(); + if (!Directory.Exists(ConstRef.TestAutoSaveArchivePath)) + { + return result; + } + + foreach (var dir in Directory.GetDirectories(ConstRef.TestAutoSaveArchivePath)) + { + var entryId = Path.GetFileName(dir); + var snapshotPath = Path.Combine(dir, $"{ConstRef.SaveSnapshotFileName}.json"); + if (!File.Exists(snapshotPath)) + { + continue; + } + + var metaPath = Path.Combine(dir, $"{ConstRef.SaveMetaFileName}.json"); + var thumbPath = Path.Combine(dir, $"{ConstRef.SaveThumbnailFileName}.png"); + + SlotMeta meta = null; + if (File.Exists(metaPath)) + { + try + { + meta = JsonConvert.DeserializeObject(File.ReadAllText(metaPath)); + } + catch (Exception ex) + { + Debug.LogWarning($"[TestSaveArchive] 读取 meta 失败 ({entryId}): {ex.Message}"); + } + } + + result.Add(new TestSaveArchiveEntry( + entryId, + dir, + snapshotPath, + File.Exists(thumbPath) ? thumbPath : null, + meta)); + } + + return result + .OrderByDescending(e => e.Meta?.savedAt ?? string.Empty) + .ThenByDescending(e => e.EntryId, StringComparer.Ordinal) + .ToList(); + } + + public static void DeleteEntry(string entryId) + { + if (string.IsNullOrEmpty(entryId)) + { + return; + } + + var dir = Path.Combine(ConstRef.TestAutoSaveArchivePath, entryId); + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + + public static void ClearAll() + { + if (!Directory.Exists(ConstRef.TestAutoSaveArchivePath)) + { + return; + } + + foreach (var dir in Directory.GetDirectories(ConstRef.TestAutoSaveArchivePath)) + { + Directory.Delete(dir, recursive: true); + } + } + + private static string BuildUniqueEntryId(SaveSnapshot snapshot) + { + var baseId = BuildEntryId(snapshot); + var entryId = baseId; + var suffix = 2; + + while (Directory.Exists(Path.Combine(ConstRef.TestAutoSaveArchivePath, entryId))) + { + entryId = $"{baseId}_{suffix}"; + suffix++; + } + + return entryId; + } + + private static string BuildEntryId(SaveSnapshot snapshot) + { + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff"); + var nodeName = snapshot.anchor?.nodeName; + var safeNode = string.IsNullOrEmpty(nodeName) ? "no_node" : SanitizeEntryName(nodeName); + return $"{timestamp}_{safeNode}"; + } + + private static SlotMeta BuildMeta(string entryId, SaveSnapshot snapshot) + { + var summary = SaveSnapshotSummary.From(snapshot); + return new SlotMeta + { + slotIndex = -1, + savedAt = snapshot.savedAt, + sceneName = summary.SceneName, + sceneSoName = summary.SceneSoName, + yarnProjectId = summary.YarnProjectId, + nodeName = snapshot.anchor?.nodeName, + schemaVersion = snapshot.schemaVersion, + gameVersion = snapshot.gameVersion, + thumbnailFile = $"{ConstRef.SaveThumbnailFileName}.png" + }; + } + + private static string SanitizeEntryName(string name) + { + foreach (var c in Path.GetInvalidFileNameChars()) + { + name = name.Replace(c, '_'); + } + + if (name.Length > 48) + { + name = name.Substring(0, 48); + } + + return string.IsNullOrWhiteSpace(name) ? "no_node" : name; + } + } + + public readonly struct TestSaveArchiveEntry + { + public readonly string EntryId; + public readonly string DirectoryPath; + public readonly string SnapshotPath; + public readonly string ThumbnailPath; + public readonly SlotMeta Meta; + + public TestSaveArchiveEntry( + string entryId, + string directoryPath, + string snapshotPath, + string thumbnailPath, + SlotMeta meta) + { + EntryId = entryId; + DirectoryPath = directoryPath; + SnapshotPath = snapshotPath; + ThumbnailPath = thumbnailPath; + Meta = meta; + } + } +} diff --git a/Assets/Scripts/SaveSystem/TestSaveArchive.cs.meta b/Assets/Scripts/SaveSystem/TestSaveArchive.cs.meta new file mode 100644 index 000000000..b4b7aa658 --- /dev/null +++ b/Assets/Scripts/SaveSystem/TestSaveArchive.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 408acae5dfcc0a94094d87e33ba53cd4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: