diff --git a/Assets/Editor/ChapterGraph.meta b/Assets/Editor/ChapterGraph.meta new file mode 100644 index 000000000..bb3d1e74e --- /dev/null +++ b/Assets/Editor/ChapterGraph.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 09d2581fcb827614bba6bf5c3279358e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs b/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs new file mode 100644 index 000000000..c265e4f6c --- /dev/null +++ b/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs @@ -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 _displayToPath = new(); + + [MenuItem("Window/Chapter Graph Editor")] + public static void ShowWindow() + { + var window = GetWindow("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(evt => + { + startWidth = leftPanel.resolvedStyle.width; + startMouseX = evt.mousePosition.x; + resizer.CaptureMouse(); + evt.StopPropagation(); + }); + resizer.RegisterCallback(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(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 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); + } + } +} diff --git a/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs.meta b/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs.meta new file mode 100644 index 000000000..a13fed005 --- /dev/null +++ b/Assets/Editor/ChapterGraph/ChapterGraphEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c30bb2152169f544cb672c29ac8cb5d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/ChapterGraph/ChapterGraphInspector.cs b/Assets/Editor/ChapterGraph/ChapterGraphInspector.cs new file mode 100644 index 000000000..1f609b90f --- /dev/null +++ b/Assets/Editor/ChapterGraph/ChapterGraphInspector.cs @@ -0,0 +1,70 @@ +using AibisDream; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.SystemEditor +{ + public class ChapterGraphInspector + { + private Editor _editor; + private TalkSceneSO _target; + private Vector2 _scrollPosition; + + public event System.Action OnColorChanged; + + public void SetTarget(TalkSceneSO so) + { + // 不能用 ==,Unity 重载了 ==,已销毁的对象 == null 为 true + if (ReferenceEquals(_target, so)) return; + + _target = so; + if (_editor != null) + { + Object.DestroyImmediate(_editor); + _editor = null; + } + + if (so != null) + { + _editor = Editor.CreateEditor(so); + } + } + + public void OnGUI() + { + if (_editor == null) + { + EditorGUILayout.HelpBox("请在图中选中一个章节节点以编辑属性", MessageType.Info); + return; + } + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + + EditorGUI.BeginChangeCheck(); + var newColor = EditorGUILayout.ColorField("节点颜色", _target.nodeColor); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_target, "Change Node Color"); + _target.nodeColor = newColor; + EditorUtility.SetDirty(_target); + OnColorChanged?.Invoke(_target); + } + + EditorGUILayout.Space(6); + + _editor.OnInspectorGUI(); + EditorGUILayout.EndScrollView(); + } + + public void Destroy() + { + if (_editor != null) + { + Object.DestroyImmediate(_editor); + _editor = null; + } + + _target = null; + } + } +} diff --git a/Assets/Editor/ChapterGraph/ChapterGraphInspector.cs.meta b/Assets/Editor/ChapterGraph/ChapterGraphInspector.cs.meta new file mode 100644 index 000000000..0e5592c47 --- /dev/null +++ b/Assets/Editor/ChapterGraph/ChapterGraphInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9b122b6f926732f46b067d9b26eb191b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/ChapterGraph/ChapterGraphView.cs b/Assets/Editor/ChapterGraph/ChapterGraphView.cs new file mode 100644 index 000000000..ea2297b9e --- /dev/null +++ b/Assets/Editor/ChapterGraph/ChapterGraphView.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AibisDream; +using UnityEditor; +using UnityEditor.Experimental.GraphView; +using UnityEngine; +using UnityEngine.UIElements; + +namespace AibisDream.SystemEditor +{ + public class ChapterGraphView : GraphView + { + public event Action OnNodeSelected; + public event Action OnNodeDeleted; + + private const float HorizontalSpacing = 420f; + private const float VerticalSpacing = 220f; + public const string DefaultFolderPrefKey = "AibisDream.ChapterGraph.DefaultFolder"; + public const string DefaultFolder = "Assets/ScriptableObjects/SceneSO"; + + public ChapterGraphView() + { + style.flexGrow = 1; + + SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale); + this.AddManipulator(new ContentDragger()); + this.AddManipulator(new SelectionDragger()); + this.AddManipulator(new RectangleSelector()); + + var grid = new GridBackground(); + Insert(0, grid); + grid.StretchToParentSize(); + + graphViewChanged += OnGraphViewChanged; + } + + public void UpdateNodeColor(TalkSceneSO so) + { + foreach (var node in graphElements.OfType()) + { + if (node.SceneSo == so) + { + node.UpdateColor(); + break; + } + } + } + + public void LoadGraph(string folderPath = null) + { + ClearGraph(); + + var guids = AssetDatabase.FindAssets("t:TalkSceneSO", folderPath != null ? new[] { folderPath } : null); + var allSos = guids.Select(g => AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(g))).Where(so => so != null).ToList(); + var nodeMap = new Dictionary(); + + // 创建节点 + foreach (var so in allSos) + { + var node = new ChapterNode(so); + AddElement(node); + nodeMap[so] = node; + } + + // 创建连线 + foreach (var so in allSos) + { + if (so.exits == null) continue; + if (!nodeMap.TryGetValue(so, out var sourceNode)) continue; + + for (int i = 0; i < so.exits.Count; i++) + { + var exit = so.exits[i]; + if (exit == null || exit.targetScene == null) continue; + if (!nodeMap.TryGetValue(exit.targetScene, out var targetNode)) continue; + + var outputPort = sourceNode.GetOutputPort(i); + var inputPort = targetNode.InputPort; + if (outputPort == null || inputPort == null) continue; + + var edge = new Edge + { + output = outputPort, + input = inputPort + }; + edge.output.Connect(edge); + edge.input.Connect(edge); + AddElement(edge); + } + } + } + + public void ClearGraph() + { + // 清除所有元素 + var elementsToRemove = graphElements.ToList(); + foreach (var element in elementsToRemove) + { + RemoveElement(element); + } + } + + public override void BuildContextualMenu(ContextualMenuPopulateEvent evt) + { + base.BuildContextualMenu(evt); + + if (evt.target is GraphView) + { + var mousePosition = contentViewContainer.WorldToLocal(evt.mousePosition); + evt.menu.AppendAction("添加章节节点", (_) => CreateChapterNodeAt(mousePosition)); + } + } + + public override List GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter) + { + var compatiblePorts = new List(); + var startNode = startPort.node as ChapterNode; + if (startNode == null) return compatiblePorts; + + ports.ForEach(port => + { + var targetNode = port.node as ChapterNode; + if (targetNode == null) return; + if (targetNode == startNode) return; + if (startPort.direction == port.direction) return; + if (startPort.node == port.node) return; + + compatiblePorts.Add(port); + }); + + return compatiblePorts; + } + + private GraphViewChange OnGraphViewChanged(GraphViewChange change) + { + // 先处理节点删除确认:用户取消则从移除列表中剔除 + if (change.elementsToRemove != null) + { + var nodesToRemove = change.elementsToRemove.OfType().ToList(); + foreach (var node in nodesToRemove) + { + if (!ConfirmRemoveNode(node)) + { + change.elementsToRemove.Remove(node); + // 同时保留与该节点相连的边,避免边被单独删掉 + var edgesToKeep = change.elementsToRemove.OfType() + .Where(e => e.output?.node == node || e.input?.node == node) + .ToList(); + foreach (var edge in edgesToKeep) + { + change.elementsToRemove.Remove(edge); + } + } + } + } + + // 处理 Edge 删除 + if (change.elementsToRemove != null) + { + foreach (var edge in change.elementsToRemove.OfType()) + { + HandleEdgeRemoved(edge); + } + + foreach (var node in change.elementsToRemove.OfType()) + { + HandleNodeRemoved(node); + } + } + + // 处理 Edge 创建 + if (change.edgesToCreate != null) + { + foreach (var edge in change.edgesToCreate) + { + HandleEdgeCreated(edge); + } + } + + // 处理节点移动 + if (change.movedElements != null) + { + foreach (var element in change.movedElements) + { + if (element is ChapterNode node) + { + node.SyncPositionToSo(); + } + } + } + + return change; + } + + private void HandleEdgeCreated(Edge edge) + { + if (!(edge.output?.node is ChapterNode sourceNode)) return; + if (!(edge.input?.node is ChapterNode targetNode)) return; + + var sourceSo = sourceNode.SceneSo; + var targetSo = targetNode.SceneSo; + if (sourceSo == null || targetSo == null) return; + + int exitIndex = edge.output.userData is int idx ? idx : -1; + if (exitIndex < 0 || exitIndex >= sourceSo.exits.Count) return; + + Undo.RecordObject(sourceSo, "Connect Chapter"); + sourceSo.exits[exitIndex].targetScene = targetSo; + EditorUtility.SetDirty(sourceSo); + } + + private void HandleEdgeRemoved(Edge edge) + { + if (!(edge.output?.node is ChapterNode sourceNode)) return; + + var sourceSo = sourceNode.SceneSo; + if (sourceSo == null) return; + + int exitIndex = edge.output.userData is int idx ? idx : -1; + if (exitIndex < 0 || exitIndex >= sourceSo.exits.Count) return; + + Undo.RecordObject(sourceSo, "Disconnect Chapter"); + sourceSo.exits[exitIndex].targetScene = null; + EditorUtility.SetDirty(sourceSo); + } + + private bool ConfirmRemoveNode(ChapterNode node) + { + var so = node.SceneSo; + if (so == null) return true; + + var referrers = new List(); + foreach (var other in graphElements.OfType()) + { + if (other == node) continue; + if (other.SceneSo == null || other.SceneSo.exits == null) continue; + foreach (var exit in other.SceneSo.exits) + { + if (exit.targetScene == so) + { + referrers.Add(string.IsNullOrEmpty(other.SceneSo.title) ? other.SceneSo.name : other.SceneSo.title); + break; + } + } + } + + var name = string.IsNullOrEmpty(so.title) ? so.name : so.title; + var msg = $"确定要删除章节节点 \"{name}\" 吗?"; + if (referrers.Count > 0) + { + msg += $"\n\n以下节点仍引用该节点:\n• {string.Join("\n• ", referrers)}"; + } + + return EditorUtility.DisplayDialog("删除确认", msg, "删除", "取消"); + } + + private void HandleNodeRemoved(ChapterNode node) + { + var so = node.SceneSo; + if (so == null) return; + + var path = AssetDatabase.GetAssetPath(so); + if (!string.IsNullOrEmpty(path)) + { + AssetDatabase.DeleteAsset(path); + } + + OnNodeDeleted?.Invoke(so); + } + + public override void AddToSelection(ISelectable selectable) + { + base.AddToSelection(selectable); + NotifySelectionChanged(); + } + + public override void RemoveFromSelection(ISelectable selectable) + { + base.RemoveFromSelection(selectable); + NotifySelectionChanged(); + } + + public override void ClearSelection() + { + base.ClearSelection(); + NotifySelectionChanged(); + } + + private void NotifySelectionChanged() + { + var selectedNode = selection.OfType().FirstOrDefault(); + OnNodeSelected?.Invoke(selectedNode?.SceneSo); + } + + public void CreateChapterNodeAt(Vector2 position, string defaultFolder = null) + { + var folder = defaultFolder ?? EditorPrefs.GetString(DefaultFolderPrefKey, DefaultFolder); + var path = EditorUtility.SaveFilePanelInProject("新建章节", "NewChapter", "asset", "选择新章节的保存位置", folder); + if (string.IsNullOrEmpty(path)) return; + + var so = ScriptableObject.CreateInstance(); + so.name = System.IO.Path.GetFileNameWithoutExtension(path); + so.title = "新章节"; + so.nodeColor = new Color(0.85f, 0.45f, 0.45f); + so.graphPosition = position; + so.exits = new System.Collections.Generic.List(); + + AssetDatabase.CreateAsset(so, path); + EditorUtility.SetDirty(so); + AssetDatabase.SaveAssets(); + + // 记住文件夹 + var savedFolder = System.IO.Path.GetDirectoryName(path)?.Replace('\\', '/'); + if (!string.IsNullOrEmpty(savedFolder)) + { + EditorPrefs.SetString(DefaultFolderPrefKey, savedFolder); + } + + var node = new ChapterNode(so); + AddElement(node); + } + + public void AutoLayout() + { + var nodes = graphElements.OfType().ToList(); + if (nodes.Count == 0) return; + + // 计算入度 + var inDegree = nodes.ToDictionary(n => n, _ => 0); + var edges = graphElements.OfType().ToList(); + foreach (var edge in edges) + { + if (edge.input?.node is ChapterNode target && inDegree.ContainsKey(target)) + { + inDegree[target]++; + } + } + + // 分层 BFS + var layer = new Dictionary(); + var queue = new Queue(); + + // 入度为 0 的节点作为起点 + foreach (var node in nodes) + { + if (inDegree[node] == 0) + { + layer[node] = 0; + queue.Enqueue(node); + } + } + + // 如果没有入度为 0 的节点(纯循环),任选一个作为起点 + if (queue.Count == 0) + { + layer[nodes[0]] = 0; + queue.Enqueue(nodes[0]); + } + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + var currentLayer = layer[current]; + + foreach (var edge in edges) + { + if (edge.output?.node == current && edge.input?.node is ChapterNode next) + { + if (!layer.ContainsKey(next)) + { + layer[next] = currentLayer + 1; + queue.Enqueue(next); + } + else + { + layer[next] = Math.Max(layer[next], currentLayer + 1); + } + } + } + } + + // 为未分配层级的节点(孤立或不连通部分)分配层级 + foreach (var node in nodes) + { + if (!layer.ContainsKey(node)) + { + layer[node] = 0; + } + } + + // 按层级分组并计算位置 + var groups = layer.GroupBy(kvp => kvp.Value).OrderBy(g => g.Key).ToList(); + Undo.RecordObjects(nodes.Select(n => n.SceneSo).ToArray(), "Auto Layout"); + + for (int i = 0; i < groups.Count; i++) + { + var group = groups[i]; + var groupNodes = group.ToList(); + float x = group.Key * HorizontalSpacing; + float startY = -(groupNodes.Count - 1) * VerticalSpacing / 2f; + + for (int j = 0; j < groupNodes.Count; j++) + { + var node = groupNodes[j].Key; + var y = startY + j * VerticalSpacing; + var newPos = new Vector2(x, y); + node.SetPosition(new Rect(newPos, Vector2.zero)); + node.SceneSo.graphPosition = newPos; + EditorUtility.SetDirty(node.SceneSo); + } + } + } + + public void ValidateGraph() + { + var nodes = graphElements.OfType().ToList(); + var issues = new List(); + var soSet = new HashSet(nodes.Select(n => n.SceneSo)); + + // 孤立节点 + foreach (var node in nodes) + { + var so = node.SceneSo; + bool hasIncoming = graphElements.OfType().Any(e => e.input?.node == node); + bool hasOutgoing = so.exits != null && so.exits.Any(e => e.targetScene != null); + + if (!hasIncoming && !hasOutgoing) + { + issues.Add($"孤立节点: {so.name} (没有入边也没有出边)"); + } + } + + // 循环引用 (DFS) + var visited = new HashSet(); + var visiting = new HashSet(); + + foreach (var so in soSet) + { + if (!visited.Contains(so)) + { + CheckCycle(so, visited, visiting, soSet, issues); + } + } + + // 空 exitName + foreach (var node in nodes) + { + var so = node.SceneSo; + if (so.exits == null) continue; + for (int i = 0; i < so.exits.Count; i++) + { + if (string.IsNullOrEmpty(so.exits[i].exitName)) + { + issues.Add($"空 exitName: {so.name} 的第 {i + 1} 个出口"); + } + } + } + + if (issues.Count == 0) + { + EditorUtility.DisplayDialog("验证结果", "图表验证通过,未发现问题。", "确定"); + } + else + { + var msg = string.Join("\n", issues.Take(20)); + if (issues.Count > 20) + { + msg += $"\n... 还有 {issues.Count - 20} 个问题"; + } + EditorUtility.DisplayDialog($"验证发现 {issues.Count} 个问题", msg, "确定"); + } + } + + private void CheckCycle(TalkSceneSO current, HashSet visited, HashSet visiting, HashSet soSet, List issues) + { + visited.Add(current); + visiting.Add(current); + + if (current.exits != null) + { + foreach (var exit in current.exits) + { + var next = exit.targetScene; + if (next == null || !soSet.Contains(next)) continue; + + if (visiting.Contains(next)) + { + issues.Add($"循环引用: {current.name} -> {next.name}"); + continue; + } + + if (!visited.Contains(next)) + { + CheckCycle(next, visited, visiting, soSet, issues); + } + } + } + + visiting.Remove(current); + } + } +} diff --git a/Assets/Editor/ChapterGraph/ChapterGraphView.cs.meta b/Assets/Editor/ChapterGraph/ChapterGraphView.cs.meta new file mode 100644 index 000000000..eeb632c81 --- /dev/null +++ b/Assets/Editor/ChapterGraph/ChapterGraphView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 913b090485a3d844fab3e436e7bf4f57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/ChapterGraph/ChapterNode.cs b/Assets/Editor/ChapterGraph/ChapterNode.cs new file mode 100644 index 000000000..cfe5a5d49 --- /dev/null +++ b/Assets/Editor/ChapterGraph/ChapterNode.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AibisDream; +using UnityEditor; +using UnityEditor.Experimental.GraphView; +using UnityEngine; +using UnityEngine.UIElements; + +namespace AibisDream.SystemEditor +{ + public class ChapterNode : Node + { + public TalkSceneSO SceneSo { get; } + public Port InputPort { get; private set; } + + private const float PortLabelMaxWidth = 120f; + + public ChapterNode(TalkSceneSO so) + { + SceneSo = so ?? throw new ArgumentNullException(nameof(so)); + + // 节点整体放大 + style.minWidth = 240; + style.paddingTop = 8; + style.paddingBottom = 8; + style.paddingLeft = 10; + style.paddingRight = 10; + + UpdateTitle(); + UpdateColor(); + BuildInputPort(); + BuildOutputPorts(); + BuildSubtitle(); + BuildAddExitButton(); + SetPosition(new Rect(so.graphPosition, Vector2.zero)); + + // 放大标题 + var titleLabel = this.Q