参考链接:https://www.youtube.com/watch?v=7KHGH0fPL84&ab_channel=MertKirimgeri
GraphView介绍
Unity在2018.1的版本开始加入了一个节点绘制系统,类似于XNode,它不需要在Unity里安装任何Package或者像XNode一样添加任何脚本,只需要使用Unity的官方API即可。Unity里的Shader Graph,VFX Graph和Visual Scripting都是通过Graph View API实现的。这玩意儿适合做Unity的相关编辑器。
相关的API都在对应的命名空间下UnityEditor.Experimental.GraphView
PS: 这一块内容其实是Unity的UI Elements的子集,了解了UI Element,再来学Graph View会更容易上手
下面做一个Demo,在这个Demo里进行Graph View API的学习,这个例子利用GraphView API和Unity的UIElements创建了一个用于人物对话的节点编辑系统,有点类似于蓝图。
1. 创建GraphView和Node的底层类
下面会利用Graph View API创建一个dialogue node system,一个节点系统的图是由图本身和其内部的节点构成的,所以这里创建两个类,分别对应着UI图的类,和UI节点的类,每个类各自对应一个脚本,代码如下:
public class DialogueGraphView: GraphView {
public DialogueGraphView() {
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
this.AddManipulator(new ContentDragger());
this.AddManipulator(new SelectionDragger());
this.AddManipulator(new RectangleSelector());
}
}
public class DialogueNode : Node {
public string GUID;
public string Text;
public bool Entry = false;
}
2. 创建空的Editor Window
在创建完GraphView和Node底层数据之后,要想把它们显示出来,到窗口上,则还需要一个EditorWindow,相关代码如下:
public class DialogueGraphWindow: EditorWindow {
[MenuItem("Graph/Open Dialogue Graph View")]
public static void OpenDialogueGraphWindow()
{
var window = GetWindow<DialogueGraphWindow>();
window.titleContent = new GUIContent( "Dialogue Graph");
}
}
然后现在点击menu下的Graph->Open Dialogue Graph View,就可以打开一个空窗口了,如下图所示:
3. 将GraphView作为Canvas,展示到对应的EditorWindow里
目前这个Window实际上跟前面的GraphView和Node类没有任何关系,只是一个空窗口,下面可以在Window类里定义GraphView为其数据成员,然后在其OnEnter函数里,对GraphView进行创建和初始化等操作:
public class DialogueGraphWindow : EditorWindow
{
private DialogueGraphView _graphView;
...
private void OnEnable()
{
Debug.Log("New GraphView");
_graphView = new DialogueGraphView
{
name = "Dialogue Graph"
};
_graphView.StretchToParentSize();
rootVisualElement.Add(_graphView);
}
private void OnDisable()
{
rootVisualElement.Remove(_graphView);
}
}
public DialogueGraphView()
{
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
this.AddManipulator(new ContentDragger());
this.AddManipulator(new SelectionDragger());
this.AddManipulator(new RectangleSelector());
}
此时再打开Window,就能在里面展示GraphView了,由于在前面的GraphView的ctor里new了RectangleSelector,所以可以在里面进行框选操作,如下图所示:
4.创建初始节点,作为EntryPoint
前面在创建Window时,new出来对应的GraphView。接下来就是创建和展示里面的Nodes了,正常的操作应该是,一个GraphView有一个初始节点,剩下的节点都可以从该节点拖拽出来。所以,初始节点的创建可以放到GraphView的Ctor里进行,代码如下所示:
public class DialogueGraphView: GraphView
{
public DialogueGraphView()
{
...
var startNode = GenEntryPointNode();
AddElement(startNode);
var port = GenPortForNode(startNode, Direction.Output);
port.portName = "Next";
startNode.outputContainer.Add(port);
}
private DialogueNode GenEntryPointNode()
{
DialogueNode node = new DialogueNode
{
title = "START",
GUID = Guid.NewGuid().ToString(),
Text = "ENTRYPOINT",
Entry = true
};
node.SetPosition(new Rect(x: 100, y: 200, width: 100, height: 150));
return node;
}
private Port GenPortForNode(Node n, Direction portDir, Port.Capacity capacity = Port.Capacity.Single)
{
return n.InstantiatePort(Orientation.Horizontal, portDir, capacity, typeof(float));
}
}
此时再打开GraphView,就可以看到对应的StartNode了,如下图所示,Next可以往外拖出Edge,而且Start节点也可以四处拖拽: 仔细看一下,发现上面的Start的左边部分还是不大对劲,这是因为在为node添加port之后,需要调用对应的refresh函数来刷新layout,所以只需要在添加port之后加上refresh的代码即可:
...
startNode.outputContainer.Add(port);
startNode.RefreshExpandedState();
startNode.RefreshPorts();
然后布局就会变成正常的样子了:
5. 添加菜单工具栏,点击工具栏可以添加更多的Node
为了实现新功能,需要做两件事情:
- 设计一个函数,函数可以产生一个Node,函数接收一个string,作为新的DialogueNode的Text
- 为GraphView添加工具栏,点击工具栏上的Add Node,即调用第一步创建的函数
第一步,写一个可以创建Node的函数,跟前面提到的GenEntryNode的方式类似,无非就是多一个Input的port,代码如下:
public void AddDialogueNode(string nodeName)
{
DialogueNode node = new DialogueNode
{
title = nodeName,
GUID = Guid.NewGuid().ToString(),
Text = nodeName,
Entry = false
};
node.SetPosition(new Rect(x: 100, y: 200, width: 100, height: 150));
var iport = GenPortForNode(node, Direction.Input, Port.Capacity.Multi);
iport.portName = "input";
node.inputContainer.Add(iport);
node.RefreshExpandedState();
node.RefreshPorts();
AddElement(node);
}
第二步,创建用于添加Node的UI按钮,即Unity的Button对象,这里把button放到统一的一行里了(即toolbar),如下图所示:
相关代码如下:
Toolbar toolbar = new Toolbar();
Button btn = new Button(clickEvent: () => { _graphView.AddDialogueNode("Dialogue"); });
btn.text = "Add Dialogue Node";
toolbar.Add(btn);
rootVisualElement.Add(toolbar);
最后的效果就是下图这样了,点击按钮可以在EntryNode相同的地方创建新的Node,可以拖拽出来:
6. 让节点之间可以拖拽连接起来
目前的StartNode节点和创建的节点是不可以连接起来的,根据视频里说的,这是因为还没有对新创建的Node的Input Port作类型要求。
var startP = n.InstantiatePort(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(float));
var newNodeP = n.InstantiatePort(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(float));
要让节点之间可以连线,需要重载函数GetCompatiblePort ,代码如下所示:
public virtual List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter);
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter adapter)
{
List<Port> compatiblePorts = new List<Port>();
ports.ForEach((port) =>
{
if (port != startPort && port.node != startPort.node)
{
compatiblePorts.Add(port);
}
});
return compatiblePorts;
}
OK,现在就可以把StartNode跟其他的Node相连了,不过一个Output口目前好像只能连一个Node,如下图所示:
7. 为节点添加output port
一个节点其output port的数量应该是可以通过节点的GUI来调整的,预期是做出下图这样的情况,当点击Add Output时,会添加Output端口:
相关的行为也可以分为两步:
- 设计一个函数,这个函数的参数是Node,它会为Node添加一个Output port
- 相关的GUI设计,当点击Node的title上的button时,调用第一步设计的函数
第一步,设计函数,代码如下:
private void AddOutputPort(DialogueNode node)
{
var outPort = GenPortForNode(node, Direction.Output);
var count = node.outputContainer.Query("connector").ToList().Count;
string name = $"Output {count}";
outPort.portName = name;
node.outputContainer.Add(outPort);
node.RefreshExpandedState();
node.RefreshPorts();
}
第二步,实现对应的GUI部分,相关的代码可以直接放到Node的创建函数里,代码如下:
private DialogueNode GenDialogueNode(string nodeName)
{
...
...
Button btn = new Button(() =>
{
AddOutputPort(node);
});
btn.text = "Add Output Port";
node.titleContainer.Add(btn);
return node;
}
这样就能实现前面图里面贴出来的效果了
8. 为Graph添加背景的网格框架
为了更完善Graph窗口,这里介绍一种为其产生背景网格线的方法,如下图所示:
首先,在Editor文件夹下创建Resources文件夹,然后在Project View里选择右键,Create->UIElements->Editor Window,取消勾选C#和UXML,如下图所示: 文件里面写:
GridBackground {
--grid-background-color: #282828;
--line-color: rgba(193, 196, 192, 0.1);
--thick-line-color: rgba(193, 196, 192, 0.1);
}
最后在创建对应的GrahView的Init阶段,读取该uss文件作为StyleSheet即可:
public DialogueGraphView()
{
StyleSheet s = Resources.Load<StyleSheet>("DialogueGraph");
styleSheets.Add(s);
...
}
9. 创建更多的菜单工具栏选项
对话节点编辑器的设计思路是这样的,在Unity的Window里进行创建和编辑,然后可以把它存储起来,在Runtime下给游戏去读取。当在Editor下点击该文件时,对应的GraphView也会蹦出来。
为了补充Save和Load功能,可以先把UI做好,这里设计了两个Button,用于点击Save和Load,又设计了一个TextField,用于指定存储的文件的名字。
之前只在菜单栏对应的toolbar里添加了一个button,代码如下:
Toolbar toolbar = new Toolbar();
Button btn = new Button(clickEvent: () => { _graphView.AddDialogueNode("Dialogue"); });
btn.text = "Add Dialogue Node";
toolbar.Add(btn);
rootVisualElement.Add(toolbar);
现在添加更多的选项,一种仍然是Button,另一种则是TextField,代码如下所示:
TextField fileNameTextField = new TextField(label: "File Name");
fileNameTextField.SetValueWithoutNotify(_fileName);
fileNameTextField.MarkDirtyRepaint();
fileNameTextField.RegisterValueChangedCallback(evt => _fileName = evt.newValue);
toolbar.Add(fileNameTextField);
toolbar.Add(new Button(() => SaveData()) { text = "Save Data" });
toolbar.Add(new Button(() => LoadData()) { text = "Load Data" });
之后的toolbar就会变成这样,多了三个元素,两个Button用于存储和读取数据,TextFiled用于指定文件路径: 然后就可以创建具体的底层代码了,从设计角度上,SaveData和LoadData没有必要放在DialogueGraphView类里,这里创建了一个GraphSaveUtility类,代码如下:
public class GraphSaveUtility
{
private DialogueGraphView _dialogueGraphView;
private List<Edge> edges => _dialogueGraphView.edges.ToList();
private List<DialogueNode> nodes => _dialogueGraphView.nodes.ToList().Cast<DialogueNode>().ToList();
public static GraphSaveUtility GetInstance(DialogueGraphView graphView)
{
return new GraphSaveUtility
{
_dialogueGraphView = graphView
};
}
public void SaveData()
{
}
public void LoadData()
{
}
}
为了实现SaveData和LoadData函数,先要实现相关的Runtime下的存储文件类,这里使用ScriptableObject作为存储DialogugGraph的存储数据文件:
[Serializable]
public class DialogueNodeData
{
public string nodeGUID;
public string nodeText;
public Vector2 position;
}
[Serializable]
public class DialogueNodeLinkData
{
public string baseNodeGuid;
public string portName;
public string targetNodeGuid;
}
public class DialogueNode : Node {
public string guid;
public string text;
public bool entry = false;
}
在创建好了Node和NodeLink对应的可序列化的数据结构后,就可以创建整个Graph对应的可序列化的数据结构了,代码如下所示:
[Serializable]
public class DialogueContainer : ScriptableObject
{
public List<DialogueNodeData> nodesData = new List<NodeData>();
public List<DialogueNodeLinkData> nodeLinksData = new List<NodeLinkData>();
}
有了这些,就可以实现SaveData和LoadData函数了:
public void SaveData(string filePath)
{
if (!edges.Any())
return;
DialogueContainer container = ScriptableObject.CreateInstance<DialogueContainer>();
Edge[] hasInputEdges = edges.Where(x => x.input.node != null).ToArray();
for (int i = 0; i < hasInputEdges.Length; i++)
{
Edge e = hasInputEdges[i];
DialogueNode inputNode = e.input.node as DialogueNode;
DialogueNode outputNode = e.output.node as DialogueNode;
container.nodeLinksData.Add(new DialogueNodeLinkData()
{
baseNodeGuid = outputNode.GUID,
portName = e.output.portName,
targetNodeGuid = inputNode.GUID
});
}
DialogueNode[] regularNodes = nodes.Where(x => (!x.Entry)).ToArray();
for (int i = 0; i < regularNodes.Length; i++)
{
DialogueNode n = regularNodes[i];
container.nodesData.Add(new DialogueNodeData()
{
nodeGuid = n.GUID,
nodeText = n.Text,
position = n.GetPosition().position
});
}
AssetDatabase.CreateAsset(container, $"Assets/Resources/{filePath}.asset");
AssetDatabase.SaveAssets();
}
附录:一些例子里没提到的拓展操作
使用API将两个Node相连接
代码如下:
============== 在GraphView的派生类里实现 ===============
private void AddEdgeByPorts(Port _outputPort, Port _inputPort)
{
Edge tempEdge = new Edge()
{
output = _outputPort,
input = _inputPort
};
tempEdge.input.Connect(tempEdge);
tempEdge.output.Connect(tempEdge);
Add(tempEdge);
}
获取Node的InputPort和OutputPort
其实前面提到了,InputPort和OutputPort应该都在对应的Container里:
Port outP = outputNode.outputContainer[0].Q<Port>();
Port inP = rootPlayableNode.inputContainer[0].Q<Port>();
获取GraphViwe对应的窗口大小
GraphView里有一个参数叫Rect layout ,可以用于表示Graph代表的窗口大小,layout一般是从0,0为Rect最小点,如果最大点为(500, 400),说明整个窗口的大小是500pixel*400pixel
读写Graph里的Node的位置
这个应该很简单,前面其实提到了
node.SetPosition(new Rect(x: 400, y: 200, width: 100, height: 150));
Rect pos = node.GetPosition();
改变Zoom缩放比例
有时Zoom In的时候觉得放的不够大,可以改这个函数:
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
SetupZoom(0.15f, 3.0f);
创建Comment Block(代码里叫做Group)
代码如下:
var group = new Group
{
autoUpdateGeometry = true,
title = "Comment Block\n 快乐吗\n 很快乐"
};
m_GraphView.AddElement(group);
group.SetPosition(new Rect(500, 20, 200, 100));
效果如图:
创建不随着整体窗口变化的固定位置的窗口(类似于Anchor UI)
这么写就可以了,这其实是UI Element的内容
private Group m_FixedGroup;
...
m_FixedGroup = new InspectorGroup
{
title = "Comment Block\n sss \n fff" +
"\nfddddddddd" +
"\nfdsafdsafdsa" +
"\nfdsafdsa" +
"\nfdsafdsa" +
"dsafd"
};
m_FixedGroup.style.position = Position.Absolute;
m_FixedGroup.style.right = 0;
m_FixedGroup.style.top = 21;
rootVisualElement.Add(m_FixedGroup);
最后效果如下:
创建自定义的Graph窗口
通过VisualElement的Layout可以更改位置,但是好像结果总是不大对,我看到项目里用.uss来进行布局和颜色的定义: 相关类的定义如下:
public class PinnedElementView : GraphElement
{
}
相关.uss文件内容如下
.pinnedElement {
position:absolute;
border-left-width: 1px;
border-top-width: 1px;
border-right-width: 1px;
border-bottom-width: 1px;
border-radius: 5px;
flex-direction: column;
background-color: #4b1b6b;
border-color: #191919;
min-width: 100px;
min-height: 100px;
}
.pinnedElement.scrollable {
position: absolute;
}
.pinnedElement:selected {
border-color: #44C0FF;
}
.pinnedElement > .mainContainer {
flex-direction: column;
align-items: stretch;
}
.pinnedElement.scrollable > .mainContainer {
position: absolute;
top:0;
left:0;
right:0;
bottom:0;
}
.pinnedElement > .mainContainer > #content {
flex-direction: column;
align-items: stretch;
}
.pinnedElement.scrollable > .mainContainer > #content {
position: absolute;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
flex-direction: column;
align-items: stretch;
}
.pinnedElement > .mainContainer > #content > ScrollView {
flex: 1 0 0;
}
.pinnedElement > .mainContainer > #content > #contentContainer {
min-height: 50px;
padding-left: 0px;
padding-top: 0px;
padding-right: 0px;
padding-bottom: 6px;
flex-direction: column;
align-items: stretch;
}
.pinnedElement > .mainContainer > #content > #header {
flex-direction: row;
align-items: stretch;
background-color: #393939;
border-bottom-width: 1px;
border-color: #212121;
border-top-right-radius: 4px;
border-top-left-radius: 4px;
padding-left: 1px;
padding-top: 4px;
padding-bottom: 2px;
}
.pinnedElement > .mainContainer > #content > #header > #labelContainer {
flex: 1 0 0;
flex-direction: column;
align-items: stretch;
}
.pinnedElement > .mainContainer > #content > #header > #addButton {
align-self:center;
font-size: 20px;
background-image: none;
padding-left: 0px;
padding-top: 0px;
padding-right: 0px;
padding-bottom: 0px;
margin-top:3px;
margin-bottom:3px;
margin-left:4px;
margin-right:4px;
border-left-width:6px;
border-top-width:4px;
border-right-width:6px;
border-bottom-width:4px;
}
.pinnedElement > .mainContainer > #content > #header > #addButton:hover {
background-image: resource("Builtin Skins/DarkSkin/Images/btn.png");
}
.pinnedElement > .mainContainer > #content > #header > #addButton:hover:active {
background-image: resource("Builtin Skins/DarkSkin/Images/btn act.png");
}
.pinnedElement > .mainContainer > #content > #header > #labelContainer > #titleLabel {
font-size : 14px;
color: #c1c1c1;
}
.pinnedElement > .mainContainer > #content > #header > #labelContainer > #subTitleLabel {
font-size: 11px;
color: #606060;
}
改变节点端口的颜色
本来想改变Edge的颜色的,但是它的颜色好像是两个端口的自动生成的渐变色
node.outputContainer[0].Q<Port>().portColor = Color.white;
改变节点的title的高度
对应在node.titlecontainer.style里:
node.titleContainer.style.height = 80;
node.titleContainer.style.unityTextAlign = TextAnchor.UpperCenter;
GraphView提供的Element类型
看了下,我知道的图形类,一共有这么些类型:
额外注意,还有很多基本的UI Element可以用,都是好东西,比如:
添加ObjectField
也是UI Element的内容
var objField = new ObjectField
{
objectType = typeof(GameObject),
allowSceneObjects = false,
value = prefabNode.output,
};
var preview = new Image();
objField.RegisterValueChangedCallback(v => {
prefabNode.output = objField.value as GameObject;
UpdatePreviewImage(preview, objField.value);
});
void UpdatePreviewImage(Image image, Object obj)
{
image.image = AssetPreview.GetAssetPreview(obj) ?? AssetPreview.GetMiniThumbnail(obj);
}
效果如下图所示:
清空GraphView里的nodes和edges
像这种写法是不行的:
List<Node> nodes = new List<Node>();
Node n = new Node();
m_GraphView.Add(n);
nodes.Add(n);
m_GraphView.DeleteElements(nodes);
虽然Node是引用类型,但是我看nodes和m_GraphView里的nodes不是同一份数据,所以得这么写:
List<Node> nodes = new List<Node>();
Node n = new Node();
m_GraphView.Add(n);
nodes.Add(n);
DeleteElements(nodes.ToList());
DeleteElements(edges.ToList());
m_Nodes.Clear();
代码实现按F的效果 在GraphView的窗口中,按F能合理的显示所有Node,这里的替代的函数为:
GraphView.FrameAll();
改变Edge颜色并强制更新
Edge edge;
edge.UpdateEdgeControl();
Runtime改变Group字体的大小
应该是可以写uss调整的,但我这里找到了代码控制的方法,具体的Hierarchy信息我是通过UI Debugger看清楚的,相关代码如下:
Group group = new Group();
group.title = "A Group";
Stack<VisualElement> stack = new Stack<VisualElement>();
stack.Push(group.headerContainer);
while (stack.Any())
{
VisualElement ve = stack.Pop();
Label label = ve as Label;
if (label != null)
{
label.style.fontSize = 5;
break;
}
var veList = ve.hierarchy.Children().ToList();
foreach (var item in veList)
{
stack.Push(item);
}
}
List<VisualElement> list = group.headerContainer.hierarchy.Children().ToList();
TemplateContainer item = (TemplateContainer)group.headerContainer.hierarchy.Children().ToList().Find(x => x is TemplateContainer);
List<VisualElement> list = item.hierarchy.Children().ToList();
foreach (var itemsss in list)
{
var ss = itemsss as Label;
if (ss != null)
{
ss.style.fontSize = 5;
}
}
高亮GraphView的节点
我发现选中Node时,它自然会显示会高亮的节点状态,所以Select Node,就可以将其高亮
一开始我这么写的:
MyGraphView.selection.Clear();
MyGraphView.selection.Add(MyNode);
发现这样写不对,其实GraphView有对应的API,应该这么写:
MyGraphView.ClearSelection();
MyGraphView.AddToSelection(MyNode);
|