From 8deff54e48d26ff5f68d0b8812271697fe899f2b Mon Sep 17 00:00:00 2001
From: bottlefish <781230111@qq.com>
Date: Thu, 16 Jul 2026 18:39:50 +0800
Subject: [PATCH] =?UTF-8?q?feat(save):=20=E5=A2=9E=E5=BC=BA=E5=BC=80?=
=?UTF-8?q?=E5=8F=91=E5=AD=98=E6=A1=A3=E8=B7=B3=E8=BD=AC=E4=B8=8E=E4=B8=A5?=
=?UTF-8?q?=E6=A0=BC=E6=A0=A1=E9=AA=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../DevSavePromoteWindow.cs | 562 +++++------
Assets/Scripts/Game Loop/GameManager.cs | 23 +-
.../Scripts/SaveSystem/ISnapshotProvider.cs | 39 +
.../SaveSystem/SaveRestoreOrchestrator.cs | 178 +++-
Assets/Scripts/SaveSystem/SnapshotRestore.cs | 17 +-
Assets/Scripts/Utility/DevSaveJumpTool.cs | 889 ++++++------------
6 files changed, 832 insertions(+), 876 deletions(-)
diff --git a/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs b/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs
index 4499f3743..4c7e5cd7d 100644
--- a/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs
+++ b/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs
@@ -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
{
- ///
- /// 将本机 testsavs 归档提升到 StreamingAssets/TestSaveFiles,便于提交与跨机器复用。
- ///
+ /// 从本机 testsavs 选择章节、去重排序并提升为可提交测试档。
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 groups = new();
+ private readonly List 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 preview = new();
[MenuItem("Tools/Aibis/Dev Save Promote")]
public static void Open()
{
var window = GetWindow("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(
- "从本机 TestAutoSaveArchive(testsavs)筛选存档,复制到可提交目录。\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();
+ 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 CollectCandidates()
- {
- var list = new List();
- 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(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 FilterHuoshanCheckpoints(List candidates)
- {
- if (!IsHuoshanSection())
- return candidates;
-
- var allowedNodes = new HashSet(
- HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName),
- StringComparer.Ordinal);
- return candidates.Where(candidate => allowedNodes.Contains(candidate.NodeName)).ToList();
- }
-
- private IEnumerable OrderCandidates(List 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 ValidateCandidates(IEnumerable candidates)
+ {
+ var errors = new List();
+ var nodes = new HashSet(StringComparer.Ordinal);
+ foreach (var item in candidates)
+ {
+ if (!nodes.Add(item.NodeName)) errors.Add($"重复节点:{item.NodeName}");
try
{
- catalog = JsonConvert.DeserializeObject(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();
- 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();
+ if (!string.IsNullOrWhiteSpace(DevSaveCatalog.LastError)) errors.Add(DevSaveCatalog.LastError);
+ foreach (var section in sections)
{
- section = new CatalogSectionDto
+ var orders = new HashSet();
+ var nodes = new HashSet(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(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(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(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 Items;
+ public string SceneName => Items[0].SceneName;
+ public string SceneSoName => Items[0].SceneSoName;
+ public string YarnProjectId => Items[0].YarnProjectId;
+ public ArchiveGroup(List 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 sections = new();
- }
-
- [Serializable]
- private class CatalogSectionDto
- {
- public string id;
- public string title;
- public List entries;
- public string expectedSceneName;
- public string expectedYarnProjectId;
- public List allowedNodes;
- }
-
- [Serializable]
- private class CatalogEntryDto
- {
- public string label;
- public string path;
- public string anchorNode;
- }
}
}
#endif
diff --git a/Assets/Scripts/Game Loop/GameManager.cs b/Assets/Scripts/Game Loop/GameManager.cs
index 393fa87f0..589964708 100644
--- a/Assets/Scripts/Game Loop/GameManager.cs
+++ b/Assets/Scripts/Game Loop/GameManager.cs
@@ -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());
}
+ ///
+ /// 可等待的完整游戏会话清理。开发跳转等编排流程必须等待它结束,避免场景卸载与读档并发。
+ ///
+ 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;
}
+ /// 只查询章节资产,不改变当前章节;供严格读档预检使用。
+ 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);
diff --git a/Assets/Scripts/SaveSystem/ISnapshotProvider.cs b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs
index 1a2fad042..7ae656cc6 100644
--- a/Assets/Scripts/SaveSystem/ISnapshotProvider.cs
+++ b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs
@@ -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 Errors { get; internal set; } = Array.Empty();
+ public IReadOnlyList Warnings { get; internal set; } = Array.Empty();
+ public IReadOnlyList Log { get; internal set; } = Array.Empty();
+ }
+
///
/// 快照提供者契约:将某一子系统的运行时状态转换为纯数据 DTO。
/// 还原侧由同步 / 异步子接口显式声明,避免 Provider 内部 fire-and-forget。
@@ -56,6 +80,19 @@ namespace AibisDream.SaveSystem
public SaveSnapshot Snapshot { get; }
public bool StrictMode { get; }
public Action LogStep { get; }
+ public string CurrentPhase { get; private set; }
+ public IReadOnlyList Warnings => _warnings;
+ public IReadOnlyList Errors => _errors;
+ public bool HasErrors => _errors.Count > 0;
+
+ private readonly List _warnings = new();
+ private readonly List _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}");
}
diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs
index 1007d0e92..3d382b18c 100644
--- a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs
+++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs
@@ -154,20 +154,70 @@ namespace AibisDream.SaveSystem
/// 从文件读档并还原;自动区分新快照格式与 legacy 格式。
public static IEnumerator RestoreFromFile(string savePath)
{
- if (SnapshotPersistence.IsLegacyFormat(savePath))
+ yield return RestoreFromFile(savePath, RestoreOptions.Default, null);
+ }
+
+ /// 从文件恢复并返回结构化结果;开发跳转使用严格校验和完整会话清理。
+ public static IEnumerator RestoreFromFile(
+ string savePath,
+ RestoreOptions options,
+ Action 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(_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(context.Errors);
+ result.Warnings = new List(context.Warnings);
+ result.Log = new List(_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.");
+ }
+ }
+
///
/// 读档前对齐「已进入游戏」的 UI 与输入状态。
/// 编辑器测试工具等路径可能跳过 ,导致 TerminalPanel 未关闭、
diff --git a/Assets/Scripts/SaveSystem/SnapshotRestore.cs b/Assets/Scripts/SaveSystem/SnapshotRestore.cs
index 4ddee700c..a66fcb175 100644
--- a/Assets/Scripts/SaveSystem/SnapshotRestore.cs
+++ b/Assets/Scripts/SaveSystem/SnapshotRestore.cs
@@ -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;
+ }
}
}
diff --git a/Assets/Scripts/Utility/DevSaveJumpTool.cs b/Assets/Scripts/Utility/DevSaveJumpTool.cs
index a5cc38d80..4c10c3bf4 100644
--- a/Assets/Scripts/Utility/DevSaveJumpTool.cs
+++ b/Assets/Scripts/Utility/DevSaveJumpTool.cs
@@ -11,592 +11,326 @@ using UnityEngine;
namespace AibisDream
{
- ///
- /// 开发用通用跳转工具:从 (可提交)读档。
- /// 目录约定:StreamingAssets/TestSaveFiles/{SectionId}/{EntryFolder}/snapshot.json
- ///
- public class DevSaveJumpTool : MonoBehaviour
+ /// 从随项目提交的 TestSaveFiles 清单中执行严格、可诊断的开发跳转。
+ public sealed class DevSaveJumpTool : MonoBehaviour
{
- private const float UiMargin = 16f;
- private const float DevButtonWidth = 96f;
- private const float DevButtonHeight = 34f;
- private const float MenuWidth = 320f;
- private const float MaxMenuHeight = 560f;
- private const float RowHeight = 30f;
- private const float HeaderHeight = 24f;
- private const float StatusWidth = 360f;
- private const float StatusHeight = 28f;
+ private const float Margin = 16f;
+ private const float ButtonWidth = 96f;
+ private const float ButtonHeight = 34f;
+ private const float PanelWidth = 500f;
+ private const float PanelHeight = 680f;
private static DevSaveJumpTool instance;
+ private readonly Dictionary expanded = new(StringComparer.Ordinal);
+ private List sections = new();
+ private Vector2 scroll;
private bool isJumping;
private bool menuOpen;
- private string status = "";
- private float statusVisibleUntil;
- private Vector2 menuScroll;
- private GUIStyle buttonStyle;
- private GUIStyle headerStyle;
- private GUIStyle menuBoxStyle;
+ private string search = string.Empty;
+ private string status = string.Empty;
+ private string details = string.Empty;
+ private GUIStyle invalidStyle;
private GUIStyle statusStyle;
- private List sections = new();
- private float cachedContentHeight;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Bootstrap()
{
- if (instance != null)
- return;
-
- var existing = FindObjectOfType();
- if (existing != null)
+ if (instance != null) return;
+ instance = FindObjectOfType();
+ if (instance == null)
{
- instance = existing;
- DontDestroyOnLoad(existing.gameObject);
- return;
+ var go = new GameObject("[Dev] Save Jump Tool");
+ instance = go.AddComponent();
}
- var toolObject = new GameObject("[Dev] Save Jump Tool");
- DontDestroyOnLoad(toolObject);
- instance = toolObject.AddComponent();
+ DontDestroyOnLoad(instance.gameObject);
}
- private void Awake()
- {
- ReloadCatalog();
- }
+ private void Awake() => ReloadCatalog();
private void OnGUI()
{
EnsureStyles();
-
- float buttonX = Screen.width - UiMargin - DevButtonWidth;
- float buttonY = Screen.height - UiMargin - DevButtonHeight;
- var devButtonRect = new Rect(buttonX, buttonY, DevButtonWidth, DevButtonHeight);
+ var buttonRect = new Rect(
+ Screen.width - Margin - ButtonWidth,
+ Screen.height - Margin - ButtonHeight,
+ ButtonWidth,
+ ButtonHeight);
if (menuOpen)
- DrawCommandMenu(devButtonRect);
-
- string label = isJumping ? "Busy..." : "Dev";
- if (GUI.Button(devButtonRect, label, buttonStyle))
{
- if (!menuOpen)
- ReloadCatalog();
+ var height = Mathf.Min(PanelHeight, Screen.height - Margin * 2f);
+ var panelRect = new Rect(
+ Screen.width - Margin - PanelWidth,
+ Mathf.Max(Margin, buttonRect.y - height - 8f),
+ PanelWidth,
+ height);
+ GUI.Box(panelRect, GUIContent.none);
+ GUILayout.BeginArea(new Rect(panelRect.x + 10f, panelRect.y + 10f, panelRect.width - 20f, panelRect.height - 20f));
+ DrawPanel();
+ GUILayout.EndArea();
+ }
+
+ GUI.enabled = !isJumping;
+ if (GUI.Button(buttonRect, isJumping ? "Busy..." : "Dev Saves"))
menuOpen = !menuOpen;
- }
-
- if (!string.IsNullOrEmpty(status) && Time.unscaledTime < statusVisibleUntil)
- {
- float menuHeight = GetMenuHeight();
- float statusY = menuOpen
- ? buttonY - menuHeight - StatusHeight - UiMargin
- : buttonY - StatusHeight - 8f;
- var statusRect = new Rect(
- Screen.width - UiMargin - StatusWidth,
- Mathf.Max(UiMargin, statusY),
- StatusWidth,
- StatusHeight);
- GUI.Label(statusRect, status, statusStyle);
- }
- }
-
- private void DrawCommandMenu(Rect devButtonRect)
- {
- float menuHeight = GetMenuHeight();
- float menuX = Screen.width - UiMargin - MenuWidth;
- float menuY = Mathf.Max(UiMargin, devButtonRect.y - menuHeight - 8f);
- var menuRect = new Rect(menuX, menuY, MenuWidth, menuHeight);
-
- GUI.Box(menuRect, GUIContent.none, menuBoxStyle);
-
- var viewRect = new Rect(0f, 0f, MenuWidth - 28f, GetContentHeight());
- var scrollRect = new Rect(
- menuRect.x + 6f,
- menuRect.y + 6f,
- menuRect.width - 12f,
- menuRect.height - 12f);
-
- menuScroll = GUI.BeginScrollView(scrollRect, menuScroll, viewRect);
-
- float y = 0f;
- if (sections.Count == 0)
- {
- GUI.Label(new Rect(0f, y, MenuWidth - 32f, RowHeight), "No TestSaveFiles found", headerStyle);
- }
- else
- {
- for (int i = 0; i < sections.Count; i++)
- {
- var section = sections[i];
- DrawHeader(ref y, section.Title);
- if (section.Entries.Count == 0)
- {
- GUI.Label(
- new Rect(0f, y, MenuWidth - 32f, RowHeight),
- "(empty — run Test Mode or promote saves)",
- statusStyle);
- y += RowHeight;
- }
- else
- {
- DrawSaveEntries(ref y, section.Entries);
- }
-
- if (i < sections.Count - 1)
- y += 6f;
- }
- }
-
- GUI.EndScrollView();
- }
-
- private void DrawHeader(ref float y, string label)
- {
- GUI.Label(new Rect(0f, y, MenuWidth - 32f, HeaderHeight), label, headerStyle);
- y += HeaderHeight;
- }
-
- private void DrawSaveEntries(ref float y, List entries)
- {
- foreach (var entry in entries)
- {
- var rect = new Rect(0f, y, MenuWidth - 32f, RowHeight);
- var captured = entry;
- DrawCommandButton(rect, captured.Label, !isJumping, () => StartCoroutine(LoadDevSave(captured)));
- y += RowHeight;
- }
- }
-
- private float GetMenuHeight()
- {
- return Mathf.Min(MaxMenuHeight, GetContentHeight() + 12f);
- }
-
- private float GetContentHeight()
- {
- if (cachedContentHeight > 0f)
- return cachedContentHeight;
-
- float height = 0f;
- if (sections.Count == 0)
- {
- height = RowHeight;
- }
- else
- {
- for (int i = 0; i < sections.Count; i++)
- {
- height += HeaderHeight;
- int rows = Math.Max(1, sections[i].Entries.Count);
- height += rows * RowHeight;
- if (i < sections.Count - 1)
- height += 6f;
- }
- }
-
- cachedContentHeight = height;
- return height;
- }
-
- private void DrawCommandButton(Rect rect, string label, bool enabled, Action action)
- {
- GUI.enabled = enabled;
- if (GUI.Button(rect, label, buttonStyle))
- {
- menuOpen = false;
- action?.Invoke();
- }
-
GUI.enabled = true;
}
+ private void DrawPanel()
+ {
+ GUILayout.Label("阶段跳转", GUI.skin.box);
+ GUILayout.BeginHorizontal();
+ GUILayout.Label("搜索", GUILayout.Width(36f));
+ search = GUILayout.TextField(search ?? string.Empty);
+ GUI.enabled = !isJumping;
+ if (GUILayout.Button("重新扫描", GUILayout.Width(76f))) ReloadCatalog();
+ if (GUILayout.Button("逐档冒烟", GUILayout.Width(76f))) StartCoroutine(SmokeTestAll());
+ GUI.enabled = true;
+ GUILayout.EndHorizontal();
+
+ if (!string.IsNullOrEmpty(status))
+ GUILayout.Label(status, statusStyle);
+
+ scroll = GUILayout.BeginScrollView(scroll, GUI.skin.box);
+ if (sections.Count == 0)
+ GUILayout.Label(string.IsNullOrEmpty(DevSaveCatalog.LastError) ? "清单中没有阶段" : DevSaveCatalog.LastError, invalidStyle);
+
+ foreach (var section in sections)
+ {
+ var visible = section.Entries.Where(MatchesSearch).ToList();
+ if (visible.Count == 0 && !string.IsNullOrWhiteSpace(search)) continue;
+
+ if (!expanded.ContainsKey(section.Id)) expanded[section.Id] = true;
+ var validCount = section.Entries.Count(entry => entry.IsValid);
+ if (GUILayout.Button($"{(expanded[section.Id] ? "▼" : "▶")} {section.Title} ({validCount}/{section.Entries.Count})"))
+ expanded[section.Id] = !expanded[section.Id];
+ if (!expanded[section.Id]) continue;
+
+ foreach (var entry in visible)
+ {
+ GUI.enabled = !isJumping && entry.IsValid;
+ if (GUILayout.Button($"{entry.Order:000} {entry.Label}"))
+ StartCoroutine(LoadDevSave(entry));
+ GUI.enabled = true;
+ if (!entry.IsValid)
+ GUILayout.Label($" 不可用:{entry.Error}", invalidStyle);
+ }
+ GUILayout.Space(5f);
+ }
+ GUILayout.EndScrollView();
+
+ if (!string.IsNullOrEmpty(details))
+ {
+ GUILayout.Label("详情");
+ GUILayout.TextArea(details, GUILayout.Height(110f));
+ }
+ }
+
+ private bool MatchesSearch(DevSaveEntry entry)
+ {
+ if (string.IsNullOrWhiteSpace(search)) return true;
+ return entry.Label.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0
+ || entry.AnchorNode.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
private IEnumerator LoadDevSave(DevSaveEntry entry)
{
- if (isJumping)
- yield break;
+ if (isJumping || !entry.IsValid) yield break;
+ isJumping = true;
+ status = $"正在跳转:{entry.Label}";
+ details = string.Empty;
- if (!entry.TryLoadSnapshot(out var snapshot, out var validationError))
+ RestoreResult result = null;
+ yield return SaveRestoreOrchestrator.RestoreFromFile(
+ entry.SnapshotPathWithoutExtension,
+ RestoreOptions.DevJump,
+ value => result = value);
+
+ if (result?.Success == true)
{
- SetStatus($"Invalid save: {validationError}", 8f);
+ status = $"已到达:{entry.Label}";
+ details = result.Warnings.Count == 0 ? string.Empty : string.Join("\n", result.Warnings);
+ menuOpen = false;
+ }
+ else
+ {
+ var errors = result?.Errors ?? Array.Empty();
+ status = $"跳转失败:{entry.Label} ({result?.FailedPhase ?? "unknown"})";
+ details = errors.Count > 0 ? string.Join("\n", errors) : "恢复流程未返回结果。";
+ }
+
+ isJumping = false;
+ }
+
+ private IEnumerator SmokeTestAll()
+ {
+ if (isJumping) yield break;
+ var entries = sections.SelectMany(section => section.Entries).Where(entry => entry.IsValid).ToList();
+ if (entries.Count == 0)
+ {
+ status = "没有可执行的有效阶段。";
yield break;
}
isJumping = true;
- try
+ var failures = new List();
+ for (var i = 0; i < entries.Count; i++)
{
- if (NeedsResetToMainMenu())
- {
- SetStatus($"Resetting: {entry.Label}");
- GameManager.Instance.QuitGame();
-
- bool mainMenuReady = false;
- yield return WaitForMainMenuReady(result => mainMenuReady = result);
- if (!mainMenuReady)
+ var entry = entries[i];
+ status = $"冒烟测试 {i + 1}/{entries.Count}:{entry.Label}";
+ RestoreResult result = null;
+ var completed = false;
+ var routine = StartCoroutine(SaveRestoreOrchestrator.RestoreFromFile(
+ entry.SnapshotPathWithoutExtension,
+ RestoreOptions.DevJump,
+ value =>
{
- SetStatus($"Reset timed out: {entry.Label}", 8f);
- yield break;
- }
- }
+ result = value;
+ completed = true;
+ }));
- SetStatus($"Loading: {entry.Label}");
- yield return SaveRestoreOrchestrator.RestoreFromFile(entry.SnapshotPathWithoutExtension);
-
- if (!ValidateRestoredRuntime(snapshot, out var restoreError))
+ const float timeoutSeconds = 60f;
+ var elapsed = 0f;
+ while (!completed && elapsed < timeoutSeconds)
{
- SetStatus($"Restore failed: {restoreError}", 8f);
- yield break;
+ elapsed += Time.unscaledDeltaTime;
+ yield return null;
}
- SetStatus($"Loaded: {entry.Label}", 3f);
- }
- finally
- {
- isJumping = false;
- }
- }
-
- private static bool NeedsResetToMainMenu()
- {
- var gameManager = GameManager.Instance;
- if (gameManager == null)
- return false;
-
- if (gameManager.state.isInGame)
- return true;
-
- var sceneLoader = SceneLoader.Instance;
- return sceneLoader != null && !string.IsNullOrEmpty(sceneLoader.CurrentSceneName);
- }
-
- private static IEnumerator WaitForMainMenuReady(Action onCompleted)
- {
- const float timeoutSeconds = 15f;
- float elapsed = 0f;
-
- while (elapsed < timeoutSeconds)
- {
- var gameManager = GameManager.Instance;
- var sceneLoader = SceneLoader.Instance;
- bool sceneCleared = sceneLoader == null || string.IsNullOrEmpty(sceneLoader.CurrentSceneName);
- bool sceneIdle = sceneLoader == null || !sceneLoader.IsLoading;
- bool notInGame = gameManager == null || !gameManager.state.isInGame;
-
- if (sceneCleared && sceneIdle && notInGame)
+ if (!completed)
{
- onCompleted?.Invoke(true);
- yield break;
+ StopCoroutine(routine);
+ failures.Add($"{entry.Label}: 超过 {timeoutSeconds:0} 秒");
+ break;
+ }
+ if (result?.Success != true)
+ {
+ failures.Add($"{entry.Label}: {result?.FailedPhase ?? "unknown"} - "
+ + string.Join("; ", result?.Errors ?? Array.Empty()));
}
-
- elapsed += Time.unscaledDeltaTime;
- yield return null;
}
- Debug.LogWarning("[DevSaveJumpTool] 等待主界面超时,已取消读档。");
- onCompleted?.Invoke(false);
- }
-
- private static bool ValidateRestoredRuntime(SaveSnapshot snapshot, out string error)
- {
- var sceneLoader = SceneLoader.Instance;
- if (sceneLoader == null || sceneLoader.IsLoading)
- {
- error = "scene loader is not ready";
- return false;
- }
-
- if (!string.Equals(sceneLoader.CurrentSceneName, snapshot.scene?.sceneName, StringComparison.Ordinal))
- {
- error = $"scene mismatch ({sceneLoader.CurrentSceneName ?? "none"})";
- return false;
- }
-
- var runner = DialogController.Instance?.DialogueRunner;
- if (runner?.YarnProject == null
- || !string.Equals(runner.YarnProject.name, snapshot.anchor?.yarnProjectId, StringComparison.Ordinal))
- {
- error = $"YarnProject mismatch ({runner?.YarnProject?.name ?? "none"})";
- return false;
- }
-
- if (snapshot.sections != null
- && snapshot.sections.ContainsKey(SnapshotProviderIds.Fix)
- && (FixSystem.FixSystemCenter.Instance == null
- || !FixSystem.FixSystemCenter.Instance.IsDirectorReady))
- {
- error = "FixSystem is not ready";
- return false;
- }
-
- error = null;
- return true;
+ status = failures.Count == 0
+ ? $"冒烟测试通过:{entries.Count}/{entries.Count}"
+ : $"冒烟测试失败:{failures.Count} 项";
+ details = failures.Count == 0 ? string.Empty : string.Join("\n", failures);
+ isJumping = false;
}
private void ReloadCatalog()
{
sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath);
- cachedContentHeight = 0f;
- }
-
- private void SetStatus(string message, float visibleSeconds = 30f)
- {
- status = message;
- statusVisibleUntil = Time.unscaledTime + visibleSeconds;
- Debug.Log($"[DevSaveJumpTool] {message}");
+ status = sections.Count == 0
+ ? "未读取到可用清单"
+ : $"已扫描 {sections.Sum(s => s.Entries.Count)} 个阶段,{sections.Sum(s => s.Entries.Count(e => !e.IsValid))} 个不可用";
+ details = DevSaveCatalog.LastError ?? string.Empty;
}
private void EnsureStyles()
{
- if (buttonStyle != null && statusStyle != null)
- return;
-
- buttonStyle = new GUIStyle(GUI.skin.button)
- {
- fontSize = 14,
- alignment = TextAnchor.MiddleCenter
- };
-
- headerStyle = new GUIStyle(GUI.skin.label)
- {
- fontSize = 13,
- fontStyle = FontStyle.Bold,
- alignment = TextAnchor.MiddleLeft,
- padding = new RectOffset(6, 0, 3, 0)
- };
-
- menuBoxStyle = new GUIStyle(GUI.skin.box)
- {
- padding = new RectOffset(6, 6, 6, 6)
- };
-
- statusStyle = new GUIStyle(GUI.skin.box)
- {
- fontSize = 13,
- alignment = TextAnchor.MiddleLeft,
- padding = new RectOffset(8, 8, 4, 4)
- };
+ invalidStyle ??= new GUIStyle(GUI.skin.label) { wordWrap = true, fontSize = 11 };
+ invalidStyle.normal.textColor = new Color(1f, 0.55f, 0.45f);
+ statusStyle ??= new GUIStyle(GUI.skin.box) { wordWrap = true, alignment = TextAnchor.MiddleLeft };
}
}
- internal static class DevSaveCatalog
+ public static class DevSaveCatalog
{
+ public const int CurrentVersion = 1;
private const string CatalogFileName = "catalog.json";
+ public static string LastError { get; private set; }
public static List Load(string root)
{
+ LastError = null;
var result = new List();
- if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
- return result;
-
- var catalogPath = Path.Combine(root, CatalogFileName);
- if (File.Exists(catalogPath))
+ var catalogPath = Path.Combine(root ?? string.Empty, CatalogFileName);
+ if (string.IsNullOrWhiteSpace(root) || !File.Exists(catalogPath))
{
- try
- {
- var catalog = JsonConvert.DeserializeObject(File.ReadAllText(catalogPath));
- if (catalog?.sections != null)
- {
- foreach (var sectionDto in catalog.sections)
- {
- if (string.IsNullOrWhiteSpace(sectionDto.id))
- continue;
-
- var sectionDir = Path.Combine(root, sectionDto.id);
- var title = string.IsNullOrWhiteSpace(sectionDto.title) ? sectionDto.id : sectionDto.title;
- result.Add(BuildSection(
- sectionDto.id,
- title,
- sectionDir,
- sectionDto.entries,
- sectionDto.expectedSceneName,
- sectionDto.expectedYarnProjectId,
- sectionDto.allowedNodes));
- }
-
- return result;
- }
- }
- catch (Exception ex)
- {
- Debug.LogWarning($"[DevSaveJumpTool] 读取 catalog.json 失败: {ex.Message}");
- }
+ LastError = $"找不到测试存档清单:{catalogPath}";
+ return result;
}
- foreach (var dir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
+ DevSaveCatalogDto catalog;
+ try
{
- var id = Path.GetFileName(dir);
- result.Add(BuildSection(id, id, dir, null, null, null, null));
+ catalog = JsonConvert.DeserializeObject(File.ReadAllText(catalogPath));
+ }
+ catch (Exception ex)
+ {
+ LastError = $"清单 JSON 损坏:{ex.Message}";
+ return result;
+ }
+
+ if (catalog == null || catalog.catalogVersion != CurrentVersion)
+ {
+ LastError = $"不支持的清单版本:{catalog?.catalogVersion.ToString() ?? "none"}";
+ return result;
+ }
+
+ foreach (var sectionDto in catalog.sections ?? new List())
+ {
+ if (string.IsNullOrWhiteSpace(sectionDto.id)) continue;
+ var entries = (sectionDto.entries ?? new List())
+ .OrderBy(entry => entry.order)
+ .Select(entry => BuildEntry(root, sectionDto, entry))
+ .ToList();
+ result.Add(new DevSaveSection(sectionDto.id,
+ string.IsNullOrWhiteSpace(sectionDto.title) ? sectionDto.id : sectionDto.title,
+ entries));
}
return result;
}
- private static DevSaveSection BuildSection(
- string id,
- string title,
- string sectionDir,
- List explicitEntries,
- string expectedSceneName,
- string expectedYarnProjectId,
- List allowedNodes)
+ private static DevSaveEntry BuildEntry(
+ string root,
+ DevSaveCatalogSectionDto section,
+ DevSaveCatalogEntryDto dto)
{
- var entries = new List();
- var root = Directory.GetParent(sectionDir)?.FullName;
+ var error = ValidateRelativePath(root, dto.path, out var entryDirectory);
+ var snapshotPath = error == null
+ ? Path.Combine(entryDirectory, ConstRef.SaveSnapshotFileName)
+ : string.Empty;
+ var entry = new DevSaveEntry(
+ dto.order,
+ string.IsNullOrWhiteSpace(dto.label) ? dto.anchorNode : dto.label,
+ dto.anchorNode,
+ snapshotPath,
+ section.expectedSceneName,
+ section.expectedSceneSoName,
+ section.expectedYarnProjectId,
+ error);
+ entry.Validate();
+ return entry;
+ }
- if (explicitEntries != null && explicitEntries.Count > 0 && root != null)
+ private static string ValidateRelativePath(string root, string relativePath, out string fullPath)
+ {
+ fullPath = null;
+ if (string.IsNullOrWhiteSpace(relativePath)) return "清单路径为空";
+ if (Path.IsPathRooted(relativePath)) return "清单路径必须是相对路径";
+ try
{
- var latestTestArchives = BuildLatestTestArchiveIndex(
- expectedSceneName,
- expectedYarnProjectId,
- allowedNodes);
-
- foreach (var entryDto in explicitEntries)
- {
- if (string.IsNullOrWhiteSpace(entryDto.path))
- continue;
-
- var relative = entryDto.path.Replace('/', Path.DirectorySeparatorChar);
- var entryDir = Path.IsPathRooted(relative)
- ? relative
- : Path.Combine(root, relative);
- var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName);
- var entry = new DevSaveEntry(
- label: string.IsNullOrWhiteSpace(entryDto.label)
- ? FormatFolderLabel(Path.GetFileName(entryDir))
- : entryDto.label,
- snapshotPathWithoutExtension: snapshot,
- expectedSceneName,
- expectedYarnProjectId,
- allowedNodes);
- if (!entry.TryLoadSnapshot(out _, out var error))
- {
- if (error == "file missing"
- && !string.IsNullOrWhiteSpace(entryDto.anchorNode)
- && latestTestArchives.TryGetValue(entryDto.anchorNode, out var archiveSnapshot))
- {
- entry = new DevSaveEntry(
- label: string.IsNullOrWhiteSpace(entryDto.label)
- ? entryDto.anchorNode
- : entryDto.label,
- snapshotPathWithoutExtension: archiveSnapshot,
- expectedSceneName,
- expectedYarnProjectId,
- allowedNodes);
-
- if (entry.TryLoadSnapshot(out _, out _))
- {
- entries.Add(entry);
- continue;
- }
- }
-
- Debug.LogWarning($"[DevSaveJumpTool] 跳过无效存档 {entryDto.path}: {error}");
- continue;
- }
-
- entries.Add(entry);
- }
+ var rootFull = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
+ fullPath = Path.GetFullPath(Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)));
+ if (!fullPath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
+ return "清单路径越过 TestSaveFiles 根目录";
+ return null;
}
- else if (Directory.Exists(sectionDir))
+ catch (Exception ex)
{
- foreach (var entryDir in Directory.GetDirectories(sectionDir)
- .OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
- {
- var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName);
- var entry = new DevSaveEntry(
- FormatFolderLabel(Path.GetFileName(entryDir)),
- snapshot,
- expectedSceneName,
- expectedYarnProjectId,
- allowedNodes);
- if (!entry.TryLoadSnapshot(out _, out var error))
- {
- Debug.LogWarning($"[DevSaveJumpTool] 跳过无效存档 {entryDir}: {error}");
- continue;
- }
-
- entries.Add(entry);
- }
+ return $"清单路径无效:{ex.Message}";
}
-
- return new DevSaveSection(id, title, entries);
- }
-
- private static Dictionary BuildLatestTestArchiveIndex(
- string expectedSceneName,
- string expectedYarnProjectId,
- List allowedNodes)
- {
- var result = new Dictionary(StringComparer.Ordinal);
- var savedAtByNode = new Dictionary(StringComparer.Ordinal);
- var archiveRoot = ConstRef.TestAutoSaveArchivePath;
- if (!Directory.Exists(archiveRoot) || allowedNodes == null || allowedNodes.Count == 0)
- return result;
-
- foreach (var archiveDir in Directory.GetDirectories(archiveRoot))
- {
- var snapshotPath = Path.Combine(archiveDir, ConstRef.SaveSnapshotFileName);
- var candidate = new DevSaveEntry(
- string.Empty,
- snapshotPath,
- expectedSceneName,
- expectedYarnProjectId,
- allowedNodes);
- if (!candidate.TryLoadSnapshot(out var snapshot, out _))
- continue;
-
- var nodeName = snapshot.anchor.nodeName;
- var savedAt = snapshot.savedAt ?? string.Empty;
- if (savedAtByNode.TryGetValue(nodeName, out var currentSavedAt)
- && string.CompareOrdinal(savedAt, currentSavedAt) <= 0)
- {
- continue;
- }
-
- savedAtByNode[nodeName] = savedAt;
- result[nodeName] = snapshotPath;
- }
-
- return result;
- }
-
- private static string FormatFolderLabel(string folderName)
- {
- if (string.IsNullOrEmpty(folderName))
- return folderName;
-
- return folderName.Replace('_', ' ');
- }
-
- [Serializable]
- private class CatalogDto
- {
- public List sections;
- }
-
- [Serializable]
- private class CatalogSectionDto
- {
- public string id;
- public string title;
- public List entries;
- public string expectedSceneName;
- public string expectedYarnProjectId;
- public List allowedNodes;
- }
-
- [Serializable]
- private class CatalogEntryDto
- {
- public string label;
- public string path;
- public string anchorNode;
}
}
- internal sealed class DevSaveSection
+ public sealed class DevSaveSection
{
- public readonly string Id;
- public readonly string Title;
- public readonly List Entries;
-
+ public string Id { get; }
+ public string Title { get; }
+ public List Entries { get; }
public DevSaveSection(string id, string title, List entries)
{
Id = id;
@@ -605,100 +339,103 @@ namespace AibisDream
}
}
- internal readonly struct DevSaveEntry
+ public sealed class DevSaveEntry
{
- public readonly string Label;
- public readonly string SnapshotPathWithoutExtension;
- private readonly string expectedSceneName;
- private readonly string expectedYarnProjectId;
- private readonly List allowedNodes;
+ public int Order { get; }
+ public string Label { get; }
+ public string AnchorNode { get; }
+ public string SnapshotPathWithoutExtension { get; }
+ public bool IsValid => string.IsNullOrEmpty(Error);
+ public string Error { get; private set; }
+ public SaveSnapshot Snapshot { get; private set; }
- public DevSaveEntry(
- string label,
- string snapshotPathWithoutExtension,
- string expectedSceneName,
- string expectedYarnProjectId,
- List allowedNodes)
+ private readonly string expectedSceneName;
+ private readonly string expectedSceneSoName;
+ private readonly string expectedYarnProjectId;
+
+ public DevSaveEntry(int order, string label, string anchorNode, string snapshotPathWithoutExtension,
+ string expectedSceneName, string expectedSceneSoName, string expectedYarnProjectId, string initialError = null)
{
- Label = label;
+ Order = order;
+ Label = label ?? string.Empty;
+ AnchorNode = anchorNode ?? string.Empty;
SnapshotPathWithoutExtension = snapshotPathWithoutExtension;
this.expectedSceneName = expectedSceneName;
+ this.expectedSceneSoName = expectedSceneSoName;
this.expectedYarnProjectId = expectedYarnProjectId;
- this.allowedNodes = allowedNodes;
+ Error = initialError;
}
- public bool TryLoadSnapshot(out SaveSnapshot snapshot, out string error)
+ public void Validate()
{
- snapshot = null;
- error = null;
-
- if (string.IsNullOrEmpty(SnapshotPathWithoutExtension)
- || !File.Exists(SnapshotPathWithoutExtension + ".json"))
+ if (!string.IsNullOrEmpty(Error)) return;
+ if (string.IsNullOrEmpty(SnapshotPathWithoutExtension) || !File.Exists(SnapshotPathWithoutExtension + ".json"))
{
- error = "file missing";
- return false;
+ Error = "存档文件缺失";
+ return;
}
try
{
if (SnapshotPersistence.IsLegacyFormat(SnapshotPathWithoutExtension))
{
- error = "legacy format is not supported";
- return false;
+ Error = "不支持旧格式存档";
+ return;
}
-
- snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension);
+ Snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension);
}
catch (Exception ex)
{
- error = $"invalid JSON ({ex.Message})";
- return false;
+ Error = $"JSON 无法读取:{ex.Message}";
+ return;
}
- if (snapshot == null || snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion)
- {
- error = $"unsupported schema ({snapshot?.schemaVersion.ToString() ?? "none"})";
- return false;
- }
-
- if (snapshot.scene == null || string.IsNullOrWhiteSpace(snapshot.scene.sceneName))
- {
- error = "scene is missing";
- return false;
- }
-
- if (snapshot.anchor == null
- || string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId)
- || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))
- {
- error = "Yarn anchor is missing";
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(expectedSceneName)
- && !string.Equals(snapshot.scene.sceneName, expectedSceneName, StringComparison.Ordinal))
- {
- error = $"scene must be {expectedSceneName}";
- return false;
- }
-
- if (!string.IsNullOrWhiteSpace(expectedYarnProjectId)
- && !string.Equals(snapshot.anchor.yarnProjectId, expectedYarnProjectId, StringComparison.Ordinal))
- {
- error = $"YarnProject must be {expectedYarnProjectId}";
- return false;
- }
-
- if (allowedNodes != null
- && allowedNodes.Count > 0
- && !allowedNodes.Contains(snapshot.anchor.nodeName, StringComparer.Ordinal))
- {
- error = $"node is not allowed ({snapshot.anchor.nodeName})";
- return false;
- }
-
- return true;
+ if (Snapshot == null || Snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion)
+ Error = $"存档版本不兼容:{Snapshot?.schemaVersion.ToString() ?? "none"}";
+ else if (Snapshot.scene == null || string.IsNullOrWhiteSpace(Snapshot.scene.sceneName))
+ Error = "存档缺少场景";
+ else if (Snapshot.anchor == null || string.IsNullOrWhiteSpace(Snapshot.anchor.nodeName))
+ Error = "存档缺少 Yarn 锚点";
+ else if (!Matches(expectedSceneName, Snapshot.scene.sceneName))
+ Error = $"场景不匹配:{Snapshot.scene.sceneName}";
+ else if (!Matches(expectedSceneSoName, Snapshot.anchor.sceneSoName))
+ Error = $"TalkSceneSO 不匹配:{Snapshot.anchor.sceneSoName}";
+ else if (!Matches(expectedYarnProjectId, Snapshot.anchor.yarnProjectId))
+ Error = $"YarnProject 不匹配:{Snapshot.anchor.yarnProjectId}";
+ else if (!string.IsNullOrWhiteSpace(AnchorNode)
+ && !string.Equals(AnchorNode, Snapshot.anchor.nodeName, StringComparison.Ordinal))
+ Error = $"节点不匹配:{Snapshot.anchor.nodeName}";
}
+
+ private static bool Matches(string expected, string actual) =>
+ string.IsNullOrWhiteSpace(expected) || string.Equals(expected, actual, StringComparison.Ordinal);
+ }
+
+ [Serializable]
+ public sealed class DevSaveCatalogDto
+ {
+ public int catalogVersion = DevSaveCatalog.CurrentVersion;
+ public List sections = new();
+ }
+
+ [Serializable]
+ public sealed class DevSaveCatalogSectionDto
+ {
+ public string id;
+ public string title;
+ public string expectedSceneName;
+ public string expectedSceneSoName;
+ public string expectedYarnProjectId;
+ public List entries = new();
+ }
+
+ [Serializable]
+ public sealed class DevSaveCatalogEntryDto
+ {
+ public int order;
+ public string label;
+ public string path;
+ public string anchorNode;
}
}
#endif