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