maltadirk 发表于 2022-6-20 05:50

Unity3D-AI敌人的制作(FSM有限状态机)

关键字索引:FSM,unity有限状态机,AI敌人制作
开始了制作3D游戏之旅。3D敌人与2D敌人有着很大的区别,2D敌人可以仅在默认Idle动画下只依靠位移就可以做到各种事情如:攻击只需要碰撞触发,受伤可以用特效变红,连死亡也可以用简单的爆炸特效敷衍过去。但是3D的敌人如果仅靠一个Idle状态做出各种相似的反应就会显得特别傻13。所以一般3D敌人一定要找动画特别多的资源,至少得包含5种动画:站立、行走、攻击、受伤、死亡。有了这5种动画,就可以做个简单的AI敌人了。
<hr/>问题一:AI敌人的几种做法。目前自己探索出来的就3种

1.if..else判断(中等)。优点:ifelse的代码做AI并不难,但是维护起来会造成巨大的麻烦。测试动画最好先用ifelse跑起来,这样不会导致后续维护麻烦。并且可以及时发现哪些动画是在update下用,哪些是只能触发一次。缺点:包含的状态超过3种就会让代码超级难写,哪怕只有3种状态:站立,攻击,死亡,也能让代码写起来非常复杂。代码复用基本不可能的了,重新为新的敌人写AI就得重新写过,不存在继承的说法(除非敌人就是个换皮)。ifelse如果代码顺序没写好,会最好时间复杂度O(m)最坏时间复杂度O(㎡)。
2.FSM有限状态机(困难)。优点:状态机最好的地方就是到什么状态就做该做的事情,状态里的内容只会在开关打开对应状态才触发。ifelse会从外到里的判断才能做出最后的表现,而有限状态机只需要写好不同状态下的内容即可,相互之间哪怕对方当前环境下能满足条件也不会触发内部的函数。缺点:手动码FSM必须要明确知道所需要的所有状态,后续若需要添加新的状态需要修改大量的代码,复杂度不比if..else少,所以用了FSM基本代表着状态确定了,所有类型的敌人都是只有站立,行走,攻击,受伤,死亡而很难再添加或者减少。而且FSM做敌人AI是个非常考验开发人员代码能力,unity生命周期和转换状态的条件, 一旦这两个把握不到位我的建议就是不要碰FSM,不然会把自己逼疯。



状态确定了就不能再增加或减少,不然转换状态条件改到想哭

3.行为树(简单)。优点:行为树可以在asset store上面购买,轮子都造好了,就别自己写了。源代码就是数据结构的树,一种从上到下(还是从下到上,我忘了怎么定义上下了)的执行方式,并且执行的逻辑是非常简单,满足条件就会顺着相应条件再接着条件判断,这样的话既有了与状态机类似的对于对应条件做自己该做的事,后续想要增加状态也很简单,因为行为树基本上每帧都是从根节点开始触发自上而下的判断,所以非常好用。缺点:(从别人的文章发现)因为每次都是从根节点开始进行逻辑判断,不管如何性能消耗肯定有。最重要的(于我而言),这个插件需要80$ 还是dollar,很遗憾,花钱的就别叫我!



老子没钱~~



行为树是一目了然的树状图

<hr/>问题二:是单类型敌人还是多类型敌人?

单类型敌人一般只需要一个FSM就可以了,并且还能专门的围绕单FSM进行添加或修改内容。但是做个动作类或者ARPG类的游戏,单类型敌人未免太过于枯燥,只是单纯的修改一些数值,伤害和换个新的套皮就会像以前玩过的哪些页游一样,毫无新意。所以一般想做含有动作元素的游戏,一般的考虑都是想要做个多类型敌人。因此需要通过继承来制作FSM。
FSM父类的代码编写:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
//枚举出5种状态下的类型
public enum State
{
    Idle,Walk,Attack,Damage,Dead,
}

