使用 Unity 制作游戏 AI

2020-01-15 16:21:14 +08:00
 unn

本文转自 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”)。

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

/**

*/

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 的行为可以被识别,监听,启用和禁用。现在我们要利用行为。

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

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

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

/**

*/

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。

/**

*/

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,可在线答疑哦,还有更多学习资源等你来发现~

1255 次点击
所在节点    UNITY
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/638180

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX