可视化对话树编辑笔记
在制作 RPG 甚至 AVG 的时候,我们要涉及大量的文本编辑,这个时候不可视化的界面会大大提升项目的维护成本(非常好脚本,使我 AVG 项目崩溃),所以我们需要自己创建一个文本编辑界面。
而在涉及对话的时候,这个叫做对话树的结构具有不错的性质,对话树是一个简单的多叉树结构,但节点被分为 CP 对话和 PL 对话,这样我们就能比较方便的依靠节点来推出 UI,下面我们就来细说一下如何实现可视化编辑对话树。
对话树部分
对话树节点
首先对于一个对话树,我们采用两个脚本来实现,一个是节点脚本,存放了每个对话节点的信息,比如:
· 文本:此时对话中显示的文本
· 对话者:这个节点中对话的发起者
· 出度:这个节点连接的下一个节点
而为了实现可视化编辑,我们还另外储存这些信息:
· 节点的 Rect :记录节点在编辑界面中的位置和长宽
· 节点的 Texture2D :记录节点的背景样式,这样对眼睛比较友好,而且易于维护节点中的对话者
· isChange :节点信息是否被改变,这样我们只在节点大小改变的时候重新绘制一次 Texture2D
那么节点的代码如下
namespace Dia.Dialogue
{
public enum Speaker
{
Player,
Npc
}
public class DialogueNode : ScriptableObject
{
[TextArea(5,50)]
public string text;
public Texture2D texture;
public Speaker speaker = Speaker.Npc;
[HideInInspector] public bool isChange = false;
[HideInInspector] public Rect pos = new Rect(0, 0, 180, 90);
[HideInInspector] public List<string> nxt = new List<string>();
private void OnValidate() => isChange = true;
}
}
文本库
另一个则是文本库,用来返回任一文本节点的信息,以及维护文本节点的创建与删除,在文本库中我们需要实现以下功能:
· 查询:利用文本节点 .name 来获取文本节点
· 获得根节点:便于后续对话的运作
· 获得所有节点:获取文本库中的所有节点
· 获取所有后代:获取一个节点的所有后代,方便对话选项的建立和 CP 随机对话
· 创建与删除节点:在可视化编辑界面中创建和删除节点,在创建或删除之后要重载查询字典和处理节点的后代
那么代码如下
namespace Dia.Dialogue
{
[CreateAssetMenu(fileName = "New Dialogue", menuName = "New Dialogue", order = 0)]
public class Dialogue : ScriptableObject
{
[SerializeField] private List<DialogueNode> nodes = new List<DialogueNode>();
private Dictionary<string, DialogueNode> NodeLookUp =new Dictionary<string, DialogueNode>();
#if UNITY_EDITOR
private void Awake()
{
Initialize();
}
#endif
public void Initialize()
{
if (nodes.Count != 0) return;
CreateNode(null);
}
private void OnValidate()
{
NodeLookUp.Clear();
foreach(DialogueNode node in GetNodes())
NodeLookUp[node.name] = node;
}
public IEnumerable<DialogueNode> GetNodes()
{
return nodes;
}
public DialogueNode GetRootNode()
{
return nodes[0];
}
public IEnumerable<DialogueNode> GetAllChildren(DialogueNode parentNode)
{
foreach(string childID in parentNode.nxt)
{
if (!NodeLookUp.ContainsKey(childID)) continue;
yield return NodeLookUp[childID];
}
}
public IEnumerable<DialogueNode> GetPlayerChildren(DialogueNode currentNode)
{
foreach(DialogueNode node in GetAllChildren(currentNode))
{
if (node.speaker == Speaker.Player) yield return node;
}
}
public IEnumerable<DialogueNode> GetAIChildren(DialogueNode currentNode)
{
foreach (DialogueNode node in GetAllChildren(currentNode))
{
if (node.speaker == Speaker.Npc) yield return node;
}
}
public void CreateNode(DialogueNode parent)
{
DialogueNode newNode = CreateInstance<DialogueNode>();
if (parent != null) newNode.name = System.Guid.NewGuid().ToString();
else newNode.name = "Root";
//Undo.RecordObject(this, "A");
//Undo.RegisterCreatedObjectUndo(newNode, "Creating Dialogue Node");
if (parent != null)
{
parent.nxt.Add(newNode.name);
newNode.pos.x = parent.pos.x + 10f;
newNode.pos.y = parent.pos.y + 10f;
if (parent.speaker == Speaker.Npc) newNode.speaker = Speaker.Player;
if (parent.speaker == Speaker.Player) newNode.speaker = Speaker.Npc;
}
nodes.Add(newNode);
AssetDatabase.AddObjectToAsset(newNode, this);
OnValidate();
}
public void DeleteNode(DialogueNode nodeToRemove)
{
Undo.RecordObject(this, "Delete Dialogue Node");
nodes.Remove(nodeToRemove);
OnValidate();
CleanChildren(nodeToRemove);
Undo.DestroyObjectImmediate(nodeToRemove);
}
private void CleanChildren(DialogueNode nodeToRemove)
{
foreach (DialogueNode node in GetNodes())
node.nxt.Remove(nodeToRemove.name);
}
}
}
文本编辑器部分
创建自定义编辑器
接下来就是可视化界面的创建, GetWindow(typeof(this.name),false,"editor name")
函数可以创建一个自定义编辑器窗口,这个函数必须在一个拥有 [MenuItem()]
属性的函数中执行,代码如下。
[MenuItem("Window/Dialogue Editor")] //Create a option in tools
public static void ShowEditorWindow()
{
GetWindow(typeof(DialogueEditor), false, "Dialogue System");
}
弹出窗口
而我们需要实现选中一个文本库时,自动弹出编辑窗口,这个时候我们就要使用 Unity 提供的一个回调属性 OnOpenAsset(x)
,其中 x
是函数被调用的次序,加入了这个属性的静态函数将会在一个资产被选中的时候被调用,这个静态函数必须拥有以下两种特征之一。(这是官网对该属性的介绍)
· static bool OnOpenAsset(int instanceID, int line)
· static bool OnOpenAsset(int instanceID, int line, int column)
其中, instanceID 是选中资产在 Unity 中的编号,利用 EditorUtility.InstanceIDToObject
函数就能获取这个资产,接着我们使用投射 as
来将这个资产转换为 dialogue ,如果转换成功的话,我们就打开窗口。以下是代码。
[OnOpenAsset(1)] //Open the window
public static bool OpenDialogue(int instanceID,int line)
{
Dialogue tmp = EditorUtility.InstanceIDToObject(instanceID) as Dialogue;
if (tmp == null) return false;
ShowEditorWindow();
return true;
}
以上是双击开启窗口,接着我们还想实现单击切换选中的文本库,我们可以使用一个 Unity 自带的 Event , Selection.selectionChanged
这个事件会在单击选中的时候触发,以及函数 Selection.activeObject
获取选中的资产。以下是代码。
private void OnEnable()
{
Selection.selectionChanged += OnSelectionChanged;
nodestyle = new GUIStyle();
//nodestyle.normal.background = EditorGUIUtility.Load("node0") as Texture2D;
}
private void OnSelectionChanged()
{
Dialogue newDialogue = Selection.activeObject as Dialogue;
if (newDialogue != null)
{
selectedDialogue = newDialogue;
selectedDialogue.Initialize();
Repaint();
}
}
绘制编辑器元素
再就是当我们在编辑器中操作时的实时更新, Unity 提供了一个函数 OnGUI
,这个函数将在你点击编辑器窗口等多种与窗口互动操作时被触发,就像是编辑器界面的 Update
函数一样。我们在这个函数中将进行多种操作来绘制这个窗口的诸多元素,我们要实现的绘制如下:
· 在没有选中窗口时的提示语
· 检测鼠标移动来实现节点拖拽
· 为编辑器窗口开启滚动窗口特质
· 绘制节点
此外,所有在编辑器中进行的编辑需要进行 SetDirty
这样才能让 Unity 确实的进行数据修改,否则引擎是不会认你的修改的(毕竟平时的修改都是在 inspector 中完成的)。这里我们使用 GUI.changed = true;
来进行 SetDirty。以下是 OnGUI
的代码
private void OnGUI()
{
if (selectedDialogue == null)
EditorGUILayout.LabelField("No Dialogue Selected");
else
{
MouseProcessEvent();
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
Rect canvas = GUILayoutUtility.GetRect(4000, 4000);
DrawBackGround(canvas);
int tmp = 0;
foreach(DialogueNode node in selectedDialogue.GetNodes())
{
OnGUINode(node, tmp++);
DrawConnections(node);
}
EditorGUILayout.EndScrollView();
if(creatingNode != null) // Creating New Node
{
Undo.RecordObject(selectedDialogue, "Add a Node");
selectedDialogue.CreateNode(creatingNode);
creatingNode = null;
}
if(deletingNode != null) // Delete Node
{
Undo.RecordObject(selectedDialogue, "Delete a Node");
selectedDialogue.DeleteNode(deletingNode);
deletingNode = null;
}
}
}
鼠标拖拽操作:使用 Event 类来检测鼠标状态,有按下、拖拽和松开三个状态,再通过用 foreach
扫描所有节点,看鼠标是否处于一个节点内,这样就能实现拖拽节点或拖拽窗口的操作。下面是代码
private void MouseProcessEvent()
{
if (Event.current.type == EventType.MouseDown && draggingNode == null)
{
draggingNode = GetNodeAtPoint(Event.current.mousePosition + scrollPosition);
if (draggingNode != null) // Select a specific Node
{
draggingOffset = draggingNode.pos.position - Event.current.mousePosition;
Selection.activeObject = draggingNode;
}
else // Select the Blank
{
lastSRPostion = Event.current.mousePosition + scrollPosition;
Selection.activeObject = selectedDialogue;
}
GUI.changed = true;
}
else if (Event.current.type == EventType.MouseDrag && draggingNode != null)
{
Undo.RecordObject(selectedDialogue, "Move Dialogue Node");
draggingNode.pos.position = Event.current.mousePosition + draggingOffset;
GUI.changed = true;
}
else if (Event.current.type == EventType.MouseUp && draggingNode != null)
{
draggingNode = null;
}
else if (Event.current.type == EventType.MouseDrag && draggingNode == null)
{
scrollPosition = lastSRPostion - Event.current.mousePosition;
GUI.changed = true;
}
}
其他渲染:还可以对节点、背景、绘制连接等方面进行渲染。
以下是文本编辑器的总代码。
总代码
namespace Dia.Dialogue.Editor
{
public class DialogueEditor : EditorWindow
{
GUIStyle nodestyle;
[NonSerialized] Dialogue selectedDialogue = null;
[NonSerialized] DialogueNode draggingNode = null;
[NonSerialized] DialogueNode creatingNode = null;
[NonSerialized] DialogueNode deletingNode = null;
[NonSerialized] DialogueNode linkingParent = null;
[NonSerialized] Vector2 lastSRPostion;
Vector2 scrollPosition;
Vector2 draggingOffset;
[MenuItem("Window/Dialogue Editor")] //Create a option in tools
public static void ShowEditorWindow()
{
GetWindow(typeof(DialogueEditor), false, "Dialogue System");
}
[OnOpenAsset(1)] //Open the window
public static bool OpenDialogue(int instanceID,int line)
{
Dialogue tmp = EditorUtility.InstanceIDToObject(instanceID) as Dialogue;
if (tmp == null) return false;
ShowEditorWindow();
return true;
}
private GUIStyle CreateNodeStyle(DialogueNode node)
{
if (node.texture == null || (node.isChange))
{
node.texture = NodeStylize.Stylize(node);
node.isChange = false;
}
nodestyle.normal.background = node.texture;
nodestyle.padding = new RectOffset(15, 15, 15, 15);
nodestyle.border = new RectOffset(15, 15, 15, 15);
return nodestyle;
}
#region select a dialogue(onject) and open the window
private void OnEnable()
{
Selection.selectionChanged += OnSelectionChanged;
nodestyle = new GUIStyle();
//nodestyle.normal.background = EditorGUIUtility.Load("node0") as Texture2D;
}
private void OnSelectionChanged()
{
Dialogue newDialogue = Selection.activeObject as Dialogue;
if (newDialogue != null)
{
selectedDialogue = newDialogue;
selectedDialogue.Initialize();
Repaint();
}
}
#endregion
#region Update the information in the GUI window
private void OnGUI()
{
if (selectedDialogue == null)
EditorGUILayout.LabelField("No Dialogue Selected");
else
{
MouseProcessEvent();
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
Rect canvas = GUILayoutUtility.GetRect(4000, 4000);
DrawBackGround(canvas);
int tmp = 0;
foreach(DialogueNode node in selectedDialogue.GetNodes())
{
OnGUINode(node, tmp++);
DrawConnections(node);
}
EditorGUILayout.EndScrollView();
if(creatingNode != null) // Creating New Node
{
Undo.RecordObject(selectedDialogue, "Add a Node");
selectedDialogue.CreateNode(creatingNode);
creatingNode = null;
}
if(deletingNode != null) // Delete Node
{
Undo.RecordObject(selectedDialogue, "Delete a Node");
selectedDialogue.DeleteNode(deletingNode);
deletingNode = null;
}
}
}
private void MouseProcessEvent()
{
if (Event.current.type == EventType.MouseDown && draggingNode == null)
{
draggingNode = GetNodeAtPoint(Event.current.mousePosition + scrollPosition);
if (draggingNode != null) // Select a specific Node
{
draggingOffset = draggingNode.pos.position - Event.current.mousePosition;
Selection.activeObject = draggingNode;
}
else // Select the Blank
{
lastSRPostion = Event.current.mousePosition + scrollPosition;
Selection.activeObject = selectedDialogue;
}
GUI.changed = true;
}
else if (Event.current.type == EventType.MouseDrag && draggingNode != null)
{
Undo.RecordObject(selectedDialogue, "Move Dialogue Node");
draggingNode.pos.position = Event.current.mousePosition + draggingOffset;
GUI.changed = true;
}
else if (Event.current.type == EventType.MouseUp && draggingNode != null)
{
draggingNode = null;
}
else if (Event.current.type == EventType.MouseDrag && draggingNode == null)
{
scrollPosition = lastSRPostion - Event.current.mousePosition;
GUI.changed = true;
}
}
private void OnGUINode(DialogueNode node, int tmp)
{
GUILayout.BeginArea(node.pos, CreateNodeStyle(node));
EditorGUI.BeginChangeCheck();
if(Selection.activeObject != node)
EditorGUILayout.LabelField("Node:" + tmp.ToString(), EditorStyles.whiteLabel);
else
EditorGUILayout.LabelField("Node:" + tmp.ToString(), EditorStyles.whiteLargeLabel);
string newText = EditorGUILayout.TextField(node.text);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(selectedDialogue, "Update Dialogue Text");
node.text = newText;
}
GUILayout.BeginHorizontal();
if (GUILayout.Button("Add")) creatingNode = node;
if (GUILayout.Button("Delete")) deletingNode = node;
DrawLinking(node);
GUILayout.EndHorizontal();
GUILayout.EndArea();
}
private void DrawLinking(DialogueNode node)
{
if (linkingParent == null) { if (GUILayout.Button("Link")) linkingParent = node; }
else if(node == linkingParent) { if (GUILayout.Button("Cancel")) linkingParent = null; }
else if (linkingParent.nxt.Contains(node.name))
{
if (GUILayout.Button("Unlink"))
{
Undo.RecordObject(selectedDialogue, "Unlink a Dialogue Link");
linkingParent.nxt.Remove(node.name);
linkingParent = null;
}
}
else if (linkingParent != null)
{
if (GUILayout.Button("This"))
{
Undo.RecordObject(selectedDialogue, "Add a Dialogue Link");
linkingParent.nxt.Add(node.name);
linkingParent = null;
}
}
}
private void DrawConnections(DialogueNode node)
{
Vector3 startPosition = new Vector3(node.pos.xMax, node.pos.center.y);
foreach(DialogueNode childNode in selectedDialogue.GetAllChildren(node))
{
Vector3 endPosition = new Vector3(childNode.pos.xMin, childNode.pos.center.y);
Vector3 controlPointOffset = endPosition - startPosition;
controlPointOffset.x *= 0.5f;
controlPointOffset.y = 0;
Handles.color = Color.white;
Handles.DrawBezier(startPosition, endPosition,
startPosition + controlPointOffset, endPosition - controlPointOffset,
Color.white, null, 2f);
}
}
private DialogueNode GetNodeAtPoint(Vector2 point)
{
DialogueNode theNode = null;
foreach (DialogueNode node in selectedDialogue.GetNodes())
if(node.pos.Contains(point))theNode = node;
return theNode;
}
#endregion
public void DrawBackGround(Rect position)
{
EditorGUI.DrawRect(position, new Color(0.15f, 0.15f, 0.15f, 1f));
Handles.BeginGUI();
Handles.color = new Color(0.12f, 0.12f, 0.12f, 1f);
for (float x = 0; x < position.height; x += 20f)
Handles.DrawLine(new Vector3(x, 0, 0), new Vector3(x, position.height, 0));
for (float y = 0; y < position.width; y += 20f)
Handles.DrawLine(new Vector3(0, y, 0), new Vector3(position.width, y, 0));
Handles.EndGUI();
}
}
}