当前位置: 首页 >  在线学习 >  Unity行为树可视化编辑器开发

Unity行为树可视化编辑器开发

导读:Unity行为树插件开发心得.概述.在ARPG项目的开发过程当中,要涉及到NPC的AI系统,一般来说,简单的AI行为使用状态机即可比较好的实现,但如果NPC的行为稍微一复杂,那么使用状态机来实现就会比较难维护,并且后期工作量会随着NPC状态的增加而成倍增加。.这时就可以考虑使用行

Unity行为树插件开发心得


概述

在ARPG项目的开发过程当中,要涉及到NPC的AI系统,一般来说,简单的AI行为使用状态机即可比较好的实现,但如果NPC的行为稍微一复杂,那么使用状态机来实现就会比较难维护,并且后期工作量会随着NPC状态的增加而成倍增加。

这时就可以考虑使用行为树来实现NPC的AI,行为树相比于状态机更利于维护,在NPC的AI比较复杂的时候,状态机已经难以我们去阅读,而行为树得益于其树形结构化的表现,也还能有不错的可读性,方便扩展和修改。

不过行为树相比于状态机也有不足,从运行速度的角度来说,行为树还是要稍逊于状态机。从实现上来说,行为树的门槛也要高于状态机,理解起来也要困难一些。这里不对二者的区别做过多的阐述。

那么在实现Unity行为树插件时,会涉及到很多部分,C#语言部分、Unity编辑器部分、行为树框架部分等等,接下来就开发过程当中涉及到的几个重要部分的知识点和关键点做解释说明。

项目github地址 Unity-RPGCore-BehaviorTree

行为树Editor部分截图(乱连的树,仅展示用)


行为树框架

要做行为树,先从理解什么是行为树开始。

对于游戏开发和人工智能来说,行为树是一种图形化编程工具,它以树状结构来描述复杂的行为逻辑。行为树由一系列节点组成,每个节点代表一种行为或条件,可以是选择节点、序列节点、条件节点、动作节点、装饰节点等等,这些节点通过连线连接起来,形成一颗树状结构。行为树可以用来描述人物角色的行为、NPC的行为、游戏AI的行为等等,具有可拓展、易维护、易修改等优点。

下面展示了一个简单的行为树

上面的行为树就是由多个不同的节点组成的。执行一颗行为树即是遍历一遍行为树,从root节点开始,优先向子节点遍历,也就是深度优先,一直到最下层的子节点也就是叶子节点开始执行。

以上图的行为树为例,首先执行的应该是PlayerInView ,这个节点是一个条件节点,负责执行条件判断,当条件满足时,它把结果返回给它的父节点也就是FollowPlayer 节点,这个节点是Sequence节点,属于一个控制节点或者说是一个组合节点。Sequence节点的特点是,当子节点返回成功时执行下一个子节点,当子节点返回失败时,不执行后序子节点并返回失败。可以将Sequence节点理解为编程语言当中的‘‘&&’’(AND),即全部成功算成功,有一个失败都算失败。

FollowPlayer 执行完成时,其运行的结果会返回给父节点也就是Root ,Root节点是一个Selector节点,同样也是属于控制或组合节点中的一员,区别于Sequence,Selector节点在编程语言中对应的是“||(OR)”,即全部失败都算失败,有一个成功就算成功。更详细的说,当Selector节点的某个子节点返回失败时,Selector节点不会返回,而是继续向下执行其子节点直到某个子节点成功或当前子节点已经是最后一个子节点了。

此时我们假设FollowPlayer 的返回值为成功,那么Root就不会再去执行后续子节点GoToSomeWhere ,如果返回值为失败,那么Root 就要继续向下执行GoToSomeWhere 。这样排列链接节点就可以实现,当Player在视野范围内即PlayerInView 为真时Follow 跟随;当Player不在视野范围内即PlayerInView 为假时GoToSomeWhere 执行,设置目的地SetDestination 、等待几秒Wait 、然后移动到目的地MoveTo ,这就是这课行为树简单的逻辑。

不理解上面说的也没关系,接下来我们详细说明各部分。上述提到了两个控制节点,Sequence节点和Selector节点,下面给出所有的基本节点的描述,所有的节点基本上都由这些基本节点变化而来。

  1. 组合节点(Composite or Control)

组合节点是行为树的内部节点,它定义了遍历其子节点的方式
Sequence :序列节点,所有子节点成功返回成功,否则返回失败(&&/AND)
Selector :选择节点,有一个子节点返回成功则返回成功,所有子节点失败则返回失败(||/OR)
Parallel :并行节点,执行所有子节点,当有N个(count>N>0)子
节点返回成功则返回成功,所有子节点返回失败则返回失败。

除了上述给出的几个基本的组合节点之外,还有很多以此为基础的变种节点,例如ReverseSequence,RandomSequence,ReverseSelector,RandomSelector等等,可以根据实际需求对其进行更改。

  1. 根节点(Root)

Root根节点,行为树的入口。从实现上来说,Root节点可以是特殊的组合节点,或者就是单独的一个节点,这个节点不做任何处理,只能有一个子节点,仅仅作为行为树的入口。

  1. 叶子节点(Leaf)

叶子节点是整个行为树最外围的部分,没有子节点,执行具体的逻辑和判断
Action :动作节点,执行具体的逻辑,通俗来说,动作节点就是专门干活的节点,轮到我了我就执行,其余的事情与我毫不相干。
Condition :条件节点,执行判断,是控制行为树执行流程的重要部分。(也有一些行为树将condition节点作为修饰节点,我们这里不讨论这种情况)

  1. 修饰节点(Decorator)

Decorator修饰节点,自定义子节点的行为。例如Invert节点,反转其子节点返回的状态信息;Repeat节点,按照给定次数重复执行子节点;TimeOut节点,规定子节点的执行时间,超过某个阈值不论子节点状态如何直接返回失败。修饰节点有且只有一个子节点。

上述给出的关于各种节点的描述比较笼统且不唯一,不同的行为树的定义略有差别,理解即可。

一般来说,节点有三种基本状态,Success(成功)、Failure(失败)、Running(运行中) ,行为树中的节点每被触发一次(Tick),就会返回当前的状态信息给它的父节点。值得一提的是,当行为树处于Running状态时,这个在树中传递的Running状态一定是由某个Action节点返回的,极端点来说,只有Action节点才能真正处于Running状态,这很好理解,除了Action节点,其余节点都可以在一个Tick当中执行完毕,但例如Wait这样一个Action节点,就要跨越多个Tick执行。当某个节点要跨越多个Tick执行时,它就要返回Running状态,且行为树的任意一个Tick,有且只能有一个Running状态的节点。

现在让我们再把这个示例行为树拿出来看看。结合上面已经提供的信息和描述,我们现在能够在脑中轻易的模拟一遍执行过程,大家可以试试看。

由此我们引出关于行为树一个非常重要的部分,即中断机制(Abort) 。什么是中断机制呢?我们还是看回这个行为树,想象这样一个情况,当此行为树执行到GoToSomeWhereWait 节点时,Wait 节点开始执行并返回Running 状态,假如说Wait 节点设置的等待时间是两秒钟,那么不出意外的话,两秒钟过后,Wait 节点执行完毕向父节点也就是GoToSomeWhere 返回成功,然后GoToSomeWhere 执行下一个子节点也就是MoveTo 节点。

