From 0a6e892504f30f117537dd995d97cb7bd8c2ed25 Mon Sep 17 00:00:00 2001 From: Ding Yuntian <1491671119@qq.com> Date: Thu, 18 Jun 2026 14:34:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(save):=20=E5=AE=9E=E7=8E=B0=20Yarn=20tag?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E5=AD=98=E6=A1=A3=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E4=B8=8E=E5=BC=82=E6=AD=A5=E5=86=99=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Scripts/Dialog System/DialogController.cs | 8 +- .../Scripts/Game Loop/YarnVariableStorage.cs | 38 +++++-- .../Scripts/SaveSystem/SavePointEvaluator.cs | 106 ++++++++++++++++-- .../SaveSystem/SaveRestoreOrchestrator.cs | 78 ++++++++++--- Assets/Scripts/SaveSystem/SlotFileSystem.cs | 30 ++++- Assets/Scripts/SaveSystem/SlotManager.cs | 50 +++++++-- Assets/Scripts/SaveSystem/SnapshotCapture.cs | 37 +++++- Assets/Scripts/SaveSystem/SnapshotService.cs | 5 +- 8 files changed, 291 insertions(+), 61 deletions(-) diff --git a/Assets/Scripts/Dialog System/DialogController.cs b/Assets/Scripts/Dialog System/DialogController.cs index b2031a28d..0ba7b236a 100644 --- a/Assets/Scripts/Dialog System/DialogController.cs +++ b/Assets/Scripts/Dialog System/DialogController.cs @@ -162,7 +162,13 @@ namespace AibisDream private void OnNodeStart(string nodeName) { UpdateCurrentNodeContext(nodeName); - SaveRestoreOrchestrator.TryAutoSave(); + + if (!SavePointEvaluator.CanAutoSave(out _)) + { + return; + } + + StartCoroutine(SaveRestoreOrchestrator.AutoSaveRoutine(nodeName)); } private void OnNodeComplete(string nodeName) diff --git a/Assets/Scripts/Game Loop/YarnVariableStorage.cs b/Assets/Scripts/Game Loop/YarnVariableStorage.cs index 4891c22c9..d4dc580c6 100644 --- a/Assets/Scripts/Game Loop/YarnVariableStorage.cs +++ b/Assets/Scripts/Game Loop/YarnVariableStorage.cs @@ -4,6 +4,7 @@ using System.Linq; using AibisDream.Framework; using AibisDream.SaveSystem; using UnityEngine; +using Yarn; using Yarn.Unity; namespace AibisDream @@ -94,17 +95,36 @@ namespace AibisDream return false; } - if (_variableDict.TryGetValue(variableName, out var value)) + switch (GetVariableKind(variableName)) { - if (value is T target) - { - result = target; - return true; - } - } + case VariableKind.Stored: + if (_variableDict.TryGetValue(variableName, out var value) && value is T target) + { + result = target; + return true; + } - result = default; - return false; + if (Program == null) + { + throw new InvalidOperationException( + $"Can't get initial value for variable {variableName}, because {nameof(Program)} is not set"); + } + + return Program.TryGetInitialValue(variableName, out result); + + case VariableKind.Smart: + if (SmartVariableEvaluator == null) + { + throw new InvalidOperationException( + $"Can't get value for smart variable {variableName}, because {nameof(SmartVariableEvaluator)} is not set"); + } + + return SmartVariableEvaluator.TryGetSmartVariable(variableName, out result); + + default: + result = default; + return false; + } } #endregion diff --git a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs index 113d160c4..735e449c7 100644 --- a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs +++ b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + namespace AibisDream.SaveSystem { /// @@ -8,21 +12,54 @@ namespace AibisDream.SaveSystem None, Restoring, GamePaused, + + // 遗留值:早期逻辑曾返回,当前实现中不再使用,保留以避免外部代码编译失败。 NoActiveNode, StartNode, FunctionNode, - NoAutoSave + NoAutoSave, + + /// 节点 tag 在默认禁止保存集合中。 + DeniedByTag, + + /// 节点显式附加了 no_save 标记。 + MarkedNoSave, } /// /// 可存点判定器。 /// - /// P3 只搭框架:规则不是最终版,后续随 Yarn 节点 tags 整理和章节策略扩展而更新。 - /// 当前所有 FixState 均放行,不可存状态由 Yarn 节点流程控制。 + /// 基于 Yarn 节点 tag 判定是否允许自动存档: + /// - 白名单 tag(hub / linear / content)默认允许; + /// - 黑名单 tag(start / init / function / detour / center / performance / event / end)默认禁止; + /// - no_save 附加标记覆盖默认语义,一律禁止; + /// - 无活跃节点、或节点未声明 tag,默认允许并打 warning; + /// - 未知 tag 默认允许并打 warning。 /// /// public static class SavePointEvaluator { + /// 默认可作为自动存档边界的节点 tag。 + private static readonly HashSet AutoSaveAllowedTags = new(StringComparer.OrdinalIgnoreCase) + { + "hub", // 梦境:场景导航中枢 + "linear", // 梦境:线性剧情推进 + "content", // 维修:阶段内容节点 + }; + + /// 默认禁止作为自动存档边界的节点 tag。 + private static readonly HashSet AutoSaveDeniedTags = new(StringComparer.OrdinalIgnoreCase) + { + "start", // 引擎入口 + "init", // 变量声明/初始化 + "function", // 纯指令函数 + "detour", // 梦境内容片段 + "center", // 维修阶段调度中枢 + "performance", // 维修原子化演出 + "event", // 维修 C# 事件响应入口 + "end", // 维修结束/收尾 + }; + /// /// 当前是否可以自动存档。 /// @@ -44,24 +81,55 @@ namespace AibisDream.SaveSystem ? DialogController.Instance.GetCurrentNodeContext() : (null, null); - if (string.IsNullOrEmpty(nodeName)) + // no_save 标记覆盖一切:即使节点类型默认可存,也不允许保存。 + if (tags != null && IsTagPresent(tags, "no_save")) { - reason = SavePointRejectReason.NoActiveNode; + reason = SavePointRejectReason.MarkedNoSave; return false; } - if (nodeName == "Start") + // 无活跃节点,或节点未声明任何 tag:允许保存。 + // 注:FixSystemNew 等交互状态可能没有活跃 Yarn 节点,仍需保存当前状态。 + if (string.IsNullOrEmpty(nodeName) || tags == null || tags.Count == 0) { - reason = SavePointRejectReason.StartNode; + if (!string.IsNullOrEmpty(nodeName) && (tags == null || tags.Count == 0)) + { + Debug.LogWarning( + $"[SavePointEvaluator] 节点 {nodeName} 未声明任何 tag,默认允许自动存档。请补全节点类型标签。"); + } + + reason = SavePointRejectReason.None; + return true; + } + + // 按主 tag 判定。 + var primaryTag = FindPrimaryTag(tags); + + // 未识别到已知主 tag:放行但警告,方便脚本作者补标。 + if (primaryTag == null) + { + Debug.LogWarning( + $"[SavePointEvaluator] 节点 {nodeName} 的 tags [{string.Join(", ", tags)}] 未声明保存语义,默认允许自动存档。"); + reason = SavePointRejectReason.None; + return true; + } + + if (AutoSaveDeniedTags.Contains(primaryTag)) + { + reason = SavePointRejectReason.DeniedByTag; return false; } - if (tags != null && IsTagPresent(tags, "function")) + if (AutoSaveAllowedTags.Contains(primaryTag)) { - reason = SavePointRejectReason.FunctionNode; - return false; + reason = SavePointRejectReason.None; + return true; } + // 理论上不会到达:已识别主 tag 但既不在允许集合也不在禁止集合。 + // 保持放行并警告,避免未来新增 tag 时意外阻断存档。 + Debug.LogWarning( + $"[SavePointEvaluator] 节点 {nodeName} 的 tag '{primaryTag}' 未声明保存语义,默认允许自动存档。"); reason = SavePointRejectReason.None; return true; } @@ -87,7 +155,7 @@ namespace AibisDream.SaveSystem return true; } - private static bool IsTagPresent(System.Collections.Generic.IReadOnlyList tags, string tag) + private static bool IsTagPresent(IReadOnlyList tags, string tag) { if (tags == null) return false; @@ -101,5 +169,21 @@ namespace AibisDream.SaveSystem return false; } + + /// + /// 从 tags 中找到第一个已知的保存语义主 tag;若均未知则返回 null。 + /// + private static string FindPrimaryTag(IReadOnlyList tags) + { + foreach (var tag in tags) + { + if (AutoSaveAllowedTags.Contains(tag) || AutoSaveDeniedTags.Contains(tag)) + { + return tag; + } + } + + return null; + } } } diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs index 0b2f1f4e7..0fd8107ae 100644 --- a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs +++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs @@ -1,7 +1,6 @@ -using System.Collections; +using System.Collections; using System.Collections.Generic; using AibisDream.Framework; -using AibisDream.Kit; using AibisDream.UI; using Newtonsoft.Json.Linq; using UnityEngine; @@ -11,7 +10,8 @@ namespace AibisDream.SaveSystem /// /// 存读档流程编排层:连接 UI、 与槽位系统。 /// - /// 对外入口:(节点进入事件触发)、 + /// 对外入口:(手动/调试触发)、 + /// (节点进入事件触发)、 /// (手动存档)、 /// (读档)、 /// (旧档/调试)。 @@ -22,11 +22,12 @@ namespace AibisDream.SaveSystem /// 是否正在读档还原中;为 true 时禁止自动存档覆盖 slot_0。 public static bool IsRestoring { get; private set; } - /// 捕获快照并写入自动档(slot 0);含保存 Loading UI。 + private static bool _isAutoSaving; + + /// 启动自动存档协程(手动/调试入口)。 public static void TryAutoSave() { - var host = YarnVariableStorage.Instance; - if (host == null) + if (YarnVariableStorage.Instance == null) { Debug.LogError("[SaveRestoreOrchestrator] YarnVariableStorage 未初始化"); return; @@ -38,17 +39,64 @@ namespace AibisDream.SaveSystem return; } - ActionKit.Sequence() - .Callback(() => UIManager.Instance.GetPanel().ShowSaveLoading()) - .Delay(1f) - .Callback(() => UIManager.Instance.GetPanel().HideSaveLoading()) - .Start(host); + var (triggerNodeName, _) = DialogController.Instance.GetCurrentNodeContext(); + DialogController.Instance.StartCoroutine(AutoSaveRoutine(triggerNodeName)); + } - using (new CodeTimer("SaveSnapshot")) + /// 自动存档协程:settle 一帧 → 主线程 Capture → 异步写盘。 + /// OnNodeStart 触发存档时的节点名,作为 anchor 写入快照。 + public static IEnumerator AutoSaveRoutine(string triggerNodeName = null) + { + if (_isAutoSaving) { - var snapshot = SnapshotService.Capture(); - var thumbnail = SlotThumbnailCapture.CapturePng(); - SlotManager.SaveToAutoSlot(snapshot, thumbnail); + yield break; + } + + yield return null; + + // 可能在 settle 帧内已有另一条协程开始写盘,须二次检查避免并发覆盖 slot_0。 + if (_isAutoSaving) + { + yield break; + } + + _isAutoSaving = true; + var infoPanel = UIManager.Instance.GetPanel(); + infoPanel?.ShowSaveLoading(); + + try + { + SaveSnapshot snapshot; + byte[] thumbnail; + + using (new CodeTimer("SaveSnapshot")) + { + snapshot = SnapshotService.Capture(triggerNodeName); + thumbnail = SlotThumbnailCapture.CapturePng(); + } + + var writeTask = SlotManager.SaveToAutoSlotAsync(snapshot, thumbnail); + while (!writeTask.IsCompleted) + { + yield return null; + } + + if (writeTask.IsFaulted) + { + Debug.LogError( + $"[SaveRestoreOrchestrator] 自动存档失败: {writeTask.Exception?.GetBaseException()}"); + yield break; + } + + if (snapshot?.anchor != null && string.IsNullOrEmpty(snapshot.anchor.nodeName)) + { + Debug.Log("[SaveRestoreOrchestrator] 已保存无 Yarn 节点活跃状态。"); + } + } + finally + { + infoPanel?.HideSaveLoading(); + _isAutoSaving = false; } } diff --git a/Assets/Scripts/SaveSystem/SlotFileSystem.cs b/Assets/Scripts/SaveSystem/SlotFileSystem.cs index 4083eb4b6..d4483e068 100644 --- a/Assets/Scripts/SaveSystem/SlotFileSystem.cs +++ b/Assets/Scripts/SaveSystem/SlotFileSystem.cs @@ -1,6 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using AibisDream.Utility; using Newtonsoft.Json; using UnityEngine; @@ -109,12 +110,29 @@ namespace AibisDream.SaveSystem private static void CommitTempFile(string tempPath, string finalPath) { - if (File.Exists(finalPath)) - { - File.Delete(finalPath); - } + const int maxAttempts = 5; - File.Move(tempPath, finalPath); + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + try + { + if (File.Exists(finalPath)) + { + // Unity 使用的 .NET Standard 2.0 无 File.Move(..., overwrite);Replace 为原子覆盖。 + File.Replace(tempPath, finalPath, null); + } + else + { + File.Move(tempPath, finalPath); + } + + return; + } + catch (IOException) when (attempt < maxAttempts - 1) + { + Thread.Sleep(20 * (attempt + 1)); + } + } } } } diff --git a/Assets/Scripts/SaveSystem/SlotManager.cs b/Assets/Scripts/SaveSystem/SlotManager.cs index f49267230..174dc2c42 100644 --- a/Assets/Scripts/SaveSystem/SlotManager.cs +++ b/Assets/Scripts/SaveSystem/SlotManager.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.IO; using System.Collections.Generic; +using System.Threading.Tasks; using AibisDream.SaveSystem.CloudSave; using AibisDream.Utility; using Newtonsoft.Json; @@ -14,6 +15,7 @@ namespace AibisDream.SaveSystem public static class SlotManager { private static bool _migrated; + private static readonly object SlotWriteLock = new object(); /// 将快照与缩略图写入指定槽位。 public static void SaveToSlot(int slotIndex, SaveSnapshot snapshot, byte[] thumbnailPng) @@ -30,6 +32,23 @@ namespace AibisDream.SaveSystem CloudSaveManager.OnSlotWritten(SlotIndex.Auto); } + /// + /// 主线程完成序列化后,在后台线程写入自动档(slot 0)。 + /// 调用方须已在主线程完成 。 + /// + public static async Task SaveToAutoSlotAsync(SaveSnapshot snapshot, byte[] thumbnailPng) + { + EnsureMigrated(); + + var json = SnapshotPersistence.Serialize(snapshot); + var meta = BuildSlotMeta(SlotIndex.Auto, snapshot); + + await Task.Run(() => WriteSlotFiles(SlotIndex.Auto, json, meta, thumbnailPng)); + + SetLatestSlotIndex(SlotIndex.Auto); + CloudSaveManager.OnSlotWritten(SlotIndex.Auto); + } + /// 将当前自动档复制到指定手动档。 public static void CopyAutoToManual(int manualSlotIndex) { @@ -226,16 +245,15 @@ namespace AibisDream.SaveSystem private static void WriteSlot(int slotIndex, SaveSnapshot snapshot, byte[] thumbnailPng) { - SlotDirectory.EnsureSlotDirectory(slotIndex); - - // snapshot.json - var snapshotPath = SlotDirectory.GetSnapshotPath(slotIndex); var json = SnapshotPersistence.Serialize(snapshot); - SlotAtomicWriter.WriteText(json, snapshotPath); + var meta = BuildSlotMeta(slotIndex, snapshot); + WriteSlotFiles(slotIndex, json, meta, thumbnailPng); + } - // meta.json + private static SlotMeta BuildSlotMeta(int slotIndex, SaveSnapshot snapshot) + { var summary = SaveSnapshotSummary.From(snapshot); - var meta = new SlotMeta + return new SlotMeta { slotIndex = slotIndex, savedAt = snapshot.savedAt, @@ -247,12 +265,20 @@ namespace AibisDream.SaveSystem gameVersion = snapshot.gameVersion, thumbnailFile = $"{ConstRef.SaveThumbnailFileName}.png" }; - WriteMeta(slotIndex, meta); + } - // thumbnail.png - if (thumbnailPng != null) + private static void WriteSlotFiles(int slotIndex, string json, SlotMeta meta, byte[] thumbnailPng) + { + lock (SlotWriteLock) { - SlotAtomicWriter.WriteBytes(thumbnailPng, SlotDirectory.GetThumbnailPath(slotIndex)); + SlotDirectory.EnsureSlotDirectory(slotIndex); + SlotAtomicWriter.WriteText(json, SlotDirectory.GetSnapshotPath(slotIndex)); + SlotAtomicWriter.WriteJson(meta, SlotDirectory.GetMetaPath(slotIndex)); + + if (thumbnailPng != null) + { + SlotAtomicWriter.WriteBytes(thumbnailPng, SlotDirectory.GetThumbnailPath(slotIndex)); + } } } diff --git a/Assets/Scripts/SaveSystem/SnapshotCapture.cs b/Assets/Scripts/SaveSystem/SnapshotCapture.cs index 44dd55d4c..908ad7ddd 100644 --- a/Assets/Scripts/SaveSystem/SnapshotCapture.cs +++ b/Assets/Scripts/SaveSystem/SnapshotCapture.cs @@ -14,7 +14,11 @@ namespace AibisDream.SaveSystem public static class SnapshotCapture { /// 捕获当前完整快照。调用前需已注册全部 provider。 - public static SaveSnapshot Capture(YarnVariableStorage storage) + /// + /// 触发存档时的 Yarn 节点名(通常为 OnNodeStart 传入值)。 + /// 非 null 时作为 anchor,并与 Capture 时刻的当前节点比较;不一致时打 warning,不阻断写盘。 + /// + public static SaveSnapshot Capture(YarnVariableStorage storage, string triggerNodeName = null) { SnapshotRegistry.ValidateRequiredProviders(); @@ -25,7 +29,7 @@ namespace AibisDream.SaveSystem }; CaptureScene(snapshot); - CaptureAnchor(snapshot); + CaptureAnchor(snapshot, triggerNodeName); CaptureYarnVariables(storage, snapshot); foreach (var provider in SnapshotRegistry.GetOrderedProviders()) @@ -47,12 +51,25 @@ namespace AibisDream.SaveSystem }; } - private static void CaptureAnchor(SaveSnapshot snapshot) + private static void CaptureAnchor(SaveSnapshot snapshot, string triggerNodeName) { var dialog = DialogController.Instance; if (dialog == null) return; - var (nodeName, _) = dialog.GetCurrentNodeContext(); + var (currentNodeName, _) = dialog.GetCurrentNodeContext(); + var anchorNodeName = triggerNodeName ?? currentNodeName; + + 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; var projectId = yarnProject != null ? yarnProject.name : string.Empty; @@ -63,10 +80,20 @@ namespace AibisDream.SaveSystem { sceneSoName = sceneSoName, yarnProjectId = projectId, - nodeName = nodeName ?? string.Empty + nodeName = anchorNodeName ?? string.Empty }; } + private static string NormalizeNodeName(string nodeName) + { + return string.IsNullOrEmpty(nodeName) ? string.Empty : nodeName; + } + + private static string FormatNodeName(string nodeName) + { + return string.IsNullOrEmpty(nodeName) ? "(无节点)" : nodeName; + } + private static void CaptureYarnVariables(YarnVariableStorage storage, SaveSnapshot snapshot) { var (floats, strings, bools) = storage.GetAllVariables(); diff --git a/Assets/Scripts/SaveSystem/SnapshotService.cs b/Assets/Scripts/SaveSystem/SnapshotService.cs index 1dd22cd32..84f3f68da 100644 --- a/Assets/Scripts/SaveSystem/SnapshotService.cs +++ b/Assets/Scripts/SaveSystem/SnapshotService.cs @@ -9,10 +9,11 @@ namespace AibisDream.SaveSystem public static class SnapshotService { /// 捕获当前运行时快照。 - public static SaveSnapshot Capture() + /// 。 + public static SaveSnapshot Capture(string triggerNodeName = null) { SnapshotRegistry.EnsureInitialized(); - return SnapshotCapture.Capture(YarnVariableStorage.Instance); + return SnapshotCapture.Capture(YarnVariableStorage.Instance, triggerNodeName); } ///