前言:节点图及其节点的编辑

可视化 脚本管理 Python 可视化脚本设计_游戏引擎


读完本节,请自觉掌握以下内容[自动狗头]:

  • 如何在节点中实现Start()和Update()方法
  • 如何定制一个自己的基类节点,用于打造接口
  • 如何定制一个节点在节点图上的外观
  • 如何定制一个节点在inspector面板上的外观

如果读完还没掌握,请接着读xNode的第二篇
Unity可视化脚本之——xNode【2】官方wiki文档试读


一、需要哪些package

  • 需要xNode包

二、xNode中的节点如何实现Update()和Start()类似的功能

  • 自定义节点的继承关系:【自定义节点】 -> 【MyNode】->【Node】->【ScriptableObject】
    因为继承自ScriptableObject,所以它没有Monobehaviour的Start()和Update()。
  • 实现的方法:写一个MonoBehaviour的脚本,在它的Start和Update中调用所有的Node中的自定义Start()和Update()方法。
  • 案例:用一个管理脚本来每帧刷新图上的所有节点的Update()方法
  • 【1】 图的构成
  • 【2】节点基类实现
  • 【3】mono管理脚本的实现

【1】下图为节点图的构成

可视化 脚本管理 Python 可视化脚本设计_可视化 脚本管理 Python_02

【2】节点基类实现

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using XNode;


/// <summary>
/// 定义一个自己的节点class,在原有Node class 的基础上,增加了虚方法:Start() 、 Update() 、TestNode()。
/// 设计的初衷:因为Node Class中不能使用monobehaviour的Start和Update,因为一个节点已经继承了Node Class(它的基类是 ScriptableObject),所以不能再继承MonoBehaviour Class
/// 【注意】Start和Update这两个方法与Monobehavior中的Start和Update行为类似,但它是一个自定义的虚方法,子节点中如果用到,需要重写。
///     myNode.Start()   ——用于节点的初始化,图加载的时候调用,调用的入口在SceneGraphManager的Start方法里,SceneGraphManager是一个monobehavior脚本
///     myNode.Update()  ——用于节点的每帧更新,调用的入口在SceneGraphManager的Update方法里
///     myNode.TestNode()——编辑器的playing模式下,测试节点的功能
///     
/// 最后修改日期:2021-11-04
/// </summary>
public class myNode : Node
{
    /*
     * flowNode Class:【游戏关卡】或者【仿真作业流程】节点
     * 功能分类:
     *  一、编辑器状态下的功能
     *      1、编辑功能
     *      2、调试功能
     *      3、提供预览功能【所见即所得的模块行为】
     *  二、发布状态下的功能
     *  
     *  三、节点功能的【预览】或者【测试】的说明
     *      1、目标:实现所见即所得的功能
     *      2、最好在Editor的playing状态下测试或者预览,不然容易更改GameObject的初始状态,也就不容易破坏刚搭建好的场景
     *        
     */

    /// <summary>
    /// Start,初始化,执行时序和功能与Monobehaviour相同
    /// </summary>
    public virtual void Start()
    {
        
    }

    /// <summary>
    /// Update,每帧调用,Start,执行时序和功能与Monobehaviour相同
    /// </summary>
    public virtual void Update()
    {
        
    }

    /// <summary>
    /// 流程进入节点的时候调用
    /// </summary>
    public virtual void EnterNode()
    {

    }

    /// <summary>
    /// 节点执行完毕,流程退出该节点的时候调用
    /// </summary>
    public virtual void ExitNode()
    {
        //判断是否有后续节点,有则激活
        List<NodePort> nextNodes = GetOutputPort("Exit").GetConnections();
        if (nextNodes != null)
        {
            Debug.Log($"当前节点:{this.name},后续节点有");
            for (int i = 0; i < nextNodes.Count; i++)
            {
                myNode nextNode = nextNodes[i].node as myNode; //此处的as必用
                Debug.Log($"{i}----" + nextNode.name + "---------");
                SceneGraphManager.CallAfterFramesCoroutine(1, nextNode.EnterNode);  //在下一帧里面激活下一节点
            }
        }
    }

    /// <summary>
    /// 编辑器的playing模式下,测试节点的功能
    /// 限定为playing的原因,playing状态下的操作,等stop后可以自动回撤
    /// </summary>
    public virtual void TestNode()
    {
        
    }
}