public class FSM : MonoBehaviour//FSM框架
{
    public State CurrentState;//当前框架
    public Transform player;//获取玩家位置信息
    public float distance;//判断AI敌人自身与玩家之间的距离
    private void Start()
    {
      CurrentState = State.Idle;//默认状态下为站立
      player = GameObject.FindWithTag("Player").transform;//找到标签为Player的位置
    }

    private void Update()
    {   //位置信息需要实时获取,所以放在update中
      distance = Vector3.Distance(player.transform.position, transform.position);
      switch (CurrentState)//switch的用法就是当前状态是哪个就执行状态下的方法。
      {//注意:因为switch是放在update状态下的所以一定要明白方法会不断地调用~
            case State.Idle:
                StateIdle();
                break;
            case State.Walk:
                StateWalk();
                break;
            case State.Attack:
                StateAttack();
                break;
            case State.Damage:
                StateDamage();
                break;
            case State.Dead:
                StateDead();
                break;
      }
    }
//注:接下来所有的方法都添加virtual,因为需要根据继承对象的不同需求定制状态内的方法
    public virtual void StateIdle()//站立状态
    {
      
    }

    public virtual void StateWalk()//行走状态
    {
      
    }

    public virtual void StateAttack()//攻击状态
    {

    }

    public virtual void StateDamage()//受伤状态
    {

    }

    public virtual void StateDead()//死亡状态
    {

    }
//这里我极力的避免通过状态内部直接修改status,所以使用了下面的Change写法,一是为了代码美观,
//更重要的是因为有时候一些代码只需要触发一次即可,所以就放在Change方法里只调用一次
    public virtual void ChangeIdle()
    {
      CurrentState = State.Idle;
    }
    public virtual void ChangeWalk()
    {
      CurrentState = State.Walk;
    }
    public virtual void ChangeAttack()
    {
      CurrentState = State.Attack;
    }
    public virtual void ChangeDamage()
    {
      CurrentState = State.Damage;
    }
    public virtual void ChangeDead()
    {
      CurrentState = State.Dead;
    }
}<hr/>问题三:动画控制器的制作逻辑。

动画控制器算是第二大头痛的问题。既要有FSM,又要同时谐调FSM与动画之间的关系,所以2D游戏在这点上比3D方便。动画控制器的制作也需要对组件有相当的了解,至少需要明白Any State与打断动画的方法,不然代码传入动画控制器会造成非常严重的鬼畜。



这是第一个敌人的代码,看起来复杂,解释起来就不难了

先说Any State,意思也很简单,就是任何状态下都能转向相对应的动画,所以anystate最好是通过trigger来进行转换,如果通过int、float会导致某种情况下FSM转换状态但是相应之前状态传入的动画参数并没有修改而让动画在某两个动画之间不断地鬼畜抽动。
然后就是分层,按照我之前FSM的定义,AI敌人一般只有5种状态。站立,行走,攻击,受伤,死亡。但是看到了我这个复杂的动画控制器就觉得头晕,其实本质上还是5种分类,只是下载的资源多了,我就添加多点动画。



看着复杂,实际还是5种状态

先说站立:站立的情况下一般是呆呆地,但是资源里面多了个左右观看地动画,让我突然想到让AI通过视野的方式来进行站立巡逻。所以我做了两个双向的动画


两个站立之间的动画不需要传入任何参数,因为也就正常向前看与左右看
这时候也是很多人都有的疑问,不取消掉Has Exit time 那么如果突然需要转换成其他状态了,怎么办呢?取消掉了Has Exit Time但是又要传入参数才能转换状态怎么办?
这里就考验对animator controller的理解了,unity一直都提供了可以瞬间打断动画的方法,那就是在HasExitTime下面setting里,只要选择了ordered interruption就可以让两个动画是能被打断。具体的内容自行搜索,打断优先级是分先后的



这里我学了好久,很少见过有人说过能打断动画的~

行走:行走动画下本来我想只有一个动画的,结果呢资源里有还多了一种跑步,拥有站立、行走、跑步的话,那么就需要用Blend Tree来进行控制。



表面上需要通过trigger让动画进入walk状态



内部里walk是通过传入Speed来进行不同情况下动画的播放

