feat(save): 为存档锚点增加显式恢复标记并加固退出档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-26 23:37:47 +08:00
co-authored by Cursor
parent a177fc8b16
commit a9bc442bc8
19 changed files with 488 additions and 192 deletions
@@ -5,9 +5,11 @@ using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AibisDream.Framework;
using AibisDream.SaveSystem;
using Newtonsoft.Json;
using NUnit.Framework;
using UnityEngine;
namespace AibisDream.DeveloperMode.Editor.Tests
{
@@ -202,12 +204,13 @@ namespace AibisDream.DeveloperMode.Editor.Tests
}
[Test]
public void Repository_StateOnlySnapshot_UsesSourceNodeMetadata()
public void Repository_StateOnlySnapshot_PreservesAnchorNode()
{
var request = CreateRequest("ChapterA", "ProjectA", "InteractionNode", "SceneA");
request.Snapshot.anchor.nodeName = string.Empty;
request.Snapshot.anchor.startDialogueOnRestore = false;
request.SaveTrigger = "DialogueExit";
request.ResumeMode = nameof(SaveResumeMode.StateOnly);
request.StartDialogueOnRestore = false;
request.DedupeKey = TestSaveRecorder.BuildDedupeKey(
request.SceneSoName,
request.YarnProjectId,
@@ -221,18 +224,19 @@ namespace AibisDream.DeveloperMode.Editor.Tests
Assert.That(loaded, Is.True, error);
Assert.That(entry.NodeName, Is.EqualTo("InteractionNode"));
Assert.That(entry.ResumeMode, Is.EqualTo(nameof(SaveResumeMode.StateOnly)));
Assert.That(snapshot.anchor.nodeName, Is.Empty);
Assert.That(snapshot.anchor.nodeName, Is.EqualTo("InteractionNode"));
Assert.That(snapshot.anchor.startDialogueOnRestore, Is.False);
}
[Test]
public void Repository_LegacyMetadata_DefaultsToRestartNode()
public void SnapshotPersistence_MissingRestoreFlag_IsRejected()
{
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
entry.Meta.saveTrigger = null;
entry.Meta.resumeMode = null;
const string json =
"{\"schemaVersion\":2,\"anchor\":{\"sceneSoName\":\"ChapterA\"," +
"\"yarnProjectId\":\"ProjectA\",\"nodeName\":\"NodeA\"}}";
Assert.That(entry.SaveTrigger, Is.EqualTo("NodeEnter"));
Assert.That(entry.ResumeMode, Is.EqualTo(nameof(SaveResumeMode.RestartNode)));
Assert.Throws<Newtonsoft.Json.JsonSerializationException>(
() => SnapshotPersistence.Deserialize(json));
}
[Test]
@@ -271,6 +275,40 @@ namespace AibisDream.DeveloperMode.Editor.Tests
Assert.That(order, Is.EqualTo(new[] { 1, 2, 3 }));
}
[Test]
public void SaveRequest_MapsResumeModeToExplicitAnchorFlag()
{
var enter = SaveRequest.NodeEnter("NodeA", "ProjectA", "ChapterA", 3, 7);
var exit = SaveRequest.DialogueExit("NodeB", "ProjectA", "ChapterA", 3, 9);
Assert.That(enter.AnchorSpec.NodeName, Is.EqualTo("NodeA"));
Assert.That(enter.AnchorSpec.StartDialogueOnRestore, Is.True);
Assert.That(exit.AnchorSpec.NodeName, Is.EqualTo("NodeB"));
Assert.That(exit.AnchorSpec.StartDialogueOnRestore, Is.False);
}
[Test]
public void InteractionLock_ComposesWithDialogueLock()
{
var gameObject = new GameObject("EventSystemEx-Test");
try
{
var eventSystem = gameObject.AddComponent<EventSystemEx>();
EnumEventSystem.Global.Send(InteractionEventEnum.DialogStart);
var checkpointLock = eventSystem.AcquireInteractionLock("test");
EnumEventSystem.Global.Send(InteractionEventEnum.DialogEnd);
Assert.That(eventSystem.isLocked, Is.True);
checkpointLock.Dispose();
Assert.That(eventSystem.isLocked, Is.False);
}
finally
{
UnityEngine.Object.DestroyImmediate(gameObject);
}
}
private static TestSaveRecordRequest CreateRequest(
string sceneSoName,
string yarnProject,
@@ -289,7 +327,8 @@ namespace AibisDream.DeveloperMode.Editor.Tests
{
sceneSoName = sceneSoName,
yarnProjectId = yarnProject,
nodeName = nodeName
nodeName = nodeName,
startDialogueOnRestore = true
}
},
DedupeKey = key,
@@ -301,6 +340,7 @@ namespace AibisDream.DeveloperMode.Editor.Tests
NodeName = nodeName,
SaveTrigger = "NodeEnter",
ResumeMode = nameof(SaveResumeMode.RestartNode),
StartDialogueOnRestore = true,
SceneName = sceneName,
GameVersion = "1.0"
};
@@ -319,6 +359,7 @@ namespace AibisDream.DeveloperMode.Editor.Tests
nodeName = request.NodeName,
saveTrigger = request.SaveTrigger,
resumeMode = request.ResumeMode,
startDialogueOnRestore = request.StartDialogueOnRestore,
sceneName = request.SceneName,
firstSeenOrder = order,
firstRecordedAt = "2026-01-01 00:00:00",
@@ -44,7 +44,7 @@ namespace AibisDream.SystemEditor.Tests
}
[Test]
public void CaptureEntry_ReusesActorSnapshotFieldsWithoutSchemaChange()
public void CaptureEntry_ReusesActorSnapshotFields()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
var actorObject = Object.Instantiate(prefab);
@@ -66,7 +66,6 @@ namespace AibisDream.SystemEditor.Tests
Assert.That(entry.actorType, Is.EqualTo(nameof(ActorType.FrameAnimation)));
Assert.That(entry.alpha, Is.EqualTo(1f));
Assert.That(entry.stateName, Is.Empty);
Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(1));
}
finally
{
@@ -44,7 +44,7 @@ namespace AibisDream.SystemEditor.Tests
Assert.That(playTool.objPicName, Is.EqualTo("证物"));
Assert.That(playTool.isFullScreenVisible, Is.True);
Assert.That(playTool.fullScreenPicName, Is.EqualTo("教室"));
Assert.That(restored.schemaVersion, Is.EqualTo(1));
Assert.That(restored.schemaVersion, Is.EqualTo(SaveSnapshotSchema.CurrentVersion));
}
[Test]
@@ -84,12 +84,12 @@ namespace AibisDream.SystemEditor.Tests
}
[Test]
public void OldSnapshotWithoutPresentationSections_RemainsCompatible()
public void OldSnapshotWithoutPresentationSections_DeserializesWithoutSynthesizingSections()
{
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(
"{\"schemaVersion\":1,\"sections\":{}}");
Assert.That(restored.schemaVersion, Is.EqualTo(SaveSnapshotSchema.CurrentVersion));
Assert.That(restored.schemaVersion, Is.EqualTo(1));
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Showcase), Is.False);
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Day2SleepPresentation), Is.False);
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.PlayTool), Is.False);
@@ -24,6 +24,11 @@ namespace AibisDream
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()
{
@@ -33,11 +38,7 @@ 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(OnDialogueComplete);
@@ -199,6 +200,7 @@ namespace AibisDream
UpdateCurrentNodeContext(nodeName);
var projectId = _dialogueRunner?.YarnProject?.name;
var talkSceneId = GetCurrentTalkSceneId();
var tags = _currentNodeTags ?? Array.Empty<string>();
if (SaveRestoreOrchestrator.IsRestoring)
@@ -227,15 +229,22 @@ namespace AibisDream
_pendingExitCheckpoint = new PendingExitCheckpoint
{
ProjectId = projectId,
TalkSceneId = talkSceneId,
NodeName = nodeName,
FlowVersion = _dialogueFlowVersion
FlowVersion = _dialogueFlowVersion,
DialogueRunId = _dialogueRunId
};
return;
}
if (policy.Timing == NodeSaveTiming.NodeEnter)
{
StartCoroutine(SaveRestoreOrchestrator.AutoSaveRoutine(nodeName));
StartCoroutine(SaveRestoreOrchestrator.AutoSaveRoutine(
nodeName,
projectId,
talkSceneId,
_dialogueRunId,
_dialogueFlowVersion));
}
}
@@ -248,7 +257,8 @@ namespace AibisDream
&& string.Equals(
_pendingExitCheckpoint.ProjectId,
_dialogueRunner?.YarnProject?.name,
StringComparison.Ordinal))
StringComparison.Ordinal)
&& _pendingExitCheckpoint.DialogueRunId == _dialogueRunId)
{
_pendingExitCheckpoint.IsNodeComplete = true;
}
@@ -258,23 +268,49 @@ namespace AibisDream
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();
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)));
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(
@@ -287,6 +323,8 @@ namespace AibisDream
_dialogueFlowVersion++;
}
ReleaseExitHandoffLock();
if (_pendingExitCheckpoint == null)
{
return;
@@ -324,11 +362,26 @@ 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;
}
@@ -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
@@ -34,26 +34,26 @@ namespace AibisDream.SaveSystem
SaveSnapshot snapshot,
byte[] thumbnail,
string saveTrigger,
string resumeMode,
string sourceNodeName)
string resumeMode)
{
if (!IsRecording
|| snapshot?.anchor == null
|| string.IsNullOrWhiteSpace(snapshot.anchor.sceneSoName)
|| string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId))
|| string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId)
|| string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))
{
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))
var expectedResumeMode = anchor.startDialogueOnRestore
? nameof(SaveResumeMode.RestartNode)
: nameof(SaveResumeMode.StateOnly);
if (!string.Equals(resumeMode, expectedResumeMode, StringComparison.Ordinal))
{
return null;
throw new InvalidOperationException(
$"测试存档恢复模式与 snapshot anchor 不一致: meta={resumeMode}, " +
$"anchor={expectedResumeMode}");
}
var chapter = GameManager.Instance?.RuntimeChapters?
@@ -64,7 +64,7 @@ namespace AibisDream.SaveSystem
var dedupeKey = BuildDedupeKey(
anchor.sceneSoName,
anchor.yarnProjectId,
recordNodeName,
anchor.nodeName,
resumeMode);
return new TestSaveRecordRequest
{
@@ -76,9 +76,10 @@ namespace AibisDream.SaveSystem
ChapterTitle = string.IsNullOrWhiteSpace(chapter?.title) ? anchor.sceneSoName : chapter.title,
SceneSoName = anchor.sceneSoName,
YarnProjectId = anchor.yarnProjectId,
NodeName = recordNodeName,
NodeName = anchor.nodeName,
SaveTrigger = saveTrigger,
ResumeMode = resumeMode,
StartDialogueOnRestore = anchor.startDialogueOnRestore,
SceneName = snapshot.scene?.sceneName,
GameVersion = snapshot.gameVersion
};
@@ -139,6 +139,7 @@ namespace AibisDream.SaveSystem
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,
@@ -217,11 +218,10 @@ 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)
|| (!stateOnly && string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)))
|| string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))
{
error = "快照缺少完整 Yarn anchor。";
snapshot = null;
@@ -232,12 +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)
|| (!stateOnly
&& !string.Equals(
snapshot.anchor.nodeName,
entry.Meta.nodeName,
StringComparison.Ordinal))
|| (stateOnly && !string.IsNullOrEmpty(snapshot.anchor.nodeName))))
|| !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;
@@ -367,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;
@@ -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;
@@ -30,6 +30,7 @@ namespace AibisDream.SaveSystem
public string nodeName;
public string saveTrigger;
public string resumeMode;
public bool startDialogueOnRestore;
public string sceneName;
public long firstSeenOrder;
public string firstRecordedAt;
@@ -54,12 +55,8 @@ 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 string SaveTrigger => Meta?.saveTrigger;
public string ResumeMode => Meta?.resumeMode;
}
public sealed class TestSaveScanResult
@@ -82,6 +79,7 @@ namespace AibisDream.SaveSystem
public string NodeName;
public string SaveTrigger;
public string ResumeMode;
public bool StartDialogueOnRestore;
public string SceneName;
public string GameVersion;
}
+4 -3
View File
@@ -18,7 +18,7 @@
// 自动存档(节点进入事件触发)
yield return SaveRestoreOrchestrator.AutoSaveRoutine(nodeName);
// Yarn 显式存档(<<save>>默认 omit anchor
// Yarn 显式存档(<<save>>保留来源节点但恢复时不启动 Yarn
yield return SaveRestoreOrchestrator.ExplicitSaveRoutine();
// 手动存档:复制最近落盘档(含 OnNodeStart 与 <<save>>
@@ -50,7 +50,8 @@ tags: content save_on_exit
- `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` 仍靠节点进入自动存;`interaction` / `save_on_exit` 用于即将进入无对话 / Fix 交互等边界。
@@ -219,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、继续游戏、新游戏覆盖自动档、游戏中读档确认等玩家流程 |
@@ -21,44 +21,83 @@ namespace AibisDream.SaveSystem
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 yarnProjectId,
string talkSceneId,
long dialogueRunId,
long flowRevision)
{
Trigger = trigger;
ResumeMode = resumeMode;
SourceNodeName = sourceNodeName;
YarnProjectId = yarnProjectId;
TalkSceneId = talkSceneId;
DialogueRunId = dialogueRunId;
FlowRevision = flowRevision;
}
public static SaveRequest NodeEnter(string nodeName, string yarnProjectId)
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);
yarnProjectId,
talkSceneId,
dialogueRunId,
flowRevision);
}
public static SaveRequest DialogueExit(string nodeName, string yarnProjectId)
public static SaveRequest DialogueExit(
string nodeName,
string yarnProjectId,
string talkSceneId,
long dialogueRunId,
long flowRevision)
{
return new SaveRequest(
SaveTrigger.DialogueExit,
SaveResumeMode.StateOnly,
nodeName,
yarnProjectId);
yarnProjectId,
talkSceneId,
dialogueRunId,
flowRevision);
}
public static SaveRequest Explicit(string nodeName, string yarnProjectId)
public static SaveRequest Explicit(
string nodeName,
string yarnProjectId,
string talkSceneId,
long dialogueRunId,
long flowRevision)
{
return new SaveRequest(
SaveTrigger.ExplicitCommand,
SaveResumeMode.StateOnly,
nodeName,
yarnProjectId);
yarnProjectId,
talkSceneId,
dialogueRunId,
flowRevision);
}
}
@@ -173,43 +212,58 @@ namespace AibisDream.SaveSystem
return;
}
var (triggerNodeName, _) = DialogController.Instance.GetCurrentNodeContext();
var projectId = DialogController.Instance.DialogueRunner?.YarnProject?.name;
DialogController.Instance.StartCoroutine(
SaveRoutine(SaveRequest.NodeEnter(triggerNodeName, projectId)));
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)
{
var projectId = DialogController.Instance?.DialogueRunner?.YarnProject?.name;
var request = omitAnchor
? SaveRequest.Explicit(triggerNodeName, projectId)
: SaveRequest.NodeEnter(triggerNodeName, projectId);
yield return SaveRoutine(request);
yield return SaveRoutine(SaveRequest.NodeEnter(
triggerNodeName,
yarnProjectId,
talkSceneId,
dialogueRunId,
flowRevision));
}
internal static IEnumerator DialogueExitSaveRoutine(
string sourceNodeName,
string yarnProjectId,
Func<bool> isRequestStillValid)
string talkSceneId,
long dialogueRunId,
long flowRevision)
{
yield return SaveRoutine(
SaveRequest.DialogueExit(sourceNodeName, yarnProjectId),
isRequestStillValid);
yield return SaveRoutine(SaveRequest.DialogueExit(
sourceNodeName,
yarnProjectId,
talkSceneId,
dialogueRunId,
flowRevision));
}
private static IEnumerator SaveRoutine(SaveRequest request, Func<bool> isRequestStillValid = null)
private static IEnumerator SaveRoutine(SaveRequest request)
{
yield return null;
if (isRequestStillValid?.Invoke() == false)
if (!ValidateSaveRequest(request, out var invalidReason))
{
Debug.LogWarning(
$"[SaveRestoreOrchestrator] interaction 退出后又启动了 Yarn 节点,取消 state-only 存档: " +
$"node={FormatNodeName(request.SourceNodeName)}");
$"[SaveRestoreOrchestrator] 取消失效存档请求: trigger={request.Trigger}, " +
$"node={FormatNodeName(request.SourceNodeName)}, project={request.YarnProjectId ?? "(none)"}, " +
$"talkScene={request.TalkSceneId ?? "(none)"}, reason={invalidReason}");
yield break;
}
@@ -221,15 +275,6 @@ namespace AibisDream.SaveSystem
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++;
@@ -245,11 +290,7 @@ namespace AibisDream.SaveSystem
{
using (new CodeTimer("SaveSnapshot"))
{
snapshot = SnapshotService.Capture(
request.ResumeMode == SaveResumeMode.RestartNode
? request.SourceNodeName
: null,
request.ResumeMode == SaveResumeMode.StateOnly);
snapshot = SnapshotService.Capture(request.AnchorSpec);
thumbnail = SlotThumbnailCapture.CapturePng();
}
}
@@ -280,8 +321,7 @@ namespace AibisDream.SaveSystem
snapshot,
thumbnail,
request.Trigger.ToString(),
request.ResumeMode.ToString(),
request.SourceNodeName);
request.ResumeMode.ToString());
}
catch (Exception ex)
{
@@ -307,7 +347,7 @@ namespace AibisDream.SaveSystem
}
/// <summary>
/// Yarn <c>&lt;&lt;save&gt;&gt;</c> 显式存档:默认 omit anchor,调用方需已通过 <see cref="SavePointEvaluator.CanExplicitSave"/>
/// Yarn <c>&lt;&lt;save&gt;&gt;</c> 显式存档:保留来源节点,但恢复时不启动 Yarn
/// </summary>
public static IEnumerator ExplicitSaveRoutine()
{
@@ -316,7 +356,12 @@ namespace AibisDream.SaveSystem
? dialog.GetCurrentNodeContext()
: (null, null);
var projectId = dialog?.DialogueRunner?.YarnProject?.name;
yield return SaveRoutine(SaveRequest.Explicit(nodeName, projectId));
yield return SaveRoutine(SaveRequest.Explicit(
nodeName,
projectId,
GetCurrentTalkSceneId(),
dialog?.DialogueRunId ?? 0,
dialog?.DialogueFlowVersion ?? 0));
}
private static long EnqueueWrite(
@@ -361,15 +406,92 @@ namespace AibisDream.SaveSystem
return sequence;
}
private static bool IsYarnProjectStillCurrent(string expectedProjectId)
private static bool ValidateSaveRequest(SaveRequest request, out string reason)
{
if (string.IsNullOrEmpty(expectedProjectId))
if (!request.AnchorSpec.IsComplete)
{
return true;
reason = "anchor identity is incomplete";
return false;
}
var currentProjectId = DialogController.Instance?.DialogueRunner?.YarnProject?.name;
return string.Equals(currentProjectId, expectedProjectId, StringComparison.Ordinal);
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)
@@ -559,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;
@@ -579,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)))
@@ -651,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)
+7 -2
View File
@@ -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>
+1
View File
@@ -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"
+1
View File
@@ -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;
+39 -57
View File
@@ -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>&lt;&lt;save&gt;&gt;</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();
+21 -11
View File
@@ -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}。");
+2 -4
View File
@@ -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);
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ ExecutePreparedRestore
→ Postflight
```
说明:显式 `<<save>>` 可以生成无节点 anchor 的新格式快照。这类快照仍预检章节和 YarnProject,但跳过节点存在性校验,并在恢复时只加载 YarnProject、不重进节点。
说明:显式 `<<save>>` 保留当前来源节点,并写入 `startDialogueOnRestore=false`。这类快照仍预检章节和 YarnProject;来源节点存在时只告警,恢复时只加载 YarnProject、不启动节点。
GameManager 管理命令锁、Session Phase、遮罩、旧会话表现清理、成功提交和失败决策。Orchestrator 不再查找或设置 GameManager 的当前章节。
+3 -1
View File
@@ -215,6 +215,8 @@ dn: 请使用检查设备继续操作。
- `interaction` 是一级类型,不与 `content` 等其他一级类型并列书写。
- 正常交互入口路径应直接结束 Dialogue;读档时只恢复状态,不重新运行该节点。
- 退出档仍会在 `anchor.nodeName` 中保留该节点名,并通过 `startDialogueOnRestore=false` 表示只加载 YarnProject。
- `DialogEnd` 初始化完成到下一帧快照捕获之间保持玩法输入锁定;快照进入写盘队列后才交出控制权。
- 允许某些条件分支 `<<jump>>` / `<<detour>>` 到其他 Yarn 节点;这些路径会取消退出档并记录告警。
- 节点结束前必须完成所有需要写入快照的状态命令。未等待的 fire-and-forget 命令不可作为可靠边界。
- 后续玩法必须能够完全通过 Snapshot Provider 恢复,包括输入、交互监听和玩法模式,而不只是视觉状态。
@@ -431,7 +433,7 @@ Center ◀─────────────────────┘
约定:
- **插入位置**:所有需要进快照的状态命令之后;`<<jump>>` / `<<detour>>` / `load_scene` **之前**(顺序即快照内容)。
- **anchor**默认 omit(读档不重进 Yarn;依赖 sections 还原画面与玩法状态。
- **anchor**保留来源节点,并写入 `startDialogueOnRestore=false`;依赖 sections 还原画面与玩法状态。
- **判定**:绕过 tag / `no_save`;读档中、暂停、SuppressAutoSave 仍拒绝。
- **async 命令**`change_actor_state_async` 等未等待的命令之后立刻 `<<save>>` 可能 capture 未完成态,save 前应 `<<wait>>` 或使用同步命令。
+2
View File
@@ -232,6 +232,8 @@ P7 横切
- `DialogController.OnNodeStart` 更新当前 node/tags 后解析 `NodeSavePolicy`:普通合法节点执行进入档;
`interaction` / `save_on_exit` 登记退出 checkpoint,节点完成并直接结束本轮 Dialogue 后写 state-only 档。
- `anchor.nodeName` 对所有档位都保存触发节点;`anchor.startDialogueOnRestore` 独立决定读档时是否启动 Yarn。
- 退出 checkpoint 在触发时冻结 node / YarnProject / TalkScene / Dialogue run`DialogEnd` 初始化期间使用 scoped interaction lock,下一帧捕获完成后才开放玩法输入。
- **Fresh 进入 vs detour 续跑**`SavePointEvaluator``yarnProjectId` 维护 `InProgressNodes`;节点尚未 `onNodeComplete` 时再次 `onNodeStart`Yarn detour 返回父节点)→ `DetourResume`,跳过自动存。`OnNodeComplete` 移出集合;切换 YarnProject / `StopDialog` 重置;读档 anchor 重进仅 `OnRestoreEnterNode` 标记,不写盘。
- **tag 规则**`hub` / `linear` / `content` 默认进入时保存;`interaction` 默认 Dialogue 退出时保存;
`save_on_exit` 可覆盖其他一级类型;`no_save` 禁止保存,并与上述退出语义冲突。