視覺化對話樹編輯筆記

蒟蒻丁發表於2024-09-28

在製作 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 ,如果轉換成功的話,我們就選中這個 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;
}

相關文章