Merge branch 'develop' into '7.26春表现优化'
# Conflicts: # Assets/Yarn/FP/FP_Peipei2/Stage6_分析.yarn
This commit is contained in:
@@ -22,6 +22,13 @@ namespace AibisDream
|
||||
|
||||
private string _currentNodeName;
|
||||
private string[] _currentNodeTags;
|
||||
private PendingExitCheckpoint _pendingExitCheckpoint;
|
||||
private long _dialogueFlowVersion;
|
||||
private long _dialogueRunId;
|
||||
private IDisposable _exitHandoffLock;
|
||||
|
||||
internal long DialogueFlowVersion => _dialogueFlowVersion;
|
||||
internal long DialogueRunId => _dialogueRunId;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
@@ -31,18 +38,9 @@ namespace AibisDream
|
||||
_dialogueRunner.onNodeStart ??= new UnityEventString();
|
||||
_dialogueRunner.onNodeComplete ??= new UnityEventString();
|
||||
|
||||
_dialogueRunner.onDialogueStart.AddListener(() =>
|
||||
{
|
||||
DialogueActivityChanged?.Invoke(true);
|
||||
EnumEventSystem.Global.Send(InteractionEventEnum.DialogStart);
|
||||
});
|
||||
_dialogueRunner.onDialogueStart.AddListener(OnDialogueStart);
|
||||
|
||||
_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 +59,8 @@ namespace AibisDream
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
CancelPendingExitCheckpoint("DialogController disabled", logIfPending: false);
|
||||
|
||||
SingleCastEventSystem.Global.Unregister(DialogEventEnum.StartNode);
|
||||
|
||||
EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionStarted, ResetAdvanceMode);
|
||||
@@ -92,6 +92,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 +105,8 @@ namespace AibisDream
|
||||
|
||||
public IEnumerator StopDialogRoutine()
|
||||
{
|
||||
CancelPendingExitCheckpoint("StopDialogRoutine", logIfPending: false);
|
||||
|
||||
if (_dialogueRunner != null && _dialogueRunner.IsDialogueRunning)
|
||||
{
|
||||
yield return _dialogueRunner.Stop();
|
||||
@@ -122,6 +125,7 @@ namespace AibisDream
|
||||
|
||||
public void LoadDialog(YarnProject yarnProject)
|
||||
{
|
||||
CancelPendingExitCheckpoint("LoadDialog 切换 YarnProject");
|
||||
PrepareYarnProjectForDialog(yarnProject);
|
||||
_dialogueRunner.SetProject(yarnProject);
|
||||
}
|
||||
@@ -189,9 +193,14 @@ namespace AibisDream
|
||||
|
||||
private void OnNodeStart(string nodeName)
|
||||
{
|
||||
_dialogueFlowVersion++;
|
||||
CancelPendingExitCheckpoint(
|
||||
$"节点 {nodeName} 已开始,说明之前的退出保存路径继续运行 Yarn",
|
||||
incrementFlowVersion: false);
|
||||
UpdateCurrentNodeContext(nodeName);
|
||||
|
||||
var projectId = _dialogueRunner?.YarnProject?.name;
|
||||
var talkSceneId = GetCurrentTalkSceneId();
|
||||
var tags = _currentNodeTags ?? Array.Empty<string>();
|
||||
|
||||
if (SaveRestoreOrchestrator.IsRestoring)
|
||||
@@ -200,7 +209,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 +224,122 @@ namespace AibisDream
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(SaveRestoreOrchestrator.AutoSaveRoutine(nodeName));
|
||||
if (policy.Timing == NodeSaveTiming.DialogueExit)
|
||||
{
|
||||
_pendingExitCheckpoint = new PendingExitCheckpoint
|
||||
{
|
||||
ProjectId = projectId,
|
||||
TalkSceneId = talkSceneId,
|
||||
NodeName = nodeName,
|
||||
FlowVersion = _dialogueFlowVersion,
|
||||
DialogueRunId = _dialogueRunId
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy.Timing == NodeSaveTiming.NodeEnter)
|
||||
{
|
||||
StartCoroutine(SaveRestoreOrchestrator.AutoSaveRoutine(
|
||||
nodeName,
|
||||
projectId,
|
||||
talkSceneId,
|
||||
_dialogueRunId,
|
||||
_dialogueFlowVersion));
|
||||
}
|
||||
}
|
||||
|
||||
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.DialogueRunId == _dialogueRunId)
|
||||
{
|
||||
_pendingExitCheckpoint.IsNodeComplete = true;
|
||||
}
|
||||
|
||||
ClearCurrentNodeContext();
|
||||
}
|
||||
|
||||
private void OnDialogueComplete()
|
||||
{
|
||||
var checkpoint = _pendingExitCheckpoint;
|
||||
_pendingExitCheckpoint = null;
|
||||
if (checkpoint?.IsNodeComplete == true)
|
||||
{
|
||||
_exitHandoffLock = EventSystemEx.Instance?.AcquireInteractionLock(
|
||||
$"exit checkpoint:{checkpoint.NodeName}");
|
||||
}
|
||||
|
||||
DialogueActivityChanged?.Invoke(false);
|
||||
EnumEventSystem.Global.Send(InteractionEventEnum.DialogEnd);
|
||||
ClearCurrentNodeContext();
|
||||
|
||||
if (checkpoint?.IsNodeComplete != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(SaveDialogueExitCheckpointRoutine(checkpoint));
|
||||
}
|
||||
|
||||
private void OnDialogueStart()
|
||||
{
|
||||
_dialogueRunId++;
|
||||
_dialogueFlowVersion++;
|
||||
DialogueActivityChanged?.Invoke(true);
|
||||
EnumEventSystem.Global.Send(InteractionEventEnum.DialogStart);
|
||||
}
|
||||
|
||||
private IEnumerator SaveDialogueExitCheckpointRoutine(PendingExitCheckpoint checkpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
yield return SaveRestoreOrchestrator.DialogueExitSaveRoutine(
|
||||
checkpoint.NodeName,
|
||||
checkpoint.ProjectId,
|
||||
checkpoint.TalkSceneId,
|
||||
checkpoint.DialogueRunId,
|
||||
checkpoint.FlowVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseExitHandoffLock();
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelPendingExitCheckpoint(
|
||||
string reason,
|
||||
bool logIfPending = true,
|
||||
bool incrementFlowVersion = true)
|
||||
{
|
||||
if (incrementFlowVersion)
|
||||
{
|
||||
_dialogueFlowVersion++;
|
||||
}
|
||||
|
||||
ReleaseExitHandoffLock();
|
||||
|
||||
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 +362,29 @@ namespace AibisDream
|
||||
_currentNodeTags = null;
|
||||
}
|
||||
|
||||
private void ReleaseExitHandoffLock()
|
||||
{
|
||||
_exitHandoffLock?.Dispose();
|
||||
_exitHandoffLock = null;
|
||||
}
|
||||
|
||||
private static string GetCurrentTalkSceneId()
|
||||
{
|
||||
return GameManager.Instance != null
|
||||
? GameManager.Session.CurrentTalkScene?.name
|
||||
: null;
|
||||
}
|
||||
|
||||
private sealed class PendingExitCheckpoint
|
||||
{
|
||||
public string ProjectId;
|
||||
public string TalkSceneId;
|
||||
public string NodeName;
|
||||
public long FlowVersion;
|
||||
public long DialogueRunId;
|
||||
public bool IsNodeComplete;
|
||||
}
|
||||
|
||||
#region 推进方式修改
|
||||
|
||||
public BindProperty<AdvanceMode> advanceMode = new(new AdvanceMode());
|
||||
|
||||
@@ -59,11 +59,6 @@ namespace AibisDream
|
||||
return YarnTask.CompletedTask;
|
||||
}
|
||||
|
||||
public override void OnNodeExit(string nodeName)
|
||||
{
|
||||
ClearPendingOptionPrompt();
|
||||
}
|
||||
|
||||
public void LoadBubbles(BubbleSlotGroupData data)
|
||||
{
|
||||
defaultViewType = data.dialogViewType;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using AibisDream.Kit;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -9,6 +10,8 @@ namespace AibisDream.Framework
|
||||
#region 状态
|
||||
|
||||
public bool isLocked;
|
||||
private bool _dialogueLocked;
|
||||
private int _scopedLockDepth;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -106,12 +109,38 @@ namespace AibisDream.Framework
|
||||
|
||||
private void HandleDialogueComplete()
|
||||
{
|
||||
isLocked = false;
|
||||
_dialogueLocked = false;
|
||||
RefreshLockState();
|
||||
}
|
||||
|
||||
private void HandleDialogueStart()
|
||||
{
|
||||
isLocked = true;
|
||||
_dialogueLocked = true;
|
||||
RefreshLockState();
|
||||
}
|
||||
|
||||
public IDisposable AcquireInteractionLock(string reason)
|
||||
{
|
||||
_scopedLockDepth++;
|
||||
RefreshLockState();
|
||||
Debug.Log(
|
||||
$"[EventSystemEx] Acquire interaction lock: {reason ?? "(unspecified)"}, " +
|
||||
$"depth={_scopedLockDepth}");
|
||||
return new InteractionLockScope(this, reason);
|
||||
}
|
||||
|
||||
private void ReleaseInteractionLock(string reason)
|
||||
{
|
||||
_scopedLockDepth = Math.Max(0, _scopedLockDepth - 1);
|
||||
RefreshLockState();
|
||||
Debug.Log(
|
||||
$"[EventSystemEx] Release interaction lock: {reason ?? "(unspecified)"}, " +
|
||||
$"depth={_scopedLockDepth}");
|
||||
}
|
||||
|
||||
private void RefreshLockState()
|
||||
{
|
||||
isLocked = _dialogueLocked || _scopedLockDepth > 0;
|
||||
}
|
||||
|
||||
#region 单例必须的代码
|
||||
@@ -121,6 +150,9 @@ namespace AibisDream.Framework
|
||||
pointerOverObjList = new List<GameObject>();
|
||||
draggingObjList = new List<GameObject>();
|
||||
holdingObjList = new List<GameObject>();
|
||||
_dialogueLocked = false;
|
||||
_scopedLockDepth = 0;
|
||||
RefreshLockState();
|
||||
|
||||
EnumEventSystem.Global.Register(GameLifecycleEvent.SessionStarted, ClearAll);
|
||||
EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, ClearAll);
|
||||
@@ -143,6 +175,33 @@ namespace AibisDream.Framework
|
||||
|
||||
EnumEventSystem.Global.UnRegister(InteractionEventEnum.DialogEnd, HandleDialogueComplete);
|
||||
EnumEventSystem.Global.UnRegister(InteractionEventEnum.DialogStart, HandleDialogueStart);
|
||||
|
||||
_dialogueLocked = false;
|
||||
_scopedLockDepth = 0;
|
||||
RefreshLockState();
|
||||
}
|
||||
|
||||
private sealed class InteractionLockScope : IDisposable
|
||||
{
|
||||
private EventSystemEx _owner;
|
||||
private readonly string _reason;
|
||||
|
||||
public InteractionLockScope(EventSystemEx owner, string reason)
|
||||
{
|
||||
_owner = owner;
|
||||
_reason = reason;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_owner == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_owner.ReleaseInteractionLock(_reason);
|
||||
_owner = null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -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,7 +30,11 @@ 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)
|
||||
{
|
||||
if (!IsRecording
|
||||
|| snapshot?.anchor == null
|
||||
@@ -42,12 +46,26 @@ namespace AibisDream.SaveSystem
|
||||
}
|
||||
|
||||
var anchor = snapshot.anchor;
|
||||
var expectedResumeMode = anchor.startDialogueOnRestore
|
||||
? nameof(SaveResumeMode.RestartNode)
|
||||
: nameof(SaveResumeMode.StateOnly);
|
||||
if (!string.Equals(resumeMode, expectedResumeMode, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"测试存档恢复模式与 snapshot anchor 不一致: meta={resumeMode}, " +
|
||||
$"anchor={expectedResumeMode}");
|
||||
}
|
||||
|
||||
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,
|
||||
anchor.nodeName,
|
||||
resumeMode);
|
||||
return new TestSaveRecordRequest
|
||||
{
|
||||
Snapshot = snapshot,
|
||||
@@ -59,6 +77,9 @@ namespace AibisDream.SaveSystem
|
||||
SceneSoName = anchor.sceneSoName,
|
||||
YarnProjectId = anchor.yarnProjectId,
|
||||
NodeName = anchor.nodeName,
|
||||
SaveTrigger = saveTrigger,
|
||||
ResumeMode = resumeMode,
|
||||
StartDialogueOnRestore = anchor.startDialogueOnRestore,
|
||||
SceneName = snapshot.scene?.sceneName,
|
||||
GameVersion = snapshot.gameVersion
|
||||
};
|
||||
@@ -81,9 +102,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,9 @@ namespace AibisDream.SaveSystem
|
||||
sceneSoName = request.SceneSoName,
|
||||
yarnProjectId = request.YarnProjectId,
|
||||
nodeName = request.NodeName,
|
||||
saveTrigger = request.SaveTrigger,
|
||||
resumeMode = request.ResumeMode,
|
||||
startDialogueOnRestore = request.Snapshot.anchor.startDialogueOnRestore,
|
||||
sceneName = request.SceneName,
|
||||
firstSeenOrder = existingMeta?.firstSeenOrder ?? NextFirstSeenOrder(),
|
||||
firstRecordedAt = existingMeta?.firstRecordedAt ?? now,
|
||||
@@ -229,7 +232,14 @@ 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)))
|
||||
|| !string.Equals(
|
||||
snapshot.anchor.nodeName,
|
||||
entry.Meta.nodeName,
|
||||
StringComparison.Ordinal)
|
||||
|| snapshot.anchor.startDialogueOnRestore
|
||||
!= entry.Meta.startDialogueOnRestore
|
||||
|| snapshot.anchor.startDialogueOnRestore
|
||||
!= !IsStateOnly(entry.Meta.resumeMode)))
|
||||
{
|
||||
error = "快照 anchor 与 meta 不一致。";
|
||||
snapshot = null;
|
||||
@@ -359,6 +369,8 @@ namespace AibisDream.SaveSystem
|
||||
else if (string.IsNullOrWhiteSpace(meta.sceneSoName)
|
||||
|| string.IsNullOrWhiteSpace(meta.yarnProjectId)
|
||||
|| string.IsNullOrWhiteSpace(meta.nodeName)
|
||||
|| string.IsNullOrWhiteSpace(meta.saveTrigger)
|
||||
|| string.IsNullOrWhiteSpace(meta.resumeMode)
|
||||
|| string.IsNullOrWhiteSpace(meta.dedupeKey))
|
||||
{
|
||||
entry.Status = TestSaveEntryStatus.InvalidAnchor;
|
||||
@@ -457,6 +469,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))
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace AibisDream.SaveSystem
|
||||
[Serializable]
|
||||
public sealed class TestSaveMeta
|
||||
{
|
||||
public const int CurrentLibraryVersion = 1;
|
||||
public const int CurrentLibraryVersion = 2;
|
||||
|
||||
public int libraryVersion = CurrentLibraryVersion;
|
||||
public string entryId;
|
||||
@@ -28,6 +28,9 @@ namespace AibisDream.SaveSystem
|
||||
public string sceneSoName;
|
||||
public string yarnProjectId;
|
||||
public string nodeName;
|
||||
public string saveTrigger;
|
||||
public string resumeMode;
|
||||
public bool startDialogueOnRestore;
|
||||
public string sceneName;
|
||||
public long firstSeenOrder;
|
||||
public string firstRecordedAt;
|
||||
@@ -52,6 +55,8 @@ namespace AibisDream.SaveSystem
|
||||
public string EntryId => Meta?.entryId;
|
||||
public string ChapterId => Meta?.chapterId;
|
||||
public string NodeName => Meta?.nodeName;
|
||||
public string SaveTrigger => Meta?.saveTrigger;
|
||||
public string ResumeMode => Meta?.resumeMode;
|
||||
}
|
||||
|
||||
public sealed class TestSaveScanResult
|
||||
@@ -72,6 +77,9 @@ namespace AibisDream.SaveSystem
|
||||
public string SceneSoName;
|
||||
public string YarnProjectId;
|
||||
public string NodeName;
|
||||
public string SaveTrigger;
|
||||
public string ResumeMode;
|
||||
public bool StartDialogueOnRestore;
|
||||
public string SceneName;
|
||||
public string GameVersion;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// 自动存档(节点进入事件触发)
|
||||
yield return SaveRestoreOrchestrator.AutoSaveRoutine(nodeName);
|
||||
|
||||
// Yarn 显式存档(<<save>>,默认 omit anchor)
|
||||
// Yarn 显式存档(<<save>>,保留来源节点但恢复时不启动 Yarn)
|
||||
yield return SaveRestoreOrchestrator.ExplicitSaveRoutine();
|
||||
|
||||
// 手动存档:复制最近落盘档(含 OnNodeStart 与 <<save>>)
|
||||
@@ -37,13 +37,23 @@ 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 + 变量。
|
||||
- `anchor.nodeName` 始终保留来源节点;`anchor.startDialogueOnRestore=false`,读档时只加载 YarnProject,不启动节点。
|
||||
- 退出档在 `DialogEnd` 同步初始化后等待一帧捕获;这一帧由 scoped interaction lock 保持输入锁定,捕获进入写盘队列后再交出控制权。
|
||||
- 绕过 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 +63,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,不写盘)。
|
||||
|
||||
@@ -210,7 +220,7 @@ Fix 场景各 State 的 `Enter()` 通常包含相机过渡、Timeline 播放、F
|
||||
| 阶段 | 内容 |
|
||||
| --- | --- |
|
||||
| P2 | 基础版已落地:槽位、原子写、meta sidecar、缩略图、latest_slot、P1 测试档迁移;正式 UI 接入仍属 P6 |
|
||||
| P3 | 基础版已落地:`onNodeStart` 判定、tag 白/黑名单、`no_save`、读档/暂停门控、**detour 返回 InProgress 去重**;`<<save>>` 显式存档(`CanExplicitSave`、omit anchor)已落地 |
|
||||
| P3 | 基础版已落地:`onNodeStart` 判定、tag 白/黑名单、`no_save`、读档/暂停门控、**detour 返回 InProgress 去重**;`<<save>>` 显式 StateOnly 存档已落地 |
|
||||
| P4 | 基础版已落地:Provider sync/async 契约、Phase + Barrier 编排、读档自动存档抑制、Timeline Addressable 可等待恢复、验证窗口读档入口 |
|
||||
| P5 | 维修子模块 section 扩展(BlockPuzzle / Memory / Cutting 等待新增 Provider) |
|
||||
| P6 | 正式存 / 读档 UI、继续游戏、新游戏覆盖自动档、游戏中读档确认等玩家流程 |
|
||||
|
||||
@@ -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 判定是否允许自动存档:
|
||||
/// - 白名单 tag(hub / linear / content)默认允许;
|
||||
/// - interaction 或 save_on_exit 在 Dialogue 正常结束时保存 state-only 档;
|
||||
/// - 黑名单 tag(start / 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,164 @@ 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 string TalkSceneId { get; }
|
||||
public long DialogueRunId { get; }
|
||||
public long FlowRevision { get; }
|
||||
|
||||
public SaveRequest(
|
||||
SaveTrigger trigger,
|
||||
SaveResumeMode resumeMode,
|
||||
string sourceNodeName,
|
||||
string yarnProjectId,
|
||||
string talkSceneId,
|
||||
long dialogueRunId,
|
||||
long flowRevision)
|
||||
{
|
||||
Trigger = trigger;
|
||||
ResumeMode = resumeMode;
|
||||
SourceNodeName = sourceNodeName;
|
||||
YarnProjectId = yarnProjectId;
|
||||
TalkSceneId = talkSceneId;
|
||||
DialogueRunId = dialogueRunId;
|
||||
FlowRevision = flowRevision;
|
||||
}
|
||||
|
||||
public AnchorCaptureSpec AnchorSpec => new(
|
||||
TalkSceneId,
|
||||
YarnProjectId,
|
||||
SourceNodeName,
|
||||
ResumeMode == SaveResumeMode.RestartNode);
|
||||
|
||||
public static SaveRequest NodeEnter(
|
||||
string nodeName,
|
||||
string yarnProjectId,
|
||||
string talkSceneId,
|
||||
long dialogueRunId,
|
||||
long flowRevision)
|
||||
{
|
||||
return new SaveRequest(
|
||||
SaveTrigger.NodeEnter,
|
||||
SaveResumeMode.RestartNode,
|
||||
nodeName,
|
||||
yarnProjectId,
|
||||
talkSceneId,
|
||||
dialogueRunId,
|
||||
flowRevision);
|
||||
}
|
||||
|
||||
public static SaveRequest DialogueExit(
|
||||
string nodeName,
|
||||
string yarnProjectId,
|
||||
string talkSceneId,
|
||||
long dialogueRunId,
|
||||
long flowRevision)
|
||||
{
|
||||
return new SaveRequest(
|
||||
SaveTrigger.DialogueExit,
|
||||
SaveResumeMode.StateOnly,
|
||||
nodeName,
|
||||
yarnProjectId,
|
||||
talkSceneId,
|
||||
dialogueRunId,
|
||||
flowRevision);
|
||||
}
|
||||
|
||||
public static SaveRequest Explicit(
|
||||
string nodeName,
|
||||
string yarnProjectId,
|
||||
string talkSceneId,
|
||||
long dialogueRunId,
|
||||
long flowRevision)
|
||||
{
|
||||
return new SaveRequest(
|
||||
SaveTrigger.ExplicitCommand,
|
||||
SaveResumeMode.StateOnly,
|
||||
nodeName,
|
||||
yarnProjectId,
|
||||
talkSceneId,
|
||||
dialogueRunId,
|
||||
flowRevision);
|
||||
}
|
||||
}
|
||||
|
||||
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 +178,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()
|
||||
@@ -43,30 +212,74 @@ namespace AibisDream.SaveSystem
|
||||
return;
|
||||
}
|
||||
|
||||
var (triggerNodeName, _) = DialogController.Instance.GetCurrentNodeContext();
|
||||
DialogController.Instance.StartCoroutine(AutoSaveRoutine(triggerNodeName));
|
||||
var dialog = DialogController.Instance;
|
||||
var (triggerNodeName, _) = dialog.GetCurrentNodeContext();
|
||||
dialog.StartCoroutine(
|
||||
SaveRoutine(SaveRequest.NodeEnter(
|
||||
triggerNodeName,
|
||||
dialog.DialogueRunner?.YarnProject?.name,
|
||||
GetCurrentTalkSceneId(),
|
||||
dialog.DialogueRunId,
|
||||
dialog.DialogueFlowVersion)));
|
||||
}
|
||||
|
||||
/// <summary>自动存档协程:settle 一帧 → 主线程 Capture → 后台序列化写盘(不阻塞主线程)。</summary>
|
||||
/// <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)
|
||||
public static IEnumerator AutoSaveRoutine(
|
||||
string triggerNodeName,
|
||||
string yarnProjectId,
|
||||
string talkSceneId,
|
||||
long dialogueRunId,
|
||||
long flowRevision)
|
||||
{
|
||||
if (_isAutoSaving)
|
||||
{
|
||||
Debug.LogWarning("[SaveRestoreOrchestrator] 存档进行中,跳过重复请求。");
|
||||
yield break;
|
||||
}
|
||||
yield return SaveRoutine(SaveRequest.NodeEnter(
|
||||
triggerNodeName,
|
||||
yarnProjectId,
|
||||
talkSceneId,
|
||||
dialogueRunId,
|
||||
flowRevision));
|
||||
}
|
||||
|
||||
internal static IEnumerator DialogueExitSaveRoutine(
|
||||
string sourceNodeName,
|
||||
string yarnProjectId,
|
||||
string talkSceneId,
|
||||
long dialogueRunId,
|
||||
long flowRevision)
|
||||
{
|
||||
yield return SaveRoutine(SaveRequest.DialogueExit(
|
||||
sourceNodeName,
|
||||
yarnProjectId,
|
||||
talkSceneId,
|
||||
dialogueRunId,
|
||||
flowRevision));
|
||||
}
|
||||
|
||||
private static IEnumerator SaveRoutine(SaveRequest request)
|
||||
{
|
||||
yield return null;
|
||||
|
||||
if (_isAutoSaving)
|
||||
if (!ValidateSaveRequest(request, out var invalidReason))
|
||||
{
|
||||
Debug.LogWarning("[SaveRestoreOrchestrator] 存档进行中,跳过重复请求。");
|
||||
Debug.LogWarning(
|
||||
$"[SaveRestoreOrchestrator] 取消失效存档请求: trigger={request.Trigger}, " +
|
||||
$"node={FormatNodeName(request.SourceNodeName)}, project={request.YarnProjectId ?? "(none)"}, " +
|
||||
$"talkScene={request.TalkSceneId ?? "(none)"}, reason={invalidReason}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
_isAutoSaving = true;
|
||||
if (!SavePointEvaluator.CanExplicitSave(out var guardReason))
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[SaveRestoreOrchestrator] 取消存档请求: trigger={request.Trigger}, " +
|
||||
$"node={FormatNodeName(request.SourceNodeName)}, reason={guardReason}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
lock (SaveQueueGate)
|
||||
{
|
||||
_activeCaptureCount++;
|
||||
}
|
||||
|
||||
var infoPanel = UIManager.Instance.GetPanel<InfoPanel>();
|
||||
infoPanel?.ShowSaveLoading();
|
||||
|
||||
@@ -77,66 +290,213 @@ namespace AibisDream.SaveSystem
|
||||
{
|
||||
using (new CodeTimer("SaveSnapshot"))
|
||||
{
|
||||
snapshot = SnapshotService.Capture(triggerNodeName, omitAnchor);
|
||||
snapshot = SnapshotService.Capture(request.AnchorSpec);
|
||||
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());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[SaveRestoreOrchestrator] 创建测试存档旁路请求失败,不影响正式存档:{ex}");
|
||||
}
|
||||
#endif
|
||||
|
||||
_ = SlotManager.SaveToAutoSlotAsync(snapshot, thumbnail).ContinueWith(
|
||||
var sequence = EnqueueWrite(
|
||||
snapshot,
|
||||
thumbnail,
|
||||
request
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
, testSaveRequest
|
||||
#endif
|
||||
);
|
||||
|
||||
if (request.ResumeMode == SaveResumeMode.StateOnly)
|
||||
{
|
||||
Debug.Log(
|
||||
$"[SaveRestoreOrchestrator] 已捕获 state-only 自动档并加入写盘队列: " +
|
||||
$"sequence={sequence}, trigger={request.Trigger}, node={FormatNodeName(request.SourceNodeName)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yarn <c><<save>></c> 显式存档:保留来源节点,但恢复时不启动 Yarn。
|
||||
/// </summary>
|
||||
public static IEnumerator ExplicitSaveRoutine()
|
||||
{
|
||||
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,
|
||||
GetCurrentTalkSceneId(),
|
||||
dialog?.DialogueRunId ?? 0,
|
||||
dialog?.DialogueFlowVersion ?? 0));
|
||||
}
|
||||
|
||||
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] 自动存档写盘失败: {writeTask.Exception?.GetBaseException()}");
|
||||
$"[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 if (!writeTask.IsCanceled)
|
||||
else
|
||||
{
|
||||
TestSaveRecorder.Enqueue(testSaveRequest);
|
||||
}
|
||||
#endif
|
||||
|
||||
_isAutoSaving = false;
|
||||
},
|
||||
TaskContinuationOptions.ExecuteSynchronously);
|
||||
TaskScheduler.Default);
|
||||
|
||||
if (omitAnchor || (snapshot?.anchor != null && string.IsNullOrEmpty(snapshot.anchor.nodeName)))
|
||||
{
|
||||
Debug.Log("[SaveRestoreOrchestrator] 已保存无 Yarn 节点 anchor 的状态。");
|
||||
}
|
||||
return sequence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yarn <c><<save>></c> 显式存档:默认 omit anchor,调用方需已通过 <see cref="SavePointEvaluator.CanExplicitSave"/>。
|
||||
/// </summary>
|
||||
public static IEnumerator ExplicitSaveRoutine()
|
||||
private static bool ValidateSaveRequest(SaveRequest request, out string reason)
|
||||
{
|
||||
yield return AutoSaveRoutine(triggerNodeName: null, omitAnchor: true);
|
||||
if (!request.AnchorSpec.IsComplete)
|
||||
{
|
||||
reason = "anchor identity is incomplete";
|
||||
return false;
|
||||
}
|
||||
|
||||
var dialog = DialogController.Instance;
|
||||
var runner = dialog?.DialogueRunner;
|
||||
if (dialog == null || runner == null)
|
||||
{
|
||||
reason = "DialogController or DialogueRunner is unavailable";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dialog.DialogueRunId != request.DialogueRunId)
|
||||
{
|
||||
reason = $"dialogue run changed ({request.DialogueRunId} -> {dialog.DialogueRunId})";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dialog.DialogueFlowVersion != request.FlowRevision)
|
||||
{
|
||||
reason = $"flow revision changed ({request.FlowRevision} -> {dialog.DialogueFlowVersion})";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(runner.YarnProject?.name, request.YarnProjectId, StringComparison.Ordinal))
|
||||
{
|
||||
reason = $"YarnProject changed to {runner.YarnProject?.name ?? "(none)"}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var currentTalkSceneId = GetCurrentTalkSceneId();
|
||||
if (!string.Equals(currentTalkSceneId, request.TalkSceneId, StringComparison.Ordinal))
|
||||
{
|
||||
reason = $"TalkScene changed to {currentTalkSceneId ?? "(none)"}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SceneLoader.Instance == null || SceneLoader.Instance.IsLoading)
|
||||
{
|
||||
reason = "scene is unavailable or loading";
|
||||
return false;
|
||||
}
|
||||
|
||||
var (currentNodeName, _) = dialog.GetCurrentNodeContext();
|
||||
if (request.Trigger == SaveTrigger.DialogueExit)
|
||||
{
|
||||
if (runner.IsDialogueRunning)
|
||||
{
|
||||
reason = "DialogueRunner is still running";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(currentNodeName))
|
||||
{
|
||||
reason = $"a current Yarn node is still present: {currentNodeName}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!runner.IsDialogueRunning)
|
||||
{
|
||||
reason = "DialogueRunner stopped before capture";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(currentNodeName, request.SourceNodeName, StringComparison.Ordinal))
|
||||
{
|
||||
reason = $"current node changed to {currentNodeName ?? "(none)"}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string GetCurrentTalkSceneId()
|
||||
{
|
||||
return GameManager.Instance != null
|
||||
? GameManager.Session.CurrentTalkScene?.name
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string FormatNodeName(string nodeName)
|
||||
{
|
||||
return string.IsNullOrEmpty(nodeName) ? "(无节点)" : nodeName;
|
||||
}
|
||||
|
||||
/// <summary>将当前自动档复制到指定手动档。</summary>
|
||||
@@ -321,6 +681,8 @@ namespace AibisDream.SaveSystem
|
||||
errors.Add("Snapshot TalkSceneSO is missing.");
|
||||
if (string.IsNullOrWhiteSpace(snapshot.anchor?.yarnProjectId))
|
||||
errors.Add("Snapshot YarnProject is missing.");
|
||||
if (string.IsNullOrWhiteSpace(snapshot.anchor?.nodeName))
|
||||
errors.Add("Snapshot Yarn node is missing.");
|
||||
}
|
||||
|
||||
TalkSceneSO targetScene = null;
|
||||
@@ -341,7 +703,7 @@ namespace AibisDream.SaveSystem
|
||||
}
|
||||
|
||||
if (errors.Count == 0
|
||||
&& !string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)
|
||||
&& snapshot.anchor.startDialogueOnRestore
|
||||
&& !Array.Exists(
|
||||
targetScene.yarnProject.NodeNames,
|
||||
node => string.Equals(node, snapshot.anchor.nodeName, StringComparison.Ordinal)))
|
||||
@@ -413,8 +775,23 @@ namespace AibisDream.SaveSystem
|
||||
var runner = DialogController.Instance?.DialogueRunner;
|
||||
if (!string.Equals(runner?.YarnProject?.name, snapshot.anchor.yarnProjectId, StringComparison.Ordinal))
|
||||
context.Error($"YarnProject mismatch: {runner?.YarnProject?.name ?? "none"}.");
|
||||
if (!string.IsNullOrEmpty(snapshot.anchor.nodeName) && runner != null && !runner.IsDialogueRunning)
|
||||
context.Error($"Yarn node did not start: {snapshot.anchor.nodeName}.");
|
||||
if (snapshot.anchor.startDialogueOnRestore)
|
||||
{
|
||||
var (currentNodeName, _) = DialogController.Instance != null
|
||||
? DialogController.Instance.GetCurrentNodeContext()
|
||||
: (null, null);
|
||||
if (runner == null || !runner.IsDialogueRunning)
|
||||
context.Error($"Yarn node did not start: {snapshot.anchor.nodeName}.");
|
||||
else if (!string.Equals(currentNodeName, snapshot.anchor.nodeName, StringComparison.Ordinal))
|
||||
context.Error(
|
||||
$"Yarn node mismatch: expected {snapshot.anchor.nodeName}, " +
|
||||
$"actual {currentNodeName ?? "none"}.");
|
||||
}
|
||||
else if (runner != null && runner.IsDialogueRunning)
|
||||
{
|
||||
context.Error(
|
||||
$"StateOnly restore unexpectedly started Yarn node {snapshot.anchor.nodeName}.");
|
||||
}
|
||||
|
||||
if (snapshot.sections != null
|
||||
&& snapshot.sections.ContainsKey(SnapshotProviderIds.Fix)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AibisDream.FixSystem;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AibisDream.SaveSystem
|
||||
{
|
||||
@@ -38,7 +39,7 @@ namespace AibisDream.SaveSystem
|
||||
/// <summary>当前场景(恢复的第一步)。</summary>
|
||||
public SceneSnapshotDto scene = new();
|
||||
|
||||
/// <summary>恢复锚点:章节、YarnProject 与节点,读档最后阶段重新进入。</summary>
|
||||
/// <summary>恢复锚点:章节、YarnProject、来源节点与显式恢复动作。</summary>
|
||||
public AnchorSnapshot anchor = new();
|
||||
|
||||
/// <summary>全部 Yarn 运行时变量(来自 <see cref="YarnVariableStorage"/>)。</summary>
|
||||
@@ -52,7 +53,8 @@ namespace AibisDream.SaveSystem
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 恢复锚点:包含宏观章节信息,读档最后阶段加载对话工程并重新 <c>StartDialogue</c>。
|
||||
/// 恢复锚点:节点名始终记录存档来源;是否重新进入节点由
|
||||
/// <see cref="startDialogueOnRestore"/> 独立决定。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class AnchorSnapshot
|
||||
@@ -61,6 +63,9 @@ namespace AibisDream.SaveSystem
|
||||
public string sceneSoName;
|
||||
public string yarnProjectId;
|
||||
public string nodeName;
|
||||
|
||||
[JsonProperty(Required = Required.Always)]
|
||||
public bool startDialogueOnRestore;
|
||||
}
|
||||
|
||||
/// <summary>Yarn 变量三分组,与 Yarn Spinner 支持的类型一致。</summary>
|
||||
|
||||
@@ -5,7 +5,7 @@ using Yarn.Unity;
|
||||
namespace AibisDream.SaveSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Yarn 显式存档命令。在节点末尾、状态命令执行完毕且即将进入无对话阶段时使用。
|
||||
/// Yarn 显式存档兼容命令。新内容应使用 interaction 一级类型或 save_on_exit 附加标记。
|
||||
/// </summary>
|
||||
public static class SaveYarnCommand
|
||||
{
|
||||
|
||||
@@ -255,6 +255,7 @@ namespace AibisDream.SaveSystem
|
||||
sceneSoName = summary.SceneSoName,
|
||||
yarnProjectId = summary.YarnProjectId,
|
||||
nodeName = snapshot.anchor?.nodeName,
|
||||
startDialogueOnRestore = snapshot.anchor?.startDialogueOnRestore ?? false,
|
||||
schemaVersion = snapshot.schemaVersion,
|
||||
gameVersion = snapshot.gameVersion,
|
||||
thumbnailFile = $"{ConstRef.SaveThumbnailFileName}.png"
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace AibisDream.SaveSystem
|
||||
public string sceneSoName;
|
||||
public string yarnProjectId;
|
||||
public string nodeName;
|
||||
public bool startDialogueOnRestore;
|
||||
public int schemaVersion;
|
||||
public string gameVersion;
|
||||
public string thumbnailFile;
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace AibisDream.SaveSystem
|
||||
{
|
||||
public readonly struct AnchorCaptureSpec
|
||||
{
|
||||
public string SceneSoName { get; }
|
||||
public string YarnProjectId { get; }
|
||||
public string NodeName { get; }
|
||||
public bool StartDialogueOnRestore { get; }
|
||||
|
||||
public AnchorCaptureSpec(
|
||||
string sceneSoName,
|
||||
string yarnProjectId,
|
||||
string nodeName,
|
||||
bool startDialogueOnRestore)
|
||||
{
|
||||
SceneSoName = sceneSoName;
|
||||
YarnProjectId = yarnProjectId;
|
||||
NodeName = nodeName;
|
||||
StartDialogueOnRestore = startDialogueOnRestore;
|
||||
}
|
||||
|
||||
public bool IsComplete =>
|
||||
!string.IsNullOrWhiteSpace(SceneSoName)
|
||||
&& !string.IsNullOrWhiteSpace(YarnProjectId)
|
||||
&& !string.IsNullOrWhiteSpace(NodeName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从运行时组装 <see cref="SaveSnapshot"/>(纯内存,不写盘)。
|
||||
/// <para>
|
||||
@@ -14,18 +38,17 @@ namespace AibisDream.SaveSystem
|
||||
public static class SnapshotCapture
|
||||
{
|
||||
/// <summary>捕获当前完整快照。调用前需已注册全部 provider。</summary>
|
||||
/// <param name="triggerNodeName">
|
||||
/// 触发存档时的 Yarn 节点名(通常为 <c>OnNodeStart</c> 传入值)。
|
||||
/// 非 null 时作为 anchor,并与 Capture 时刻的当前节点比较;不一致时打 warning,不阻断写盘。
|
||||
/// </param>
|
||||
/// <param name="omitAnchor">
|
||||
/// 为 true 时写入空 <see cref="AnchorSnapshot.nodeName"/>(显式 <c><<save>></c> 默认行为)。
|
||||
/// </param>
|
||||
public static SaveSnapshot Capture(
|
||||
YarnVariableStorage storage,
|
||||
string triggerNodeName = null,
|
||||
bool omitAnchor = false)
|
||||
AnchorCaptureSpec anchorSpec)
|
||||
{
|
||||
if (!anchorSpec.IsComplete)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Snapshot anchor requires TalkScene, YarnProject and node name.",
|
||||
nameof(anchorSpec));
|
||||
}
|
||||
|
||||
SnapshotRegistry.ValidateRequiredProviders();
|
||||
|
||||
var snapshot = new SaveSnapshot
|
||||
@@ -35,7 +58,7 @@ namespace AibisDream.SaveSystem
|
||||
};
|
||||
|
||||
CaptureScene(snapshot);
|
||||
CaptureAnchor(snapshot, triggerNodeName, omitAnchor);
|
||||
CaptureAnchor(snapshot, anchorSpec);
|
||||
CaptureYarnVariables(storage, snapshot);
|
||||
|
||||
foreach (var provider in SnapshotRegistry.GetOrderedProviders())
|
||||
@@ -58,58 +81,17 @@ namespace AibisDream.SaveSystem
|
||||
};
|
||||
}
|
||||
|
||||
private static void CaptureAnchor(SaveSnapshot snapshot, string triggerNodeName, bool omitAnchor)
|
||||
private static void CaptureAnchor(SaveSnapshot snapshot, AnchorCaptureSpec anchorSpec)
|
||||
{
|
||||
var dialog = DialogController.Instance;
|
||||
if (dialog == null) return;
|
||||
|
||||
string anchorNodeName;
|
||||
if (omitAnchor)
|
||||
{
|
||||
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;
|
||||
var projectId = yarnProject != null ? yarnProject.name : string.Empty;
|
||||
|
||||
var sceneSoName = GameManager.Instance != null
|
||||
? GameManager.Session.CurrentTalkScene?.name
|
||||
: null;
|
||||
|
||||
snapshot.anchor = new AnchorSnapshot
|
||||
{
|
||||
sceneSoName = sceneSoName,
|
||||
yarnProjectId = projectId,
|
||||
nodeName = anchorNodeName
|
||||
sceneSoName = anchorSpec.SceneSoName,
|
||||
yarnProjectId = anchorSpec.YarnProjectId,
|
||||
nodeName = anchorSpec.NodeName,
|
||||
startDialogueOnRestore = anchorSpec.StartDialogueOnRestore
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace AibisDream.SaveSystem
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 3:按 <see cref="AnchorSnapshot"/> 加载对话工程;若有节点名则重新进入 Yarn 节点。
|
||||
/// Phase 3:按 <see cref="AnchorSnapshot"/> 加载对话工程,并按显式恢复标记决定是否启动节点。
|
||||
/// </summary>
|
||||
public static IEnumerator RestoreAnchor(SaveSnapshot snapshot, SnapshotRestoreContext context = null)
|
||||
{
|
||||
@@ -52,10 +52,10 @@ namespace AibisDream.SaveSystem
|
||||
|
||||
context ??= new SnapshotRestoreContext(snapshot);
|
||||
|
||||
var hasNode = !string.IsNullOrEmpty(snapshot.anchor.nodeName);
|
||||
context.SetPhase(hasNode
|
||||
var shouldStartDialogue = snapshot.anchor.startDialogueOnRestore;
|
||||
context.SetPhase(shouldStartDialogue
|
||||
? $"Phase 3: Restore anchor {snapshot.anchor.nodeName}"
|
||||
: "Phase 3: No anchor node, restore YarnProject only");
|
||||
: $"Phase 3: Restore YarnProject only (source {snapshot.anchor.nodeName})");
|
||||
|
||||
var dialog = DialogController.Instance;
|
||||
if (dialog == null)
|
||||
@@ -93,18 +93,28 @@ namespace AibisDream.SaveSystem
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasNode)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (runner.IsDialogueRunning)
|
||||
{
|
||||
yield return runner.Stop();
|
||||
}
|
||||
|
||||
if (runner.YarnProject == null
|
||||
|| !System.Array.Exists(runner.YarnProject.NodeNames, node => node == snapshot.anchor.nodeName))
|
||||
var nodeExists = runner.YarnProject != null
|
||||
&& System.Array.Exists(
|
||||
runner.YarnProject.NodeNames,
|
||||
node => node == snapshot.anchor.nodeName);
|
||||
if (!shouldStartDialogue)
|
||||
{
|
||||
if (!nodeExists)
|
||||
{
|
||||
context.Warn(
|
||||
$"StateOnly 来源节点 {snapshot.anchor.nodeName} 已不在 " +
|
||||
$"YarnProject {runner.YarnProject?.name ?? "none"} 中;状态恢复继续。");
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!nodeExists)
|
||||
{
|
||||
context.Error(
|
||||
$"YarnProject {runner.YarnProject?.name ?? "none"} 不包含节点 {snapshot.anchor.nodeName}。");
|
||||
|
||||
@@ -7,12 +7,10 @@ namespace AibisDream.SaveSystem
|
||||
public static class SnapshotService
|
||||
{
|
||||
/// <summary>捕获当前运行时快照。</summary>
|
||||
/// <param name="triggerNodeName">见 <see cref="SnapshotCapture.Capture"/>。</param>
|
||||
/// <param name="omitAnchor">见 <see cref="SnapshotCapture.Capture"/>。</param>
|
||||
public static SaveSnapshot Capture(string triggerNodeName = null, bool omitAnchor = false)
|
||||
public static SaveSnapshot Capture(AnchorCaptureSpec anchorSpec)
|
||||
{
|
||||
SnapshotRegistry.EnsureInitialized();
|
||||
return SnapshotCapture.Capture(YarnVariableStorage.Instance, triggerNodeName, omitAnchor);
|
||||
return SnapshotCapture.Capture(YarnVariableStorage.Instance, anchorSpec);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user