一切都很美好不是吗?可是不出意外的话意外发生了,当Wait 节点正在执行的时候,如果PlayerInView 节点的判断为TRUE了,此时我们应该怎么办?这可能会让人有些疑惑,我们不是在说Wait 节点吗?这里我们要这样想,要轮到Wait 节点执行,那前提一定是PlayerInView 节点返回FALSE了才行,因为行为树节点的执行有优先级,一般来说,父节点的优先级比子节点高,兄弟节点之中,左边节点的优先级比右边节点的优先级高,如果只考虑叶子节点的话,可以笼统的概括为优先级由左到右依次递减。

所以在示例行为树中,FollowPlayer 的子节点的执行优先级要高于GoToSomeWhere 的子节点,只有当FollowPlayer 无法执行了,才会轮到GoToSomeWhere ,换句话说,我们要保证优先执行优先级高的节点,放到示例行为树上来说就是当PlayerInView 为TRUE时,不论GoToSomeWhere 的子节点执行状态如何,我们都要立刻执行FollowPlayer 下的子节点,即当NPC发现了Player,那么不论当前NPC是在Wait 还是在MoveTo ,都要立即执行Follow ,这样才符合逻辑。

这里高优先级节点打断低优先级节点执行优先执行自己的机制就是中断机制(Abort) 。打断是高优先级打断低优先级,所以我们在设置中断的时候一定是在相对高优先级的节点上设置的,并且是在组合节点上设置的,话句话说,在打断的时候,我们要把打断后要执行的那一组兄弟节点看为一个整体,那么在示例行为树中,中断的设置就应该放在FollowPlayer 节点上。(这个地方可能说的有点绕,实际上手写过之后就会好理解很多)

理解了中断机制,我们就可以来详细说明一下中断的几种类型。Self、LowPriority、Both、Noone

  1. Noone

Noone即是不打断。

  1. LowPriority

打断低优先级的节点,因为中断是在组合节点上设置的,所以低优先级是相对于此组合节点的右边的兄弟节点及其子节点的。在示例中,FollowPlayer 是高优先级,GoToSomeWhere 是低优先级。

  1. Self

打断自身优先级低的子节点的执行,在示例中,如果FollowPlayer 下的Follow 正在执行的时候,Player离开了NPC的视野范围,也就是PlayerInView 为FALSE,那么就打断Follow 的执行。

  1. Both

LowPriority与Self结合。在示例中,FollowPlayer 如果设置为Both就很符合一个NPC的行为逻辑。

到这里,其实我们就可以尝试实现一个简单的行为树了,不过这时行为树的每个节点之间都还是孤立的,就比如如果一个节点的运行要根据上一个节点计算的值来执行的话,我们就没有办法,这个时候我们就要想一个办法,让节点之间共享数据

要在两个相互独立的节点之间传递数据,首先我们就不能让其中一个节点直接持有数据,而是应该把数据存储在一个任何节点都能够访问的地方,这样节点就可以去找到想要的数据进行读取或修改。这样一种存储共享数据的地方我们称之为Blackboard (黑板),而我们的数据就像是写在黑板上的东西,所有人都能看到并且修改,我们也称之为Blackboard Value (黑板值)。

Blackboard 具体到实现上其实也很简单,只需要用一个字典来存储黑板值,为了方便查找,一个key对应一个value,key就是黑板值得名称,而value自然是对应的实际值,不过为了用一个字典存储所有类型的黑板值,我们需要对数据做一层封装。除此之外,为了方便保存黑板值,我们应该使用ScriptableObject ,或者直接使用MonoBehavior 来存储,ScriptableObject 能够保存值很好理解,而MonoBehavior 能够保存值得原因也很好理解,因为MonoBehavior 作为组件会挂载在GameObject 对象上,这时就算停止运行了之后,写在MonoBehavior 里的字段值也会保留下来,这样我们就达到了保存值得目的,而本项目中也是使用了MonoBehavior来保存值。

了解了这些内容之后我们就可以写一个Runtime 版的Blackboard 了,至于为什么是Runtime 版,我们后面做Blackboard Editor 的时候再作说明。

行为树讲到这里其实就差不多了,具体实现都很灵活按照自己的想法实现即可,更进一步的话就是关于行为树设计方面的东西,这我也是刚刚接触不是很清楚就不做说明了。有了行为树的基本知识,我们就可以着手开始写一个简单的行为树了。这里我们不从头开始写一个行为树了,更多关于行为树的详细介绍请移步 行为树的理论与实践入门


UIToolkit

前面我们已经了解了行为树基本理论,假设当前你已经实现了一个行为树,大概率是有至少两个部分,一个是Node 即节点部分,一个是BehaviorTree 即行为树部分,一个BehaviorTree 持有一个RootNode ,通过去调用RootNodeTick ,从而遍历整个由若干个Node 构成的行为树。如果没有可视化编辑器的话,那么构造一个示例行为树的部分代码大概率长下面这样。

{
    Node FollowPlayer = new Sequence();
        FollowPlayer.AddChildNode(new PlayerInView());
        FollowPlayer.AddChildNode(new Follow());
    Node GoToSomeWhere = new Sequience();
        GoToSomeWhere.AddChildNode(new SetDestination());
        GoToSomeWhere.AddChildNode(new Wait());
        GoToSomeWhere.AddChildNode(new MoveTo());
    Node Root = new Selector();
        Root.AddChildNode(FollowPlayer);
        Root.AddChildNode(GoToSomeWhere);
    BehaviorTree tree = new BehaviorTree(Root);
}

看起来其实也还好,但是别忘了示例行为树还非常简单,但凡行为树的节点一多,那么手写构造行为树的代码简直就是噩梦。然而行为树本身很重要的一点就是可视化编辑,所以当把行为树的Runtime 基本写好之后,我们就可以开始考虑实现行为树的Editor

在Unity中实现这样的节点编辑器其实有很多选择,有很多第三方的插件可供选择,不过在本项目中我选择了Unity已经内置了的UIToolKit来实现整个行为树的Editor UI。关于UIToolKit,下面给出一个官方的解释

UI Toolkit是用于开发用户界面(UI)的功能、资源和工具的集合。你可以使用UI Toolkit为Unity编辑器、运行时调试工具以及游戏和应用程序的运行时UI开发自定义UI和扩展。

UIToolKit受网页开发技术的启发,通过类似于HTML+CSS的方法来构建UI,在Unity中对应的就是UXML和USS,简单来说,UXML规定UI布局,USS规定UI样式。在UI的实际开发过程当中,我们也主要是和这两个部分打交道。

UIToolKit提供了一个UIBuilder工具来帮助我们完成UI的布局,具体的UIBuilder应该怎么使用这里就不做过多的解释。

UIBuilder

而在本项目中关于UIToolKit值得一提的是USS的编写自定义控件 。首先我们来说USS ,编辑USS 的办法有两种,第一种是直接在UIBulider中通过选中一个VisualElement ,在其Inspector 面板的Inlined Style 下拉菜单中直接对当前的VisualElementStyleSheet 进行编辑,因为是直接编辑,所以叫Inlined ,或者将当前的Inlined Style 导出为一个单独的Class ,即一个 ,这里的类说的是一类样式,导出的好处就是可以在其他的VisualElement 上应用这个Class ,就不用每次都重新设置了。一个USS文件可以有很多类,这涉及到了后面要讲的USS Selector (USS选择器)。第二种就是直接编写USS的代码,例如:

.ClassName {
   PropertyName : PropertyValue
}

在一个USS文件当中,可以有很多个这样的结构,每一个结构描述了对应选择器 的样式,选择器 指定了哪些元素可以应用所描述的样式,这里的ClassName 就是选择器的一种,在官方文档中是这样描述USS Selector

选择器决定USS规则影响哪些元素。USS支持一组与CSS中的简单选择器类似但不完全相同的简单选择器。一个简单的选择器根据元素类型USS类元素名称通配符 匹配元素。您可以将简单的选择器组合成复杂的选择器,或者向它们附加伪类以瞄准处于特定状态的元素。USS支持子代选择器、子选择器和多个复杂选择器。

上面我们提到的.ClassName就是USS类选择器 ,下面我们大概了解一下不同选择器之间的区别。

  1. 元素类型

例如Label、Button、ListView等等,使用元素的类型作为选择器,只要元素类型相同就可以被选中。

  1. USS类

类选择器应该以英文句号”.“开头,类名由自己定义。当一个元素指定了多个类时,选择器只需要匹配其中一个类来匹配元素。

  1. 元素名称

元素名称选择器以”#“开头,后面接着的是元素的名称,例如#DescriptionContainer就匹配了一个名称为DescriptionContainer的元素。

  1. 通配符

通配符只有一个”*“, 顾名思义,就是可以匹配所有元素。

  1. 伪类

伪类缩小了选择器的范围,因此它只匹配进入特定状态的元素。例如:hover ,光标位于元素上方时被选择;:focus ,元素具有焦点时被选则。将伪类附加到简单的选择器以匹配处于特定状态的特定元素。

除了上面提到的基本的选择器之外,我们还可以组合不同的选择器,以达到精确指定元素的需求。

  1. 后代选择器 Descendant Selector

一个选择器后面空一格跟着另一个选择器
selector1 selector2 {...}
匹配的元素就是selector1下的任何满足selector2的元素。

  1. 子代选择器 Child Selector

子选择器由多个以>分隔的简单选择器组成。
selector1 > selector2 {...}
与后代选择器相似,区别在于,selector2必须是selector1的子类。

  1. 多重选择器 Multiple Selector

多重选择器由多个简单选择器组成,没有任何东西将它们分开
selector1selector2 {...}
多重选择器是多个简单选择器的组合。它选择匹配所有简单选择器的任何元素。

了解USS的编写之后,我们就可以针对不同的元素制定不同的样式,让我们的UI看起来美观且好用。通过在代码中调用AddClassToList 方法来启用一个Class,新加入的Class会覆盖旧的Class。更多关于USS 的内容请移步Unity Style Sheet (USS)

接下来就是关于自定义控件 的部分,像UIToolKit内置的Label、Button、ListView等等就是控件,如下图所示,就是UIToolKit内置的部分控件

我们可以通过排列组合不同的控件以搭建起我们想要的UI,一般来说,简单的UI使用内置的控件就足够了,但如果内置的控件不能满足我们的需求,我们也可以自定义控件。例如本项目所要用的到TwoPaneSplitView ,它属于一个Container控件,它被分成的两个部分,两个部分中间有滑条可以让我们自由调整两个部分相对比例大小

TowPaneSplitView 我们在UIToolKit的Library中找不到这样一个控件,这就需要我们自己写出这个控件来,好在UIToolKit并不是没有这样一个控件,而只是没有把这样一个控件暴露给UIBulider,所以我们先从把自定义控件暴露给UIBulider以方便我们可视化拖拽编辑开始。

using UnityEngine.UIElements;
//继承自默认的TwoPaneSplitView以定制自己的SplitView
public class TwoPaneSplitViewExposed : TwoPaneSplitView
{
	public new class UxmlFactory : UxmlFactory<TwoPaneSplitViewExposed, UxmlTraits>{ }
}

新建一个脚本,键入上述代码,返回到UIBulider当中,在Library的Project一栏当中我们就可以看到已经有TwoPaneSplitViewExposed 这一选项了。

这个时候我们就可以把这个控件拖拽进Hierarchy中进行显示了。而其中的关键就是这样一句代码

public new class UxmlFactory : UxmlFactory<TwoPaneSplitViewExposed, UxmlTraits>{ }

这句代码的意思就是通过UxmlFactory 这样一个工厂类,来生成一个临时的TwoPaneSplitViewExposed 供UIBuilder显示并使用。还有一个很重要的点就是,如果我们在Hierarchy中展开TwoPaneSplitViewExposed 我们会发现这个控件下的元素都不能被我们编辑,但是我们可以通过拖拽将控件放入指定的元素下,又或者在脚本中用代码来生成元素放入TwoPaneSplitViewExposed 中。

在上图中,LeftPane和RightPane以及LeftPane下的Label都是由代码生成的,而名称为#Drag的Label则是拖拽放入的。但不论是代码生成还是拖拽放入都只能是作为#unity- content-container的子级元素,这是由TwoPaneSplitViewExposed 的内部代码规定的,我们这里不做过多阐释。更多关于自定义控件 的内容请移步custom control

关于UIToolKit还有很重要的一个部分就是数据绑定 Data Binding,这个部分我们留到后面的部分在讲一讲,更多的细节请浏览官方文档SerializedObject data binding


Unity GraphView

通过学习UIToolKit,假设我们已经通过UIBulider搭建了一个行为树的布局界面

这个界面用到了两个TowPaneSplitView ,一个横向分割,分开了graphView 部分和InspectorBlackboard 组成的数据编辑部分;一个竖向分割,分割开了InspectorBlackboard 。现在我们还缺少一最重要的部分,就是右边部分的GraphView ,graphview的作用就是显示节点视图,并且可以让我们添加、删除、连接、复制节点等操作,所以说Graphview 是行为树可视化编辑最为重要的部分之一,接下来我们就详细说一说Graphview

首先Unity已经为我们提供了一个功能完善的Graphview 模块,我们只需要继承这个模块,就能够定义自己的Graphview 。要用这个模块我们就要引入此模块的命名空间

using UnityEditor.Experimental.GraphView;

现在我们就可以继承自GraphView 这个类。

public class BehaviorTreeNodeGraphView : GraphView{}

根据我们前面了解到的关于UIToolKit的内容,我们可以让我们的graphview暴露在UIBulider中,即加上下面一段代码

public new class UxmlFactory : UxmlFactory<BehaviorTreeNodeGraphView, UxmlTraits>{ }

这样我们就可以在UIBulider中通过拖拽将graphview加入UI中了。不过在这里我们不使用这样的方式,而是直接通过代码操作graphview或者其它元素的加入。在这之前,我们还是先讲一讲和Graphview本身相关的内容。

此时如果我们只是继承了Graphview 而不做任何改动的话,那么显示出来的graphview就什么也没有,一片空白,甚至没有第一张图展示出来的背景的格子,因为默认的Graphview就是一片空白,如果需要什么外观和功能都要我们自己添加。

所以现在我就把所有此项目中用到的功能和外观全都一一列举出来做大概说明。首先我们来为graphview添加网格和一些拖拽框选的基本功能。在构造函数当中我们写上以下代码

