feat(chapter): 添加章节图可视化编辑器

基于 UIToolkit 实现 ChapterGraph 编辑器窗口,可视化查看
和编辑 TalkSceneSO 之间的多出口连接关系。
This commit is contained in:
2026-04-25 20:13:55 +08:00
parent 93012f5f7c
commit 94d89330dc
11 changed files with 1217 additions and 0 deletions
+282
View File
@@ -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<Label>("title-label");
if (titleLabel != null)
{
titleLabel.style.fontSize = 16;
titleLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
}
}
public void UpdateTitle()
{
title = string.IsNullOrEmpty(SceneSo.title) ? SceneSo.name : SceneSo.title;
}
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
base.BuildContextualMenu(evt);
if (SceneSo.exits != null && SceneSo.exits.Count > 0)
{
for (int i = 0; i < SceneSo.exits.Count; i++)
{
var exit = SceneSo.exits[i];
var name = string.IsNullOrEmpty(exit.exitName) ? $"出口{i}" : exit.exitName;
var idx = i;
evt.menu.AppendAction($"删除出口/{name}", _ => RemoveExitAt(idx));
}
}
}
public void UpdateColor()
{
style.backgroundColor = new StyleColor(SceneSo.nodeColor);
}
private void BuildInputPort()
{
InputPort = Port.Create<Edge>(
Orientation.Horizontal,
Direction.Input,
Port.Capacity.Single,
typeof(TalkSceneSO));
InputPort.portName = "▶";
InputPort.style.fontSize = 14;
InputPort.AddManipulator(new EdgeConnector<Edge>(new EmptyEdgeListener()));
inputContainer.Add(InputPort);
}
private void BuildOutputPorts()
{
outputContainer.Clear();
if (SceneSo.exits == null) return;
for (int i = 0; i < SceneSo.exits.Count; i++)
{
var exit = SceneSo.exits[i];
var port = CreateOutputPort(exit, i);
outputContainer.Add(port);
}
}
private Port CreateOutputPort(SceneExit exit, int index)
{
var port = Port.Create<Edge>(
Orientation.Horizontal,
Direction.Output,
Port.Capacity.Single,
typeof(TalkSceneSO));
port.portName = string.IsNullOrEmpty(exit.exitName) ? $"出口{index}" : exit.exitName;
port.userData = index;
// 限制端口标签宽度并放大字体
var label = port.Q<Label>("type");
if (label != null)
{
label.style.maxWidth = PortLabelMaxWidth;
label.style.fontSize = 12;
}
port.AddManipulator(new EdgeConnector<Edge>(new EmptyEdgeListener()));
port.RegisterCallback<ContextualMenuPopulateEvent>((evt) =>
{
var currentIndex = port.userData is int idx ? idx : -1;
evt.menu.AppendAction("删除出口", (_) => RemoveExitAt(currentIndex));
}, TrickleDown.TrickleDown);
return port;
}
private void BuildSubtitle()
{
var subtitleLabel = new Label($"{SceneSo.name}")
{
style =
{
fontSize = 12,
unityTextAlign = TextAnchor.MiddleCenter,
color = new Color(0.8f, 0.8f, 0.8f),
paddingTop = 4,
paddingBottom = 2
}
};
extensionContainer.Add(subtitleLabel);
RefreshExpandedState();
}
private void BuildAddExitButton()
{
var removeButton = new Button(() => RemoveLastExit())
{
text = "-",
style =
{
width = 26,
height = 22,
fontSize = 16,
unityFontStyleAndWeight = FontStyle.Bold,
marginRight = 4
}
};
titleButtonContainer.Add(removeButton);
var addButton = new Button(() => AddExit())
{
text = "+",
style = { width = 26, height = 22, fontSize = 16, unityFontStyleAndWeight = FontStyle.Bold }
};
titleButtonContainer.Add(addButton);
}
private void RemoveLastExit()
{
if (SceneSo.exits == null || SceneSo.exits.Count == 0) return;
RemoveExitAt(SceneSo.exits.Count - 1);
}
public void AddExit()
{
Undo.RecordObject(SceneSo, "Add Exit");
if (SceneSo.exits == null)
SceneSo.exits = new System.Collections.Generic.List<SceneExit>();
var newExit = new SceneExit
{
exitName = $"exit_{SceneSo.exits.Count}"
};
SceneSo.exits.Add(newExit);
EditorUtility.SetDirty(SceneSo);
var newPort = CreateOutputPort(newExit, SceneSo.exits.Count - 1);
outputContainer.Add(newPort);
RefreshPorts();
}
public void RemoveExitAt(int index)
{
if (SceneSo.exits == null || index < 0 || index >= SceneSo.exits.Count) return;
// 断开并移除该端口的所有边
var portToRemove = GetOutputPort(index);
if (portToRemove != null)
{
var edges = portToRemove.connections.ToList();
foreach (var edge in edges)
{
edge.input?.Disconnect(edge);
edge.output?.Disconnect(edge);
edge.RemoveFromHierarchy();
}
portToRemove.RemoveFromHierarchy();
}
Undo.RecordObject(SceneSo, "Remove Exit");
SceneSo.exits.RemoveAt(index);
EditorUtility.SetDirty(SceneSo);
// 更新后续端口的 userData 和 portName
for (int i = index; i < SceneSo.exits.Count; i++)
{
var port = GetOutputPort(i);
if (port != null)
{
port.userData = i;
var exit = SceneSo.exits[i];
port.portName = string.IsNullOrEmpty(exit.exitName) ? $"出口{i}" : exit.exitName;
}
}
RefreshPorts();
}
public void RefreshOutputPorts()
{
// 先断开所有输出端口的边
var edgesToRemove = outputContainer.Children()
.OfType<Port>()
.SelectMany(p => p.connections.ToList())
.ToList();
foreach (var edge in edgesToRemove)
{
edge.input?.Disconnect(edge);
edge.output?.Disconnect(edge);
edge.RemoveFromHierarchy();
}
BuildOutputPorts();
RefreshPorts();
}
public Port GetOutputPort(int index)
{
return outputContainer.Children().OfType<Port>().ElementAtOrDefault(index);
}
public override void SetPosition(Rect newPos)
{
base.SetPosition(newPos);
if (SceneSo != null)
{
SceneSo.graphPosition = newPos.position;
EditorUtility.SetDirty(SceneSo);
}
}
public void SyncPositionToSo()
{
if (SceneSo == null) return;
var pos = GetPosition();
if (SceneSo.graphPosition != pos.position)
{
SceneSo.graphPosition = pos.position;
EditorUtility.SetDirty(SceneSo);
}
}
private class EmptyEdgeListener : IEdgeConnectorListener
{
public void OnDropOutsidePort(Edge edge, Vector2 position) { }
public void OnDrop(GraphView graphView, Edge edge) { }
}
}
}