
在我们设计敌人等NPC的行为时,一定会遇到一些复杂的逻辑。
例如,以敌人为例,现在有下面一系列逻辑:
当敌人的视觉感官到玩家时,敌人进入追踪状态。
当敌人的听觉系统听到队友报点时,敌人进入追踪状态。
当敌人的听觉系统听到玩家脚步声时,敌人进入追踪状态。
在敌人追踪玩家过程中,若丢失了视野和报点信息,则扔朝玩家方向追踪,直到一段时间内视觉和听觉都没有捕捉到玩家,则返回空闲状态。 这段时间如果又重新看到或听到玩家信息,则回到追踪状态,继续持续追踪。
敌人追踪过程中,距离玩家在一定范围内后减速步行。
敌人继续距玩家一定距离后停止超玩家行进,改为左右走位。
敌人在左右走位时,找准时机,向玩家突进并进行攻击。
这一套逻辑互相牵扯,并且拥有各种条件判断。如果我们直接用代码,各种 if、switch 和嵌套,那么估计写到一半就很难再写下去了,并且回头看时,也很难再看懂自己写的一堆代码。
对于这种情况,我们就需要用到 “状态机” 和 “行为树” 这两种技术了。 其实 “状态机” 和 “行为树”都是一种专门用于处理逻辑关系的框架。
既然是一种框架,那么当我们拥有了之后,再遇到任何复杂的逻辑关系,都可以通过它来快速的实现。并且,“状态机” 和 “行为树” 通常指的还是一种可视化方案,就像 ShaderGraph 一样,或者说就像 虚幻的蓝图,又或者说我们可以像制作思维导图一样便捷快速的来制作我们的逻辑代码。
说道状态机,就肯定会拿Unity自己的动画状态机举例。Unity的动画状态机就是一个增强版的状态机。一开始我们处于某种状态,当满足一定条件后我们进入其它状态,这就是状态机的标准逻辑流程。

AnimationController
行为树按照我的个人理解,就是强化版的状态机。
我们一开始处于一个大的状态,而每个大的状态下面又有许多小的状态或者说判断条件。根据我们的大状态所设置的条件,进入到小状态中进行条件判断,之后向大状态返回信息。 若大状态根据收到的信息,决定逻辑是进入其它状态还是保持该状态。(大状态设置的条件包括:是所有小状态的条件判断都成立才可转为其它状态还是只有一个小状态条件判断成功即可、各个小状态之间进行判断的先后顺序等等)。
那么对于行为树,我们通常会在 AssetStore 上购买较为成熟的行为树框架,或者自己写。
对于购买,在 AssetStore 上有许多非常成熟的行为树框架:

直接在 AssetStore 上搜索 Behaviour 就可以找到行为树相关插件

“Behavior” 插件。功能丰富且非常美观

“Behavior Designer”插件。可以看出能够胜任非常复杂的逻辑系统。
而对于自己来写一个行为树框架,这里我只说一下大致思路:
首先要摸清行为树的逻辑结构,设计好整个行为树的数据结构。
面向对象写代码。我们可以让 “条件判断” 这一行为作为一种对象,该对象必须有 “进行条件判断” 这一方法。 所以,我们可以先做一个抽象类(abstract),通过抽象类定义的抽象方法来规范对象行为。 同时,我们可以让抽象类继承自 ScriptableObject 这个类,这样配合着 CreateAssetMenu 特性,我们就可以像创建一个物体一样,来创建新的对象。 同样的道理,对于 状态(State)、任务(Task)、节点(Node) 等等一系列定义,进行对象化。
对 UnityEditor 编写要非常熟悉。 设计好逻辑和结构之后,就是要可视化的部分了。可视化就少不了 编辑器的编写。 我们可以在一个大窗口中,让每个行为树节点作为一个小窗口,并且每个窗口都保存着上面提到的 ScriptableObject 对象,然后当行为树制作完后再将所有的 结构和数据 存入到一个 ScriptableObject 对象中(行为树对象),这样,我们就可以得到一个 制作出来的行为树物体了。
可以看出,自己手写行为树框架如果想写的比较丰富,那么是一件工作量较庞大且复杂的事情。
OK,进入我们的主题。上面我有说道,Unity的动画状态机是一个强化版的状态机,那么行为树也是个强化版的状态机。那么能不能用动画状态机来作为行为树呢?这里我做了尝试:
以开头说道的,敌人行为逻辑为例,我在敌人身上挂载了一个动画状态机。该状态机有两个状态层:一个是“动画层”,另一个是“行为树层”:

Base Layer 是动画层,FSM Layer 是行为树层
顾名思义,动画层存放的就是敌人的各种动画,用于动画直接的切换。 而 行为树层 就是专门负责逻辑之间的切换,和各个逻辑状态下任务的执行。
我们重点关注 行为树层:

行为树层
该层的所有状态节点都是没有动画的:

