在之前的引擎工具总结中提到了可以用XNode来写可视化,正好荷兰冬假期间就之前写的简易可视化对话编辑器来介绍下XNode以及相关使用。

关于XNode

  XNode是一个Unity可视化编辑器制作插件,同类的还有NodeGraphProcessor。个人因比较喜欢XNode的界面选择了(自称)轻量,用户友好的XNode。这里放下图也方便各位对比下。

XNode

NodeGraphProcessor

使用XNode

  XNode中分为Graph(整个可视化的图),Node(自定义的节点)和Port(节点间连线的端口)。为创建自定义的可视化编辑器,需要Graph脚本(创建对应的图),Node脚本(定义节点的内容),NodeEditor脚本(非必须,定义节点中的元素在图中如何绘制)。可以完全接入Odin并交由其控制,也可以使用Unity进行序列化并调用GUI进行绘制,个人选择的是后者(没钱买Odin.jpg)。另外虽然官方给了相应的演示工程,但那些加减乘除太简单了根本没用到定制,所以后续我会结合我代码内容进行简单讲解(需要细节而基础的可以移步[官方文档](https://github.com/Siccity/xNode/wiki/Getting Started))。

节点

  先从节点开始讲起吧,这是XNode最关键且必不可少的部分。但这部分XNode封装的比较好,继承Node后使用其给定的属性就可。大概就是类前可加[NodeWidth]限定宽度,类内元素加[Input], [Output]以决定是输入还是输出端。

1
2
3
4
5
6
[Serializable] [NodeWidth(350)]
public class DialogueNode : Node
{
[Input] public Nothing before;
[Output(connectionType = ConnectionType.Multiple)] public Nothing after;
}

编辑器

  然后是编辑器,虽然非必须但如果你想整点什么花活就不得不修改这个。对于我这个对话编辑器来说就是单个对话项的输入输出端口数不定,这就需要继承NodeEditor去修改编辑器。这里截取了下我的部分代码,主要集中在对ReorderableList的drawElementCallback函数的魔改。另外值得一提的是这里比较底层Unity的EditorGUILayout是无效的,老老实实用EditorGUI自己搓对齐或者拿Odin来点科技与狠活吧。

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
[CustomNodeEditor(typeof(DialogueNode))]
public class DialogueNodeEditor : NodeEditor
{
DialogueNode node; DialogueGraph dialogGraph;
NodePort before, after;

public override void OnCreate()
{
base.OnCreate();
node = serializedObject.targetObject as DialogueNode;
}

public override void OnBodyGUI()
{
NodeEditorGUILayout.DynamicPortList("dialogueList", typeof(DialogueInfo), serializedObject,
IO.Output, ConnectionType.Override, TypeConstraint.Inherited, InitList);
}

void InitList(ReorderableList list)
{
int reorderableListIndex = -1;
SerializedProperty arrayData = serializedObject.FindProperty("dialogueList");
if (dialogGraph == null) dialogGraph = window.graph as DialogueGraph;

list.drawElementCallback = (Rect rect, int index, bool isActive, bool isFocused) =>
{
SerializedProperty itemData = arrayData.GetArrayElementAtIndex(index), sprite = itemData.FindPropertyRelative("sprite"),
person = itemData.FindPropertyRelative("person"), type = itemData.FindPropertyRelative("type"),
context = itemData.FindPropertyRelative("context");

float padding = 5f, labelWidth = 50f, enumWidth = 60f, imageX = rect.x + padding;
float imageWidth = 50f, y = rect.y + padding, fieldHeight = EditorGUIUtility.singleLineHeight;

Rect imageRect = new Rect(imageX, y, imageWidth, imageWidth);
sprite.objectReferenceValue = EditorGUI.ObjectField(imageRect, sprite.objectReferenceValue, typeof(Sprite), false);

float contentStartX = imageX + imageWidth + padding;

Rect personLabelRect = new Rect(contentStartX, y, labelWidth, fieldHeight);
EditorGUI.LabelField(personLabelRect, "Person");

Rect personFieldRect = new Rect(contentStartX + labelWidth, y, enumWidth, fieldHeight);
person.intValue = EditorGUI.Popup(personFieldRect, person.intValue, dialogGraph.names.ToArray());

Rect typeLabelRect = new Rect(contentStartX + labelWidth + enumWidth + padding, y, labelWidth, fieldHeight);
EditorGUI.LabelField(typeLabelRect, "Type");

Rect typeFieldRect = new Rect(contentStartX + labelWidth + enumWidth + padding + labelWidth, y, enumWidth, fieldHeight);
type.enumValueIndex = (int)(PortType)EditorGUI.EnumPopup(typeFieldRect, (PortType)type.enumValueIndex);

y += fieldHeight + padding;
Rect contextLabelRect = new Rect(contentStartX, y, labelWidth, fieldHeight);
EditorGUI.LabelField(contextLabelRect, "Context");

Rect contextFieldRect = new Rect(contentStartX + labelWidth, y, rect.width - labelWidth - 2 * padding - imageWidth, 1.5f * fieldHeight);
context.stringValue = EditorGUI.TextArea(contextFieldRect, context.stringValue, EditorStyles.wordWrappedLabel);

NodePort port = node.GetPort("inList " + index);
if (port != null && (type.enumValueIndex & (int)DialogueNode.PortType.Input) != 0)
{
Vector2 portPosition = rect.position + new Vector2(-35, EditorGUIUtility.singleLineHeight * 1.2f);
NodeEditorGUILayout.PortField(portPosition, port);
}
port = node.GetPort("dialogueList " + index);

if (port != null && (type.enumValueIndex & (int)DialogueNode.PortType.Output) != 0)
{
Vector2 portPosition = rect.position + new Vector2(rect.width + 6, EditorGUIUtility.singleLineHeight * 1.2f);
NodeEditorGUILayout.PortField(portPosition, port);
}

serializedObject.ApplyModifiedProperties();
serializedObject.Update();
};
}
}

  最后图这边其实也没什么说的,继承NodeGraph后CreateAssetMenu给下名称就行。但对于一个编辑器而言,得在这定义如何读取你图中那些节点以及如何传递信息,这点就交由各位去头疼了。

1
2
3
4
5
6
7
[Serializable, CreateAssetMenu(fileName = "New Dialogue Graph", menuName = "Dialogue Graph")]
public class DialogueGraph : NodeGraph
{
void Init() {}
void GetInfo() {}
void MoveOn() {}
}

  上张最终完成的对话节点图,内容选取的是《胆怯色上发光的画布》中的一段。算是挺有感触的。前进一步亦或后退一步,你又会作何选择呢?

胆怯色上发光的画布

写在最后

  XNode的简单说明就到此结束了,编辑器工具看似简单,但真做起来也是极为费事,毕竟后续我还引入了对变量和函数的反射以实现检索和事件节点并封装DialogueSystem靠Next()读取图中信息。完成后也学XNode上架Unity Assets的同时将工具和源码就开源到Github,希望对后来者有些许帮助。

对话系统的四种节点