feat(save-system): 新增 Yarn <<save>> 显式存档与测试归档模式

This commit is contained in:
2026-06-24 22:45:00 +08:00
parent e90da7bab7
commit 5a0b7a047b
8 changed files with 372 additions and 47 deletions
+42 -16
View File
@@ -61,26 +61,28 @@ namespace AibisDream.SaveSystem
"end", // 维修结束/收尾
};
/// <summary>
/// 当前是否可以执行 Yarn 显式 <c>&lt;&lt;save&gt;&gt;</c> 命令。
/// 仅检查全局门控,不检查节点 tag / no_save。
/// </summary>
public static bool CanExplicitSave(out SavePointRejectReason reason)
{
if (!TryPassGlobalSaveGuards(out reason))
{
return false;
}
reason = SavePointRejectReason.None;
return true;
}
/// <summary>
/// 当前是否可以自动存档。
/// </summary>
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
/// <summary>
/// 当前是否可以手动存档。
/// 手动档 = 复制最近自动档,因此需要当前可自动存档且自动档存在
/// 手动档 = 复制最近落盘的自动档(含 OnNodeStart 与显式 <c>&lt;&lt;save&gt;&gt;</c>
/// </summary>
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<string> tags, string tag)
{
if (tags == null) return false;
@@ -47,10 +47,13 @@ namespace AibisDream.SaveSystem
}
/// <summary>自动存档协程:settle 一帧 → 主线程 Capture → 异步写盘。</summary>
public static IEnumerator AutoSaveRoutine(string triggerNodeName = null)
/// <param name="triggerNodeName">见 <see cref="SnapshotCapture.Capture"/>;显式 save 传 null。</param>
/// <param name="omitAnchor">为 true 时不写入 Yarn 节点 anchor。</param>
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
}
}
/// <summary>
/// Yarn <c>&lt;&lt;save&gt;&gt;</c> 显式存档:默认 omit anchor,调用方需已通过 <see cref="SavePointEvaluator.CanExplicitSave"/>。
/// </summary>
public static IEnumerator ExplicitSaveRoutine()
{
yield return AutoSaveRoutine(triggerNodeName: null, omitAnchor: true);
}
/// <summary>将当前自动档复制到指定手动档。</summary>
public static void CreateManualSlot(int slotIndex)
{
@@ -0,0 +1,24 @@
using System.Collections;
using UnityEngine;
using Yarn.Unity;
namespace AibisDream.SaveSystem
{
/// <summary>
/// Yarn 显式存档命令。在节点末尾、状态命令执行完毕且即将进入无对话阶段时使用。
/// </summary>
public static class SaveYarnCommand
{
[YarnCommand("save")]
public static IEnumerator Save()
{
if (!SavePointEvaluator.CanExplicitSave(out var reason))
{
Debug.LogWarning($"[SaveYarnCommand] <<save>> 被拒绝: {reason}");
yield break;
}
yield return SaveRestoreOrchestrator.ExplicitSaveRoutine();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1fc9f19f937b87d4d8fd6d2f057ca14c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+29 -15
View File
@@ -18,7 +18,13 @@ namespace AibisDream.SaveSystem
/// 触发存档时的 Yarn 节点名(通常为 <c>OnNodeStart</c> 传入值)。
/// 非 null 时作为 anchor,并与 Capture 时刻的当前节点比较;不一致时打 warning,不阻断写盘。
/// </param>
public static SaveSnapshot Capture(YarnVariableStorage storage, string triggerNodeName = null)
/// <param name="omitAnchor">
/// 为 true 时写入空 <see cref="AnchorSnapshot.nodeName"/>(显式 <c>&lt;&lt;save&gt;&gt;</c> 默认行为)。
/// </param>
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
};
}
+3 -2
View File
@@ -10,10 +10,11 @@ namespace AibisDream.SaveSystem
{
/// <summary>捕获当前运行时快照。</summary>
/// <param name="triggerNodeName">见 <see cref="SnapshotCapture.Capture"/>。</param>
public static SaveSnapshot Capture(string triggerNodeName = null)
/// <param name="omitAnchor">见 <see cref="SnapshotCapture.Capture"/>。</param>
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);
}
/// <summary>
@@ -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
{
/// <summary>
/// 测试存档模式:每次自动存档写入 <see cref="ConstRef.TestAutoSaveArchivePath"/> 下的独立子目录。
/// 开启后不写入 slot_0,存多少次就保留多少份,与正式 saves/ 完全隔离。
/// </summary>
public static class TestSaveArchive
{
public const string EditorPrefsEnabledKey = "AibisDream.SaveSystem.TestArchiveEnabled";
/// <summary>测试存档模式是否开启(Editor 下由 EditorPrefs 控制,构建产物恒为 false)。</summary>
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();
/// <summary>将快照写入 testsavs 新条目,返回文件夹名。每次调用均新建,不覆盖已有条目。</summary>
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<TestSaveArchiveEntry> ListEntries()
{
var result = new List<TestSaveArchiveEntry>();
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<SlotMeta>(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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 408acae5dfcc0a94094d87e33ba53cd4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: