使用Unity制作游戏AI

本文转自Unity Connect官方文章

简介

本教程主要介绍游戏AI的概念和开发方法。虽然实现过程是面向Unity的,但整个理论方法可以应用于任何其它游戏引擎。

本文介绍的所有概念都是我们团队在开发《Radiant Blade》游戏的原型阶段学习到的,目前该游戏已经到达成品阶段。

游戏过程演示画面

使用游戏AI的原因

开始介绍技术内容前,我们首先要思考为什么要为游戏添加AI。

很长一段时间以来,我都在幻想着为游戏开发令人惊奇的AI,让AI给玩家带来印象深刻的体验。这种AI可以预料到玩家的每一个操作,几乎无法被打败。但说实话,这种AI毫无对抗的乐趣。

值得玩家去玩的游戏应该是玩家可以获得乐趣的游戏。因此我们的AI必须可以和玩家旗鼓相当。AI可以作为伙伴,让玩家通过特别的方法进行交互。

显然,只有乐趣的游戏不会是优秀的游戏。游戏也必须有炫酷的机制,深刻的含义,以及精美的外观。但对我们的AI而言,我们希望AI具有娱乐性,因此我们要进一步缩小这个概念。

游戏设计

什么是娱乐性?更具体来说,游戏中的娱乐性是什么?

开发团队花了一些时间思考这个问题,我们的结论可以总结为一个词:学习。具备娱乐性的游戏是玩家可以从中学习和利用知识的游戏。

娱乐性源于小小的好奇心,在玩家看到新事物时,好奇心会占据玩家的头脑,并会不断增长,直到玩家完全理解这项新事物。

也就

是说,具有娱乐性的AI必须是可以被玩家学习的。

这个简单的概念形成了所有游戏中AI的广泛理解,包括:《超级玛丽》,《毁灭战士》,《魔兽世界》和《以撒的结合》。

如果分析这些游戏的AI,我们会发现它们都是可以预测的。由于加入了一些随机元素,这些游戏AI不是完全固定不变的,但仍有预测的可能。

这样又出现了另一个问题:如何制作出可预测的游戏AI?

答案很简单:使用状态机。

状态机

状态机是包含状态和过渡的数学工具。

基本的状态机

在确定性状态机中,我们会处于一个特定状态,在移动时,我们会随着其中一个可用过渡转变到新状态。过渡可能会受到条件限制,例如:只有在拥有特定法术时,AI才可以到达指定状态。

状态机的优点是:它们具有表现力和可预测性。例如,假设状态包括“攻击”,“受击”,“奔跑至目标”和“逃跑”,我们可以使用一些过渡,创建出模拟AI基本行为的状态机。

简单的AI示例

我们制作的AI可以用下面三句话描述:

生命值在10%以下时,AI会逃跑。

AI可以受到攻击。

玩家处在AI范围内时,AI会向玩家跑去,然后攻击玩家。

这意味着AI很简单。简单是件好事情。如果我们无法简单地描述自己的AI,那么我们可能需要对AI做进一步思考。

状态机和Unity

我们知道状态机很厉害,那么我们是否可以在Unity使用状态机?

当然可以。

大致的方法有三种:

自己开发;

使用Animator实现;

从Asset Store资源商店获取相应资源。

由于状态机是游戏中很常见的工具,我不建议开发者自己开发状态机,因为已经有很多人实现过状态机,除非开发者希望学习怎么通过代码实现状态机,否则我们可以直接获取可以使用的状态机。

第二种方法是使用Unity的内置Animator功能。虽然这个名称不太好理解,但它其实是一种可以播放动画的状态机。但在Animator中,我们不一定要使用动画,如果不使用动画的话,它的工作方式和状态机一样。

Animator使用起来快捷而直观。

《Radiant Blade》中使用Unity Animator实现的弓箭手AI

第三种方法是从Asset Store资源商店获取相关资源。我们没有试过这个方法,但我们相信应该不少资源有和Animator一样不错的效果。

如果你使用过比Animator更好的资源,请来告诉我们。

Animator

或许你使用过Animator在Unity中实现标准动画,但我们在此会根据需求调整一些方法。

下面开始吧。

状态

通常,Animator的状态包含动画。我们没有这样使用,而是把状态关联到描述行为的代码。

为了演示这一点,我们现在查看定义弓箭手的游戏对象。

Behaviours对象的子对象是AI行为。它们其实是小型控制器,在对应状态激活时,它们会控制弓箭手。

在Shoot状态激活时,会在弓箭手上使用Shoot Behaviour脚本

这是基于状态的对象。在完成行为后,Shoot Behaviour会通知Animator。Animator内置的蓝色进度条可能会让人迷惑,但它只在外观上起到作用。

变量

这里的AI设计是响应式系统,它会随条件而变化,那么条件是什么呢?当然是玩家和环境。

Animator的变量用于描述游戏的状态,以及做出已知决策。

上图是弓箭手使用的变量,它们描述了形成AI的所有要素

