迷宮地圖編輯器:Xnode插件實踐


前言


在unity開發游戲編輯器擴展中,我們常常會用到節點編輯器。節點編輯器就是用節點和節點連接的圖形話編輯器,比如unity自帶的shadergraph就是一個節點編輯器。用戶通過節點之間的拖拖拉拉,連線就能得到想要的結果。

在一段時間里,打算做一個迷宮的地圖編輯器,每個地圖有上下左右4個門,可以和其他地圖連接。於是打算開發一個編輯器,通過可視化的方式去生成整個迷宮的數據。查了很多的節點插件,xnode作為輕量級的節點編輯器就非常適合用來做二次開發

Xnode(https://github.com/Siccity/xNode)是開源的輕量級的節點編輯器插件。所以理解和開發起來非常方便

最終開發效果如下。可以在窗口內創建節點,設置四個門的跳轉關系,最終點擊導出保存為一個指定的數據文件

屏幕截圖 2021-09-25 193709


安裝Xnode


從github下載這個插件,解壓出來放在Assets目錄下即可

不過這里我做了一點點改動。考慮到只想要xnode的編輯器功能,不打算打包的時候把xnode的代碼帶到最終的包里面,所以把runtime的代碼全部放到了Editor目錄里面。這樣就可以保證打包時候,我們游戲的工程是和xnode相互獨立的。

如圖所示,現在工程里面代碼全部在Editor下面

屏幕截圖 2021-09-25 194330


定義圖和節點


xnode是非常輕量級的非常易開發的。它的概念就是圖Graph,節點Node,端口Port。端口我們可以先不關心。先來定義一下圖

[CreateAssetMenu(fileName = "mazelayer", menuName = "UmGame/場景/迷宮編輯器數據")]
public class MazeLayerGraph : NodeGraph
{ 
}

就是這么簡單,繼承一下NodeGraph即可。

這里給MazeLayerGraph加了一個CreateAssetMenu標簽。這樣就可以在Project面板中,通過右鍵菜單的方式創建出這個圖文件。

屏幕截圖 2021-09-25 195638

創建之后,就可以通過雙擊這個文件或者在Inspector面板中點擊Open或者點擊EditGraph都可以打開

屏幕截圖 2021-09-25 200003

打開的界面什么都沒有,右鍵菜單里面也沒什么東西

屏幕截圖 2021-09-25 200210

這是因為還沒有定義節點Node,所以開始定義一下Node

也很簡單,繼承Node即可。這里因為后面打算要畫一個迷宮房間的圖,還要記錄房間的ID,所以定義了一些變量。回到unity,右鍵就可以看到有一個菜單了

屏幕截圖 2021-09-25 200917

選擇這個菜單就可以創建出一個節點了

屏幕截圖 2021-09-25 201637

但是這些數據不是我想在圖中展示的,所以打算重寫節點的繪制。


Node重繪


xnode中實現Node重繪非常簡單,只要類似unity寫一個CustomEditor就可以。對二次開發非常友好。

namespace Game
{

    [CustomNodeEditor(typeof(MazeLayerRoomNode))]
     public class MazeLayerRoomNodeEditor : NodeEditor
     {
         public override void OnHeaderGUI()
         {
             var tar = (MazeLayerRoomNode)target;
             var head = tar.RoomID.ToString();
             if (!string.IsNullOrEmpty(tar.RoomName))
                 head += ":" + tar.RoomName;
             EditorGUILayout.LabelField(head, NodeEditorResources.styles.nodeHeader, GUILayout.Height(30));
         }

        public override int GetWidth()
         {
             return 115;
         }

        public Rect[] Points = new Rect[4];

        public override void OnBodyGUI()
         {
             var tar = (MazeLayerRoomNode)target;
             var width = GetWidth();
             if (tar.Tex == null)
                 tar.Tex = AssetDatabase.LoadAssetAtPath<Texture2D>(
                     $"Assets/Game/Art/Map/levelmap/{tar.TerrianID}.png");
             GUILayout.Label("", GUILayout.Width(width - 20), GUILayout.Height(210 / 2f));

            var r = new Rect(20, 35, 150 / 2f, 210 / 2f);
             if (tar.Tex != null)
                 EditorGUI.DrawPreviewTexture(r, tar.Tex);
             else
                 EditorGUI.DrawRect(r, Color.white);

            var height = GUILayoutUtility.GetLastRect().yMax;
             EditorGUILayout.LabelField("", GUILayout.Height(height));
             var rect = new Rect(0, 0, 16, 16);
             foreach (var port in target.DynamicPorts)
             {
                 var rport = port;
                 var strs = rport.fieldName.Split('_');
                 if (strs.Length != 2)
                     continue;
                 var doorid = (EmDoorID)int.Parse(strs[0]);
                 switch (doorid)
                 {
                     case EmDoorID.Bottom:
                         rect.y = height;
                         if (rport.IsInput)
                             rect.x = width / 2f + 8;
                         else
                             rect.x = width / 2f - 8 - 16;
                         break;
                     case EmDoorID.Left:
                         rect.x = 0;
                         if (rport.IsInput)
                             rect.y = height / 2f - 8 - 16;
                         else
                             rect.y = height / 2f + 8;
                         rect.y += 15;
                         break;
                     case EmDoorID.Right:
                         rect.x = width - 16;
                         if (rport.IsInput)
                             rect.y = height / 2f + 8;
                         else
                             rect.y = height / 2f - 8 - 16;
                         rect.y += 15;
                         break;
                     case EmDoorID.Top:
                         rect.y = 0;
                         if (rport.IsInput)
                             rect.x = width / 2f - 8 - 16;
                         else
                             rect.x = width / 2f + 8;
                         break;
                     default:
                         throw new ArgumentOutOfRangeException();
                 }

                var color = rport.IsInput ? Color.red : Color.green;

                NodeEditorGUILayout.DrawPortHandle(rect, Color.black, color);
                 portPositions[rport] = rect.center;
             }
         }

        public override void AddContextMenuItems(GenericMenu menu)
         {
             base.AddContextMenuItems(menu);
             var tar = (MazeLayerRoomNode)target;
             if (Selection.objects.Length == 1 && Selection.activeObject is XNode.Node)
             {
                 menu.AddItem(new GUIContent("增加門/↑上"), false, () => tar.AddDoor(EmDoorID.Top));
                 menu.AddItem(new GUIContent("增加門/↓下"), false, () => tar.AddDoor(EmDoorID.Bottom));
                 menu.AddItem(new GUIContent("增加門/上下"), false, () =>
                 {
                     tar.AddDoor(EmDoorID.Bottom);
                     tar.AddDoor(EmDoorID.Top);
                 });
                 menu.AddItem(new GUIContent("增加門/←左"), false, () => tar.AddDoor(EmDoorID.Left));
                 menu.AddItem(new GUIContent("增加門/→右"), false, () => tar.AddDoor(EmDoorID.Right));
                 menu.AddItem(new GUIContent("增加門/左右"), false, () =>
                 {
                     tar.AddDoor(EmDoorID.Right);
                     tar.AddDoor(EmDoorID.Left);
                 });
                 menu.AddItem(new GUIContent("增加門/上下左右"), false, () =>
                 {
                     tar.AddDoor(EmDoorID.Bottom);
                     tar.AddDoor(EmDoorID.Top);
                     tar.AddDoor(EmDoorID.Right);
                     tar.AddDoor(EmDoorID.Left);
                 });
             }
         }
     }
}

1、繼承NodeEditor,比用CustomNodeEditor和我們自定義的Node相關聯

2、復寫了OnHeaderGUI,讓節點頭部顯示房間的ID和名稱,這樣查看比較方便

3、復寫GetWidth。因為我知道畫的圖的大小,其他不想顯示,所以固定為115

4、復寫OnBodyGUI。這個是重點。因為在節點畫出圖,所以在這個函數里面,先去AssetDataBase里面找到圖,然后使用DrawPreviewTexture畫出來。最后我要畫出門的出入口,所以使用Node的DynamicPorts存儲門的數據。最后把這些port的位置記錄到portPositions數組中,這樣到時候去就可以去做連線了。

5、復寫AddContextMenuItems,在節點的右鍵菜單的時候加上一些動態加門的方法

屏幕截圖 2021-09-25 203058

如圖,添加4個門之后,就在節點周圍畫出了4個入口和4個出口

多加幾個節點,然后連接起來

屏幕截圖 2021-09-25 203300

這個曲線不怎么滿意,怎么辦。


Graph重繪


在xnode中,不僅Node可以重繪,Graph也可以

    [CustomNodeGraphEditor(typeof(MazeLayerGraph))]
    public class MazeLayerGraphEditor : NodeGraphEditor
    {

               public override NoodlePath GetNoodlePath(NodePort output, NodePort input)
                {
                     return NoodlePath.Straight;
                }

    }


類似的,我們只要繼承NodeGraphEditor,並用CustomNodeGraphEditor和我們自定義的Graph關聯就可以了。

為了實現曲線變直線,我們只要復寫GetNoodlePath即可,NoodlePath.Straight表示直線

屏幕截圖 2021-09-25 203756

OK。那大部分就完成了。最后就是保存了。我們在MazeLayerGraphEditor中的OnGUI可以寫一個buttom,然后把相關數據寫到自定義的數據就可以了。




免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM