#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 { /// 从项目外置的 DevSaveFiles 清单中执行严格、可诊断的开发跳转。 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 expanded = new(StringComparer.Ordinal); private List 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(); if (instance == null) { var go = new GameObject("[Dev] Save Jump Tool"); instance = go.AddComponent(); } 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] = false; 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 || entry.LaunchNode.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.PrepareRestorePath(), 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(); 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(); 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.PrepareRestorePath(), 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())); } } 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 Load(string root) { LastError = null; var result = new List(); 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(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()) .OrderBy(section => section.order)) { 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 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, dto.launchNode, 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 "清单路径越过 DevSaveFiles 根目录"; return null; } catch (Exception ex) { return $"清单路径无效:{ex.Message}"; } } } public sealed class DevSaveSection { public string Id { get; } public string Title { get; } public List Entries { get; } public DevSaveSection(string id, string title, List entries) { Id = id; Title = title; Entries = entries ?? new List(); } } public sealed class DevSaveEntry { public int Order { get; } public string Label { get; } public string AnchorNode { get; } public string LaunchNode { get; } public string DestinationNode => string.IsNullOrWhiteSpace(LaunchNode) ? AnchorNode : LaunchNode; 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 launchNode, string snapshotPathWithoutExtension, string expectedSceneName, string expectedSceneSoName, string expectedYarnProjectId, string initialError = null) { Order = order; Label = label ?? string.Empty; AnchorNode = anchorNode ?? string.Empty; LaunchNode = launchNode ?? string.Empty; SnapshotPathWithoutExtension = snapshotPathWithoutExtension; this.expectedSceneName = expectedSceneName; this.expectedSceneSoName = expectedSceneSoName; this.expectedYarnProjectId = expectedYarnProjectId; Error = initialError; } public string PrepareRestorePath() { if (string.IsNullOrWhiteSpace(LaunchNode) || Snapshot?.anchor == null) return SnapshotPathWithoutExtension; var launchSnapshot = SnapshotPersistence.Deserialize(SnapshotPersistence.Serialize(Snapshot)); launchSnapshot.anchor.nodeName = LaunchNode; var directory = Path.Combine(Application.temporaryCachePath, "DevSaveJump"); var safeLabel = string.Concat(Label.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch)); var path = Path.Combine(directory, $"launch_{Order:000}_{safeLabel}"); SnapshotPersistence.Save(launchSnapshot, path); return path; } 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 sections = new(); } [Serializable] public sealed class DevSaveCatalogSectionDto { public int order; 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; public string launchNode; } } #endif