这是一项重要的概念。在以传统方法使用Animator时,大多数状态过渡会随着关联动画结束而结束。对于AI来说,状态就是行为,它会在未定义的时间内保存游戏逻辑。

我们使用了两个变量,它们的作用是通知状态的结束,即behaviour_ended和behaviour_error。它们是状态的输出结果,表示状态成功结束,或是出现错误。

过渡

过渡定义了AI行为的改变过程,表示:当AI完成向目标行走的过程后,它应该要做什么。

示例过渡:如果目标在近战范围内,AI会进行攻击

对Unity的Animator,有些开发者可能不知道的是:过渡是有先后顺序的。特定过渡会被首先评估,仅在它的相关条件为假时,第二个过渡才会进行评估。

选中Neutral状态时,我们可以查看过渡的优先级

这项功能很不错,因为它允许我们把AI设计为中心大脑,根据优先级来做出合适的选择。

是否还记得我们之前展示的弓箭手AI?请注意AI的顺序和中心部分。Neutral节点是决策中心,它的主要工作过程如下:

如果没有玩家的话,AI停止战斗;

如果玩家距离较远,AI向玩家移动,进入射击范围;

如果玩家不在AI的视线方向,AI向玩家移动,从而能够进行射击;

如果处于近战范围,则进行近战攻击;

如果玩家过于接近AI,AI可能会向后退;

AI有可能随机改变和玩家的方向;

AI会向玩家射击。

该功能的好处在于,每个单独的过渡都非常简单:过渡会总结为一次测试,或甚至没有测试。使用后续过渡的前提是之前的过渡条件必须为假。

实现方法

从这部分开始,我们应该会开始了解具体操作。你是否注意到,到现在我还未提供过任何相关代码。

这个状态不错,因为这意味着我们的框架有足够高的抽象级,不必处理任何技术细节,就可以很好进行解释。在代码部分完成后,设计AI的过程非常直观。

我们需要什么

下面是实现AI的任务:

编写AI行为;

把Animator和可用行为关联;

为Animator更新游戏相关变量的列表。

行为

开始处理前,首先回顾行为的功能。

行为会和游戏的角色控制器一起工作;

行为可以被识别;

行为可以被启用;

行为可以成功完成;

行为也可以出现错误;

行为可以被中断;

大概就是这样。

public abstract class AbstractAIBehaviour : MonoBehaviour {

    // 角色由行为控制

    [SerializeField]

    protected CharController charController;

    // 必须返回对应行为的Animator状态的短哈希值。

    abstract public int GetBehaviourHash();

    // 在行为成功结束时调用的事件。

    public event Action OnBehaviourEnded;

    // 在行为失败时,要调用的事件

    public event Action OnBehaviourError;

    // OnEnable()

    // OnDisable()

    // enable = true/false;

}

对于启用和禁用部分,我们会利用Unity的内置方法,这里不必自己编写方法。

我们会使用简洁的API。

对于识别符,我创建了带有特殊名称的方法:GetBehaviourHash。因为Animator状态的识别方式是:状态的识别符是其名称的哈希值。https://docs.unity3d.com/ScriptReference/Animator.StringToHash.html

因此对于Shoot状态,对应的识别符是Animator.StringToHash(“Shoot”)。

为了弄清楚对象,避免再次计算相同的哈希值,我们可以把它们保存为静态变量:

/**

* 该类是预计算哈希值的占位符。

*

  * 目的是创建Animator状态名称和AI行为之间的关联。

* 下面定义的整数应该用于GetBehaviourHash中继承自AbstractAIBehaviour的类。

*/

public class BehaviourHashes {

    // 我们会使用行为,让角色向目标移动。

    static public readonly int OBJ_MOVETO_STATE = Animator.StringToHash("Obj MoveTo");

    // 我们会使用行为,让角色什么都不做。

    static public readonly int IDLE_STATE = Animator.StringToHash("Idle");

    // 此时角色会漫无目的地四处移动。

    static public readonly int ROAM_STATE = Animator.StringToHash("Roam")

    // ...

}

考虑到这点,AbstractAIBehaviour的实现代码如下:

// 必须返回对应行为的Animator状态的短哈希值。

    public override int GetBehaviourHash()

    {

        // Animator中的状态名称为Idle。

        return BehaviourHashes.IDLE_STATE;

    }

我们会把每个哈希值存到对应的脚本中,因此ROAM_STATE可以保存在RoamBehaviour类中。

唯一的问题在于:由于我们暗中把每个行为关联到名称,因此可能很难打开每个行为类,从而收集Animator状态的授权名称。

从此开始,我们的工作是为真实行为编写实际的代码,但这取决于开发者,因为这要根据自己的游戏来实现。我们需要做的是实现AbstractAIBehaviour的子类。

关联行为和Animator

我们的AI的行为可以被识别,监听,启用和禁用。现在我们要利用行为。

我们要从控制器开始,由于我们有多个互相独立的实体,我们需要同步它们,实现流畅的工作效果。

该控制器的目的是确保每次只启用一个行为,并提供修改当前行为的切入点。

