diff --git a/Assets/Editor/DeveloperMode/TestSaveSystemTests.cs b/Assets/Editor/DeveloperMode/TestSaveSystemTests.cs index d1ef08196..ebbac4868 100644 --- a/Assets/Editor/DeveloperMode/TestSaveSystemTests.cs +++ b/Assets/Editor/DeveloperMode/TestSaveSystemTests.cs @@ -1,5 +1,6 @@ #if UNITY_EDITOR using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -178,6 +179,98 @@ namespace AibisDream.DeveloperMode.Editor.Tests Assert.That(actual, Is.EqualTo(expected)); } + [TestCase(new[] { "content" }, NodeSaveTiming.NodeEnter, SaveResumeMode.RestartNode)] + [TestCase(new[] { "interaction" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)] + [TestCase(new[] { "content", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)] + [TestCase(new[] { "event", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)] + [TestCase(new[] { "interaction", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)] + [TestCase(new[] { "interaction", "no_save" }, NodeSaveTiming.None, SaveResumeMode.RestartNode)] + [TestCase(new[] { "content", "event" }, NodeSaveTiming.None, SaveResumeMode.RestartNode)] + public void ResolveNodePolicy_MapsNodeTypeAndModifiers( + string[] tags, + NodeSaveTiming expectedTiming, + SaveResumeMode expectedResumeMode) + { + var policy = SavePointEvaluator.ResolveNodePolicy( + "Node", + tags, + out _, + logWarnings: false); + + Assert.That(policy.Timing, Is.EqualTo(expectedTiming)); + Assert.That(policy.ResumeMode, Is.EqualTo(expectedResumeMode)); + } + + [Test] + public void Repository_StateOnlySnapshot_UsesSourceNodeMetadata() + { + var request = CreateRequest("ChapterA", "ProjectA", "InteractionNode", "SceneA"); + request.Snapshot.anchor.nodeName = string.Empty; + request.SaveTrigger = "DialogueExit"; + request.ResumeMode = nameof(SaveResumeMode.StateOnly); + request.DedupeKey = TestSaveRecorder.BuildDedupeKey( + request.SceneSoName, + request.YarnProjectId, + request.NodeName, + request.ResumeMode); + request.EntryId = TestSaveRepository.StableHash(request.DedupeKey); + + var entry = _repository.Record(request); + var loaded = _repository.TryLoad(entry, out var snapshot, out var error); + + Assert.That(loaded, Is.True, error); + Assert.That(entry.NodeName, Is.EqualTo("InteractionNode")); + Assert.That(entry.ResumeMode, Is.EqualTo(nameof(SaveResumeMode.StateOnly))); + Assert.That(snapshot.anchor.nodeName, Is.Empty); + } + + [Test] + public void Repository_LegacyMetadata_DefaultsToRestartNode() + { + var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA")); + entry.Meta.saveTrigger = null; + entry.Meta.resumeMode = null; + + Assert.That(entry.SaveTrigger, Is.EqualTo("NodeEnter")); + Assert.That(entry.ResumeMode, Is.EqualTo(nameof(SaveResumeMode.RestartNode))); + } + + [Test] + public void SerialTaskQueue_DoesNotDropOrReorderRequests() + { + var queue = new SerialTaskQueue(); + var releaseFirst = new TaskCompletionSource(); + var firstStarted = new TaskCompletionSource(); + var order = new List(); + + var first = queue.Enqueue(async () => + { + order.Add(1); + firstStarted.SetResult(true); + await releaseFirst.Task; + order.Add(2); + }); + var second = queue.Enqueue(() => + { + order.Add(3); + return Task.CompletedTask; + }); + + Assert.That(firstStarted.Task.Wait(TimeSpan.FromSeconds(2)), Is.True); + Assert.That(order, Is.EqualTo(new[] { 1 })); + Assert.That(queue.PendingCount, Is.EqualTo(2)); + + releaseFirst.SetResult(true); + Assert.That( + Task.WaitAll( + new[] { first.Completion, second.Completion }, + TimeSpan.FromSeconds(2)), + Is.True); + + Assert.That(first.Sequence, Is.LessThan(second.Sequence)); + Assert.That(order, Is.EqualTo(new[] { 1, 2, 3 })); + } + private static TestSaveRecordRequest CreateRequest( string sceneSoName, string yarnProject, @@ -206,6 +299,8 @@ namespace AibisDream.DeveloperMode.Editor.Tests SceneSoName = sceneSoName, YarnProjectId = yarnProject, NodeName = nodeName, + SaveTrigger = "NodeEnter", + ResumeMode = nameof(SaveResumeMode.RestartNode), SceneName = sceneName, GameVersion = "1.0" }; @@ -222,6 +317,8 @@ namespace AibisDream.DeveloperMode.Editor.Tests sceneSoName = request.SceneSoName, yarnProjectId = request.YarnProjectId, nodeName = request.NodeName, + saveTrigger = request.SaveTrigger, + resumeMode = request.ResumeMode, sceneName = request.SceneName, firstSeenOrder = order, firstRecordedAt = "2026-01-01 00:00:00", diff --git a/Assets/Scripts/Dialog System/DialogController.cs b/Assets/Scripts/Dialog System/DialogController.cs index 71ce27eb0..677236b0e 100644 --- a/Assets/Scripts/Dialog System/DialogController.cs +++ b/Assets/Scripts/Dialog System/DialogController.cs @@ -22,6 +22,8 @@ namespace AibisDream private string _currentNodeName; private string[] _currentNodeTags; + private PendingExitCheckpoint _pendingExitCheckpoint; + private long _dialogueFlowVersion; private void Start() { @@ -37,12 +39,7 @@ namespace AibisDream EnumEventSystem.Global.Send(InteractionEventEnum.DialogStart); }); - _dialogueRunner.onDialogueComplete.AddListener(() => - { - DialogueActivityChanged?.Invoke(false); - EnumEventSystem.Global.Send(InteractionEventEnum.DialogEnd); - ClearCurrentNodeContext(); - }); + _dialogueRunner.onDialogueComplete.AddListener(OnDialogueComplete); _dialogueRunner.onNodeStart.AddListener(OnNodeStart); _dialogueRunner.onNodeComplete.AddListener(OnNodeComplete); @@ -61,6 +58,8 @@ namespace AibisDream private void OnDisable() { + CancelPendingExitCheckpoint("DialogController disabled", logIfPending: false); + SingleCastEventSystem.Global.Unregister(DialogEventEnum.StartNode); EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionStarted, ResetAdvanceMode); @@ -92,6 +91,7 @@ namespace AibisDream /// Yarn组 public void StartDialog(YarnProject yarnProject) { + CancelPendingExitCheckpoint("StartDialog 切换 YarnProject"); PrepareYarnProjectForDialog(yarnProject); _dialogueRunner.SetProject(yarnProject); _ = _dialogueRunner.StartDialogue("Start"); @@ -104,6 +104,8 @@ namespace AibisDream public IEnumerator StopDialogRoutine() { + CancelPendingExitCheckpoint("StopDialogRoutine", logIfPending: false); + if (_dialogueRunner != null && _dialogueRunner.IsDialogueRunning) { yield return _dialogueRunner.Stop(); @@ -122,6 +124,7 @@ namespace AibisDream public void LoadDialog(YarnProject yarnProject) { + CancelPendingExitCheckpoint("LoadDialog 切换 YarnProject"); PrepareYarnProjectForDialog(yarnProject); _dialogueRunner.SetProject(yarnProject); } @@ -189,6 +192,10 @@ namespace AibisDream private void OnNodeStart(string nodeName) { + _dialogueFlowVersion++; + CancelPendingExitCheckpoint( + $"节点 {nodeName} 已开始,说明之前的退出保存路径继续运行 Yarn", + incrementFlowVersion: false); UpdateCurrentNodeContext(nodeName); var projectId = _dialogueRunner?.YarnProject?.name; @@ -200,7 +207,12 @@ namespace AibisDream return; } - if (!SavePointEvaluator.OnNodeStartForAutoSave(projectId, nodeName, tags, out var reason)) + if (!SavePointEvaluator.OnNodeStartForSavePolicy( + projectId, + nodeName, + tags, + out var policy, + out var reason)) { if (reason == SavePointRejectReason.DetourResume) { @@ -210,15 +222,86 @@ namespace AibisDream return; } - StartCoroutine(SaveRestoreOrchestrator.AutoSaveRoutine(nodeName)); + if (policy.Timing == NodeSaveTiming.DialogueExit) + { + _pendingExitCheckpoint = new PendingExitCheckpoint + { + ProjectId = projectId, + NodeName = nodeName, + FlowVersion = _dialogueFlowVersion + }; + return; + } + + if (policy.Timing == NodeSaveTiming.NodeEnter) + { + StartCoroutine(SaveRestoreOrchestrator.AutoSaveRoutine(nodeName)); + } } private void OnNodeComplete(string nodeName) { SavePointEvaluator.OnNodeComplete(_dialogueRunner?.YarnProject?.name, nodeName); + + if (_pendingExitCheckpoint != null + && string.Equals(_pendingExitCheckpoint.NodeName, nodeName, StringComparison.Ordinal) + && string.Equals( + _pendingExitCheckpoint.ProjectId, + _dialogueRunner?.YarnProject?.name, + StringComparison.Ordinal)) + { + _pendingExitCheckpoint.IsNodeComplete = true; + } + ClearCurrentNodeContext(); } + private void OnDialogueComplete() + { + DialogueActivityChanged?.Invoke(false); + EnumEventSystem.Global.Send(InteractionEventEnum.DialogEnd); + ClearCurrentNodeContext(); + + var checkpoint = _pendingExitCheckpoint; + _pendingExitCheckpoint = null; + if (checkpoint?.IsNodeComplete != true) + { + return; + } + + var expectedFlowVersion = checkpoint.FlowVersion; + StartCoroutine(SaveRestoreOrchestrator.DialogueExitSaveRoutine( + checkpoint.NodeName, + checkpoint.ProjectId, + () => _dialogueFlowVersion == expectedFlowVersion + && (_dialogueRunner == null || !_dialogueRunner.IsDialogueRunning))); + } + + private void CancelPendingExitCheckpoint( + string reason, + bool logIfPending = true, + bool incrementFlowVersion = true) + { + if (incrementFlowVersion) + { + _dialogueFlowVersion++; + } + + if (_pendingExitCheckpoint == null) + { + return; + } + + if (logIfPending) + { + Debug.LogWarning( + $"[DialogController] 取消 interaction/save_on_exit 退出存档: " + + $"node={_pendingExitCheckpoint.NodeName}, reason={reason}"); + } + + _pendingExitCheckpoint = null; + } + private void UpdateCurrentNodeContext(string nodeName) { if (_dialogueRunner == null || _dialogueRunner.Dialogue == null) @@ -241,6 +324,14 @@ namespace AibisDream _currentNodeTags = null; } + private sealed class PendingExitCheckpoint + { + public string ProjectId; + public string NodeName; + public long FlowVersion; + public bool IsNodeComplete; + } + #region 推进方式修改 public BindProperty advanceMode = new(new AdvanceMode()); diff --git a/Assets/Scripts/SaveSystem/Development/TestSaveCoverage.cs b/Assets/Scripts/SaveSystem/Development/TestSaveCoverage.cs index d795ae4ec..dce66a7a5 100644 --- a/Assets/Scripts/SaveSystem/Development/TestSaveCoverage.cs +++ b/Assets/Scripts/SaveSystem/Development/TestSaveCoverage.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using UnityEngine; namespace AibisDream.SaveSystem { @@ -16,6 +17,7 @@ namespace AibisDream.SaveSystem { public TestSaveCoverageRowKind Kind { get; internal set; } public string NodeName { get; internal set; } + public string ResumeMode { get; internal set; } public TestSaveEntry Entry { get; internal set; } public long SortOrder { get; internal set; } } @@ -49,7 +51,7 @@ namespace AibisDream.SaveSystem var chapter = chapters[chapterIndex]; if (chapter == null) continue; - var expectedNodes = GetExpectedNodes(chapter); + var expectedCheckpoints = GetExpectedCheckpoints(chapter); var chapterEntries = entries .Where(item => item.Meta != null && string.Equals(item.Meta.sceneSoName, chapter.name, StringComparison.Ordinal) @@ -60,33 +62,37 @@ namespace AibisDream.SaveSystem .ToArray(); foreach (var entry in chapterEntries) consumed.Add(entry); - var validByNode = chapterEntries + var validByCheckpoint = chapterEntries .Where(item => item.IsValid) - .GroupBy(item => item.NodeName, StringComparer.Ordinal) + .GroupBy( + item => BuildCheckpointKey(item.NodeName, item.ResumeMode), + StringComparer.Ordinal) .ToDictionary( group => group.Key, group => group.OrderBy(item => item.Meta.firstSeenOrder).First(), StringComparer.Ordinal); var rows = new List(); - foreach (var entry in validByNode.Values.OrderBy(item => item.Meta.firstSeenOrder)) + foreach (var entry in validByCheckpoint.Values.OrderBy(item => item.Meta.firstSeenOrder)) { rows.Add(new TestSaveCoverageRow { Kind = TestSaveCoverageRowKind.Recorded, NodeName = entry.NodeName, + ResumeMode = entry.ResumeMode, Entry = entry, SortOrder = entry.Meta.firstSeenOrder }); } - foreach (var nodeName in expectedNodes - .Where(node => !validByNode.ContainsKey(node)) - .OrderBy(node => node, StringComparer.OrdinalIgnoreCase)) + foreach (var expectation in expectedCheckpoints.Values + .Where(item => !validByCheckpoint.ContainsKey(item.Key)) + .OrderBy(item => item.NodeName, StringComparer.OrdinalIgnoreCase)) { rows.Add(new TestSaveCoverageRow { Kind = TestSaveCoverageRowKind.Missing, - NodeName = nodeName, + NodeName = expectation.NodeName, + ResumeMode = expectation.ResumeMode, SortOrder = long.MaxValue }); } @@ -98,6 +104,7 @@ namespace AibisDream.SaveSystem { Kind = TestSaveCoverageRowKind.Invalid, NodeName = entry.NodeName ?? "(未知节点)", + ResumeMode = entry.ResumeMode, Entry = entry, SortOrder = entry.Meta?.firstSeenOrder ?? long.MaxValue }); @@ -108,8 +115,8 @@ namespace AibisDream.SaveSystem ChapterId = chapter.name, Title = string.IsNullOrWhiteSpace(chapter.title) ? chapter.name : chapter.title, ChapterOrder = chapterIndex, - RecordedCount = validByNode.Keys.Count(expectedNodes.Contains), - ExpectedCount = expectedNodes.Count, + RecordedCount = validByCheckpoint.Keys.Count(expectedCheckpoints.ContainsKey), + ExpectedCount = expectedCheckpoints.Count, Rows = rows }); } @@ -131,6 +138,7 @@ namespace AibisDream.SaveSystem { Kind = TestSaveCoverageRowKind.Invalid, NodeName = item.NodeName ?? "(未知节点)", + ResumeMode = item.ResumeMode, Entry = item, SortOrder = item.Meta?.firstSeenOrder ?? long.MaxValue }) @@ -143,7 +151,14 @@ namespace AibisDream.SaveSystem public static HashSet GetExpectedNodes(TalkSceneSO chapter) { - var result = new HashSet(StringComparer.Ordinal); + return GetExpectedCheckpoints(chapter).Values + .Select(item => item.NodeName) + .ToHashSet(StringComparer.Ordinal); + } + + private static Dictionary GetExpectedCheckpoints(TalkSceneSO chapter) + { + var result = new Dictionary(StringComparer.Ordinal); var project = chapter?.yarnProject; if (project?.Program?.Nodes == null) return result; @@ -157,14 +172,65 @@ namespace AibisDream.SaveSystem var tags = string.IsNullOrWhiteSpace(tagsValue) ? Array.Empty() : tagsValue.Split(Array.Empty(), StringSplitOptions.RemoveEmptyEntries); - if (SavePointEvaluator.EvaluateNodeTagsForAutoSaveSilently(node.Name, tags, out _)) + var policy = SavePointEvaluator.ResolveNodePolicy( + node.Name, + tags, + out var reason, + logWarnings: false); + if (reason == SavePointRejectReason.InvalidTagConfiguration) { - result.Add(node.Name); + Debug.LogError( + $"[TestSaveCoverage] 节点 {node.Name} 的保存标签配置无效: " + + $"[{string.Join(", ", tags)}]"); } + else if (tags.Any(tag => + string.Equals(tag, "interaction", StringComparison.OrdinalIgnoreCase)) + && tags.Any(tag => + string.Equals(tag, "save_on_exit", StringComparison.OrdinalIgnoreCase))) + { + Debug.LogWarning( + $"[TestSaveCoverage] 节点 {node.Name} 的 interaction 已包含 save_on_exit 语义," + + "请移除重复标记。"); + } + + if (policy.Timing == NodeSaveTiming.None) + { + continue; + } + + var resumeMode = policy.ResumeMode.ToString(); + var key = BuildCheckpointKey(node.Name, resumeMode); + result[key] = new ExpectedCheckpoint(key, node.Name, resumeMode); } return result; } + + private static string BuildCheckpointKey(string nodeName, string resumeMode) + { + return $"{nodeName ?? string.Empty}\n{NormalizeResumeMode(resumeMode)}"; + } + + private static string NormalizeResumeMode(string resumeMode) + { + return string.IsNullOrEmpty(resumeMode) + ? nameof(SaveResumeMode.RestartNode) + : resumeMode; + } + + private readonly struct ExpectedCheckpoint + { + public readonly string Key; + public readonly string NodeName; + public readonly string ResumeMode; + + public ExpectedCheckpoint(string key, string nodeName, string resumeMode) + { + Key = key; + NodeName = nodeName; + ResumeMode = resumeMode; + } + } } } #endif diff --git a/Assets/Scripts/SaveSystem/Development/TestSaveRecorder.cs b/Assets/Scripts/SaveSystem/Development/TestSaveRecorder.cs index f7bf5f6c2..d7af09084 100644 --- a/Assets/Scripts/SaveSystem/Development/TestSaveRecorder.cs +++ b/Assets/Scripts/SaveSystem/Development/TestSaveRecorder.cs @@ -30,24 +30,42 @@ namespace AibisDream.SaveSystem IsRecording = recording; } - internal static TestSaveRecordRequest CreateRequest(SaveSnapshot snapshot, byte[] thumbnail) + internal static TestSaveRecordRequest CreateRequest( + SaveSnapshot snapshot, + byte[] thumbnail, + string saveTrigger, + string resumeMode, + string sourceNodeName) { if (!IsRecording || snapshot?.anchor == null || string.IsNullOrWhiteSpace(snapshot.anchor.sceneSoName) - || string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId) - || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) + || string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId)) { return null; } var anchor = snapshot.anchor; + var stateOnly = string.Equals( + resumeMode, + nameof(SaveResumeMode.StateOnly), + StringComparison.Ordinal); + var recordNodeName = stateOnly ? sourceNodeName : anchor.nodeName; + if (string.IsNullOrWhiteSpace(recordNodeName)) + { + return null; + } + var chapter = GameManager.Instance?.RuntimeChapters? .FirstOrDefault(item => item != null && string.Equals(item.name, anchor.sceneSoName, StringComparison.Ordinal) && string.Equals(item.yarnProject?.name, anchor.yarnProjectId, StringComparison.Ordinal)); - var dedupeKey = BuildDedupeKey(anchor.sceneSoName, anchor.yarnProjectId, anchor.nodeName); + var dedupeKey = BuildDedupeKey( + anchor.sceneSoName, + anchor.yarnProjectId, + recordNodeName, + resumeMode); return new TestSaveRecordRequest { Snapshot = snapshot, @@ -58,7 +76,9 @@ namespace AibisDream.SaveSystem ChapterTitle = string.IsNullOrWhiteSpace(chapter?.title) ? anchor.sceneSoName : chapter.title, SceneSoName = anchor.sceneSoName, YarnProjectId = anchor.yarnProjectId, - NodeName = anchor.nodeName, + NodeName = recordNodeName, + SaveTrigger = saveTrigger, + ResumeMode = resumeMode, SceneName = snapshot.scene?.sceneName, GameVersion = snapshot.gameVersion }; @@ -81,9 +101,21 @@ namespace AibisDream.SaveSystem }); } - public static string BuildDedupeKey(string sceneSoName, string yarnProjectId, string nodeName) + public static string BuildDedupeKey( + string sceneSoName, + string yarnProjectId, + string nodeName, + string resumeMode = null) { - return $"{sceneSoName ?? string.Empty}\n{yarnProjectId ?? string.Empty}\n{nodeName ?? string.Empty}"; + var baseKey = + $"{sceneSoName ?? string.Empty}\n{yarnProjectId ?? string.Empty}\n{nodeName ?? string.Empty}"; + return string.IsNullOrEmpty(resumeMode) + || string.Equals( + resumeMode, + nameof(SaveResumeMode.RestartNode), + StringComparison.Ordinal) + ? baseKey + : $"{baseKey}\n{resumeMode}"; } } } diff --git a/Assets/Scripts/SaveSystem/Development/TestSaveRepository.cs b/Assets/Scripts/SaveSystem/Development/TestSaveRepository.cs index b6bfdc6f0..94dd764c8 100644 --- a/Assets/Scripts/SaveSystem/Development/TestSaveRepository.cs +++ b/Assets/Scripts/SaveSystem/Development/TestSaveRepository.cs @@ -137,6 +137,8 @@ namespace AibisDream.SaveSystem sceneSoName = request.SceneSoName, yarnProjectId = request.YarnProjectId, nodeName = request.NodeName, + saveTrigger = request.SaveTrigger, + resumeMode = request.ResumeMode, sceneName = request.SceneName, firstSeenOrder = existingMeta?.firstSeenOrder ?? NextFirstSeenOrder(), firstRecordedAt = existingMeta?.firstRecordedAt ?? now, @@ -215,10 +217,11 @@ namespace AibisDream.SaveSystem return false; } + var stateOnly = IsStateOnly(entry.Meta?.resumeMode); if (snapshot?.anchor == null || string.IsNullOrWhiteSpace(snapshot.anchor.sceneSoName) || string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId) - || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) + || (!stateOnly && string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))) { error = "快照缺少完整 Yarn anchor。"; snapshot = null; @@ -229,7 +232,12 @@ namespace AibisDream.SaveSystem if (entry.Meta != null && (!string.Equals(snapshot.anchor.sceneSoName, entry.Meta.sceneSoName, StringComparison.Ordinal) || !string.Equals(snapshot.anchor.yarnProjectId, entry.Meta.yarnProjectId, StringComparison.Ordinal) - || !string.Equals(snapshot.anchor.nodeName, entry.Meta.nodeName, StringComparison.Ordinal))) + || (!stateOnly + && !string.Equals( + snapshot.anchor.nodeName, + entry.Meta.nodeName, + StringComparison.Ordinal)) + || (stateOnly && !string.IsNullOrEmpty(snapshot.anchor.nodeName)))) { error = "快照 anchor 与 meta 不一致。"; snapshot = null; @@ -457,6 +465,14 @@ namespace AibisDream.SaveSystem } } + private static bool IsStateOnly(string resumeMode) + { + return string.Equals( + resumeMode, + nameof(SaveResumeMode.StateOnly), + StringComparison.Ordinal); + } + private void RecoverTransactions() { if (!Directory.Exists(_rootPath)) diff --git a/Assets/Scripts/SaveSystem/Development/TestSaveTypes.cs b/Assets/Scripts/SaveSystem/Development/TestSaveTypes.cs index 7b9bc449a..c00ecc054 100644 --- a/Assets/Scripts/SaveSystem/Development/TestSaveTypes.cs +++ b/Assets/Scripts/SaveSystem/Development/TestSaveTypes.cs @@ -28,6 +28,8 @@ namespace AibisDream.SaveSystem public string sceneSoName; public string yarnProjectId; public string nodeName; + public string saveTrigger; + public string resumeMode; public string sceneName; public long firstSeenOrder; public string firstRecordedAt; @@ -52,6 +54,12 @@ namespace AibisDream.SaveSystem public string EntryId => Meta?.entryId; public string ChapterId => Meta?.chapterId; public string NodeName => Meta?.nodeName; + public string SaveTrigger => string.IsNullOrEmpty(Meta?.saveTrigger) + ? "NodeEnter" + : Meta.saveTrigger; + public string ResumeMode => string.IsNullOrEmpty(Meta?.resumeMode) + ? "RestartNode" + : Meta.resumeMode; } public sealed class TestSaveScanResult @@ -72,6 +80,8 @@ namespace AibisDream.SaveSystem public string SceneSoName; public string YarnProjectId; public string NodeName; + public string SaveTrigger; + public string ResumeMode; public string SceneName; public string GameVersion; } diff --git a/Assets/Scripts/SaveSystem/README.md b/Assets/Scripts/SaveSystem/README.md index c0c7b793d..8c69322ac 100644 --- a/Assets/Scripts/SaveSystem/README.md +++ b/Assets/Scripts/SaveSystem/README.md @@ -37,13 +37,22 @@ YarnVariableStorage.Instance.SetValue("$foo", 1f); Yarn 脚本: ```yarn +// 推荐:有内容的交互入口节点 +tags: interaction + +// 或为其他一级类型覆盖保存时机 +tags: content save_on_exit + +// 仅兼容旧内容 <> ``` +- `interaction` / `save_on_exit` 进入时不存档;对应节点完成后若直接结束本轮 Dialogue,则自动保存 state-only 档。 +- 若执行路径继续 `jump` / `detour` 到其他 Yarn 节点,退出档会取消并记录告警。 - 放在目标节点**末尾**:所有状态命令(`switch_fix_system_to`、`hide_dialog`、`play_timeline` 等)执行完毕之后,`<>` / `<>` 之前。 - 默认 **omit anchor**(`anchor.nodeName` 为空);读档时不重进 Yarn,仅还原 scene + sections + 变量。 - 绕过 tag / `no_save` 的自动判定(`CanExplicitSave`);仍受读档中、暂停、SuppressAutoSave 门控。 -- 与 `OnNodeStart` 分工:常规 `hub` / `linear` / `content` 仍靠节点进入自动存;`<>` 用于即将进入无对话 / Fix 交互等 `OnNodeStart` 覆盖不到的边界。 +- 与 `OnNodeStart` 分工:常规 `hub` / `linear` / `content` 仍靠节点进入自动存;`interaction` / `save_on_exit` 用于即将进入无对话 / Fix 交互等边界。 ### P3 自动档边界(含 detour 去重) @@ -53,7 +62,7 @@ Yarn 脚本: 1. 全局门控:`IsRestoring` / `IsAutoSaveSuppressed` / `GameManager.pause` 2. **Fresh vs detour 续跑**:同一 `YarnProject` 下,若节点名已在 `InProgressNodes`(尚未 `onNodeComplete`)→ `DetourResume`,**不自动存**(避免 detour 返回父节点时重复写盘) -3. 将节点加入 `InProgressNodes`,再走 tag / `no_save` 白黑名单(`hub` / `linear` / `content` 允许;`detour` / `function` 等禁止) +3. 将节点加入 `InProgressNodes`,再解析 `NodeSavePolicy`:`hub` / `linear` / `content` 进入时保存;`interaction` / `save_on_exit` 在 Dialogue 正常结束时保存 state-only 档;`detour` / `function` 等默认禁止 `OnNodeComplete` 将节点移出 `InProgressNodes`。`StartDialog` / `LoadDialog` 切换 `YarnProject.name` 时、`StopDialog` 时重置集合。读档重进 anchor 走 `OnRestoreEnterNode`(只标记 InProgress,不写盘)。 diff --git a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs index ad92ac6ef..a5ce1df15 100644 --- a/Assets/Scripts/SaveSystem/SavePointEvaluator.cs +++ b/Assets/Scripts/SaveSystem/SavePointEvaluator.cs @@ -1,9 +1,44 @@ using System; using System.Collections.Generic; +using System.Linq; using UnityEngine; namespace AibisDream.SaveSystem { + public enum NodeSaveTiming + { + None, + NodeEnter, + DialogueExit + } + + public enum SaveResumeMode + { + RestartNode, + StateOnly + } + + public readonly struct NodeSavePolicy + { + public NodeSaveTiming Timing { get; } + public SaveResumeMode ResumeMode { get; } + + public NodeSavePolicy(NodeSaveTiming timing, SaveResumeMode resumeMode) + { + Timing = timing; + ResumeMode = resumeMode; + } + + public static NodeSavePolicy None => + new(NodeSaveTiming.None, SaveResumeMode.RestartNode); + + public static NodeSavePolicy Enter => + new(NodeSaveTiming.NodeEnter, SaveResumeMode.RestartNode); + + public static NodeSavePolicy Exit => + new(NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly); + } + /// /// 可存点被拒绝的原因,供日志与 UI 提示使用。 /// @@ -28,6 +63,9 @@ namespace AibisDream.SaveSystem /// 同一节点仍在执行中(detour 返回续跑),非 Fresh 进入。 DetourResume, + + /// 节点类型或保存附加标记互相冲突。 + InvalidTagConfiguration, } /// @@ -35,8 +73,9 @@ namespace AibisDream.SaveSystem /// /// 基于 Yarn 节点 tag 判定是否允许自动存档: /// - 白名单 tag(hub / linear / content)默认允许; + /// - interaction 或 save_on_exit 在 Dialogue 正常结束时保存 state-only 档; /// - 黑名单 tag(start / init / function / detour / center / performance / event / end)默认禁止; - /// - no_save 附加标记覆盖默认语义,一律禁止; + /// - no_save 附加标记覆盖默认语义;与退出保存语义并存时视为配置冲突; /// - 无活跃节点、或节点未声明 tag,默认允许并打 warning; /// - 未知 tag 默认允许并打 warning。 /// @@ -68,6 +107,13 @@ namespace AibisDream.SaveSystem "end", // 维修结束/收尾 }; + private static readonly HashSet PrimaryNodeTags = new( + AutoSaveAllowedTags.Concat(AutoSaveDeniedTags), + StringComparer.OrdinalIgnoreCase) + { + "interaction", + }; + private static string _activeProjectId; private static readonly HashSet InProgressNodes = new(StringComparer.Ordinal); @@ -113,6 +159,27 @@ namespace AibisDream.SaveSystem IReadOnlyList tags, out SavePointRejectReason reason) { + var accepted = OnNodeStartForSavePolicy( + projectId, + nodeName, + tags, + out var policy, + out reason); + return accepted && policy.Timing == NodeSaveTiming.NodeEnter; + } + + /// + /// OnNodeStart 存档策略判定:Fresh 进入时返回节点的进入/退出保存策略; + /// detour 返回续跑或全局门控失败时返回 false。 + /// + public static bool OnNodeStartForSavePolicy( + string projectId, + string nodeName, + IReadOnlyList tags, + out NodeSavePolicy policy, + out SavePointRejectReason reason) + { + policy = NodeSavePolicy.None; if (!TryPassGlobalSaveGuards(out reason)) { return false; @@ -131,7 +198,8 @@ namespace AibisDream.SaveSystem InProgressNodes.Add(nodeName); } - return EvaluateTagsForAutoSave(nodeName, tags, out reason); + policy = ResolveNodePolicy(nodeName, tags, out reason); + return policy.Timing != NodeSaveTiming.None; } /// 仅 tag / no_save 判定,不含 InProgress detour 门控(供编辑器模拟)。 @@ -140,7 +208,8 @@ namespace AibisDream.SaveSystem IReadOnlyList tags, out SavePointRejectReason reason) { - return EvaluateTagsForAutoSave(nodeName, tags, out reason, logWarnings: true); + return ResolveNodePolicy(nodeName, tags, out reason, logWarnings: true).Timing + == NodeSaveTiming.NodeEnter; } #if UNITY_EDITOR || DEVELOPMENT_BUILD @@ -150,10 +219,111 @@ namespace AibisDream.SaveSystem IReadOnlyList tags, out SavePointRejectReason reason) { - return EvaluateTagsForAutoSave(nodeName, tags, out reason, logWarnings: false); + return ResolveNodePolicy(nodeName, tags, out reason, logWarnings: false).Timing + == NodeSaveTiming.NodeEnter; } #endif + /// 仅根据节点 tags 解析保存策略,不检查全局门控或 detour 状态。 + public static NodeSavePolicy ResolveNodePolicy( + string nodeName, + IReadOnlyList tags, + out SavePointRejectReason reason, + bool logWarnings = true) + { + var hasNoSave = IsTagPresent(tags, "no_save"); + var hasSaveOnExit = IsTagPresent(tags, "save_on_exit"); + var primaryTags = FindPrimaryTags(tags); + var hasInteraction = primaryTags.Exists( + tag => string.Equals(tag, "interaction", StringComparison.OrdinalIgnoreCase)); + + if (primaryTags.Count > 1) + { + if (logWarnings) + { + Debug.LogError( + $"[SavePointEvaluator] 节点 {FormatNodeName(nodeName)} 声明了多个一级类型: " + + $"[{string.Join(", ", primaryTags)}]。为避免错误存档,已禁用该节点存档。"); + } + + reason = SavePointRejectReason.InvalidTagConfiguration; + return NodeSavePolicy.None; + } + + if (hasNoSave && (hasInteraction || hasSaveOnExit)) + { + if (logWarnings) + { + Debug.LogError( + $"[SavePointEvaluator] 节点 {FormatNodeName(nodeName)} 的 no_save 与 " + + $"{(hasInteraction ? "interaction" : "save_on_exit")} 冲突,已禁用该节点存档。"); + } + + reason = SavePointRejectReason.InvalidTagConfiguration; + return NodeSavePolicy.None; + } + + if (hasNoSave) + { + reason = SavePointRejectReason.MarkedNoSave; + return NodeSavePolicy.None; + } + + if (hasInteraction) + { + if (hasSaveOnExit && logWarnings) + { + Debug.LogWarning( + $"[SavePointEvaluator] 节点 {FormatNodeName(nodeName)} 的 interaction 已包含 " + + "save_on_exit 语义,无需重复标记。"); + } + + reason = SavePointRejectReason.None; + return NodeSavePolicy.Exit; + } + + if (hasSaveOnExit) + { + reason = SavePointRejectReason.None; + return NodeSavePolicy.Exit; + } + + if (string.IsNullOrEmpty(nodeName) || tags == null || tags.Count == 0) + { + if (logWarnings && !string.IsNullOrEmpty(nodeName) && (tags == null || tags.Count == 0)) + { + Debug.LogWarning( + $"[SavePointEvaluator] 节点 {nodeName} 未声明任何 tag,默认允许进入时自动存档。请补全节点类型标签。"); + } + + reason = SavePointRejectReason.None; + return NodeSavePolicy.Enter; + } + + var primaryTag = primaryTags.Count == 1 ? primaryTags[0] : null; + if (primaryTag == null) + { + if (logWarnings) + { + Debug.LogWarning( + $"[SavePointEvaluator] 节点 {nodeName} 的 tags [{string.Join(", ", tags)}] " + + "未声明保存语义,默认允许进入时自动存档。"); + } + + reason = SavePointRejectReason.None; + return NodeSavePolicy.Enter; + } + + if (AutoSaveDeniedTags.Contains(primaryTag)) + { + reason = SavePointRejectReason.DeniedByTag; + return NodeSavePolicy.None; + } + + reason = SavePointRejectReason.None; + return NodeSavePolicy.Enter; + } + /// 节点执行完毕,移出 InProgress。 public static void OnNodeComplete(string projectId, string nodeName) { @@ -195,7 +365,7 @@ namespace AibisDream.SaveSystem ? DialogController.Instance.GetCurrentNodeContext() : (null, null); - return EvaluateTagsForAutoSave(nodeName, tags, out reason); + return ResolveNodePolicy(nodeName, tags, out reason).Timing == NodeSaveTiming.NodeEnter; } /// @@ -251,71 +421,13 @@ namespace AibisDream.SaveSystem return true; } - private static bool EvaluateTagsForAutoSave( - string nodeName, - IReadOnlyList tags, - out SavePointRejectReason reason, - bool logWarnings = true) - { - if (tags != null && IsTagPresent(tags, "no_save")) - { - reason = SavePointRejectReason.MarkedNoSave; - return false; - } - - if (string.IsNullOrEmpty(nodeName) || tags == null || tags.Count == 0) - { - if (logWarnings && !string.IsNullOrEmpty(nodeName) && (tags == null || tags.Count == 0)) - { - Debug.LogWarning( - $"[SavePointEvaluator] 节点 {nodeName} 未声明任何 tag,默认允许自动存档。请补全节点类型标签。"); - } - - reason = SavePointRejectReason.None; - return true; - } - - var primaryTag = FindPrimaryTag(tags); - - if (primaryTag == null) - { - if (logWarnings) - { - Debug.LogWarning( - $"[SavePointEvaluator] 节点 {nodeName} 的 tags [{string.Join(", ", tags)}] 未声明保存语义,默认允许自动存档。"); - } - reason = SavePointRejectReason.None; - return true; - } - - if (AutoSaveDeniedTags.Contains(primaryTag)) - { - reason = SavePointRejectReason.DeniedByTag; - return false; - } - - if (AutoSaveAllowedTags.Contains(primaryTag)) - { - reason = SavePointRejectReason.None; - return true; - } - - if (logWarnings) - { - Debug.LogWarning( - $"[SavePointEvaluator] 节点 {nodeName} 的 tag '{primaryTag}' 未声明保存语义,默认允许自动存档。"); - } - reason = SavePointRejectReason.None; - return true; - } - private static bool IsTagPresent(IReadOnlyList tags, string tag) { if (tags == null) return false; foreach (var t in tags) { - if (t == tag) + if (string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)) { return true; } @@ -324,17 +436,29 @@ namespace AibisDream.SaveSystem return false; } - private static string FindPrimaryTag(IReadOnlyList tags) + private static List FindPrimaryTags(IReadOnlyList tags) { + var result = new List(); + if (tags == null) + { + return result; + } + foreach (var tag in tags) { - if (AutoSaveAllowedTags.Contains(tag) || AutoSaveDeniedTags.Contains(tag)) + if (PrimaryNodeTags.Contains(tag) + && !result.Exists(item => string.Equals(item, tag, StringComparison.OrdinalIgnoreCase))) { - return tag; + result.Add(tag); } } - return null; + return result; + } + + private static string FormatNodeName(string nodeName) + { + return string.IsNullOrEmpty(nodeName) ? "(无节点)" : nodeName; } } } diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs index cfeeb83a8..1e5d16d9e 100644 --- a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs +++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs @@ -8,6 +8,125 @@ using UnityEngine; namespace AibisDream.SaveSystem { + internal enum SaveTrigger + { + NodeEnter, + DialogueExit, + ExplicitCommand + } + + internal readonly struct SaveRequest + { + public SaveTrigger Trigger { get; } + public SaveResumeMode ResumeMode { get; } + public string SourceNodeName { get; } + public string YarnProjectId { get; } + + public SaveRequest( + SaveTrigger trigger, + SaveResumeMode resumeMode, + string sourceNodeName, + string yarnProjectId) + { + Trigger = trigger; + ResumeMode = resumeMode; + SourceNodeName = sourceNodeName; + YarnProjectId = yarnProjectId; + } + + public static SaveRequest NodeEnter(string nodeName, string yarnProjectId) + { + return new SaveRequest( + SaveTrigger.NodeEnter, + SaveResumeMode.RestartNode, + nodeName, + yarnProjectId); + } + + public static SaveRequest DialogueExit(string nodeName, string yarnProjectId) + { + return new SaveRequest( + SaveTrigger.DialogueExit, + SaveResumeMode.StateOnly, + nodeName, + yarnProjectId); + } + + public static SaveRequest Explicit(string nodeName, string yarnProjectId) + { + return new SaveRequest( + SaveTrigger.ExplicitCommand, + SaveResumeMode.StateOnly, + nodeName, + yarnProjectId); + } + } + + internal readonly struct SerialTaskQueueEntry + { + public long Sequence { get; } + public Task Completion { get; } + + public SerialTaskQueueEntry(long sequence, Task completion) + { + Sequence = sequence; + Completion = completion; + } + } + + /// 按请求顺序串行执行异步任务;前一个任务失败不会阻断后续任务。 + internal sealed class SerialTaskQueue + { + private readonly object _gate = new(); + private Task _tail = Task.CompletedTask; + private long _nextSequence; + private int _pendingCount; + + public int PendingCount + { + get + { + lock (_gate) + { + return _pendingCount; + } + } + } + + public SerialTaskQueueEntry Enqueue(Func operation) + { + if (operation == null) + { + throw new ArgumentNullException(nameof(operation)); + } + + long sequence; + Task completion; + lock (_gate) + { + sequence = ++_nextSequence; + _pendingCount++; + completion = _tail.ContinueWith( + _ => operation(), + TaskScheduler.Default) + .Unwrap(); + _tail = completion; + } + + _ = completion.ContinueWith( + _ => + { + lock (_gate) + { + _pendingCount = Math.Max(0, _pendingCount - 1); + } + }, + TaskScheduler.Default); + + return new SerialTaskQueueEntry(sequence, completion); + } + } + /// /// 存读档流程编排层:连接 UI、 与槽位系统。 /// @@ -20,13 +139,24 @@ namespace AibisDream.SaveSystem public static bool IsAutoSaveSuppressed => _autoSaveSuppressDepth > 0; /// 是否正在异步捕获或写入自动档;供只读诊断 UI 使用。 - public static bool IsSaving => _isAutoSaving; + public static bool IsSaving + { + get + { + lock (SaveQueueGate) + { + return _activeCaptureCount > 0 || SaveWriteQueue.PendingCount > 0; + } + } + } public static IReadOnlyList LastRestoreLog => _lastRestoreLog; - private static bool _isAutoSaving; private static int _autoSaveSuppressDepth; private static readonly List _lastRestoreLog = new(); + private static readonly object SaveQueueGate = new(); + private static readonly SerialTaskQueue SaveWriteQueue = new(); + private static int _activeCaptureCount; /// 启动自动存档协程(手动/调试入口)。 public static void TryAutoSave() @@ -44,7 +174,9 @@ namespace AibisDream.SaveSystem } var (triggerNodeName, _) = DialogController.Instance.GetCurrentNodeContext(); - DialogController.Instance.StartCoroutine(AutoSaveRoutine(triggerNodeName)); + var projectId = DialogController.Instance.DialogueRunner?.YarnProject?.name; + DialogController.Instance.StartCoroutine( + SaveRoutine(SaveRequest.NodeEnter(triggerNodeName, projectId))); } /// 自动存档协程:settle 一帧 → 主线程 Capture → 后台序列化写盘(不阻塞主线程)。 @@ -52,21 +184,57 @@ namespace AibisDream.SaveSystem /// 为 true 时不写入 Yarn 节点 anchor。 public static IEnumerator AutoSaveRoutine(string triggerNodeName = null, bool omitAnchor = false) { - if (_isAutoSaving) - { - Debug.LogWarning("[SaveRestoreOrchestrator] 存档进行中,跳过重复请求。"); - yield break; - } + var projectId = DialogController.Instance?.DialogueRunner?.YarnProject?.name; + var request = omitAnchor + ? SaveRequest.Explicit(triggerNodeName, projectId) + : SaveRequest.NodeEnter(triggerNodeName, projectId); + yield return SaveRoutine(request); + } + internal static IEnumerator DialogueExitSaveRoutine( + string sourceNodeName, + string yarnProjectId, + Func isRequestStillValid) + { + yield return SaveRoutine( + SaveRequest.DialogueExit(sourceNodeName, yarnProjectId), + isRequestStillValid); + } + + private static IEnumerator SaveRoutine(SaveRequest request, Func isRequestStillValid = null) + { yield return null; - if (_isAutoSaving) + if (isRequestStillValid?.Invoke() == false) { - Debug.LogWarning("[SaveRestoreOrchestrator] 存档进行中,跳过重复请求。"); + Debug.LogWarning( + $"[SaveRestoreOrchestrator] interaction 退出后又启动了 Yarn 节点,取消 state-only 存档: " + + $"node={FormatNodeName(request.SourceNodeName)}"); yield break; } - _isAutoSaving = true; + if (!SavePointEvaluator.CanExplicitSave(out var guardReason)) + { + Debug.LogWarning( + $"[SaveRestoreOrchestrator] 取消存档请求: trigger={request.Trigger}, " + + $"node={FormatNodeName(request.SourceNodeName)}, reason={guardReason}"); + yield break; + } + + if (request.Trigger == SaveTrigger.DialogueExit + && !IsYarnProjectStillCurrent(request.YarnProjectId)) + { + Debug.LogWarning( + $"[SaveRestoreOrchestrator] interaction 退出存档的 YarnProject 已变化,取消请求: " + + $"node={FormatNodeName(request.SourceNodeName)}, project={request.YarnProjectId ?? "(none)"}"); + yield break; + } + + lock (SaveQueueGate) + { + _activeCaptureCount++; + } + var infoPanel = UIManager.Instance.GetPanel(); infoPanel?.ShowSaveLoading(); @@ -77,57 +245,64 @@ namespace AibisDream.SaveSystem { using (new CodeTimer("SaveSnapshot")) { - snapshot = SnapshotService.Capture(triggerNodeName, omitAnchor); + snapshot = SnapshotService.Capture( + request.ResumeMode == SaveResumeMode.RestartNode + ? request.SourceNodeName + : null, + request.ResumeMode == SaveResumeMode.StateOnly); thumbnail = SlotThumbnailCapture.CapturePng(); } } catch (Exception ex) { - Debug.LogError($"[SaveRestoreOrchestrator] 自动存档 Capture 失败: {ex}"); + Debug.LogError( + $"[SaveRestoreOrchestrator] 自动存档 Capture 失败: trigger={request.Trigger}, " + + $"node={FormatNodeName(request.SourceNodeName)}, error={ex}"); infoPanel?.HideSaveLoading(); - _isAutoSaving = false; + lock (SaveQueueGate) + { + _activeCaptureCount = Math.Max(0, _activeCaptureCount - 1); + } yield break; } infoPanel?.HideSaveLoading(); + lock (SaveQueueGate) + { + _activeCaptureCount = Math.Max(0, _activeCaptureCount - 1); + } #if UNITY_EDITOR || DEVELOPMENT_BUILD TestSaveRecordRequest testSaveRequest = null; - if (!omitAnchor) + try { - try - { - testSaveRequest = TestSaveRecorder.CreateRequest(snapshot, thumbnail); - } - catch (Exception ex) - { - Debug.LogError($"[SaveRestoreOrchestrator] 创建测试存档旁路请求失败,不影响正式存档:{ex}"); - } + testSaveRequest = TestSaveRecorder.CreateRequest( + snapshot, + thumbnail, + request.Trigger.ToString(), + request.ResumeMode.ToString(), + request.SourceNodeName); + } + catch (Exception ex) + { + Debug.LogError($"[SaveRestoreOrchestrator] 创建测试存档旁路请求失败,不影响正式存档:{ex}"); } #endif - _ = SlotManager.SaveToAutoSlotAsync(snapshot, thumbnail).ContinueWith( - writeTask => - { - if (writeTask.IsFaulted) - { - Debug.LogError( - $"[SaveRestoreOrchestrator] 自动存档写盘失败: {writeTask.Exception?.GetBaseException()}"); - } + var sequence = EnqueueWrite( + snapshot, + thumbnail, + request #if UNITY_EDITOR || DEVELOPMENT_BUILD - else if (!writeTask.IsCanceled) - { - TestSaveRecorder.Enqueue(testSaveRequest); - } + , testSaveRequest #endif + ); - _isAutoSaving = false; - }, - TaskContinuationOptions.ExecuteSynchronously); - - if (omitAnchor || (snapshot?.anchor != null && string.IsNullOrEmpty(snapshot.anchor.nodeName))) + if (request.ResumeMode == SaveResumeMode.StateOnly) { - Debug.Log("[SaveRestoreOrchestrator] 已保存无 Yarn 节点 anchor 的状态。"); + Debug.Log( + $"[SaveRestoreOrchestrator] 已捕获 state-only 自动档并加入写盘队列: " + + $"sequence={sequence}, trigger={request.Trigger}, node={FormatNodeName(request.SourceNodeName)}"); } } @@ -136,7 +311,70 @@ namespace AibisDream.SaveSystem /// public static IEnumerator ExplicitSaveRoutine() { - yield return AutoSaveRoutine(triggerNodeName: null, omitAnchor: true); + var dialog = DialogController.Instance; + var (nodeName, _) = dialog != null + ? dialog.GetCurrentNodeContext() + : (null, null); + var projectId = dialog?.DialogueRunner?.YarnProject?.name; + yield return SaveRoutine(SaveRequest.Explicit(nodeName, projectId)); + } + + private static long EnqueueWrite( + SaveSnapshot snapshot, + byte[] thumbnail, + SaveRequest request +#if UNITY_EDITOR || DEVELOPMENT_BUILD + , TestSaveRecordRequest testSaveRequest +#endif + ) + { + var queueEntry = SaveWriteQueue.Enqueue( + () => SlotManager.SaveToAutoSlotAsync(snapshot, thumbnail)); + var sequence = queueEntry.Sequence; + var queuedTask = queueEntry.Completion; + + _ = queuedTask.ContinueWith( + writeTask => + { + if (writeTask.IsFaulted) + { + Debug.LogError( + $"[SaveRestoreOrchestrator] 自动存档写盘失败: sequence={sequence}, " + + $"trigger={request.Trigger}, node={FormatNodeName(request.SourceNodeName)}, " + + $"error={writeTask.Exception?.GetBaseException()}"); + } + else if (writeTask.IsCanceled) + { + Debug.LogWarning( + $"[SaveRestoreOrchestrator] 自动存档写盘已取消: sequence={sequence}, " + + $"trigger={request.Trigger}, node={FormatNodeName(request.SourceNodeName)}"); + } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else + { + TestSaveRecorder.Enqueue(testSaveRequest); + } +#endif + }, + TaskScheduler.Default); + + return sequence; + } + + private static bool IsYarnProjectStillCurrent(string expectedProjectId) + { + if (string.IsNullOrEmpty(expectedProjectId)) + { + return true; + } + + var currentProjectId = DialogController.Instance?.DialogueRunner?.YarnProject?.name; + return string.Equals(currentProjectId, expectedProjectId, StringComparison.Ordinal); + } + + private static string FormatNodeName(string nodeName) + { + return string.IsNullOrEmpty(nodeName) ? "(无节点)" : nodeName; } /// 将当前自动档复制到指定手动档。 diff --git a/Assets/Scripts/SaveSystem/SaveYarnCommand.cs b/Assets/Scripts/SaveSystem/SaveYarnCommand.cs index 87c3fcd71..3e73f3749 100644 --- a/Assets/Scripts/SaveSystem/SaveYarnCommand.cs +++ b/Assets/Scripts/SaveSystem/SaveYarnCommand.cs @@ -5,7 +5,7 @@ using Yarn.Unity; namespace AibisDream.SaveSystem { /// - /// Yarn 显式存档命令。在节点末尾、状态命令执行完毕且即将进入无对话阶段时使用。 + /// Yarn 显式存档兼容命令。新内容应使用 interaction 一级类型或 save_on_exit 附加标记。 /// public static class SaveYarnCommand { diff --git a/Assets/Scripts/UI/Panel/DeveloperModePanel.cs b/Assets/Scripts/UI/Panel/DeveloperModePanel.cs index d10549f1b..f6809ebb8 100644 --- a/Assets/Scripts/UI/Panel/DeveloperModePanel.cs +++ b/Assets/Scripts/UI/Panel/DeveloperModePanel.cs @@ -731,16 +731,26 @@ namespace AibisDream.UI private static (string Label, Color Color, bool IsError) FormatTestSaveRow( TestSaveCoverageRow row) { + var resumeModeLabel = string.Equals( + row.ResumeMode, + nameof(SaveResumeMode.StateOnly), + StringComparison.Ordinal) + ? " [StateOnly]" + : string.Empty; + if (row.Kind == TestSaveCoverageRowKind.Missing) { - return ($"○ 缺失 {row.NodeName}", new Color(0.6f, 0.68f, 0.72f), false); + return ( + $"○ 缺失 {row.NodeName}{resumeModeLabel}", + new Color(0.6f, 0.68f, 0.72f), + false); } var entry = row.Entry; if (row.Kind == TestSaveCoverageRowKind.Invalid || entry == null || !entry.IsValid) { return ( - $"× 无效 {row.NodeName} | " + $"× 无效 {row.NodeName}{resumeModeLabel} | " + (entry?.IsValid == true ? "当前流程找不到对应章节/YarnProject。" : entry?.StatusMessage ?? "未知错误"), @@ -750,7 +760,8 @@ namespace AibisDream.UI var warning = entry.HasVersionWarning ? $" ⚠ game {entry.Meta.gameVersion}" : string.Empty; return ( - $"● {row.NodeName} | {entry.Meta.lastRecordedAt} | {entry.Meta.sceneName}{warning}", + $"● {row.NodeName}{resumeModeLabel} | " + + $"{entry.Meta.lastRecordedAt} | {entry.Meta.sceneName}{warning}", entry.HasVersionWarning ? new Color(1f, 0.82f, 0.35f) : Color.white, false); } diff --git a/Assets/Tests/FrameAnimation/GeneratedImportTests.meta b/Assets/Tests/FrameAnimation/GeneratedImportTests.meta new file mode 100644 index 000000000..612b5afa5 --- /dev/null +++ b/Assets/Tests/FrameAnimation/GeneratedImportTests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ce717c358aef1574ab0edd4df45152e6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/FrameAnimation/GeneratedImportTests/Atlas.png b/Assets/Tests/FrameAnimation/GeneratedImportTests/Atlas.png new file mode 100644 index 000000000..f7dfbac46 --- /dev/null +++ b/Assets/Tests/FrameAnimation/GeneratedImportTests/Atlas.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4104a521960d83c028d619b7cf9b4227c03a470455ef36578eb77737df0de13b +size 76 diff --git a/Assets/Tests/FrameAnimation/GeneratedImportTests/Atlas.png.meta b/Assets/Tests/FrameAnimation/GeneratedImportTests/Atlas.png.meta new file mode 100644 index 000000000..852e38d2a --- /dev/null +++ b/Assets/Tests/FrameAnimation/GeneratedImportTests/Atlas.png.meta @@ -0,0 +1,209 @@ +fileFormatVersion: 2 +guid: ad7135517a3e7d44fab0e319f21e484c +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 12 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 2 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 3 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: iPhone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: Server + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: FrameA + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 2 + height: 2 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: 3203ad1f5d9bbf94d8607def47ab8917 + internalID: -3259341585125249141 + vertices: [] + indices: + edges: [] + weights: [] + - serializedVersion: 2 + name: FrameB + rect: + serializedVersion: 2 + x: 2 + y: 0 + width: 2 + height: 2 + alignment: 9 + pivot: {x: 0.5, y: 0.5} + border: {x: 0, y: 0, z: 0, w: 0} + outline: [] + physicsShape: [] + tessellationDetail: 0 + bones: [] + spriteID: e18a996baef906548b59c270239ac77d + internalID: 760278470 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + nameFileIdTable: + FrameA: -3259341585125249141 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/FrameAnimation/GeneratedImportTests/ImportGraph.asset b/Assets/Tests/FrameAnimation/GeneratedImportTests/ImportGraph.asset new file mode 100644 index 000000000..e0db2970b --- /dev/null +++ b/Assets/Tests/FrameAnimation/GeneratedImportTests/ImportGraph.asset @@ -0,0 +1,75 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-7417057855660477100 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 408fe45af4d321848a6c97f0356e8e5c, type: 3} + m_Name: SharedPair + m_EditorClassIdentifier: + id: SharedPair + displayName: SharedPair + frames: + - sprite: {fileID: -3259341585125249141, guid: ad7135517a3e7d44fab0e319f21e484c, type: 3} + durationMs: 100 + frameName: FrameA + sourceIndex: 0 + - sprite: {fileID: -3259341585125249141, guid: ad7135517a3e7d44fab0e319f21e484c, type: 3} + durationMs: 300 + frameName: FrameB + sourceIndex: 1 + speed: 1 + defaultEndBehavior: 0 + hasImportInfo: 1 + importInfo: + importSourceId: 52e497304ca9471c89e585f213e29f12 + sourceTagName: SharedPair + isMissingFromSource: 0 + hasStandaloneImportSource: 0 + standaloneImportSource: + texture: {fileID: 0} + asepriteJson: {fileID: 0} + pivot: {x: 0.5, y: 0.5} + manageSpriteSlicing: 0 + lastSourceHash: + lastImportedTagName: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4c9e0458642034545b44454bbadcf059, type: 3} + m_Name: ImportGraph + m_EditorClassIdentifier: + id: ImportGraph + displayName: Import Graph + clips: + - {fileID: -7417057855660477100} + nodes: [] + edges: [] + flows: [] + importSources: + - internalId: 52e497304ca9471c89e585f213e29f12 + displayName: "\u4E2D\u6587\u6765\u6E90" + isEnabled: 1 + texture: {fileID: 2800000, guid: ad7135517a3e7d44fab0e319f21e484c, type: 3} + asepriteJson: {fileID: 4900000, guid: eea7be1f48597614e8e13e85c42c8a42, type: 3} + pivot: {x: 0.5, y: 0.5} + manageSpriteSlicing: 1 + defaultNewClipEndBehavior: 0 + lastSourceHash: cc9e5035efe44f71c253579af48d546926f600feeb8719e30b9995e404c13077 + settings: + defaultPlayableId: + newManualClipDefaultEndBehavior: 0 + editorData: + nodeEditorData: [] + flowEditorData: [] diff --git a/Assets/Tests/FrameAnimation/GeneratedImportTests/ImportGraph.asset.meta b/Assets/Tests/FrameAnimation/GeneratedImportTests/ImportGraph.asset.meta new file mode 100644 index 000000000..d9425a12c --- /dev/null +++ b/Assets/Tests/FrameAnimation/GeneratedImportTests/ImportGraph.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c31fd505b342238419aa20e24f87671d +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Docs/Yarn维修节点类型规范.md b/Docs/Yarn维修节点类型规范.md index 3df624302..1eebe0784 100644 --- a/Docs/Yarn维修节点类型规范.md +++ b/Docs/Yarn维修节点类型规范.md @@ -20,6 +20,7 @@ | `init` | `init` | 变量声明与初始状态 | 无出口 | 仅 `<>` / `<>` / 全局参数初始化;本身不跳转 | | `center` | `center` | 阶段调度中枢 | 可多出口 `<>` | 仅按 `$gameStage` 分发,无文本、无选项 | | `content` | `content` | 阶段内容节点 | 通常单出口 | 可含叙事文本、局部选项、小游戏指令 | +| `interaction` | `interaction` | 交互内容节点 | 可按分支结束或跳转 | 可含叙事、选项与玩法准备指令;正常结束 Dialogue 后进入游戏内交互 | | `performance` | `performance` | 原子化线性演出节点 | 单出口 | timeline、cutscene 等,禁止选项 | | `event` | `event` | C# 调用的事件响应入口 | 单/多出口 `<>` | 仅轻量路由,禁止大段叙事 | | `function` | `function` | 纯指令封装 | **禁止 `<>`** | 仅 Command,无文本;命名加 `_Func` 后缀 | @@ -31,8 +32,12 @@ - `deprecated`:已废弃节点,运行时不可达。 - `wip`:待编辑/占位节点,运行时不可达。 - `no_save`:该节点**不可作为保存边界**。即使节点类型通常允许保存(如 `content`),附加此标签也表示此处不应触发保存。 +- `save_on_exit`:覆盖一级类型的默认保存语义;进入节点时不保存,节点完成后直接结束本轮 Dialogue 时保存 state-only 档。 - **瞬跳节点**:见 [§8 瞬跳节点与存档边界](#8-瞬跳节点与存档边界)。所有瞬跳节点均不可作为保存边界。 +`interaction` 已天然包含 `save_on_exit`,不得重复标记。`no_save` 与 `interaction` / `save_on_exit` +互相冲突;运行时遇到冲突时按不保存处理,并输出配置错误。 + --- ## 2. 命名规则 @@ -44,6 +49,7 @@ | `init` | `VarsInit`、`数据初始化` | 建议统一为 `VarsInit`;如项目已有 `数据初始化`,可保留但同一 project 内保持一致 | | `center` | `Center` | 固定命名,每个维修 Yarn project 一个 | | `content` | `Stage1`、`开头对话`、`检查情绪`、`旋钮调节` | 阶段入口建议用 `StageN`;阶段内子节点用中文动宾/名词短语 | +| `interaction` | `Stage3`、`等待插线`、`End_memoryPaly` | 沿用内容节点命名;名称应能表达即将进入的游戏内交互 | | `performance` | `地铁到站演出`、`手术动画`、`滤波器启动动画` | 中文描述性名称,明确表达演出内容 | | `event` | `EmoPlugIn`、`IntoEyeView`、`OnSalesTurnComplete` | 用 `OnXxx` 或模块名 + 事件名,表达触发来源 | | `function` | `Wave_Show_Func`、`Wave_Hide_Func`、`Module_HighlightOff_Func` | `领域_动作_Func` 或 `模块_动作_Func`,PascalCase;必须带 `_Func` 后缀 | @@ -190,6 +196,32 @@ hs: 医——生——! #line:0bade9e --- +### 3.4.1 interaction 节点 + +`interaction` 是一种有内容的玩法交互入口。它可以承载与 `content` 相同的台词、选项、演出和 +状态准备,但进入节点时不保存;只有节点完成后直接结束本轮 Dialogue 的执行路径,才会生成 +state-only 自动档,并把控制权交给游戏系统。 + +```yarn +title: Stage3 +tags: interaction +--- +<> +dn: 请使用检查设备继续操作。 +=== +``` + +**约束清单:** + +- `interaction` 是一级类型,不与 `content` 等其他一级类型并列书写。 +- 正常交互入口路径应直接结束 Dialogue;读档时只恢复状态,不重新运行该节点。 +- 允许某些条件分支 `<>` / `<>` 到其他 Yarn 节点;这些路径会取消退出档并记录告警。 +- 节点结束前必须完成所有需要写入快照的状态命令。未等待的 fire-and-forget 命令不可作为可靠边界。 +- 后续玩法必须能够完全通过 Snapshot Provider 恢复,包括输入、交互监听和玩法模式,而不只是视觉状态。 +- 普通节点需要同样语义时可附加 `save_on_exit`;`interaction` 无需重复附加。 + +--- + ### 3.5 performance 节点 原子化线性演出节点,用于 timeline、cutscene 等必须完整播放的演出。 @@ -326,6 +358,8 @@ Center ◀─────────────────────┘ │ │ │ ├── content 子节点 ── ... │ │ + │ ├── interaction 子节点 ──> 结束 Dialogue ──> 游戏内交互 + │ │ │ ├── performance 子节点 ──<>──> content 或 end │ │ │ └── <> ──> <> @@ -350,14 +384,14 @@ Center ◀─────────────────────┘ ## 6. 快速检查表 -| 检查项 | start | init | center | content | performance | event | function | end | -| ------------------ | ----- | ---- | ------ | ------- | ------------------------ | ------------------- | -------- | ----------------- | -| 是否包含叙事文本? | 禁止 | 禁止 | 禁止 | 允许 | 禁止 | 禁止 | 禁止 | 允许(少量) | -| 是否包含选项? | 禁止 | 禁止 | 禁止 | 允许 | 禁止 | 禁止 | 禁止 | 禁止 | -| 是否有 `<>`? | 仅一个出口 | 禁止 | 可多出口 | 通常一个出口 | 单出口 | 可有 | 禁止 | 最终 `<>` | -| 是否修改 `$gameStage`? | 禁止 | 可初始化 | 禁止 | 允许 | 禁止 | 尽量避免 | 禁止 | 禁止 | -| 是否被 C# 调用? | 否 | 否 | 否 | 否 | 否 | 是 | 否 | 否 | -| 是否可作为保存边界? | 否 | 否 | 否(瞬跳) | 是(**瞬跳 content 除外**,须 `no_save`) | 仅开头/结尾(节点本身建议 `no_save`) | 否(可附加 `no_save` 强调) | 否 | 否(建议 `no_save`) | +| 检查项 | start | init | center | content | interaction | performance | event | function | end | +| ------------------ | ----- | ---- | ------ | ------- | ----------- | ------------------------ | ------------------- | -------- | ----------------- | +| 是否包含叙事文本? | 禁止 | 禁止 | 禁止 | 允许 | 允许 | 禁止 | 禁止 | 禁止 | 允许(少量) | +| 是否包含选项? | 禁止 | 禁止 | 禁止 | 允许 | 允许 | 禁止 | 禁止 | 禁止 | 禁止 | +| 是否有 `<>`? | 仅一个出口 | 禁止 | 可多出口 | 通常一个出口 | 可按分支跳转 | 单出口 | 可有 | 禁止 | 最终 `<>` | +| 是否修改 `$gameStage`? | 禁止 | 可初始化 | 禁止 | 允许 | 允许 | 禁止 | 尽量避免 | 禁止 | 禁止 | +| 是否被 C# 调用? | 否 | 否 | 否 | 否 | 否 | 否 | 是 | 否 | 否 | +| 是否可作为保存边界? | 否 | 否 | 否(瞬跳) | 是(**瞬跳 content 除外**,须 `no_save`) | 正常结束时保存 state-only | 仅开头/结尾(节点本身建议 `no_save`) | 否(可附加 `no_save` 强调) | 否 | 否(建议 `no_save`) | --- @@ -367,6 +401,7 @@ Center ◀─────────────────────┘ - `deprecated`:已废弃节点,运行时不可达。 - `wip`:待编辑/占位节点,运行时不可达。 - `no_save`:该节点不可作为保存边界,运行时经过此处不应触发保存。 +- `save_on_exit`:节点进入时不保存;节点完成并直接结束本轮 Dialogue 后保存 state-only 档。 ### 7.1 `no_save` 使用场景 @@ -380,9 +415,10 @@ Center ◀─────────────────────┘ 任何活跃节点不得 `<>` 到 `deprecated` / `wip` 节点,也不得在标记为 `no_save` 的节点处触发 **OnNodeStart 自动保存**。 -### 7.2 Yarn 显式存档 `<>` +### 7.2 Yarn 显式存档 `<>`(兼容旧内容) -用于节点末尾、状态已就位、即将进入无对话 / 纯 C# 交互阶段时的**显式存盘点**(`OnNodeStart` 无法覆盖的场景)。 +`<>` 已标记为 deprecated,仅用于兼容尚未迁移的旧内容。新内容进入无对话 / 纯 C# 交互阶段时, +应优先使用一级类型 `interaction`;其他一级类型需要相同行为时使用 `save_on_exit`。 ```yarn <> diff --git a/Docs/存档系统设计方案.md b/Docs/存档系统设计方案.md index c12c0ba7d..21c9ebc2c 100644 --- a/Docs/存档系统设计方案.md +++ b/Docs/存档系统设计方案.md @@ -230,11 +230,14 @@ P7 横切 ### P3 可存点判定层(基础版已落地) -- `DialogController.OnNodeStart` 更新当前 node/tags 后调用 `SavePointEvaluator.OnNodeStartForAutoSave()`,通过后执行 `SaveRestoreOrchestrator.AutoSaveRoutine(nodeName)`。 +- `DialogController.OnNodeStart` 更新当前 node/tags 后解析 `NodeSavePolicy`:普通合法节点执行进入档; + `interaction` / `save_on_exit` 登记退出 checkpoint,节点完成并直接结束本轮 Dialogue 后写 state-only 档。 - **Fresh 进入 vs detour 续跑**:`SavePointEvaluator` 按 `yarnProjectId` 维护 `InProgressNodes`;节点尚未 `onNodeComplete` 时再次 `onNodeStart`(Yarn detour 返回父节点)→ `DetourResume`,跳过自动存。`OnNodeComplete` 移出集合;切换 YarnProject / `StopDialog` 重置;读档 anchor 重进仅 `OnRestoreEnterNode` 标记,不写盘。 -- **tag 规则**:`hub` / `linear` / `content` 默认允许;`start` / `init` / `function` / `detour` / `center` / `performance` / `event` / `end` 默认禁止;`no_save` 覆盖一切并禁止 **OnNodeStart 自动存**。 +- **tag 规则**:`hub` / `linear` / `content` 默认进入时保存;`interaction` 默认 Dialogue 退出时保存; + `save_on_exit` 可覆盖其他一级类型;`no_save` 禁止保存,并与上述退出语义冲突。 - 自动档边界 = **Fresh OnNodeStart** 且 tag 判定通过且非 `DetourResume`(不区分 hub / content / linear 的额外终身去重)。 -- Yarn `<>`(`SaveYarnCommand`)走 `CanExplicitSave`:仅全局门控,默认 omit anchor;用于节点末尾进入无对话 / Fix 交互等边界。 +- Yarn `<>`(`SaveYarnCommand`)保留为旧内容兼容入口;新内容使用 `interaction` 或 `save_on_exit`。 +- 自动档 Capture 后进入串行写盘队列;后续有效 checkpoint 不再因前一档仍在写盘而被丢弃。 - `SaveRestoreOrchestrator.IsRestoring` 与 `GameManager.Session.IsPaused` 会阻止自动保存与显式 save,避免读档中覆盖自动档。 - `CreateManualSlot` 复用 `CanManualSave`:`slot_0` 存在且通过全局门控即可复制(含显式 `<>` 写盘后的手动档)。