🔗 Unity 节点编辑器完全指南:从零实现可视化编程系统

💡 可视化编程的威力

  • 想实现类似蓝图、Shader Graph 的节点式编辑器?
  • GraphView 新 API 怎么使用,有哪些坑?
  • 如何实现节点拖拽、连线、端口等核心功能?
  • 从零开始做一个完整的节点编辑器需要多久?

这篇文章! 将带你从零开始,使用 Unity GraphView 实现一个完整的节点编辑器!

一、节点编辑器概述

节点编辑器是一种通过连接节点来创建逻辑的可视化编程工具。

1.1 节点编辑器的应用

应用 描述 示例
游戏逻辑 可视化编程 Unreal Blueprint, Unity Visual Scripting
着色器 图形化着色器编辑 Shader Graph, Amplify Shader
动画 动画状态机 Animator Controller
AI 行为 行为树编辑 Behavior Designer
对话系统 对话流程编辑 yarn, Articy

1.2 基本构成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌────────────────────────────────────────────────────────────┐
│ 节点编辑器界面 │
├────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ 连线 ┌──────────────┐ │
│ │ 节点 A │ ═══════════>│ 节点 B │ │
│ │ ┌────────┐ │ │ ┌────────┐ │ │
│ │ │ 输入端口 │ │ │ │ 输入端口 │ │ │
│ │ └────────┘ │ │ └────────┘ │ │
│ │ ┌────────┐ │ │ ┌────────┐ │ │
│ │ │输出端口 │ │ │ │输出端口 │ │ │
│ │ └────────┘ │ │ └────────┘ │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ 节点 = 数据处理单元 │
│ 端口 = 数据输入/输出点 │
│ 连线 = 数据流向 │
└────────────────────────────────────────────────────────────┘

二、使用 GraphView(Unity 2019+)

从 Unity 2019 开始,Unity 引入了 GraphView 框架,专门用于创建节点编辑器。

2.1 基本结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using UnityEngine;
using UnityEditor;
using UnityEditor.Experimental.GraphView;

public class SimpleNodeEditor : EditorWindow
{
private SimpleGraphView graphView;

[MenuItem("Tools/Node Editor")]
public static void ShowWindow()
{
var window = GetWindow<SimpleNodeEditor>();
window.titleContent = new GUIContent("节点编辑器");
window.Show();
}

void OnEnable()
{
// 创建 GraphView
graphView = new SimpleGraphView()
{
style = { flexGrow = 1 }
};

// 添加到窗口
rootVisualElement.Add(graphView);
}
}

2.2 创建自定义 GraphView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
using UnityEditor.Experimental.GraphView;

public class SimpleGraphView : GraphView
{
public SimpleGraphView()
{
// 设置缩放和拖拽
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);

// 添加背景网格
var grid = new GridBackground();
Insert(0, grid);
grid.StretchToParentSize();

// 添加样式
AddStyles();
}

private void AddStyles()
{
// 加载 USS 样式文件
var style = StyleSheet.Load("SimpleNodeEditor.uss");
styleSheets.Add(style);
}

// 获取可兼容的端口
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
var compatiblePorts = new List<Port>();

// 遍历所有端口
ports.ForEach((port) =>
{
// 不能连接到自己
if (startPort == port) return;

// 不能连接到同一节点的其他端口
if (startPort.node == port.node) return;

// 输入只能连输出,输出只能连输入
if (startPort.direction == port.direction) return;

// 类型必须匹配
if (startPort.portType != port.portType) return;

compatiblePorts.Add(port);
});

return compatiblePorts;
}
}

三、创建节点

3.1 节点基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using UnityEditor.Experimental.GraphView;

public class BaseNode : Node
{
public string GUID { get; protected set; }
public string NodeName { get; protected set; }

public BaseNode()
{
GUID = Guid.NewGuid().ToString();
}

// 创建输入端口
protected Port CreateInputPort(string portName, System.Type type)
{
var port = InstantiatePort(Orientation.Horizontal, Direction.Input, Capacity.Multi, type);
port.portName = portName;
inputContainer.Add(port);
return port;
}

// 创建输出端口
protected Port CreateOutputPort(string portName, System.Type type)
{
var port = InstantiatePort(Orientation.Horizontal, Direction.Output, Capacity.Multi, type);
port.portName = portName;
outputContainer.Add(port);
return port;
}
}

