跳至正文

AI编写与状态机

到此为止,第二部分的内容都已经讲完了。我主要向你们介绍了一个合格的Mod制作者应该掌握的基本知识,有些内容可能确实有点难度,现在不理解也没关系,不过重要的是你是真的不理解,而不是因为不想去学而不理解。第三部分以后的内容将会频繁的用到这些知识点,希望在那个时候你能会想起第二部分所学的内容与他们的联系。

本章作为补充章节,让我为你介绍一个制作AI的利器:状态机模型。它会对你以后设计自己的弹幕,NPC等等都会有巨大帮助。


有限状态机

我们之前制作的弹幕依靠计时器和插值实现了很多功能,但是随着我们的AI越来越复杂,有时候弹幕所具备的行为已经不是短短几行能够描述的了。尽管弹幕可能比较简单,但是之后我们要介绍的NPC可就是一个大难题了。

NPCAI不同,毕竟是具有智慧(?)的生物,为了击杀敌人就会用到各种策略,这时候用编写弹幕的方式编写AI代码就会十分混乱。如果你用上一章的方法看了原版NPC的源码,你一定会被那晦涩无比的写法,满屏的num和ai[]给劝退。这时候我们就需要一个清晰,有条理的设计方案去编写一个易读,易懂,很好维护的AI代码,幸好前辈们发明了一种十分优雅的抽象行为方式:状态驱动

什么是状态呢?比如NPC处于游荡状态的时候不会主动攻击,而是漫无目的的移动。当NPC处于攻击状态的时候就会寻找目标敌人并进行攻击。处于防御状态的时候就会减少玩家的伤害,处于逃跑状态的时候……

NPC会有规律的切换进入这些状态,比如一段时间后从游荡状态变成攻击状态(或者当玩家攻击该npc的时候),弹幕有时候要从准备状态变成发射状态,从游荡变成跟随状态(比如召唤物)。这种切换状态的条件可以是受到伤害,有敌人处于攻击距离,血量甚至计时器的值,当条件被满足的时候,我们说这个物体的状态从一个状态切换到了另一个状态。

用这种方式描述一个智能体的行为,是不是就清晰很多?而很多游戏中出现的bug,很有可能就是对于这些行为的描述过于混乱,导致无法预测可能发生的行为。

有限状态机(Finite State Machine),简称FSM,就是一种实现这个状态驱动AI的方式。假设我们有一个NPC或者召唤物,平时没有敌人的时候是游荡状态,一旦发现敌人会先进行远程攻击,如果敌人距离足够近则会使用近战攻击,一直到敌人死亡或者超出距离为止,此时这个生物会回到游荡状态。那么这样的逻辑可以用一个状态图表示

状态图中,每个圆圈代表的就是状态,而每个箭头代表的是状态的转移,包括从哪个状态转移到哪个状态,以及这个转移需要满足的条件。当然,如果状态转换数量很多,那么光是状态图还是不够的,否则箭头会多到你都看不清,这时候我们就需要状态转移表:

当前状态条件转移到的状态
普通玩家(威胁等级<5)攻击1
普通玩家(5<威胁等级<10)攻击2
普通玩家(10<威胁等级)逃跑
攻击1自身血量低于50%攻击3
攻击2自身血量低于20%逃跑
攻击3自身血量低于10%自爆

而状态机要做的就是从初始状态开始,在满足条件的时候沿着这些状态一直走,直到停止。

那么这样的抽象逻辑好处在哪呢?

我们编写代码的时候就可以把NPC的每一个状态单独编写代码,而不用思考其他状态的情况,只需要在需要切换状态的时候切换到下一个状态就好了!在软件工程领域,这样的设计属于高内聚,低耦合的,是一个良好的软件设计方案。

