用动画状态机当AI状态机
写ai逻辑基本上都需要状态机。因为懒得手搓状态机,所以选择直接用动画状态机当逻辑状态机用。
AI的基本行为
- 不停检测视野范围内是否有敌人;
- 没发现敌人时,静止、沿设定路线巡逻或随意走动;
- 发现敌人后,进入警戒状态,并进入攻击状态,转向敌人,攻击;
- 丢失目标后,保持警戒一段时间,然后进入安全状态;
- 丢失目标后可以选择是否追击,追击则移动到;
- 受攻击后会进入警戒状态;
- 警戒状态随意走动,指定一个附近目的地,朝向它,前进,同时不停检测是否已经足够接近,接近了就停止,再指定目的地,循环;
- 警戒状态,环顾四周;
- 攻击状态朝向目标,开启瞄准约束,开枪一段时间,停火一段时间,然后检查没子弹就换弹,如此循环;
AI程序的特点是在一个检测状态的大循环之下,各状态有攻击、环顾、走动等小循环。
架构设计
因为敌人的根节点已经有一个animator控制动画,只能增加一个子对象AI,给它加一个animator指向逻辑“动画”状态机。还有一个脚本,用来放一些检测函数和动画事件函数。
状态图设计
所有状态都不停执行检测敌人的方法。
动画剪辑设置
动画剪辑添加一个无关紧要的属性(如Scale)来卡时间。重要的是在特定的时间执行动画事件。如在Safe一段时间后开始巡逻:
public void StartPatrolling(){ enemyController.SetBool(patrolling,true); }攻击状态开枪几秒,停歇几秒,然后检查是否该换弹:
状态机行为脚本
主要用于在特定状态才每帧执行的代码。
在状态机行为脚本里满足某些条件时执行animator.SetXXX()改变动画参数,动画参数改变又引起状态转换,执行新的状态机行为脚本,可以达到状态机“自驱动”的效果。
public class EnemyAlert : StateMachineBehaviour { Character1 myCharacter; MyNPCAI myAI; override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex){ myCharacter=animator.transform.parent.GetComponent<Character1>(); myCharacter.PutAwayGun(); myAI=animator.GetComponent<MyNPCAI>(); myCharacter.PlayRandomClip(myAI.findAudio); myCharacter.UseRifle(); myAI.StopLookingAround(); } // OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex){ if(myAI.DetectEnemy()){ animator.SetBool(MyNPCAI.foundEnemyPara,true); } } }但是每个状态机行为脚本进入时都要获取一遍组件。这很恶心。
慢慢地我发现用协程好像也能替代状态机行为的功能,只需要状态机+动画事件+协程就
状态里的小状态
- Alert交替进行环顾四周、随机移动。环顾四周是一个过程,可以指定目标方向或旋转时间,移动也是类似的过程;
- 攻击要先转向目标,然后交替进行射击和停火,也是个子状态机;
- 其他状态要想更细,都会变成子状态机;
要把状态分成小状态吗?状态会多一点,多出来的转换就更多了。或者可以用协程做小状态?怎么用协程做个小状态机?可以在行为IEnumerator里开启下一个行为的协程,会不会有什么问题?
AI检测敌人
一开始我想让AI能发现前方扇形区域的敌人,没有扇形碰撞体,用代码写了一个。后来为了简单,直接在人物前方用球形范围检测了。
除了范围检测,还要用Physics.LineCast()检测中间有没有障碍物。
bool DetectEnemy() { Vector3 center = transform.position + transform.forward * detectRadius; Collider[] colliders = Physics.OverlapSphere(center, detectRadius, MyGameManager.Instance.aiDetectLayers); for (int i = 0; i < colliders.Length; i++) { CharacterBase character; if (colliders[i].TryGetComponent(out character) && !CheckBarrier(character) && character.HP > 0) { if (character.characterSide != myCharacter.characterSide) { target = character; targetLastPosition=character.transform.position; return true; } } } return false; }AI瞄准敌人
给AI的枪绑定对象加了AimConstraint使枪对准敌人,省去了写复杂的瞄准算法。
为了使AI不每枪必中,给AimVector加了随机误差。
void AddAimError(){ gunAim.aimVector=Vector3.forward+ UnityEngine.Random.Range(-aimErrorRange,aimErrorRange)*Vector3.up+ UnityEngine.Random.Range(-aimErrorRange,aimErrorRange)*Vector3.right; }这容易造成AI身体没指向目标时就打开瞄准约束,造成很不自然的样子。
AI攻击流程
进入攻击状态后转向目标,面向目标后交替开火和停火。攻击过程中要一直朝向敌人,没有朝向敌人不能开火。
public void TurnTo(Vector3 target) { Vector3 aimVector = target - myCharacter.transform.position; aimVector = new Vector3(aimVector.x, 0, aimVector.z); if (aimVector == Vector3.zero) { return; } Quaternion targetRotation = Quaternion.LookRotation(aimVector); myCharacter.transform.rotation=Quaternion.Lerp(myCharacter.transform.rotation, targetRotation,.2f); }AI环顾四周
先得到一个随机方向A,然后一帧帧转过去,和方向A的偏差小于一个值后再得到随机方向,循环。
效果演示
Unity简单敌人逻辑演示:巡逻、发现、攻击、追击_演示
总结
- 使用状态机写AI,一开始会想分成安全、警戒、攻击等状态,但是很快会发现这样粗略的状态划分不够,大状态下必然有次级状态规定人物的行为;
- 人物的行为不是执行一个函数就完成,都需要持续一段时间,有的持续一段时间结束,有的持续中还要检测结束条件;
不用动画状态机写ai
首先还是状态基类、状态管理器,状态基类有进入、更新、退出。然后状态管理器持有状态的方式可以用字段,也可以用字典。
我选择直接让管理器持有每种状态作为字段,因为状态机拥有的状态数量是写死的,不需要运行中添加移出状态,反而需要一开始就保证所有需要的状态都有了。
记录当前状态的方法,可以直接维护一个状态父类字段,但是我们想在检查器看到当前状态,那么可以自定义检查器,打印当前状态的名字。如果用字典记录状态则可以弄状态枚举,枚举记录当前状态,检查器显示枚举。那样除了定义一堆状态子类还要一一定义枚举。
然后更新状态是用switch还是用多态方法。用switch状态多了会变成巨量代码。
最终我们选定的方案组合是:
- 字段存储状态(无动态增减状态需求,代码比定义字典然后添加状态少一些)
- 一个状态父类字段表示当前状态;
- 用当前状态的Update多态更新状态;
然后难搞的还是那几个问题:
- 大状态里的小状态,就拿人物射击攻击为例,分为转向、开枪、间歇。开枪和间歇循环,开枪期结束后要检查要不要换弹。这个攻击状态本身就是一个状态机。
- 时间控制。以人物开枪0.6秒为例,动画状态机在动画剪辑里很容易控制时间,纯程序要么计时器,要么协程。我们需要手搓一个类似Animation时间轴配置动画事件的界面。我们想到弄一个列表,列表元素里记录距上一个动作的时间,以及这个动作的方法名字。或者如果懒得填方法名字则用方法枚举,弄一个枚举-方法映射字典。