3.2 具体节点实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// 数学运算节点
public class MathNode : BaseNode
{
private Port inputPortA;
private Port inputPortB;
private Port outputPort;

public MathNode()
{
NodeName = "Math (Add)";
title = NodeName;

// 创建输入端口
inputPortA = CreateInputPort("A", typeof(float));
inputPortB = CreateInputPort("B", typeof(float));

// 创建输出端口
outputPort = CreateOutputPort("Result", typeof(float));

// 添加选择框
var operationEnum = new System.EnumField(MathOperation.Add);
operationEnum.SetValueWithoutNotify(MathOperation.Add);
mainContainer.Add(operationEnum);

RefreshExpandedState();
RefreshPorts();
}

public enum MathOperation
{
Add,
Subtract,
Multiply,
Divide
}
}

// 数值节点
public class NumberNode : BaseNode
{
private Port outputPort;
private TextField valueField;

public float Value { get; set; } = 0f;

public NumberNode()
{
NodeName = "Number";
title = NodeName;

// 创建输出端口
outputPort = CreateOutputPort("Value", typeof(float));

// 创建数值输入框
valueField = new TextField("Value")
{
value = Value.ToString()
};
valueField.RegisterValueChangedCallback(evt =>
{
if (float.TryParse(evt.newValue, out float newValue))
{
Value = newValue;
}
});

mainContainer.Add(valueField);

RefreshExpandedState();
RefreshPorts();
}
}

// 输出节点
public class OutputNode : BaseNode
{
private Port inputPort;
private TextField resultField;

public OutputNode()
{
NodeName = "Output";
title = NodeName;

// 创建输入端口
inputPort = CreateInputPort("Input", typeof(float));

// 创建结果显示框
resultField = new TextField("Result")
{
isReadOnly = true,
value = "0"
};
mainContainer.Add(resultField);

RefreshExpandedState();
RefreshPorts();
}
}

3.3 节点创建器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using UnityEditor.Experimental.GraphView;

public class NodeCreationProvider : ScriptableObject, ISearchWindowProvider
{
private GraphView graphView;
private EditorWindow window;

public void Initialize(GraphView graphView, EditorWindow window)
{
this.graphView = graphView;
this.window = window;
}

public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
{
var tree = new List<SearchTreeEntry>
{
new SearchTreeGroupEntry(new GUIContent("创建节点")),
new SearchTreeEntry(new GUIContent("数值节点"))
{
level = 1,
userData = new NumberNode()
},
new SearchTreeEntry(new GUIContent("数学运算"))
{
level = 1,
userData = new MathNode()
},
new SearchTreeEntry(new GUIContent("输出节点"))
{
level = 1,
userData = new OutputNode()
}
};
return tree;
}

public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context)
{
if (entry.userData is BaseNode node)
{
// 获取鼠标位置
var mousePos = window.rootVisualElement.ChangeCoordinatesTo(
window.rootVisualElement.parent,
context.screenMousePosition - window.position.position
);

// 设置节点位置
node.SetPosition(new Rect(mousePos, Vector2.zero));

// 添加到 GraphView
graphView.AddElement(node);
return true;
}
return false;
}
}

四、完整 GraphView 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
using UnityEngine;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using System.Linq;

public class SimpleGraphView : GraphView
{
private NodeCreationProvider nodeCreationProvider;

public SimpleGraphView(EditorWindow window)
{
// 设置缩放
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);

// 添加背景网格
var grid = new GridBackground();
Insert(0, grid);
grid.StretchToParentSize();

// 添加节点创建提供者
nodeCreationProvider = ScriptableObject.CreateInstance<NodeCreationProvider>();
nodeCreationProvider.Initialize(this, window);

// 添加搜索窗口
var searchWindow = ScriptableObject.CreateInstance<SearchWindow>();
searchWindow.context = nodeCreationProvider;
searchWindow.OnSelectEntryHandler = nodeCreationProvider.OnSelectEntry;

// 注册右键菜单回调
nodeCreationRequest = context =>
{
searchWindow.Open(
context.screenMousePosition - window.position.position,
null,
this,
nodeCreationProvider
);
};

// 添加样式
AddStyles();

// 添加一些初始节点
AddInitialNodes();
}

