思考并回答以下问题:
本章将创建一个具有地形的世界、第一人称视角角色以及一些敌人。这些敌人都拥有人工智能,它们在场景中进行巡逻并搜索玩家,如果发现玩家就会发起攻击。
专注于创造一个有智慧敌人的相关理论和编码技术。这些敌人将会表现出3种主要行为:巡逻、追击和进攻。本章将深入讨论以下主题:
- 如何对敌人的人工智能系统进行设计和编码
- 如何编码有限状态机
- 如何创建视野范围功能
敌人的人工智能——视野范围
现在根据游戏的功能要求来开发敌人的人工智能。场景中的敌人将会以一个巡逻模式开始,从一个地方徘徊到另一个地方不断地搜索玩家角色。如果敌人发现了玩家角色,就会改变巡逻并开始追逐,试图接近玩家并发起攻击。如果玩家进入了敌人的攻击范围内,敌人将会从追逐变为攻击。如果玩家成功地摆脱了敌人,那么敌人就会停止追逐并再次开始巡逻,就像它们一开始那样。以上描述了我们所需要的敌人的人工智能行为。
为了实现这些功能,需要对敌人的视野范围功能进行编码,敌人需要能够看到玩家角色,或者时刻检测玩家是否在敌人的视线中,这将帮助敌人决定它们应该继续巡逻,还是开始追逐玩家角色。实现这个功能最好使用源文件的LineSight.cs脚本。下面的代码示例1中的脚本文件应该添加到创建的敌人角色身上:
代码示例 1
1 | using UnityEngine; |
下面对代码进行几点总结。
- LineSight类应该附加到所有的敌人角色对象上,目的是为了计算在玩家和敌人之间是否存在一个可用的直视视线。
- 变量CanSeeTarget是一个Boolean类型(true/false),这个值会在每一帧更新一次,描述敌人现在是否可以看到玩家(当前帧)。“true”表示玩家在敌人的视野中,“false”表示玩家不在敌人的视野中。
- 变量FieldOfView是一个浮点数值,它表示敌人眼睛所能看到的两侧之间的角度,在这个范围内的对象(如玩家)是可以看到的。这个值设定得越大,敌人发现玩家的概率就越大。
- InFOV函数将会返回true或者false,这个值表示玩家是否处于敌人的视野范围内。但是这个值忽略了玩家是否被墙或者其他实体(如支柱)挡住的情况,它只是简单地考虑了敌人眼睛的位置,确定一个到玩家的向量,并测量前向向量和玩家之间的角度。然后将这个值与变量FieldOfView进行比较,如果玩家与敌人之间的角度小于变量FieldOfView,就返回true。简而言之,如果有一个明确的视线,这个函数可以告诉你敌人是否可以看到玩家。
- 函数ClearLineofSight返回值也是true或者false,这个值表示在敌人的眼睛和玩家之间是否存在任何物理的障碍(碰撞体),例如墙或者道具。这个函数并不考虑玩家是否在敌人的视野范围内。将这个函数与InFOV函数结合使用,就可以判断玩家是否处在敌人的无遮挡的视野范围内,因此可以认为玩家是可见的。
- 函数OnTriggerStay和OnTriggerExit这两个函数分别表示玩家进入到了敌人的触发区域和玩家离开了敌人的触发区域。正如我们所看到的,一个球状的碰撞器可以附加到敌人角色身上代表它的视野。这意味着在一定距离或者半径内,敌人可以看见里面的玩家,只要视野清晰且没有遮挡即可。
有限状态机概述
如果为NPC对象创建一个AI,刚刚创建的视线功能还不够,还需要使用有限状态机(Finite State Machines, FSMs)。有限状态机并不是Unity中的内容,也不是C#语言的组成部分。有限状态机是一个概念、框架或者说是想法,将有限状态机应用在编码中可以实现特定的人工智能行为。它来自于智能角色的一种具体思维方式。总结一下NPC在任意时刻可能处于的全部3种状态,分别是巡逻模式、追逐模式以及攻击模式。这些模式是一种具有独特行为而且唯一的状态,敌人在任何时间都必须且只能处于这3种状态之一。例如,敌人不能同时巡逻和追逐,也不能同时巡逻和攻击,因为这不符合游戏中设计的逻辑。
除了这些状态以外,还有一个状态连接的规则集,其决定了一个状态在何时转换成另一个状态。例如,NPC可以看到玩家但是并不具备攻击条件的时候,它只能从巡逻状态切换到追逐状态。同样,如果敌人在攻击状态时失去玩家的踪影,它们应该从进攻切换到巡逻状态。因此,这些状态和它们的连接规则的组合形成了一个有限状态机,任何实现了这个功能的代码其实就是一个有限状态机。编码实现有限状态机的方法本身并没有什么正确或者错误之分,这里有很多简单方法,对于特定的目的来说,其中有一些是合适的,另一些是不合适的。这一节将使用Coroutines来实现有限状态机的编码。从创建一个主要结构开始,代码示例2给出的AI_Enemy.cs脚本中的代码就是这样的一个主要结构:
代码示例2 AI_Enemy.cs
1 | using UnityEngine; |
- AI_Enemy类创建到现在并没有完全地实现一个完整的有限状态机,而只是实现了一个开始的框架。它说明了整体的结构,其中每一个状态对应一个单独的coroutine。
- CurrentState变量定义了当前选中的激活状态,终止所有当前的coroutine并启动相关的coroutine。
- 每个状态coroutine将会运行在一个框架安全(Frame Safe)的无限循环中,只要有限状态机是活动的,这个循环就不停止。这将允许敌人对象更新它的行为,下面很快会看到。
在下一步开始之前,要确保将AI_Enemy脚本附加到了NPC对象上,如下图所示。
巡逻状态
NPC人工智能3个状态中的第一个状态是巡逻状态。在前面的章节中,已经配置了动画的巡逻对象,NPC处在巡逻状态时,会一直跟随着这些巡逻对象移动。巡逻对象会根据预先定义好的动画资源从一个位置移动到另一个位置,不断地在场景中巡逻。不过,在此之前,NPC只是简单地跟随这个对象没有尽头,而巡逻状态需要NPC都能看见玩家是否在它的路线上。如果看见,状态就需要改变。为了支持这个功能,需要对巡逻状态和AI_Enemy的Start函数进行编码,下面的代码示例3给出了具体的代码内容。
代码示例3
1 | void Start() |
- Start函数将敌人的初始状态设置为巡逻模式,CoroutineAIPatrol操作这个模式。
- AIPatrol Coroutine在巡逻状态激活时会一直循环。记住,在一个Coroutine与一个yield组合下,无限循环不一定是坏事,这可以实现对长期行为进行简单整齐的编码。
- SetDestination函数用来调用将NavMeshAgent发送到指定目的地。后面跟随一个pathPending控制,它是NavMeshAgent中的一个变量。这个控制会一直持续到pathPending的值变为false的时候,此时意味着已经计算完了从源地址到目的地的整个路径。对于简单的或者短的旅程,路径几乎可以立刻计算出来,但是对于复杂的路径,可能需要很长的时间。
- 在巡逻状态时,需要不断地对LineSight组件进行检查,以便知道敌人是否具有到玩家的一个直视视线,如果具有,那么敌人立刻由巡逻状态转为追逐状态。
- 记住,yield返回的空语句将会暂停一个Coroutine直到下一帧。
现在,将AIEnemy脚本拖曳到场景中的NPC角色上。巡逻模式下NPC将跟随一个移动的物体而运动,也就是说敌人会一直跟随一个移动的目的地。在上一章中已经使用Animation窗口在场景中创建了一个随着时间移动的对象,这个对象会从一个地方跳到另一个地方,如图7所示。
为了获得可移动的对象,可以在场景中创建一个或者更多的目的地对象,将它们的“Tag”属性设置为“Dest”。AIEnemy对象的Start函数会搜索所有场景Tag属性为“Dest”的对象,这些对象将会被当做目的地点,如图8所示。
追逐状态
追逐状态时敌人有限状态机3种状态中的第二种,这个状态直接关联到巡逻和攻击状态,它可以转换到两种状态之一。如果一个正在巡逻的NPC建立了一个到玩家的直接视线,那么NPC就会由巡逻模式转为追逐模式。相反,如果一个正在攻击的NPC落在了玩家的范围之外(也许因为玩家逃跑了),NPC会再次转入追逐模式。从追逐状态本身,它可以转换成巡逻状态或者攻击状态,相反,就会进入追逐状态。如果NPC与玩家的距离接近到了可以攻击的距离内,就会切换到攻击模式,下面的代码示例4进行了修改,实现了追逐行为。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34public IEnumerator AIChase()
{
// 循环追逐状态
while(currentstate == ENEMY_STATE.CHASE)
{
// 将查找模式设置为“loose”
ThisLineSight.Sensitity = LineSight.SightSensitivity.LOOSE;
// 追踪的最后一个位置
ThisAgent.Resume();
ThisAgent.SetDestination(ThisLineSight.LastKnowSighting);
// 等待直到路径计算完成
while(ThisAgent.pathPending)
yield return null;
// 我们是否到达目的地
if(ThisAgent.remainingDistance <= ThisAgent.stoppingDistance)
{
// 停止代理
ThisAgent.Stop();
// 到达目的地但是无法看到玩家
if(!ThisLineSight.CanSeeTarget)
CurrentState = ENEMY_STATE.PATROL;
else // Reached destination and can see player. Reached attacking distance
CurrentState = ENEMY_STATE.ATTACK;
yield break;
}
// 等待到下一帧
yield return null;
}
}
- 当进入到追逐状态以后,就会启动AIChasecoroutine,跟巡逻状态一样,只要它处于激活状态,Coroutine就会无限循环。
- NavMeshAgent的成员变量remainingDistance用来检测NPC是否进入了攻击玩家的范围。
- LineSight类中的CanSeeTarget布尔型变量表示玩家是否可见,并会影响到NPC是否返回到巡逻模式。
现在来运行一下这段代码,此时已经拥有了一个可以巡逻和追逐的敌人角色。
攻击状态
第三个也是最后一个NPC的状态是攻击状态,在此期间NPC会不断地攻击玩家。只能从追逐状态转换到攻击状态。在追逐过程中,NPC必须检测是否进入了攻击距离,如果进入了,就必须将追逐状态切换到攻击状态。在攻击状态中,如果玩家远离NPC并超出了攻击距离,NPC就必须从攻击状态转换为追逐模式,下面的代码示例5包含了EnemyAI类及全部的代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106using UnityEngine;
using System.Collections;
public class AI_Enemy : MonoBehaviour
{
public enum ENEMY_STATE
{
PATROL,
CHASE,
ATTACK
}
[ ]
private ENEMY_STATE currentstate = ENEMY_STATE.PATROL;
//
private LineSight ThisLineSight = null;
//
private NavMeshAgent ThisAgent = null;
//
private Transform PlayerTransform = null;
//
public ENEMY_STATE CurrentState
{
get
{
return currentstate;
}
set
{
//
currentstate = value;
//
StopAllCoroutines();
switch(currentstate)
{
case ENEMY_STATE.PATROL:
StartCoroutine(AIPatrol());
break;
case ENEMY_STATE.CHASE:
StartCoroutine(AIChase());
break;
case ENEMY_STATE.ATTACK:
StartCoroutine(AIAttack());
break;
}
}
}
void Awake()
{
ThisLineSight = GetComponent<LineSight>();
ThisAgent = GetComponent<NavMeshAgent>();
PlayerTransform = GameObject.FindGameObjectWithTag("Player").GetComponent<Transform>();
}
void Start()
{
CurrentState = ENEMY_STATE.PATROL;
}
public IEnumerator AIPatrol()
{
yield break;
}
public IEnumerator AIChase()
{
yield break;
}
public IEnumerator AIAttack()
{
while(currentstate == ENEMY_STATE.ATTACK)
{
ThisAgent.Resume();
ThisAgent.SetDestination(PlayerTransform.position);
while(ThisAgent.pathPending)
yield return null;
if(ThisAgent.remainingDistance > ThisAgent.stoppingDistance)
{
CurrentState = ENEMY_STATE.CHASE;
yield break;
}
else
{
PlayerHealth.HealthPoints -= MaxDamage * Time.deltaTime;
}
yield return null;
}
yield break;
yield break;
}
}
- 当攻击状态(敌人处于这个状态之后会进行攻击)被激活以后,AIAttack coroutine就会无限制地执行。
- 变量MaxDamage用来指明敌人每秒会对玩家造成的伤害。
- AIAttack coroutine依靠Health来造成对生命值的伤害。这是一个自定义的生命值编码组件。玩家和敌人都应该拥有一个表示他们健康情况的生命值组件。
脚本Health(Health.cs)由AIEnemy类所引用,对玩家造成伤害。基于这个原因,玩家需要附加一个生命值组件,这个组件的代码如下所示。
Health.cs
1 | using UnityEngine; |
Health脚本十分简单,它负责维护一个数值形式的生命值,当这个生命值下降到0或者0以下时,就会摧毁这个脚本附加的对象。这个脚本必须附加到玩家角色上,允许NPC靠近玩家时对其造成伤害。同样,这个脚本也可以附加到NPC对象上,允许玩家对NPC进行攻击,如下图所示。
现在已经做好了对这个项目的测试准备。首先,把敌人对象做成一个预设体,具体的方法就是将NPC游戏对象从场景视图或者Hierarchy视图拖动到项目面板上,然后就可以在场景中创建任意数量的敌人了,如图10所示。
现在已经开发好了一个完整的环境,拥有智能的敌人可以寻找、追逐并攻击玩家。在某些情况下,可能需要对敌人FOV进行调整和改进,以便更好地匹配游戏环境和角色类型,如图11所示。
小结
我们创建了一个完整的地形、一个NPC预设体以及一系列协同工作实现了人工智能的脚本。