一些开发者可能不知道应该何时给游戏添加新控制器的类。好的习惯是把控制器看作用来同步多个较小功能的代码。

/**

* AIBehaviourController应该关联AI的Animator和相应行为。

*/

public class AIBehaviourController

    /**

    * Contains the available Behaviours.

    * 包含可用行为

    *

    * The key of a Behaviour is the value returned by its GetBehaviourHash method.

    * 行为的关键是GetBehaviourHash方法返回的数值

    */

    protected Dictionary<int, AbstractAIBehaviour> behaviours = new Dictionary<int, AbstractAIBehaviour>();

    // AI的Animator

    private Animator stateMachine;

    // 正在执行的行为

    private AbstractAIBehaviour currentBehaviour;   

    // 必须存在AI Animator中的触发器

    public static readonly int BEHAVIOUR_ENDED = Animator.StringToHash("behaviour_ended");

    public static readonly int BEHAVIOUR_ERROR = Animator.StringToHash("behaviour_error");

    /**

    * 强制某个行为中断正在执行的行为

    */

    public void SetBehaviour(int behaviorHash)

    {

        // 安全地禁用当前行为

        if (currentBehaviour)

            currentBehaviour.enabled = false;

        try

        {

            // 开始新的行为

            currentBehaviour = behaviours[behaviorHash];

            currentBehaviour.enabled = true;

        }

        catch (KeyNotFoundException)

        {

            currentBehaviour = null;

        }

    }

    void Awake()

    {

        stateMachine = GetComponent<Animator>();

        // 对于每个子对象

        foreach (AbstractAIBehaviour behaviour in GetComponentsInChildren<AbstractAIBehaviour>())

        {

            // 注册行为

            behaviours.Add(behaviour.GetBehaviourHash(), behaviour);

            // 监听行为

            behaviour.OnBehaviourEnded += OnBehaviourEnded;

            behaviour.OnBehaviourError += OnBehaviourError;

        }

    }

    /**

    * 在行为结束时,通知AI的Animator

    */

    private void OnBehaviourEnded()

    {

        stateMachine.SetTrigger(BEHAVIOUR_ENDED);

    }

    /**

    * 在行为失败时,通知AI的Animator

    */

    private void OnBehaviourError()

    {

        stateMachine.SetTrigger(BEHAVIOUR_ERROR);

    }

}

这个类比较长,但是代码其实很简单:

字典包含我们已知的行为;

方法可以激活特定行为;

两个事件用于在行为结束时通知Animator。

有了切入点,我们可以把它和Animator连接起来。怎么连接呢?我们会使用一个不常用的功能:StateMachineBehaviour。

选中Animator时,如果在空白处单击左键,我们会聚焦Animator本身,并显示Animator的隐藏检视窗口

StateMachineBehaviour的功能是什么?它允许我们向Animator插入自定义代码。我们要怎么使用它呢?

我们会在Animator的状态变化时,调用我们的AIBehaviourController。

/**

* 该类会插入AI的Animator。

*

* 它的唯一作用是监视Animator中的状态转换。

*/

public class AIStateController : StateMachineBehaviour {

    /**

    * 在Animator进入新状态时,通知AI控制器。

    */

    override public void OnStateEnter(Animator animator, AnimatorStateInfo info, int layerIndex)

    {

        if (!animator.GetComponent<AIBehaviourController>().SetBehaviour(animatorStateInfo.shortNameHash))

        {

            // 如果状态不存在,那么把它设为决策中心。

            // 强制Animator直接评估该状态。

            animator.Update(0f);

        }

    }

}

这些代码非常直观,它会处理Unity的一个特别之处:Animator无法在每帧处理多个状态,因此在我们遍历决策中心时,会造成短暂的延迟。

幸运的是,解决方法很简单,我们可以强行执行Update方法,强制Animator处理状态。

通过使用我们的新类,我们可以把功能结合起来,只要把该脚本添加到AI的Animator即可。

现在进入新状态时,我们的AI Animator会调用AIBehaviourController

最后,我们有框架的三个类部分,子类,以及角色控制器,它们包含着实际的游戏逻辑。

组合成AI框架的小型类图示

包含游戏逻辑

总而言之,技术方面的解决方法可以总结为三个类,每个类都非常简洁。

我们还需要什么呢?当然是游戏本身了。但这个部分必须由开发者自己制作。

总之,实现自己的AI需要的内容如下:

一个角色控制器,负责角色和其渲染的实际逻辑;

变量,以及让变量与Animator保持同步的代码;

自定义行为,例如:攻击,移动。

此时我们要处理的都是常见的Unity标准代码。

变量,Animator和行为都协同工作

原文链接:https://connect.unity.com/p/creating-an-a-i-with-unity-shi-yong-unityzhi-zuo-you-xi-ai?app=true

各位在开发过程中遇到问题?欢迎大家戳上方链接,下载官方app,可在线答疑哦,还有更多学习资源等你来发现~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352

推荐阅读更多精彩内容