攻击:攻击的方式提供了两种动画,但是同一时间不可能触发两种动画,所以这时候attack的状态下需要进行逻辑判断了,用Random来进行判断。


攻击状态下动画能不能打断这个要看情况而言,如果想要敌人是个攻击时也能被打到,那么可以添加打断,如果敌人是BOSS并且自带霸体的,那么敌人攻击动画就不能被打断。
并且要注意,攻击动画只能触发一次,所以不能让动画参数传入一直都在update里面,就算要写也得加上bool 来锁住,否则一秒钟会执行几百帧的攻击动画播放。
最后需要让两个attack退出回到idle中,如果没有退出的话,有时候会导致敌人重复播放攻击。
受伤:受伤一般也不能在update中出现,必须是受击一次才触发一次,并且后续需要回到idle动画。


死亡:死亡需要注意的地方就是只触发一次动画,要注意重复调用或者循环播放,同时也不需要再返回到某个动画了。


这样子动画控制器就很明朗了,只需要6个trigger与一个行走动画下传入浮点数speed总共7个变量。


<hr/>最后就是实现代码了,编写一个新的脚本,继承自FSM。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
publicclass Enemy1_FSM : FSM
{
    public GameObject[] Target;//用来存放敌人巡逻时的位置坐标,一般两个以上
    private Animator ani;//可以放在FSM里面,但是这里我还没确定下来后续的动画是不是都有5种。
    private NavMeshAgent agent;//我通过NavMesh导航
    private Coroutine async;//协程,很大作用
    private bool AttackLock;//攻击锁,防止重复攻击
    private int i = 0;
    private float time = 5.0f;
    private float CD = 2f;

    private void Awake()
    {
      ani=GetComponent<Animator>();
      agent = GetComponent<NavMeshAgent>();
      AttackLock = false;//开始时是锁住的
    }

    public override void StateIdle()
    {
      agent.speed = 2.0f;//AI导航速度为2.0F,一般每个状态下都需要编写不同的速度。
      time -= Time.deltaTime;
      if (time <= 0)
      {
            time = 5.0f;
            i = Random.Range(0, Target.Length);//计时器,再不同的巡逻点之间进行随机选择性巡逻
      }
      agent.SetDestination(Target.transform.position);//朝向目的移动
//下面转换追击玩家的逻辑是判断距离,实际上我想做的是视野判断,不过暂时还没做碰撞器,所以先用判断距离
      if (distance < 5)
      {
            ChangeWalk();
      }
    }

    public override void StateWalk()
    {
      float speedPercent = agent.velocity.magnitude / agent.speed;//算是取当前速度的标准化数值
      agent.speed = 5.0f;//追击状态下速度升到5.0f
      agent.SetDestination(player.transform.position);//敌人开始朝着玩家的位置
      ani.SetFloat("Speed", speedPercent, 0.1f, Time.deltaTime);//把速度传入动画控制器,
      if (distance < 2)//判断距离小于2,开始攻击
      {
            ChangeAttack();
      }
    }

    public override void StateAttack()
    {
      if (AttackLock == false)//没上锁的情况下才能进行攻击。
      {
            int j = Random.Range(0, 2);//随机数判断采用哪个攻击动画
            AttackLock = true;//攻击开始了就锁住
            agent.speed = 0f;//必须在每个动画设置速度,不然会保持前一个状态下的速度
            gameObject.transform.LookAt(player.transform.position);//朝向玩家攻击
            if (j == 0) ani.SetTrigger("Attack1");
            else ani.SetTrigger("Attack2");
//注意:协程判断,如果当前async是为空,则直接打开携程,否则先停掉上一个携程再开启新协程
//在受伤逻辑里说明这样做的目的
             if (async != null)
            {
                StopCoroutine(StateChange());
            }
            async=StartCoroutine(StateChange());
      }
    }
//暂时还没写好受伤,因为目前敌人还没写好敌人的生命系统,属性系统,所以先暂时放着
    public override void StateDamage()
    {
      agent.speed = 0;
    }
//同上
    public override void StateDead()
    {
      agent.speed = 0;
    }
//变回巡逻状态只触发一次,所以settrigger只在change里面出现一次。
    public override void ChangeIdle()
    {
      ani.SetTrigger("Idle");
      base.ChangeIdle();
    }

    public override void ChangeWalk()
    {
      ani.SetTrigger("Walk");
      base.ChangeWalk();
    }
//注:伤害这里说明一下协程的原因,主要是敌人有可能会一直受到玩家的攻击,但是我希望敌人
//只记录最后一次攻击,然后如果玩家不攻击了再开始判断是追击还是回去巡逻。
    public override void ChangeDamage()
    {
      ani.SetTrigger("Damage");
      if (async != null)
      {
            StopCoroutine(async);
      }
      async = StartCoroutine(StateChange());
      base.ChangeDamage();
    }

    public override void ChangeDead()
    {
      ani.SetTrigger("Die");
      base.ChangeDead();
    }
//协程说明:等待CD时间后,敌人再根据与玩家的距离做出判断。
    private IEnumerator StateChange( )
    {   
      yield return new WaitForSeconds(CD);
      AttackLock = false;//不管是攻击开启协程还是受伤开启,理论上都得锁住攻击
      async = null;//让协程为空,只触发最后一次调用的协程。
      if (distance > 1)//距离大于1就进入攻击
      {
            ChangeWalk();
      }
      else//距离小于1,也就是敌人与玩家贴脸,那么继续下一次攻击。
      {
            ChangeAttack();
      }
    }
}
<hr/>之前找了很多FSM,但是每个人所处的环境,情况,要求都不一样,符合做成AI的都不同,所以我自己摸索了一种方法来这样写,上面的框架其实还有很多可修改可优化的地方:

