488 lines
19 KiB
C#
488 lines
19 KiB
C#
#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>从项目外置的 DevSaveFiles 清单中执行严格、可诊断的开发跳转。</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] = 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<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.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<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>())
|
||
.OrderBy(section => section.order))
|
||
{
|
||
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,
|
||
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<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 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<DevSaveCatalogSectionDto> 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<DevSaveCatalogEntryDto> entries = new();
|
||
}
|
||
|
||
[Serializable]
|
||
public sealed class DevSaveCatalogEntryDto
|
||
{
|
||
public int order;
|
||
public string label;
|
||
public string path;
|
||
public string anchorNode;
|
||
public string launchNode;
|
||
}
|
||
}
|
||
#endif
|