#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