public BTNodeGraphView()
{
   //加载背景网格的USS文件
   styleSheets.Add(Resources.Load<StyleSheet>("NodeGraphGridBackground"));
   //设置视图滚轮缩放
   SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
   //添加拖拽、选择、框选Manipulator 固定搭配
   this.AddManipulator(new ContentDragger());
   this.AddManipulator(new SelectionDragger());
   this.AddManipulator(new RectangleSelector());
   //为视图添加背景网格
   var grid = new GridBackground();
   Insert(0, grid);
}

上述代码都是常规操作,无脑粘贴复制即可,值得一提的是第一行加载USS文件,需要把USS资源文件放到Resource 同名文件夹下才能使用Resources相关方法读取。其余例如添加Manipulator 的操作这里不做解释,有兴趣的自行百度。这里再放出背景网格的USS文件作为参考

GridBackground {
    --grid-background-color : #222222;
    --line-color: rgba(193,196,192,0.1);
    --thick-line-color: rgba(193,196,192,0.1);
    --spacing: 25;
}

有了上述代码我们的graphview就像模像样了,缩放、可拖拽、可框选、还有背景网格。接下来我们就要进行下一步,在graphview中添加节点Node

我们先使用一种粗暴简单的方法给Graphview 中添加Node ,因为实际的使用过程当中,添加Node这一操作还涉及到了其它很多方面,这里先按下不表,我们先来关注Node 本身。

Unity同样为我们提供了Node 的模板,与Graphview 在同一命名空间下,现在我们在Graphview 中写一个测试方法,以用来添加一个默认的Node

private void Test(){
   AddElement(new Node());
}

AddElement是添加GraphElement专用的方法。然后我们在构造函数中调用Test。

public BTNodeGraphView()
{
   ······
   //在构造函数中调用
   Test();
}

这里我们测试一下,效果就是下面这样。在graphview视图的左上角有一个小小的矩形,这个矩形就是我们添加的默认的Node ,而这个Node 的位置就是graphview的(0,0)点。

现在我们就可以试着对这个小小的Node,进行选中拖拽删除等操作了。不过很显然,默认的Node 根本不能达到我们的要求,所以我们就要试着自己定义一个Node。

GraphView 相同,自定义Node 同样要继承自Node

public class BehaviorTreeNodeGraph : Node{}

这时我们去

AddElement(new BehaviorTreeNodeGraph());

就与之前没有区别,因为我们并没有进行什么自定义操作。一个Node作为一个Node,首要的功能就是能够和其它Node相连才有意义,而两个Node相连需要的就是连接的端口Port ,接下来我们就为Node添加Port 。而这一系列操作都是在Node 的构造函数中完成的。

Port inputPort;
Port outputPort;
public class BehaviorTreeNodeGraph : Node
{
   public BehaviorTreeNodeGraph(){
      //添加输入端口
      inputPort = InstantiatePort(Orientation.Horizontal, Direction.Input,Port.Capacity.Single, typeof(Node));
      inputPort.PortName = "Input";
      inputContainer.Add(inputPort);
      //添加输出端口
      outputPort = InstantiatePort(Orientation.Horizontal, Direction.Output,Port.Capacity.Multi, typeof(Node));
      outputPort.PortName = "Output";
      outputContainer.Add(outputPort);
      //刷新 不然会有显示BUG
      RefreshExpandedState();
      RefreshPorts();
   }
}

其它的先不说,我们来看一下效果

非常好,现在我们已经有了Port 了,现在让我来解释一下是如何添加Port 的,核心的部分就是InstantiatePort 方法,这个方法是在Node 下的,作用就是根据参数创建一个Port,第一个参数Orientation ,指的是端口的方向,实际表现出来的就是,当我们连接两个节点的时候是上下(Vertical )连接还是左右(Horizontal )连接;第二个参数Direction,这个很好理解,创建输入端口就选Input,创建输出端口就选Output;第三个参数指的是允许连接到此端口的节点数量,Single 就是这个端口只能有一个连接,Multi 就是这个端口可以有多个连接;最后一个参数不做解释,默认传入一个类型即可。除此之外,要有一步就是把创建的Port加入到节点中,这里直接把Port 加入到对应的容器中即可。最后要刷新一下Node以免出现显示BUG。

有了节点有了端口,我们就可以连接两个端口啦!可事实上我们还不能连接两个节点,问题出在连接两个节点的时候我们还不知道这个连接是不是有效连接,也就是说,我们从一个端口拉一条连接线出来的时候,要知道有哪些端口是可以连接的。这个时候就需要在Graphview 中覆盖一个方法,这个方法就是GetCompatiblePorts ,翻译过来就是获取到兼容的端口,返回值是一个List 。所以我们就在Graphview 中覆写这样一个方法

public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
   //存储符合条件的兼容的端口
   List<Port> compatiblePorts = new List<Port>();
   //遍历Graphview中所有的Port 从中寻找
   ports.ForEach(
      (port) =>
      {
         if (startPort.node != port.node && startPort.direction != port.direction)
         {
            compatiblePorts.Add(port);
         }
      }
   );
   return compatiblePorts;
}

覆写此方法之后,我们再从某个端口拉一条连线出来的时候就会发现,不符合条件的端口就直接暗掉不能连接了,而符合条件的端口则可以正常连接。此时,我们就可以成功连接两个节点了。

值得一提的是,当我们连接了两个节点的时候,就是在Graphview 上创建了一个Edge 即一条边,而这个Edge 也属于是一种GraphElement,边上记录了两个连接的节点的信息。同时在Graphview 中有三个字段分别保存了视图上所有的节点和所有的端口以及所有的连接,分别是nodesportsedges ,并且我们可以对其进行增删改查,有很多操作也都需要用到这些字段,且看后面的内容。

这里只是简单讲了讲graphview最基础的部分,目前为止我们可以创建节点,并手动连接节点了。既然说到了手动连接节点,那么是不是就可以自动连接节点呢?确实,在这个项目当中,当我们打开一个行为树编辑器的时候,如果当前存在一个行为树,那么在打开编辑器后就应该显示出节点并且已经把所有的节点连接好了。说到底自动连接节点就是在GraphView 中创建一个Edge然后指定这个Edge的输入节点和输出节点即可。

public Edge MakeEdge(Port oput, Port iput)
{
   var edge = new Edge { output = oput, input = iput };
   edge?.input.Connect(edge);
   edge?.output.Connect(edge);
   AddElement(edge);
   return edge;
}

上面的代码是写在Graphview 中的,当需要自动连接两个节点,比如编辑器刚打开时,就会调用这个方法,传入两个需要连接的节点,然后创建Edge ,除了可以自动创建连线,自动创建节点也是必须的。

public BehaviorTreeGraphNode MakeNode(Vector2 position)
{
   BehaviorTreeGraphNode node = new BehaviorTreeGraphNode();
   node.SetPosition(new Rect(position, defaultNodeSize));
   AddElement(node);
   return node;
}

同样的,需要自动创建节点的时,就可以调用上面的方法。两个方法结合使用就可以自动根据已有的数据,创建出一颗可视化行为树出来。

最后一个关于Graphview 比较重要的部分就是SearchWindowSearchWindow 是干什么的呢?简单来说,SearchWindow 就是提供一个搜索树目录,这个目录中的各项在本项目中就是各种各样的节点,选择某个节点之后就创建选择的节点在视图上。具体长下面这样:

我们所有节点都被分类收录到了一起然后由SearchWindow 显示出来,但SearchWindow 只负责提供一个显示的平台,显示什么,怎么显示就是我们自己规定的。与graphview上其它元素不同的是,要创建一个SearchWindow ,要继承ScriptableObjectISearchWindowProvider

public class BehaviorTreeSearchWindow : ScriptableObject, ISearchWindowProvider
{
   List<SearchTreeEntry> ISearchWindowProvider.CreateSearchTree(SearchWindowContext context){
      List<SearchTreeEntry> searchTreeEntries = new List<SearchTreeEntry>();
      //添加至少一项 否则不显示
      searchTreeEntries.Add(new SearchTreeGroupEntry(new GUIContent("Behavior Tree Nodes"), 0));
      return searchTreeEntries;
   }
}

因为拥有了ISearchWindowProvider 这个接口,所以我们要实现CreateSearchTree 方法,这个方法的作用就是生成一组数据用以填充SearchWindow 。而其中的SearchTreeEntry 就代表了一个搜索树条目,并且数据条目的类型有两中,一种是SearchTreeGroupEntry 即一个搜索树条目组,另一种是SearchTreeEntry 即一个实际的搜索数条目。其实在更底层,这两种类型都是SearchTreeEntry

这里我们不讨论如何构造一个符合实际需求的搜索树目录,我们先让空白的SearchWindow 显示出来。我们在Graphview 中添加如下字段与方法

private BehaviorTreeSearchWindow searchWindow;
private void AddSearchWindow()
{
   //创建一个searchWindow的实例
   searchWindow = ScriptableObject.CreateInstance<BehaviorTreeSearchWindow>();
   //添加一个回调 当按下空格的时候调用
   nodeCreationRequest = context =>
   {
      //打开一个searchWindow
      SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), searchWindow);
   };
}

然后在Graphview 的构造函数中调用AddSearchWindow ,那么当我们打开编辑器按下空格键的时候,就会跳出一个空白的SearchWindow

关于Graphview 的内容暂且就讲到这里,因为很多功能都是多个部分相互穿插配合才能够实现,所以还有部分我们放到后面一一道来。


Unity Editor

现在行为树可视化编辑器的UI我们有了,Graphview视图我们也有了,接下来就是把这两个放到一起,然后用一个EditorWindow 把UI真正显示出来。在Unity中,任何窗口,例如Inspector、Animator、TimeLine等等都是EditorWindow ,我们现在也要把让一个EditorWindow 来显示做好的UI。新建一个脚本,引入UnityEditor 命名空间并继承自EditorWindow

public class BehaviorTreeEditorWindow : EditorWindow{}

现在我们就有了一个属于自己的EditorWindow ,其他的先不说,让我们把这个窗口打开显示出来。如果要打开一个自定义窗口,在Unity中有一个通用的方法。

[MenuItem("Window/AI/BehaviorTree Editor")]
public static void OpenEditorWindow()
{
   window = EditorWindow.GetWindow<BehaviorTreeEditorWindow>();
   window.titleContent = new GUIContent("BehaviorTree Editor");
   window.minSize = new Vector2(540, 480);
}

上面这段代码就是我们能够打开EditorWindow 的关键,首先这个方法必须是静态的,因为我们可以直接不持有窗口的实例直接调用此方法,在这个方法中,EditorWindow 下的GetWindow 就是打开一个窗口的核心方法,这也是一个静态方法,方法内剩下的部分就是在设置这个窗口的基本参数,比如窗口的名称和最小大小。现在我们在来看方法上的那一个属性MenuItem ,这个属性的作用就是告诉Unity,这个方法是一个菜单项,也就是说这个方法能够被我们在菜单的某一项里调用,而这个调用的具体位置就由我们自己来设定,我设定的位置就是Window->AI->BehaviorTree Editor。下面我们来菜单栏中找一找看是不是有这样一个选项。

成功了,现在我们就能打开一个EditorWindow ,并且这个窗口和名称和我们设置的一样,但是目前这个窗口一片空白,那是因为我们还没有加载做好的UI。

现在我们就来加载做好UI界面,我们在BehaviorTreeEditorWindowOnEnable 方法中写加载UI的功能,每次打开窗口的时候都会OnEnable 被调用。添加以下代码。

//加载主界面
VisualTreeAsset editorViewTree = Resources.Load<VisualTreeAsset>("BehaviorTreeEditorWindow");
TemplateContainer editorInstance = editorViewTree.CloneTree();
editorInstance.StretchToParentSize();
rootVisualElement.Add(editorInstance);

首先我们要把我们用UIBulider做好的UI界面的资源文件加载到内存中,这个资源文件其实就是UXML 文件,加载进来的UXML 文件会被一个VisualTreeAsset 的实例保存为VisualElementAsset 树,UXML 文件中的每一个节点都是一个VisualElementAsset 。需要注意的是,我们需要把UXML文件放到Resources 文件夹中,并且在读取的时候,文件名后面不需要文件后缀,只需名称即可。然后我们将加载进来的UXML文件调用CloneTree 进行克隆,克隆后返回值是一个TemplateContainer 类型的,这个类保存了当前克隆的VisualTreeRoot 即保存了根。然后调用StretchToParentSize 让UI填满整个父级窗口,这里的父级窗口自然就是整个窗口了,最后把获取的UI的根元素,加入到窗口的根元素下,这样我们就完成了主界面UI的加载。

非常好,确实将我们做的UI界面加载了进来,不过似乎Graphview 并没有被加载,这很正常,因为我们之前并没有在UIBulider中添加Graphview ,况且我们也并没有把Graphview 暴露给UIBulider。现在我们依旧用代码将Graphview 加载进来。

//加载节点视图
nodeGraphView = new BTNodeGraphView(this);
nodeGraphView.StretchToParentSize();
rootVisualElement.Q<VisualElement>("RightPane").Add(nodeGraphView);

这与加载主界面UI类似,先创建一个Graphview 的实例,然后将这个界面也设置为填充父级的窗口,不过这最后一步稍有不同,此时我们的EditorWindow中已经有各种UI元素了,现在我要确定哪一个元素是Graphview 的父级元素,在这里显而易见是名称为“RightPaned”的元素,所以我们使用Q 方法找到这样一个元素,Q 的是Query 的缩写也就是查找,然后把Graphview 加入RightPane中作为其子级元素。

Graphview 被正确的加载进来了,我们添加的功能也都可以正常执行,不过我们现在还没有做在编辑器界面添加节点的功能,并且左边的InspectorBlackboard 以及ToolBar 上的各种按钮也没有利用起来,稍安勿躁,我们一步一步来。不过我不打算讲的太细,因为内容确实很多,我也不想篇幅过长,所以我们挑一些很重要的地方说一下。

在我们的设想中,添加节点的时候我们是打开SearchWindow 然后选择要创建的节点,虽然现在SearchWindow 我们已经可以打开了,不过并没有任何东西,在这里我先跳过SearchWindow 获取所有节点并生成搜索树目录的部分,因为这部分更多的还是涉及到C#语言的部分,这个部分我们主要讲解与UnityEditor相关的部分。

