feat(save): 实现 Yarn tag 自动存档判定与异步写盘

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 14:34:19 +08:00
co-authored by Cursor
parent de671ff94c
commit 0a6e892504
8 changed files with 291 additions and 61 deletions
@@ -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)
@@ -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
+95 -11
View File
@@ -1,3 +1,7 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace AibisDream.SaveSystem
{
/// <summary>
@@ -8,21 +12,54 @@ namespace AibisDream.SaveSystem
None,
Restoring,
GamePaused,
// 遗留值:早期逻辑曾返回,当前实现中不再使用,保留以避免外部代码编译失败。
NoActiveNode,
StartNode,
FunctionNode,
NoAutoSave
NoAutoSave,
/// <summary>节点 tag 在默认禁止保存集合中。</summary>
DeniedByTag,
/// <summary>节点显式附加了 no_save 标记。</summary>
MarkedNoSave,
}
/// <summary>
/// 可存点判定器。
/// <para>
/// P3 只搭框架:规则不是最终版,后续随 Yarn 节点 tags 整理和章节策略扩展而更新。
/// 当前所有 FixState 均放行,不可存状态由 Yarn 节点流程控制。
/// 基于 Yarn 节点 tag 判定是否允许自动存档:
/// - 白名单 taghub / linear / content)默认允许;
/// - 黑名单 tagstart / init / function / detour / center / performance / event / end)默认禁止;
/// - no_save 附加标记覆盖默认语义,一律禁止;
/// - 无活跃节点、或节点未声明 tag,默认允许并打 warning
/// - 未知 tag 默认允许并打 warning。
/// </para>
/// </summary>
public static class SavePointEvaluator
{
/// <summary>默认可作为自动存档边界的节点 tag。</summary>
private static readonly HashSet<string> AutoSaveAllowedTags = new(StringComparer.OrdinalIgnoreCase)
{
"hub", // 梦境:场景导航中枢
"linear", // 梦境:线性剧情推进
"content", // 维修:阶段内容节点
};
/// <summary>默认禁止作为自动存档边界的节点 tag。</summary>
private static readonly HashSet<string> AutoSaveDeniedTags = new(StringComparer.OrdinalIgnoreCase)
{
"start", // 引擎入口
"init", // 变量声明/初始化
"function", // 纯指令函数
"detour", // 梦境内容片段
"center", // 维修阶段调度中枢
"performance", // 维修原子化演出
"event", // 维修 C# 事件响应入口
"end", // 维修结束/收尾
};
/// <summary>
/// 当前是否可以自动存档。
/// </summary>
@@ -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<string> tags, string tag)
private static bool IsTagPresent(IReadOnlyList<string> tags, string tag)
{
if (tags == null) return false;
@@ -101,5 +169,21 @@ namespace AibisDream.SaveSystem
return false;
}
/// <summary>
/// 从 tags 中找到第一个已知的保存语义主 tag;若均未知则返回 null。
/// </summary>
private static string FindPrimaryTag(IReadOnlyList<string> tags)
{
foreach (var tag in tags)
{
if (AutoSaveAllowedTags.Contains(tag) || AutoSaveDeniedTags.Contains(tag))
{
return tag;
}
}
return null;
}
}
}
@@ -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
/// <summary>
/// 存读档流程编排层:连接 UI、<see cref="SnapshotService"/> 与槽位系统。
/// <para>
/// 对外入口:<see cref="TryAutoSave"/>节点进入事件触发)、
/// 对外入口:<see cref="TryAutoSave"/>手动/调试触发)、
/// <see cref="AutoSaveRoutine"/>(节点进入事件触发)、
/// <see cref="CreateManualSlot"/>(手动存档)、
/// <see cref="RestoreFromSlot"/>(读档)、
/// <see cref="RestoreFromFile"/>(旧档/调试)。
@@ -22,11 +22,12 @@ namespace AibisDream.SaveSystem
/// <summary>是否正在读档还原中;为 true 时禁止自动存档覆盖 slot_0。</summary>
public static bool IsRestoring { get; private set; }
/// <summary>捕获快照并写入自动档(slot 0);含保存 Loading UI。</summary>
private static bool _isAutoSaving;
/// <summary>启动自动存档协程(手动/调试入口)。</summary>
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<InfoPanel>().ShowSaveLoading())
.Delay(1f)
.Callback(() => UIManager.Instance.GetPanel<InfoPanel>().HideSaveLoading())
.Start(host);
var (triggerNodeName, _) = DialogController.Instance.GetCurrentNodeContext();
DialogController.Instance.StartCoroutine(AutoSaveRoutine(triggerNodeName));
}
using (new CodeTimer("SaveSnapshot"))
/// <summary>自动存档协程:settle 一帧 → 主线程 Capture → 异步写盘。</summary>
/// <param name="triggerNodeName"><c>OnNodeStart</c> 触发存档时的节点名,作为 anchor 写入快照。</param>
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>();
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;
}
}
+24 -6
View File
@@ -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));
}
}
}
}
}
+38 -12
View File
@@ -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();
/// <summary>将快照与缩略图写入指定槽位。</summary>
public static void SaveToSlot(int slotIndex, SaveSnapshot snapshot, byte[] thumbnailPng)
@@ -30,6 +32,23 @@ namespace AibisDream.SaveSystem
CloudSaveManager.OnSlotWritten(SlotIndex.Auto);
}
/// <summary>
/// 主线程完成序列化后,在后台线程写入自动档(slot 0)。
/// 调用方须已在主线程完成 <see cref="SnapshotService.Capture"/>。
/// </summary>
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);
}
/// <summary>将当前自动档复制到指定手动档。</summary>
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));
}
}
}
+32 -5
View File
@@ -14,7 +14,11 @@ namespace AibisDream.SaveSystem
public static class SnapshotCapture
{
/// <summary>捕获当前完整快照。调用前需已注册全部 provider。</summary>
public static SaveSnapshot Capture(YarnVariableStorage storage)
/// <param name="triggerNodeName">
/// 触发存档时的 Yarn 节点名(通常为 <c>OnNodeStart</c> 传入值)。
/// 非 null 时作为 anchor,并与 Capture 时刻的当前节点比较;不一致时打 warning,不阻断写盘。
/// </param>
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();
+3 -2
View File
@@ -9,10 +9,11 @@ namespace AibisDream.SaveSystem
public static class SnapshotService
{
/// <summary>捕获当前运行时快照。</summary>
public static SaveSnapshot Capture()
/// <param name="triggerNodeName">见 <see cref="SnapshotCapture.Capture"/>。</param>
public static SaveSnapshot Capture(string triggerNodeName = null)
{
SnapshotRegistry.EnsureInitialized();
return SnapshotCapture.Capture(YarnVariableStorage.Instance);
return SnapshotCapture.Capture(YarnVariableStorage.Instance, triggerNodeName);
}
/// <summary>