private void AddStyles()
{
var style = StyleSheet.Load("SimpleNodeEditor.uss");
if (style != null)
{
styleSheets.Add(style);
}
}

private void AddInitialNodes()
{
// 创建一个数值节点
var numberNode = new NumberNode
{
Value = 10f
};
numberNode.SetPosition(new Rect(100, 100, 100, 100));
AddElement(numberNode);

// 创建一个输出节点
var outputNode = new OutputNode();
outputNode.SetPosition(new Rect(400, 100, 100, 100));
AddElement(outputNode);
}

public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
var compatiblePorts = new List<Port>();

ports.ForEach(port =>
{
if (startPort == port) return;
if (startPort.node == port.node) return;
if (startPort.direction == port.direction) return;

compatiblePorts.Add(port);
});

return compatiblePorts;
}
}

五、节点数据序列化

5.1 节点数据类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System;
using UnityEngine;

[Serializable]
public class NodeData
{
public string guid;
public string typeName;
public Vector2 position;
public string jsonData;
}

[Serializable]
public class EdgeData
{
public string sourceNodeGuid;
public string sourcePortName;
public string targetNodeGuid;
public string targetPortName;
}

[Serializable]
public class GraphData
{
public NodeData[] nodes;
public EdgeData[] edges;
}

5.2 序列化/反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
using UnityEngine;
using UnityEditor;
using System.IO;
using UnityEditor.Experimental.GraphView;

public partial class SimpleGraphView : GraphView
{
public void SaveGraph(string path)
{
var graphData = new GraphData();

// 收集节点数据
var nodes = graphElements.ToList().OfType<BaseNode>().ToList();
graphData.nodes = nodes.Select(node => new NodeData
{
guid = node.GUID,
typeName = node.GetType().Name,
position = node.GetPosition().position,
jsonData = JsonUtility.ToJson(node)
}).ToArray();

// 收集连线数据
var edges = graphElements.ToList().OfType<Edge>().ToList();
graphData.edges = edges.Select(edge => new EdgeData
{
sourceNodeGuid = (edge.output.node as BaseNode)?.GUID,
sourcePortName = edge.output.portName,
targetNodeGuid = (edge.input.node as BaseNode)?.GUID,
targetPortName = edge.input.portName
}).ToArray();

// 写入文件
var json = JsonUtility.ToJson(graphData, true);
File.WriteAllText(path, json);

Debug.Log($"Graph saved to: {path}");
}

public void LoadGraph(string path)
{
if (!File.Exists(path))
{
Debug.LogWarning($"Graph file not found: {path}");
return;
}

// 读取文件
var json = File.ReadAllText(path);
var graphData = JsonUtility.FromJson<GraphData>(json);

// 清空当前图
DeleteElements(graphElements.ToList());

// 重建节点
foreach (var nodeData in graphData.nodes)
{
var node = CreateNodeFromType(nodeData.typeName);
if (node != null)
{
node.GUID = nodeData.guid;
node.SetPosition(new Rect(nodeData.position, Vector2.zero));
JsonUtility.FromJsonOverwrite(nodeData.jsonData, node);
AddElement(node);
}
}

// 重建连线
foreach (var edgeData in graphData.edges)
{
var sourceNode = graphElements.ToList().OfType<BaseNode>()
.FirstOrDefault(n => n.GUID == edgeData.sourceNodeGuid);
var targetNode = graphElements.ToList().OfType<BaseNode>()
.FirstOrDefault(n => n.GUID == edgeData.targetNodeGuid);

if (sourceNode != null && targetNode != null)
{
var sourcePort = sourceNode.outputContainer.Children()
.OfType<Port>().FirstOrDefault(p => p.portName == edgeData.sourcePortName);
var targetPort = targetNode.inputContainer.Children()
.OfType<Port>().FirstOrDefault(p => p.portName == edgeData.targetPortName);

if (sourcePort != null && targetPort != null)
{
var edge = sourcePort.ConnectTo(targetPort);
AddElement(edge);
}
}
}

Debug.Log($"Graph loaded from: {path}");
}

private BaseNode CreateNodeFromType(string typeName)
{
switch (typeName)
{
case nameof(NumberNode):
return new NumberNode();
case nameof(MathNode):
return new MathNode();
case nameof(OutputNode):
return new OutputNode();
default:
return null;
}
}
}