假设现在我们已经获取到了所有节点,并且可以生成选择的行为树节点了。在生成行为树节点的时候,我们也应该生成不一样的节点图,换句话来说,我们要做到每一个不同的节点要有一个唯一的对应的节点图。这么说很容易把人搞混乱,我们先规定一下,能够显示在Graphview 上的继承自Node 的节点,我们称之为graphNode ,实际参与行为树运行,有具体数据和逻辑的继承自MonoBehaviorScriptableObject 的节点,我们称之为monoNode 。我们在Graphview 上创建的节点是graphNode ,那也就意味着要有一个monoNode 与之对应,这就是我们所说的一个graphNode 对应一个monoNode ,也就是说我们在GraphView 上创建graphNode 的同时也要创建一个monoNode

假设这里我们创建graphNode 的时候也会自动添加一个monoNode ,那么现在我们就可以在GraphView 上用可视化的方式连出一颗行为树了。

最后的效果,仅做参考

现在让我们把目光放到编辑器的左边,InspectorBlackboard ,选中一个graphNode 时,Inspector 会显示对应的monoNode 的序列化字段并且我们可以进行编辑。
Blackboard 允许我们创建或删除一个Blackboard Value

先来讲Inspector ,假设我们可以获取到当前选中的graphNode 对应的monoNode ,那么我们就把这个monoNode 当中的序列化字段 显示到Inspector 中。要显示序列化字段 ,就像我们选中一个GameObject 的时候在Unity编辑器的Inspector中显示的挂载的组件上的各种字段一样。关键在于一个在UnityEditor.UIElement命名空间下的类,PropertyField ,和Label与TextField等相似,都是属于一个UI控件。

PropertyField 可以将序列化字段可视化出来,并且在绑定了数据源后,可以编辑并修改绑定的字段。除此之外,如何获得一个脚本的序列化字段也很重要,其中发挥关键作用的是两个类,SerializedObjectSerializedProperty 。现在我们就使用这三个类来将monoNode 中的序列化字段显示到Inspector 上。

//获取到当前节点中所有序列化数据
SerializedObject serializedNode = new SerializedObject(monoNode);
SerializedProperty nodeProperty = serializedNode.GetIterator();
nodeProperty.Next(true);
//遍历所有序列化数据
while (nodeProperty.NextVisible(false))
{
   //构造一个PropertyField用于显示
   PropertyField field = new PropertyField(nodeProperty);
   //与实际的节点数据绑定
   field.Bind(serializedNode);
   //加入到Inspector
   nodeInspector.Add(field);
}

上述代码主要干的事情就是,先用SerializedObject 拿到monoNode的所有序列化数据,然后再使用SerializedProperty 一个个的遍历这些序列化字段,之后用PropertyField 绑定到字段对应的节点并加入Inspector 面板中显示此字段,此时我们当前选中的节点的可视化数据就显示出来了。其实实际项目中的代码不会这么简单,这里只是核心部分。

说完Inspector ,我们来说一说Blackboard ,Blackboard的实现其实可以与Inspector相同,不过我在项目中还额外使用到了ListView ,ListView中的每一项就是一个Blackboard Value ,要往ListView 中添加元素有三步,第一步makeItem ,第二步bindItem ,第三步指定itemsSourcemakeItemListView 下的一个Func ,可以理解为返回值为VisualElement 的委托或者回调函数,而makeItem 就是在ListView中生成Item时调用,作用就是以我们自己定义的方式生成一个Item,这里生成的Item是一个VisualElement

makeItem 之后,ListView中就多了一个空白的VisualElement ,此时我们要使用bindItem 来为这个空白的VisualElement 填充内容。bindItem 是一个Action ,即是一个有两个参数的事件或回调函数,这里的参数VisualElement 是我们使用makeItem 生成的,而int 参数则代表了,当前是ListView 中的第几项,这个时候我们就可以为空白的VisualElement 中添加元素了,比如添加一个Label ,再添加一个PropertyField

最后我们再将itemSource 设置为我们在Blackboard中保存值的List即可。多说无益,上代码。

private ListView variableList;
variableList.makeItem = () =>
{
   TemplateContainer variableViewInstance = variableViewTree.CloneTree();
   return variableViewInstance;
};
variableList.bindItem = (item, index) =>
{
   item.Q<Label>("variableName").text = treeBlackboard.variables[index].key;
   SerializedObject serializedObject = new SerializedObject(treeBlackboard.variables[index]);
   SerializedProperty property = serializedObject.FindProperty("val");
   item.Q<PropertyField>("field").label = "";
   item.Q<PropertyField>("field").BindProperty(property);
   item.Q<PropertyField>("field").Bind(serializedObject);
};
variableList.itemsSource = treeBlackboard.variables;

上面的代码是项目中的代码,makeItem 中的variableViewTree 是加载的UXML文件,然后用这个UXML文件生成的VisualTree作为一个Item。bindItem 中干的事情其实就是,在item中填入并绑定第index个BlackboardValue 的名称为“val”的序列化字段。最后将这个ListView的数据源设置为Blackboard 中存放实际值的List 。这样我们就能够在Blackboard 面板中显示所有的Value

可以对照一下代码,看具体是怎么生成ListView 中的每一项的。关于Blackboard其实还有很多很多可以讲的,在实际的项目中Blackboard的实现相对来说也是很复杂的,而且在设计上也很有意思,不过这里我只是大概的讲一讲,如果有时间,我也许会写一篇详细的关于Blackboard的博客。

到这里关于Unity编辑器还有很重要的一个部分,就是自定义属性绘制,CustomPropertyDrawer ,在什么情况下我们需要自定义属性绘制呢,就比如一个float值,默认在Inspector上是显示为一个名称和一个field,我们可以在这个field中输入任何一个值,但是如果我们想让这个float值限制在某个范围,我们除了可以在OnValidate 方法中限制之外,还可以用Range 属性来规定范围,并且Range 属性在修饰float值后,还会在inspector中显示一个滑条,方便我们靠拖拉滑条修改float的值。那么像这种效果我们使用PropertyDrawer 覆盖原有字段的绘制也可以实现,而在本项目中使用到PropertyDrawer 的地方就是显示node中的引用Blackboard Value 的部分

[CustomPropertyDrawer(typeof(targetProperty), true)]
public class targetPropertyDrawer : PropertyDrawer
{
   public override void OnGUI(Rect position, SerializedProperty property, GUIContent label){}
}

上述代码就是修改某个属性(targetProperty)的绘制方式的基础代码,首先就是CustomPropertyDrawer 属性,这个属性就是告诉Unity,我们覆盖绘制的目标是谁,属性中的第二个参数的意思就是当前这个类的子级的字段绘制也要覆盖掉。然后我们需要继承自PropertyDrawer ,最后在OnGUI 中实现我们自己的绘制代码,如果就拿上面的代码去覆盖某一个属性的绘制的话,那么最后什么都不会显示,因为我们根本没有在OnGUI 中写任何的代码啊。

上图红色方框内就是使用PropertyDrawer 实现的自定义属性绘制,绘制的是Node中声明的Blackboard VariableReference ,现在我们就可以通过下拉选项框来选择Blackboard 中同一类型的值了,方便又直观。如果要在Node中访问Blackboard值就要使用对应类型的VariableReference ,这是blackboard系统设计上的内容,这里就不做过多说明。总之,如果要深入定制自己的编辑器,PropertyDrawer 是必不可少的。更多内容移步官方文档PropertyDrawer