【3】mono管理脚本的实现

void Start()
    {
        //图中的所有节点进行初始化
        foreach (myNode nd in graph.nodes)
        {
            nd.Start();
        }

        //脚本单例判断
        attachedCount++;
        if (attachedCount > 1) Debug.LogError("【mySceneGraph.cs】脚本只能被挂载1次");
    }    

 void Update()
    {
        totalTime += Time.deltaTime;
        
        //每帧都调用所有节点的Update函数 
        foreach(myNode nd in graph.nodes)
        {
            nd.Update();
        }      
    }

三、一个简单的普通的流程节点是什么样的

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using XNode;


/// <summary>
/// Non-operation node 没有操作的节点:用于流程节点的连接,导通的作用
/// 使用场景:假定第一步操作结束后,要进行第二步操作,第二步操作有5个node需要同时启动,不使用nop节点的情况下,第一步操作的最后一个节点要
/// 连接5根线到第二步操作的5个节点中,如果流程变动,第一步的最后一个操作节点需要切换,那么要重新连接5根线。
/// 如果第二个节点的开始处是用nop节点作为起始节点,上面的情况,只需修改一根连接。
/// </summary>
public class NopNode : myNode
{
    //========通用参数设置区  ========begin

    /// <summary>
    /// 该Node的功能说明
    /// </summary>
    [Header("功能备注")]
    [TextArea]
    public string tooltip;

    [HideInInspector]
    [Input] public Empty Enter;

    [HideInInspector]
    [Output] public Empty Exit;

    /// <summary>
    /// 当前节点是不是处于【Enter状态】,Enter状态的意思就是该节点正在执行当中。
    /// 说明:一个Graph中,同一时刻里,处于Enter状态的节点,不止一个。
    /// </summary>
    [HideInInspector]
    public bool isEnter;

    /// <summary>
    /// 节点用到的node class 脚本
    /// </summary>
    [HideInInspector]
    public string scriptName;

    //========通用参数设置区  ========end

    /// <summary>
    /// 初始化
    /// </summary>
    protected override void Init()
    {
        scriptName = this.GetType().Name;
    }

    public override void Update()
    {
        
    }

    /// <summary>
    /// 执行流程进入节点,这个节点开始执行
    /// </summary>
    public override void EnterNode()
    {
        base.EnterNode();

        Debug.Log($"流程进入节点:{this.name}");
        isEnter = true;

        ExitNode();
    }

    /// <summary>
    /// 节点执行完毕后,流程退出该节点,进入后续节点
    /// </summary>
    public override void ExitNode()
    {
        base.ExitNode();
        isEnter = false;
    }

    //端口的类型,【注意】:port必须能够系列化,不然graph上渲染的时候不能全部显示,会出错
    [System.Serializable]
    public class Empty { };


    //编辑器模式,才显示【测试功能】菜单,鼠标点击header,右键显示菜单【测试功能】
#if UNITY_EDITOR 
    [ContextMenu("测试功能")]
#endif
    public override void TestNode()
    {
        if (!(Application.isEditor && Application.isPlaying))
        {
            Debug.Log("编辑器运行模式下才能进行测试!");
            return;
        }

        Debug.Log($"开始测试{this.name}模块的功能......");

        //具体的测试
    }
}

四、【等待消息】节点是如何实现的

  • 【1】图上的等待节点
  • 可视化 脚本管理 Python 可视化脚本设计_ide_03


  • 可视化 脚本管理 Python 可视化脚本设计_unity_04

  • 【2】Inspector面板上的参数
  • 可视化 脚本管理 Python 可视化脚本设计_unity_05

  • 【3】节点的实现
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using XNode;

/// <summary>
/// 等待所有消息:所有的消息等到后,才执行后面的节点
/// </summary>
public class waitAllMessagesNode : myNode
{
    //========通用参数设置区  ========begin

    /// <summary>
    /// 该Node的功能说明
    /// </summary>
    [Header("功能备注")]
    [TextArea]
    public string tooltip;

    [HideInInspector]
    [Input] public Empty Enter;

    [HideInInspector]
    [Output] public Empty Exit;

