Files
aibis-dream/Assets/Scripts/Utility/DevSaveJumpTool.cs
T

460 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AibisDream.SaveSystem;
using AibisDream.Utility;
using Newtonsoft.Json;
using UnityEngine;
namespace AibisDream
{
/// <summary>从随项目提交的 TestSaveFiles 清单中执行严格、可诊断的开发跳转。</summary>
public sealed class DevSaveJumpTool : MonoBehaviour
{
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<string, bool> expanded = new(StringComparer.Ordinal);
private List<DevSaveSection> sections = new();
private Vector2 scroll;
private bool isJumping;
private bool menuOpen;
private string search = string.Empty;
private string status = string.Empty;
private string details = string.Empty;
private GUIStyle invalidStyle;
private GUIStyle statusStyle;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Bootstrap()
{
if (instance != null) return;
instance = FindObjectOfType<DevSaveJumpTool>();
if (instance == null)
{
var go = new GameObject("[Dev] Save Jump Tool");
instance = go.AddComponent<DevSaveJumpTool>();
}
DontDestroyOnLoad(instance.gameObject);
}
private void Awake() => ReloadCatalog();
private void OnGUI()
{
EnsureStyles();
var buttonRect = new Rect(
Screen.width - Margin - ButtonWidth,
Screen.height - Margin - ButtonHeight,
ButtonWidth,
ButtonHeight);
if (menuOpen)
{
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;
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 || !entry.IsValid) yield break;
isJumping = true;
status = $"正在跳转:{entry.Label}";
details = string.Empty;
RestoreResult result = null;
var completed = false;
var accepted = GameManager.Instance.TryRestoreFile(
entry.SnapshotPathWithoutExtension,
RestoreOptions.DevJump,
value =>
{
result = value;
completed = true;
});
if (!accepted)
{
status = $"跳转失败:{entry.Label}(当前存在其他会话命令)";
isJumping = false;
yield break;
}
while (!completed)
{
yield return null;
}
if (result?.Success == true)
{
status = $"已到达:{entry.Label}";
details = result.Warnings.Count == 0 ? string.Empty : string.Join("\n", result.Warnings);
menuOpen = false;
}
else
{
var errors = result?.Errors ?? Array.Empty<string>();
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;
var failures = new List<string>();
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
status = $"冒烟测试 {i + 1}/{entries.Count}{entry.Label}";
RestoreResult result = null;
var completed = false;
var accepted = GameManager.Instance.TryRestoreFile(
entry.SnapshotPathWithoutExtension,
RestoreOptions.DevJump,
value =>
{
result = value;
completed = true;
});
if (!accepted)
{
failures.Add($"{entry.Label}: 当前存在其他会话命令");
break;
}
const float timeoutSeconds = 60f;
var elapsed = 0f;
while (!completed && elapsed < timeoutSeconds)
{
elapsed += Time.unscaledDeltaTime;
yield return null;
}
if (!completed)
{
GameManager.Instance.TryReturnToMainMenu();
failures.Add($"{entry.Label}: 超过 {timeoutSeconds:0} 秒");
break;
}
if (result?.Success != true)
{
failures.Add($"{entry.Label}: {result?.FailedPhase ?? "unknown"} - "
+ string.Join("; ", result?.Errors ?? Array.Empty<string>()));
}
}
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);
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()
{
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 };
}
}
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<DevSaveSection> Load(string root)
{
LastError = null;
var result = new List<DevSaveSection>();
var catalogPath = Path.Combine(root ?? string.Empty, CatalogFileName);
if (string.IsNullOrWhiteSpace(root) || !File.Exists(catalogPath))
{
LastError = $"找不到测试存档清单:{catalogPath}";
return result;
}
DevSaveCatalogDto catalog;
try
{
catalog = JsonConvert.DeserializeObject<DevSaveCatalogDto>(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<DevSaveCatalogSectionDto>())
{
if (string.IsNullOrWhiteSpace(sectionDto.id)) continue;
var entries = (sectionDto.entries ?? new List<DevSaveCatalogEntryDto>())
.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 DevSaveEntry BuildEntry(
string root,
DevSaveCatalogSectionDto section,
DevSaveCatalogEntryDto dto)
{
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;
}
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 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;
}
catch (Exception ex)
{
return $"清单路径无效:{ex.Message}";
}
}
}
public sealed class DevSaveSection
{
public string Id { get; }
public string Title { get; }
public List<DevSaveEntry> Entries { get; }
public DevSaveSection(string id, string title, List<DevSaveEntry> entries)
{
Id = id;
Title = title;
Entries = entries ?? new List<DevSaveEntry>();
}
}
public sealed class DevSaveEntry
{
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; }
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)
{
Order = order;
Label = label ?? string.Empty;
AnchorNode = anchorNode ?? string.Empty;
SnapshotPathWithoutExtension = snapshotPathWithoutExtension;
this.expectedSceneName = expectedSceneName;
this.expectedSceneSoName = expectedSceneSoName;
this.expectedYarnProjectId = expectedYarnProjectId;
Error = initialError;
}
public void Validate()
{
if (!string.IsNullOrEmpty(Error)) return;
if (string.IsNullOrEmpty(SnapshotPathWithoutExtension) || !File.Exists(SnapshotPathWithoutExtension + ".json"))
{
Error = "存档文件缺失";
return;
}
try
{
Snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension);
}
catch (Exception ex)
{
Error = $"JSON 无法读取:{ex.Message}";
return;
}
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<DevSaveCatalogSectionDto> sections = new();
}
[Serializable]
public sealed class DevSaveCatalogSectionDto
{
public string id;
public string title;
public string expectedSceneName;
public string expectedSceneSoName;
public string expectedYarnProjectId;
public List<DevSaveCatalogEntryDto> entries = new();
}
[Serializable]
public sealed class DevSaveCatalogEntryDto
{
public int order;
public string label;
public string path;
public string anchorNode;
}
}
#endif