1104 lines
43 KiB
C#
1104 lines
43 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|