[*]玩家位置。如果一两个敌人则只需要简单的FindTag就可以,但是问题是如果敌人数量太多了,后续每一个都去获取玩家位置就很消耗性能,这里可以用单例模式,时刻记录玩家位置。
[*]动画是太多了,可以简单的缩成5个,这样代码就不需要这么长。
[*]目前代码里面敌人是根据与玩家的距离来进行改变的,实际上可以弄一个视野相关的方式让敌人根据视野碰撞器来检测是否看到玩家。
[*]有部分代码其实可以移植回到FSM框架里面,不过因为这个做了挺久了所以还没开始优化,有兴趣的自行优化。
至于通过其他的文件调用FSM里面的方式就更简单了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Enemy_Health : MonoBehaviour,IDamage
{
    public int MaxHP;
    private int CurrentHP;
    private FSM fsm;

    private void Start()
    {
      CurrentHP = MaxHP;
      fsm = GetComponent<Enemy1_FSM>();//这里要明白面向对象的多态与继承
    }
只需要
FSM fsm=GetComponent<Enemy1_FSM>();这样就可以获取敌人身上的专属FSM了。不了解这里实现的原理需要学习下多态,virtual与override的用法。
<hr/>说实话,一个有限状态机就至少需要明白了很多知识才能写出来,unity生命周期,继承与多态,枚举,协程,Animator Controller的打断方法,Any State的用法以及为外部脚本提供能修改状态的接口方法。做个AI都麻烦啊~~~


不过自己遇到过的想解决的方法,现在解决了,分享出来让别人少走点弯路也感觉挺不错的,以后遇到同样需求的话可以参考一下。

XGundam05 发表于 2022-6-20 05:55

感谢分享

KaaPexei 发表于 2022-6-20 06:05

感谢!

量子计算9 发表于 2022-6-20 06:06

[调皮]行为树的话,自己实现其实难度比状态机大,使用上行为树比状态机简单。好一些的状态机写法可以看看《游戏编程模式》里的状态模式。给每个状态抽象成类,继承父类的Enter Run Exit等方法,然后再具体实现,状态机只需要执行当前状态类的方法就可以了,这样简洁明了很多。
页: [1]
查看完整版本: Unity3D-AI敌人的制作(FSM有限状态机)