前一节举了一个视觉感知的例子,感觉和AI的关系并不很大,今天尽量把上节课学的东西利用起来,然后再让AI能够根据情况分解问题,让敌人看起来有点智能,不要太弱了 ╮(╯▽╰)╭。
前言、状态机与AI
有限状态机(FASM)是长久以来AI编程最基本的方法,就像拼UI界面要用坐标、层级一样,非常基本。而且这种方法的适应性非常好,只要开动脑筋仔细设计触发条件、执行动作,总能达到想要的效果。
(从反面讲,触发条件和状态转移的设置一不小心就会冲突造成奇怪的BUG,一定要有充分心理准备。) ( ̄~ ̄;)
AI状态机设计举例:
上图就是一个简单的状态机设计图,应该非常通俗易懂。某些讲解AI的书籍会把类似的思想换一个角度来讲:AI在每一个时刻都有1~N种选择,换句话说游戏进行过程中,每种情况下下AI都有一些可以选择的行为,把这些可能性和AI的各种状态组织起来,就形成了可能性图。
可能性图(Possibility Map)举例:
上图就是一个可能性图,简单地把AI可以做的各种行为列举出来即可。但是这个图只是简化过的,严格地说,如果玩家(也就是AI的敌人)没有出现,那么“攻击”、“攻击并前进”这两个行为就没有目标,这两个选择也就不存在了;而如果AI已经呆在原地了,那么“退回岗位”的行为也就不存在了。也就是说在不同状态下AI能选择的行为是受限制的,AI只能在有限行为中选择合适的,这就是可能性图的真正含义。
补充一句,其实玩家行为也是受限制的,设计AI的方法有很多地方和设计游戏玩法是通用的,毕竟玩家只是一种特殊的AI而已 ㄟ( ▔, ▔ )ㄏ
如果仅仅作为一个程序实现者,搞清楚状态转移图已经可以很好地实现功能了。但是如果你想自己设计游戏,就要考虑AI和玩家到底什么时间应该做什么,就应当画一个完整的可能性图来帮助你思考了。
1、制作状态机AI的准备工作
本节内容要新建一个Unity工程,依然可以借用前两节里面的一些脚本和Prefab,在上面修改。新建工程而不在上节的工程中修改,是为了避免混乱,毕竟脚本细节还是有很多不同的。这是我们第一次做真正的AI,抓紧坐稳了啊。
1、新建工程,再单独开一个Unity窗口打开原来的工程。把前两节课做的敌人、玩家都保存成Prefab,然后把场景、材质(Material)、Prefab都拷贝到新工程里(可以在Unity外面直接拷贝Assets目录里的文件)。脚本就不用拷贝了,这次变化会很大。(这步可以帮助你熟悉Unity文件操作,如果不太会整,可以新建一个,也不麻烦)。
2、如图用Box做一个仓库的样子,这节课可能没有实际作用,但是看起来会好一些,也会给你下一步改进的灵感。
3、上节的脚本都不要直接复制过来。新建一个Player脚本挂在白色的玩家身上,新建一个Enemy脚本挂在红色的敌人身上,然后把之前写过的代码部分地粘贴过来。玩家要有移动功能(第一节讲的),敌人有虚拟视野(第二节讲的)。
Player.cs 代码如下,还原出移动的功能即可:
// Player.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour {
public float moveSpeed = 6;
Rigidbody myRigidbody;
void Start()
{
myRigidbody = GetComponent<Rigidbody>();
}
void Update()
{
if (hp > 0)
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitt = new RaycastHit();
Physics.Raycast(ray, out hitt, 100, LayerMask.GetMask("Ground"));
if (hitt.transform != null)
{
transform.LookAt(new Vector3(hitt.point.x, transform.position.y, hitt.point.z));
}
myRigidbody.velocity = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical")).normalized * moveSpeed;
}
}
Enemy.cs代码如下,还原出虚拟视野的功能即可:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Enemy : MonoBehaviour {
public float viewRadius = 8.0f;
public float viewAngleStep = 40;
Vector3 basePosition; // 原始位置
Quaternion baseDirection; // 原始方向
void Start () {
basePosition = transform.position;
baseDirection = transform.rotation;
}
void Update() {
DrawFieldOfView();
}
void DrawFieldOfView()
{
// 获得最左边那条射线的向量,相对正前方,角度是-45
Vector3 forward_left = Quaternion.Euler(0, -45, 0) * transform.forward * viewRadius;
// 依次处理每一条射线
for (int i = 0; i <= viewAngleStep; i++)
{
// 每条射线都在forward_left的基础上偏转一点,最后一个正好偏转90度到视线最右侧
Vector3 v = Quaternion.Euler(0, (90.0f / viewAngleStep) * i, 0) * forward_left; ;
// 创建射线
Ray ray = new Ray(transform.position, v);
RaycastHit hitt = new RaycastHit();
// 射线只与两种层碰撞,注意名字和你添加的layer一致,其他层忽略
int mask = LayerMask.GetMask("Obstacle", "Player");
Physics.Raycast(ray, out hitt, viewRadius, mask);
// Player位置加v,就是射线终点pos
Vector3 pos = transform.position + v;
if (hitt.transform != null)
{
// 如果碰撞到什么东西,射线终点就变为碰撞的点了
pos = hitt.point;
}
// 从玩家位置到pos画线段,只会在编辑器里看到
Debug.DrawLine(transform.position, pos, Color.red); ;
// 如果真的碰撞到敌人,进一步处理
if (hitt.transform!=null && hitt.transform.gameObject.layer == LayerMask.NameToLayer("Player"))
{
OnEnemySpotted(hitt.transform.gameObject);
}
}
}
void OnEnemySpotted(GameObject enemy)
{ Debug.Log("Player Spotted");
}
}
4、测试写好的部分,要做到:玩家Player可以按WASD键走动,用鼠标控制方向,敌人Enemy可以发射射线,在射线碰到玩家时控制台会打印“Player Spotted”。注意脚本错误、Layer设置错误等问题。
Unity做这些基本的东西比较考验耐心,有问题都可以在之前的章节里找到说明。
5、添加射击的功能。先给玩家添加射击功能,相对简单一些,先加上以下变量,用于控制开火的:
public GameObject bullet; // 子弹Prefab,用它来生成更多子弹
public float bulletSpeed = 30.0f; // 子弹速度
public float fireInterval = 0.3f; // 射击最小间隔
float fireCd = 0; // 记录CD时间,用来控制子弹射击频率
添加了bullet变量以后,在编辑器里做一个球体的Prefab,要带有刚体属性设置和玩家自己一样,调好颜色,把它拖到变量上,如下图。Unity里动态生成对象经常用到这个方法:
添加一个Fire函数:
void Fire()
{
if (fireCd > Time.time)
{
return;
}
var b = Instantiate(bullet, transform.position, Quaternion.identity, transform);
var rigid = b.GetComponent<Rigidbody>();
rigid.velocity = transform.forward * bulletSpeed;
fireCd = Time.time + fireInterval;
}
然后在Player.cs Update函数最后面加上如下代码,鼠标左键就可以开火:
if (Input.GetMouseButtonDown(0))
{
Fire();
}
简单讲解一下。开火原理:生成一个子弹并给它一个初速度。CD控制原理:把下一次可以开火的时间记录到fireCd变量里。下次只有时间过了fireCd记录的时间,才能开火。
6、测试一下Player的开火功能,如果没有问题了,就再做Enemy的开火功能。方法和Player一模一样,给Enemy也加上开火用的变量并设置好Prefab(Prefab要另外作一个子弹,不要哦和玩家用同一个),加一个同样的Fire()函数。测试的时候可以同样做成鼠标点击时候Enemy开火,测试OK以后就删掉测试代码。
效果如下图:
7、准备工作基本完成。本节代码较多,难免有疏漏。本文最后会放上工程下载地址,对照着做一遍可以解决99%的问题。
2、设计AI状态机
作为一个教学用的例子,还是先看看最终效果,否则可能不知道我在说什么(。・ω・)ノ゙ 。
可以看到,敌人AI具有的功能:
1、随时探测,“看见”玩家。
2、发现玩家后,射击,如果玩家远离就追击。
3、离开原始范围一定范围后,就回到守门的位置。
这个例子是已经看到效果的,其实设计的时候还是要仔细想想才能做,利用完整的可能性图(Possibility Map)来帮我们设计:
可以看到我们随时随地都可能有不止一个选择(这让我想起了存在主义 ( ̄. ̄))。去掉一些绝无可能的选择(比如发现了敌人,我还待机不动),剩下一些就可能都是有道理的。通过多次过滤,从仅有的几种选择里挑出最合适的,离成功就近了一半。这个例子比较简单,相信大家看看就明白了每种情况下最好的选择只有一种。而当游戏比较复杂的时候,可以玩花样的地方就多了,嗯(・(ェ)・)。
最终我们得到了一个简单的状态与状态转移设计图,也就是状态机图:
3、实现状态机AI
以下讲解不再是手把手教的方式了,因为代码量比较大,希望读者着重理解过程。具体代码可以打开工程参考。以下代码均写在Enemy.cs里面
1、如何定义状态。使用C# enum枚举可以方便地定义状态。
public enum State
{
Idle, // 待命状态
Attack, // 进攻敌方
Back, // 回归原位
Dead, // 死亡
}
public State state = State.Idle; // AI当前状态
GameObject invader = null; // 入侵者GameObject
我们定义了4种状态,顺便用一个State类型的变量state表示当前状态;另外进攻状态一定和入侵者有关,要在发现入侵者时,把入侵者的GameObject保存下来。
2、一系列工具函数,对理解游戏中的3D运算非常有帮助,可以仔细看看。后面用到再回来参考。
// 是否正在面对入侵者,即已经正确瞄准
bool IsFacingInvader()
{
if (invader == null)
{
return false;
}
Vector3 v1 = invader.transform.position - transform.position;
v1.y = 0;
// Vector3.Angle获得的是一个0~180度的角度,和参数两个向量顺序无关
if (Vector3.Angle(transform.forward, v1) < 1)
{
return true;
}
return false;
}
// 转向入侵者方向,每次只转一点,速度受turnSpeed控制
void RotateToInvader()
{
if (invader == null)
{
return;
}
Vector3 v1 = invader.transform.position - transform.position;
v1.y = 0;
// 结合叉积和Rotate函数进行旋转,很简洁很好用,建议掌握
// 使用Mathf.Min(turnSpeed, Mathf.Abs(angle))是为了严谨,避免旋转过度导致的抖动
Vector3 cross = Vector3.Cross(transform.forward, v1);
float angle = Vector3.Angle(transform.forward, v1);
transform.Rotate(cross, Mathf.Min(turnSpeed, Mathf.Abs(angle)));
}
// 转向参数指定的方向,每次只转一点,速度受turnSpeed控制。这里有点不够严谨,参考上面的方法
void RotateToDirection(Quaternion rot)
{
Quaternion.RotateTowards(transform.rotation, rot, turnSpeed);
}
// 是否正位于某个点, 注意float比较时绝不能采用 == 判断
bool IsInPosition(Vector3 pos)
{
Vector3 v = pos - transform.position;
v.y = 0;
return v.magnitude < 0.05f;
}
// 移动到某个点,每次只移动一点。也不严谨,有可能超过目标一点点
void MoveToPosition(Vector3 pos)
{
Vector3 v = pos - transform.position;
v.y = 0;
transform.position += v.normalized * moveSpeed * Time.deltaTime;
}
注意其中的v.y=0这句话,因为敌人高度可能和Player高度不一致,导致向量的Y轴方向不是0,特地处理一下,这个问题会导致计算失误,干扰了我很久 (#`皿´)。
另外可以看到注释里已经指出了可能有问题的点,读者阅读时要思考应该怎么改才能更好,关键是要利用Mathf.Min防止转动太多或移动太多。
3、严格按照设计,在Update函数中,针对当前的每种状态,实现相应效果。注意在我的设计中,与敌人距离过远或者离开原始位置过远都要回家:
void Update() {
if (state == State.Dead)
{
return;
}
if (state == State.Idle)
{
// 方向不对的话,转一下
transform.rotation = Quaternion.RotateTowards(transform.rotation, baseDirection, turnSpeed);
}
else if (state == State.Attack)
{
if (invader != null)
{
if (Vector3.Distance(invader.transform.position, transform.position) > maxChaseDist)
{
// 与敌人距离过大,追丢的情况
state = State.Back;
return;
}
if (Vector3.Distance(basePosition, transform.position) > maxLeaveDist)
{
// 离开原始位置过远的情况
state = State.Back;
return;
}
if (Vector3.Distance(invader.transform.position, transform.position) > maxChaseDist/2)
{
// 追击敌人
MoveToPosition(invader.transform.position);
}
// 转向敌人
if (!IsFacingInvader())
{
RotateToInvader();
}
else
{// 开火
Fire();
}
}
}
else if (state == State.Back)
{
if (IsInPosition(basePosition))
{
state = State.Idle;
return;
}
MoveToPosition(basePosition);
}
DrawFieldOfView();
}
第一次读这段代码,要关注整体,看清楚每种状态之间是如何实现转移的。还有一部分转移漏了,在视野射线发现Player的地方:
void OnEnemySpotted(GameObject enemy)
{
invader = enemy;
state = State.Attack; // 发现玩家,进入攻击状态
}
读这些代码的时候,一要看每种状态下,应当做什么事;二要看一种状态在什么时候转换到另一种状态。我在写这些代码时,BUG往往发生在state == State.Attack这种情况下,因为攻击状态实际上有几种子情况,根据maxChaseDist和maxLeaveDist来判断是否要继续追击还是回去,而一旦转换状态就return,这一帧立即结束,这样可以简化代码避免BUG。在同一帧内多次转换状态其实也可以做到,但是非常烧脑 ( _ _)ノ|。
4、补充一些漏掉的变量。另外敌人需要一开始记录好自己的出生位置,以便回去。
public float moveSpeed = 1.0f; // 移动速度
public float turnSpeed = 3.0f; // 转身速度
public float maxChaseDist = 11.0f; // 最大追击距离
public float maxLeaveDist = 2.0f; // 最大离开原位距离
Vector3 basePosition; // 原始位置
Quaternion baseDirection; // 原始方向
初始化时记录出生位置和面对方向
void Start () {
basePosition = transform.position;
baseDirection = transform.rotation;
}
5、多测试一下吧,如果有问题请参考下载的工程。
4、总结
本节在写作时,明显感觉到由于难度提升,很难一步一步描述清楚整个操作过程,需要读者动手实践,遇到问题并解决后才能理解。
本章的例子编写难度也较大,本人在编写时在状态判断的细节方面发现了很多问题,大部分都解决了。某些情况,比如后退时又发现了玩家这种情况,就比较难处理。如果处理好代码量会继续膨胀,好在后果并不严重,不影响介绍状态机的使用。下节讲增强AI时必定会仔细处理这些问题(因为不处理不行,会影响效果 _(:3 」∠)_)。
本章示例工程下载:
如果你讨厌一个程序员,就让他去做AI,因为那会让他抓狂。
如果你喜欢一个程序员,就让他去做AI,那会让他飞速成长。
如果你不信,那么咱们就下期见。
————————————————————————————————————
官网地址:http://levelpp.com/
游戏开发技术交流群:610475807
微信公众号:皮皮关
暂无关于此日志的评论。