    /// <summary>
    /// 当前节点是不是处于【Enter状态】,Enter状态的意思就是该节点正在执行当中。
    /// 说明:一个Graph中,同一时刻里,处于Enter状态的节点,不止一个。
    /// </summary>
    [HideInInspector]
    public bool isEnter;

    /// <summary>
    /// 节点用到的node class 脚本
    /// </summary>
    [HideInInspector]
    public string scriptName;

    //========通用参数设置区  ========end

    //========自定义参数设置区========begin

    [Header("等待的消息列表")]
    public string[] messages;

    //[Header("参数(多个参数中间用[#]隔开)")]
    //public string msgArg;

    /// <summary>
    /// 要等待的消息,初始化的时候,存入一个字典里面,收到一个消息则从字典里面清除该消息,字典item为0的时候,代表所有的消息都收到
    /// </summary>
    private Dictionary<string, string> msgDict = new Dictionary<string, string>();

    //========自定义参数设置区========end

    /// <summary>
    /// 初始化
    /// </summary>
    protected override void Init()
    {
        //脚本的名字:class名
        scriptName = this.GetType().Name;
    }

    public override void Start()
    {
        base.Start();

        //等待的消息注册
        if (messages.Length > 0)
        {
            foreach (string msg in messages)
            {
                MessageManager.AddMsgFunc("{msg}@", WaitMsg);
            }
        }
    }


    void WaitMsg(string msgArg)
    {
        /*
         * 不同的消息指令合用该方法,如何区分是哪个消息指令触发了该方法,需要用【@】split后取参数
         * arg = [消息名]@[参数1#参数2#...#参数n] 
         */

        var msg = msgArg.Split('@')[0]; //解析消息名称        

        //Debug.Log("执行了WaitMsg方法");
        //Debug.Log("inCurrentFlow = " + inCurrentFlow);
        if (isEnter)
        {          
            msgDict.Remove(msg);

            if (msgDict.Count == 0)
            {
                ExitNode();                
            }
        }
    }


    /// <summary>
    /// 执行流程进入节点,这个节点开始执行
    /// </summary>
    public override void EnterNode()
    {
        base.EnterNode();

        Debug.Log($"流程进入节点:{this.name}");
        isEnter = true;

        //消息装入字典里面。收到一个消息,则删除该消息,等字典为空的时候,代表所有消息都收到,重复执行的时候有bug,
        foreach (var msg in messages)
        {
            msgDict.Add(msg, "");
        }
    }

    /// <summary>
    /// 节点执行完毕后,流程退出该节点,进入后续节点
    /// </summary>
    public override void ExitNode()
    {
        base.ExitNode();

        isEnter = false;
    }

    //端口的类型,【注意】:port必须能够系列化,不然graph上渲染的时候不能全部显示,会出错
    [System.Serializable]
    public class Empty { };


    //编辑器模式,才显示【测试功能】菜单,鼠标点击header,右键显示菜单【测试功能】
#if UNITY_EDITOR 
    [ContextMenu("测试功能")]
#endif
    public override void TestNode()
    {
        if (!(Application.isEditor && Application.isPlaying))
        {
            Debug.Log("编辑器运行模式下才能进行测试!");
            return;
        }

        Debug.Log($"开始测试{this.name}模块的功能......");

        //具体的测试
    }
}

五、节点的外观怎么定制

1、如何让节点的外观简洁明:

以篮圈中的节点为例介绍

(1)Enter:流程进入的连线

(2)Exit:流程退出,next node的连线

(3)模块的功能:

(4)具体的功能描述

(5)使用到脚本

可视化 脚本管理 Python 可视化脚本设计_ide_06

2、实现的原理

给这个脚本编写一个继承NodeEditor的脚本,用于定制node在graph上的外观,下面是【相机移动(moveCameraNode)】的NodeEditor脚本

(1)定义header的显示方式

public override void OnHeaderGUI(){...}

(2)定义body的显示方式

public override void OnBodyGUI(){...}

(3)务必记得把更新的内容进行apply,以便持久化

serializedObject.ApplyModifiedProperties();

(4)完整代码

using System;
using UnityEditor;
using UnityEngine;
using XNode;
using XNodeEditor;
using static XNodeEditor.NodeEditor;

