25
2021
09

迷宫地图编辑器: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,然后把相关数据写到自定义的数据就可以了。



« 上一篇下一篇 »