#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using AibisDream.SaveSystem; using AibisDream.Utility; using Newtonsoft.Json; using UnityEditor; using UnityEngine; namespace AibisDream.EditorTools { /// /// 将本机 testsavs 归档提升到 StreamingAssets/TestSaveFiles,便于提交与跨机器复用。 /// public sealed class DevSavePromoteWindow : EditorWindow { private string sectionId = "Huoshan1"; private string yarnProjectFilter = "FP_Huoshan1"; private string sceneFilter = "Scene/HuoShanFixScene"; private bool matchYarn = true; private bool matchScene = true; private bool keepLatestPerNode = true; private bool clearSectionFirst; private Vector2 scroll; private string status = ""; private List preview = new(); [MenuItem("Tools/Aibis/Dev Save Promote")] public static void Open() { var window = GetWindow("Dev Save Promote"); window.minSize = new Vector2(520, 420); window.Show(); } private void OnGUI() { EditorGUILayout.LabelField("Promote testsavs → StreamingAssets/TestSaveFiles", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "从本机 TestAutoSaveArchive(testsavs)筛选存档,复制到可提交目录。\n" + $"目标: {ConstRef.TestSaveFilePath}", MessageType.Info); sectionId = EditorGUILayout.TextField("Section Id", sectionId); matchYarn = EditorGUILayout.Toggle("Filter by YarnProject", matchYarn); using (new EditorGUI.DisabledScope(!matchYarn)) yarnProjectFilter = EditorGUILayout.TextField("YarnProject Id", yarnProjectFilter); matchScene = EditorGUILayout.Toggle("Filter by Scene", matchScene); using (new EditorGUI.DisabledScope(!matchScene)) sceneFilter = EditorGUILayout.TextField("Scene Name", sceneFilter); keepLatestPerNode = EditorGUILayout.Toggle("Keep latest per nodeName", keepLatestPerNode); clearSectionFirst = EditorGUILayout.Toggle("Clear section folder first", clearSectionFirst); EditorGUILayout.Space(8); using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Refresh Preview", GUILayout.Height(28))) RefreshPreview(); if (GUILayout.Button("Promote Selected Filters", GUILayout.Height(28))) Promote(); } if (!string.IsNullOrEmpty(status)) EditorGUILayout.HelpBox(status, MessageType.None); EditorGUILayout.Space(4); EditorGUILayout.LabelField($"Matches: {preview.Count}", EditorStyles.miniBoldLabel); scroll = EditorGUILayout.BeginScrollView(scroll); foreach (var item in preview) { EditorGUILayout.LabelField( $"{item.FolderName} | node={item.NodeName} | yarn={item.YarnProjectId} | scene={item.SceneName}"); } EditorGUILayout.EndScrollView(); } private void RefreshPreview() { preview = CollectCandidates(); status = $"Preview {preview.Count} entries from {ConstRef.TestAutoSaveArchivePath}"; } private void Promote() { if (string.IsNullOrWhiteSpace(sectionId)) { status = "Section Id 不能为空。"; return; } var candidates = CollectCandidates(); if (candidates.Count == 0) { status = "没有匹配的 testsavs 条目。先在 Editor 里打通流程(测试存档模式会写入 testsavs)。"; preview = candidates; return; } var sectionDir = Path.Combine(ConstRef.TestSaveFilePath, sectionId.Trim()); if (clearSectionFirst && Directory.Exists(sectionDir)) Directory.Delete(sectionDir, recursive: true); Directory.CreateDirectory(sectionDir); EnsureCatalogSection(sectionId.Trim()); int copied = 0; int index = 1; foreach (var item in candidates.OrderBy(c => c.SavedAt, StringComparer.Ordinal) .ThenBy(c => c.NodeName, StringComparer.Ordinal)) { var safeNode = SanitizeFolderName(string.IsNullOrEmpty(item.NodeName) ? "no_node" : item.NodeName); var folderName = $"{index:00}_{safeNode}"; var destDir = Path.Combine(sectionDir, folderName); Directory.CreateDirectory(destDir); CopyIfExists(item.SnapshotPath, Path.Combine(destDir, $"{ConstRef.SaveSnapshotFileName}.json")); CopyIfExists(item.MetaPath, Path.Combine(destDir, $"{ConstRef.SaveMetaFileName}.json")); copied++; index++; } preview = candidates; AssetDatabase.Refresh(); status = $"已复制 {copied} 份到 TestSaveFiles/{sectionId.Trim()}"; } private List CollectCandidates() { var list = new List(); if (!Directory.Exists(ConstRef.TestAutoSaveArchivePath)) return list; foreach (var dir in Directory.GetDirectories(ConstRef.TestAutoSaveArchivePath)) { var snapshotPath = Path.Combine(dir, $"{ConstRef.SaveSnapshotFileName}.json"); if (!File.Exists(snapshotPath)) continue; var metaPath = Path.Combine(dir, $"{ConstRef.SaveMetaFileName}.json"); SlotMeta meta = null; if (File.Exists(metaPath)) { try { meta = JsonConvert.DeserializeObject(File.ReadAllText(metaPath, Encoding.UTF8)); } catch { // ignore broken meta } } var yarn = meta?.yarnProjectId ?? string.Empty; var scene = meta?.sceneName ?? string.Empty; var node = meta?.nodeName ?? string.Empty; var savedAt = meta?.savedAt ?? string.Empty; bool yarnOk = !matchYarn || string.Equals(yarn, yarnProjectFilter, StringComparison.OrdinalIgnoreCase); bool sceneOk = !matchScene || string.Equals(scene, sceneFilter, StringComparison.OrdinalIgnoreCase); if (!yarnOk || !sceneOk) continue; list.Add(new Candidate( Path.GetFileName(dir), dir, snapshotPath, File.Exists(metaPath) ? metaPath : null, node, yarn, scene, savedAt)); } if (!keepLatestPerNode) return list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList(); return list .GroupBy(c => string.IsNullOrEmpty(c.NodeName) ? c.FolderName : c.NodeName, StringComparer.Ordinal) .Select(g => g.OrderByDescending(c => c.SavedAt, StringComparer.Ordinal).First()) .OrderBy(c => c.SavedAt, StringComparer.Ordinal) .ToList(); } private static void EnsureCatalogSection(string sectionId) { var root = ConstRef.TestSaveFilePath; Directory.CreateDirectory(root); var catalogPath = Path.Combine(root, "catalog.json"); CatalogDto catalog; if (File.Exists(catalogPath)) { try { catalog = JsonConvert.DeserializeObject(File.ReadAllText(catalogPath, Encoding.UTF8)) ?? new CatalogDto(); } catch { catalog = new CatalogDto(); } } else { catalog = new CatalogDto(); } catalog.sections ??= new List(); if (catalog.sections.All(s => !string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase))) { catalog.sections.Add(new CatalogSectionDto { id = sectionId, title = sectionId }); File.WriteAllText( catalogPath, JsonConvert.SerializeObject(catalog, Formatting.Indented), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } } private static void CopyIfExists(string src, string dest) { if (string.IsNullOrEmpty(src) || !File.Exists(src)) return; File.Copy(src, dest, overwrite: true); } private static string SanitizeFolderName(string name) { foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_'); name = name.Replace(' ', '_'); if (name.Length > 48) name = name.Substring(0, 48); return string.IsNullOrWhiteSpace(name) ? "no_node" : name; } private sealed class Candidate { public readonly string FolderName; public readonly string DirectoryPath; public readonly string SnapshotPath; public readonly string MetaPath; public readonly string NodeName; public readonly string YarnProjectId; public readonly string SceneName; public readonly string SavedAt; public Candidate( string folderName, string directoryPath, string snapshotPath, string metaPath, string nodeName, string yarnProjectId, string sceneName, string savedAt) { FolderName = folderName; DirectoryPath = directoryPath; SnapshotPath = snapshotPath; MetaPath = metaPath; NodeName = nodeName; YarnProjectId = yarnProjectId; SceneName = sceneName; SavedAt = savedAt; } } [Serializable] private class CatalogDto { public List sections = new(); } [Serializable] private class CatalogSectionDto { public string id; public string title; } } } #endif