除此之外,使用状态自动机都有哪些好处呢?

  1. 编程快速简单:有很多种方式去设计一个状态自动机,而且所有的设计方案复杂度都不高,并且可以根据自己的需求使用简单版本的还是复杂一点的版本,他们各有利弊。本章会介绍两种方法,一种简单,一种复杂,他们都同等重要。
  2. 易于调试:假设你发现智能体的行为不符合预期,你可以很快的确定它是在哪个状态出问题的,通过跟踪状态转移图,我们可以轻松的调试。
  3. 符合直觉:如我所说的,把智能体的行为通过状态进行分解是人类自然的思维方式,所以读到这样的代码的时候你也更容易去理解他们的行为,同时一个很重要的原因就是,即使别人不懂任何代码,也可以跟你交流AI的设计方案。

实现方法

朴素法

比较幼稚的实现方法就是用if else,还记得我们之前写的弹幕吗,我们其实就是利用if else加上计时器来实现状态的分解的。但是只用计时器我们并不好解读出这个时刻的行为,同时也无法处理不依赖计时器的行为,最好的方法是让ai[0]变成一个状态指示器:

public int State {
    get { return (int)projectile.ai[0]; }
    set { projectile.ai[0] = value; }
}

这样我们每帧更新的时候我们只需要判断State的值就知道自己处于哪个状态了,这也是泰拉瑞亚原版所用的方法。于是问题又来了,因为State是个int,所以我们只能看到当前状态是个数字,并不知道这个数字代表了什么状态,除非我们写上详细想注释。(但是众所周知,程序员是不爱写注释的

那么如何让你和与你合作的程序员更好地得知这个状态代表的是什么呢?这时候我们就要利用C#的枚举类型了(Enum)

private enum ProjState {
    // 正常状态
    Normal,
    // 攻击状态
    Attack
}

private ProjState State {
    get { return (ProjState)(int)projectile.ai[0]; }
    set { projectile.ai[0] = (int)value; }
}

这时候我们就可以更好地判断State具体是属于哪个状态了:

不过因为状态可能会非常多,所以可以用switch来代替if else,这样代码看上去也会更有条理

switch (State) {
    case ProjState.Normal:
        // 正常状态的代码
        break;
    case ProjState.Attack:
        // 攻击的代码
        break;
    case ProjState.Flee:
        // 逃跑的代码
        break;

    // ...更多状态
    default:
        break;
}

至于状态转换,也很简单

private void SwitchTo(ProjState state) {
    State = state;
}

总而言之,这是一个相当简单的实现,不过这个实现对于非复杂的Boss来说,已经完全够用了,我们先用一个回力标的例子来看看怎么使用这个状态机吧。(对我就是说吧)

回力标的状态有两种,飞过去和飞回来,飞过去1.5秒以后就会飞回来,直到回力标撞到玩家后消失。于是我们先定义状态和一个计时器,因为ai[0]已经用来表示状态了,所以我们用ai[1]作为计时器

private enum ProjState {
    // 向前飞行的状态
    Forward,
    // 返回玩家的状态
    Backward
}
private int Timer {
    get { return (int)projectile.ai[1]; }
    set { projectile.ai[1] = value; }
}

因为ai[0]如果没有设置一开始就是0,所以我们一开始的状态就是ProjState的第一个,也就是向前飞,所以我们只需要1.5秒后切换到第二个状态即可,具体代码如下

switch (State) {
    case ProjState.Forward: {
            // 让弹幕随着移动速度的快慢旋转
            projectile.rotation += 0.05f * projectile.velocity.Length();
            Timer++;
            // 这个插值是t^2,直到90帧(1.5秒)后变成1
            float factor = Timer / 90f;
            factor *= factor;
            // 速度逐渐变慢
            projectile.velocity = Vector2.Normalize(projectile.velocity) * 9f * (1.0f - factor);
            if (Timer >= 90) SwitchTo(ProjState.Backward);
            break;
        }
    case ProjState.Backward: {
            // 反着转
            projectile.rotation -= 0.05f * projectile.velocity.Length();
            // 弹幕的主人
            Player owner = Main.player[projectile.owner];
            // 飞回弹幕的主人那里
            projectile.velocity = Vector2.Normalize(owner.Center - projectile.Center) * 9f;
            // 如果弹幕碰到了玩家
            if (projectile.Hitbox.Intersects(owner.Hitbox)) {
                // 就消失吧
                projectile.Kill();
            }
            break;
        }
    default:
        break;
}

速度插值带来的效果还不错

面向对象法——状态模式

不过作为一个程序员,我们不可能满足够用就好,我们一定要超纲,不,是找到一个适用范围更广,写起来更舒服的方式。

我们在第二部分的第二章就学过了面向对象,那么我们也可以把状态机描述成一个对象,状态机本身就是我们的弹幕/NPC/智能体。他们的游戏逻辑就是沿着这些状态不断地执行和转移。但是对于状态,我们就可以定义成一个对象了:

public abstract class ProjState {
    // AI函数接受一个SMProjectile类型的mod弹幕对象
    public abstract void AI(SMProjectile proj);
}

接下来就是我们的重头戏SMProjectile,也就是状态驱动的ModProjectile类,源码直接在这里复制即可,我们不讨论它的实现,只讨论怎么去使用

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Terraria.ModLoader;

namespace TemplateMod2.Projectiles.StateMachine {
    /// <summary>
    /// 基于状态机的ModProjectile类,一定要先在Initialize里注册弹幕的状态才能使用哦
    /// </summary>
    public abstract class SMProjectile : ModProjectile {
        public ProjState currentState => projStates[State - 1];
        private List<ProjState> projStates = new List<ProjState>();
        private Dictionary<string, int> stateDict = new Dictionary<string, int>();
        private int State {
            get { return (int)projectile.ai[0]; }
            set { projectile.ai[0] = (int)value; }
        }
        public int Timer {
            get { return (int)projectile.ai[1]; }
            set { projectile.ai[1] = value; }
        }
        /// <summary>
        /// 把当前状态变为指定的弹幕状态实例
        /// </summary>
        /// <typeparam name="T">注册过的<see cref="ProjState"/>类名</typeparam>
        public void SetState<T>() where T : ProjState {
            var name = typeof(T).FullName;
            if (!stateDict.ContainsKey(name)) throw new ArgumentException("这个状态并不存在");
            State = stateDict[name];
        }
        /// <summary>
        /// 注册状态
        /// </summary>
        /// <typeparam name="T">需要注册的<see cref="ProjState"/>类</typeparam>
        /// <param name="state">需要注册的<see cref="ProjState"/>类的实例</param>
        protected void RegisterState<T>(T state) where T : ProjState {
            var name = typeof(T).FullName;
            if (stateDict.ContainsKey(name)) throw new ArgumentException("这个状态已经注册过了");
            projStates.Add(state);
            stateDict.Add(name, projStates.Count);
        }

        /// <summary>
        /// 初始化函数,用于注册弹幕状态
        /// </summary>
        public abstract void Initialize();
        /// <summary>
        /// 我把AI函数封住了,这样在子类无法重写AI函数,只能用before和after函数
        /// </summary>
        public sealed override void AI() {
            if (State == 0) {
                Initialize();
                State = 1;
            }
            AIBefore();
            currentState.AI(this);
            AIAfter();
        }
        /// <summary>
        /// 在状态机执行之前要执行的代码,可以重写
        /// </summary>
        public virtual void AIAfter() { }
        /// <summary>
        /// 在状态机执行之后要执行的代码,可以重写
        /// </summary>
        public virtual void AIBefore() { }
    }
}

为什么我要把AI函数封住呢?因为我要确保AI函数里面状态机的逻辑以及初始化情况不会被覆盖,所以说,子类是不应该继承AI函数的。那么如何使用这个状态机弹幕类呢?

我们先新建一个类,继承SMProjectile,之后按照VS的提示重写Initialize函数

public override void Initialize() {
    RegisterState(new ForwardState());
    RegisterState(new BackwardState());
}

之后我们开始编写状态类,由于这个状态类只用于这个弹幕,所以我们可以使用技巧——类套类(禁止套娃)。在当前的弹幕类里面建立两个私有ProjState类,当然,单独定义在别的地方也是可以的,不过得是public

private class ForwardState : ProjState {
    public override void AI(SMProjectile proj) {
        var projectile = proj.projectile;
        projectile.rotation += 0.05f * projectile.velocity.Length();
        proj.Timer++;
        float factor = proj.Timer / 90f;
        factor *= factor;
        projectile.velocity = Vector2.Normalize(projectile.velocity) * 9f * (1.0f - factor);
        // 状态转移
        if (proj.Timer >= 90) proj.SetState<BackwardState>();
    }
}

private class BackwardState : ProjState {
    public override void AI(SMProjectile proj) {
        var projectile = proj.projectile;
        projectile.rotation -= 0.05f * projectile.velocity.Length();
        Player owner = Main.player[projectile.owner];
        projectile.velocity = Vector2.Normalize(owner.Center - projectile.Center) * 9f;
        if (projectile.Hitbox.Intersects(owner.Hitbox)) {
            projectile.Kill();
        }
    }
}

最后设置一下SetDefaults和视觉效果,然后我们就完成了!刚才回力标的所有功能已经用状态机对象实现了。

但是有个问题,为啥SMProjectile这个类继承了ModProjectile却没有要求你加入相应的贴图??那是因为这个类被标记了abstract,所以说TML读取到这个类的时候就不会把它当作真实的Mod弹幕类(试试从源码中找到这段代码)

看起来我们多写了好多代码为了实现这个完全一样的功能是不是有点得不偿失?不是的。

  1. 我们消除了庞大的分支语句,把每个状态要做的事情分散在了很多个小类中,使得每个状态里面的代码互相不影响。
  2. 如果我们编写的状态类可以用于多个弹幕,那么我们就没必要把同样的代码写很多遍了(需要让这个状态类变成public)
  3. 扩展容易,如果我们想改变弹幕除了AI以外的行为,我们只需要更改ProjState的接口,如果我们想增加状态改变瞬间发生的事情也可以直接在SMProjectile类里面加接口SetState。

不过要注意的是SMProjectile把所有的ai[]数组都占用了,所以如果还需要新的ai[](一般不会)就得手动增加并且手动进行联机同步了。

如果要使用全局状态,你可以在BeforeAI里面写自己要的代码,比如说,提前结束一个状态(Boss血量过低),转移到另一个状态,我们会在制作Boss的时候频繁提到这个全局状态。

没有一种设计模式是完美的,能够解决所有可能出现的问题和需求,我给出的SMProjectile尽力满足了大部分状态切换的需求,但是还有很多地方是这个状态机类没有覆盖到的。不过等到那个时候,你们应该也有足够的能力去定制属于自己的状态机了吧。


如何学习AI

对我来说,最制作Mod最吸引我的地方就是制作AI了,为了写出一个优质的AI你需要思考很多方面,同时也要细心的规划。那么如何练习AI的制作呢?

首先就是要善用源码,源码的代码虽然很糟糕但是如果你仔细看还是能看出一些逻辑的,重要的不是它写了什么,而是它想干什么。原版有一些AI还是很有意思的,如果你能照着源码画出状态图,并且重新用状态图和自己的理解还原这个AI,那么你就算学会了这个AI。

学好数学,物理,很多AI行为都与数学和物理模型息息相关,如果数学不好很可能无法实现这些行为,或者用了一个相当复杂且低效的方法。

多玩游戏,看看别的游戏有没有哪写地方值得你借鉴的,你能不能在不知道源码的情况下画出状态图,模拟出这个AI?


练习

  1. 给回力镖加上一个缓慢加速回来的状态,跟踪敌人的状态和自爆状态,想一想怎么让回力镖在不同情况下进入不同的状态。你的状态图是什么?
  2. 看一看原版关于史莱姆的AI,你能画出它的状态图吗?他跟你想象的史莱姆攻击方式一致吗?
  3. 把炮台的AI用状态机重写一遍,看看还能不能增加一些状态(比如装子弹)?

《AI编写与状态机》有8个想法

  1. RainbowFluorescence

    把一些常用的ai逻辑编写成工具方法就好了。比如追踪啊、在pro的贴图随机位置生成dust啊、向某个点加速啊之类的。

  2. Pingback: TeddyTerri:使用绘制来实现影子拖尾 - 裙中世界

  3. Pingback: TeddyTerri:使用绘制实现影子拖尾 – TeddyTerri's Blog

发表回复