目标
主要参考《在Unity中创建基于节点的编辑器_嘿嘿-CSDN博客_unity节点编辑器》,一步步学习如何在Unity中制作节点编辑器。
0. 创建窗口
在工程中添加一个MyNodeEditor.cs 文件,MyNodeEditor 将继承自EditorWindow。 代码内容如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
public class MyNodeEditor : EditorWindow
{
[MenuItem("Window/My Node Editor")]
private static void OpenWindow()
{
MyNodeEditor window = GetWindow<MyNodeEditor>();
window.titleContent = new GUIContent("My Node Editor");
}
}
随后,将可以在菜单中找到 “My Node Editor”:
1. 定义节点对象
在工程中添加一个MyNode.cs 文件,MyNode 不继承任何基类。 当前它只是提供画一个矩形的职责:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
public class MyNode
{
public Rect rect;
public MyNode(Vector2 position)
{
rect = new Rect(position.x, position.y, 160, 40);
}
public void Draw()
{
GUI.Box(rect, "MyNode");
}
}
随后,在MyNodeEditor 中,定义一个节点列表用于存放所有节点:
private List<MyNode> nodes;
然后在其OpenWindow() 函数中创建这个对象,并临时加一个测试用节点
window.nodes = new List<MyNode>();
window.nodes.Add(new MyNode(new Vector2(0, 0)));
最后,调用它的 OnGUI 接口,用于将所有的节点都绘制出来:
private void OnGUI()
{
DrawNodes();
if (GUI.changed)
Repaint();
}
private void DrawNodes()
{
for (int i = 0; i < nodes.Count; i++)
nodes[i].Draw();
}
现在,打开MyNodeEditor可以看到一个矩形:
2. 实现“添加节点”功能
首先,在MyNodeEditor 中添加处理事件的逻辑,应该在鼠标右键点击后出现一个菜单,然后菜单中有一项是“Add Node”用于在鼠标之处添加节点。代码如下:
private void ProcessEvents(Event e)
{
switch (e.type)
{
case EventType.MouseDown:
if (e.button == 1)
{
RightMouseMenu(e.mousePosition);
}
break;
}
}
private void RightMouseMenu(Vector2 mousePosition)
{
GenericMenu genericMenu = new GenericMenu();
genericMenu.AddItem(new GUIContent("Add node"), false, () => ProcessAddNode(mousePosition));
genericMenu.ShowAsContext();
}
private void ProcessAddNode(Vector2 nodePosition)
{
nodes.Add(new MyNode(nodePosition));
}
随后,在 OnGUI 中运行处理事件的逻辑:
private void OnGUI()
{
DrawNodes();
ProcessEvents(Event.current);
if (GUI.changed)
Repaint();
}
现在,MyNodeEditor 可以右键添加节点了:
3. 实现“拖拽节点”功能
首先看MyNode 。对于他来说,拖拽本身的表现很简单,就是位置加上偏移:
public void ProcessDrag(Vector2 delta)
{
rect.position += delta;
}
而触发拖拽的逻辑,则由一个Event(事件)来控制。Event将指明鼠标的状态,是按下、松开、拖拽。具体来说:
public bool ProcessEvents(Event e)
{
switch (e.type)
{
case EventType.MouseDown:
if (e.button == 0)
{
if (rect.Contains(e.mousePosition))
isDragged = true;
GUI.changed = true;
}
break;
case EventType.MouseUp:
isDragged = false;
break;
case EventType.MouseDrag:
if (e.button == 0 && isDragged)
{
ProcessDrag(e.delta);
e.Use();
return true;
}
break;
}
return false;
}
其中,isDragged 是定义在节点中的一个成员变量,它标志着是否在拖拽状态。
private bool isDragged;
接下来,就是在MyNodeEditor 中把这个Event传给每个节点。时机就是MyNodeEditor 的ProcessEvents 函数中:
for (int i = nodes.Count - 1; i >= 0; i--)
{
bool DragHappend = nodes[i].ProcessEvents(e);
if (DragHappend)
GUI.changed = true;
}
这样,节点就可以用鼠标左键拖拽了:
4. 创建“连接点”控件
创建一个新脚本文件ConnectionPoint.cs 。ConnectionPoint 代表着节点两侧的连接点,它会画一个按钮控件。具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum ConnectionPointType { In, Out }
public class ConnectionPoint
{
public Rect rect;
public ConnectionPointType type;
public MyNode OwnerNode;
public ConnectionPoint(MyNode owner, ConnectionPointType type)
{
this.OwnerNode = owner;
this.type = type;
rect = new Rect
(0,
0,
10f,
20f);
}
public void Draw()
{
rect.y = OwnerNode.rect.y + (OwnerNode.rect.height * 0.5f) - rect.height * 0.5f;
switch (type)
{
case ConnectionPointType.In:
rect.x = OwnerNode.rect.x - rect.width;
break;
case ConnectionPointType.Out:
rect.x = OwnerNode.rect.x + OwnerNode.rect.width;
break;
}
GUI.Button(rect, "");
}
}
然后,在MyNode 中,需要定义两侧连接点成员:
public ConnectionPoint inPoint;
public ConnectionPoint outPoint;
在构造函数中,创建这两个对象:
inPoint = new ConnectionPoint(this, ConnectionPointType.In);
outPoint = new ConnectionPoint(this, ConnectionPointType.Out);
在绘制函数,也就是Draw() 中,添加对这两个连接点的绘制调用:
public void Draw()
{
GUI.Box(rect, "MyNode");
inPoint.Draw();
outPoint.Draw();
}
随后,就可以在节点两侧看到连接点按钮了:
5. 创建“连接线”对象
创建一个新的脚本文件Connection.cs 。Connection 将代表连接线,其功能很简单,就是画出给定两个连接点之间的曲线:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
public class Connection
{
public ConnectionPoint inPoint;
public ConnectionPoint outPoint;
public Connection(ConnectionPoint inPoint, ConnectionPoint outPoint)
{
this.inPoint = inPoint;
this.outPoint = outPoint;
}
public void Draw()
{
Handles.DrawBezier(
inPoint.rect.center,
outPoint.rect.center,
inPoint.rect.center + Vector2.left * 50f,
outPoint.rect.center - Vector2.left * 50f,
Color.white,
null,
2f
);
}
}
而在MyNodeEditor 中,需要创建一个列表来容纳当前所有的连线:
private List<Connection> connections;
然后需要一个函数来绘制所有的连线,即调用他们的Draw 函数:
private void DrawConnections()
{
for (int i = 0; i < connections.Count; i++)
connections[i].Draw();
}
当然,此函数需要在OnGUI 中调用:
private void OnGUI()
{
DrawNodes();
DrawConnections();
...
}
为了测试,我在初始就加了些节点和连线。 另外,我将之前在OpenWindow() 中的一些初始化用的函数移动到了OnEnable函数中,修改后代码如下:
[MenuItem("Window/My Node Editor")]
private static void OpenWindow()
{
MyNodeEditor window = GetWindow<MyNodeEditor>();
}
private void OnEnable()
{
titleContent = new GUIContent("My Node Editor");
nodes = new List<MyNode>();
connections = new List<Connection>();
{
MyNode n1 = new MyNode(new Vector2(0, 0));
nodes.Add(n1);
MyNode n2 = new MyNode(new Vector2(200, 200));
nodes.Add(n2);
connections.Add(new Connection(n2.inPoint, n1.outPoint));
}
}
现在,打开窗口后会看到两个节点并有连线了:
6. 实现“添加连线”功能
“添加连线”需要两个连接点的配合。因此,可以在MyNodeEditor 中记录一个当前所选的连接点:
public ConnectionPoint SelectingPoint;
而对于连接点,它在调用GUI.Button时,不仅执行了绘制按钮的命令,也将返回按钮是否被按下。因此可以在这里做连线的逻辑:(对于连接点的OwnerWindow 指针,则就是在构造函数中传入了,为此添加了一些零碎的代码,省略细节)
if(GUI.Button(rect, ""))
{
if (OwnerWindow.SelectingPoint == null)
OwnerWindow.SelectingPoint = this;
else
{
if (OwnerWindow.SelectingPoint.type!=this.type)
{
if (this.type == ConnectionPointType.In)
OwnerWindow.connections.Add(new Connection(this, OwnerWindow.SelectingPoint));
else
OwnerWindow.connections.Add(new Connection(OwnerWindow.SelectingPoint, this));
OwnerWindow.SelectingPoint = null;
}
}
}
另外,为了能区分出所选的连接点是哪个,我还添加了一个逻辑:将所选的连接点的宽度稍微变长一些:
rect.width = (OwnerWindow.SelectingPoint == this) ? 20 : 10;
现在,可以有连线功能了:
7. 绘制“待连接线”
当所选一个连接点时,如果能画出一条连接鼠标的线,则会有较好的提示作用。为此需要增加一个函数:
private void DrawPendingConnection(Event e)
{
if(SelectingPoint!=null)
{
Vector3 startPosition = (SelectingPoint.type == ConnectionPointType.In) ? SelectingPoint.rect.center : e.mousePosition;
Vector3 endPosition = (SelectingPoint.type == ConnectionPointType.In) ? e.mousePosition : SelectingPoint.rect.center;
Handles.DrawBezier(
startPosition,
endPosition,
startPosition + Vector3.left * 50f,
endPosition - Vector3.left * 50f,
Color.white,
null,
2f
);
GUI.changed = true;
}
}
将此函数在OnGUI中调用:
private void OnGUI()
{
...
DrawPendingConnection(Event.current);
...
}
另外,在空白处按下鼠标左键的时候,希望能取消所选的连接点,为此,需要在 ProcessEvents 中添加对其的判断:
private void ProcessEvents(Event e)
{
switch (e.type)
{
case EventType.MouseDown:
...
if (e.button == 0)
{
SelectingPoint = null;
}
break;
}
...
}
(此外,由于此连接线已经提示了所选的连接点,所以上一步中将所选连接点宽度稍微变长的操作不再需要了)
8. 改变控件风格
控件风格是由 GUIStyle 指定的。它指定了不同状态(例如,当鼠标悬停在控件上时)的字体和贴图等等细节。上面绘制节点时调用的 GUI.Box 以及绘制连接点调用的GUI.Button都可指明GUIStyle参数。
GUIStyle是相对全局的,不需要每个控件都创建一个,于是我选择在MyNodeEditor 中创建并记录下来,待之后分配给新的节点与连接点控件:
GUIStyle style_Node;
GUIStyle style_Point;
我做出了一些控件图片,然后放在工程目录的Assets文件夹下 然后在MyNodeEditor 的OnEnable中创建 GUIStyle 并将图片指定给各个状态:
{
style_Node = new GUIStyle();
style_Node.normal.background = EditorGUIUtility.Load("Assets/node.png") as Texture2D;
style_Node.alignment = TextAnchor.MiddleCenter;
style_Point = new GUIStyle();
style_Point.normal.background = EditorGUIUtility.Load("Assets/point_normal.png") as Texture2D;
style_Point.active.background = EditorGUIUtility.Load("Assets/point_active.png") as Texture2D;
style_Point.hover.background = EditorGUIUtility.Load("Assets/point_hover.png") as Texture2D;
}
接下来,这两个 GUIStyle 将在节点和连接点的构造函数中被传入,并在GUI.Box和GUI.Button时使用。(代码略)
(另外,我还稍微调整了下连接点的位置,以及连接线的宽度)
9. 实现“选择节点”功能
为了标志节点是否被选择,需要在MyNode 中添加一个成员:
private bool isSelected;
判断是否被选择时机很简单,就是在鼠标左键按下时——若点中了自己则自己就是被选中的,否则就不是:
public bool ProcessEvents(Event e)
{
switch (e.type)
{
case EventType.MouseDown:
if (e.button == 0)
{
if (rect.Contains(e.mousePosition))
{
isDragged = true;
isSelected = true;
}
else
{
isSelected = false;
}
...
}
break;
...
}
...
}
而当节点进入选择状态时,希望能换一个GUIStyle来提示自己被选择,因此节点现在需要两个GUIStyle了:
private GUIStyle style;
private GUIStyle style_select;
而新的 GUIStyle 自然也是在MyNodeEditor 中创建:
style_Node_select = new GUIStyle();
style_Node_select.normal.background = EditorGUIUtility.Load("Assets/node_select.png") as Texture2D;
style_Node_select.alignment = TextAnchor.MiddleCenter;
style_Node_select.fontStyle = FontStyle.Bold;
随后在MyNode 的构造函数中传入。
而MyNode 在绘制时则根据isSelected 的值调整自己的GUIStyle:
GUI.Box(rect, "MyNode", isSelected ? style_select : style);
10. 实现“删除节点”功能
删除节点需要注意的是,删除的时候还需要判断哪些连接和它有关,要一并删除。 我将删除节点的函数放在了MyNodeEditor 中:
public void ProcessRemoveNode(MyNode node)
{
List<Connection> connectionsToRemove = new List<Connection>();
for (int i = 0; i < connections.Count; i++)
{
if (connections[i].inPoint == node.inPoint || connections[i].outPoint == node.outPoint)
connectionsToRemove.Add(connections[i]);
}
for (int i = 0; i < connectionsToRemove.Count; i++)
connections.Remove(connectionsToRemove[i]);
connectionsToRemove = null;
nodes.Remove(node);
}
而触发它的时机和“添加节点”类似,都是右键菜单。只不过这次需要在MyNode 中判断,并且在此节点是所选节点的情况下才触发:
public bool ProcessEvents(Event e)
{
switch (e.type)
{
case EventType.MouseDown:
...
if (e.button == 1)
{
if(isSelected && rect.Contains(e.mousePosition))
{
RightMouseMenu();
e.Use();
}
}
break;
...
}
...
}
而RightMouseMenu() 中将负责生成一个菜单并调用之前的ProcessRemoveNode 函数:
private void RightMouseMenu()
{
GenericMenu genericMenu = new GenericMenu();
genericMenu.AddItem(new GUIContent("Remove node"), false, () => OwnerWindow.ProcessRemoveNode(this));
genericMenu.ShowAsContext();
}
11. 实现“删除连线”功能
删除连线的方式,原教程是在连线上放一个按钮点击可以删除。虽然有些影响美观,但这确实是一种简单直接的方式,因此我决定沿用这一做法,但稍微加一些改动——仅在按住Y键时才显示菜单。
所以,需要在MyNodeEditor 中增加一个变量用于表示是否进入移除连线模式
public bool isRemoveConnectionMode;
而其变化的逻辑和鼠标事件一样,也在ProcessEvents 中做判断:
private void ProcessEvents(Event e)
{
switch (e.type)
{
...
case EventType.KeyDown:
if (e.keyCode == KeyCode.Y)
{
isRemoveConnectionMode = true;
GUI.changed = true;
}
break;
case EventType.KeyUp:
if (e.keyCode == KeyCode.Y)
{
isRemoveConnectionMode = false;
GUI.changed = true;
}
break;
}
...
}
随后,在Connection 中就可以根据此变量来判断是否该显示按钮了:
if(OwnerWindow.isRemoveConnectionMode)
{
Vector2 buttonSize = new Vector2(20, 20);
Vector2 LineCenter = (inPoint.rect.center + outPoint.rect.center) / 2;
if (GUI.Button(new Rect(LineCenter - buttonSize / 2, buttonSize), "X"))
OwnerWindow.connections.Remove(this);
}
12. 实现“拖拽画布”功能
拖拽画布其实就是再MyNodeEditor 中拖拽所有节点:
private void DragAllNodes(Vector2 delta)
{
for (int i = 0; i < nodes.Count; i++)
nodes[i].ProcessDrag(delta);
}
接下来是判断何时发生了拖拽,很自然想到的是,依旧通过 EventType.MouseDrag 来判断:
private void ProcessEvents(Event e)
{
switch (e.type)
{
...
case EventType.MouseDrag:
if(e.button == 0)
{
DragAllNodes(e.delta);
GUI.changed = true;
}
break;
}
}
然而问题是,节点的拖拽也是同样的判断,那么该如何区分“节点的拖拽”与“画布的拖拽”呢? 关键的注意点是:要先处理节点的事件,再处理MyNodeEditor自身的事件。因为如果节点接受到了拖拽的信息,则会调用Event.Use: 这样,之后的MyNodeEditor自身就不会再处理拖拽事件了。
13. 绘制背景网格
绘制背景网格其实就是基于当前的窗口画若干条直线。 由于我们还有拖拽画布的功能,所以背景网格也需要知道拖拽的偏移才能画出正确位置的网格。
private Vector2 GridOffset;
此值会在拖拽时一并修改:
case EventType.MouseDrag:
if(e.button == 0)
{
...
GridOffset += e.delta;
...
}
绘制网格的代码如下:
private void DrawGrid(float gridSpacing, float gridOpacity, Color gridColor)
{
int widthDivs = Mathf.CeilToInt(position.width / gridSpacing);
int heightDivs = Mathf.CeilToInt(position.height / gridSpacing);
Handles.BeginGUI();
{
Handles.color = new Color(gridColor.r, gridColor.g, gridColor.b, gridOpacity);
Vector3 gridOffset = new Vector3(GridOffset.x % gridSpacing, GridOffset.y % gridSpacing, 0);
for (int i = 0; i < widthDivs; i++)
{
Handles.DrawLine(
new Vector3(gridSpacing * i, 0 - gridSpacing, 0) + gridOffset,
new Vector3(gridSpacing * i, position.height + gridSpacing, 0f) + gridOffset);
}
for (int j = 0; j < heightDivs; j++)
{
Handles.DrawLine(
new Vector3(0 - gridSpacing, gridSpacing * j, 0) + gridOffset,
new Vector3(position.width + gridSpacing, gridSpacing * j, 0f) + gridOffset);
}
Handles.color = Color.white;
}
Handles.EndGUI();
}
而在OnGUI() 中将调用两次,第二次是更宽的更明显的网格:
private void OnGUI()
{
DrawGrid(20, 0.2f, Color.gray);
DrawGrid(100, 0.4f, Color.gray);
...
}
最终代码
代码与图片资源详见GIT:https://codechina.csdn.net/u013412391/unitytestnodeeditor.git 对于每一个步骤都可见提交历史:
|