379 lines
15 KiB
C#
379 lines
15 KiB
C#
#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
|
||
{
|
||
/// <summary>
|
||
/// 将本机 testsavs 归档提升到 StreamingAssets/TestSaveFiles,便于提交与跨机器复用。
|
||
/// </summary>
|
||
public sealed class DevSavePromoteWindow : EditorWindow
|
||
{
|
||
private const string HuoshanSectionId = "Huoshan1";
|
||
private const string HuoshanSceneName = "Scene/HuoShanFixScene";
|
||
private const string HuoshanYarnProjectId = "FP_Huoshan1";
|
||
|
||
private static readonly (string NodeName, string Label, string FolderName)[] HuoshanCheckpoints =
|
||
{
|
||
("开头对话", "Stage1 开场", "01_Stage1"),
|
||
("Stage2就绪", "Stage2 初检", "02_Stage2"),
|
||
("Stage3就绪", "Stage3 滤波器", "03_Stage3"),
|
||
("Stage4就绪", "Stage4 表达深入", "04_Stage4"),
|
||
("Stage5就绪", "Stage5 中场对话", "05_Stage5"),
|
||
("Stage6就绪", "Stage6 LOG 释放", "06_Stage6"),
|
||
("Stage7就绪", "Stage7 最终检查", "07_Stage7"),
|
||
("结束对话", "Stage8 结束", "08_Stage8"),
|
||
};
|
||
|
||
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<Candidate> preview = new();
|
||
|
||
[MenuItem("Tools/Aibis/Dev Save Promote")]
|
||
public static void Open()
|
||
{
|
||
var window = GetWindow<DevSavePromoteWindow>("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;
|
||
}
|
||
|
||
if (IsHuoshanSection()
|
||
&& HuoshanCheckpoints.Any(checkpoint => candidates.All(c => c.NodeName != checkpoint.NodeName)))
|
||
{
|
||
var missing = HuoshanCheckpoints
|
||
.Where(checkpoint => candidates.All(c => c.NodeName != checkpoint.NodeName))
|
||
.Select(checkpoint => checkpoint.NodeName);
|
||
status = $"Huoshan1 缺少阶段存档:{string.Join(", ", missing)}。请完整跑通流程后再提升。";
|
||
preview = candidates;
|
||
return;
|
||
}
|
||
|
||
var sectionDir = Path.Combine(ConstRef.TestSaveFilePath, sectionId.Trim());
|
||
if ((clearSectionFirst || IsHuoshanSection()) && Directory.Exists(sectionDir))
|
||
Directory.Delete(sectionDir, recursive: true);
|
||
|
||
Directory.CreateDirectory(sectionDir);
|
||
EnsureCatalogSection(sectionId.Trim());
|
||
|
||
int copied = 0;
|
||
int index = 1;
|
||
foreach (var item in OrderCandidates(candidates))
|
||
{
|
||
var safeNode = SanitizeFolderName(string.IsNullOrEmpty(item.NodeName) ? "no_node" : item.NodeName);
|
||
var folderName = IsHuoshanSection()
|
||
? HuoshanCheckpoints.First(checkpoint => checkpoint.NodeName == item.NodeName).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<Candidate> CollectCandidates()
|
||
{
|
||
var list = new List<Candidate>();
|
||
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<SlotMeta>(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 FilterHuoshanCheckpoints(list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList());
|
||
|
||
var latestPerNode = 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();
|
||
return FilterHuoshanCheckpoints(latestPerNode);
|
||
}
|
||
|
||
private bool IsHuoshanSection()
|
||
{
|
||
return string.Equals(sectionId?.Trim(), HuoshanSectionId, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private List<Candidate> FilterHuoshanCheckpoints(List<Candidate> candidates)
|
||
{
|
||
if (!IsHuoshanSection())
|
||
return candidates;
|
||
|
||
var allowedNodes = new HashSet<string>(
|
||
HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName),
|
||
StringComparer.Ordinal);
|
||
return candidates.Where(candidate => allowedNodes.Contains(candidate.NodeName)).ToList();
|
||
}
|
||
|
||
private IEnumerable<Candidate> OrderCandidates(List<Candidate> candidates)
|
||
{
|
||
if (!IsHuoshanSection())
|
||
{
|
||
return candidates.OrderBy(c => c.SavedAt, StringComparer.Ordinal)
|
||
.ThenBy(c => c.NodeName, StringComparer.Ordinal);
|
||
}
|
||
|
||
return HuoshanCheckpoints.Select(checkpoint =>
|
||
candidates.First(candidate => candidate.NodeName == checkpoint.NodeName));
|
||
}
|
||
|
||
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<CatalogDto>(File.ReadAllText(catalogPath, Encoding.UTF8))
|
||
?? new CatalogDto();
|
||
}
|
||
catch
|
||
{
|
||
catalog = new CatalogDto();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
catalog = new CatalogDto();
|
||
}
|
||
|
||
catalog.sections ??= new List<CatalogSectionDto>();
|
||
var section = catalog.sections.FirstOrDefault(
|
||
s => string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase));
|
||
if (section == null)
|
||
{
|
||
section = new CatalogSectionDto
|
||
{
|
||
id = sectionId,
|
||
title = sectionId
|
||
};
|
||
catalog.sections.Add(section);
|
||
}
|
||
|
||
if (string.Equals(sectionId, HuoshanSectionId, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
section.title = "Huoshan 1";
|
||
section.expectedSceneName = HuoshanSceneName;
|
||
section.expectedYarnProjectId = HuoshanYarnProjectId;
|
||
section.allowedNodes = HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName).ToList();
|
||
section.entries = HuoshanCheckpoints.Select(checkpoint => new CatalogEntryDto
|
||
{
|
||
label = checkpoint.Label,
|
||
path = $"{HuoshanSectionId}/{checkpoint.FolderName}",
|
||
anchorNode = checkpoint.NodeName
|
||
}).ToList();
|
||
}
|
||
|
||
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<CatalogSectionDto> sections = new();
|
||
}
|
||
|
||
[Serializable]
|
||
private class CatalogSectionDto
|
||
{
|
||
public string id;
|
||
public string title;
|
||
public List<CatalogEntryDto> entries;
|
||
public string expectedSceneName;
|
||
public string expectedYarnProjectId;
|
||
public List<string> allowedNodes;
|
||
}
|
||
|
||
[Serializable]
|
||
private class CatalogEntryDto
|
||
{
|
||
public string label;
|
||
public string path;
|
||
public string anchorNode;
|
||
}
|
||
}
|
||
}
|
||
#endif
|