六、USS 样式

创建 SimpleNodeEditor.uss 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/* SimpleNodeEditor.uss */

/* 节点样式 */
.node {
border-radius: 5px;
border-width: 2px;
border-color: #555;
background-color: #333;
}

.node:hover {
border-color: #888;
}

.node.selected {
border-color: #4a90d9;
background-color: #3a3a3a;
}

/* 端口样式 */
.port {
border-radius: 5px;
border-width: 2px;
border-color: #666;
background-color: #444;
width: 12px;
height: 12px;
}

.port:hover {
background-color: #666;
}

/* 输入端口 */
.port.input {
border-color: #66d9ef;
}

/* 输出端口 */
.port.output {
border-color: #f39c12;
}

/* 连线样式 */
.edge {
stroke-color: #999;
stroke-width: 2px;
}

.edge:hover {
stroke-color: #4a90d9;
stroke-width: 3px;
}

/* 文本样式 */
TextField {
border-color: #555;
background-color: #222;
color: #fff;
font-size: 12px;
border-radius: 3px;
padding: 3px;
}

EnumField {
border-color: #555;
background-color: #222;
color: #fff;
}

七、完整窗口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
using UnityEngine;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using System.IO;

public class SimpleNodeEditor : EditorWindow
{
private SimpleGraphView graphView;
private Toolbar toolbar;

[MenuItem("Tools/Node Editor")]
public static void ShowWindow()
{
var window = GetWindow<SimpleNodeEditor>();
window.titleContent = new GUIContent("节点编辑器");
window.Show();
}

void OnEnable()
{
// 创建工具栏
CreateToolbar();

// 创建 GraphView
graphView = new SimpleGraphView(this);
graphView.StretchToParentSize();
rootVisualElement.Add(graphView);
}

private void CreateToolbar()
{
toolbar = new Toolbar();

// 保存按钮
var saveButton = new Button(() => SaveGraph())
{
text = "保存"
};
toolbar.Add(saveButton);

// 加载按钮
var loadButton = new Button(() => LoadGraph())
{
text = "加载"
};
toolbar.Add(loadButton);

// 清空按钮
var clearButton = new Button(() => graphView.DeleteElements(graphView.graphElements.ToList()))
{
text = "清空"
};
toolbar.Add(clearButton);

rootVisualElement.Add(toolbar);
}

private void SaveGraph()
{
var path = EditorUtility.SaveFilePanelInProject(
"保存节点图",
"MyGraph",
"json",
"请选择保存位置"
);

if (!string.IsNullOrEmpty(path))
{
graphView.SaveGraph(path);
}
}

private void LoadGraph()
{
var path = EditorUtility.OpenFilePanel(
"加载节点图",
Application.dataPath,
"json"
);

if (!string.IsNullOrEmpty(path))
{
// 转换为相对路径
if (path.StartsWith(Application.dataPath))
{
path = "Assets" + path.Substring(Application.dataPath.Length);
}
graphView.LoadGraph(path);
}
}
}

八、使用效果

8.1 创建节点

  1. 打开 Tools > Node Editor
  2. 右键点击空白区域
  3. 从搜索窗口选择要创建的节点

8.2 连接节点

  1. 点击输出端口拖动到输入端口
  2. 自动创建连接线

8.3 界面效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌────────────────────────────────────────────────────────────┐
│ 节点编辑器 [保存][加载] │
├────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ Result ┌──────────┐ │
│ │ Number │──────────>│ Output │ │
│ │ │ │ │ │
│ │ Value: │ │ Result: │ │
│ │ [10] │ │ [10] │ │
│ └─────────┘ └──────────┘ │
│ │
│ ┌─────────┐ Result ┌──────────┐ │
│ │ Math │──────────>│ Output │ │
│ │ │ │ │ │
│ │ A [○] │ │ Input: │ │
│ │ │ │ [0] │ │
│ │ B [○] │ └──────────┘ │
│ │ Add ▼ │ │
│ └─────────┘ │
│ │
│ 右键打开菜单创建节点 │
│ 拖动端口连接节点 │
└────────────────────────────────────────────────────────────┘

