feat(save-system): 新增 Yarn <<save>> 显式存档与测试归档模式
This commit is contained in:
@@ -61,26 +61,28 @@ namespace AibisDream.SaveSystem
|
||||
"end", // 维修结束/收尾
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 当前是否可以执行 Yarn 显式 <c><<save>></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><<save>></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><<save>></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:
|
||||
@@ -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><<save>></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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user