给猫看的游戏AI实战 (三):基于状态机的 AI 系统

作者:Levelplay皮皮关游戏教育
2017-10-31
2 3 1

前言、状态机与AI

前一篇文章举了一个视觉感知的例子,感觉和 AI 的关系并不很大,今天尽量把上节课学的东西利用起来,然后再让AI能够根据情况分解问题,让敌人看起来有点智能,不要太弱了 ╮(╯▽╰)╭。

有限状态机(FASM)是长久以来AI编程最基本的方法,就像拼UI界面要用坐标、层级一样,非常基本。而且这种方法的适应性非常好,只要开动脑筋仔细设计触发条件、执行动作,总能达到想要的效果。

(从反面讲,触发条件和状态转移的设置一不小心就会冲突造成奇怪的BUG,一定要有充分心理准备。) ( ̄~ ̄;)

AI状态机设计举例:

2

上图就是一个简单的状态机设计图,应该非常通俗易懂。某些讲解AI的书籍会把类似的思想换一个角度来讲:AI 在每一个时刻都有 1~N 种选择,换句话说游戏进行过程中,每种情况下下 AI 都有一些可以选择的行为,把这些可能性和 AI 的各种状态组织起来,就形成了可能性图。

可能性图(Possibility Map)举例:

3

上图就是一个可能性图,简单地把AI可以做的各种行为列举出来即可。但是这个图只是简化过的,严格地说,如果玩家(也就是 AI 的敌人)没有出现,那么“攻击”、“攻击并前进”这两个行为就没有目标,这两个选择也就不存在了;而如果AI已经呆在原地了,那么“退回岗位”的行为也就不存在了。也就是说在不同状态下AI能选择的行为是受限制的,AI只能在有限行为中选择合适的,这就是可能性图的真正含义。

补充一句,其实玩家行为也是受限制的,设计AI的方法有很多地方和设计游戏玩法是通用的,毕竟玩家只是一种特殊的AI而已 ㄟ( ▔, ▔ )ㄏ

如果仅仅作为一个程序实现者,搞清楚状态转移图已经可以很好地实现功能了。但是如果你想自己设计游戏,就要考虑AI和玩家到底什么时间应该做什么,就应当画一个完整的可能性图来帮助你思考了。

一、制作状态机AI的准备工作

本节内容要新建一个 Unity 工程,依然可以借用前两节里面的一些脚本和 Prefab,在上面修改。新建工程而不在上节的工程中修改,是为了避免混乱,毕竟脚本细节还是有很多不同的。这是我们第一次做真正的AI,抓紧坐稳了啊。

1、新建工程,再单独开一个 Unity 窗口打开原来的工程。把前两节课做的敌人、玩家都保存成 Prefab,然后把场景、材质(Material)、Prefab 都拷贝到新工程里(可以在 Unity 外面直接拷贝 Assets 目录里的文件)。脚本就不用拷贝了,这次变化会很大。(这步可以帮助你熟悉 Unity 文件操作,如果不太会整,可以新建一个,也不麻烦)。

2、如图用 Box 做一个仓库的样子,这节课可能没有实际作用,但是看起来会好一些,也会给你下一步改进的灵感。

4

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();
    }

    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 里动态生成对象经常用到这个方法:

5

添加一个 Fire 函数:

  void Fire()
    {
        if (fireCd > Time.time)
        {
            return;
        }
        var b = Instantiate(bullet, transform.position, Quaternion.identity, transform);
        var rigid = b.GetComponent();
        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 以后就删掉测试代码。

效果如下图:

6

7、准备工作基本完成。本节代码较多,难免有疏漏。本文最后会放上工程下载地址,对照着做一遍可以解决99%的问题。

二、设计 AI 状态机

作为一个教学用的例子,还是先看看最终效果,否则可能不知道我在说什么(。・ω・)ノ゙ 。

7
可以看到,敌 人 AI 具有的功能:

1、随时探测,“看见”玩家。

2、发现玩家后,射击,如果玩家远离就追击。

3、离开原始范围一定范围后,就回到守门的位置。

这个例子是已经看到效果的,其实设计的时候还是要仔细想想才能做,利用完整的可能性图(Possibility Map)来帮我们设计:

8

可以看到我们随时随地都可能有不止一个选择(这让我想起了存在主义 ( ̄. ̄))。去掉一些绝无可能的选择(比如发现了敌人,我还待机不动),剩下一些就可能都是有道理的。通过多次过滤,从仅有的几种选择里挑出最合适的,离成功就近了一半。这个例子比较简单,相信大家看看就明白了每种情况下最好的选择只有一种。而当游戏比较复杂的时候,可以玩花样的地方就多了,嗯(・(ェ)・)。

最终我们得到了一个简单的状态与状态转移设计图,也就是状态机图:

9

三、实现状态机 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、多测试一下吧,如果有问题请参考下载的工程。

四、总结

本节在写作时,明显感觉到由于难度提升,很难一步一步描述清楚整个操作过程,需要读者动手实践,遇到问题并解决后才能理解。

本章的例子编写难度也较大,本人在编写时在状态判断的细节方面发现了很多问题,大部分都解决了。某些情况,比如后退时又发现了玩家这种情况,就比较难处理。如果处理好代码量会继续膨胀,好在后果并不严重,不影响介绍状态机的使用。下节讲增强AI时必定会仔细处理这些问题(因为不处理不行,会影响效果 _(:3 」∠)_)。

本章示例工程下载戳这里

GitHub_AI_Enemy1

如果你讨厌一个程序员,就让他去做 AI,因为那会让他抓狂。

如果你喜欢一个程序员,就让他去做 AI,那会让他飞速成长。

如果你不信,那么咱们就下期见。

  • 官网
  • 游戏开发技术交流群:610475807
  • 微信公众号:皮皮关

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. zmfrog 2017-11-02

    不知道相比起用碰撞盒检测(暂不考虑区域形状),用扇形射线检测能带来多大的性能收益?

您需要登录或者注册后才能发表评论

登录/注册