/*
 * 为一个节点定制它的外观。
 *    1、这里的外观是指在Graph上的外观,而不是inspector面板上的外观
 *    2、定制外观的目的,是让Graph上的节点占地面积小一点,防止后期节点太多,装不下
 *    3、所有节点的editor代码都相同,能不能用一个脚本来处理
 *    4、快速更替class的名字,本例中的moveCameraNode,快速修改成需要的class
 */

[CustomNodeEditor(typeof(moveCameraNode))]
public class moveCameraNodeEditor : NodeEditor
{
    private moveCameraNode myFlowNode; //定义了一个类型的节点,在绘制节点body的时候用

    /// <summary>
    /// Node header的绘制
    /// </summary>
    public override void OnHeaderGUI()
    {
        GUI.color = Color.white;
        moveCameraNode node = target as moveCameraNode;        //获取node引用对象 
        flowGraph graph = node.graph as flowGraph;             //获取graph引用对象

        if (node.isEnter)
        {
            GUI.color = Color.red;     //如果当前节点是current节点,GUI.color 设置为蓝色
        }
        GUILayout.Label(target.name, NodeEditorResources.styles.nodeHeader, GUILayout.Height(30));
        GUI.color = Color.white;
    }

    /// <summary>
    /// 功能:Draws standard field editors for all public fields
    /// 疑问:绘制public的字段,谁的public fields;绘制到哪里,是绘制到graph中的node GUI上,还是node的inspector上
    ///       这个函数是每帧调用?
    /// </summary>
    public override void OnBodyGUI()
    {
        /* 说明:
         * 1、如果simpleNode为空,那么初始化simpleNode
         * 2、【serializedObject.Update()】:更新【系列化的物体】的representation(表现,表象)
         * 3、【PropertyField()】:Make a field for a serialized property. Automatically displays relevant node port.
         *     为【序列化属性】创建一个字段。 并把这个字段显示在与它相对应的端口上。  
         * 4、【LabelField()】:创建一个标签字段。 (用于显示只读信息。)
         */

        if (myFlowNode == null) myFlowNode = target as moveCameraNode; //as - 引用类型之间的转变

        //Update serialized object's representation。更新【系列化的物体】的representation(表现,表象)
        //与【serializedObject.ApplyModifiedProperties()】配对使用
        serializedObject.Update();

        //模块功能设置

        /*
        * ====函数说明====
        * UnityEditor.EditorGUILayout.LabelField(myFlowNode.tooltip)  // Make a label field. (Useful for showing read-only info.)
        * serializedObject.FindProperty("Enter")                      // Find serialized property by name.
        */

        UnityEditor.EditorGUILayout.LabelField(myFlowNode.tooltip);
        UnityEditor.EditorGUILayout.LabelField("script:" + myFlowNode.scriptName);

        NodeEditorGUILayout.PropertyField(serializedObject.FindProperty("Enter"));
        NodeEditorGUILayout.PropertyField(serializedObject.FindProperty("Exit"));

        // Apply property modifications。修改完毕后,应用这些修改。
        serializedObject.ApplyModifiedProperties();
    }
}

六、节点在inspector面板上的外观定制

比如【移动相机】节点在inspector面板上的外观如下:

一共有5个交互的元素,其中还包括一个定制的button——【测试节点功能】

可视化 脚本管理 Python 可视化脚本设计_System_07


1、实现的方法:

修改以下脚本

可视化 脚本管理 Python 可视化脚本设计_ide_08

2、修改的内容
在 GlobalNodeEditor的 OnInspectorGUI()方法中添加代码,注意代码的位置,需要放在
serializedObject.ApplyModifiedProperties()语句之前。

(1)添加的代码

// ======= 添加的代码 begin
            if (GUILayout.Button("测试节点功能", GUILayout.Height(40)))
            {
                Debug.Log("调用对应节点的测试方法进行测试!");
                foreach (var go in serializedObject.targetObjects)
                {
                    Debug.Log(go.name);
                    Debug.Log(go.GetType());
                    foreach (var m in go.GetType().GetMethods()) //用到了反射
                    {
                        //Debug.Log(m.Name);

                        if (m.Name == "TestNode")
                        {
                            Debug.Log("侦测到测试节点的方法TestNode");
                        }

                        /*
                         * Get the ItsMagic method and invoke with a parameter value of 100
                         * MethodInfo magicMethod = magicType.GetMethod("ItsMagic");
                         * object magicValue = magicMethod.Invoke(magicClassObject, new object[]{100});
                         */                                                                     
                    }

                    var myfunc = go.GetType().GetMethod("TestNode");
                    myfunc.Invoke(go, null);
                }
            }

            // ======= 添加的代码 end