讲到这里Unity Editor 部分的内容就差不多了,其实还有很多本项目中涉及到的关于Unity Editor 的内容没有讲到,但是这部分的内容多且杂,全部讲完也不现实,我们继续讲解后面的部分。


C#语言

终于要开始讲C#语言部分了,上面讲的大多数功能其实都离不来C#语言的一些很好用的特性,比如反射属性Linq 等等,C#语言也是非常优秀的一门编程语言,相比于C++来说牺牲了部分性能,但是换来的确是非常舒适的编写体验。接下来我就讲一讲本项目中各个部分在实现过程中应用到的C#的语言特性。

  1. 属性(Attribute)
    以下是我对Attribute的一些粗浅的理解

应该如何理解C#中的特性(Attribute)?先用一句话总结一下,特性就是用来对C#中的类、字段、属性、方法、甚至是特性自身添加额外的信息和行为的一种方式。为什么说是添加“额外”的信息和行为呢?因为一个元素使用Attribute后实际上会在编译后的元素内部产生IL语言,并且在Metadata中会有记录。

与我们的代码注释有点相似,只不过代码注释是给开发人员看的,而Attribute可以理解为是给编译器看的注释,即注释不能直接影响程序的运行,但是特性Attribute可以。

实际上,特性也是一个类,即不论是框架自带的特性还是我们自定义的特性,都需要继承自Attribute类,值得注意的是,我们可以在自定义的Attribute中进行任何对于一般类的操作,写属性写字段写方法等等,因为Attribute就是一个类,我们不要因为他的特殊性就设定很多的条条框框。Attribute与一般的类不同的点就在于,我们在代码阶段我们就要确定我们写的Attribute中所含所有信息,列如字段属性的类型、值、方法的参数、返回值等等,因为在编译后Attribute就不能再被动态的修改了,就像泛型那样。

总之特性可以在不破坏类型封装的前提下,添加额外的信息和行为。

回到项目当中,我们为节点添加属性,让编写的节点能够被识别到。下面我们来看一个简单的行为树节点。

    [BTNode("Example/PrintLog", "打印日志信息")]
public class PrintLog : BTNodeAction
{
   public string logMsg;
   public override NodeResult Execute()
   {
      Debug.Log(logMsg);
      return NodeResult.success;
   }
}

上述代码中的首行代码就是本项目中用来标记一个节点的属性,当节点被这个BTNode 属性标记之后,我们就可以在SearchWIndow中通过反射来获取到这个节点上属性中的信息,从而帮助我们构造搜索树目录,具体实现我们放到后面,现在我们先来看看这个Attribute 的实现

    [AttributeUsage(AttributeTargets.Class)]
 public class BTNodeAttribute : Attribute
 {
 	//节点分类路径
 	public string NodePath { get; set; }
 	//节点描述
 	public string NodeDescription { get; set; } = "";
 	public BTNodeAttribute(string nodepath)
 	{
 		NodePath = nodepath;
 	}
 	public BTNodeAttribute(string nodepath, string description)
 	{
 		NodePath = nodepath;
 		NodeDescription = description;
 	}
 }

上述代码就是整个BTNodeAttribute 的实现,很简短,但很有用,我们来具体看看。首先C#中的Attribute 本质上也是一个类,只不过我们自定义的Attribute 要继承自Attribute 基类,然后我们看第一行,同样是个Attribute ,只不过这个Attribute 是修饰AttributeAttribute ,这个名叫AttributeUsage 的作用就是规定当前我们自定义
Attribute 的修饰范围,是只能修饰类,还是只能修饰方法,还是只能修饰字段,或者都可以修饰。在这里我们规定自定义的Attribute 只能修饰类。

在这个BTNodeAttribute 中我们声明了两个字段,一个字段记录一个路径,这个路径规定了被修饰节点在SearchWindow 中搜索树目录的路径以及名称,而第二个字段就是记录了一些被修饰节点的描述。现在再来看示例节点上的BTNoddAttitude 就一目了然了。

    [BTNode("Example/PrintLog", "打印日志信息")]

“Example/PrintLog”是路径与节点名称,”打印日志信息”是节点描述。这个时候我们有了属性,属性里面也有了各种信息,但其实如果我们不进行进一步的操作的话,就什么用也没有,也就是说Attribute 不会对类产生任何影响,除非我们主动去访问里面的信息或方法,是的,Attribute 也能够实现方法,只不过这个方法也需要我们主动调用。

那么我们怎么去访问属性中的字段与方法呢?这个时候就要引出下一个部分的内容了,反射Reflection

  1. 反射(Reflection)
    关于反射,以下是一些大概的解释

反射是.Net中获取运行时类型信息的方式,.Net应用程序由几个部分组成:程序集(Assembly)、模块(Module)、类型(class)组成,而反射提供一种编程方式,让程序员可以在运行时期获取这几个组成部分的相关信息。

Assembly类可以获得正在运行的装配件信息,也可以动态的加载装配件,以及在装配件中查找类型信息,并且创建该类型的实例。Type类可以获得对象的类型信息,此信息包含对象的所有要素:方法、构造器、属性、字段等等,通过Type类可以得到这些要素的信息,并且调用。除此之外,还有列如FieldInfo、EventInfo等等,这些类都包含在System.Reflection命名空间下。

其实关于反射,理论方面的东西比较复杂,不过我们现在主要关心应用,总的来说,反射就是让程序自己了解自己的一种方法,就像我们化的妆要靠镜子反射才能看到一样,虽然这么比喻不怎么正确。废话不多说,我们直接上代码,下面这段代码就是我们实现SearchWindow 构造搜索树目录反射部分的代码。

    public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
{
   ······
   //通过反射程序集找到所有继承自BTNodeBase的类 也就是找到所有节点类
   List<Type> types = new List<Type>();
   foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
   {
      List<Type> result = assembly.GetTypes().Where(type =>
      {
         return type.IsClass && !type.IsAbstract && type.IsSubclassOf(typeof(BTNodeBase));
      }).ToList();
      types.AddRange(result);
   }
   ······
}

现在我们一行一行代码来看,首先我们声明并初始化了一个List ,这个List 存储的值是Type ,这个Type 类型又是什么类型呢。

Type类,用来包含类型的特性。对于程序中的每一个类型,都会有一个包含这个类型的信息的Type类的对象,类型信息包含数据,属性和方法等信息。

根据上述描述我们可以知道,Type 记录了类的各种信息,那我们接着看下面的foreach循环,首先选择的循环对象是当前程序域AppDomain 中所有的程序集AssemblyAssembly 是什么呢,在前面我们提到Assembly类可以获得正在运行的装配件信息 ,那装配件又是什么呢?似乎这里涉及到的东西一层套一层难以理解,那么我们干脆就从C#程序的构成讲起。

C#应用程序结构包含 应用程序域(AppDomain),程序集(Assembly),模块(Module),类型(Type),成员(EventInfo、FieldInfo、MethodInfo、PropertyInfo) 几个层次

他们之间是一种从属关系,也就是说,一个AppDomain能够包括N个Assembly,一个Assembly能够包括N个Module,一个Module能够包括N个Type,一个Type能够包括N个成员。他们都在System.Reflection命名空间下。

从上图中我们不难看出程序集Assembly的组成。

