feat(chapter): 添加章节图可视化编辑器
基于 UIToolkit 实现 ChapterGraph 编辑器窗口,可视化查看 和编辑 TalkSceneSO 之间的多出口连接关系。
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AibisDream;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class ChapterGraphEditorWindow : EditorWindow
|
||||
{
|
||||
private ChapterGraphView _graphView;
|
||||
private ChapterGraphInspector _inspector;
|
||||
private IMGUIContainer _inspectorContainer;
|
||||
|
||||
private const string RootFolderPrefKey = "AibisDream.ChapterGraph.RootFolder";
|
||||
private const string CurrentFolderPrefKey = "AibisDream.ChapterGraph.CurrentFolder";
|
||||
private string _rootFolder;
|
||||
private string _currentFolder;
|
||||
private DropdownField _folderDropdown;
|
||||
private readonly Dictionary<string, string> _displayToPath = new();
|
||||
|
||||
[MenuItem("Window/Chapter Graph Editor")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<ChapterGraphEditorWindow>("Chapter Graph Editor");
|
||||
window.minSize = new Vector2(800, 600);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_graphView == null) return;
|
||||
_graphView.LoadGraph(_currentFolder);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_graphView != null)
|
||||
{
|
||||
_graphView.OnNodeDeleted -= OnNodeDeleted;
|
||||
}
|
||||
if (_inspector != null)
|
||||
{
|
||||
_inspector.OnColorChanged -= OnNodeColorChanged;
|
||||
_inspector.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateGUI()
|
||||
{
|
||||
// 根容器:纵向排列
|
||||
var root = rootVisualElement;
|
||||
root.style.flexDirection = FlexDirection.Column;
|
||||
|
||||
// 顶部工具栏
|
||||
var toolbar = new Toolbar();
|
||||
toolbar.Add(CreateToolbarButton("Save", OnSaveClicked));
|
||||
toolbar.Add(CreateToolbarButton("Auto Layout", OnAutoLayoutClicked));
|
||||
toolbar.Add(CreateToolbarButton("Validate", OnValidateClicked));
|
||||
|
||||
_rootFolder = EditorPrefs.GetString(RootFolderPrefKey, "Assets/ScriptableObjects/SceneSO");
|
||||
|
||||
_folderDropdown = new DropdownField("路径");
|
||||
_folderDropdown.style.width = 160;
|
||||
_folderDropdown.style.marginLeft = 8;
|
||||
_folderDropdown.style.marginRight = 4;
|
||||
RefreshFolderChoices();
|
||||
_folderDropdown.RegisterValueChangedCallback(OnFolderChanged);
|
||||
toolbar.Add(_folderDropdown);
|
||||
|
||||
var setRootBtn = new Button(OnSetRootFolderClicked) { text = "..." };
|
||||
setRootBtn.style.width = 28;
|
||||
toolbar.Add(setRootBtn);
|
||||
|
||||
toolbar.Add(new ToolbarSpacer() { style = { flexGrow = 1 } });
|
||||
toolbar.Add(CreateToolbarButton("Create Chapter", OnCreateChapterClicked));
|
||||
root.Add(toolbar);
|
||||
|
||||
// 主内容区:横向分栏
|
||||
var mainContainer = new VisualElement();
|
||||
mainContainer.style.flexGrow = 1;
|
||||
mainContainer.style.flexDirection = FlexDirection.Row;
|
||||
mainContainer.style.overflow = Overflow.Hidden;
|
||||
|
||||
// 左侧 Inspector 面板
|
||||
var leftPanel = new VisualElement();
|
||||
leftPanel.style.width = 320;
|
||||
leftPanel.style.minWidth = 200;
|
||||
leftPanel.style.maxWidth = 500;
|
||||
leftPanel.style.flexShrink = 0;
|
||||
leftPanel.style.flexDirection = FlexDirection.Column;
|
||||
leftPanel.style.borderRightWidth = 1;
|
||||
leftPanel.style.borderRightColor = new StyleColor(new Color(0.15f, 0.15f, 0.15f));
|
||||
|
||||
// Inspector 标题
|
||||
var header = new Label("章节属性")
|
||||
{
|
||||
style =
|
||||
{
|
||||
unityFontStyleAndWeight = FontStyle.Bold,
|
||||
fontSize = 14,
|
||||
paddingTop = 8,
|
||||
paddingBottom = 8,
|
||||
paddingLeft = 12,
|
||||
paddingRight = 12,
|
||||
borderBottomWidth = 1,
|
||||
borderBottomColor = new StyleColor(new Color(0.15f, 0.15f, 0.15f))
|
||||
}
|
||||
};
|
||||
leftPanel.Add(header);
|
||||
|
||||
// IMGUI Inspector
|
||||
_inspector = new ChapterGraphInspector();
|
||||
_inspectorContainer = new IMGUIContainer(() => _inspector.OnGUI());
|
||||
_inspectorContainer.style.flexGrow = 1;
|
||||
leftPanel.Add(_inspectorContainer);
|
||||
|
||||
mainContainer.Add(leftPanel);
|
||||
|
||||
// 拖拽条
|
||||
var resizer = new VisualElement();
|
||||
resizer.style.width = 5;
|
||||
resizer.style.backgroundColor = new StyleColor(new Color(0.15f, 0.15f, 0.15f));
|
||||
mainContainer.Add(resizer);
|
||||
|
||||
float startWidth = 0;
|
||||
float startMouseX = 0;
|
||||
resizer.RegisterCallback<MouseDownEvent>(evt =>
|
||||
{
|
||||
startWidth = leftPanel.resolvedStyle.width;
|
||||
startMouseX = evt.mousePosition.x;
|
||||
resizer.CaptureMouse();
|
||||
evt.StopPropagation();
|
||||
});
|
||||
resizer.RegisterCallback<MouseMoveEvent>(evt =>
|
||||
{
|
||||
if (!resizer.HasMouseCapture()) return;
|
||||
var delta = evt.mousePosition.x - startMouseX;
|
||||
var newWidth = Mathf.Clamp(startWidth + delta, 200, 600);
|
||||
leftPanel.style.width = newWidth;
|
||||
});
|
||||
resizer.RegisterCallback<MouseUpEvent>(evt =>
|
||||
{
|
||||
if (resizer.HasMouseCapture())
|
||||
resizer.ReleaseMouse();
|
||||
});
|
||||
|
||||
// 右侧 GraphView
|
||||
_graphView = new ChapterGraphView();
|
||||
_graphView.style.flexGrow = 1;
|
||||
mainContainer.Add(_graphView);
|
||||
|
||||
root.Add(mainContainer);
|
||||
|
||||
// 加载图数据
|
||||
_graphView.LoadGraph(_currentFolder);
|
||||
|
||||
// 选中节点同步到 Inspector
|
||||
_graphView.OnNodeSelected += OnNodeSelected;
|
||||
_graphView.OnNodeDeleted += OnNodeDeleted;
|
||||
_inspector.OnColorChanged += OnNodeColorChanged;
|
||||
}
|
||||
|
||||
private void OnNodeDeleted(TalkSceneSO so)
|
||||
{
|
||||
_inspector?.SetTarget(null);
|
||||
_inspectorContainer?.MarkDirtyRepaint();
|
||||
}
|
||||
|
||||
private void OnNodeColorChanged(TalkSceneSO so)
|
||||
{
|
||||
_graphView?.UpdateNodeColor(so);
|
||||
}
|
||||
|
||||
private Button CreateToolbarButton(string text, System.Action onClick)
|
||||
{
|
||||
var btn = new Button(onClick) { text = text };
|
||||
return btn;
|
||||
}
|
||||
|
||||
private void OnSaveClicked()
|
||||
{
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorUtility.DisplayDialog("保存", "所有资源已保存。", "确定");
|
||||
}
|
||||
|
||||
private void OnAutoLayoutClicked()
|
||||
{
|
||||
_graphView?.AutoLayout();
|
||||
}
|
||||
|
||||
private void OnValidateClicked()
|
||||
{
|
||||
_graphView?.ValidateGraph();
|
||||
}
|
||||
|
||||
private void OnCreateChapterClicked()
|
||||
{
|
||||
var center = new Vector2(position.width / 2f, position.height / 2f);
|
||||
var localPos = _graphView.contentViewContainer.WorldToLocal(center);
|
||||
var defaultFolder = _currentFolder ?? EditorPrefs.GetString(ChapterGraphView.DefaultFolderPrefKey, _rootFolder);
|
||||
_graphView?.CreateChapterNodeAt(localPos, defaultFolder);
|
||||
}
|
||||
|
||||
private void OnNodeSelected(TalkSceneSO so)
|
||||
{
|
||||
_inspector?.SetTarget(so);
|
||||
_inspectorContainer?.MarkDirtyRepaint();
|
||||
}
|
||||
|
||||
private void RefreshFolderChoices()
|
||||
{
|
||||
_displayToPath.Clear();
|
||||
_displayToPath["All"] = null;
|
||||
|
||||
var subFolders = AssetDatabase.GetSubFolders(_rootFolder);
|
||||
foreach (var folder in subFolders)
|
||||
{
|
||||
var name = Path.GetFileName(folder);
|
||||
_displayToPath[name] = folder;
|
||||
}
|
||||
|
||||
var rootHasSO = AssetDatabase.FindAssets("t:TalkSceneSO", new[] { _rootFolder })
|
||||
.Select(g => AssetDatabase.GUIDToAssetPath(g))
|
||||
.Any(path => Path.GetDirectoryName(path)?.Replace('\\', '/') == _rootFolder);
|
||||
|
||||
if (rootHasSO)
|
||||
{
|
||||
_displayToPath["(Root)"] = _rootFolder;
|
||||
}
|
||||
|
||||
_folderDropdown.choices = _displayToPath.Keys.ToList();
|
||||
|
||||
var saved = EditorPrefs.GetString(CurrentFolderPrefKey, "All");
|
||||
_folderDropdown.value = _displayToPath.ContainsKey(saved) ? saved : "All";
|
||||
_currentFolder = _displayToPath.TryGetValue(_folderDropdown.value, out var path) ? path : null;
|
||||
}
|
||||
|
||||
private void OnFolderChanged(ChangeEvent<string> evt)
|
||||
{
|
||||
_currentFolder = _displayToPath.TryGetValue(evt.newValue, out var path) ? path : null;
|
||||
EditorPrefs.SetString(CurrentFolderPrefKey, evt.newValue);
|
||||
_graphView?.LoadGraph(_currentFolder);
|
||||
}
|
||||
|
||||
private void OnSetRootFolderClicked()
|
||||
{
|
||||
var path = EditorUtility.OpenFolderPanel("选择章节根目录", _rootFolder, "");
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
|
||||
var absoluteRoot = Application.dataPath.Replace('\\', '/');
|
||||
var selected = path.Replace('\\', '/');
|
||||
if (!selected.StartsWith(absoluteRoot))
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "请选择项目 Assets 目录内的文件夹。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
_rootFolder = "Assets" + selected.Substring(absoluteRoot.Length);
|
||||
EditorPrefs.SetString(RootFolderPrefKey, _rootFolder);
|
||||
RefreshFolderChoices();
|
||||
_graphView?.LoadGraph(_currentFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user