对话系统特点
- 使用节点编辑器编辑对话,便于策划等非程序岗参与项目开发
- 拓展性强,可自定义节点,方便根据项目需求进行拓展
- 使用逻辑简单、直观,无需进行大量配置
- 对话数据持久化储存,且带增删管理
- 节点可进行逻辑控制
系统实现
首先,我们设计一下对话系统进行的结构分层,在该对话系统中,我们将其分为节点编辑器、对话数据,对话逻辑处理系统三个部分。我们可以用下图来表示:
对话数据采用 Scriptableobject 来实现编辑模式下的数据可变性和运行模式下的数据可持久性,节点编辑器于编辑器下运行,为对话数据文件唯一编辑方式。
编辑器部分
对于该节点式编辑器的制作,我们这里使用的是 Unity 自带的 GraphView API 来进行开发,GraphView 的相关中文资料较少,在开发的过程中也是踩到了不少坑。
为了创建一个编辑器窗口,我们打开资源菜单,选择创建,选择UI工具箱(UI UIToolkit),选择编辑器窗口,会出现以下画面:
我们可以将其命名为DialogGraphWindow,点击创建,Unity会自动为我们创建文件。
创建完成后,首先是编辑器的主体部分,双击创建好的DialogGraphWindow.uxml文件,Unity会自动打开 UIToolkit 中的 UIBuilder,在该窗口中,我们可以自定义我们的窗口布局,下面是本对话系统的UI布局:
布局结构很简单,值得注意的是,我们需要在面板的Hierarchy窗口中点击.uxml文件,然后在Inspector窗口中勾选Editor Extension Authoring选项,这样我们才可以在编辑模式下使用编辑器UI组件,如下图:
其中的DialogGraphView组件,并不是在Stander窗口里创建。我们没有在 Standard中看到这一 UI 组件,这是因为 GraphView 组件是需要我们自己手动创建的,新建一个脚本,让他继承 GraphView,并进行该自定义 UI 组件的配置,代码如下:
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace DialogueSystem
{
public class DialogGraphView : GraphView
{
public new class UxmlFactory : UxmlFactory<DialogGraphView, GraphView.UxmlTraits>
{
}
public DialogGraphView()
{
//增加格子背景
Insert(0, new GridBackground());
//增加内容缩放,拖动,拖拽,框选控制器
this.AddManipulator(new ContentZoomer());
this.AddManipulator(new ContentDragger());
this.AddManipulator(new SelectionDragger());
//框选 bug
//大坑!控制器之间存在优先级
//这就是为什么框选控制器放在选择拖放节点控制器之前会导致节点无法移动
//因为框选的优先级更高
this.AddManipulator(new RectangleSelector());
var styleSheet =
AssetDatabase.LoadAssetAtPath<StyleSheet>(
//此处填你项目对应 uss 文件路径
"Assets/DialogueSystem/NodeEditor/EditorWindow/DialogueTreeView.uss");
styleSheets.Add(styleSheet);
//初始化 treedata 布局
if (treeData != null)
{
contentViewContainer.transform.position = treeData.GraphViewData.Position;
contentViewContainer.transform.scale = treeData.GraphViewData.Scale;
}
}
}
#endif
打开DialogGraphWindow.cs脚本,修改脚本如下:
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
namespace LFramework.AI.Kit.DialogueSystem
{
public class DialogGraphWindow : EditorWindow
{
[MenuItem("Window/UI Toolkit/DialogueView")]
public static void ShowExample()
{
DialogueView wnd = GetWindow<DialogueView>();
wnd.titleContent = new GUIContent("DialogueView");
}
private DialogGraphView _graphView = null;
public static void CloseEditorWindow()
{
DialogGraphWindow wnd = GetWindow<DialogGraphWindow>();
wnd.Close();
}
public void CreateGUI()
{
var visualTree =
AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
"Assets/DialogueSystem/NodeEditor/EditorWindow/DialogGraphWindow.uxml");
visualTree.CloneTree(rootVisualElement);
_graphView = rootVisualElement.Q<DialogGraphView>("DialogGraphView");
var saveButton = rootVisualElement.Q<ToolbarButton>("SaveButton");
saveButton.clicked += OnSaveButtonClicked;
}
//保存资源文件
private void OnSaveButtonClicked()
{
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
private void OnDestroy()
{
AssetDatabase.SaveAssets();
}
}
}
#endif
现在我们回到 Untiy,重新编译后,点击 project 按钮,就可以看到我们刚刚创建好的组件。拖拽组件到窗口中,或许屏幕上并没有变化。选中 GraphView 组件,修改其 grow 值为 1,现在我们应该就能看到我们的 GraphView 了。完成之后,我们来开始创建我们的对话节点。
对话节点
对于每一个对话节点,我们都可以将其分为 Data 层和 View 层。在View 层中,我们只是对我们的节点数据进行可视化处理,在 View 层中,我们也能对对话节点进行编辑,编辑的目标就是我们的 Data 层。Data 层才是我们对话数据的关键部分,保存节点包含的各种信息。
在该对话系统中,我们采用了 Scriptableobject 来进行数据的存储,使用 Scriptableobject 可以实现在编辑器中的数据持久化,而且还能达成数据的可复用性。
所以首先,我们需要一个用来管理所有对话节点数据的数据结构类,创建一个 DialogTree 类,代码如下:
using System;
using System.Collections.Generic;
using LFramework.AI.Kit.DialogueSystem;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace DialogueSystem
{
[CreateAssetMenu(menuName = "Create new DialogTreeData",fileName = "DialogTreeData")]
public class DialogTree : ScriptableObject
{
public DialogNodeDataBase StartNodeData = null;
public List<DialogNodeDataBase> ChildNodeDataList = new List<DialogNodeDataBase>();
// 用来储存节点视图信息
[Serializable]
public class ViewData
{
public Vector3 Position;
public Vector3 Scale = new Vector3(1, 1, 1);
}
public ViewData GraphViewData = new ViewData();
}
}
在这里,我们定义一个头结点对象和一个子节点列表,每个对话树 Scriptableobject 都记录着他所管理的所有对话节点的信息,所以接下来,我们来定义节点数据的基类 DialogDataBase,代码如下:
using System.Collections.Generic;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
namespace DialogueSystem
{
public abstract class DialogNodeDataBase : ScriptableObject
{
/// <summary>
/// 节点坐标
/// </summary>
[HideInInspector] public Vector2 Position = Vector2.zero;
[HideInInspector] public string Path;
/// <summary>
/// 节点类型
/// </summary>
public abstract NodeType NodeType { get; }
protected DialogNodeDataBase()
{
}
private void OnValidate()
{
#if UNITY_EDITOR
AssetDatabase.SaveAssets();
#endif
}
public List<string> OutputItems = new List<string>();
public List<DialogNodeDataBase> ChildNode = new List<DialogNodeDataBase>();
}
}
在这个对话节点基类中,我们定义了所有节点的通用属性,其中最重要的是两个列表,一个是用于储存节点输出信息的 OutputItems 列表,他可以用于储存对话数据,也可以根据不同的需求,储存节点所需的字符串信息。另一个ChildNode列表用于储存对话节点间的关系,包含了该节点的所有子节点对象,这是这套对话系统的关键。
在读取数据时,根据对话树的头节点,按照节点连线规则一直往下选择下一个对话节点,重复读取直至对话节点进行至 End 节点结束。这样的逻辑有点像是链表。
除此之外,节点中的 NodeType 属性也是对话系统的关键部分,在读取一个节点时,通过检测节点的类型,我们可以使用不同的方式来处理节点,以实现节点的逻辑控制,这部分我们将在后面构建对话系统的数据处理部分详细介绍。
所以,接下来我们来创建两个关键的对话节点,StartNode 跟 EndNode。在创建节点之前,我们先创建一个新的 C#脚本,在脚本中创建一个 NodeType 枚举类,代码如下:
namespace DialogueSystem
{
public enum NodeType
{
Start,
End,
}
}
接下来是我们的两个对话节点,代码如下;
StartNode:
namespace DialogueSystem
{
public class StartNodeData : DialogNodeDataBase
{
public override NodeType NodeType => NodeType.Start;
}
}
EndNode:
namespace DialogueSystem
{
public class EndNodeData : DialogNodeDataBase
{
public override NodeType NodeType => NodeType.End;
}
}
这两个都继承了我们的 DialogDataBase 基类,在这里我们只需要指定他们的类型即可。
创建好节点的 Data 层后,我们来实现对话节点的 View 层,创建一个 NodeViewBase 基类,代码如下:
#if UNITY_EDITOR
using System;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
namespace DialogueSystem
{
public abstract class NodeViewBase : Node
{
public Action<NodeViewBase> OnNodeSelected;
public string GUID;
//对话数据
public DialogNodeDataBase DialogNodeData = null;
public NodeViewBase(DialogNodeDataBase dialogNodeData) : base()
{
GUID = Guid.NewGuid().ToString();
DialogNodeData = dialogNodeData;
//大坑,新版本unity不自动在磁盘上应用资源更新,必须先给目标物体打上Dirty标记
EditorUtility.SetDirty(DialogNodeData);
}
public Port GetPortForNode(NodeViewBase node, Direction portDirection,
Port.Capacity capacity = Port.Capacity.Single)
{
return node.InstantiatePort(Orientation.Horizontal, portDirection, capacity, typeof(bool));
}
public override void OnSelected()
{
base.OnSelected();
OnNodeSelected?.Invoke(this);
}
}
}
#endif
view 层我们主要使用了 GraphView 的 Node 基类,我们的基类继承了 Node,这样可以使我们在 GraphView 中创建出自定义的节点UI,上面的代码我们重写了OnSelected方法,将节点被选中的事件发送出去。
按照我们的设计,开始节点只有一个输出口,且接口只能连接一个子节点,也就是说,StartNode拥有一个单一输出口。而EndNode则可以接受多个输入,但他不能有输出口,因为EndNode节点表示的是对话树的终止处,所以EndNode将有一个支持多输入的输入口,我们来实现这两个节点,代码如下:
StartNode:
#if UNITY_EDITOR
using UnityEditor.Experimental.GraphView;
using UnityEngine;
namespace DialogueSystem
{
public class StartNodeView : NodeViewBase
{
public StartNodeView(DialogNodeDataBase dialogNodeData) : base(dialogNodeData)
{
title = "Start";
Port output = GetPortForNode(this, Direction.Output, Port.Capacity.Single);
output.portName = "output";
output.name = "0";
output.portColor = Color.green;
outputContainer.Add(output);
if (DialogNodeData.ChildNode.Count < 1)
{
DialogNodeData.ChildNode.Add(null);
}
}
}
}
#endif
EndNode:
#if UNITY_EDITOR
using UnityEditor.Experimental.GraphView;
using UnityEngine;
namespace DialogueSystem
{
public class EndNodeView : NodeViewBase
{
public EndNodeView(DialogNodeDataBase dialogNodeData) : base(dialogNodeData)
{
title = "End";
Port input = GetPortForNode(this, Direction.Input, Port.Capacity.Multi);
input.portName = "input";
input.portColor = Color.gray;
inputContainer.Add(input);
}
}
}
#endif
很简单,我们为我们的节点创建了上面所描述类型的Port,并把他们分别添加进InputContainner和OutputContainner中。接下来,我们回到GraphView脚本,我们来拓展右键菜单,让我们可以创建出我们的节点。代码如下:
/// <summary>
/// 菜单点击时鼠标位置
/// </summary>
private Vector2 clickPosition;
/// <summary>
/// 节点点击事件
/// </summary>
public Action<NodeViewBase> OnNodeSelected;
public static DialogTree treeData = null;
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
base.BuildContextualMenu(evt);
Debug.Log(evt.mousePosition);
//将鼠标世界坐标转为视图本地坐标
clickPosition = contentViewContainer.WorldToLocal(evt.mousePosition);
evt.menu.AppendAction("Create StartNode", x =>
{
var dialogNodeData = ScriptableObject.CreateInstance<StartNodeData>();
var nodeView = new StartNodeView(dialogNodeData)
{
//设置点击事件
OnNodeSelected = OnNodeSelected
};
nodeView.SetPosition(new Rect(clickPosition, nodeView.GetPosition().size));
this.AddElement(nodeView);
});
evt.menu.AppendAction("Create EndNode", x =>
{
var dialogNodeData = ScriptableObject.CreateInstance<EndNodeData>();
var nodeView = new EndNodeView(dialogNodeData)
{
//设置点击事件
OnNodeSelected = OnNodeSelected
};
nodeView.SetPosition(new Rect(clickPosition, nodeView.GetPosition().size));
this.AddElement(nodeView);
});
}
如果我们现在回到Unity打开我们节点编辑器,右键创建节点,我们会看到两个节点都成功的创建了出来。可当我们尝试连接节点时,我们会发现所有的节点端口都变灰了,我们无法连接任何节点。这是因为我们还没有配置节点的连接规则,重新回到GraphView脚本,Override GetCompatiblePorts函数。代码如下:
//节点链接规则
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
return ports.ToList().Where(endPort =>
endPort.direction != startPort.direction &&
endPort.node != startPort.node &&
endPort.portType == startPort.portType
).ToList();
}
连接规则很简单,即节点自己不能连接自己,节点接口方向不能相同,且只有相同类型的端口可以互相连接。在我们这个系统中,节点间的连线仅表示数据流向,不涉及数据类型,项目里所有节点接口的类型都被我统一为bool类型。
现在我们回到Unity,打开节点编辑器,创建节点,连接,这次的效果就符合我们的预期了。
现在我们完成的是节点UI的显示,也就是说,现阶段的节点是没有任何实际功能的。我们来继续完善Start跟End节点,使他们与他们对应的Data层对象相关联。
首先是创建节点的部分,我们打开GraphView脚本,增加一个CreateNode方法。代码如下:
//确保目录存在
private void MakeSureTheFolder()
{
//TODO:做成可自行设置的对话资源文件部署
if (!AssetDatabase.IsValidFolder("Assets/DialogueData/NodeData"))
{
AssetDatabase.CreateFolder("Assets", "DialogueData");
AssetDatabase.CreateFolder("Assets/DialogueData", "NodeData");
}
}
/// <summary>
/// 新建节点
/// </summary>
/// <param name="type"></param>
/// <param name="position"></param>
private void CreateNode(NodeType type, Vector2 position = default)
{
if (treeData == null)
{
return;
}
MakeSureTheFolder();
NodeViewBase nodeView = null;
//创建节点的核心,新增的节点需要在这里进行创建方式的添加
switch (type)
{
case NodeType.Start:
{
var dialogNodeData = ScriptableObject.CreateInstance<StartNodeData>();
dialogNodeData.Path = $"Assets/DialogueData/NodeData/StartData[{dialogNodeData.GetInstanceID()}].asset";
EditorUtility.SetDirty(dialogNodeData);
AssetDatabase.CreateAsset(dialogNodeData,
$"Assets/DialogueData/NodeData/StartData[{dialogNodeData.GetInstanceID()}].asset");
nodeView = new StartNodeView(dialogNodeData);
break;
}
case NodeType.End:
{
var dialogNodeData = ScriptableObject.CreateInstance<EndNodeData>();
dialogNodeData.Path = $"Assets/DialogueData/NodeData/EndData[{dialogNodeData.GetInstanceID()}].asset";
EditorUtility.SetDirty(dialogNodeData);
AssetDatabase.CreateAsset(dialogNodeData,
$"Assets/DialogueData/NodeData/EndData[{dialogNodeData.GetInstanceID()}].asset");
nodeView = new EndNodeView(dialogNodeData);
break;
}
default:
{
Debug.LogError("未找到该类型的节点");
break;
}
}
//添加节点被选择事件
nodeView.OnNodeSelected = OnNodeSelected;
nodeView.SetPosition(new Rect(position, nodeView.GetPosition().size));
this.AddElement(nodeView);
}
我们在创建节点UI之前,都会创建一个对应节点的DialogNodeDataBase对象,并将其保存为asset文件。而在View中,在创建出节点UI时,将持有刚创建的DialogNodeDataBase对象。
现在我们创建节点就可以直接使用CreateNode函数了,我们可以修改右键菜单,使用新的创建节点方法,修改GraphView中代码如下:
/// <summary>
/// 右键菜单
/// </summary>
/// <param name="evt"></param>
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
base.BuildContextualMenu(evt);
Debug.Log(evt.mousePosition);
//将鼠标世界坐标转为视图本地坐标
clickPosition = contentViewContainer.WorldToLocal(evt.mousePosition);
if (treeData.StartNodeData == null)
{
evt.menu.AppendAction("Create StartNode", x => { CreateNode(NodeType.Start, clickPosition); });
}
evt.menu.AppendAction("Create EndNode", x => { CreateNode(NodeType.End, clickPosition); });
}
到这里,我们的单个节点才总算是相对完整了,但是,我们还没有处理节点与节点间的数据连接,在该对话系统的设计中,节点与节点间的连接表示的是对话节点数据的读取顺序,Unity在GraphView中提供了一个graphViewChanged事件,该事件可以监听GraphView中的各种变化,包括节点的移动,连线的创建、删除等,我们打开GraphView脚本,增加以下代码:
/// <summary>
/// 节点图变化事件
/// </summary>
/// <param name="graphviewchange"></param>
/// <returns></returns>
private GraphViewChange OnGraphViewChanged(GraphViewChange graphviewchange)
{
if (graphviewchange.elementsToRemove != null)
{
graphviewchange.elementsToRemove.ForEach(elem =>
{
//连线删除
else if (elem is Edge edge)
{
NodeViewBase parentNodeView = edge.output.node as NodeViewBase;
NodeViewBase childNodeView = edge.input.node as NodeViewBase;
if (parentNodeView != null && childNodeView != null)
{
if (int.TryParse(edge.output.name, out int index))
{
parentNodeView.DialogNodeData.ChildNode[index] = null;
}
else
{
Debug.LogError("Node.name(string) to int fail");
}
}
}
});
}
if (graphviewchange.edgesToCreate != null)
{
//创建连线
graphviewchange.edgesToCreate.ForEach(edge =>
{
NodeViewBase parentNodeView = edge.output.node as NodeViewBase;
NodeViewBase childNodeView = edge.input.node as NodeViewBase;
if (parentNodeView != null && childNodeView != null)
{
if (int.TryParse(edge.output.name, out int index))
{
parentNodeView.DialogNodeData.ChildNode[index] = childNodeView.DialogNodeData;
}
else
{
Debug.LogError("Node.name(string) to int fail");
}
}
});
}
return graphviewchange;
}
然后在初始化GraphView的时候往graphViewChanged事件添加我们的OnGraphViewChanged方法,在GraphView脚本中的DialogGraphView()方法添加以下代码即可。代码如下:
//监听graphView变化事件
graphViewChanged += OnGraphViewChanged;
创建连线时,我们获取一下该连线的两端节点,并将输入口的节点对应的DialogNodeDataBase对象添加进输出口节点的DialogNodeDataBase的子对象列表中。当然,在连线删除的时候,我们也应该同步删除父节点子对象列表中的对应子节点。
现在,节点间已经能够在数据层面上进行连接了,但我们还有一件重要的事情没有做。如果将节点编辑器关闭后再打开,我们会发现节点编辑器回到了初始状态,我们的节点数据并没有在节点图中展示。所以接下来我们来实现一下节点图的保存与解析。
节点图的保存与解析
我们在节点编辑器里编辑的节点,最终保存为一个个节点数据文件,还记得我们当时创建的DialogTree类吗?DialogTree就是专门用来管理节点数据的数据结构类,我们修改CreateNode代码,在创建节点的时候将节点对象添加进DialogTree中。打开GraphView脚本,在CreateNode函数末尾加入代码,代码如下:
//对Start节点做个特判
if (nodeView.DialogNodeData.NodeType == NodeType.Start)
{
treeData.StartNodeData = nodeView.DialogNodeData;
}
else
{
treeData.ChildNodeDataList.Add(nodeView.DialogNodeData);
}
在一个DialogTree中,我们只能有一个StartNode,并且StartNode对象是作为一个特别对待的节点储存在DialogTree中的StartNodeData变量中。现在我们可以更改一下编辑器的打开逻辑,不再是通过菜单栏打开节点编辑器,而是改成打开DialogTree对象的asset文间来打开节点编辑器,这样也可以在打开时获取到打开的DialogTree对象,从而对该DialogTree与GraphView进行关联。打开DialogGraphWindow脚本,编辑代码如下:
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
namespace LFramework.AI.Kit.DialogueSystem
{
public class DialogGraphWindow : EditorWindow
{
// 移除原先的打开方式
// [MenuItem("Window/UI Toolkit/DialogueView")]
// public static void ShowExample()
// {
//
// DialogueView wnd = GetWindow<DialogueView>();
// wnd.titleContent = new GUIContent("DialogueView");
// }
private DialogGraphView _graphView = null;
// 打开DialogTree资源时触发
[OnOpenAsset(1)]
public static bool OnOpenAsssets(int id, int line)
{
if (EditorUtility.InstanceIDToObject(id) is DialogTree tree)
{
//打开不同DialogTree文件
if (DialogGraphView.treeData != tree)
{
Debug.Log(DialogGraphView.treeData);
DialogGraphView.treeData = tree;
//判断窗口是否打开
if (HasOpenInstances<DialogGraphWindow>())
{
CloseEditorWindow();
}
//大大大大大坑!新版本unity不自动在磁盘上应用资源更新,必须先给目标物体打上Dirty标记
EditorUtility.SetDirty(tree);
}
DialogGraphWindow wnd = GetWindow<DialogGraphWindow>();
wnd.titleContent = new GUIContent("DialogueView");
return true;
}
return false;
}
public static void CloseEditorWindow()
{
DialogGraphWindow wnd = GetWindow<DialogGraphWindow>();
wnd.Close();
}
public void CreateGUI()
{
var visualTree =
AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
"Assets/DialogueSystem/NodeEditor/EditorWindow/DialogGraphWindow.uxml");
visualTree.CloneTree(rootVisualElement);
_graphView = rootVisualElement.Q<DialogGraphView>("DialogGraphView");
_inspectorView = rootVisualElement.Q<InspectorView>("InspectorView");
var saveButton = rootVisualElement.Q<ToolbarButton>("SaveButton");
saveButton.clicked += OnSaveButtonClicked;
}
//保存资源文件
private void OnSaveButtonClicked()
{
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("Save");
}
private void OnDestroy()
{
//对象销毁之前记得保存一下,保险
AssetDatabase.SaveAssets();
}
}
}
#endif
打开GraphView脚本,添加一个静态变量用于储存当前DialogTree。代码如下:
public static DialogTree treeData = null;
回到Unity,双击DialogTree.asset文件,可以看到,节点编辑器被成功打开。但是,我们还是没有在编辑器里看到任何节点,那是因为我们还没有对DialogTree中的数据进行处理。既然在DialogTree里已经有了我们整个对话树的节点数据,那我们是不是就可以在编辑器打开的时候,读取DialogTree中的节点数据,并按照所储存的数据对节点进行复原。
我们打开GraphView脚本,添加以下代码:
/// <summary>
/// 临时字典,用于初始化节点图的,用完记得把内存释放掉
/// </summary>
private Dictionary<DialogNodeDataBase, NodeViewBase> NodeDirt;
/// <summary>
/// 重置节点图
/// </summary>
public void ResetNodeView()
{
if (treeData != null)
{
//初始化字典
NodeDirt = new Dictionary<DialogNodeDataBase, NodeViewBase>();
var nodeData = treeData.ChildNodeDataList;
//检查StartNode是否存在
if (treeData.StartNodeData == null)
{
CreateNode(NodeType.Start);
}
else
{
RecoveryNode(treeData.StartNodeData);
}
//恢复节点
foreach (var node in nodeData)
{
RecoveryNode(node);
}
//清除字典,后面会讲到为什么
NodeDirt.Clear();
}
}
上面的代码中,我们提供了一个复原节点图的函数,我们在这里遍历DialongTree记录的所有节点,并依次还原所有的节点跟连线。
首先还原节点,在GraphView增加以下代码:
/// <summary>
/// 恢复节点
/// </summary>
/// <param name="DialogNodeData"></param>
private void RecoveryNode(DialogNodeDataBase DialogNodeData)
{
if (DialogNodeData == null)
{
return;
}
NodeViewBase nodeView = null;
//恢复节点的核心部分,新增的节点需要在这里进行恢复方式的添加
switch (DialogNodeData.NodeType)
{
case NodeType.Start:
{
nodeView = new StartNodeView(DialogNodeData);
break;
}
case NodeType.End:
{
nodeView = new EndNodeView(DialogNodeData);
break;
}
default:
{
Debug.LogError("未找到该类型的节点");
break;
}
}
nodeView.OnNodeSelected = OnNodeSelected;
nodeView.SetPosition(new Rect(DialogNodeData.Position, nodeView.GetPosition().size));
this.AddElement(nodeView);
}
在RecoveryNode方法中,我们根据所传的DialogDataBase对象,创建出对应的NodeViewBase。这里跟创建节点的CreateNode方法不同,我们不需要再创建出节点的Data层,我们只需要根据已有的Data,复原出节点的View层就可以了。
在复原节点的时候,我们需要让节点回到他们原本的位置,所以现在我们需要记录节点的位置信息。还记得我们之前用来处理节点图变化的OnGraphViewChanged函数吗?我们直接在那里监听节点的位置变化就行了。修改OnGraphViewChanged函数,在末尾加入以下代码:
//遍历节点,记录节点位置信息
nodes.ForEach(node =>
{
NodeViewBase nodeView = node as NodeViewBase;
if (nodeView != null && nodeView.DialogNodeData != null)
{
nodeView.DialogNodeData.Position = nodeView.GetPosition().position;
}
});
既然记录了节点位置,我们不妨也记录一下节点视图的位置与缩放,在打开编辑器的时候也进行复原,这样我们就能保证用户下一次打开编辑器时节点图与上次关闭保持一致。刚好Unity在GraphView中提供了一个viewTransformChanged事件,跟GraphViewChanged一样,我们在GraphView脚本里添加OnViewTransformChanged函数,代码如下:
/// <summary>
/// graphView的Transform发生变化时触发
/// </summary>
/// <param name="graphView"></param>
private void OnViewTransformChanged(GraphView graphView)
{
if (treeData != null)
{
//保存视图Transform信息
treeData.GraphViewData.Position = contentViewContainer.transform.position;
treeData.GraphViewData.Scale = contentViewContainer.transform.scale;
}
}
在DialogGraphView()中监听事件:
//监听视图Transform变化事件
viewTransformChanged += OnViewTransformChanged;
现在,节点的复原已经完成了,且GraphView的位置跟缩放也将与保存时的状态保存一致。
但距离我们解析复原节点图完成还剩下最后一个关键部分,复原节点端口间的连线。根据这套对话系统的设计,我们对话节点的输入端口可以与多个端口连接,而输出端口只能与一个端口连接。在每一个节点的DialogNodeDataBase中,都有一个ChildNode列表用来记录该节点所连的子节点。因此我们可以先遍历图里的所有节点,再分别遍历节点里面的ChildNode,将每一个子节点的输入端口与父节点对应的输出端口相连就可以了。我们打开GraphView脚本,增加以下代码:
/// <summary>
/// 链接两个点
/// </summary>
/// <param name="_outputPort">outputPort</param>
/// <param name="_inputPort">inputPort</param>
private void AddEdgeByPorts(Port _outputPort, Port _inputPort)
{
//虽然是不可能发生,但还是保守一点
if (_outputPort.node == _inputPort.node)
{
return;
}
Edge tempEdge = new Edge()
{
input = _inputPort,
output = _outputPort
};
tempEdge.input.Connect(tempEdge);
tempEdge.output.Connect(tempEdge);
Add(tempEdge);
}
/// <summary>
/// 恢复节点连线
/// </summary>
private void RecoveryEdge(DialogNodeDataBase DialogNodeData)
{
if (DialogNodeData.ChildNode == null)
{
return;
}
for (int i = 0; i < DialogNodeData.ChildNode.Count; i++)
{
//没连就跳过
if (DialogNodeData.ChildNode[i] == null)
{
continue;
}
Port _output = NodeDirt[DialogNodeData].outputContainer[i].Q<Port>();
Port _input = NodeDirt[DialogNodeData.ChildNode[i]].inputContainer[0].Q<Port>();
AddEdgeByPorts(_output, _input);
}
}
上面代码我们添加了连接两个Port的函数,用的是Unity提供的API,很简单。接下来我们添加了一个能根据DialogNodeDataBase对象,遍历其子节点并复原连线的函数。由于我们的连接操作是在View层的事情,我们需要持有根据DialogNodeDataBase对象所创建的NodeViewBase对象。为了避免耦合,且我们的复原操作仅在DialogTree打开时进行一次,所以这里我们使用了一个临时的字典来缓存对象间互相持有的关系。字典的填充在复原节点的时候进行,我们修改RecoveryNode函数,在函数末尾加入以下代码:
NodeDirt.Add(DialogNodeData, nodeView);
现在,节点图的连线部分也完成了,我们来做最后的收尾工作。我们将在GraphView脚本中创建一个静态变量,用于实现简单的单例模式。代码如下:
public static DialogGraphView Instance;
public DialogGraphView()
{
//以上代码省略,在DialogGraphView末尾添加下面代码即可
//简单的单例模式
Instance = this;
}
修改ResetNodeView方法,增加复原节点连线的逻辑,代码如下:
/// <summary>
/// 重置节点图
/// </summary>
public void ResetNodeView()
{
if (treeData != null)
{
//初始化字典
NodeDirt = new Dictionary<DialogNodeDataBase, NodeViewBase>();
var nodeData = treeData.ChildNodeDataList;
//检查StartNode是否存在
if (treeData.StartNodeData == null)
{
CreateNode(NodeType.Start);
}
else
{
RecoveryNode(treeData.StartNodeData);
}
//恢复节点
foreach (var node in nodeData)
{
RecoveryNode(node);
}
//恢复节点边
RecoveryEdge(treeData.StartNodeData);
foreach (var node in nodeData)
{
RecoveryEdge(node);
}
//清除字典
NodeDirt.Clear();
}
}
现在我们打开DialogGraphWindow脚本,在末尾调用ResetNodeView()方法。代码如下:
public void CreateGUI()
{
var visualTree =
AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
"Assets/DialogueSystem/NodeEditor/EditorWindow/DialogGraphWindow.uxml");
visualTree.CloneTree(rootVisualElement);
_graphView = rootVisualElement.Q<DialogGraphView>("DialogGraphView");
_inspectorView = rootVisualElement.Q<InspectorView>("InspectorView");
var saveButton = rootVisualElement.Q<ToolbarButton>("SaveButton");
saveButton.clicked += OnSaveButtonClicked;
//初始化节点图
DialogGraphView.Instance.ResetNodeView();
}
现在我们打开DialogTree,可以看到,节点图完美复原。到此,我们完成了一个基本的对话系统节点编辑器。但现在我们只有Start跟End两个节点,这显然还不能算是一个对话系统。所以接下来,我们要做的就是拓展更多节点,完善我们的对话系统 。
拓展更多节点
在前面的代码实现中,我们已经实现了一个节点View层与Data层的基类,NodeViewBase与DialogNodeDataBase。那么对于拓展节点,无非就是继承这两个类,并实现不同节点特有的功能而已。
所以现在我们来规划一下要实现的节点,本次我们先拓展两个基础的对话系统节点:
- 顺序对话节点:能够按照从上到下的顺序输出节点内的对话语句
- 随机对话节点:能从节点内对话数据中随机选出一句进行输出。
我们打开NodeType枚举,增加新的节点类型,代码如下:
public enum NodeType
{
Start,
RandomDialogNode,
SequentialDialogNode,
End,
}
首先我们来拓展顺序对话节点:
Data层:
新建一个SequentialDialogNodeData脚本,代码如下:
namespace DialogueSystem
{
public class SequentialDialogNodeData : DialogNodeDataBase
{
public override NodeType NodeType => NodeType.SequentialDialogNode;
}
}
View层:
新建一个SequentialDialogNodeView脚本,代码如下:
#if UNITY_EDITOR
using UnityEditor.Experimental.GraphView;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace DialogueSystem
{
public class SequentialDialogNodeView : NodeViewBase
{
private int nextIndex = 0;
public SequentialDialogNodeView(DialogNodeDataBase dialogNodeData) : base(dialogNodeData)
{
title = "SequentialDialogNode";
Port input = GetPortForNode(this, Direction.Input, Port.Capacity.Multi);
Port output = GetPortForNode(this, Direction.Output, Port.Capacity.Single);
input.portName = "Input";
input.portColor = Color.yellow;
output.portName = "Output";
output.portColor = Color.yellow;
output.name = "0";
inputContainer.Add(input);
outputContainer.Add(output);
//工具条
Toolbar toolbar = new Toolbar();
ToolbarButton addButton = new ToolbarButton(AddTextField)
{
text = "Add"
};
ToolbarButton delButton = new ToolbarButton(DeleteTextField)
{
text = "Del"
};
toolbar.Add(addButton);
toolbar.Add(delButton);
toolbar.style.flexDirection = FlexDirection.RowReverse;
contentContainer.Add(toolbar);
while (nextIndex < DialogNodeData.OutputItems.Count)
{
AddTextField();
}
if (DialogNodeData.ChildNode.Count < 1)
{
DialogNodeData.ChildNode.Add(null);
}
}
public void AddTextField()
{
if (DialogNodeData.OutputItems.Count < nextIndex + 1)
{
DialogNodeData.OutputItems.Add(default);
}
//拿了个没有功能的按键当背景,这个按键并没有什么实质性的功能哈哈
Button background = new Button();
TextField textField = new TextField();
textField.name = nextIndex.ToString();
textField.style.minWidth = 160;
//初始化
textField.SetValueWithoutNotify(DialogNodeData.OutputItems[nextIndex]);
textField.RegisterValueChangedCallback(evt =>
{
if (int.TryParse(textField.name, out int index))
{
DialogNodeData.OutputItems[index] = evt.newValue;
}
else
{
Debug.LogError("textField.name(string) to int fail");
}
});
background.Add(textField);
extensionContainer.Add(background);
RefreshExpandedState();
nextIndex++;
}
public void DeleteTextField()
{
if (nextIndex > 0)
{
nextIndex--;
DialogNodeData.OutputItems.RemoveAt(DialogNodeData.OutputItems.Count - 1);
extensionContainer.Remove(extensionContainer[nextIndex]);
}
}
}
}
#endif
接下来是随机对话节点:
Data层:
新建一个RandomDialogNodeData脚本,代码如下:
namespace DialogueSystem
{
public class RandomDialogNodeData : DialogNodeDataBase
{
public override NodeType NodeType => NodeType.RandomDialogNode;
}
}
View层:
新建一个RandomDialogNodeView脚本,代码如下:
#if UNITY_EDITOR
using UnityEditor.Experimental.GraphView;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace DialogueSystem
{
public class RandomDialogNodeView : NodeViewBase
{
private int nextIndex = 0;
public RandomDialogNodeView(DialogNodeDataBase dialogNodeData) : base(dialogNodeData)
{
title = "RandomDialogNode";
Port input = GetPortForNode(this, Direction.Input, Port.Capacity.Multi);
Port output = GetPortForNode(this, Direction.Output, Port.Capacity.Single);
input.portName = "Input";
input.portColor = Color.magenta;
output.portName = "Output";
output.portColor = Color.magenta;
output.name = "0";
inputContainer.Add(input);
outputContainer.Add(output);
//工具条
Toolbar toolbar = new Toolbar();
ToolbarButton addButton = new ToolbarButton(AddTextField)
{
text = "Add"
};
ToolbarButton delButton = new ToolbarButton(DeleteTextField)
{
text = "Del"
};
toolbar.Add(addButton);
toolbar.Add(delButton);
toolbar.style.flexDirection = FlexDirection.RowReverse;
contentContainer.Add(toolbar);
while (nextIndex < DialogNodeData.OutputItems.Count)
{
AddTextField();
}
//加个判断,不然每开一次创一个
if (DialogNodeData.ChildNode.Count < 1)
{
DialogNodeData.ChildNode.Add(null);
}
}
public void AddTextField()
{
if (DialogNodeData.OutputItems.Count < nextIndex + 1)
{
DialogNodeData.OutputItems.Add(default);
}
//拿了个没有功能的按键当背景,这个按键并没有什么实质性的功能哈哈
Button background = new Button();
TextField textField = new TextField();
textField.name = nextIndex.ToString();
textField.style.minWidth = 160;
//初始化
textField.SetValueWithoutNotify(DialogNodeData.OutputItems[nextIndex]);
textField.RegisterValueChangedCallback(evt =>
{
if (int.TryParse(textField.name, out int index))
{
DialogNodeData.OutputItems[index] = evt.newValue;
}
else
{
Debug.LogError("textField.name(string) to int fail");
}
});
background.Add(textField);
extensionContainer.Add(background);
RefreshExpandedState();
nextIndex++;
}
public void DeleteTextField()
{
if (nextIndex > 0)
{
nextIndex--;
DialogNodeData.OutputItems.RemoveAt(DialogNodeData.OutputItems.Count - 1);
extensionContainer.Remove(extensionContainer[nextIndex]);
}
}
}
}
#endif
完成之后,我们打开GraphView脚本,编辑CreateNode、RecoveryNode方法,使节点能够在GraphView中创建,代码如下:
/// <summary>
/// 新建节点
/// </summary>
/// <param name="type"></param>
/// <param name="position"></param>
private void CreateNode(NodeType type, Vector2 position = default)
{
if (treeData == null)
{
return;
}
MakeSureTheFolder();
NodeViewBase nodeView = null;
//创建节点的核心,新增的节点需要在这里进行创建方式的添加
switch (type)
{
case NodeType.Start:
{
var dialogNodeData = ScriptableObject.CreateInstance<StartNodeData>();
dialogNodeData.Path =
$"Assets/DialogueData/NodeData/StartData[{dialogNodeData.GetInstanceID()}].asset";
EditorUtility.SetDirty(dialogNodeData);
AssetDatabase.CreateAsset(dialogNodeData,
$"Assets/DialogueData/NodeData/StartData[{dialogNodeData.GetInstanceID()}].asset");
nodeView = new StartNodeView(dialogNodeData);
break;
}
case NodeType.RandomDialogNode:
{
var dialogNodeData = ScriptableObject.CreateInstance<RandomDialogNodeData>();
dialogNodeData.Path =
$"Assets/DialogueData/NodeData/RandomDialogData[{dialogNodeData.GetInstanceID()}].asset";
EditorUtility.SetDirty(dialogNodeData);
AssetDatabase.CreateAsset(dialogNodeData,
$"Assets/DialogueData/NodeData/RandomDialogData[{dialogNodeData.GetInstanceID()}].asset");
nodeView = new RandomDialogNodeView(dialogNodeData);
break;
}
case NodeType.SequentialDialogNode:
{
var dialogNodeData = ScriptableObject.CreateInstance<SequentialDialogNodeData>();
dialogNodeData.Path =
$"Assets/DialogueData/NodeData/SequentialDialogData[{dialogNodeData.GetInstanceID()}].asset";
EditorUtility.SetDirty(dialogNodeData);
AssetDatabase.CreateAsset(dialogNodeData,
$"Assets/DialogueData/NodeData/SequentialDialogData[{dialogNodeData.GetInstanceID()}].asset");
nodeView = new SequentialDialogNodeView(dialogNodeData);
break;
}
case NodeType.End:
{
var dialogNodeData = ScriptableObject.CreateInstance<EndNodeData>();
dialogNodeData.Path =
$"Assets/DialogueData/NodeData/EndData[{dialogNodeData.GetInstanceID()}].asset";
EditorUtility.SetDirty(dialogNodeData);
AssetDatabase.CreateAsset(dialogNodeData,
$"Assets/DialogueData/NodeData/EndData[{dialogNodeData.GetInstanceID()}].asset");
nodeView = new EndNodeView(dialogNodeData);
break;
}
default:
{
Debug.LogError("未找到该类型的节点");
break;
}
}
//添加节点被选择事件
nodeView.OnNodeSelected = OnNodeSelected;
nodeView.SetPosition(new Rect(position, nodeView.GetPosition().size));
//对Start节点做个特判
if (nodeView.DialogNodeData.NodeType == NodeType.Start)
{
treeData.StartNodeData = nodeView.DialogNodeData;
}
else
{
treeData.ChildNodeDataList.Add(nodeView.DialogNodeData);
}
this.AddElement(nodeView);
}
/// <summary>
/// 恢复节点
/// </summary>
/// <param name="DialogNodeData"></param>
private void RecoveryNode(DialogNodeDataBase DialogNodeData)
{
if (DialogNodeData == null)
{
return;
}
NodeViewBase nodeView = null;
//恢复节点的核心部分,新增的节点需要在这里进行恢复方式的添加
switch (DialogNodeData.NodeType)
{
case NodeType.Start:
{
nodeView = new StartNodeView(DialogNodeData);
break;
}
case NodeType.RandomDialogNode:
{
nodeView = new RandomDialogNodeView(DialogNodeData);
break;
}
case NodeType.SequentialDialogNode:
{
nodeView = new SequentialDialogNodeView(DialogNodeData);
break;
}
case NodeType.End:
{
nodeView = new EndNodeView(DialogNodeData);
break;
}
default:
{
Debug.LogError("未找到该类型的节点");
break;
}
}
nodeView.OnNodeSelected = OnNodeSelected;
nodeView.SetPosition(new Rect(DialogNodeData.Position, nodeView.GetPosition().size));
NodeDirt.Add(DialogNodeData, nodeView);
this.AddElement(nodeView);
}
最后,修改右键菜单,代码如下:
/// <summary>
/// 右键菜单
/// </summary>
/// <param name="evt"></param>
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
base.BuildContextualMenu(evt);
Debug.Log(evt.mousePosition);
//将鼠标世界坐标转为视图本地坐标
clickPosition = contentViewContainer.WorldToLocal(evt.mousePosition);
if (treeData.StartNodeData == null)
{
evt.menu.AppendAction("Create StartNode", x => { CreateNode(NodeType.Start, clickPosition); });
}
evt.menu.AppendAction("Create RandomDialogNode",
x => { CreateNode(NodeType.RandomDialogNode, clickPosition); });
evt.menu.AppendAction("Create SequentialDialogNode",
x => CreateNode(NodeType.SequentialDialogNode, clickPosition));
evt.menu.AppendAction("Create EndNode", x => { CreateNode(NodeType.End, clickPosition); });
}
现在我们回到Unity,随便打开一个节点编辑器,创建我们的新节点试试。可以看到,节点正常被创建了出来,并且我们在节点里面编辑数据,节点对应的DialogNodeDataBase对象中的数据也会同步编辑。
文章来源:https://www.toymoban.com/news/detail-668012.html
到这里,我们的对话系统的对话数据跟节点编辑器部分就已经完成了。我们完成了两个基本的系统流程节点,还有两个用于对话控制的对话节点。根据我们的系统架构,我们还可以自由拓展出更多的自定义节点,比如选择对话节点,事件节点等等。在编辑器中我们实现了利用节点图来编辑对话数据文件的功能,而在数据文件中我们只让它负责了数据处理的相关逻辑。具体的对话逻辑我们并不是在数据类中实现,这样的设计不仅实现了解耦,还实现了模块化,便于维护、拓展新的节点模块。下一节,我们将实现对话系统的的逻辑处理部分,以及能挂在Unity GameObject上的Mono对话系统组件。文章来源地址https://www.toymoban.com/news/detail-668012.html
到了这里,关于Unity 基于GraphView的对话系统设计(一)对话数据与节点编辑器的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!