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 { /// /// Editor / Development Build 中使用的统一运行时开发模式。 /// 这是非模态覆盖层,所有改变游戏状态的操作仍通过正式 GameManager/SaveSystem 入口执行。 /// 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 _logRows = new(); private readonly List _variableRows = new(); #if UNITY_EDITOR || DEVELOPMENT_BUILD private readonly List _testSaveChapterRows = new(); private readonly List _testSaveEntryRows = new(); private IReadOnlyList _testSaveCoverage = Array.Empty(); 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(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.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.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 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(); 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()?.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 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 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); } } }