From 43c3f8531d028483bd4575873110f3a2a43e5983 Mon Sep 17 00:00:00 2001 From: Ding Yuntian <1491671119@qq.com> Date: Wed, 24 Jun 2026 22:45:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=20SaveSystem?= =?UTF-8?q?=20=E6=B5=8B=E8=AF=95=E7=AA=97=E5=8F=A3=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=AA=8C=E8=AF=81=E5=B7=A5=E5=85=B7=E8=AF=BB=E6=A1=A3?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SaveSystemTestWindow.cs | 667 ++++++++++++++++++ .../SaveSystemTestWindow.cs.meta | 11 + .../SaveSystemValidationWindow.cs | 24 +- 3 files changed, 699 insertions(+), 3 deletions(-) create mode 100644 Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs create mode 100644 Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs.meta diff --git a/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs b/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs new file mode 100644 index 000000000..902ecaae7 --- /dev/null +++ b/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs @@ -0,0 +1,667 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AibisDream; +using AibisDream.SaveSystem; +using AibisDream.Utility; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.SaveSystem.Editor +{ + /// + /// 存读档临时测试工具:触发存档、从槽位/测试归档读档、浏览 testsavs 历史。 + /// + public class SaveSystemTestWindow : EditorWindow + { + private const string MenuPath = "AIBIS/存档测试工具"; + private const string WindowTitle = "SaveSystem Test"; + + private Vector2 _scrollPosition; + private string _lastLog = string.Empty; + private int _restoreSlotIndex; + private int _manualSlotIndex = 1; + private bool _testArchiveEnabled; + private string _selectedArchiveEntryId; + private Texture2D _selectedThumbnail; + + private List _archiveEntries = new(); + private List _archiveEntriesSnapshot = new(); + private double _lastArchiveRefreshTime; + private bool _repaintQueued; + private List _slotOverviewLabels = new(); + private List _restoreLogSnapshot = new(); + private string _displaySelectedArchiveEntryId; + private Texture2D _displayThumbnail; + + private void RequestRepaint() + { + if (_repaintQueued) + { + return; + } + + _repaintQueued = true; + EditorApplication.delayCall += () => + { + _repaintQueued = false; + if (this != null) + { + Repaint(); + } + }; + } + + [MenuItem(MenuPath)] + public static void Open() + { + var window = GetWindow(WindowTitle); + window.minSize = new Vector2(480, 560); + } + + private void OnEnable() + { + _testArchiveEnabled = EditorPrefs.GetBool(TestSaveArchive.EditorPrefsEnabledKey, true); + RefreshArchiveEntries(requestRepaint: false); + EditorApplication.update += OnEditorUpdate; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + private void OnDisable() + { + EditorApplication.update -= OnEditorUpdate; + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + ReleaseThumbnail(); + } + + private void OnEditorUpdate() + { + if (!Application.isPlaying) + { + return; + } + + if (EditorApplication.timeSinceStartup - _lastArchiveRefreshTime < 1.0) + { + return; + } + + RefreshArchiveEntries(requestRepaint: true); + } + + private void OnPlayModeStateChanged(PlayModeStateChange state) + { + if (state == PlayModeStateChange.EnteredPlayMode || state == PlayModeStateChange.ExitingPlayMode) + { + RefreshArchiveEntries(); + } + } + + private void OnGUI() + { + if (Event.current.type == EventType.Layout) + { + SnapshotDynamicGuiData(); + } + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + + DrawHeader(); + GUILayout.Space(8); + DrawTestArchiveSettings(); + GUILayout.Space(8); + DrawSaveActions(); + GUILayout.Space(8); + DrawSlotRestoreSection(); + GUILayout.Space(8); + DrawTestArchiveSection(); + GUILayout.Space(8); + DrawRestoreLogSection(); + GUILayout.Space(8); + DrawUtilitySection(); + GUILayout.Space(8); + DrawLogSection(); + + EditorGUILayout.EndScrollView(); + } + + /// + /// 在 Layout/Repaint 之前快照可变列表,避免同一 GUI 帧内控件数量不一致。 + /// + private void SnapshotDynamicGuiData() + { + _displaySelectedArchiveEntryId = _selectedArchiveEntryId; + _displayThumbnail = _selectedThumbnail; + + _archiveEntriesSnapshot.Clear(); + for (var i = 0; i < _archiveEntries.Count; i++) + { + _archiveEntriesSnapshot.Add(_archiveEntries[i]); + } + + _slotOverviewLabels.Clear(); + foreach (var vm in SlotManager.GetSlotViewModels()) + { + if (vm.IsEmpty) + { + continue; + } + + var meta = SlotManager.LoadMeta(vm.SlotIndex); + _slotOverviewLabels.Add( + $"slot_{vm.SlotIndex}" + + (vm.IsAutoSlot ? " [自动]" : "") + + $" | {vm.SavedAt} | {meta?.nodeName ?? "—"} | {vm.SceneName}"); + } + + _restoreLogSnapshot.Clear(); + var restoreLog = SaveRestoreOrchestrator.LastRestoreLog; + for (var i = 0; i < restoreLog.Count; i++) + { + _restoreLogSnapshot.Add(restoreLog[i]); + } + } + + private void DrawHeader() + { + EditorGUILayout.LabelField("存读档测试工具", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "用于 Play Mode 下验证自动存档、槽位读档与 testsavs 测试归档。\n" + + "正式存档目录: saves/ | 测试归档: testsavs/(每次自动存档独立子文件夹)", + MessageType.Info); + + if (!Application.isPlaying) + { + EditorGUILayout.HelpBox("存档/读档操作需要在 Play Mode 下执行。", MessageType.Warning); + } + + EditorGUILayout.LabelField( + $"状态: IsRestoring={SaveRestoreOrchestrator.IsRestoring}, " + + $"SuppressAutoSave={SaveRestoreOrchestrator.IsAutoSaveSuppressed}"); + } + + private void DrawTestArchiveSettings() + { + EditorGUILayout.LabelField("测试存档模式", EditorStyles.boldLabel); + + EditorGUI.BeginChangeCheck(); + _testArchiveEnabled = EditorGUILayout.Toggle("启用测试存档模式", _testArchiveEnabled); + if (EditorGUI.EndChangeCheck()) + { + EditorPrefs.SetBool(TestSaveArchive.EditorPrefsEnabledKey, _testArchiveEnabled); + } + + EditorGUILayout.LabelField($"testsavs 路径: {ConstRef.TestAutoSaveArchivePath}", EditorStyles.miniLabel); + EditorGUILayout.HelpBox( + "开启后:每次 AutoSave 仅在 testsavs 下新建一份独立存档,不写入、不覆盖 saves/slot_0。\n" + + "存多少次就保留多少份;关闭后恢复正式行为(仅覆盖 slot_0)。", + MessageType.None); + } + + private void DrawSaveActions() + { + EditorGUILayout.LabelField("存档", EditorStyles.boldLabel); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("触发自动存档 (完整流程)")) + { + TriggerAutoSave(); + } + + if (GUILayout.Button("仅 Capture (不写盘)")) + { + CaptureInMemory(); + } + } + + if (GUILayout.Button("手动档: 复制 slot_0 → 上方指定槽位")) + { + CopyAutoToManual(); + } + } + + private void DrawSlotRestoreSection() + { + EditorGUILayout.LabelField("从正式槽位读档", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"saves 路径: {ConstRef.SaveFilePath}", EditorStyles.miniLabel); + + var latest = SlotManager.GetLatestSlotIndex(); + EditorGUILayout.LabelField($"最近槽位: {(latest.HasValue ? $"slot_{latest.Value}" : "无")}"); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("读档: Latest")) + { + RestoreLatestSlot(); + } + + if (GUILayout.Button("读档: slot_0")) + { + RestoreSlot(SlotIndex.Auto); + } + } + + _restoreSlotIndex = EditorGUILayout.IntSlider("槽位", _restoreSlotIndex, 0, SlotIndex.ManualEnd); + if (GUILayout.Button($"读档: slot_{_restoreSlotIndex}")) + { + RestoreSlot(_restoreSlotIndex); + } + + GUILayout.Space(4); + _manualSlotIndex = EditorGUILayout.IntSlider( + "复制到手动槽位", + _manualSlotIndex, + SlotIndex.ManualStart, + SlotIndex.ManualEnd); + + DrawSlotBriefOverview(); + } + + private void DrawSlotBriefOverview() + { + foreach (var label in _slotOverviewLabels) + { + EditorGUILayout.LabelField(label, EditorStyles.miniLabel); + } + } + + private void DrawTestArchiveSection() + { + EditorGUILayout.LabelField("testsavs 测试归档", EditorStyles.boldLabel); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("刷新列表")) + { + RefreshArchiveEntries(); + } + + if (GUILayout.Button("清空 testsavs")) + { + ClearTestArchives(); + } + + if (GUILayout.Button("打开 testsavs 文件夹")) + { + OpenDirectory(ConstRef.TestAutoSaveArchivePath); + } + } + + EditorGUILayout.LabelField($"共 {_archiveEntriesSnapshot.Count} 条", EditorStyles.miniLabel); + + if (_archiveEntriesSnapshot.Count == 0) + { + EditorGUILayout.HelpBox( + "暂无测试归档。开启「testsavs 独立归档」后触发自动存档,或从正式 slot_0 手动归档。", + MessageType.None); + } + else + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + foreach (var entry in _archiveEntriesSnapshot) + { + DrawArchiveEntry(entry); + } + } + } + + if (GUILayout.Button("将当前 slot_0 手动归档到 testsavs")) + { + ArchiveCurrentAutoSlot(); + } + } + + private void DrawArchiveEntry(TestSaveArchiveEntry entry) + { + var isSelected = _displaySelectedArchiveEntryId == entry.EntryId; + var boxStyle = isSelected ? EditorStyles.helpBox : EditorStyles.textArea; + + using (new EditorGUILayout.VerticalScope(boxStyle)) + { + EditorGUILayout.LabelField(entry.EntryId, EditorStyles.boldLabel); + EditorGUILayout.LabelField( + $"时间: {entry.Meta?.savedAt ?? "—"} | 节点: {entry.Meta?.nodeName ?? "—"}", + EditorStyles.miniLabel); + EditorGUILayout.LabelField( + $"场景: {entry.Meta?.sceneName ?? "—"} | SO: {entry.Meta?.sceneSoName ?? "—"}", + EditorStyles.miniLabel); + + if (isSelected) + { + var maxWidth = EditorGUIUtility.currentViewWidth - 48f; + var height = 120f; + if (_displayThumbnail != null) + { + height = Mathf.Min(120f, _displayThumbnail.height * maxWidth / _displayThumbnail.width); + GUILayout.Label(_displayThumbnail, GUILayout.Width(maxWidth), GUILayout.Height(height)); + } + else + { + EditorGUILayout.LabelField( + "(无缩略图)", + EditorStyles.miniLabel, + GUILayout.Width(maxWidth), + GUILayout.Height(height)); + } + } + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("选中")) + { + SelectArchiveEntry(entry); + } + + if (GUILayout.Button("读档")) + { + RestoreFromArchive(entry); + } + + if (GUILayout.Button("删除")) + { + DeleteArchiveEntry(entry); + } + } + } + + GUILayout.Space(4); + } + + private void DrawRestoreLogSection() + { + EditorGUILayout.LabelField("读档 Pipeline 日志", EditorStyles.boldLabel); + + if (_restoreLogSnapshot.Count == 0) + { + EditorGUILayout.LabelField("(暂无)", EditorStyles.miniLabel); + return; + } + + foreach (var line in _restoreLogSnapshot) + { + EditorGUILayout.LabelField(line, EditorStyles.wordWrappedMiniLabel); + } + } + + private void DrawUtilitySection() + { + EditorGUILayout.LabelField("工具", EditorStyles.boldLabel); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("打开 saves 文件夹")) + { + OpenDirectory(ConstRef.SaveFilePath); + } + + if (GUILayout.Button("打开存档验证工具")) + { + SaveSystemValidationWindow.Open(); + } + } + } + + private void DrawLogSection() + { + EditorGUILayout.LabelField("操作结果", EditorStyles.boldLabel); + EditorGUILayout.SelectableLabel(_lastLog, EditorStyles.textArea, GUILayout.MinHeight(80)); + } + + private void RefreshArchiveEntries(bool requestRepaint = true) + { + var newEntries = TestSaveArchive.ListEntries(); + var entriesChanged = !ArchiveEntriesEqual(newEntries, _archiveEntries); + _archiveEntries = newEntries; + _lastArchiveRefreshTime = EditorApplication.timeSinceStartup; + + if (!string.IsNullOrEmpty(_selectedArchiveEntryId) && + !_archiveEntries.Exists(e => e.EntryId == _selectedArchiveEntryId)) + { + _selectedArchiveEntryId = null; + ReleaseThumbnail(); + entriesChanged = true; + } + + if (requestRepaint && entriesChanged) + { + RequestRepaint(); + } + } + + private static bool ArchiveEntriesEqual(List a, List b) + { + if (a.Count != b.Count) + { + return false; + } + + for (var i = 0; i < a.Count; i++) + { + if (a[i].EntryId != b[i].EntryId) + { + return false; + } + } + + return true; + } + + private void SelectArchiveEntry(TestSaveArchiveEntry entry) + { + _selectedArchiveEntryId = entry.EntryId; + ReleaseThumbnail(); + + if (!string.IsNullOrEmpty(entry.ThumbnailPath) && File.Exists(entry.ThumbnailPath)) + { + var bytes = File.ReadAllBytes(entry.ThumbnailPath); + _selectedThumbnail = new Texture2D(2, 2); + _selectedThumbnail.LoadImage(bytes); + } + + RequestRepaint(); + } + + private void ReleaseThumbnail() + { + if (_selectedThumbnail != null) + { + DestroyImmediate(_selectedThumbnail); + _selectedThumbnail = null; + } + } + + private void TriggerAutoSave() + { + if (!Application.isPlaying) + { + Log("需要在 Play Mode 下执行。"); + return; + } + + try + { + if (!SavePointEvaluator.CanAutoSave(out var reason)) + { + Log($"CanAutoSave 拒绝: {reason}"); + return; + } + + var routine = SaveRestoreOrchestrator.AutoSaveRoutine(); + while (routine.MoveNext()) + { + } + + RefreshArchiveEntries(); + Log(TestSaveArchive.IsEnabled + ? "测试存档模式:已写入 testsavs(未覆盖 slot_0)。" + : "正式模式:已写入 slot_0。"); + } + catch (Exception ex) + { + Log($"自动存档失败: {ex}"); + } + } + + private void CaptureInMemory() + { + try + { + if (YarnVariableStorage.Instance == null) + { + Log("YarnVariableStorage 未初始化。"); + return; + } + + var snapshot = SnapshotService.Capture(); + Log($"Capture 成功: 场景={snapshot.scene?.sceneName}, 节点={snapshot.anchor?.nodeName}"); + } + catch (Exception ex) + { + Log($"Capture 失败: {ex.Message}"); + } + } + + private void CopyAutoToManual() + { + if (!SlotDirectory.Exists(SlotIndex.Auto)) + { + Log("slot_0 不存在。"); + return; + } + + SlotManager.CopyAutoToManual(_manualSlotIndex); + Log($"已复制到 slot_{_manualSlotIndex}。"); + } + + private void RestoreLatestSlot() + { + var latest = SlotManager.GetLatestSlotIndex(); + if (!latest.HasValue) + { + Log("没有 latest_slot。"); + return; + } + + RestoreSlot(latest.Value); + } + + private void RestoreSlot(int slotIndex) + { + if (!Application.isPlaying) + { + Log("读档需要在 Play Mode 下执行。"); + return; + } + + if (GameManager.Instance == null) + { + Log("GameManager 未初始化。"); + return; + } + + if (!SlotDirectory.Exists(slotIndex)) + { + Log($"slot_{slotIndex} 不存在。"); + return; + } + + GameManager.Instance.StartWithSlot(slotIndex); + Log($"已启动读档 slot_{slotIndex}。"); + } + + private void RestoreFromArchive(TestSaveArchiveEntry entry) + { + if (!Application.isPlaying) + { + Log("读档需要在 Play Mode 下执行。"); + return; + } + + if (GameManager.Instance == null) + { + Log("GameManager 未初始化。"); + return; + } + + GameManager.Instance.StartWithSaveFile(entry.SnapshotPath); + Log($"已启动读档: {entry.EntryId}"); + } + + private void ArchiveCurrentAutoSlot() + { + var snapshot = SlotManager.LoadSnapshot(SlotIndex.Auto); + if (snapshot == null) + { + Log("slot_0 无快照。"); + return; + } + + var thumbnail = SlotManager.LoadThumbnail(SlotIndex.Auto); + var entryId = TestSaveArchive.Archive(snapshot, thumbnail); + RefreshArchiveEntries(); + Log($"已手动归档: {entryId}"); + } + + private void DeleteArchiveEntry(TestSaveArchiveEntry entry) + { + var entryId = entry.EntryId; + EditorApplication.delayCall += () => ConfirmDeleteArchiveEntry(entryId); + } + + private void ConfirmDeleteArchiveEntry(string entryId) + { + if (!EditorUtility.DisplayDialog("删除测试归档", $"删除 {entryId}?", "删除", "取消")) + { + return; + } + + TestSaveArchive.DeleteEntry(entryId); + if (_selectedArchiveEntryId == entryId) + { + _selectedArchiveEntryId = null; + ReleaseThumbnail(); + } + + RefreshArchiveEntries(); + Log($"已删除: {entryId}"); + } + + private void ClearTestArchives() + { + EditorApplication.delayCall += ConfirmClearTestArchives; + } + + private void ConfirmClearTestArchives() + { + if (!EditorUtility.DisplayDialog("清空 testsavs", "删除所有测试归档?不可撤销。", "删除", "取消")) + { + return; + } + + TestSaveArchive.ClearAll(); + _selectedArchiveEntryId = null; + ReleaseThumbnail(); + RefreshArchiveEntries(); + Log("已清空 testsavs。"); + } + + private static void OpenDirectory(string path) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + + EditorUtility.RevealInFinder(path); + } + + private void Log(string message) + { + _lastLog = $"[{DateTime.Now:HH:mm:ss}] {message}"; + Debug.Log($"[SaveSystemTest] {message}"); + RequestRepaint(); + } + } +} diff --git a/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs.meta b/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs.meta new file mode 100644 index 000000000..8e6df22af --- /dev/null +++ b/Assets/Editor/SaveSystemValidation/SaveSystemTestWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27c49345ec3a4e849af3f9da7045f7a3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs b/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs index 968e37814..e9c4e5683 100644 --- a/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs +++ b/Assets/Editor/SaveSystemValidation/SaveSystemValidationWindow.cs @@ -586,7 +586,7 @@ namespace AibisDream.SaveSystem.Editor return; } - GameManager.Instance.StartCoroutine(SaveRestoreOrchestrator.RestoreFromSlot(slotIndex)); + GameManager.Instance.StartWithSlot(slotIndex); Log($"已启动 Restore slot_{slotIndex}。请观察 Restore Pipeline Log 与场景状态。"); } @@ -622,13 +622,31 @@ namespace AibisDream.SaveSystem.Editor $"yarnVariables.strings: {snapshot.yarnVariables?.strings?.Count ?? 0} entries", $"yarnVariables.bools: {snapshot.yarnVariables?.bools?.Count ?? 0} entries", $"sections: {(snapshot.sections != null ? string.Join(", ", snapshot.sections.Keys) : "null")}", - "JSON preview (first 1500 chars):", - json.Length > 1500 ? json.Substring(0, 1500) + "..." : json }; + if (snapshot.sections != null) + { + AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.PunchTape); + AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.Fix); + AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.FixPanel); + AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.BodyModule); + AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.Eye); + } + + lines.Add("JSON preview (first 1500 chars):"); + lines.Add(json.Length > 1500 ? json.Substring(0, 1500) + "..." : json); + Log(string.Join("\n", lines)); } + private static void AppendFixSectionHint(List lines, SaveSnapshot snapshot, string sectionId) + { + if (snapshot.sections.ContainsKey(sectionId)) + { + lines.Add($" [fix-related] {sectionId}: captured"); + } + } + private void Log(string message) { _lastLog = $"[{DateTime.Now:HH:mm:ss}] {message}";