九、进阶功能

9.1 节点分组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 创建分组
public class NodeGroup : GraphElement
{
private string title = "Group";

public NodeGroup()
{
var titleField = new Label(title);
titleField.style.backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.2f, 0.8f));
titleField.style.paddingTop = 5;
titleField.style.paddingLeft = 5;
titleField.style.paddingBottom = 5;
Add(titleField);

capabilities |= Capabilities.Selectable | Capabilities.Movable | Capabilities.Deletable;
}
}

9.2 节点搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class NodeSearchWindow : ScriptableObject, ISearchWindowProvider
{
public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
{
var tree = new List<SearchTreeEntry>
{
new SearchTreeGroupEntry(new GUIContent("节点创建"), 0),

// 数学分类
new SearchTreeGroupEntry(new GUIContent("数学运算"), 1),
new SearchTreeEntry(new GUIContent("加法")) { level = 2, userData = typeof(AddNode) },
new SearchTreeEntry(new GUIContent("减法")) { level = 2, userData = typeof(SubtractNode) },
new SearchTreeEntry(new GUIContent("乘法")) { level = 2, userData = typeof(MultiplyNode) },
new SearchTreeEntry(new GUIContent("除法")) { level = 2, userData = typeof(DivideNode) },

// 逻辑分类
new SearchTreeGroupEntry(new GUIContent("逻辑运算"), 1),
new SearchTreeEntry(new GUIContent("与")) { level = 2, userData = typeof(AndNode) },
new SearchTreeEntry(new GUIContent("或")) { level = 2, userData = typeof(OrNode) },
new SearchTreeEntry(new GUIContent("非")) { level = 2, userData = typeof(NotNode) },

// 其他...
};
return tree;
}

public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context)
{
// 创建选中节点
return true;
}
}

9.3 实时计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class GraphEvaluator
{
private Dictionary<string, object> nodeValues = new Dictionary<string, object>();

public object EvaluateNode(BaseNode node)
{
// 如果已计算过,返回缓存值
if (nodeValues.ContainsKey(node.GUID))
{
return nodeValues[node.GUID];
}

object result = null;

if (node is NumberNode numberNode)
{
result = numberNode.Value;
}
else if (node is MathNode mathNode)
{
var inputA = GetInputValue(mathNode, 0);
var inputB = GetInputValue(mathNode, 1);
result = CalculateMath(inputA, inputB, mathNode.operation);
}

// 缓存结果
nodeValues[node.GUID] = result;
return result;
}

private float GetInputValue(BaseNode node, int portIndex)
{
var port = node.inputContainer.Children().ElementAt(portIndex) as Port;
if (port?.connections?.Count() > 0)
{
var connectedPort = port.connections.First().output;
var connectedNode = connectedPort.node as BaseNode;
return (float)EvaluateNode(connectedNode);
}
return 0f;
}

private float CalculateMath(float a, float b, MathOperation op)
{
switch (op)
{
case MathOperation.Add: return a + b;
case MathOperation.Subtract: return a - b;
case MathOperation.Multiply: return a * b;
case MathOperation.Divide: return a / (b != 0 ? b : 1);
default: return 0f;
}
}
}

十、总结

本文介绍了创建简单节点编辑器的核心要点:

主题 要点
GraphView Unity 2019+ 的节点编辑框架
Node 节点基类,包含端口和内容
Port 输入/输出端口,用于连接节点
Edge 连接端口的数据流
SearchWindow 右键菜单搜索创建节点
序列化 保存/加载节点图数据
USS 样式 自定义节点编辑器外观
计算系统 实时计算节点结果

💡 开发建议

  • 使用 GraphView 框架快速搭建节点编辑器
  • 节点数据使用 Serializable 类便于序列化
  • 使用 USS 样式美化界面
  • 考虑添加撤销/重做功能
  • 大型项目可参考 XNode 的设计思路

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1487842110@qq.com