可以看到,Motion 为 None
且该层的权重(Weight) 为 0。
既然权重为0,那么是不是该层的状态就不会执行呢? 答案是:仍会执行。动画状态机中的层级权重(Weight) 指的是动画所占权重,当该层权重为0时,该层的所有状态和状态间的逻辑仍会进行计算,只是该层的状态动画对于最终的动画效果不起作用。
再来看一下我这个动画状态机的布置:

敌人的行为逻辑
Idle 状态,为敌人的初始状态。
当敌人的视觉或听觉感官获取到玩家的位置信息后,进入到 Track 状态,即追踪状态。
追着追着如果怂了(例如发觉前方的队友都被玩家干掉了),则进入 Escape 状态,逃跑状态。
如果追踪时丢失了玩家位置信息,则进入 Lose 状态,在 Lose状态 持续丢失信息一段时间后回到 Idle 状态,否则回到 Track 状态。
当追踪到离玩家较近的距离时,进入到 NearPlayer 这一个大的状态中。
为什么说 NearPlayer 是一个 “大状态” 呢? 因为 NearPlayer 是一个 Sub-State Machine 即子状态机 或者说是 状态机中的状态机。
这么听可能会感觉有点懵。 我们可以通过 右键-> Create Sub-State Machine 来创建一个子状态机。来看一下我这个 NearPlayer 子状态机究竟是什么:

通过双击子状态机,可以查看子状态机的内部
会发现,它就是一个状态机(好像有点废话)。
Entry 指向的状态,就是该子状态机 外部状态 进入时的默认状态。 即,当我们从 Track 状态 转到 NearPlayer 子状态机时,如果没有明确的指定 NearPlayer 状态机中的具体状态,那么就会默认转到 Walk 状态。
之后就是这一套逻辑:
在 Walk 状态就是缓步接近玩家。
距离玩家一定距离后,进入 观察状态(Watch),即不主动出手攻击玩家,而是先走位,寻找合适的时机。
找到合适时机后,进入 攻击状态(Attacking),攻击玩家,攻击完毕回到 观察状态。
在观察状态时,如果玩家距离自己过远,则回到 Walk 状态,如果更远,则跳出该子状态机,回到 Track 状态,追击玩家。
这样,配合着权重(Weight)为0的动画机层级,和 子状态机(sub-state machine),我们就可以通过动画状态机来在敌人身上设计敌人的行为树逻辑了。
但是,现在我们的行为树只有状态和条件转换,并没有“行为”。
例如,在 Track 状态,我们的敌人要执行 “追击玩家” 这一行为、在 Watch 状态,敌人要执行 “走位寻找时机” 这一行为。 这些行为,都是我们要用代码来具体实现的,并且我们的 状态 要能够去调用我们实现的行为。
可以发现,在动画状态的 Inspector 面板,我们可以 “添加行为”

最下面的 Add Behaviour 按钮可以添加行为
点击之后,Unity 会自动的帮我们创建并挂载到该状态上一个脚本。
让我们来看一下这个脚本的结构:

Unity 为我们创建出的 Behaviour 脚本
首先,该脚本继承自 StateMachineBehaviour 类
该脚本有很多被注释的方法
OnStateEnter:当进入到 该脚本所挂载的状态(下面简称为该状态)时,该方法会被调用一次。
OnStateUpdate:当处于该状态时,每帧都会调用一次该方法。
OnStateExit:当该状态转到其他状态时,该方法会被调用一次。
OnStateMove:当该状态的动画进行根运动计算时(或者说之后),该方法会被调用(按游戏帧进行调用)。
OnStateIK:当该状态的动画进化骨骼运动计算时(或者说之后),该方法会被调用(按游戏帧进行调用)
因为 动画根运算 和 骨骼运算都是每帧计算一次,所以 OnStateMove 和 OnStateIK 可以简单地理解为也是每帧调用一次。
在 OnStateIK 方法中,我们可以对动画的骨骼进行调整。 还记得前段时间虚幻五的预告吗?在一段角色推门进入新场景的动画中,演示了虚幻五的自动优化骨骼位置的功能。其实就是内置的骨骼调整系统。 你也可以在 Unity 中自行实现(说不定 AssetStore 上已经有较为成熟的解决方案了)。 还有个常用例子就是:在射击游戏中,角色拿着枪的动画,其身体和手臂肯定要跟着玩家瞄准的位置而改变,此时也需要对 IK 进行修改。
可以看到,我们可以通过 StateMachineBehaviour 脚本,来在当前状态的各个时机去执行我们想要执行的代码。
行为树是一种框架。既然是框架就应该有复用性。如果我们对每一个状态都单独创建一个 StateMachineBehaviour,那么当状态比较多时,操作就显得繁琐,且大量的脚本堆积也不好看。
我们可以创建这四种脚本:
OnStateEnterCall:进入状态时执行的行为
OnStateUpdateCall:处于状态时持续执行的行为
OnYieldUpdateCall:处于状态时间隔固定帧执行的行为
OnStateExitCall:离开状态时执行的行为

每种脚本开启对应的方法

