Files
aibis-dream/Assets/Editor/ChapterGraph/ChapterGraphView.cs
T
dingyuntian 94d89330dc feat(chapter): 添加章节图可视化编辑器
基于 UIToolkit 实现 ChapterGraph 编辑器窗口,可视化查看
和编辑 TalkSceneSO 之间的多出口连接关系。
2026-04-25 20:13:55 +08:00

504 lines
17 KiB
C#

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<TalkSceneSO> OnNodeSelected;
public event Action<TalkSceneSO> 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<ChapterNode>())
{
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<TalkSceneSO>(AssetDatabase.GUIDToAssetPath(g))).Where(so => so != null).ToList();
var nodeMap = new Dictionary<TalkSceneSO, ChapterNode>();
// 创建节点
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<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
var compatiblePorts = new List<Port>();
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<ChapterNode>().ToList();
foreach (var node in nodesToRemove)
{
if (!ConfirmRemoveNode(node))
{
change.elementsToRemove.Remove(node);
// 同时保留与该节点相连的边,避免边被单独删掉
var edgesToKeep = change.elementsToRemove.OfType<Edge>()
.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<Edge>())
{
HandleEdgeRemoved(edge);
}
foreach (var node in change.elementsToRemove.OfType<ChapterNode>())
{
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<string>();
foreach (var other in graphElements.OfType<ChapterNode>())
{
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<ChapterNode>().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<TalkSceneSO>();
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<SceneExit>();
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<ChapterNode>().ToList();
if (nodes.Count == 0) return;
// 计算入度
var inDegree = nodes.ToDictionary(n => n, _ => 0);
var edges = graphElements.OfType<Edge>().ToList();
foreach (var edge in edges)
{
if (edge.input?.node is ChapterNode target && inDegree.ContainsKey(target))
{
inDegree[target]++;
}
}
// 分层 BFS
var layer = new Dictionary<ChapterNode, int>();
var queue = new Queue<ChapterNode>();
// 入度为 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<ChapterNode>().ToList();
var issues = new List<string>();
var soSet = new HashSet<TalkSceneSO>(nodes.Select(n => n.SceneSo));
// 孤立节点
foreach (var node in nodes)
{
var so = node.SceneSo;
bool hasIncoming = graphElements.OfType<Edge>().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<TalkSceneSO>();
var visiting = new HashSet<TalkSceneSO>();
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<TalkSceneSO> visited, HashSet<TalkSceneSO> visiting, HashSet<TalkSceneSO> soSet, List<string> 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);
}
}
}