前言
存储游戏配置常用excel进行,主打一张表配到天荒地老。奈何表格处理数据方便,处理逻辑略显吃力。笔者个人开发,时间充足,于是尝试手搓编辑器,利用ScriptableObject存储配置。这一过程中吃了GraphView资料奇缺的亏,不过修修补补终于是能用了,于是将思路分享出来。
实现方式多种多样,本人只是分享其中一种可行的思路。个人能力水平有限,不乏错漏混乱之处,敬请谅解。
工具
编辑器用到了以下工具。
EditorWindow:用来自定义窗口的基类,开发者可以用它来制作开发工具的界面。还有个与它名称相似的基类叫Editor偏向用于自定义Inspector,两者不是一回事。
UI Element:又名UI Tookit,是Unity官方推出的一套新UI解决方案,借鉴了前端开发的思想。使用uxml(类似html)定义UI结构,结合uss(类似css)描述布局样式。此方案既可用于编辑器也可用于运行时,不过等Unity搞出它时前端已经不流行这套了,所以现在地位尴尬。
GraphView:Unity实验中的模块,用于开发者自定义一套节点工具,资料文档稀缺。
![图片[1]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240120143711.png)
设计
图形结构
GraphView的基础框架很简单:节点(Node)上有若干个输入和输出端口(Port),用线条(Edge)可以连接这些端口。不过由于笔者开发编辑器的初衷是用来编辑buff,并且期望能扩展为对话编辑器、事件编辑器等,需要对此框架进行扩展。
由于缺少设计能力,笔者根据理想中的开发方式绘制了一张概念图,并尝试根据结果逆推出需要扩展的功能。
![图片[2]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240120145615.png)
预想效果为:当回合开始时,若场上只有1个敌方角色,就给自己加一层指定buff。结合扩展需求分析,该编辑器的核心在于能够“在节点间传递某种数据“。进一步地,可以分析出以下需求:
- 至少有一个入口节点
- 节点(node)本身能够储存数据
- 节点里可以划出多个功能区域,每个区域有自己的输入/输出端口
- 端口(port)指定了能够传递的数据类型
- 节点能够对传入的数据进行一定处理,然后决定输出什么数据
各个节点间存在大量相似的逻辑功能,比如说都有若干字段,这些字段的值可从其他节点传入,也可在编辑器中手动设置,然后用于传给其他节点。不妨把这些逻辑功能相似的部分独立成比节点更小的单位,本人称之为功能条(piece),以供节点按需求自由组装。
依此可建立一个以节点为主体的图形结构。设想中存在不需要功能条的简单节点,且大多数节点都存在一个关键数据,因此为每个节点设立一个主要的输入、输出端口,以及功能条若干。
![图片[3]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240120154754.png)
图的执行顺序
不论何种节点编辑器工具,作用都是编辑节点图。而节点图里的数据才是开发者真正关心的东西,编辑器丑点简陋点倒也无伤大雅。换言说,编辑器开发的核心工作在于设计一套新的、高度定制化的、基于图的规则。
在各个平台上,我也见过不少种功能各异的节点编辑器。一般这些节点图大约可分为三种:
- 第一种,节点需要翻译为目标数据。例如ue5的蓝图,开发者在编辑器里编辑蓝图节点的过程,和在ide修改代码片段的本质没什么不同,都需要经过编译才能使用。
- 第二种,节点本身不包含业务逻辑,由相应的解释器分析并执行。例如某种对话树,节点本身只包含ID、类型和文本内容等配置项。解释器通过分析这些数据,决定是输出对话还是提供选项等。
- 第三种,节点本身已经包含业务逻辑,不需要配备额外的解释器,只需要按照既定规则顺序执行即可。
节点图的侧重点不同,具体设计细节也就不同。笔者设计的节点图,需要节点本身就有一定的逻辑处理能力,且要易于扩展,符合第三种情况。对于复杂的buff,需要用到多个数据,内部的流程也比较复杂。参考下面两幅图,为了让其按照预想的顺序执行,必须设计一个合理的执行规则。
![图片[4]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240120171645.png)
![图片[5]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240120172724.png)
若一个节点需要用到另一个节点的数据,一定是期望另一个节点先被执行。在本编辑器中,判断一个节点能否被执行的一般条件是”有连接的输入端口都已经得到了合法的数据“,也就意味着这个节点需要的数据都已经配备好了。设计中执行节点的过程分为六个步骤:
- 一、开始执行某节点
- 二、节点的功能条(piece)从与其相连的输出端口获取数据(条事件1)
- 三、节点执行其功能,改变功能条中的数据(节点事件)
- 四、节点的功能条将数据传递给输出端口(条事件2)
- 五、节点的各个输出端口依次激活,返回所有可执行的下一层节点(探测事件)
- 六、执行上一步中返回的各个节点
出于安全考虑,节点与节点间只能通过端口传递数据,而前一个节点的输出端口和后一个节点的输入端口间只需要有一个能暂存数据即可。这里一共设计了四个事件,方便以后对节点和条的功能进行扩展。根据具体编辑器需求,亦可自行扩展或删减事件数。
数据结构
节点图的存储有一个易被忽视,却很重要的问题:如何存储端口之间的连接。按引用存储只能引用自身ScriptableObject中的数据或着其他ScriptableObject本身。在本编辑器中,每个节点都作为一个独立的ScriptableObject存储,而一条连接信息需要记录两个节点上的端口,意味着其无法按直接引用存储(经测试,若并强行按引用存储,unity会将节点上的端口完整拷贝一份到自身YAML中,引起不可预知的错误)。
本项目中的节点图按照编辑时和运行时分为两种结构,在编辑器中仅记录输入输出端口的GUID对,在运行时才记录两方端口的引用。
![图片[6]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240120213231.png)
PortPair和DataConnection两种连接的代码(部分)
[Serializable]
public class PortPair
{
public string inPort;
public string outPort;
public PortPair(string pIn, string pOut)
{
inPort = pIn;
outPort = pOut;
}
}
public class DataConnection
{
public PortDataBase outPort;
public PortDataBase inPort;
public bool IsTransmitted { get; protected set; }
public DataConnection()
{
IsTransmitted = false;
}
public bool Transmit(RuntimeNodesGraph graph)
{
this.IsTransmitted = true;
return inPort.nodeData.IsCanBeInvoked(graph);
}
}
在实际开发中,节点图(NodesGraph)仍不是最高一级的存储单位。因为节点图有专属的类型,在编辑器中还要储存每个节点的位置,当前视图的位置和缩放等。除此之外,NodesGraph还是批量复制粘贴的用到的结构。所以准确的说,NodesGraph仅代表着图的一部分,也就是子图。
/// <summary>
/// 图文件的抽象类,储存节点,保存视图信息
/// </summary>
public abstract class GraphBase : ScriptableObject, IScriptable
{
//实际存储的节点图
[SerializeReference]
public NodesGraph nodesGraph;
/// <summary>
/// 指向图的ID,由创建者手动指定
/// </summary>
public string ID;
[SerializeField]
private bool isFirstCreated = true;
public bool IsFirstCreated { get => isFirstCreated; protected set => isFirstCreated = value; }
// 用来储存节点视图信息
[Serializable]
public class ViewTransformData
{
public Vector3 position;
public Vector3 scale = new Vector3(1, 1, 1);
}
/// <summary>
/// 节点视图信息
/// </summary>
public ViewTransformData graphViewTransformData;
/// <summary>
/// 节点对应的位置,用于可视化编辑器的初始化
/// </summary>
[SerializeField]
protected SerializedDictionary<string, Vector2> nodePositionDic;
//使用序列化字典需要引用UnityEngine.Rendering命名空间
public SerializedDictionary<string, Vector2> NodePositionDic => nodePositionDic;
private void Awake()
{
//可确保InitOnFirstCreated方法在从创建到销毁的完整生命周期中绝对只执行一次
if (!isFirstCreated) return;
InitOnFirstCreated();
isFirstCreated = false;
}
protected virtual void InitOnFirstCreated()
{
nodesGraph = new NodesGraph();
graphViewTransformData = new ViewTransformData();
nodePositionDic = new SerializedDictionary<string, Vector2>();
}
protected virtual void AddNodePosition(NodeDataBase node,Vector2 position)
{
NodePositionDic.Add(node.GUID, position);
}
protected virtual void RemoveNodePosition(NodeDataBase node)
{
NodePositionDic.Remove(node.GUID);
}
public Vector2 GetNodePosition(NodeDataBase node)
{
if(!NodePositionDic.ContainsKey(node.GUID))
{
Debug.LogWarning($"在图里找不到节点的位置。已使用默认位置代替。");
return Vector2.zero;
}
return NodePositionDic[node.GUID];
}
public void SetNodePosition(NodeDataBase node, Vector2 position)
{
NodePositionDic[node.GUID] = position;
}
/// <summary>
/// 添加节点到图的存储结构里,同时在位置字典里添加
/// </summary>
public abstract void AddNode(NodeDataBase nodeData,Vector2 position);
/// <summary>
/// 从存储结构里移除节点,同时在位置字典里移除
/// </summary>
public abstract void RemoveNode(NodeDataBase nodeData);
/// <summary>
/// 将子图并入图中
/// </summary>
public virtual void AddGraph(NodesGraph add, List<Vector2> poses, Vector2 offset)
{
if (add.datas.Count != poses.Count)
{
throw new ArgumentException($"需要加入的节点列表(length:{add.datas.Count})必须与位置列表(length:{poses.Count})等长");
}
var itor = poses.GetEnumerator();
itor.MoveNext();
foreach (var data in add.datas)
{
//位置=整体偏移量offset+相对位置
AddNode(data, offset + itor.Current);
itor.MoveNext();
}
foreach (var connection in add.Pairs)
{
nodesGraph.CreatePair(connection.outPort, connection.inPort);
}
}
protected void OnValidate()
{
#if UNITY_EDITOR
EditorUtility.SetDirty(this);
#endif
}
}
和节点图一样,节点也有两种形态:NodeData是继承自ScriptableObject,存储节点数据的类。NodeView是继承自UnityEditor.Experimental.GraphView.Node,负责节点表现的类。这两个类的代码会在文章结尾给出。
在开发前期,每添加一个节点就要编写对应的Data和View两个类,非常麻烦。后来将表现部分的逻辑也转移到NodeData中,从此绝大部分的节点不需要再专门编写一个View类。虽然这违背了单一职责原则,但是大大降低了编写新节点的工作量,也算一种妥协了。
开发
编辑器窗口-EditorWindow
设计完成后,需先完成编辑器基础框架的编程。笔者的需求是可以同时存在多个同种窗口,并且可以进行一定交互。于是需要对当前存在的窗口进行缓存。这里使用了容易想到的静态类来保存,不知道在编辑器中还有没有更好的方案。
//编辑器窗口的缓存及配置
public static class EditorWindowBaseCntr
{
private static List<EditorWindowBase> windows;
//图的Type->与之兼容的编辑器Type
private static Dictionary<Type,Type> GraphTypeToMatchableEditorTypeDic;
static EditorWindowBaseCntr()
{
windows ??= new List<EditorWindowBase>();
if(GraphTypeToMatchableEditorTypeDic is null)InitOpenSets();
}
//通过反射自动完成配置
private static void InitOpenSets()
{
GraphTypeToMatchableEditorTypeDic = new Dictionary<Type, Type>();
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var editorType in assembly.GetTypes())
{
if (!editorType.IsSubclassOf(typeof(EditorWindowBase))) continue;
var attribute = editorType.GetCustomAttribute<OpenSetAttribute>();
if (attribute is null) throw new CustomAttributeFormatException($"{editorType.Name}缺少OpenSet标签");
Type graphType = attribute.GraphType;
GraphTypeToMatchableEditorTypeDic.Add(graphType, editorType);
}
}
}
private static void AddWindow(EditorWindowBase window)
{
windows.Add(window);
}
private static void RemoveWindow(EditorWindowBase window)
{
windows.Remove(window);
}
public static EditorWindowBase FindWindowByGraphInstanceID(int graphInstanceID)
{
return windows.Find(window => window.WindowGraphView.Graph && window.WindowGraphView.Graph.GetInstanceID() == graphInstanceID);
}
//打开指定图文件的编辑器窗口
private static EditorWindowBase OpenGraphWindow(GraphBase graph,Type editorType)
{
//从当前窗口缓存中寻找该图文件的窗口
var window = FindWindowByGraphInstanceID(graph.GetInstanceID());
//寻找空闲窗口
if (!window) window = OpenGraphWithAvailableWindow(graph,editorType);
//没有窗口就创建一个
if (!window)window = CreateGraphWindow(graph,editorType);
//聚焦此窗口
window.Focus();
return window;
}
//寻找指定图文件的空闲编辑器窗口并打开图
private static EditorWindowBase OpenGraphWithAvailableWindow(GraphBase graph, Type editorType)
{
var window = windows.Find(window => editorType.IsAssignableFrom(window.GetType()) && !window.WindowGraphView.Graph);
if (window is null) return null;
//使用window内部的逻辑打开图
window.OpenGraph(graph);
EditorUtility.SetDirty(graph);
return window;
}
//创建指定图文件的编辑器窗口并打开图
private static EditorWindowBase CreateGraphWindow(GraphBase graph,Type editorType)
{
var method = typeof(EditorWindow).GetMethod("CreateWindow",new Type[] { typeof(Type[]) }).MakeGenericMethod(editorType);
var window = method.Invoke(null, new object[] { Type.EmptyTypes}) as EditorWindowBase;
//使用window内部的逻辑打开图
window.OpenGraph(graph);
AddWindow(window);
EditorUtility.SetDirty(graph);
return window;
}
// 打开图资源/节点时触发
[OnOpenAsset(1)]
public static bool OnOpenAssets(int id, int line)
{
UnityEngine.Object obj = EditorUtility.InstanceIDToObject(id);
//匹配节点文件
if (obj is NodeDataBase node)
{
//尝试找到节点目录的图文件
var path = VEUtility.GetGraphPathByNode(node);
GraphBase graph = AssetDatabase.LoadAssetAtPath<GraphBase>(path);
//没找到图则返回
if (!graph) return false;
//打开窗口
var window = OpenGraphWindow(graph,GraphTypeToMatchableEditorTypeDic[graph.GetType()]);
//聚焦节点
var nodeView = window.WindowGraphView.FindNodeViewByInstanceID(id);
//在图里没找到节点则返回(例如本该被删掉的节点没有被正确删除)
if (nodeView is null) return false;
//将视图中心聚焦至节点
window.WindowGraphView.FocusViewOnNode(node);
//选择该节点
nodeView.Select(window.WindowGraphView, false);
return true;
}
//匹配图文件
if (obj is GraphBase g)
{
OpenGraphWindow(g, GraphTypeToMatchableEditorTypeDic[g.GetType()]);
return true;
}
return false;
}
public static EditorWindowType OpenWindowByMenu<EditorWindowType>() where EditorWindowType : EditorWindowBase
{
var window = EditorWindow.CreateWindow<EditorWindowType>();
AddWindow(window);
window.Focus();
return window;
}
public static void RemoveWindowBySelf(EditorWindowBase window)
{
RemoveWindow(window);
}
}
下一步是编写窗口的抽象类。一种具体的编辑器对应一种窗口类型,比如buff编辑器是一种窗口类型,对话编辑器是另一种类型等。不同编辑器的主要差异都写在窗口类中,而共用同种GraphView组件(早期为每种编辑器类型单独写一种GraphView,后来发现其大体内容相同,没有必要单独派生出不同类,于是进行了简化)。
public abstract class EditorWindowBase : EditorWindow
{
//窗口中的GraphView
public VEGraphView WindowGraphView { get; set; }
//本窗口对应uss文件的地址(以Assets/开头)
public abstract string EditorUssPath { get; }
//所编辑资源数据的根目录地址(以Assets/开头)
public abstract string DataPath { get; }
//本窗口对应uxml文件的地址(以Assets/开头)
public abstract string EditorUmxlPath { get; }
protected static EditorWindowType BaseOnOpenedByMenu<EditorWindowType>()
where EditorWindowType : EditorWindowBase
{
return EditorWindowBaseCntr.OpenWindowByMenu<EditorWindowType>();
}
//Unity里,绘制GUI时的回调
public void CreateGUI()
{
//为了代码复用,将其拆成另一个方法
OnCreateGUI();
wantsMouseMove = true;
}
protected void OnDisable()
{
EditorWindowBaseCntr.RemoveWindowBySelf(this);
}
//窗口当前的位置(以屏幕左上角为零点)
public Vector2 ScreenPosition=> this.position.position;
public virtual VisualElement OnCreateGUI()
{
var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(EditorUmxlPath);
visualTree.CloneTree(rootVisualElement);
//获取编辑器视图,这个视图下文会编写
WindowGraphView = rootVisualElement.Q<VEGraphView>("VEGraphView");
//添加编辑器视图中可以使用的节点类型
WindowGraphView.AddSelectableNode(typeof(VEBaseNodeData));
//添加编辑器视图中右键可以触发的功能菜单
WindowGraphView.OnBuildContextualMenu = OnGraphViewBuildContextualMenu;
return rootVisualElement;
}
//根据节点类型,获取其合适的存储地址
public string GetNodeDataPath(Type nodeDataType)
{
//意味着图中相同类型的节点都会存储在 DataPath/图的ID/节点类型 文件夹下
string nodeDataPath = DataPath + $"/{WindowGraphView.Graph.ID}/{nodeDataType.Name}";
VEUtility.MakeSureTheFolder(nodeDataPath);
return nodeDataPath;
}
public virtual void OpenGraph(GraphBase graph)
{
WindowGraphView.OpenGraph(graph);
}
protected virtual void OnGraphViewBuildContextualMenu(ContextualMenuPopulateEvent evt) { }
protected void OnDestroy()
{
AssetDatabase.SaveAssets();
}
}
视图-GraphView
视图中创建、销毁节点等的逻辑都写在GraphView组件里。为了使逻辑清晰,把具体的创建和销毁逻辑写到了单独的类中。官方提供的复制粘贴接口比较难用,笔者自己另做了一套,这里忽略,下文会单独说。
public class VEGraphView : GraphView
{
//为了让这个组件能够在umxl里被使用,需要添加这样一个类覆盖基类
public new class UxmlFactory : UxmlFactory<VEGraphView, VEGraphView.UxmlTraits> { }
//当前视图的编辑器窗口
private EditorWindowBase window;
//用来为SearchWindow,也就是Unity写好的查询节点的界面提供数据的类
private SearchWindowProvider provider;
//用来创建节点的工具
private NodeCreateBat create;
//用来销毁节点的工具
private NodeDeleteBat delete;
//限制编辑器中只能使用的节点类型
protected List<Type> selectableNodeViewType;
/// <summary>
/// 节点点击事件
/// </summary>
public Action<NodeViewBase> OnNodeSelected;
/// <summary>
/// 构建上下文事件
/// </summary>
public Action<ContextualMenuPopulateEvent> OnBuildContextualMenu;
public GraphBase Graph { get; set; }
//默认继承的这个List不好用,偶尔会出错,干脆就禁用了,改用自己的List
[Obsolete("'ports' is not valid in this project. Use 'Ports' instead.", true)]
public new List<Port> ports;
public List<Port> Ports { get; set; }
//在UI层级中,各节点UI的容器
public VisualElement NodesContainer { get; protected set; }
//鼠标在窗口上最后的位置
protected Vector2 lastMouseWindowPosition;
public VEGraphView():base()
{
//分别增加内容缩放,拖动,拖拽,框选控制器。如名称所示,它们分别允许对视图进行不同操作
var contentZoomer = new ContentZoomer();
//0.2f代表最小能缩放到标准尺寸的0.2倍
contentZoomer.minScale = 0.2f;
contentZoomer.maxScale = 1.5f;
this.AddManipulator(contentZoomer);
this.AddManipulator(new ContentDragger());
this.AddManipulator(new SelectionDragger());
//各个控制器的添加顺序是有要求的,不能乱加
this.AddManipulator(new RectangleSelector());
//侦测鼠标位置
this.RegisterCallback<MouseMoveEvent>(evt => lastMouseWindowPosition = evt.mousePosition);
//一些配置
graphViewChanged += OnGraphViewChanged;
viewTransformChanged += OnViewTransformChangedSaveInformation;
OnNodeSelected += OpenInspectorOnClickNodeView;
provider = ScriptableObject.CreateInstance<SearchWindowProvider>();
provider.Initialize(this);
selectableNodeViewType = new List<Type>();
//增加格子背景
var grid = new GridBackground();
grid.AddToClassList("Grid");
Insert(0, grid);
//将节点的UI容器添加到UI中,方便之后对节点的UI做调整
NodesContainer = new VisualElement();
NodesContainer.name = "Nodes";
contentViewContainer.Add(NodesContainer);
}
//通过具体的窗口完成初始化,获取该视图的UI样式等
public void InitByWindow(EditorWindowBase window)
{
this.window = window;
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(window.EditorUssPath);
styleSheets.Add(styleSheet);
create = new NodeCreateBat(window);
delete = new NodeDeleteBat(window);
}
//添加右键菜单中的功能
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
base.BuildContextualMenu(evt);
//本编辑器中禁用duplicate功能,从菜单中删除
var dulplicateIndex = evt.menu.MenuItems().Count - 2;
if (dulplicateIndex >= 0)
{
evt.menu.RemoveItemAt(dulplicateIndex);
evt.menu.RemoveItemAt(dulplicateIndex);
}
//断开连接指一键断开节点的连接;重建节点,也就是删除后再创建同类节点
if (evt.target is NodeViewBase view)
{
var nodes = selection.OfType<GraphElement>().OfType<NodeViewBase>().ToList();
evt.menu.AppendAction("断开连接", act => delete.DisconnectNodes(nodes));
evt.menu.AppendAction("重建节点",
action => nodes.ForEach(n=>RecreateNode(n)),view.canDelete?DropdownMenuAction.Status.Normal: DropdownMenuAction.Status.Disabled);
evt.menu.AppendSeparator();
//打印节点在图里的位置,用于初期DEBUG,后面基本没啥用
if (selection.OfType<NodeViewBase>().Count() == 1)
{
evt.menu.AppendAction("输出位置", action => Debug.Log($"Node:{view.NodeData.GUID} Position:{view.GetPosition().position}"));
evt.menu.AppendSeparator();
}
}
//把视图拉回零点并重置缩放
if (evt.target is VEGraphView)
{
evt.menu.AppendAction("重置视图", act => ChangeViewToOriginalPoint());
}
OnBuildContextualMenu?.Invoke(evt);
}
//重建节点
public void RecreateNode(NodeViewBase node)
{
var type = node.NodeData.GetType();
var position = Graph.GetNodePosition(node.NodeData);
//DeleteElements方法删除视图上的节点UI,会触发回调,删除节点的数据
DeleteElements(new List<GraphElement>() { node });
create.CreateNewNode(type, position);
}
public void ChangeViewToOriginalPoint()
{
this.UpdateViewTransform(Vector3.zero, Vector3.one);
}
//在编辑器里选中节点UI时,在Inspector面板同步打开节点文件(选中节点数据文件)
private void OpenInspectorOnClickNodeView(NodeViewBase nodeView)
{
Selection.activeObject = nodeView.NodeData;
}
public virtual void OpenGraph(GraphBase graph)
{
//清除节点UI
NodesContainer.Clear();
//清除连接UI,用is或OfType判断会出错,涉及类型转换问题
//var layer = contentViewContainer.Children().OfType<Layer>().FirstOrDefault();
var layer = contentViewContainer.Children().FirstOrDefault(ele => ele.GetType().Equals(typeof(Layer)));
layer?.Clear();
Graph = graph;
Ports ??= new List<Port>();
Ports.Clear();
var nodePositions = from node in Graph.nodesGraph.datas select Graph.GetNodePosition(node);
List<Vector2> positions = nodePositions.ToList();
//在视图上复原各节点的表现
create.RecoverNodesViewByDatasWithEdges(Graph.nodesGraph, positions, Vector2.zero);
//恢复上一次关闭时的布局
contentViewContainer.transform.position = Graph.graphViewTransformData.position;
contentViewContainer.transform.scale = Graph.graphViewTransformData.scale;
}
public NodeViewBase FindNodeViewByInstanceID(int id)
{
var nodeViews = from element in NodesContainer.Children()
where element is NodeViewBase view && view.NodeData.GetInstanceID() == id
select element as NodeViewBase;
return nodeViews.FirstOrDefault();
}
public void FocusViewOnNode(NodeDataBase node)
{
//将视图中心聚焦至节点
var newPos = -Graph.GetNodePosition(node) * viewTransform.scale;
newPos += window.position.size / 2;
UpdateViewTransform(newPos, Graph.graphViewTransformData.scale);
}
public void AddSelectableNode(Type newTypeView)
{
selectableNodeViewType.Add(newTypeView);
}
/// <summary>
/// 添加节点创建委托
/// </summary>
public void ApplyNodeCreationRequest()
{
provider.SetNodeViewType(selectableNodeViewType);
nodeCreationRequest = context =>
{
if (!Graph)
{
EditorUtility.DisplayDialog("无法创建节点", "请先创建或加载图文件", "好的");
return;
}
//打开SearchWindow
SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), provider);
};
}
//节点连接规则。当选中某端口(第一个参数)时,这个方法返回其可以进行连接的端口。
//第二个参数是节点适配器,负责转化节点。也就是判断“两个节点间能否连接”那一套,在本项目中没有用处
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
bool IsTypeAssignable(Port endPort)
{
//连接规则视具体需求而定,仅供参考。
if (startPort.portType is null) return false;
if (endPort.portType is null) return false;
if (startPort.portType.Equals(typeof(object))) return true;
if (endPort.portType.Equals(typeof(object))) return true;
//……
//允许子类数据->父类数据这样传递,反之则不允许
if (startPort.direction == Direction.Output)
{
return endPort.portType.IsAssignableFrom(startPort.portType);
}
else// (startPort.direction == Direction.Input)
{
return startPort.portType.IsAssignableFrom(endPort.portType);
}
}
//返回能连接的端口
var list = Ports.ToList().Where(endPort =>endPort.enabledSelf && startPort.enabledSelf && endPort.direction != startPort.direction && IsTypeAssignable(endPort));
return list.ToList() ;
}
//专为Provider提供的创建节点的方法,编辑器中右键添加节点就是调用的此方法。涉及到坐标转化,下文会说
public void CreateNewNodeForProvider(Type dataType, Vector2 clickPositionRelatedToScreen)
{
Vector2 clickPositionRelatedToWindow = VEUtility.PositionScreenToWindow(window, clickPositionRelatedToScreen);
Vector2 clickPositionRelatedToGraphView = VEUtility.PositionWindowToView(this, clickPositionRelatedToWindow);
Vector2 expectPosition = clickPositionRelatedToGraphView;
create.CreateNewNode(dataType, expectPosition);
}
//其他创建节点的方法,例如想在新建图时自动创建入口节点,就可以在EditorWindow中调用此方法
public NodeViewBase CreateNewNode(Type viewType, Vector2 clickPositionRelatedToGraphView)
{
Vector2 expectPosition = clickPositionRelatedToGraphView;
return create.CreateNewNode(viewType, expectPosition);
}
public Port FindPort(string guid)
{
return Ports.Find(port => port.viewDataKey.Equals(guid));
}
public void Disconnect(Port port)
{
delete.DisconnectPort(port);
}
/// <summary>
/// 视图变化事件,当视图中表现层产生变化时触发,在这里做数据的修改
/// </summary>
private GraphViewChange OnGraphViewChanged(GraphViewChange graphviewchange)
{
//视图中移除元素时
graphviewchange.elementsToRemove?.ForEach(elem =>
{
ElementToRemove(elem);
});
//视图中创建连线时
graphviewchange.edgesToCreate?.ForEach(edge =>
{
create.EdgeToCreate(edge);
});
//视图中移动元素时
graphviewchange.movedElements?.ForEach(element =>
{
ElementToMove(element);
});
return graphviewchange;
}
private void ElementToRemove(GraphElement elem)
{
//连线删除
if (elem is Edge edge)
{
delete.DeleteEdgeData(edge);
}
//节点删除
if (elem is NodeViewBase node)
{
delete.NodeElementToRemove(node);
}
}
private void ElementToMove(GraphElement element)
{
if (element is NodeViewBase nodeView)
Graph.SetNodePosition(nodeView.NodeData, nodeView.GetPosition().position);
}
/// <summary>
/// 在视图窗口中移动、缩放时触发
/// </summary>
private void OnViewTransformChangedSaveInformation(GraphView graphView)
{
if (!Graph) return;
Graph.graphViewTransformData.position = graphView.viewTransform.position;
Graph.graphViewTransformData.scale = graphView.viewTransform.scale;
}
//覆盖基类的方法。上面已经有一个FindPort方法了,可以只保留一个,这里属于防呆
public new Port GetPortByGuid(string VEGuid)
{
return Ports.FirstOrDefault(p => p.viewDataKey == VEGuid);
}
}
下面给出create和delete以及provider三个工具的代码。
public class NodeCreateBat
{
readonly EditorWindowBase window;
private GraphBase Graph => window.WindowGraphView?.Graph;
public NodeCreateBat(EditorWindowBase window)
{
this.window = window;
}
/// <summary>
/// 将已有的节点数据批量恢复成视图节点(NodeView)。包括打开图时和粘贴两种情况
/// </summary>
public void RecoverNodesViewByDatasWithEdges(NodesGraph ins, List<Vector2> relatedPoses, Vector2 origin)
{
if (!Graph) return;
var nodeDatas = ins.datas;
if (nodeDatas.Count != relatedPoses.Count)
throw new ArgumentException($"需要创建的节点列表(length:{nodeDatas.Count})必须与位置列表(length:{relatedPoses.Count})等长");
//Port(Data)的GUID->Port(View)的GUID
Dictionary<string, string> dataToPort = new Dictionary<string, string>();
var posEnumerator = relatedPoses.GetEnumerator();
posEnumerator.MoveNext();
foreach (var data in nodeDatas)
{
Vector2 position = origin + posEnumerator.Current;
//创建NodeView,这一过程中其内部会生成一个Port(View)GUID->Port(Data)GUID的字典
var view = RecoverNode(data, position);
posEnumerator.MoveNext();
foreach (var pair in view.portToData)
{
//将字典的键和值颠倒,得到一个新字典
dataToPort.Add(pair.Value.GUID,pair.Key);
}
}
//创建节点的边
RecoverEdgeView(ins, dataToPort);
//清除字典
dataToPort.Clear();
}
/// <summary>
/// 创建单个节点并加入到图中
/// </summary>
public NodeViewBase CreateNewNode(Type dataType, Vector2 position)
{
if (!Graph) return null;
string nodeDataPath = window.GetNodeDataPath(dataType);
var viewType = VEUtility.GetNodeCreationAttribute(dataType).ViewType;
//创建节点View
NodeViewBase nodeView = Activator.CreateInstance(viewType) as NodeViewBase;
//创建节点Data
NodeDataBase data = NodeDataBase.Create(dataType,nodeDataPath);
nodeView.SetNodeData(data);
InitNodeView(nodeView, position);
Graph.AddNode(nodeView.NodeData, position);
return nodeView;
}
private NodeViewBase RecoverNode(NodeDataBase data, Vector2 position)
{
if (!Graph) return null;
//创建View节点
Type type = data.GetViewType();
NodeViewBase nodeView = Activator.CreateInstance(type) as NodeViewBase;
nodeView.SetNodeData(data);
InitNodeView(nodeView, position);
return nodeView;
}
private void InitNodeView(NodeViewBase nodeView, Vector2 position)
{
//添加节点被选择事件
nodeView.OnNodeSelected = window.WindowGraphView.OnNodeSelected;
//由于NodeView本质是一个VisualElement,将其添加到节点UI容器中
//不要用 AddElement(nodeView),某些情况会无法添加,原因未知
window.WindowGraphView.NodesContainer.Add(nodeView);
//为节点设置位置(相对于视图窗口)
nodeView.SetPosition(new Rect(position, nodeView.GetPosition().size));
//初始化样貌
nodeView.InitNodeViewGUI(window.WindowGraphView);
//这里是前文提到的,将表现逻辑转移到NodeData中以简化开发
nodeView.NodeData.OnInitNodeViewGUI(window.WindowGraphView,nodeView);
}
/// <summary>
/// 遍历节点的输出端口,恢复节点连线
/// </summary>
private void RecoverEdgeView(NodesGraph ins, Dictionary<string, string> dataToPort)
{
foreach (var pair in ins.Pairs)
{
Port _output = window.WindowGraphView.FindPort(dataToPort[pair.outPort]);
Port _input = window.WindowGraphView.FindPort(dataToPort[pair.inPort]);
Edge _edge = new Edge()
{
input = _input,
output = _output,
};
//这行与_input.Connect(_edge)等效,就是把端口连接到此edge上
_edge.input.Connect(_edge);
_edge.output.Connect(_edge);
//加入到视图中
window.WindowGraphView.Add(_edge);
};
}
/// <summary>
/// 通过视图里的edge创建实际的数据连接关系
/// </summary>
public void EdgeToCreate(Edge edge)
{
NodeViewBase outputNode = edge.output.node as NodeViewBase;
NodeViewBase inputNode = edge.input.node as NodeViewBase;
OutputDataPort outputPort = outputNode.portToData[edge.output.viewDataKey] as OutputDataPort;
InputDataPort inputPort = inputNode.portToData[edge.input.viewDataKey] as InputDataPort;
Graph.nodesGraph.CreatePair(outputPort, inputPort);
}
}
public class NodeDeleteBat
{
readonly EditorWindowBase window;
private GraphBase Graph => window.WindowGraphView.Graph;
public NodeDeleteBat(EditorWindowBase window)
{
this.window = window;
}
/// <summary>
/// 删除节点本身的数据(NodeData)
/// </summary>
private void DeleteNodeData(NodeViewBase toDelete)
{
//删除端口
window.WindowGraphView.Ports.RemoveAll(port => toDelete.portToData.ContainsKey(port.viewDataKey));
//从图中移除节点
window.WindowGraphView.Graph.RemoveNode(toDelete.NodeData);
string nodePath = AssetDatabase.GetAssetPath(toDelete.NodeData.GetInstanceID());
//在项目中删除节点数据
AssetDatabase.DeleteAsset(nodePath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
/// <summary>
/// 移除节点的连接,包括图像和数据
/// </summary>
public void DisconnectNodes(List<NodeViewBase> nodes)
{
for (int i = 0; i < nodes.Count; i++)
{
DisconnectNode(nodes[i],false);
}
AssetDatabase.SaveAssets();
}
/// <summary>
/// 移除节点的连接,包括图像和数据
/// </summary>
public void DisconnectNode(NodeViewBase node, bool save = true)
{
var ports = node.portToData.Keys.ToList();
for (int portIndex = 0; portIndex < ports.Count; portIndex++)
{
var port = window.WindowGraphView.FindPort(ports[portIndex]);
DisconnectPort(port,false);
}
if (save) AssetDatabase.SaveAssets();
}
/// <summary>
/// 移除端口的连接,包括图像和数据
/// </summary>
public void DisconnectPort(Port port,bool save=true)
{
var connections = port.connections?.ToArray();
for (int edgeIndex = 0; edgeIndex < connections?.Length; edgeIndex++)
{
var tempEdge = connections[edgeIndex];
DeleteEdgeViewAndData(tempEdge);
}
if(save) AssetDatabase.SaveAssets();
}
/// <summary>
/// 移除连接数据
/// </summary>
public void DeleteEdgeData(Edge edge, bool save = true)
{
if (edge is null) return;
NodeViewBase outputNode = edge.output.node as NodeViewBase;
NodeViewBase inputNode = edge.input.node as NodeViewBase;
PortDataBase outputPort = outputNode.portToData[edge.output.viewDataKey];
PortDataBase inputPort = inputNode.portToData[edge.input.viewDataKey];
Graph.nodesGraph.RemovePair(outputPort, inputPort);
if (save) AssetDatabase.SaveAssets();
}
/// <summary>
/// 移除连接,包括图像和数据
/// </summary>
public void DeleteEdgeViewAndData(Edge edge, bool save = true)
{
//删除数据
DeleteEdgeData(edge,save);
//移除图像
DisConnectEdge(edge);
window.WindowGraphView.RemoveElement(edge);
}
private void DisConnectEdge(Edge edge)
{
edge.input.Disconnect(edge);
edge.output.Disconnect(edge);
edge.input = null;
edge.output = null;
}
public void NodeElementToRemove(NodeViewBase node)
{
DisconnectNode(node);
DeleteNodeData(node);
}
}
public class SearchWindowProvider : ScriptableObject, ISearchWindowProvider
{
private VEGraphView graphView;
//可用的节点类型,由编辑器窗口指定,传递到GraphView视图中,在视图创建provider时赋予
private List<Type> nodeDataTypeList;
public void Initialize(VEGraphView graphView)
{
this.graphView = graphView;
}
public void SetNodeViewType(List<Type> newTypes)
{
this.nodeDataTypeList = newTypes;
}
//以目前的设计,只能选择单个节点。如果想整什么创建节点组合,需要改造
//实现接口
List<SearchTreeEntry> ISearchWindowProvider.CreateSearchTree(SearchWindowContext context)
{
//key是节点类型名称 value是该类型下的节点
//这里的思路是:每个节点在创建菜单中都有一个父类别,相同父类别的节点在菜单中会放到一起
Dictionary<string, List<SearchTreeEntry>> dict=new Dictionary<string, List<SearchTreeEntry>>();
var entries = new List<SearchTreeEntry>
{
new SearchTreeGroupEntry(new GUIContent("创建节点"))
};
bool IsCanMatch(Type type)
{
//筛选出节点的非抽象类
if (!type.IsClass || type.IsAbstract) return false;
//筛选出可以兼容的类,即可以创建的节点(View)
bool canMatch = nodeDataTypeList.Exists(nType => nType.IsAssignableFrom(type));
return canMatch;
}
void AddEntry(string kind,SearchTreeEntry entry)
{
if(!dict.ContainsKey(kind))
//创建1级菜单,也就是选择父类别
dict.Add(kind, new List<SearchTreeEntry>() { new SearchTreeGroupEntry(new GUIContent(kind),1) });
dict[kind].Add(entry);
}
void SortEntries()
{
foreach (var list in dict.Values)
{
entries.AddRange(list);
}
}
//用反射获取到所有节点类型,自动完成菜单创建
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in assembly.GetTypes())
{
if (!IsCanMatch(type)) continue;
var attribute = VEUtility.GetNodeCreationAttribute(type);
var name = attribute.CreateName;
var kind = attribute.CreateBaseLevel;
//创建2级选择项
var entry = new SearchTreeEntry(new GUIContent(name)) { level = 2, userData = type };
AddEntry(kind,entry);
}
}
SortEntries();
return entries;
}
//实现接口,当开发者点击选项时实际执行的内容
bool ISearchWindowProvider.OnSelectEntry(SearchTreeEntry searchTreeEntry, SearchWindowContext context)
{
var type = searchTreeEntry.userData as System.Type;
var clickPosition = context.screenMousePosition;
graphView.CreateNewNodeForProvider(type,clickPosition);
return true;
}
}
坐标转换
provider接口中所给的坐标context.screenMousePosition,只有在编辑器窗口全屏并且没有缩放的时候,创建节点才不会错位。于是需要一个screen->window->graphview的转换,如下图所示。
![图片[7]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240122213319.png)
这里给出转换代码。注意,这个计算方式仍有误差,里面没有考虑GraphView窗口在EditorWindow里面的实际位置,这取决于开发者实际的UI布局,不方便计算。不过总体已经和实际位置比较接近了,在可容忍范围内。
public static Vector2 PositionScreenToWindow(EditorWindowBase window, Vector2 screenPos)
{
return screenPos - window.ScreenPosition;
}
public static Vector2 PositionWindowToView(VEGraphView graphView, Vector2 windowPos)
{
var pos = windowPos - new Vector2(graphView.viewTransform.position.x, graphView.viewTransform.position.y);
return pos / graphView.viewTransform.scale;
}
功能条
功能条基类的结构,除了在设计部分提到的几个事件,还有绘制/移除GUI的方法。
[Serializable]
public abstract class PieceDataBase:IGuidAble
{
public abstract InputDataPort InputPort { get; protected set; }
public abstract OutputDataPort OutputPort { get; protected set; }
public string Name;
[SerializeField]
[SerializeReference]
private string guid;
public string GUID => guid;
/// <summary>
/// Editor-Only
/// </summary>
public abstract void AddPieceGUI(VisualElement pieceContentContainer);
/// <summary>
/// Editor-Only
/// </summary>
public abstract void RemovePieceGUI(VisualElement pieceContentContainer);
public virtual void ClearUsedMark() { }
/// <summary>
/// 条事件1,在节点事件之前执行
/// </summary>
public virtual void ExecutePieceBeforeNodeEvent(RuntimeNodesGraph graph) { }
/// <summary>
/// 条事件2,在节点事件之后执行
/// </summary>
public virtual void ExecutePieceAfterNodeEvent(RuntimeNodesGraph graph) { }
/// <summary>
/// 为此条事件及其端口分配永久GUID
/// </summary>
public virtual void AllowcatePersistantGUID(bool _reset=false)
{
this.SetNodeGUID(reset: _reset);
InputPort?.SetNodeGUID(reset:_reset);
OutputPort?.SetNodeGUID(reset:_reset);
}
public void SetNodeGUID(string target = null, bool reset = false)
{
if (!reset && !string.IsNullOrEmpty(guid))
{
Debug.LogWarning($"为已有GUID的条再次分配,请检查错误。Debug:原GUID:{guid}");
return;
}
if (target is null) target = Guid.NewGuid().ToString();
guid = target;
}
public T As<T>() where T: PieceDataBase => this as T;
}
实用容器
有些情况下,根据节点中的选项不同,其需要的参数也不同。为了将不同的功能条打包成组,需要开发一个容器,使其能在编辑器中随时切换功能条组。
![图片[8]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240123185538.png)
为了能正常序列化,将List<Piece>封装成一个类(page),代表一个页面。该容器内存储多个页面,根据开发者的选择进行切换。这个类使用泛型仅仅是方便链式调用,如无需要可略去或改用扩展方法实现。
public abstract class BasePlexer<Self> where Self: BasePlexer<Self>
{
[SerializeField]
[SerializeReference]
protected List<PiecesPlexerPage> pages;
[SerializeField]
protected string nowPageName;
public PiecesPlexerPage NowPageContent => FindPage(nowPageName);
public string NowPageName => nowPageName;
//参数值->目标页名 当选择改变时,跳转至目标页
public Func<object,string> onValueChange;
//跳转结束后,根据新的选择值进行操作
public Action<object> onOpenPage;
public BasePlexer()
{
pages ??= new List<PiecesPlexerPage>();
}
public PiecesPlexerPage AddPage(string pageName)
{
var newPage = new PiecesPlexerPage(pageName);
pages.Add(newPage);
return newPage;
}
public Self AddPiece(string pageName, PieceDataBase piece)
{
var find = FindPage(pageName) ?? AddPage(pageName);
find.choicePieces.Add(piece);
piece.AllowcatePersistantGUID();
return this as Self;
}
protected void OpenPage(string pageName, NodeViewBase view, VEGraphView graphView)
{
var nowPage = FindPage(nowPageName);
var targetPage = FindPage(pageName);
if (targetPage is null) return;
//移除现有Page Data
if (nowPage is not null)
view.NodeData.Pieces.RemoveAll(p => nowPage.choicePieces.Contains(p));
//添加目标Page Data
if (targetPage.choicePieces.Count > 0)
view.NodeData.Pieces.AddRange(targetPage.choicePieces);
//重设外观
nowPage?.choicePieces.ForEach(p => view.RemovePieceView(graphView, p));
targetPage.choicePieces.ForEach(p => view.AddPieceView(graphView, p));
nowPageName = pageName;
}
public void InitOnGUI(VEGraphView graphView, NodeViewBase view, InputBasePieceData input)
{
input.RegisterEditorValueChangedCallback(change => OnChanged(graphView, view, change));
if(input.EditorObjectValue is not null)
OnChanged(graphView, view, input.EditorObjectValue);
}
private void OnChanged(VEGraphView graphView, NodeViewBase view, object newChoice)
{
var open = onValueChange?.Invoke(newChoice) ?? newChoice.ToString();
OpenPage(open, view, graphView);
onOpenPage?.Invoke(newChoice);
}
private PiecesPlexerPage FindPage(string pageName)
{
return pages.Find(p => p.name == pageName);
}
public List<PieceDataBase> FindPageContent(string pageName)
{
return FindPage(pageName)?.choicePieces;
}
}
[Serializable]
public class PiecesPlexerPage:IEnumerable<PieceDataBase>,IEnumerable
{
public string name;
[SerializeReference]
public List<PieceDataBase> choicePieces;
public PiecesPlexerPage(string pageName)
{
choicePieces ??= new List<PieceDataBase>();
name = pageName;
}
public PieceDataBase this[string name]=>choicePieces.Find(p=>p.Name.Equals(name));
public IEnumerator<PieceDataBase> GetEnumerator()
{
return choicePieces.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return choicePieces.GetEnumerator();
}
}
另一种常见的需求是,动态地添加/移除功能条,例如想操作字典里的参数。
![图片[9]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240124192259.png)
主要操作是在面板上加入可供操作的按钮。开始的预想是提供下拉菜单,后来发现直接加入“上移”“下移”等按钮更简洁。
[Serializable]
public class PiecesLister
{
[SerializeField]
[SerializeReference]
private List<PieceDataBase> pieces;
public IEnumerable<PieceDataBase> Pieces => pieces;
public int PiecesCount => pieces.Count;
//生成新的功能条
protected Func<PieceDataBase> onClickAdd;
//对新生成的功能条进行某些操作
protected Action<PieceDataBase> onInitGUI;
private NodeViewBase view;
private VEGraphView graphView;
[SerializeField]
[SerializeReference]
private TipPieceData titlePiece;
public string title;
public bool canMove;
public PiecesLister()
{
pieces ??= new List<PieceDataBase>();
canMove = true;
}
public PiecesLister(string title):this()
{
this.title = title;
}
//提供给子类
public virtual void InitOnGUI(NodeViewBase view, VEGraphView graphView)
{
this.view = view;
this.graphView = graphView;
if (titlePiece is null)
{
titlePiece = new TipPieceData(title);
view.AddPiece(graphView, titlePiece);
}
var add = new Button(() => OnAddPiece(onClickAdd()))
{
text = "+"
};
view.AddElementForPiece(titlePiece, add);
for (int i = 0; i < pieces?.Count; i++)
{
ExtendPieceItemGUI(pieces[i]);
}
}
public void InitOnGUI(NodeViewBase view, VEGraphView graphView, Func<PieceDataBase> onCreate,Action<PieceDataBase> onInitGUI=null)
{
this.onClickAdd = onCreate;
this.onInitGUI = onInitGUI;
this.InitOnGUI(view, graphView);
}
//扩展在列表中移动、删除的按钮
private void ExtendPieceItemGUI(PieceDataBase piece)
{
if(canMove)
{
var up = new Button(() => OnMovePiece(piece, -1))
{
text = "↑"
};
var down = new Button(() => OnMovePiece(piece, 1))
{
text = "↓"
};
view.AddElementForPiece(piece, up);
view.AddElementForPiece(piece, down);
}
var delete = new Button(() => OnRemovePiece(piece))
{
text = "X"
};
view.AddElementForPiece(piece, delete);
}
private void OnAddPiece(PieceDataBase add)
{
pieces.Add(add);
view.AddPiece(graphView, add);
view.MovePieceTo(add, titlePiece);
ExtendPieceItemGUI(add);
onInitGUI?.Invoke(add);
}
private void OnMovePiece(PieceDataBase move,int step)
{
var titleIndex = view.GetPieceIndex(titlePiece);
view.MovePiece(move, step, titleIndex+1,titleIndex + PiecesCount);
}
private void OnRemovePiece(PieceDataBase remove)
{
pieces.Remove(remove);
view.RemovePiece(graphView, remove);
}
}
复制粘贴
关于复制粘贴,GraphView默认提供的委托形式如下
- string SerializeGraphElementsDelegate(IEnumerable<GraphElement> elements),官方的注释是://Callback for serializing graph elements for copy/paste and other actions.将图元素列表序列化为一个字符串。
- bool CanPasteSerializedDataDelegate(string data),官方注释://Ask whether or not the serialized data can be pasted.也就是根据上一步序列化得到的字符串,判断其能否被粘贴。
- void UnserializeAndPasteDelegate(string operationName, string data),官方注释://Callback for unserializing graph elements and adding them to the graph.若上一步判断能够粘贴,将序列化得到的字符串反序列化为图元素并放入视图中。
可见Unity对开发者的水平非常自信,认为开发者可以轻松将一堆图元素序列化为一个字符串。遗憾的是笔者的水平不足,只会克隆ScriptableObject。标准的复制粘贴,应该是只复制引用数据,粘贴时才完成拷贝工作。因为工作量较大,笔者还是选用了比较简单的步骤,在复制时就完成拷贝,在粘贴时再次拷贝。
这里使用一个”剪切板“类,用于根据图像元素完成位置和连接的记录。
public class NodesClipBoard : ScriptableObject,IClipBoard
{
//支持的编辑器类型,用于跨编辑器复制
public SerializableType editorWindowType;
//复制的“部分”节点图
public NodesGraph rawCopy;
public IEnumerable<ScriptableObject> RawScriptablesToSave => rawCopy.datas;
/// <summary>
/// 所有元素中点的位置
/// </summary>
public Vector2 AverPos { get; private set; }
/// <summary>
/// 各元素相对所有元素中点的位置
/// </summary>
[SerializeReference]
public List<Vector2> relatedPos;
public void Init(Type type, IEnumerable<GraphElement> elements)
{
rawCopy = new NodesGraph();
var views = elements.OfType<NodeViewBase>();
var links = elements.OfType<Edge>();
editorWindowType = new SerializableType(type);
relatedPos = CalRelatedPosOnInit(views).ToList();
foreach (var view in views)
{
var ins = ScriptableObject.Instantiate(view.NodeData);
rawCopy.datas.Add(ins);
}
SavePortPair(views,links);
}
//计算各节点相对于中心的偏移量(即相对位置)
private Vector2[] CalRelatedPosOnInit(IEnumerable<NodeViewBase> nodes)
{
int count = nodes.Count();
var absolutePos = new Vector2[count];
var gItor = nodes.GetEnumerator();
gItor.MoveNext();
for (int index = 0; index < count; index++)
{
absolutePos[index] = gItor.Current.GetPosition().position;
gItor.MoveNext();
}
AverPos = absolutePos.Aggregate<Vector2>((v1, v2) => v1 + v2) / count;
var relatedPosArray = new Vector2[count];
for (int index = 0; index < count; index++)
{
relatedPosArray[index] = absolutePos[index] - AverPos;
}
return relatedPosArray;
}
//根据图像记录端口的连接信息
private void SavePortPair(IEnumerable<NodeViewBase> views,IEnumerable<Edge> edges)
{
foreach(var edge in edges)
{
//只保存两端都在选择范围内的边
var outputNode = edge.output.node as NodeViewBase;
if (!views.Contains(outputNode)) continue;
var inputNode = edge.input.node as NodeViewBase;
if (!views.Contains(inputNode)) continue;
var output = outputNode.portToData[edge.output.viewDataKey];
var input = inputNode.portToData[edge.input.viewDataKey];
rawCopy.CreatePair(output, input);
}
}
public NodesGraph OnPaste(GraphBase to,Vector2 offset,Func<Type,string> GetNodeDataPath)
{
//将当前复制的节点(未经处理)拷贝一份用于处理和粘贴
var clonePart = rawCopy.GetClone(node => VEUtility.SaveNodeInAsset(node, GetNodeDataPath.Invoke(node.GetType())),true);
to.AddGraph(clonePart,relatedPos,offset);
return clonePart;
}
public bool IsMatchTo(Type type) => editorWindowType.typeValue?.IsAssignableFrom(type) ?? false;
}
然后,编写委托内容
public VEGraphView():base()
{
……
serializeGraphElements += GraphSerializeGraphElementsDelegate;
canPasteSerializedData += GraphCanPasteSerializedDataDelegate;
unserializeAndPaste += GraphUnserializeAndPasteDelegate;
……
}
protected string GraphSerializeGraphElementsDelegate(IEnumerable<GraphElement> elements)
{
//因为elements里不包含edge,所以改用selection里的数据
var graphs = selection.OfType<GraphElement>();
var clip = create.Copy(graphs);
//将剪切板保存到项目文件中
window.SetClip(clip);
//不走原来的string data通道
return null;
}
protected bool GraphCanPasteSerializedDataDelegate(string data)
{
//图文件加载时才允许粘贴
if (!Graph) return false;
//剪切板内容匹配graph时允许粘贴
if (!window.IsClipMatch()) return false;
return true;
}
protected void GraphUnserializeAndPasteDelegate(string operationName, string data)
{
var clip = window.GetClip() as NodesClipBoard;
//以鼠标位置为中心,粘贴剪切板内容
create.Paste(clip, VEUtility.PositionWindowToView(this, lastMouseWindowPosition));
}
这便是实现复制粘贴功能的核心部分,剩下只需要在编辑器窗口中完成与剪切板类的交互即可。
执行
按照既定的图的执行顺序,依次执行即可。
//添加入口节点……
while (invokeList.Count > 0)
{
int count = 0;
var node = invokeList[count];
//处理节点内部事件
node.ExecutePiecesBeforeEvent(nodesGraph);
node.ExecuteNodeEvent(nodesGraph);
node.ExecutePiecesAfterEvent(nodesGraph);
//获取接下来的的可执行节点
var adds = node.ExecuteOutportsEvent(nodesGraph);
//异常处理等……
invokeList.RemoveAt(count);
invokeList.AddValidRange(adds);
}
UI
至此,编辑器的基础框架完成。在此之上开发具体的编辑器类型,最少仅需要完成相应编辑器窗口的开发即可。编辑器窗口主要负责配置UI和加载/创建图的工作。这里给出笔者所做的buff编辑器窗口,用于参考加载/创建图的过程。
[OpenSet(typeof(BuffGraph))]
public class BuffEditorWindow : EditorWindowBase
{
//窗口的UI组件
private Label idLabel;
private TextField idInputField;
private Label viewPosLabel;
//样式地址和存储地址
public override string EditorUmxlPath => "Assets/Scripts/BuffVisualEditor/BuffEditorWindow.uxml";
public override string EditorUssPath => "Assets/Scripts/BuffVisualEditor/BuffEditorWindow.uss";
public override string DataPath => "Assets/Scriptable/BuffData";
//打开方式
[MenuItem("编辑工具/Buff编辑器")]
public static void OnOpenedByMenu()
{
var window = BaseOnOpenedByMenu<BuffEditorWindow>();
window.titleContent.text = "BuffEditor";
}
//进行UI组件的初始化
public override VisualElement OnCreateGUI()
{
var root = base.OnCreateGUI();
WindowGraphView.InitByWindow(this);
//添加可用的节点类型
WindowGraphView.AddSelectableNode(typeof(BuffNodeDataBase));
//如若有其他节点类型可用,在这里添加……
//添加完成后,进行应用
WindowGraphView.ApplyNodeCreationRequest();
//获取组件并初始化
idLabel = root.Q<Label>("IDShower");
idInputField = root.Q<TextField>("IDField");
viewPosLabel = root.Q<Label>("ViewPos");
WindowGraphView.viewTransformChanged += UpdateViewPosLabel;
var openButton = root.Q<ToolbarButton>("OpenButton");
openButton.clicked += OnOpenGraphButtonClicked;
return root;
}
public override void OpenGraph(GraphBase graph)
{
base.OpenGraph(graph);
UpdateIDLabelText();
UpdateViewPosLabel(WindowGraphView);
}
//视图更新时,刷新坐标显示
private void UpdateViewPosLabel(GraphView view)
{
Vector2 viewPos = view.viewTransform.position;
viewPosLabel.text = $"X:{(int)viewPos.x} Y:{(int)viewPos.y}";
}
protected void OnOpenGraphButtonClicked()
{
//加载图的预备工作
string targetID = idInputField.text;
//不能以空白作为名称首位
if (targetID.Length == 0 || char.IsWhiteSpace(targetID[0])|| char.IsWhiteSpace(targetID[^1]))
{
//EditorUtility.DisplayDialog("无效输入", "输入不能为空", "确定");
//return;
//测试阶段,默认加载
targetID = "Test";
}
var path = DataPath+$"/{targetID}";
VEUtility.MakeSureTheFolder(path);
//在文件里尝试寻找目标
string[] guids = AssetDatabase.FindAssets("t:BuffGraph",new string[] { path});
var find = guids.FirstOrDefault();
bool hasFound = find is not null;
BuffGraph graph = null;
//没找到则新建图文件
if (!hasFound)
{
graph = ScriptableObject.CreateInstance<BuffGraph>();
graph.ID = targetID;
AssetDatabase.CreateAsset(graph, path + $"/Graph_{targetID}.Asset");
}
//找到了则获取图文件
if (hasFound)
{
var targetPath = AssetDatabase.GUIDToAssetPath(find);
graph = AssetDatabase.LoadAssetAtPath<BuffGraph>(targetPath);
var hasOpened = EditorWindowBaseCntr.FindWindowByGraphInstanceID(graph.GetInstanceID());
if(hasOpened)
{
EditorUtility.DisplayDialog("图已被占用", "该图已被其他窗口占用,请勿重复获取", "了解");
return;
}
}
EditorUtility.SetDirty(graph);
//打开图文件
this.OpenGraph(graph);
//这里已经完成了图的加载/创建,各个编辑器的思路应该差不多。
……
}
//扩展视图中的右键菜单功能
protected override void OnGraphViewBuildContextualMenu(ContextualMenuPopulateEvent evt)
{
if (evt.target is VEGraphView view)
{
evt.menu.AppendSeparator();
evt.menu.AppendAction("重建定义节点", _ => CreateDefineNode(view.Graph as BuffGraph));
}
}
//更新ID展示
private void UpdateIDLabelText()
{
idLabel.text = WindowGraphView.Graph.ID;
}
}
然后绘制编辑器窗口,首先要创建样式。确保已经按装UI Tookit模块(2021.3之前版本需手动安装)。在项目(Project)视图右键-Create-UITookit-EditorWindow,输入编辑器名称即可。详细一点可参考这篇博客。
创建之后,打开uxml文件,Hierarchy窗口里展示了当前UI层级。为了使用仅编辑器可用的组件,需要先在层级中点击.uxml文件。
![图片[10]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240124205608.png)
然后,在其Inspector面板里勾选编辑器扩展按钮。
![图片[11]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240124205621.png)
接下来,就可以使用所有Library窗口里展示的UI组件了。GraphView组件,需要在Library/Project里找到对应脚本位置。
![图片[12]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240124205648.png)
拼装完成后,还可以编辑.uss进行美化。
效果
以上,编辑器开发思路已结束。接下来分享笔者用此框架开发的buff编辑器的实际效果和部分代码思路。
![图片[13]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/Screenshot-2024-01-24-224901.jpg)
![图片[14]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240124231407.png)
//此标签描述在创建菜单里的名称和分类
[NodeCreation("BuffEnter", "流程节点", nameof(BuffEnterNodeData))]
public class BuffEnterNodeData:BuffNodeDataBase,IEnterNode
{
//下拉分页器,用Dropdown将不同参数分成不同页面
[SerializeField]
protected DropdownPlexer plexer;
//入口名称
public string EnterCallbackName => plexer.DropdownValue;
//入口参数
protected object[] invokeParams;
public object[] InvokeParams { set => invokeParams = value; }
protected override void InitOnFirstCreated()
{
base.InitOnFirstCreated();
//创建类型为信号枚举的主输出端口
CreateMainOut<SignalEnum>();
//创建分页器
plexer = new DropdownPlexer();
//为字段功能条配置:不可设值,无输入端口,有输出端口
bool[] paramSet = new bool[] { false, false, true };
//获取所有回调点类型
foreach (var callbackType in typeof(SETUP).GetNestedTypes())
{
var pageName = callbackType.GetLastName();
var tip = callbackType.GetCustomAttribute<VisualEditorTipAttribute>().Tip;
//新增选项页
plexer.AddSetAndPage(pageName,tip);
//根据回调点委托参数生成相应类型的字段功能条到该页中
foreach (var paramItem in BuffDelegateUtility.GetDelegateParameterInfos(callbackType))
{
plexer.AddPiece(pageName, new FieldPieceData(this, paramItem.ParameterType, paramItem.Name, paramSet));
}
}
}
public override void ExecuteNodeEvent(RuntimeNodesGraph graph)
{
//获取当前页面,为其中每个字段功能条设置启动参数
var nowPageList = plexer.NowPageContent.choicePieces;
FieldPieceData field;
for (int i = 0; i < nowPageList?.Count; i++)
{
field = nowPageList[i] as FieldPieceData;
field.SetRuntimeValue(invokeParams[i]);
}
mainOut.SetValue(SignalEnum.Default.Normal());
}
public override void OnInitNodeViewGUI(VEGraphView graphView, NodeViewBase view)
{
//绘制分页器的GUI
plexer.InitOnGUI(graphView, view);
}
}
![图片[15]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240124234717.png)
在开发一节提到过能够动态添加/删除功能条的容器,这里对其进行了小幅改造,使其成为新的功能独特的容器,具体代码就不赘述了。
结束之前,展示一下完整的编辑器界面,以及复制粘贴过程(实际过程中不需要窗口同时保持打开状态,这里只是为了方便演示)
![图片[16]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/Screenshot-2024-01-24-235947.jpg)
![图片[17]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/Screenshot-2024-01-25-000107.jpg)
最后,放一张节点图的执行记录(执行的是上图中左边窗口所加载的节点图)
![图片[18]-【Unity】GraphView节点编辑器 完整开发思路](https://www.shuibo.moe/wp-content/uploads/2024/01/QQ截图20240125001353.png)
代码附录
编辑器还在不断完善中,因此无法给出完整项目,请见谅。若有疑问或建议请留言,也可发送邮件至acoloco@shuibo.moe
这里给出编辑器中其余一些关键的代码部分,仅供参考。
节点数据-NodeData
public abstract class NodeDataBase : ScriptableObject,IGuidAble,IScriptable
{
public abstract InputDataPort MainInputPort { get; }
public abstract OutputDataPort MainOutputPort { get; }
[SerializeReference]
public List<PieceDataBase> Pieces;
public PieceDataBase FindPieceByName(string pieceName)
{
var p = Pieces.Find(p => p.Name==pieceName);
if(p is null) Debug.LogWarning($"未找到名为 {pieceName} 的Piece,可能导致值出错");
return p;
}
[SerializeField]
[SerializeReference]
private string guid;
public string GUID => guid;
[SerializeField]
private bool isFirstCreated = true;
public bool IsFirstCreated { get=>isFirstCreated; protected set=>isFirstCreated = value; }
private void Awake()
{
if (!isFirstCreated) return;
InitOnFirstCreated();
isFirstCreated = false;
}
protected virtual void InitOnFirstCreated()
{
Pieces = new List<PieceDataBase>();
}
public virtual void OnInitNodeViewGUI(VEGraphView graphView, NodeViewBase view) { }
/// <summary>
/// 在创建节点时分配永久GUID
/// </summary>
public virtual void AllowcatePersistantGUIDForCreatingNode()
{
this.SetNodeGUID();
MainInputPort?.SetNodeGUID();
MainOutputPort?.SetNodeGUID();
Pieces.ForEach(piece => piece.AllowcatePersistantGUID());
}
public IEnumerable<PortDataBase> GetPorts()
{
List<PortDataBase> ports = new List<PortDataBase>();
if(MainInputPort is not null)ports.Add(MainInputPort);
if (MainOutputPort is not null) ports.Add(MainOutputPort);
Pieces.ForEach(p =>
{
if (p.InputPort is not null) ports.Add(p.InputPort);
if (p.OutputPort is not null) ports.Add(p.OutputPort);
});
return ports;
}
public IEnumerable<PortDataBase> GetPorts(Predicate<InputDataPort> forIn,Predicate<OutputDataPort> forOut)
{
List<PortDataBase> ports = new List<PortDataBase>();
if (MainInputPort is not null && forIn.Invoke(MainInputPort)) ports.Add(MainInputPort);
if (MainOutputPort is not null && forOut.Invoke(MainOutputPort)) ports.Add(MainOutputPort);
Pieces.ForEach(p =>
{
if (p.InputPort is not null && forIn.Invoke(p.InputPort)) ports.Add(p.InputPort);
if (p.OutputPort is not null && forOut.Invoke(p.OutputPort)) ports.Add(p.OutputPort);
});
return ports;
}
public IEnumerable<PortDataBase> GetLinkedPorts(RuntimeNodesGraph graph)
{
return GetPorts(i => graph.runtimeConnections.IsLinked(i),o=>graph.runtimeConnections.IsLinked(o));
}
public IEnumerable<PortDataBase> GetLinkedPorts(NodesGraph graph)
{
return GetPorts(i => graph.IsLinked(i), o => graph.IsLinked(o));
}
public IEnumerable<InputDataPort> GetLinkedInputPorts(RuntimeNodesGraph graph)
{
return GetPorts(i => graph.runtimeConnections.IsLinked(i), o => false).OfType< InputDataPort>();
}
public IEnumerable<OutputDataPort> GetLinkedOutputPorts(RuntimeNodesGraph graph)
{
return GetPorts(i => false, o => graph.runtimeConnections.IsLinked(o)).OfType<OutputDataPort>();
}
/// <summary>
/// <para>是否可执行(如激活端口已满足等)</para>
/// </summary>
public virtual bool IsCanBeInvoked(RuntimeNodesGraph graph)
{
bool can = true;
//若存在主端口,必须提交
//所有有连接端口必须提交
if(MainInputPort is not null)
{
can &= this.MainTransmitted(graph);
}
can &= this.AllLinkedTransmitted(graph);
return can;
}
public virtual void ClearUsedMark()
{
Pieces.ForEach(p => p.ClearUsedMark());
}
/// <summary>
/// 块事件
/// </summary>
public abstract void ExecuteNodeEvent(RuntimeNodesGraph graph);
/// <summary>
/// 先条事件
/// </summary>
public virtual void ExecutePiecesBeforeEvent(RuntimeNodesGraph graph)
{
Pieces.ForEach(piece => piece.ExecutePieceBeforeNodeEvent(graph));
}
/// <summary>
///后条事件
/// </summary>
public virtual void ExecutePiecesAfterEvent(RuntimeNodesGraph graph)
{
Pieces.ForEach(piece => piece.ExecutePieceAfterNodeEvent(graph));
}
/// <summary>
/// 端口提交事件
/// </summary>
/// <returns>能执行的节点</returns>
public virtual List<NodeDataBase> ExecuteOutportsEvent(RuntimeNodesGraph graph)
{
List<NodeDataBase> list = new List<NodeDataBase>();
list.AddValidRange(MainOutputPort?.Transmit(graph));
foreach(var piece in Pieces)
{
var result = piece.OutputPort?.Transmit(graph);
list.AddValidRange(result);
}
var types = from n in list select n.GetType().Name;
//Debug.Log($"经过节点{this.ViewType}后可执行{list.Count}个节点,分别是:{string.Join(',', types)}");
return list;
}
protected void OnValidate()
{
#if UNITY_EDITOR
EditorUtility.SetDirty(this);
#endif
}
public void SetNodeGUID(string target = null, bool reset = false)
{
if (!reset && guid is not null)
{
Debug.LogWarning("为已有GUID的NodeData再次分配,检查错误");
return;
}
if (target is null) target = Guid.NewGuid().ToString();
guid = target;
}
/// <summary>
/// 用于新建节点
/// </summary>
public static NodeDataBase Create(Type nodeDataType, string nodeDataPath)
{
var nodeData = ScriptableObject.CreateInstance(nodeDataType) as NodeDataBase;
nodeData.AllowcatePersistantGUIDForCreatingNode();
return VEUtility.SaveNodeInAsset(nodeData, nodeDataPath);
}
}
节点表现-NodeView
public class NodeViewBase : Node
{
public Action<NodeViewBase> OnNodeSelected;
/// <summary>
/// PortView的GUID到对应数据端口的字典,在GUI生成后有效,用于编辑器中节点连接判断等
/// </summary>
public Dictionary<string, PortDataBase> portToData;
//数据,在View被创建出来时注入
public virtual NodeDataBase NodeData { get; set; }
protected VisualElement customTopParent;
protected VisualElement pieceElementParent;
public NodeViewBase() : base()
{
portToData = new Dictionary<string, PortDataBase>();
}
public virtual void InitNodeViewGUI(VEGraphView graphView)
{
customTopParent = new VisualElement() { name = "NodeTop" };
customTopParent.AddToClassList("Border");
customTopParent.AddToClassList("Space");
contentContainer.Add(customTopParent);
pieceElementParent = new VisualElement() { name = "Pieces" };
pieceElementParent.AddToClassList("Border");
contentContainer.Add(pieceElementParent);
AddPort(graphView, customTopParent.contentContainer,NodeData.MainInputPort);
customTopParent.contentContainer.Add(new TextElement() { text = VEUtility.GetNodeCreationAttribute(NodeData.GetType()).CreateName }) ;
AddPort(graphView, customTopParent.contentContainer,NodeData.MainOutputPort);
NodeData.Pieces.ForEach(piece =>
{
AddPieceView(graphView, piece);
});
}
/// <summary>
/// 用于克隆节点数据,会清除连接
/// </summary>
public virtual NodeDataBase CloneNodeData(string nodeDataPath)
{
return VEUtility.InstantiateNodeDataAndSave(NodeData, nodeDataPath);
}
public NodeViewBase SetNodeData(NodeDataBase nodeData)
{
SetDataWithDirty(nodeData);
return this;
}
private void SetDataWithDirty(NodeDataBase nodeData)
{
NodeData = nodeData;
if(!EditorUtility.IsDirty(NodeData.GetInstanceID()))
EditorUtility.SetDirty(NodeData);
}
public void AddPiece(VEGraphView graphView, PieceDataBase piece)
{
piece.AllowcatePersistantGUID();
NodeData.Pieces.Add(piece);
AddPieceView(graphView, piece);
}
/// <summary>
/// 恢复外观
/// </summary>
public void AddPieceView(VEGraphView graphView,PieceDataBase piece)
{
var pieceElement = new VisualElement()
{
name = piece.Name,
viewDataKey = piece.GUID
};
pieceElement.AddToClassList("Piece");
pieceElement.AddToClassList("Space");
pieceElementParent.Add(pieceElement);
AddPort(graphView,pieceElement,piece.InputPort);
piece.AddPieceGUI(pieceElement.contentContainer);
AddPort(graphView, pieceElement, piece.OutputPort);
}
private VisualElement FindPieceViewByData(PieceDataBase piece)
{
return pieceElementParent.Children().FirstOrDefault(p => p.viewDataKey?.Equals(piece.GUID) ?? false);
}
protected internal void AddElementForPiece(PieceDataBase piece,VisualElement add)
{
var pieceElement = FindPieceViewByData(piece);
if(pieceElement is null)
{
Debug.LogWarning("没有找到PieceView,这意味着为条扩展元素出现了一些错误。");
return;
}
var container = pieceElement.contentContainer;
container.Insert(container.childCount - 1, add);
}
protected internal void RemovePiece(VEGraphView graphView, PieceDataBase piece)
{
NodeData.Pieces.Remove(piece);
RemovePieceView(graphView, piece);
}
protected internal void RemovePieceView(VEGraphView graphView, PieceDataBase piece)
{
var toRemove = FindPieceViewByData(piece);
if (toRemove is null)
{
Debug.LogWarning("没有找到PieceView,这意味着删除条出现了一些错误。");
return;
}
RemovePort(graphView, toRemove, piece.InputPort);
piece.RemovePieceGUI(toRemove.contentContainer);
RemovePort(graphView, toRemove, piece.OutputPort);
pieceElementParent.Remove(toRemove);
}
protected internal void MovePiece(PieceDataBase piece, int move)
{
MovePieceView(piece, move);
NodeData.MovePiece(piece, move);
}
protected internal void MovePiece(PieceDataBase piece, int move, int min)
{
MovePieceView(piece, move, min);
NodeData.MovePiece(piece, move, min);
}
protected internal void MovePiece(PieceDataBase piece, int move, int min, int max)
{
MovePieceView(piece, move, min, max);
NodeData.MovePiece(piece, move, min, max);
}
protected internal void MovePieceView(PieceDataBase piece,int move)
{
MovePieceView(piece, move, 0, pieceElementParent.childCount - 1);
}
protected internal void MovePieceView(PieceDataBase piece, int move, int min)
{
MovePieceView(piece, move, min, pieceElementParent.childCount - 1);
}
protected internal void MovePieceView(PieceDataBase piece, int move, int min, int max)
{
var pieceElement = FindPieceViewByData(piece);
if (pieceElement is null)
{
Debug.LogWarning("没有找到PieceView,这意味着移动条出现了一些错误。");
return;
}
var index = pieceElementParent.IndexOf(pieceElement);
var endIndex = Mathf.Clamp(index + move, min, max);
pieceElementParent.RemoveAt(index);
pieceElementParent.Insert(endIndex, pieceElement);
}
protected internal void MovePieceTo(PieceDataBase piece, PieceDataBase beforeTatget)
{
MovePieceViewTo(piece, beforeTatget);
NodeData.MovePieceTo(piece, beforeTatget);
}
protected internal void MovePieceViewTo(PieceDataBase piece, PieceDataBase beforeTatget)
{
MovePiece(piece, GetPieceIndex(beforeTatget) - GetPieceIndex(piece) + 1, 0, pieceElementParent.childCount - 1);
}
protected internal int GetPieceIndex(PieceDataBase piece)
{
var pieceElement = FindPieceViewByData(piece);
return pieceElementParent.IndexOf(pieceElement);
}
/// 编辑器根据Data初始化端口
protected Port AddPort(VEGraphView graphView, VisualElement container,PortDataBase dataPort)
{
//可能是单边端口,或者无端口等情况
if (dataPort is null)
{
var blankPort = this.InstantiatePort(Orientation.Horizontal, Direction.Input, Port.Capacity.Single, null);
container.Add(blankPort);
blankPort.visible = false;
return blankPort;
}
Direction direction = (dataPort is InputDataPort) ? Direction.Input : Direction.Output;
Port.Capacity capacity = (dataPort.IsMulti) ? Port.Capacity.Multi : Port.Capacity.Single;
var insPort = this.InstantiatePort(Orientation.Horizontal, direction, capacity, dataPort.DataType);
insPort.portName = dataPort.Name;
container.Add(insPort);
portToData.Add(insPort.viewDataKey, dataPort);
graphView.Ports.Add(insPort);
return insPort;
}
public string FindPort(PortDataBase data)
{
return portToData.Keys.FirstOrDefault(key => portToData[key].Equals(data));
}
public void RemovePort(VEGraphView graphView, VisualElement container, PortDataBase dataPort)
{
if (dataPort is null) return;
var portDataGuid = FindPort(dataPort);
var portView = graphView.GetPortByGuid(portDataGuid);
if (portView is null) return;
graphView.Disconnect(portView);
container.Remove(portView);
graphView.Ports.Remove(portView);
portToData.Remove(portDataGuid);
}
public void SetPortEnabled(VEGraphView graphView, PortDataBase dataPort,bool enabled)
{
if (dataPort is null) return;
var portDataGuid = FindPort(dataPort);
var portView = graphView.GetPortByGuid(portDataGuid);
if (portView is null) return;
portView.SetEnabled(enabled);
if (!enabled) graphView.Disconnect(portView);
}
public override void OnSelected()
{
base.OnSelected();
OnNodeSelected?.Invoke(this);
}
}
端口-PortData
[Serializable]
public abstract class PortDataBase:IGuidAble
{
/// <summary>
/// 传递的数据类型
/// </summary>
public abstract Type DataType { get; }
public abstract bool IsMulti { get; }
[SerializeField]
[SerializeReference]
private string guid;
public string GUID => guid;
public string Name;
[SerializeReference]
public NodeDataBase nodeData;
public PortDataBase(NodeDataBase nodeData)
{
this.nodeData = nodeData;
}
/// <summary>
/// 若此端口没有GUID,为其分配指定或随机生成的GUID。
/// </summary>
/// <param name="target">新GUID</param>
/// <param name="reset">强制变更</param>
public void SetNodeGUID(string target = null,bool reset = false)
{
if (!reset && !string.IsNullOrEmpty(guid))
{
Debug.LogWarning("为已有GUID的端口再次分配,检查错误");
return;
}
if(target is null)target = Guid.NewGuid().ToString();
guid = target;
}
}
节点子图-NodesGraph
[Serializable]
public class NodesGraph
{
/// <summary>
/// 节点data数据,在关闭窗口后或着删除节点后不会丢失
/// </summary>
[SerializeReference]
public List<NodeDataBase> datas;
/// <summary>
/// 头部节点,如入口节点
/// </summary>
[SerializeReference]
public List<NodeDataBase> heads;
/// <summary>
/// 记录连接对
/// </summary>
[SerializeReference]
[SerializeField]
protected List<PortPair> pairs;
public NodesGraph()
{
datas = new List<NodeDataBase>();
heads = new List<NodeDataBase>();
pairs = new List<PortPair>();
}
public bool IsLinked(PortDataBase port)
{
var hasPair = pairs.Find(p => p.inPort == port.GUID || p.outPort == port.GUID);
return hasPair is not null;
}
/// <summary>
/// 在编辑器中创建连接
/// </summary>
public void CreatePair(PortDataBase output, PortDataBase input)
{
CreatePair(output.GUID, input.GUID);
}
/// <summary>
/// 在编辑器中创建连接
/// </summary>
public void CreatePair(string output, string input)
{
var hasPair = pairs.Find(p => p.inPort == input && p.outPort == output);
if (hasPair is null) pairs.Add(new PortPair(input, output));
}
/// <summary>
/// 在编辑器中移除连接
/// </summary>
public void RemovePair(PortDataBase output, PortDataBase input)
{
RemovePair(output.GUID, input.GUID);
}
/// <summary>
/// 在编辑器中移除连接
/// </summary>
public void RemovePair(string output, string input)
{
pairs.RemoveAll(p => p.inPort == input && p.outPort == output);
}
/// <summary>
/// 在编辑器中移除节点连接
/// </summary>
public void RemovePair(NodeDataBase node)
{
List<PortDataBase> linkedPorts = new List<PortDataBase>(node.GetLinkedPorts(this));
for (int i = 0; i < linkedPorts.Count; i++)
{
pairs.RemoveAll(p => p.inPort == linkedPorts[i].GUID || p.outPort == linkedPorts[i].GUID);
}
}
/// <summary>
/// 重置Guid(Pairs将同步更新),返回一份新Guid到端口的字典
/// </summary>
public Dictionary<string,PortDataBase> ResetGuid()
{
Dictionary<string, PortDataBase> oldGuidToNewPort = new Dictionary<string, PortDataBase>();
Dictionary<string, PortDataBase> newGuidToNewPort = new Dictionary<string, PortDataBase>();
foreach (var node in datas)
{
foreach (var piece in node.Pieces)
{
piece.SetNodeGUID(reset: true);
}
foreach (var port in node.GetPorts())
{
string oldGuid = port.GUID;
port.SetNodeGUID(reset: true);
oldGuidToNewPort.Add(oldGuid, port);
newGuidToNewPort.Add(port.GUID, port);
}
}
var newPairs = new List<PortPair>();
foreach (var pair in pairs)
{
newPairs.Add(new PortPair(oldGuidToNewPort[pair.inPort].GUID, oldGuidToNewPort[pair.outPort].GUID));
}
pairs = newPairs;
return newGuidToNewPort;
}
/// <summary>
/// 返回一份节点拷贝
/// </summary>
public NodesGraph GetClone(Action<NodeDataBase> onInstantiate,bool resetGuid)
{
var clone = new NodesGraph();
foreach (var node in datas)
{
var ins = VEUtility.InstantiateNodeData(node);
if (ins is IEnterNode) clone.heads.Add(ins);
onInstantiate?.Invoke(ins);
clone.datas.Add(ins);
}
clone.pairs = new List<PortPair>(pairs);
if(resetGuid) clone.ResetGuid();
return clone;
}
}
文章版权归作者所有









- 最新
- 最热
只看作者