前言:节点图及其节点的编辑
读完本节,请自觉掌握以下内容[自动狗头]:
- 如何在节点中实现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】下图为节点图的构成
【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】图上的等待节点
-
【2】Inspector面板上的参数
【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)使用到脚本
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——【测试节点功能】
1、实现的方法:
修改以下脚本
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的对象