#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 { /// /// 开发用通用跳转工具:从 (可提交)读档。 /// 目录约定:StreamingAssets/TestSaveFiles/{SectionId}/{EntryFolder}/snapshot.json /// public 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 static DevSaveJumpTool instance; 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 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) { instance = existing; DontDestroyOnLoad(existing.gameObject); return; } var toolObject = new GameObject("[Dev] Save Jump Tool"); DontDestroyOnLoad(toolObject); instance = toolObject.AddComponent(); } 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); if (menuOpen) DrawCommandMenu(devButtonRect); string label = isJumping ? "Busy..." : "Dev"; if (GUI.Button(devButtonRect, label, buttonStyle)) { if (!menuOpen) ReloadCatalog(); 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 IEnumerator LoadDevSave(DevSaveEntry entry) { if (isJumping) yield break; if (!entry.TryLoadSnapshot(out var snapshot, out var validationError)) { SetStatus($"Invalid save: {validationError}", 8f); yield break; } isJumping = true; try { if (NeedsResetToMainMenu()) { SetStatus($"Resetting: {entry.Label}"); GameManager.Instance.QuitGame(); bool mainMenuReady = false; yield return WaitForMainMenuReady(result => mainMenuReady = result); if (!mainMenuReady) { SetStatus($"Reset timed out: {entry.Label}", 8f); yield break; } } SetStatus($"Loading: {entry.Label}"); yield return SaveRestoreOrchestrator.RestoreFromFile(entry.SnapshotPathWithoutExtension); if (!ValidateRestoredRuntime(snapshot, out var restoreError)) { SetStatus($"Restore failed: {restoreError}", 8f); yield break; } 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) { onCompleted?.Invoke(true); yield break; } 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; } 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}"); } 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) }; } } internal static class DevSaveCatalog { private const string CatalogFileName = "catalog.json"; public static List Load(string root) { var result = new List(); if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) return result; var catalogPath = Path.Combine(root, CatalogFileName); if (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}"); } } foreach (var dir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase)) { var id = Path.GetFileName(dir); result.Add(BuildSection(id, id, dir, null, null, null, null)); } return result; } private static DevSaveSection BuildSection( string id, string title, string sectionDir, List explicitEntries, string expectedSceneName, string expectedYarnProjectId, List allowedNodes) { var entries = new List(); var root = Directory.GetParent(sectionDir)?.FullName; if (explicitEntries != null && explicitEntries.Count > 0 && root != null) { 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); } } else if (Directory.Exists(sectionDir)) { 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 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 readonly string Id; public readonly string Title; public readonly List Entries; public DevSaveSection(string id, string title, List entries) { Id = id; Title = title; Entries = entries ?? new List(); } } internal readonly struct DevSaveEntry { public readonly string Label; public readonly string SnapshotPathWithoutExtension; private readonly string expectedSceneName; private readonly string expectedYarnProjectId; private readonly List allowedNodes; public DevSaveEntry( string label, string snapshotPathWithoutExtension, string expectedSceneName, string expectedYarnProjectId, List allowedNodes) { Label = label; SnapshotPathWithoutExtension = snapshotPathWithoutExtension; this.expectedSceneName = expectedSceneName; this.expectedYarnProjectId = expectedYarnProjectId; this.allowedNodes = allowedNodes; } public bool TryLoadSnapshot(out SaveSnapshot snapshot, out string error) { snapshot = null; error = null; if (string.IsNullOrEmpty(SnapshotPathWithoutExtension) || !File.Exists(SnapshotPathWithoutExtension + ".json")) { error = "file missing"; return false; } try { if (SnapshotPersistence.IsLegacyFormat(SnapshotPathWithoutExtension)) { error = "legacy format is not supported"; return false; } snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension); } catch (Exception ex) { error = $"invalid JSON ({ex.Message})"; return false; } 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; } } } #endif