feat(save): 增强开发存档跳转与严格校验

This commit is contained in:
2026-07-16 18:39:50 +08:00
parent 7b287a6248
commit 8deff54e48
6 changed files with 832 additions and 876 deletions
@@ -8,371 +8,371 @@ using AibisDream.SaveSystem;
using AibisDream.Utility;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
namespace AibisDream.EditorTools
{
/// <summary>
/// 将本机 testsavs 归档提升到 StreamingAssets/TestSaveFiles,便于提交与跨机器复用。
/// </summary>
/// <summary>从本机 testsavs 选择章节、去重排序并提升为可提交测试档。</summary>
public sealed class DevSavePromoteWindow : EditorWindow
{
private const string HuoshanSectionId = "Huoshan1";
private const string HuoshanSceneName = "Scene/HuoShanFixScene";
private const string HuoshanYarnProjectId = "FP_Huoshan1";
private static readonly (string NodeName, string Label, string FolderName)[] HuoshanCheckpoints =
{
("开头对话", "Stage1 开场", "01_Stage1"),
("Stage2就绪", "Stage2 初检", "02_Stage2"),
("Stage3就绪", "Stage3 滤波器", "03_Stage3"),
("Stage4就绪", "Stage4 表达深入", "04_Stage4"),
("Stage5就绪", "Stage5 中场对话", "05_Stage5"),
("Stage6就绪", "Stage6 LOG 释放", "06_Stage6"),
("Stage7就绪", "Stage7 最终检查", "07_Stage7"),
("结束对话", "Stage8 结束", "08_Stage8"),
};
private string sectionId = "Huoshan1";
private string yarnProjectFilter = "FP_Huoshan1";
private string sceneFilter = "Scene/HuoShanFixScene";
private bool matchYarn = true;
private bool matchScene = true;
private bool keepLatestPerNode = true;
private bool clearSectionFirst;
private readonly List<ArchiveGroup> groups = new();
private readonly List<Candidate> preview = new();
private int selectedGroup;
private string sectionId = string.Empty;
private string sectionTitle = string.Empty;
private string status = string.Empty;
private Vector2 scroll;
private string status = "";
private List<Candidate> preview = new();
[MenuItem("Tools/Aibis/Dev Save Promote")]
public static void Open()
{
var window = GetWindow<DevSavePromoteWindow>("Dev Save Promote");
window.minSize = new Vector2(520, 420);
window.minSize = new Vector2(720f, 520f);
window.Show();
}
private void OnEnable() => RefreshGroups();
private void OnGUI()
{
EditorGUILayout.LabelField("Promote testsavs → StreamingAssets/TestSaveFiles", EditorStyles.boldLabel);
EditorGUILayout.LabelField("测试存档提升与校验", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"本机 TestAutoSaveArchivetestsavs)筛选存档,复制到可提交目录。\n" +
$"目标: {ConstRef.TestSaveFilePath}",
"来源是本机 testsavs;目标是可提交、可随 Development Build 分发的 StreamingAssets/TestSaveFiles。",
MessageType.Info);
sectionId = EditorGUILayout.TextField("Section Id", sectionId);
matchYarn = EditorGUILayout.Toggle("Filter by YarnProject", matchYarn);
using (new EditorGUI.DisabledScope(!matchYarn))
yarnProjectFilter = EditorGUILayout.TextField("YarnProject Id", yarnProjectFilter);
matchScene = EditorGUILayout.Toggle("Filter by Scene", matchScene);
using (new EditorGUI.DisabledScope(!matchScene))
sceneFilter = EditorGUILayout.TextField("Scene Name", sceneFilter);
keepLatestPerNode = EditorGUILayout.Toggle("Keep latest per nodeName", keepLatestPerNode);
clearSectionFirst = EditorGUILayout.Toggle("Clear section folder first", clearSectionFirst);
EditorGUILayout.Space(8);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Refresh Preview", GUILayout.Height(28)))
RefreshPreview();
if (GUILayout.Button("Promote Selected Filters", GUILayout.Height(28)))
Promote();
if (GUILayout.Button("刷新本机归档", GUILayout.Height(26f))) RefreshGroups();
if (GUILayout.Button("校验全部已提交测试档", GUILayout.Height(26f))) ValidateCommittedCatalog();
}
if (!string.IsNullOrEmpty(status))
EditorGUILayout.HelpBox(status, MessageType.None);
EditorGUILayout.Space(4);
EditorGUILayout.LabelField($"Matches: {preview.Count}", EditorStyles.miniBoldLabel);
scroll = EditorGUILayout.BeginScrollView(scroll);
foreach (var item in preview)
if (groups.Count == 0)
{
EditorGUILayout.LabelField(
$"{item.FolderName} | node={item.NodeName} | yarn={item.YarnProjectId} | scene={item.SceneName}");
EditorGUILayout.HelpBox("本机没有可用 testsavs。", MessageType.Warning);
DrawStatus();
return;
}
var labels = groups.Select(group =>
$"{group.SceneSoName} | {group.YarnProjectId} | {group.SceneName} ({group.Items.Count})").ToArray();
var nextGroup = EditorGUILayout.Popup("章节归档", Mathf.Clamp(selectedGroup, 0, labels.Length - 1), labels);
if (nextGroup != selectedGroup || preview.Count == 0)
{
selectedGroup = nextGroup;
SelectGroup(groups[selectedGroup]);
}
sectionId = EditorGUILayout.TextField("Section Id", sectionId);
sectionTitle = EditorGUILayout.TextField("显示名称", sectionTitle);
EditorGUILayout.LabelField("规则", "每个 Yarn 节点保留 savedAt 最新一份;默认按 savedAt 升序");
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.FlexibleSpace();
if (GUILayout.Button("提升当前章节", GUILayout.Width(180f), GUILayout.Height(28f))) Promote();
}
EditorGUILayout.Space(5f);
EditorGUILayout.LabelField($"阶段预览:{preview.Count}", EditorStyles.boldLabel);
scroll = EditorGUILayout.BeginScrollView(scroll);
for (var i = 0; i < preview.Count; i++)
{
var item = preview[i];
using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox))
{
EditorGUILayout.LabelField($"{i + 1:000}", GUILayout.Width(38f));
item.Label = EditorGUILayout.TextField(item.Label, GUILayout.Width(210f));
EditorGUILayout.LabelField(item.NodeName, GUILayout.MinWidth(180f));
EditorGUILayout.LabelField(item.SavedAt, GUILayout.Width(140f));
using (new EditorGUI.DisabledScope(i == 0))
{
if (GUILayout.Button("↑", GUILayout.Width(26f))) Move(i, i - 1);
}
using (new EditorGUI.DisabledScope(i == preview.Count - 1))
{
if (GUILayout.Button("↓", GUILayout.Width(26f))) Move(i, i + 1);
}
}
}
EditorGUILayout.EndScrollView();
DrawStatus();
}
private void RefreshPreview()
private void DrawStatus()
{
preview = CollectCandidates();
status = $"Preview {preview.Count} entries from {ConstRef.TestAutoSaveArchivePath}";
if (!string.IsNullOrWhiteSpace(status))
EditorGUILayout.HelpBox(status, status.StartsWith("OK", StringComparison.Ordinal) ? MessageType.Info : MessageType.Warning);
}
private void RefreshGroups()
{
groups.Clear();
preview.Clear();
var root = ConstRef.TestAutoSaveArchivePath;
if (!Directory.Exists(root))
{
status = $"找不到本机归档:{root}";
return;
}
var candidates = new List<Candidate>();
foreach (var directory in Directory.GetDirectories(root))
{
var snapshotPath = Path.Combine(directory, $"{ConstRef.SaveSnapshotFileName}.json");
if (!File.Exists(snapshotPath)) continue;
try
{
var snapshot = SnapshotPersistence.Load(Path.Combine(directory, ConstRef.SaveSnapshotFileName));
if (snapshot?.anchor == null || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) continue;
candidates.Add(new Candidate(
snapshotPath,
Path.Combine(directory, $"{ConstRef.SaveMetaFileName}.json"),
snapshot.scene?.sceneName,
snapshot.anchor.sceneSoName,
snapshot.anchor.yarnProjectId,
snapshot.anchor.nodeName,
snapshot.savedAt));
}
catch (Exception ex)
{
Debug.LogWarning($"[DevSavePromote] 跳过损坏归档 {snapshotPath}: {ex.Message}");
}
}
groups.AddRange(candidates
.GroupBy(item => $"{item.SceneName}\n{item.SceneSoName}\n{item.YarnProjectId}", StringComparer.Ordinal)
.Select(group => new ArchiveGroup(group.ToList()))
.OrderBy(group => group.SceneSoName, StringComparer.Ordinal));
selectedGroup = Mathf.Clamp(selectedGroup, 0, Math.Max(0, groups.Count - 1));
if (groups.Count > 0) SelectGroup(groups[selectedGroup]);
status = $"OK:读取 {candidates.Count} 份归档,分为 {groups.Count} 个章节。";
}
private void SelectGroup(ArchiveGroup group)
{
preview.Clear();
preview.AddRange(group.Items
.GroupBy(item => item.NodeName, StringComparer.Ordinal)
.Select(nodes => nodes.OrderByDescending(item => item.SavedAt, StringComparer.Ordinal).First())
.OrderBy(item => item.SavedAt, StringComparer.Ordinal));
sectionId = SanitizeIdentifier(group.SceneSoName);
sectionTitle = group.SceneSoName;
}
private void Move(int from, int to)
{
if (from < 0 || to < 0 || from >= preview.Count || to >= preview.Count) return;
var item = preview[from];
preview.RemoveAt(from);
preview.Insert(to, item);
GUI.FocusControl(null);
}
private void Promote()
{
if (string.IsNullOrWhiteSpace(sectionId))
if (preview.Count == 0 || string.IsNullOrWhiteSpace(sectionId))
{
status = "Section Id 不能为空。";
status = "没有可提升阶段,或 Section Id 为空。";
return;
}
var candidates = CollectCandidates();
if (candidates.Count == 0)
var validation = ValidateCandidates(preview);
if (validation.Count > 0)
{
status = "没有匹配的 testsavs 条目。先在 Editor 里打通流程(测试存档模式会写入 testsavs)。";
preview = candidates;
status = string.Join("\n", validation);
return;
}
if (IsHuoshanSection()
&& HuoshanCheckpoints.Any(checkpoint => candidates.All(c => c.NodeName != checkpoint.NodeName)))
{
var missing = HuoshanCheckpoints
.Where(checkpoint => candidates.All(c => c.NodeName != checkpoint.NodeName))
.Select(checkpoint => checkpoint.NodeName);
status = $"Huoshan1 缺少阶段存档:{string.Join(", ", missing)}。请完整跑通流程后再提升。";
preview = candidates;
return;
}
var sectionDir = Path.Combine(ConstRef.TestSaveFilePath, sectionId.Trim());
if ((clearSectionFirst || IsHuoshanSection()) && Directory.Exists(sectionDir))
Directory.Delete(sectionDir, recursive: true);
Directory.CreateDirectory(sectionDir);
EnsureCatalogSection(sectionId.Trim());
int copied = 0;
int index = 1;
foreach (var item in OrderCandidates(candidates))
{
var safeNode = SanitizeFolderName(string.IsNullOrEmpty(item.NodeName) ? "no_node" : item.NodeName);
var folderName = IsHuoshanSection()
? HuoshanCheckpoints.First(checkpoint => checkpoint.NodeName == item.NodeName).FolderName
: $"{index:00}_{safeNode}";
var destDir = Path.Combine(sectionDir, folderName);
Directory.CreateDirectory(destDir);
CopyIfExists(item.SnapshotPath, Path.Combine(destDir, $"{ConstRef.SaveSnapshotFileName}.json"));
CopyIfExists(item.MetaPath, Path.Combine(destDir, $"{ConstRef.SaveMetaFileName}.json"));
copied++;
index++;
}
preview = candidates;
AssetDatabase.Refresh();
status = $"已复制 {copied} 份到 TestSaveFiles/{sectionId.Trim()}";
}
private List<Candidate> CollectCandidates()
{
var list = new List<Candidate>();
if (!Directory.Exists(ConstRef.TestAutoSaveArchivePath))
return list;
foreach (var dir in Directory.GetDirectories(ConstRef.TestAutoSaveArchivePath))
{
var snapshotPath = Path.Combine(dir, $"{ConstRef.SaveSnapshotFileName}.json");
if (!File.Exists(snapshotPath))
continue;
var metaPath = Path.Combine(dir, $"{ConstRef.SaveMetaFileName}.json");
SlotMeta meta = null;
if (File.Exists(metaPath))
{
try
{
meta = JsonConvert.DeserializeObject<SlotMeta>(File.ReadAllText(metaPath, Encoding.UTF8));
}
catch
{
// ignore broken meta
}
}
var yarn = meta?.yarnProjectId ?? string.Empty;
var scene = meta?.sceneName ?? string.Empty;
var node = meta?.nodeName ?? string.Empty;
var savedAt = meta?.savedAt ?? string.Empty;
bool yarnOk = !matchYarn || string.Equals(yarn, yarnProjectFilter, StringComparison.OrdinalIgnoreCase);
bool sceneOk = !matchScene || string.Equals(scene, sceneFilter, StringComparison.OrdinalIgnoreCase);
if (!yarnOk || !sceneOk)
continue;
list.Add(new Candidate(
Path.GetFileName(dir),
dir,
snapshotPath,
File.Exists(metaPath) ? metaPath : null,
node,
yarn,
scene,
savedAt));
}
if (!keepLatestPerNode)
return FilterHuoshanCheckpoints(list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList());
var latestPerNode = list
.GroupBy(c => string.IsNullOrEmpty(c.NodeName) ? c.FolderName : c.NodeName, StringComparer.Ordinal)
.Select(g => g.OrderByDescending(c => c.SavedAt, StringComparer.Ordinal).First())
.OrderBy(c => c.SavedAt, StringComparer.Ordinal)
.ToList();
return FilterHuoshanCheckpoints(latestPerNode);
}
private bool IsHuoshanSection()
{
return string.Equals(sectionId?.Trim(), HuoshanSectionId, StringComparison.OrdinalIgnoreCase);
}
private List<Candidate> FilterHuoshanCheckpoints(List<Candidate> candidates)
{
if (!IsHuoshanSection())
return candidates;
var allowedNodes = new HashSet<string>(
HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName),
StringComparer.Ordinal);
return candidates.Where(candidate => allowedNodes.Contains(candidate.NodeName)).ToList();
}
private IEnumerable<Candidate> OrderCandidates(List<Candidate> candidates)
{
if (!IsHuoshanSection())
{
return candidates.OrderBy(c => c.SavedAt, StringComparer.Ordinal)
.ThenBy(c => c.NodeName, StringComparer.Ordinal);
}
return HuoshanCheckpoints.Select(checkpoint =>
candidates.First(candidate => candidate.NodeName == checkpoint.NodeName));
}
private static void EnsureCatalogSection(string sectionId)
{
var group = groups[selectedGroup];
var root = ConstRef.TestSaveFilePath;
Directory.CreateDirectory(root);
var catalogPath = Path.Combine(root, "catalog.json");
var targetSection = Path.Combine(root, sectionId);
if (Directory.Exists(targetSection)) Directory.Delete(targetSection, true);
Directory.CreateDirectory(targetSection);
CatalogDto catalog;
if (File.Exists(catalogPath))
var catalog = LoadCatalogForWrite();
catalog.sections.RemoveAll(section => string.Equals(section.id, sectionId, StringComparison.OrdinalIgnoreCase));
var sectionDto = new DevSaveCatalogSectionDto
{
id = sectionId,
title = string.IsNullOrWhiteSpace(sectionTitle) ? sectionId : sectionTitle,
expectedSceneName = group.SceneName,
expectedSceneSoName = group.SceneSoName,
expectedYarnProjectId = group.YarnProjectId
};
for (var i = 0; i < preview.Count; i++)
{
var item = preview[i];
var folder = $"{i + 1:000}_{SanitizeIdentifier(item.NodeName)}";
var destination = Path.Combine(targetSection, folder);
Directory.CreateDirectory(destination);
File.Copy(item.SnapshotPath, Path.Combine(destination, $"{ConstRef.SaveSnapshotFileName}.json"), true);
if (File.Exists(item.MetaPath))
File.Copy(item.MetaPath, Path.Combine(destination, $"{ConstRef.SaveMetaFileName}.json"), true);
sectionDto.entries.Add(new DevSaveCatalogEntryDto
{
order = i + 1,
label = string.IsNullOrWhiteSpace(item.Label) ? item.NodeName : item.Label,
path = $"{sectionId}/{folder}",
anchorNode = item.NodeName
});
}
catalog.sections.Add(sectionDto);
WriteCatalog(catalog);
AssetDatabase.Refresh();
ValidateCommittedCatalog();
}
private static List<string> ValidateCandidates(IEnumerable<Candidate> candidates)
{
var errors = new List<string>();
var nodes = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in candidates)
{
if (!nodes.Add(item.NodeName)) errors.Add($"重复节点:{item.NodeName}");
try
{
catalog = JsonConvert.DeserializeObject<CatalogDto>(File.ReadAllText(catalogPath, Encoding.UTF8))
?? new CatalogDto();
var snapshot = SnapshotPersistence.Load(Path.Combine(Path.GetDirectoryName(item.SnapshotPath)!, ConstRef.SaveSnapshotFileName));
if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion)
errors.Add($"{item.NodeName}: schema {snapshot.schemaVersion} 不兼容");
}
catch
catch (Exception ex)
{
catalog = new CatalogDto();
errors.Add($"{item.NodeName}: {ex.Message}");
}
}
else
{
catalog = new CatalogDto();
}
return errors;
}
catalog.sections ??= new List<CatalogSectionDto>();
var section = catalog.sections.FirstOrDefault(
s => string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase));
if (section == null)
private void ValidateCommittedCatalog()
{
var sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath);
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(DevSaveCatalog.LastError)) errors.Add(DevSaveCatalog.LastError);
foreach (var section in sections)
{
section = new CatalogSectionDto
var orders = new HashSet<int>();
var nodes = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in section.Entries)
{
id = sectionId,
title = sectionId
};
catalog.sections.Add(section);
if (!orders.Add(entry.Order)) errors.Add($"{section.Id}: 重复 order {entry.Order}");
if (!nodes.Add(entry.AnchorNode)) errors.Add($"{section.Id}: 重复节点 {entry.AnchorNode}");
if (!entry.IsValid) errors.Add($"{section.Id}/{entry.Label}: {entry.Error}");
}
}
if (string.Equals(sectionId, HuoshanSectionId, StringComparison.OrdinalIgnoreCase))
var dto = LoadCatalogForWrite();
foreach (var section in dto.sections)
{
section.title = "Huoshan 1";
section.expectedSceneName = HuoshanSceneName;
section.expectedYarnProjectId = HuoshanYarnProjectId;
section.allowedNodes = HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName).ToList();
section.entries = HuoshanCheckpoints.Select(checkpoint => new CatalogEntryDto
if (!SceneAddressExists(section.expectedSceneName))
errors.Add($"{section.id}: Addressable 场景不存在 {section.expectedSceneName}");
var sceneSo = FindTalkSceneSo(section.expectedSceneSoName);
if (sceneSo == null)
errors.Add($"{section.id}: TalkSceneSO 不存在 {section.expectedSceneSoName}");
else if (sceneSo.yarnProject == null || sceneSo.yarnProject.name != section.expectedYarnProjectId)
errors.Add($"{section.id}: TalkSceneSO 的 YarnProject 不匹配 {section.expectedYarnProjectId}");
else
{
label = checkpoint.Label,
path = $"{HuoshanSectionId}/{checkpoint.FolderName}",
anchorNode = checkpoint.NodeName
}).ToList();
var nodeNames = new HashSet<string>(sceneSo.yarnProject.NodeNames, StringComparer.Ordinal);
foreach (var entry in section.entries.Where(entry => !nodeNames.Contains(entry.anchorNode)))
errors.Add($"{section.id}: Yarn 节点不存在 {entry.anchorNode}");
}
}
status = errors.Count == 0
? $"OK{sections.Count} 个章节、{sections.Sum(s => s.Entries.Count)} 个阶段全部通过校验。"
: $"校验失败({errors.Count}):\n{string.Join("\n", errors)}";
if (errors.Count > 0) Debug.LogError($"[DevSavePromote] {status}");
else Debug.Log($"[DevSavePromote] {status}");
}
private static bool SceneAddressExists(string address)
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
return settings != null && settings.groups
.Where(group => group != null)
.SelectMany(group => group.entries)
.Any(entry => string.Equals(entry.address, address, StringComparison.Ordinal));
}
private static TalkSceneSO FindTalkSceneSo(string assetName)
{
foreach (var guid in AssetDatabase.FindAssets($"t:TalkSceneSO {assetName}"))
{
var asset = AssetDatabase.LoadAssetAtPath<TalkSceneSO>(AssetDatabase.GUIDToAssetPath(guid));
if (asset != null && asset.name == assetName) return asset;
}
return null;
}
private static DevSaveCatalogDto LoadCatalogForWrite()
{
var path = Path.Combine(ConstRef.TestSaveFilePath, "catalog.json");
if (!File.Exists(path)) return new DevSaveCatalogDto();
try
{
return JsonConvert.DeserializeObject<DevSaveCatalogDto>(File.ReadAllText(path, Encoding.UTF8))
?? new DevSaveCatalogDto();
}
catch
{
return new DevSaveCatalogDto();
}
}
private static void WriteCatalog(DevSaveCatalogDto catalog)
{
Directory.CreateDirectory(ConstRef.TestSaveFilePath);
File.WriteAllText(
catalogPath,
Path.Combine(ConstRef.TestSaveFilePath, "catalog.json"),
JsonConvert.SerializeObject(catalog, Formatting.Indented),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
new UTF8Encoding(false));
}
private static void CopyIfExists(string src, string dest)
private static string SanitizeIdentifier(string value)
{
if (string.IsNullOrEmpty(src) || !File.Exists(src))
return;
File.Copy(src, dest, overwrite: true);
if (string.IsNullOrWhiteSpace(value)) return "unnamed";
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
value = value.Replace(' ', '_');
return value.Length <= 48 ? value : value.Substring(0, 48);
}
private static string SanitizeFolderName(string name)
private sealed class ArchiveGroup
{
foreach (var c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
name = name.Replace(' ', '_');
if (name.Length > 48)
name = name.Substring(0, 48);
return string.IsNullOrWhiteSpace(name) ? "no_node" : name;
public readonly List<Candidate> Items;
public string SceneName => Items[0].SceneName;
public string SceneSoName => Items[0].SceneSoName;
public string YarnProjectId => Items[0].YarnProjectId;
public ArchiveGroup(List<Candidate> items) => Items = items;
}
private sealed class Candidate
{
public readonly string FolderName;
public readonly string DirectoryPath;
public readonly string SnapshotPath;
public readonly string MetaPath;
public readonly string NodeName;
public readonly string YarnProjectId;
public readonly string SceneName;
public readonly string SceneSoName;
public readonly string YarnProjectId;
public readonly string NodeName;
public readonly string SavedAt;
public string Label;
public Candidate(
string folderName,
string directoryPath,
string snapshotPath,
string metaPath,
string nodeName,
string yarnProjectId,
string sceneName,
string savedAt)
public Candidate(string snapshotPath, string metaPath, string sceneName, string sceneSoName,
string yarnProjectId, string nodeName, string savedAt)
{
FolderName = folderName;
DirectoryPath = directoryPath;
SnapshotPath = snapshotPath;
MetaPath = metaPath;
NodeName = nodeName;
YarnProjectId = yarnProjectId;
SceneName = sceneName;
SavedAt = savedAt;
SceneSoName = sceneSoName;
YarnProjectId = yarnProjectId;
NodeName = nodeName;
SavedAt = savedAt ?? string.Empty;
Label = nodeName;
}
}
[Serializable]
private class CatalogDto
{
public List<CatalogSectionDto> sections = new();
}
[Serializable]
private class CatalogSectionDto
{
public string id;
public string title;
public List<CatalogEntryDto> entries;
public string expectedSceneName;
public string expectedYarnProjectId;
public List<string> allowedNodes;
}
[Serializable]
private class CatalogEntryDto
{
public string label;
public string path;
public string anchorNode;
}
}
}
#endif
+21 -2
View File
@@ -7,6 +7,7 @@ using AibisDream.Kit;
using AibisDream.SaveSystem;
using AibisDream.UI;
using UnityEngine;
using UnityEngine.EventSystems;
namespace AibisDream
{
@@ -219,6 +220,16 @@ namespace AibisDream
StartCoroutine(RestartCoroutine());
}
/// <summary>
/// 可等待的完整游戏会话清理。开发跳转等编排流程必须等待它结束,避免场景卸载与读档并发。
/// </summary>
public IEnumerator ResetGameSessionRoutine()
{
Time.timeScale = 1;
ScreenEffectManager.Instance?.ResetSaturation();
yield return RestartCoroutine();
}
public void QuitApp()
{
Application.Quit();
@@ -262,6 +273,8 @@ namespace AibisDream
{
yield return playTool.FadeOutAsync(MainMenuFadeDuration);
}
EventSystem.current?.SetSelectedGameObject(null);
}
#endregion
@@ -338,8 +351,7 @@ namespace AibisDream
public bool SetSceneSoByName(string soName)
{
var sceneSo = _totalSceneSos?.Find(so => so != null && so.name == soName);
sceneSo ??= additionalRestoreSceneSos?.Find(so => so != null && so.name == soName);
var sceneSo = FindSceneSoByName(soName);
if (sceneSo == null)
{
@@ -351,6 +363,13 @@ namespace AibisDream
return true;
}
/// <summary>只查询章节资产,不改变当前章节;供严格读档预检使用。</summary>
public TalkSceneSO FindSceneSoByName(string soName)
{
var sceneSo = _totalSceneSos?.Find(so => so != null && so.name == soName);
return sceneSo ?? additionalRestoreSceneSos?.Find(so => so != null && so.name == soName);
}
public void StartDialog()
{
DialogController.Instance.LoadDialog(_currentTalkSceneSo.Value.yarnProject);
@@ -1,9 +1,33 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace AibisDream.SaveSystem
{
[Serializable]
public sealed class RestoreOptions
{
public bool CleanSessionFirst;
public bool StrictValidation;
public static RestoreOptions Default => new();
public static RestoreOptions DevJump => new()
{
CleanSessionFirst = true,
StrictValidation = true
};
}
public sealed class RestoreResult
{
public bool Success { get; internal set; }
public string FailedPhase { get; internal set; }
public IReadOnlyList<string> Errors { get; internal set; } = Array.Empty<string>();
public IReadOnlyList<string> Warnings { get; internal set; } = Array.Empty<string>();
public IReadOnlyList<string> Log { get; internal set; } = Array.Empty<string>();
}
/// <summary>
/// 快照提供者契约:将某一子系统的运行时状态转换为纯数据 DTO。
/// 还原侧由同步 / 异步子接口显式声明,避免 Provider 内部 fire-and-forget。
@@ -56,6 +80,19 @@ namespace AibisDream.SaveSystem
public SaveSnapshot Snapshot { get; }
public bool StrictMode { get; }
public Action<string> LogStep { get; }
public string CurrentPhase { get; private set; }
public IReadOnlyList<string> Warnings => _warnings;
public IReadOnlyList<string> Errors => _errors;
public bool HasErrors => _errors.Count > 0;
private readonly List<string> _warnings = new();
private readonly List<string> _errors = new();
public void SetPhase(string phase)
{
CurrentPhase = phase;
Log(phase);
}
public void Log(string message)
{
@@ -65,12 +102,14 @@ namespace AibisDream.SaveSystem
public void Warn(string message)
{
_warnings.Add(message);
LogStep?.Invoke($"WARN: {message}");
Debug.LogWarning($"[SnapshotRestore] {message}");
}
public void Error(string message)
{
_errors.Add(message);
LogStep?.Invoke($"ERROR: {message}");
Debug.LogError($"[SnapshotRestore] {message}");
}
@@ -154,20 +154,70 @@ namespace AibisDream.SaveSystem
/// <summary>从文件读档并还原;自动区分新快照格式与 legacy 格式。</summary>
public static IEnumerator RestoreFromFile(string savePath)
{
if (SnapshotPersistence.IsLegacyFormat(savePath))
yield return RestoreFromFile(savePath, RestoreOptions.Default, null);
}
/// <summary>从文件恢复并返回结构化结果;开发跳转使用严格校验和完整会话清理。</summary>
public static IEnumerator RestoreFromFile(
string savePath,
RestoreOptions options,
Action<RestoreResult> completed)
{
options ??= RestoreOptions.Default;
var result = new RestoreResult();
bool isLegacy;
try
{
Debug.LogWarning("[SaveRestoreOrchestrator] 检测到旧格式存档,请使用新快照格式重新保存。");
yield return RestoreLegacyWithFlow(savePath);
isLegacy = SnapshotPersistence.IsLegacyFormat(savePath);
}
catch (Exception ex)
{
result.Success = false;
result.FailedPhase = "LoadSnapshot";
result.Errors = new[] { ex.Message };
completed?.Invoke(result);
yield break;
}
SaveSnapshot snapshot;
using (new CodeTimer("LoadSnapshot"))
if (isLegacy)
{
snapshot = SnapshotPersistence.Load(savePath);
if (options.StrictValidation)
{
result.Success = false;
result.FailedPhase = "Preflight";
result.Errors = new[] { "legacy format is not supported by strict restore" };
completed?.Invoke(result);
yield break;
}
Debug.LogWarning("[SaveRestoreOrchestrator] 检测到旧格式存档,请使用新快照格式重新保存。");
yield return RestoreLegacyWithFlow(savePath);
result.Success = true;
result.Log = new List<string>(_lastRestoreLog);
completed?.Invoke(result);
yield break;
}
yield return RestoreSnapshot(snapshot, savePath);
SaveSnapshot snapshot = null;
try
{
using (new CodeTimer("LoadSnapshot"))
{
snapshot = SnapshotPersistence.Load(savePath);
}
}
catch (Exception ex)
{
result.Success = false;
result.FailedPhase = "LoadSnapshot";
result.Errors = new[] { ex.Message };
completed?.Invoke(result);
yield break;
}
yield return RestoreSnapshot(snapshot, savePath, options, result);
completed?.Invoke(result);
}
public static IDisposable SuppressAutoSaveScope(string reason)
@@ -224,10 +274,22 @@ namespace AibisDream.SaveSystem
}
private static IEnumerator RestoreSnapshot(SaveSnapshot snapshot, string sourceLabel)
{
yield return RestoreSnapshot(snapshot, sourceLabel, RestoreOptions.Default, new RestoreResult());
}
private static IEnumerator RestoreSnapshot(
SaveSnapshot snapshot,
string sourceLabel,
RestoreOptions options,
RestoreResult result)
{
if (snapshot == null)
{
Debug.LogError("[SaveRestoreOrchestrator] snapshot 为 null,无法读档。");
result.Success = false;
result.FailedPhase = "Preflight";
result.Errors = new[] { "snapshot is null" };
yield break;
}
@@ -235,22 +297,48 @@ namespace AibisDream.SaveSystem
{
IsRestoring = true;
ResetRestoreLog(sourceLabel);
var context = new SnapshotRestoreContext(snapshot, logStep: AddRestoreLog);
var context = new SnapshotRestoreContext(
snapshot,
strictMode: options.StrictValidation,
logStep: AddRestoreLog);
try
{
context.SetPhase("Preflight");
ValidateSnapshotForRestore(snapshot, context);
if (context.StrictMode && context.HasErrors)
{
yield break;
}
if (options.CleanSessionFirst && GameManager.Instance != null)
{
context.SetPhase("Clean game session");
yield return GameManager.Instance.ResetGameSessionRoutine();
}
PrepareGameSessionForRestore();
yield return FadeInForRestore();
SnapshotRegistry.EnsureInitialized();
yield return SnapshotRestore.RestoreState(YarnVariableStorage.Instance, snapshot, context);
yield return SnapshotRestore.RestoreAnchor(snapshot, context);
if (!context.StrictMode || !context.HasErrors)
{
yield return SnapshotRestore.RestoreAnchor(snapshot, context);
}
AddRestoreLog("Phase 3.5: settle one frame");
yield return null;
if (!context.StrictMode || !context.HasErrors)
{
context.SetPhase("Phase 3.5: settle one frame");
yield return null;
ValidateRestoredRuntime(snapshot, context);
}
FinalizeGameSessionAfterRestore();
if (!context.StrictMode || !context.HasErrors)
{
FinalizeGameSessionAfterRestore();
}
yield return FadeOutForRestore();
ScreenSnapshotHelper.ApplyDeferredFadeScreenIfNeeded();
@@ -259,10 +347,76 @@ namespace AibisDream.SaveSystem
{
IsRestoring = false;
AddRestoreLog("Restore finished");
result.Success = !context.HasErrors;
result.FailedPhase = context.HasErrors ? context.CurrentPhase : null;
result.Errors = new List<string>(context.Errors);
result.Warnings = new List<string>(context.Warnings);
result.Log = new List<string>(_lastRestoreLog);
}
}
}
private static void ValidateSnapshotForRestore(SaveSnapshot snapshot, SnapshotRestoreContext context)
{
if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion)
context.Error($"Unsupported schema {snapshot.schemaVersion}; expected {SaveSnapshotSchema.CurrentVersion}.");
if (string.IsNullOrWhiteSpace(snapshot.scene?.sceneName))
context.Error("Snapshot scene is missing.");
if (string.IsNullOrWhiteSpace(snapshot.anchor?.sceneSoName))
context.Error("Snapshot TalkSceneSO is missing.");
if (string.IsNullOrWhiteSpace(snapshot.anchor?.yarnProjectId))
context.Error("Snapshot YarnProject is missing.");
if (string.IsNullOrWhiteSpace(snapshot.anchor?.nodeName))
context.Error("Snapshot Yarn node is missing.");
if (context.HasErrors) return;
var sceneSo = GameManager.Instance?.FindSceneSoByName(snapshot.anchor.sceneSoName);
if (sceneSo == null)
{
context.Error($"TalkSceneSO does not exist: {snapshot.anchor.sceneSoName}.");
return;
}
if (sceneSo.yarnProject == null
|| !string.Equals(sceneSo.yarnProject.name, snapshot.anchor.yarnProjectId, StringComparison.Ordinal))
{
context.Error($"TalkSceneSO YarnProject mismatch: {snapshot.anchor.yarnProjectId}.");
return;
}
if (!Array.Exists(sceneSo.yarnProject.NodeNames,
node => string.Equals(node, snapshot.anchor.nodeName, StringComparison.Ordinal)))
{
context.Error($"Yarn node does not exist: {snapshot.anchor.nodeName}.");
}
}
private static void ValidateRestoredRuntime(SaveSnapshot snapshot, SnapshotRestoreContext context)
{
context.SetPhase("Postflight validation");
var sceneLoader = SceneLoader.Instance;
if (sceneLoader == null || sceneLoader.IsLoading)
context.Error("SceneLoader is not ready.");
else if (!string.Equals(sceneLoader.CurrentSceneName, snapshot.scene.sceneName, StringComparison.Ordinal))
context.Error($"Scene mismatch: {sceneLoader.CurrentSceneName ?? "none"}.");
var sceneSo = GameManager.Instance?.GetCurrentTalkSceneSo();
if (!string.Equals(sceneSo?.name, snapshot.anchor.sceneSoName, StringComparison.Ordinal))
context.Error($"TalkSceneSO mismatch: {sceneSo?.name ?? "none"}.");
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.sections != null
&& snapshot.sections.ContainsKey(SnapshotProviderIds.Fix)
&& (FixSystem.FixSystemCenter.Instance == null
|| !FixSystem.FixSystemCenter.Instance.IsDirectorReady))
{
context.Error("FixSystem is not ready.");
}
}
/// <summary>
/// 读档前对齐「已进入游戏」的 UI 与输入状态。
/// 编辑器测试工具等路径可能跳过 <see cref="GameLoopEnum.GameStart"/>,导致 TerminalPanel 未关闭、
+12 -5
View File
@@ -25,16 +25,18 @@ namespace AibisDream.SaveSystem
context ??= new SnapshotRestoreContext(snapshot);
context.Log("Phase 0: Restore Yarn variables");
context.SetPhase("Phase 0: Restore Yarn variables");
RestoreYarnVariables(storage, snapshot);
context.Log("Phase 1: Load scene");
context.SetPhase("Phase 1: Load scene");
yield return RestoreScene(snapshot, context);
if (context.StrictMode && context.HasErrors) yield break;
context.Log("Phase 1.5: Restore scene SO");
context.SetPhase("Phase 1.5: Restore scene SO");
RestoreSceneSo(snapshot, context);
if (context.StrictMode && context.HasErrors) yield break;
context.Log("Phase 2: Restore providers (ordered by RestoreOrder)");
context.SetPhase("Phase 2: Restore providers (ordered by RestoreOrder)");
yield return RestoreProvidersInOrder(snapshot, context);
}
@@ -52,7 +54,7 @@ namespace AibisDream.SaveSystem
context ??= new SnapshotRestoreContext(snapshot);
var hasNode = !string.IsNullOrEmpty(snapshot.anchor.nodeName);
context.Log(hasNode
context.SetPhase(hasNode
? $"Phase 3: Restore anchor {snapshot.anchor.nodeName}"
: "Phase 3: No anchor node, restore YarnProject only");
@@ -192,6 +194,11 @@ namespace AibisDream.SaveSystem
context.Warn($"Provider {provider.SaveId} 未实现同步或异步还原契约,已跳过。");
break;
}
if (context.StrictMode && context.HasErrors)
{
yield break;
}
}
}
File diff suppressed because it is too large Load Diff