YieldUpdate 就是内置一个计时器
那么接下来就是能够让这四种脚本调用我们定义的想要的方法。
怎么调用呢?
可以看到,StateMachineBehaviour 里的方法,Unity 都会为我们传入 Animator 对象,即当前状态所在的动画状态机对象。我们可以通过 animator.gameObject 来得到动画状态所挂载的物体。 然后再通过 animator.gameObject.SendMessage("methodName") 来调用方法,只要该物体身上挂的脚本有名为“methodName”的方法,就会被调用。 但是该方法是基于 “反射” 实现的,效率较慢。
更好的方法:
我们可以通过 “注册回调” 的手段来跳过 “反射”。 首先我们创建一个脚本:AnimatorEventManager, 该脚本用于 存放和调用 我们注册的回调。
我们可以在进入状态时,通过 animator 对象,来获取状态机挂载物体上挂载的 AnimatorEventManager 脚本对象,当然,为了避免我们忘记了在物体上挂载该脚本,我们可以再判断一次,当获取到的对象为 null时,通过代码给加上去。

当 AnimatorEventManager 对象获取失败时,通过代码添加该对象
存储回调:
在 AnimatorEventManager 中,我们通过一个字典来存储 回调方法的名称 和 回调方法本身:

回调名称是string类型,而回调本身则是一个Action委托
调用回调:
之后,我们通过 方法名 直接在字典中找到对应回调,然后调用即可。我们定义 Invoke 方法:

根据方法名查找回调并调用,如果没找到回调则输出日志
注册回调:
我们在 AnimatorEventManager 中定义一个注册回调的方法。这样,我们可以随便在哪都可以去声明我们状态要执行的对应程序。然后通过注册方法,将回调和回调名存入到字典中即可。这样我们的状态就可以通过回调名来调用回调啦:

注册回调的方法
向字典中添加元素可以直接通过 dic[key] = value; 的方式。 这里我不想写 if 进行字典查重,就直接用 try catch 来捕捉报错了。
使用例子:

OnStateEnterCallBack 完整代码

设置 OnStateEnter 要执行的回调名

注册回调

敌人跑向玩家,接近是转为走路,之后围着玩家转圈
最后
我们也可以把这个用到动画事件上(AnimationEvent), 我们让动画事件调用 Invoke 方法,参数传入要调用的回调名。这样,如果我们动画状态机挂载的物体只挂了 AnimatorEventManager 一个脚本,那么动画事件也会较快的执行:

动画事件(AnimationEvent)

物体只挂 AnimatorEventManager 一个脚本,其它脚本挂载到相应的 Controller 物体上
OK,文章到这里就结束啦~~~~~
ps:为什么不做视频? 因为之前侄子上网课,把麦克风送给侄子了,加上现在也一直在做些自己想研究的东西,再加上也没什么做视频的动力,写写文章还挺有意思的。所以就没事的时候会写篇博文。
模糊逻辑:
想象一下这个场景:都说心急吃不了热豆腐,有一盘豆腐刚出锅,我们立马把他送进嘴里,那我们下意识说的应该是:“woc!这豆腐好烫!”, 而不是:“woc!这豆腐有六十度!”
没错,如果我们做的游戏不是什么造火箭的模拟类游戏,那么游戏中的大部分逻辑都应该像现实生活中一样具有一定“模糊性”。
“模糊性”即,我们对于执行行为的判断或者说对于状态的判断是通过一个或多个数值而取得的概率或者由概率而产生的确切的感知。
例如,当天气温度为 26度时,我们会觉得凉爽,而为 零度时就会感受到寒冷, 35度时就会觉得热。
又例如,即使玩家进入到了敌人的视野范围内,敌人也不一定就认出了玩家。因为距离越远,人看到的东西就越不清晰,即距离越远,认出玩家的概率就越小。
对于模糊逻辑,我们可以通过 Unity 的 AnimationCurve 来做定义,以敌人视野为例:
我定义了一个 敌人发现玩家的概率 与 敌人与玩家间距离相关的 AnimationCurve,也定义了一个 概率 与 玩家视野方向夹角的 Curve(因为我们通常只关注正前方的失误,而视野的两侧则较为模糊):

定义曲线(AnimationCurve)

曲线的 Inspector 面板

我们可以很方便的去设置曲线
这两天曲线定义的都是发现玩家的 “概率”, 是 0-1 的值。
接下来,进行概率的计算:

概率计算部分的代码
注意:图片给出的取随机值方法有误。 Random.Range(0, 1) 这样调用,传入的两个 int 类型参数,该方法的返回值也是整数。 应当使用:Random.Range(0f, 1f),来保证传入参数是浮点数。
我们要把计算得到的距离和角度缩放到 0-1 的范围(因为我们的曲线 x轴 只定义了 0-1 的范围),然后在曲线上进行取值(curve.Evaluate(time))。这样,我们就取得了两个概率:disP 和 angleP。
两个概率相乘,就是我们最终敌人会发现玩家的概率 P = disP * angleP
之后,我们再在 0-1 范围,取得一个随机值,如果该随机值小于我们的概率,则说明发现了玩家,否则没有发现玩家。
可以很好地理解,这样子做,敌人发现玩家的概率,就是我们计算得到的 P。
OK!现在是真的结束啦!