(2)完整的代码

using UnityEditor;
using UnityEngine;
#if ODIN_INSPECTOR
using Sirenix.OdinInspector.Editor;
using Sirenix.Utilities;
using Sirenix.Utilities.Editor;
#endif

namespace XNodeEditor
{
    /// <summary> Override graph inspector to show an 'Open Graph' button at the top </summary>
    [CustomEditor(typeof(XNode.NodeGraph), true)]
#if ODIN_INSPECTOR
    public class GlobalGraphEditor : OdinEditor {
        public override void OnInspectorGUI() {
            if (GUILayout.Button("Edit graph", GUILayout.Height(40))) {
                NodeEditorWindow.Open(serializedObject.targetObject as XNode.NodeGraph);
            }
            base.OnInspectorGUI();
        }
    }
#else
    [CanEditMultipleObjects]
    public class GlobalGraphEditor : Editor
    {
        public override void OnInspectorGUI()
        {
            serializedObject.Update();

            if (GUILayout.Button("Edit graph", GUILayout.Height(40)))
            {
                NodeEditorWindow.Open(serializedObject.targetObject as XNode.NodeGraph);
            }

            GUILayout.Space(EditorGUIUtility.singleLineHeight);
            GUILayout.Label("Raw data", "BoldLabel");

            DrawDefaultInspector(); //Inspector绘制,Unity核心

            serializedObject.ApplyModifiedProperties();
        }
    }
#endif

    [CustomEditor(typeof(XNode.Node), true)]
#if ODIN_INSPECTOR
    public class GlobalNodeEditor : OdinEditor {
        public override void OnInspectorGUI() {
            if (GUILayout.Button("Edit graph", GUILayout.Height(40))) {
                SerializedProperty graphProp = serializedObject.FindProperty("graph");
                NodeEditorWindow w = NodeEditorWindow.Open(graphProp.objectReferenceValue as XNode.NodeGraph);
                w.Home(); // Focus selected node
            }
            base.OnInspectorGUI();
        }
    }
#else
    [CanEditMultipleObjects]
    public class GlobalNodeEditor : Editor
    {
        public override void OnInspectorGUI()
        {
            serializedObject.Update();

            if (GUILayout.Button("Edit graph", GUILayout.Height(40)))
            {
                SerializedProperty graphProp = serializedObject.FindProperty("graph");
                NodeEditorWindow w = NodeEditorWindow.Open(graphProp.objectReferenceValue as XNode.NodeGraph);
                w.Home(); // Focus selected node
            }

            GUILayout.Space(EditorGUIUtility.singleLineHeight);
            GUILayout.Label("Raw data", "BoldLabel");

            // Now draw the node itself.
            DrawDefaultInspector();

            // ======= 添加的代码 begin
            if (GUILayout.Button("测试节点功能", GUILayout.Height(40)))
            {
                Debug.Log("调用对应节点的测试方法进行测试!");
                foreach (var go in serializedObject.targetObjects)
                {
                    Debug.Log(go.name);
                    Debug.Log(go.GetType());
                    foreach (var m in go.GetType().GetMethods()) //用到了反射
                    {
                        //Debug.Log(m.Name);

                        if (m.Name == "TestNode")
                        {
                            Debug.Log("侦测到测试节点的方法TestNode");
                        }

                        /*
                         * Get the ItsMagic method and invoke with a parameter value of 100
                         * MethodInfo magicMethod = magicType.GetMethod("ItsMagic");
                         * object magicValue = magicMethod.Invoke(magicClassObject, new object[]{100});
                         */                                                                     
                    }

                    var myfunc = go.GetType().GetMethod("TestNode");
                    myfunc.Invoke(go, null);
                }
            }

            // ======= 添加的代码 end  
            serializedObject.ApplyModifiedProperties();
        }
    }
#endif
}

七、其它:

(1)如何在一个节点里面调用协程(协程只能在monobehaviour,而节点继承scriptableObject)
(2)节点里面如何引用scene的对象