综合上述描述,我们现在就应该能够理解循环中我们在干什么了。我们先从AppDomain 中获取一个个Assembly ,获取到某一个Assembly 后我们调用GetTypes 获取到当前程序集中所有的Type 也就是获取到所有的类,此方法的返回值是一个数组,之后我们使用Where 查找返回所有符合要求的类,这个属于C# Linq 的内容我们之后在讲,总之Where 的作用就是构造并返回符合给定要求的可枚举列表,简单来说就是起到了一个筛选的作用,在这里我们给定的要求是,首先要是一个类,其次,这个类不能是抽象类,最后,这个类要是节点基类的子类。这样一来我们就获取到了所有的非抽象类的节点类。最后这个Assembly 的节点类获取完后我们就去找下一个Assembly 的节点类,直到我们找到了所有的节点类。

看,反射就是如此神奇,我们不用手动去找所有的节点类,只需要依靠反射就能够找到项目中所有我们定义的节点。找到所有的节点之后我们就可以进行下一步操作了,即去获取我们在节点声明的属性BTNodeAttribute 中的内容了。

    foreach (Type type in types)
{
   //获取节点属性的NodePath
   string nodePath = type.GetCustomAttribute<BTNodeAttribute>()?.NodePath;
   if (nodePath == null) continue;
   //将路径中每一项分割
   string[] menus = nodePath.Split('/');
   ······
}

首先我们遍历所有我们找到的节点类,然后通过调用GetCustomAttribute 方法来获取我们指定的自定义属性,获取到属性之后我们就可以放心大胆的去拿到我们在属性中定义的字段后者方法了,在这里我们就拿到了BTNodeAttribute 中的NodePath 字段,至此我们就可以对拿到的字段做进一步处理方便我们构造SearchWindow搜索树目录 了。

具体怎么构造SearchWindow搜索树目录 这里不做过多阐述,大概思路就是把获取到的路径分割为一个个目录项进行遍历,当前目录项存在就前往下一层,如果当前目录项不存在就判断,如果当前项是目录项就构造一个新的目录项,如果不是说明是实际的节点名称,就构造一个节点项。

至此,我们通过设置属性和使用反射就把所有的节点类获取到并且以此构造了我们的SearchWindow搜索树目录 了。

  1. Linq(Linq语法)
    在项目中我们还用到了C#语法中比较牛叉的一个部分,那就是Linq ,那啥是Linq 呢?

Linq 全称 Language Integrated Query,语言集成查询 ,是一种使用类似SQL语句操作多种数据源的功能。

这么解释比较抽象,如果你了解过SQL的话那应该就比较好理解,如果没有了解使用过SQL的话,比如我,就这么理解Linq

Linq就是C#中为我们封装好的一堆查询方法,查询的对象就是各种可以遍历的数据源,比如List、Array、IEnumerable等等。

我们只需要提供核心的比较、修改等方法,就可以对整个数据源进行自定义的遍历。话不多说,我们上代码。

    public void ClearNodeGraph()
{
   foreach (var node in nodes)
   {
      //Remove Edges
      edges.ToList()
            .Where(x => x.input.node == node)
            .ToList()
            .ForEach(edge => RemoveElement(edge));

      //Remove Node
      RemoveElement(node);
   }
}

上面这个方法的作用是清除GraphView 上所有的元素,其中就运用到了Linq ,我们来看清除Edges的代码,首先将Graphview 上的edges转换为List ,然后使用Where 遍历找到与当前节点的输入端口连接相同的节点,找到后再ToList ,最后使用ForEach 遍历获取到的edges,然后从Graphview 上移出。整个过程非常的丝滑,一步步的就将我们需要的部分查询了出来,并且中途直接使用返回值来进行下一步操作,如果这部分我们自己写循环实现的话,差不多三十行代码起步,但是使用Linq 就能够节约大量时间,并且让我们专注于筛选查找的逻辑以便我们更精确的查找到我们想要的部分。

Linq 的魅力还远不止于此,我们上述说的只是其中很小一部分,Linq 作为C#中的一大特性,不经可以操纵程序中的数据源,还可以访问并操纵非程序的内容比如,对外部数据库进行查询,或者是XML等等,这里给出一个比较专业的解释

Linq to Object。提供程序查询内存中的集合和数组。
Linq to DataSet。提供程序查询http://ADO.NET数据集中的数据。
Linq to SQL。提供程序查询和修改Sql Server数据库中的数据,将应用程序中的对象模型映射到数据库表。
Linq to Entities。使用linq to entities时,会在后台将linq语句转换为sql语句与数据库交互,并能提供数据变化追踪。
Linq to XML。提供程序查询和修改XML,既能修改内存中的xml,也可以修改从文件中加载的。

所以说Linq是很强大的,有兴趣的朋友可以自行搜索相关教程进行学习。

那么在本项目中所使用到的C#相关的内容就这么多,或许还有一些零零星星的小点没有讲到,这里就不花篇幅一一讲解了。其实C#还有很多有意思的地方,协程、泛型、委托等等,这部分的内容大家就自行了解吧。


结语

终于,关于如何从零开始开发Unity行为树插件到这里就接近尾声了,没料到最后竟然写了这么多,一开始打算的是就写一写开发过程当中的心得避免直接忘记,但是写着写着发现写的有点详细了,毕竟是第一次写博客,在内容的规划上还是没有经验。

本文中涉及到的几个部分其实如果单独拿出来讲的话都可以写一个系列博客了,有机会的话,我也会深入研究研究。

最后感谢您能够看到这里,如果这篇博客能够对你有所帮助,那是我莫大的荣幸。如果您还有任何的问题的话欢迎在评论区留言,我会竭尽所能回答,当然,如果您觉得本文有任何不妥之处也欢迎指出。(2023/08/15)

内容
  • 一文揭秘DDD到底解决了什么问题
    一文揭秘DDD到底解决了什么问题
    2023-12-01
    DDD作为架构设计思想帮助微服务控制规模复杂度,那它是怎么做到的呢?.一、架构设计是为了解决系统复杂度.谈到架构,相信每
  • Unity实现3D物体遮挡血条
    Unity实现3D物体遮挡血条
    2023-12-08
    Unity 实现3D物体遮挡血条.######.前言:在游戏开发中,我们经常会遇到UI和3D物体的层级遮挡问题,最常见的
  • 一个公式让你35岁以后能越过越好!大神修炼心法
    一个公式让你35岁以后能越过越好
    2023-12-08
    前言.Cocos 的老铁,如果你这几天没有被麒麟子给卷到?那说明你还没有真正进入 Cocos 圈子里来。为什么这么说呢?
  • C++学习-static
    C++学习-static
    2023-12-02
    全局变量使用:.作用是限定全局变量的作用范围,只能在当前文件使用,类似给它加了个private属性。.其他文件即使使用e
  • 【Oculus Interaction SDK】(五)设置不同的抓握手势
    【Oculus Interact
    2023-12-10
    前言.前段时间 Oculus 的 SDK.频繁更新,很多已有的教程都不再适用于现在的版本了。本系列文章的主要目的是记录现
  • 吃透单调栈(2)——解两道Hard题:接雨水、柱状图中最大的矩形问题
    吃透单调栈(2)——解两道Har
    2023-12-04
    怎么想到要用单调栈的?.这类题目的数据通常是一维数组,要寻找任一个元素的右边或者左边第一个 比自己大 或者小 的元素的位