feat: 存档系统增加退出节点保存时机

This commit is contained in:
2026-07-26 19:59:56 +08:00
parent 4caf70b554
commit 82c15e8f48
18 changed files with 1195 additions and 159 deletions
@@ -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
/// <param name="yarnProject">Yarn组</param>
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> advanceMode = new(new AdvanceMode());
@@ -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<TestSaveCoverageRow>();
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<string> GetExpectedNodes(TalkSceneSO chapter)
{
var result = new HashSet<string>(StringComparer.Ordinal);
return GetExpectedCheckpoints(chapter).Values
.Select(item => item.NodeName)
.ToHashSet(StringComparer.Ordinal);
}
private static Dictionary<string, ExpectedCheckpoint> GetExpectedCheckpoints(TalkSceneSO chapter)
{
var result = new Dictionary<string, ExpectedCheckpoint>(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<string>()
: tagsValue.Split(Array.Empty<char>(), 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
@@ -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}";
}
}
}
@@ -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))
@@ -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;
}
+11 -2
View File
@@ -37,13 +37,22 @@ YarnVariableStorage.Instance.SetValue("$foo", 1f);
Yarn 脚本:
```yarn
// 推荐:有内容的交互入口节点
tags: interaction
// 或为其他一级类型覆盖保存时机
tags: content save_on_exit
// 仅兼容旧内容
<<save>>
```
- `interaction` / `save_on_exit` 进入时不存档;对应节点完成后若直接结束本轮 Dialogue,则自动保存 state-only 档。
- 若执行路径继续 `jump` / `detour` 到其他 Yarn 节点,退出档会取消并记录告警。
- 放在目标节点**末尾**:所有状态命令(`switch_fix_system_to``hide_dialog``play_timeline` 等)执行完毕之后,`<<jump>>` / `<<detour>>` 之前。
- 默认 **omit anchor**`anchor.nodeName` 为空);读档时不重进 Yarn,仅还原 scene + sections + 变量。
- 绕过 tag / `no_save` 的自动判定(`CanExplicitSave`);仍受读档中、暂停、SuppressAutoSave 门控。
-`OnNodeStart` 分工:常规 `hub` / `linear` / `content` 仍靠节点进入自动存;`<<save>>` 用于即将进入无对话 / 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,不写盘)。
+192 -68
View File
@@ -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);
}
/// <summary>
/// 可存点被拒绝的原因,供日志与 UI 提示使用。
/// </summary>
@@ -28,6 +63,9 @@ namespace AibisDream.SaveSystem
/// <summary>同一节点仍在执行中(detour 返回续跑),非 Fresh 进入。</summary>
DetourResume,
/// <summary>节点类型或保存附加标记互相冲突。</summary>
InvalidTagConfiguration,
}
/// <summary>
@@ -35,8 +73,9 @@ namespace AibisDream.SaveSystem
/// <para>
/// 基于 Yarn 节点 tag 判定是否允许自动存档:
/// - 白名单 taghub / linear / content)默认允许;
/// - interaction 或 save_on_exit 在 Dialogue 正常结束时保存 state-only 档;
/// - 黑名单 tagstart / init / function / detour / center / performance / event / end)默认禁止;
/// - no_save 附加标记覆盖默认语义,一律禁止
/// - no_save 附加标记覆盖默认语义;与退出保存语义并存时视为配置冲突
/// - 无活跃节点、或节点未声明 tag,默认允许并打 warning
/// - 未知 tag 默认允许并打 warning。
/// </para>
@@ -68,6 +107,13 @@ namespace AibisDream.SaveSystem
"end", // 维修结束/收尾
};
private static readonly HashSet<string> PrimaryNodeTags = new(
AutoSaveAllowedTags.Concat(AutoSaveDeniedTags),
StringComparer.OrdinalIgnoreCase)
{
"interaction",
};
private static string _activeProjectId;
private static readonly HashSet<string> InProgressNodes = new(StringComparer.Ordinal);
@@ -113,6 +159,27 @@ namespace AibisDream.SaveSystem
IReadOnlyList<string> tags,
out SavePointRejectReason reason)
{
var accepted = OnNodeStartForSavePolicy(
projectId,
nodeName,
tags,
out var policy,
out reason);
return accepted && policy.Timing == NodeSaveTiming.NodeEnter;
}
/// <summary>
/// OnNodeStart 存档策略判定:Fresh 进入时返回节点的进入/退出保存策略;
/// detour 返回续跑或全局门控失败时返回 false。
/// </summary>
public static bool OnNodeStartForSavePolicy(
string projectId,
string nodeName,
IReadOnlyList<string> 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;
}
/// <summary>仅 tag / no_save 判定,不含 InProgress detour 门控(供编辑器模拟)。</summary>
@@ -140,7 +208,8 @@ namespace AibisDream.SaveSystem
IReadOnlyList<string> 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<string> tags,
out SavePointRejectReason reason)
{
return EvaluateTagsForAutoSave(nodeName, tags, out reason, logWarnings: false);
return ResolveNodePolicy(nodeName, tags, out reason, logWarnings: false).Timing
== NodeSaveTiming.NodeEnter;
}
#endif
/// <summary>仅根据节点 tags 解析保存策略,不检查全局门控或 detour 状态。</summary>
public static NodeSavePolicy ResolveNodePolicy(
string nodeName,
IReadOnlyList<string> 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;
}
/// <summary>节点执行完毕,移出 InProgress。</summary>
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;
}
/// <summary>
@@ -251,71 +421,13 @@ namespace AibisDream.SaveSystem
return true;
}
private static bool EvaluateTagsForAutoSave(
string nodeName,
IReadOnlyList<string> 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<string> 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<string> tags)
private static List<string> FindPrimaryTags(IReadOnlyList<string> tags)
{
var result = new List<string>();
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;
}
}
}
@@ -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;
}
}
/// <summary>按请求顺序串行执行异步任务;前一个任务失败不会阻断后续任务。</summary>
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<Task> 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);
}
}
/// <summary>
/// 存读档流程编排层:连接 UI、<see cref="SnapshotService"/> 与槽位系统。
/// </summary>
@@ -20,13 +139,24 @@ namespace AibisDream.SaveSystem
public static bool IsAutoSaveSuppressed => _autoSaveSuppressDepth > 0;
/// <summary>是否正在异步捕获或写入自动档;供只读诊断 UI 使用。</summary>
public static bool IsSaving => _isAutoSaving;
public static bool IsSaving
{
get
{
lock (SaveQueueGate)
{
return _activeCaptureCount > 0 || SaveWriteQueue.PendingCount > 0;
}
}
}
public static IReadOnlyList<string> LastRestoreLog => _lastRestoreLog;
private static bool _isAutoSaving;
private static int _autoSaveSuppressDepth;
private static readonly List<string> _lastRestoreLog = new();
private static readonly object SaveQueueGate = new();
private static readonly SerialTaskQueue SaveWriteQueue = new();
private static int _activeCaptureCount;
/// <summary>启动自动存档协程(手动/调试入口)。</summary>
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)));
}
/// <summary>自动存档协程:settle 一帧 → 主线程 Capture → 后台序列化写盘(不阻塞主线程)。</summary>
@@ -52,21 +184,57 @@ namespace AibisDream.SaveSystem
/// <param name="omitAnchor">为 true 时不写入 Yarn 节点 anchor。</param>
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<bool> isRequestStillValid)
{
yield return SaveRoutine(
SaveRequest.DialogueExit(sourceNodeName, yarnProjectId),
isRequestStillValid);
}
private static IEnumerator SaveRoutine(SaveRequest request, Func<bool> 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>();
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
/// </summary>
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;
}
/// <summary>将当前自动档复制到指定手动档。</summary>
+1 -1
View File
@@ -5,7 +5,7 @@ using Yarn.Unity;
namespace AibisDream.SaveSystem
{
/// <summary>
/// Yarn 显式存档命令。在节点末尾、状态命令执行完毕且即将进入无对话阶段时使用
/// Yarn 显式存档兼容命令。新内容应使用 interaction 一级类型或 save_on_exit 附加标记
/// </summary>
public static class SaveYarnCommand
{
+14 -3
View File
@@ -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);
}