Files
aibis-dream/Assets/Scripts/UI/Panel/DeveloperModePanel.cs
T
2026-07-24 13:16:03 +08:00

1104 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using AibisDream.Framework;
using AibisDream.Kit;
using AibisDream.SaveSystem;
using AibisDream.Utility;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace AibisDream.UI
{
/// <summary>
/// Editor / Development Build 中使用的统一运行时开发模式。
/// 这是非模态覆盖层,所有改变游戏状态的操作仍通过正式 GameManager/SaveSystem 入口执行。
/// </summary>
public sealed class DeveloperModePanel : MonoBehaviour
{
private const float OverviewRefreshInterval = 0.25f;
private const float SlowStateRefreshInterval = 1f;
private const int MaxRenderedLogRows = 300;
[Header("Navigation")]
[SerializeField] private Button closeButton;
[SerializeField] private Button[] tabButtons;
[SerializeField] private GameObject[] tabPages;
[Header("Overview")]
[SerializeField] private TMP_Text overviewText;
[SerializeField] private TMP_Text overviewStatusText;
[SerializeField] private Button copyDiagnosticsButton;
[Header("Logs")]
[SerializeField] private TMP_InputField logSearchInput;
[SerializeField] private Button logLevelButton;
[SerializeField] private TMP_Text logLevelLabel;
[SerializeField] private Button logCategoryButton;
[SerializeField] private TMP_Text logCategoryLabel;
[SerializeField] private Toggle pauseLogToggle;
[SerializeField] private Button clearLogsButton;
[SerializeField] private Button copyFilteredLogsButton;
[SerializeField] private Button copySelectedLogButton;
[SerializeField] private TMP_Text logCountText;
[SerializeField] private TMP_Text logDetailsText;
[SerializeField] private ScrollRect logScrollRect;
[SerializeField] private RectTransform logContent;
[SerializeField] private DeveloperModeListRow logRowTemplate;
[Header("Variables")]
[SerializeField] private TMP_InputField variableSearchInput;
[SerializeField] private Button variableScopeButton;
[SerializeField] private TMP_Text variableScopeLabel;
[SerializeField] private Button variableTypeButton;
[SerializeField] private TMP_Text variableTypeLabel;
[SerializeField] private TMP_Text variableCountText;
[SerializeField] private RectTransform variableContent;
[SerializeField] private DeveloperModeListRow variableRowTemplate;
[Header("Saves")]
[SerializeField] private Toggle testSaveRecordingToggle;
[SerializeField] private TMP_Text testSaveRecordingStateText;
[SerializeField] private TMP_InputField testSaveSearchInput;
[SerializeField] private Button reloadSavesButton;
[SerializeField] private Button openTestSaveFolderButton;
[SerializeField] private Button jumpToTestSaveButton;
[SerializeField] private Button deleteSelectedTestSaveButton;
[SerializeField] private Button deleteInvalidTestSavesButton;
[SerializeField] private Button clearTestSaveLibraryButton;
[SerializeField] private TMP_Text saveStatusText;
[SerializeField] private RectTransform testSaveChapterContent;
[SerializeField] private DeveloperModeListRow testSaveChapterRowTemplate;
[SerializeField] private RectTransform testSaveEntryContent;
[SerializeField] private DeveloperModeListRow testSaveEntryRowTemplate;
[Header("Display")]
[SerializeField] private Toggle showCursorToggle;
[SerializeField] private Toggle showVersionToggle;
[SerializeField] private Toggle showTopBarToggle;
[SerializeField] private Toggle showDialogToggle;
[SerializeField] private Button pauseGameButton;
[SerializeField] private Button resumeGameButton;
[SerializeField] private Button halfSpeedButton;
[SerializeField] private Button normalSpeedButton;
[SerializeField] private Button doubleSpeedButton;
[SerializeField] private TMP_Text controlStatusText;
private readonly List<DeveloperModeListRow> _logRows = new();
private readonly List<DeveloperModeListRow> _variableRows = new();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
private readonly List<DeveloperModeListRow> _testSaveChapterRows = new();
private readonly List<DeveloperModeListRow> _testSaveEntryRows = new();
private IReadOnlyList<TestSaveChapterCoverage> _testSaveCoverage = Array.Empty<TestSaveChapterCoverage>();
private TestSaveEntry _selectedTestSave;
private bool _selectedTestSaveCanJump;
private string _selectedTestSaveChapterId;
private string _pendingDestructiveAction;
private float _pendingDestructiveActionDeadline;
private bool _validateTestSavesOnRefresh;
#endif
private VerText _verText;
private RuntimeLogRecord? _selectedLog;
private int _currentTab;
private int _logLevelIndex;
private int _logCategoryIndex;
private int _variableScopeIndex;
private int _variableTypeIndex;
private int _observedLogVersion = -1;
private string _observedYarnProject;
private string _latestSlotSummary = "N/A";
private bool _variablesDirty = true;
private bool _savesDirty = true;
private bool _isRestorePending;
private bool _showCursor = true;
private bool _showVersion = true;
private bool _showTopBar = true;
private bool _showDialog = true;
private float _nextOverviewRefresh;
private float _nextSlowStateRefresh;
private float _smoothedFps;
private bool _bound;
public bool IsOpen => gameObject.activeSelf;
public bool IsConfigured => tabPages is { Length: 5 } && closeButton != null && overviewText != null;
private void Awake()
{
if (!DeveloperModeGate.IsEnabled)
{
gameObject.SetActive(false);
enabled = false;
return;
}
_verText = FindObjectOfType<VerText>(true);
BindUi();
}
private void OnEnable()
{
if (!DeveloperModeGate.IsEnabled)
{
gameObject.SetActive(false);
return;
}
BindUi();
SubscribeEvents();
CursorManager.Instance?.SetCursorVisible(true);
_variablesDirty = true;
_savesDirty = true;
_nextOverviewRefresh = 0f;
_nextSlowStateRefresh = 0f;
ShowTab(_currentTab);
}
private void OnDisable()
{
if (!DeveloperModeGate.IsEnabled) return;
UnsubscribeEvents();
CursorManager.Instance?.SetCursorVisible(_showCursor);
}
private void Update()
{
var delta = Time.unscaledDeltaTime;
if (delta > 0.0001f)
{
var instantaneous = 1f / delta;
_smoothedFps = _smoothedFps <= 0f
? instantaneous
: Mathf.Lerp(_smoothedFps, instantaneous, 0.08f);
}
var now = Time.unscaledTime;
if (now >= _nextSlowStateRefresh)
{
_nextSlowStateRefresh = now + SlowStateRefreshInterval;
RefreshSlowState();
}
if (now >= _nextOverviewRefresh)
{
_nextOverviewRefresh = now + OverviewRefreshInterval;
RefreshOverview();
DetectYarnProjectChange();
}
if (_currentTab == 1 && pauseLogToggle != null && !pauseLogToggle.isOn
&& _observedLogVersion != LogKit.RuntimeLogVersion)
{
RefreshLogs(scrollToBottom: true);
}
if (_currentTab == 2 && _variablesDirty)
{
RefreshVariables();
}
if (_currentTab == 3 && _savesDirty)
{
RefreshSaves();
}
}
public void TogglePanel()
{
if (!DeveloperModeGate.IsEnabled) return;
if (gameObject.activeSelf)
ClosePanel();
else
gameObject.SetActive(true);
}
public bool HandleEscape()
{
if (!gameObject.activeSelf) return false;
ClosePanel();
return true;
}
public void ClosePanel()
{
gameObject.SetActive(false);
}
private void BindUi()
{
if (_bound) return;
_bound = true;
closeButton?.onClick.AddListener(ClosePanel);
if (tabButtons != null)
{
for (var i = 0; i < tabButtons.Length; i++)
{
var index = i;
tabButtons[i]?.onClick.AddListener(() => ShowTab(index));
}
}
copyDiagnosticsButton?.onClick.AddListener(CopyDiagnostics);
logSearchInput?.onValueChanged.AddListener(_ => RefreshLogs(false));
logLevelButton?.onClick.AddListener(CycleLogLevel);
logCategoryButton?.onClick.AddListener(CycleLogCategory);
pauseLogToggle?.onValueChanged.AddListener(paused =>
{
if (!paused) RefreshLogs(true);
});
clearLogsButton?.onClick.AddListener(ClearLogs);
copyFilteredLogsButton?.onClick.AddListener(CopyFilteredLogs);
copySelectedLogButton?.onClick.AddListener(CopySelectedLog);
variableSearchInput?.onValueChanged.AddListener(_ =>
{
_variablesDirty = true;
});
variableScopeButton?.onClick.AddListener(CycleVariableScope);
variableTypeButton?.onClick.AddListener(CycleVariableType);
reloadSavesButton?.onClick.AddListener(() =>
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
_validateTestSavesOnRefresh = true;
#endif
_savesDirty = true;
RefreshSaves();
});
#if UNITY_EDITOR || DEVELOPMENT_BUILD
testSaveRecordingToggle?.SetIsOnWithoutNotify(false);
UpdateTestSaveRecordingState(false);
testSaveRecordingToggle?.onValueChanged.AddListener(value =>
{
TestSaveRecorder.SetRecording(value);
UpdateTestSaveRecordingState(value);
SetSaveStatus(value
? "录制已开启:正式节点自动档成功后会复制到测试存档库。"
: "录制已关闭。已有测试存档不受影响。", false);
});
testSaveSearchInput?.onValueChanged.AddListener(_ =>
{
_savesDirty = true;
RefreshSaves();
});
openTestSaveFolderButton?.onClick.AddListener(OpenTestSaveFolder);
jumpToTestSaveButton?.onClick.AddListener(JumpToSelectedTestSave);
deleteSelectedTestSaveButton?.onClick.AddListener(DeleteSelectedTestSave);
deleteInvalidTestSavesButton?.onClick.AddListener(DeleteInvalidTestSaves);
clearTestSaveLibraryButton?.onClick.AddListener(ClearTestSaveLibrary);
if (!DeveloperPathUtility.CanReveal && openTestSaveFolderButton != null)
{
openTestSaveFolderButton.interactable = false;
}
#endif
BindDisplayControls();
UpdateFilterLabels();
}
private void SubscribeEvents()
{
EnumEventSystem.Global.Register<StorageEvent, VariableItem>(StorageEvent.VariableSet, OnVariableSet);
EnumEventSystem.Global.Register(StorageEvent.VariablesCleared, OnVariablesCleared);
EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, OnSessionEnded);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
TestSaveRecorder.LibraryChanged += OnTestSaveLibraryChanged;
#endif
}
private void UnsubscribeEvents()
{
EnumEventSystem.Global.UnRegister<StorageEvent, VariableItem>(StorageEvent.VariableSet, OnVariableSet);
EnumEventSystem.Global.UnRegister(StorageEvent.VariablesCleared, OnVariablesCleared);
EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionEnded, OnSessionEnded);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
TestSaveRecorder.LibraryChanged -= OnTestSaveLibraryChanged;
#endif
}
private void ShowTab(int index)
{
if (tabPages == null || tabPages.Length == 0) return;
_currentTab = Mathf.Clamp(index, 0, tabPages.Length - 1);
for (var i = 0; i < tabPages.Length; i++)
{
tabPages[i]?.SetActive(i == _currentTab);
}
if (_currentTab == 1) RefreshLogs(true);
if (_currentTab == 2)
{
_variablesDirty = true;
RefreshVariables();
}
if (_currentTab == 3)
{
_savesDirty = true;
RefreshSaves();
}
}
private void RefreshSlowState()
{
try
{
var latest = SlotManager.GetLatestSlotIndex();
if (!latest.HasValue)
{
_latestSlotSummary = "N/A";
return;
}
var meta = SlotManager.LoadMeta(latest.Value);
_latestSlotSummary = meta == null
? $"slot_{latest.Value}meta 不可用)"
: $"slot_{latest.Value} | {meta.savedAt} | {meta.sceneSoName}/{meta.nodeName}";
}
catch (Exception ex)
{
_latestSlotSummary = $"读取失败:{ex.Message}";
}
}
private void RefreshOverview()
{
if (overviewText != null)
{
overviewText.text = BuildOverviewText();
}
}
private string BuildOverviewText()
{
var builder = new StringBuilder(1024);
var gameManager = GameManager.Instance;
var session = gameManager != null ? GameManager.Session : null;
var talkScene = session?.CurrentTalkScene;
var sceneLoader = SceneLoader.Instance;
var dialog = DialogController.Instance;
var runner = dialog?.DialogueRunner;
var node = dialog?.GetCurrentNodeContext();
var locale = LocalizationKit.CurrentLocale;
var persistentHandles = ResourceSystem.PersistentLoader?.TotalHandleCount ?? 0;
var sceneHandles = ResourceSystem.CurrentSceneLoader?.TotalHandleCount ?? 0;
builder.AppendLine($"构建 {Application.version} | {Application.platform} | Dev={Debug.isDebugBuild} | Unity {Application.unityVersion}");
builder.AppendLine($"性能 FPS {_smoothedFps:0.0} | timeScale {Time.timeScale:0.##} | frame {Time.frameCount}");
builder.AppendLine($"会话 {session?.Phase.ToString() ?? "N/A"} | paused={session?.IsPaused.ToString() ?? "N/A"} | dialogue={session?.IsDialogueActive.ToString() ?? "N/A"} | busy={session?.IsBusy.ToString() ?? "N/A"}");
builder.AppendLine($"章节 {talkScene?.name ?? "N/A"} | {talkScene?.chapter ?? "N/A"} | {talkScene?.title ?? "N/A"}");
builder.AppendLine($"场景 key={sceneLoader?.CurrentSceneName ?? "N/A"} | active={SceneManager.GetActiveScene().name}");
builder.AppendLine($"Yarn project={runner?.YarnProject?.name ?? "N/A"} | running={runner?.IsDialogueRunning.ToString() ?? "N/A"}");
builder.AppendLine($"节点 {node?.nodeName ?? "N/A"} | tags={FormatTags(node?.tags)}");
builder.AppendLine($"语言 {LocalizationKit.CurrentLanguageCode} | {locale?.name ?? "N/A"}");
builder.AppendLine($"存档 latest={_latestSlotSummary}");
builder.AppendLine($"存档状态 saving={SaveRestoreOrchestrator.IsSaving} | restoring={SaveRestoreOrchestrator.IsRestoring} | suppressAuto={SaveRestoreOrchestrator.IsAutoSaveSuppressed}");
builder.AppendLine($"资源句柄 persistent={persistentHandles} | scene={sceneHandles}");
builder.Append($"日志 memory={LogKit.RuntimeLogCount}/{RuntimeLogBuffer.DefaultCapacity} | file={(LogKit.IsFileLoggingEnabled ? LogKit.CurrentLogFilePath : "Editor profile disabled")}");
return builder.ToString();
}
private static string FormatTags(IReadOnlyList<string> tags)
{
return tags == null || tags.Count == 0 ? "-" : string.Join(",", tags);
}
private void DetectYarnProjectChange()
{
var current = DialogController.Instance?.DialogueRunner?.YarnProject?.name;
if (string.Equals(current, _observedYarnProject, StringComparison.Ordinal)) return;
_observedYarnProject = current;
_variablesDirty = true;
}
private void CopyDiagnostics()
{
var builder = new StringBuilder();
builder.AppendLine($"AIBIS Dream diagnostics @ {DateTime.Now:O}");
builder.AppendLine(BuildOverviewText());
builder.AppendLine();
builder.AppendLine("Recent warnings/errors:");
foreach (var record in LogKit.GetRuntimeLogSnapshot()
.Where(item => item.Level >= LogLevel.Warning)
.TakeLast(50))
{
builder.AppendLine(record.ToDisplayString(includeStackTrace: true));
}
GUIUtility.systemCopyBuffer = builder.ToString();
SetOverviewStatus("诊断摘要已复制到剪贴板。");
}
private void CycleLogLevel()
{
_logLevelIndex = (_logLevelIndex + 1) % (Enum.GetValues(typeof(LogLevel)).Length + 1);
UpdateFilterLabels();
RefreshLogs(false);
}
private void CycleLogCategory()
{
_logCategoryIndex = (_logCategoryIndex + 1) % (Enum.GetValues(typeof(LogCategory)).Length + 1);
UpdateFilterLabels();
RefreshLogs(false);
}
private void RefreshLogs(bool scrollToBottom)
{
if (logContent == null || logRowTemplate == null) return;
var snapshot = LogKit.GetRuntimeLogSnapshot();
_observedLogVersion = LogKit.RuntimeLogVersion;
var filtered = snapshot.Where(MatchesLogFilter).ToArray();
var rendered = filtered.Skip(Mathf.Max(0, filtered.Length - MaxRenderedLogRows)).ToArray();
for (var i = 0; i < rendered.Length; i++)
{
var record = rendered[i];
var row = GetRow(_logRows, logRowTemplate, logContent, i);
row.Bind(
Truncate(record.ToDisplayString(false).Replace('\n', ' '), 220),
() => SelectLog(record),
true,
GetLogColor(record.Level));
}
HideRows(_logRows, rendered.Length);
if (logCountText != null)
{
logCountText.text = filtered.Length > rendered.Length
? $"显示最新 {rendered.Length}/{filtered.Length},缓冲 {snapshot.Length}"
: $"显示 {rendered.Length},缓冲 {snapshot.Length}";
}
if (scrollToBottom && logScrollRect != null)
{
Canvas.ForceUpdateCanvases();
logScrollRect.verticalNormalizedPosition = 0f;
}
}
private bool MatchesLogFilter(RuntimeLogRecord record)
{
if (_logLevelIndex > 0 && record.Level < (LogLevel)(_logLevelIndex - 1)) return false;
if (_logCategoryIndex > 0 && record.Category != (LogCategory)(_logCategoryIndex - 1)) return false;
var search = logSearchInput?.text;
if (string.IsNullOrWhiteSpace(search)) return true;
return Contains(record.Message, search)
|| Contains(record.Context, search)
|| Contains(record.StackTrace, search)
|| Contains(record.SceneName, search)
|| Contains(record.Category.ToString(), search);
}
private void SelectLog(RuntimeLogRecord record)
{
_selectedLog = record;
if (logDetailsText != null)
{
logDetailsText.text = record.ToDisplayString(includeStackTrace: true);
}
}
private void ClearLogs()
{
LogKit.ClearRuntimeLogs();
_selectedLog = null;
if (logDetailsText != null) logDetailsText.text = "未选择日志。";
RefreshLogs(false);
}
private void CopyFilteredLogs()
{
GUIUtility.systemCopyBuffer = string.Join(
"\n",
LogKit.GetRuntimeLogSnapshot().Where(MatchesLogFilter).Select(item => item.ToDisplayString(true)));
}
private void CopySelectedLog()
{
if (_selectedLog.HasValue)
{
GUIUtility.systemCopyBuffer = _selectedLog.Value.ToDisplayString(true);
}
}
private void CycleVariableScope()
{
_variableScopeIndex = (_variableScopeIndex + 1) % 3;
UpdateFilterLabels();
_variablesDirty = true;
}
private void CycleVariableType()
{
_variableTypeIndex = (_variableTypeIndex + 1) % 4;
UpdateFilterLabels();
_variablesDirty = true;
}
private void RefreshVariables()
{
_variablesDirty = false;
if (variableContent == null || variableRowTemplate == null) return;
var storage = YarnVariableStorage.Instance;
var project = DialogController.Instance?.DialogueRunner?.YarnProject;
var snapshot = DeveloperVariableSnapshot.Capture(storage, project);
var filtered = snapshot.Where(MatchesVariableFilter).ToArray();
for (var i = 0; i < filtered.Length; i++)
{
var variable = filtered[i];
var line = $"{variable.Name} = {variable.Value} [{variable.Type}/{variable.Kind}/{(variable.HasRuntimeOverride ? "Runtime" : "Default")}]";
var row = GetRow(_variableRows, variableRowTemplate, variableContent, i);
row.Bind(
line,
() => GUIUtility.systemCopyBuffer = line,
true,
variable.IsGlobal ? new Color(0.35f, 0.9f, 1f) : Color.white);
}
HideRows(_variableRows, filtered.Length);
if (variableCountText != null)
{
variableCountText.text = $"{filtered.Length}/{snapshot.Length} variables";
}
}
private bool MatchesVariableFilter(DeveloperVariableRecord variable)
{
if (_variableScopeIndex == 1 && !variable.IsGlobal) return false;
if (_variableScopeIndex == 2 && variable.IsGlobal) return false;
if (_variableTypeIndex > 0)
{
var expected = _variableTypeIndex switch
{
1 => "Number",
2 => "String",
3 => "Bool",
_ => null
};
if (!string.Equals(variable.Type, expected, StringComparison.Ordinal)) return false;
}
var search = variableSearchInput?.text;
return string.IsNullOrWhiteSpace(search)
|| Contains(variable.Name, search)
|| Contains(variable.Value, search);
}
private void OnVariableSet(VariableItem _) => _variablesDirty = true;
private void OnVariablesCleared() => _variablesDirty = true;
private void RefreshSaves()
{
_savesDirty = false;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
RefreshTestSaveRows();
#endif
}
private bool CanStartRestore()
{
if (_isRestorePending || SaveRestoreOrchestrator.IsRestoring || GameManager.Instance == null) return false;
var session = GameManager.Session;
return !session.IsBusy && session.Phase is GameSessionPhase.MainMenu or GameSessionPhase.Playing;
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
private void RefreshTestSaveRows()
{
if (testSaveChapterContent == null
|| testSaveChapterRowTemplate == null
|| testSaveEntryContent == null
|| testSaveEntryRowTemplate == null)
{
return;
}
TestSaveScanResult scan;
try
{
scan = TestSaveRecorder.Repository.Scan(Application.version);
if (_validateTestSavesOnRefresh)
{
_validateTestSavesOnRefresh = false;
foreach (var entry in scan.Entries.Where(item => item.IsValid))
{
TestSaveRecorder.Repository.Validate(entry, out _);
}
scan = TestSaveRecorder.Repository.Scan(Application.version);
}
_testSaveCoverage = TestSaveCoverage.Build(
GameManager.Instance?.RuntimeChapters,
scan.Entries);
}
catch (Exception ex)
{
SetSaveStatus($"扫描测试存档失败:{ex.Message}", true);
return;
}
var search = testSaveSearchInput?.text?.Trim();
var visibleChapters = _testSaveCoverage
.Where(chapter => string.IsNullOrWhiteSpace(search)
|| Contains(chapter.Title, search)
|| Contains(chapter.ChapterId, search)
|| chapter.Rows.Any(row => Contains(row.NodeName, search)))
.ToArray();
if (visibleChapters.Length > 0
&& visibleChapters.All(chapter =>
!string.Equals(chapter.ChapterId, _selectedTestSaveChapterId, StringComparison.Ordinal)))
{
_selectedTestSaveChapterId = visibleChapters[0].ChapterId;
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
}
for (var i = 0; i < visibleChapters.Length; i++)
{
var chapter = visibleChapters[i];
var selected = string.Equals(
chapter.ChapterId,
_selectedTestSaveChapterId,
StringComparison.Ordinal);
var label = chapter.IsOrphanGroup
? $"{chapter.Title} ({chapter.Rows.Count})"
: $"{chapter.Title} {chapter.RecordedCount}/{chapter.ExpectedCount}";
var row = GetRow(
_testSaveChapterRows,
testSaveChapterRowTemplate,
testSaveChapterContent,
i);
row.Bind(
label,
() => SelectTestSaveChapter(chapter.ChapterId),
true,
selected ? new Color(0.25f, 0.95f, 1f) : Color.white,
isError: chapter.IsOrphanGroup,
isSelected: selected);
}
HideRows(_testSaveChapterRows, visibleChapters.Length);
var selectedChapter = visibleChapters.FirstOrDefault(chapter =>
string.Equals(chapter.ChapterId, _selectedTestSaveChapterId, StringComparison.Ordinal));
var visibleRows = selectedChapter?.Rows
.Where(row => string.IsNullOrWhiteSpace(search)
|| Contains(row.NodeName, search)
|| Contains(row.Entry?.Meta?.sceneName, search))
.ToArray() ?? Array.Empty<TestSaveCoverageRow>();
for (var i = 0; i < visibleRows.Length; i++)
{
var coverageRow = visibleRows[i];
var entry = coverageRow.Entry;
var selected = entry != null
&& string.Equals(
entry.EntryId ?? entry.DirectoryPath,
_selectedTestSave?.EntryId ?? _selectedTestSave?.DirectoryPath,
StringComparison.Ordinal);
var (label, color, isError) = FormatTestSaveRow(coverageRow);
var row = GetRow(
_testSaveEntryRows,
testSaveEntryRowTemplate,
testSaveEntryContent,
i);
row.Bind(
label,
entry == null
? null
: () => SelectTestSave(
entry,
coverageRow.Kind == TestSaveCoverageRowKind.Recorded),
entry != null,
color,
isError,
selected);
}
HideRows(_testSaveEntryRows, visibleRows.Length);
UpdateTestSaveActions();
var recorded = _testSaveCoverage.Sum(chapter => chapter.RecordedCount);
var expected = _testSaveCoverage.Sum(chapter => chapter.ExpectedCount);
SetSaveStatus(
$"测试存档 {scan.ValidCount} 个有效 / {scan.InvalidCount} 个无效;当前流程覆盖 {recorded}/{expected}。",
false);
}
private static (string Label, Color Color, bool IsError) FormatTestSaveRow(
TestSaveCoverageRow row)
{
if (row.Kind == TestSaveCoverageRowKind.Missing)
{
return ($"○ 缺失 {row.NodeName}", new Color(0.6f, 0.68f, 0.72f), false);
}
var entry = row.Entry;
if (row.Kind == TestSaveCoverageRowKind.Invalid || entry == null || !entry.IsValid)
{
return (
$"✕ 无效 {row.NodeName} | "
+ (entry?.IsValid == true
? "当前流程找不到对应章节/YarnProject。"
: entry?.StatusMessage ?? "未知错误"),
new Color(1f, 0.5f, 0.45f),
true);
}
var warning = entry.HasVersionWarning ? $" ⚠ game {entry.Meta.gameVersion}" : string.Empty;
return (
$"● {row.NodeName} | {entry.Meta.lastRecordedAt} | {entry.Meta.sceneName}{warning}",
entry.HasVersionWarning ? new Color(1f, 0.82f, 0.35f) : Color.white,
false);
}
private void SelectTestSaveChapter(string chapterId)
{
_selectedTestSaveChapterId = chapterId;
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
_savesDirty = true;
RefreshSaves();
}
private void SelectTestSave(TestSaveEntry entry, bool canJump)
{
_selectedTestSave = entry;
_selectedTestSaveCanJump = canJump && entry?.IsValid == true;
_savesDirty = true;
RefreshSaves();
}
private void JumpToSelectedTestSave()
{
if (_selectedTestSave?.IsValid != true || !_selectedTestSaveCanJump)
{
SetSaveStatus("请先选择一个有效测试存档。", true);
return;
}
if (!TestSaveRecorder.Repository.Validate(_selectedTestSave, out var validationError))
{
SetSaveStatus($"测试存档验证失败:{validationError}", true);
_savesDirty = true;
return;
}
if (!CanStartRestore())
{
SetSaveStatus("当前会话忙碌,无法跳转。", true);
return;
}
_isRestorePending = true;
SetSaveStatus($"正在跳转到 {_selectedTestSave.NodeName}...", false);
if (!GameManager.Instance.TryRestoreTestSave(_selectedTestSave, OnRestoreCompleted))
{
_isRestorePending = false;
SetSaveStatus("跳转请求被 GameManager 拒绝。", true);
}
}
private void OnRestoreCompleted(RestoreResult result)
{
_isRestorePending = false;
_savesDirty = true;
if (result?.Success == true)
{
var warnings = result.Warnings.Count == 0
? string.Empty
: $"\n{string.Join("\n", result.Warnings)}";
SetSaveStatus($"测试存档跳转成功。{warnings}", false);
return;
}
var errors = result?.Errors == null || result.Errors.Count == 0
? "未返回错误详情。"
: string.Join("\n", result.Errors);
SetSaveStatus($"跳转失败 [{result?.FailedPhase ?? "unknown"}]\n{errors}", true);
}
private void OpenTestSaveFolder()
{
if (DeveloperPathUtility.TryReveal(ConstRef.TestSavePath, true, out var error))
SetSaveStatus($"已打开:{ConstRef.TestSavePath}", false);
else
SetSaveStatus(error, true);
}
private void DeleteSelectedTestSave()
{
if (_selectedTestSave == null)
{
SetSaveStatus("请先选择要删除的测试存档。", true);
return;
}
if (!ConfirmDestructiveAction(
"delete-selected",
$"再次点击“删除选中”以确认删除 {_selectedTestSave.NodeName}。"))
{
return;
}
if (TestSaveRecorder.Repository.Delete(_selectedTestSave, out var error))
{
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
_savesDirty = true;
RefreshSaves();
}
else
{
SetSaveStatus($"删除失败:{error}", true);
}
}
private void DeleteInvalidTestSaves()
{
if (!ConfirmDestructiveAction("delete-invalid", "再次点击“清理无效”以确认批量删除所有无效条目。"))
{
return;
}
var count = TestSaveRecorder.Repository.DeleteInvalid(out var error);
if (error == null)
{
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
_savesDirty = true;
RefreshSaves();
SetSaveStatus($"已清理 {count} 个无效测试存档。", false);
}
else
{
SetSaveStatus($"清理失败:{error}", true);
}
}
private void ClearTestSaveLibrary()
{
if (!ConfirmDestructiveAction("clear-library", "再次点击“清空存档库”以确认删除全部测试存档。"))
{
return;
}
if (TestSaveRecorder.Repository.Clear(out var error))
{
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
_selectedTestSaveChapterId = null;
_savesDirty = true;
RefreshSaves();
SetSaveStatus("测试存档库已清空。", false);
}
else
{
SetSaveStatus($"清空失败:{error}", true);
}
}
private bool ConfirmDestructiveAction(string action, string prompt)
{
var now = Time.unscaledTime;
if (!string.Equals(_pendingDestructiveAction, action, StringComparison.Ordinal)
|| now > _pendingDestructiveActionDeadline)
{
_pendingDestructiveAction = action;
_pendingDestructiveActionDeadline = now + 5f;
SetSaveStatus(prompt + "5 秒内有效)", true);
return false;
}
_pendingDestructiveAction = null;
_pendingDestructiveActionDeadline = 0f;
return true;
}
private void UpdateTestSaveActions()
{
var hasSelection = _selectedTestSave != null;
if (jumpToTestSaveButton != null)
jumpToTestSaveButton.interactable = hasSelection
&& _selectedTestSaveCanJump
&& _selectedTestSave.IsValid
&& CanStartRestore();
if (deleteSelectedTestSaveButton != null)
deleteSelectedTestSaveButton.interactable = hasSelection;
}
private void UpdateTestSaveRecordingState(bool recording)
{
if (testSaveRecordingStateText == null) return;
testSaveRecordingStateText.text = recording ? "● RECORDING" : "○ OFF";
testSaveRecordingStateText.color = recording
? new Color(1f, 0.35f, 0.3f)
: new Color(0.55f, 0.65f, 0.7f);
}
private void OnTestSaveLibraryChanged()
{
_savesDirty = true;
}
#endif
private void BindDisplayControls()
{
showCursorToggle?.SetIsOnWithoutNotify(_showCursor);
showVersionToggle?.SetIsOnWithoutNotify(_showVersion);
showTopBarToggle?.SetIsOnWithoutNotify(_showTopBar);
showDialogToggle?.SetIsOnWithoutNotify(_showDialog);
showCursorToggle?.onValueChanged.AddListener(value =>
{
_showCursor = value;
if (!gameObject.activeSelf) CursorManager.Instance?.SetCursorVisible(value);
});
showVersionToggle?.onValueChanged.AddListener(value =>
{
_showVersion = value;
_verText?.SetVisible(value);
});
showTopBarToggle?.onValueChanged.AddListener(value =>
{
_showTopBar = value;
UIManager.Instance?.GetPanel<MainPanel>()?.SetTopBarVisible(value);
});
showDialogToggle?.onValueChanged.AddListener(value =>
{
_showDialog = value;
DialogUIManager.Instance?.SetDialogVisualVisible(value);
});
pauseGameButton?.onClick.AddListener(PauseGame);
resumeGameButton?.onClick.AddListener(ResumeGame);
halfSpeedButton?.onClick.AddListener(() => SetTimeScale(0.5f));
normalSpeedButton?.onClick.AddListener(() => SetTimeScale(1f));
doubleSpeedButton?.onClick.AddListener(() => SetTimeScale(2f));
}
private void PauseGame()
{
var success = GameManager.Instance != null && GameManager.Instance.TryPause();
SetControlStatus(success ? "游戏已暂停。" : "当前状态不可暂停。", !success);
}
private void ResumeGame()
{
var success = GameManager.Instance != null && GameManager.Instance.TryResume();
SetControlStatus(success ? "游戏已继续。" : "当前状态不可继续。", !success);
}
private void SetTimeScale(float value)
{
if (GameManager.Instance == null || GameManager.Session.IsBusy || GameManager.Session.IsPaused
|| GameManager.Session.Phase != GameSessionPhase.Playing)
{
SetControlStatus("仅在非暂停、非忙碌的 Playing 状态可调整倍速。", true);
return;
}
Time.timeScale = value;
SetControlStatus($"时间倍率已设为 {value:0.##}×。", false);
}
private void OnSessionEnded()
{
Time.timeScale = 1f;
_variablesDirty = true;
_savesDirty = true;
}
private void UpdateFilterLabels()
{
if (logLevelLabel != null)
{
logLevelLabel.text = _logLevelIndex == 0
? "最低等级:全部"
: $"最低等级:{(LogLevel)(_logLevelIndex - 1)}";
}
if (logCategoryLabel != null)
{
logCategoryLabel.text = _logCategoryIndex == 0
? "类别:全部"
: $"类别:{(LogCategory)(_logCategoryIndex - 1)}";
}
if (variableScopeLabel != null)
variableScopeLabel.text = new[] { "范围:全部", "范围:Global", "范围:Local" }[_variableScopeIndex];
if (variableTypeLabel != null)
variableTypeLabel.text = new[] { "类型:全部", "类型:Number", "类型:String", "类型:Bool" }[_variableTypeIndex];
}
private static DeveloperModeListRow GetRow(
IList<DeveloperModeListRow> pool,
DeveloperModeListRow template,
RectTransform parent,
int index)
{
while (pool.Count <= index)
{
var row = Instantiate(template, parent);
row.name = $"Row {pool.Count:000}";
pool.Add(row);
}
return pool[index];
}
private static void HideRows(IList<DeveloperModeListRow> rows, int fromIndex)
{
for (var i = fromIndex; i < rows.Count; i++)
{
rows[i].Hide();
}
}
private static Color GetLogColor(LogLevel level)
{
return level switch
{
LogLevel.Warning => new Color(1f, 0.82f, 0.3f),
LogLevel.Error or LogLevel.Fatal => new Color(1f, 0.42f, 0.38f),
LogLevel.Debug => new Color(0.6f, 0.7f, 0.75f),
_ => Color.white
};
}
private static bool Contains(string value, string search)
{
return !string.IsNullOrEmpty(value)
&& value.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength) return value;
return value.Substring(0, maxLength - 1) + "…";
}
private void SetOverviewStatus(string message)
{
if (overviewStatusText != null) overviewStatusText.text = message ?? string.Empty;
}
private void SetSaveStatus(string message, bool error)
{
if (saveStatusText == null) return;
saveStatusText.text = message ?? string.Empty;
saveStatusText.color = error ? new Color(1f, 0.45f, 0.4f) : new Color(0.45f, 1f, 0.65f);
}
private void SetControlStatus(string message, bool error)
{
if (controlStatusText == null) return;
controlStatusText.text = message ?? string.Empty;
controlStatusText.color = error ? new Color(1f, 0.45f, 0.4f) : new Color(0.45f, 1f, 0.65f);
}
}
}