#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 — promote saves first)", 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; string savePath = entry.SnapshotPathWithoutExtension; if (string.IsNullOrEmpty(savePath) || !File.Exists(savePath + ".json")) { SetStatus($"Save missing: {entry.Label}", 5f); yield break; } isJumping = true; if (NeedsResetToMainMenu()) { SetStatus($"Resetting: {entry.Label}"); GameManager.Instance.QuitGame(); yield return WaitForMainMenuReady(); } SetStatus($"Loading: {entry.Label}"); yield return SaveRestoreOrchestrator.RestoreFromFile(savePath); SetStatus($"Loaded: {entry.Label}", 3f); 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() { 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 notInGame = gameManager == null || !gameManager.state.isInGame; if (sceneCleared && notInGame) yield break; elapsed += Time.unscaledDeltaTime; yield return null; } Debug.LogWarning("[DevSaveJumpTool] 等待主界面超时,继续读档。"); } 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)); } 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)); } return result; } private static DevSaveSection BuildSection( string id, string title, string sectionDir, List explicitEntries) { var entries = new List(); var root = Directory.GetParent(sectionDir)?.FullName; if (explicitEntries != null && explicitEntries.Count > 0 && root != null) { 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); if (!File.Exists(snapshot + ".json")) continue; var label = string.IsNullOrWhiteSpace(entryDto.label) ? FormatFolderLabel(Path.GetFileName(entryDir)) : entryDto.label; entries.Add(new DevSaveEntry(label, snapshot)); } } else if (Directory.Exists(sectionDir)) { foreach (var entryDir in Directory.GetDirectories(sectionDir) .OrderBy(d => d, StringComparer.OrdinalIgnoreCase)) { var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName); if (!File.Exists(snapshot + ".json")) continue; entries.Add(new DevSaveEntry(FormatFolderLabel(Path.GetFileName(entryDir)), snapshot)); } } return new DevSaveSection(id, title, entries); } 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; } [Serializable] private class CatalogEntryDto { public string label; public string path; } } 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; public DevSaveEntry(string label, string snapshotPathWithoutExtension) { Label = label; SnapshotPathWithoutExtension = snapshotPathWithoutExtension; } } } #endif