504 lines
17 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|