可视化对话树编辑笔记

在制作 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();
        }
    }
}
posted @ 2024-09-28 10:36  蒟蒻丁  阅读(8)  评论(0编辑  收藏  举报