feat(save): 增强开发存档跳转与严格校验
This commit is contained in:
@@ -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(
|
||||
"从本机 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<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
|
||||
|
||||
@@ -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 未关闭、
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user