角色AI--状态模式

思考并回答以下问题:

  • 需要首先定义一个枚举,列举所有的AI状态,为什么?
  • FSM这三个词代表的意思并没有多大的技术含量。FSM有两种实现方式。1.switch case;2.状态模式。明确这一点怎么理解?
  • 状态模式就是新增状态接口类和几个具体状态类。

本章涵盖:

  • 角色的AI
  • 状态模式
  • 使用状态模式实现角色AI
    • 角色AI的实现
    • 实现说明
    • 使用状态模式的优点
    • 角色AI执行流程
  • 状态模式面对变化时
  • 结论

角色的AI

在前面几个章节中,我们将《P级阵地》的角色属性、装备武器、武器攻击流程进行了说明。本章我们将把重点放在如何能让角色在场景上“根据战场情况来移动或攻击”。

游戏开始时,玩家会先决定由哪一个兵营产生角色,而角色在经过一段时间的训练后,就会出现在战场上,负责守护阵地防止被敌方角色占领。同时,画面的右方会出现敌方角色,并且不断地朝玩家阵地前进,他们的目的是“占领玩家阵地”。当双方角色在地图上遭遇时会相互攻击,这时候,玩家角色要击退敌人,而敌人角色则是努力突破防线。在过程中,玩家无法参与指挥任何一只角色,任由他们自动决定要如何行动。

在玩家不能参与操作角色的情况下,双方角色要如何自动攻击和防守呢?一般会使用所谓的“人工智能”(AI)来实现这一目标。或许读者会认为“人工智能”是一门很高深的技术,其实不然,它不像字义表面那么复杂,有时候它也可以用很简单的方式来实现。

在实现前先分析一下游戏需求,列出双方阵营的行为模式:

  • 玩家阵营角色出现在战场时,原地不动,之后:
    • 当侦测到敌方阵营角色在“侦测范围”内时,往敌方角色移动。
    • 当角色抵达“武器可攻击的距离”时,使用武器攻击对手。
    • 当对手阵亡时,寻找下一个目标。
    • 当没有敌方阵营角色可以被找到,就停在原地不动。
  • 敌方阵营角色出现在战场时,往阵地中央前进,之后:
    • 当侦测到玩家阵营角色在“侦测范围”内时,往玩家角色移动。
    • 当角色到达“武器可攻击的距离”时,使用武器攻击对手。
    • 当对手阵亡时,寻找下一个目标。
    • 当没有玩家阵营角色可以被找到,就往阵地中央目标前进。

通过上述的分析,可以得知,双方阵营的角色都有4个条件作为判断的依据,而这些条件都可以改变角色的行为(状态)。

例如,原本一出现在场景上的玩家角色A,其状态为“闲置状态(Idle)”。而进入闲置(Idle)状态的单位A,会不断地侦测它的“视野范围”内是否有可攻击的目标(敌方阵营单位)。此时,敌方角色B出现在场景中,并且会往阵地中央前进,如图1所示。

图1 闲置状态

当角色B进入单位A的“视野范围”内时,单位A即进入“追击状态(Chase)”并往单位B方向移动,如图2所示。

图2 追击状态

当单位A追击B到达武器的“射程距离”内时,即进入“攻击状态(Attack)”并使用武器攻击单位B,如图3所示。

图3 攻击状态

在经过一番交火之后,当单位B阵亡时,且单位A又回到“闲置状态(Idle)”,则寻找下一个可攻击的单位,如图4所示。

图4 恢复闲置状态

所以单位A是在不同的状态之间进行切换,因此,实现时可以使用“有限状态机”来完成上述的需求。“有限状态机”通常用来说明系统在几个“状态”之间进行转换,可以用图5来表示。

图5 玩家阵营角色的AI转换状态机

敌方阵营角色的AI转换则可用如图6所示的状态图来表示。

图6 敌对阵营角色的AI转换状态机

“有限状态机”用于游戏的AI开发时,并不是特别复杂的技术或理论,只需要应用者定义好几个“状态”,并且将每个状态的“转换规则”定义好,就可以使用“有限状态机”来完成AI的功能。

在《P级阵地》开始实现时,可以使用C#的枚举(enum)功能,将所有可能的状态列举出来,并且在角色类ICharacter中增加一个AI状态的属性。另外,也将在各状态下使用到的参数一并定义进去:

Listing1 角色AI的第一次实现

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
// AI状态
public enum ENUM_AI_State
{
Idle = 0, // 闲置
Chase, // 追击
Attack, // 攻击
Move, // 移动
}

// 角色接口
public abstract class ICharacter
{
// 状态
protected ENUM_AI_State m_AiState = ENUM_AI_State.Idle;

// 移动相关
protected const float MOVE_CHECK_DIST = 1.5f;
protected bool m_bOnMove = false;

// 是否有攻击的地点
protected bool m_bSetAttackPosition = false;
protected Vector3 m_AttackPosition;

// 追击的对象
protected bool m_bOnChase = false;
protected ICharacter m_ChaseTarget = null;
protected const float CHASE_CHECK_DIST = 2.0f;

// 攻击的对象
protected ICharacter m_AttackTarget = null;

// 更新AI
public abstract void UpdateAI(List<ICharacter> Targets);
...
}

因为游戏的需求,两个阵营角色的行为有如下差异:

  • 玩家阵营:没有目标时,设为闲置状态(Idle),并留在原地。
  • 敌方阵营:没有目标时,设为移动状态(Move),并向攻击的目标前进。

所以,将AI更新方法UpdateAI声明为抽象方法,分别由玩家阵营类ISoldier和敌方阵营类IEnemy两个子类重新实现。以下是ISoldier的实现:

Listing2 Soldier实现AI状态转换

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
public class ISoldier : ICharacter
{
// 更新AI
public override void UpdateAI(List<ICharacter> Targets)
{
switch( m_AiState )
{
case ENUM_AI_State.Idle: // 闲置
// 找出最近的目标
ICharacter theNearTarget = GetNearTarget(Targets);
if( theNearTarget==null)
return;

// 是否在距离內
if( TargetInAttackRange( theNearTarget ))
{
m_AttackTarget = theNearTarget;
m_AiState = ENUM_AI_State.Attack; // 攻击状态
}
else
{
m_ChaseTarget = theNearTarget;
m_AiState = ENUM_AI_State.Chase; // 追击状态
}
break;

case ENUM_AI_State.Chase: // 追击
// 没有目标时,改为闲置
if(m_ChaseTarget == null || m_ChaseTarget.IsKilled() )
{
m_AiState = ENUM_AI_State.Idle;
return ;
}

// 在攻击目标内,改为攻击
if( TargetInAttackRange( m_ChaseTarget ))
{
StopMove();
m_AiState = ENUM_AI_State.Attack;
return ;
}

// 已经在追击
if( m_bOnChase)
{
// 超出追击的距离
float dist = GetTargetDist( m_ChaseTarget );
if( dist < CHASE_CHECK_DIST )
m_AiState = ENUM_AI_State.Idle;
return ;
}

// 往目标移动
m_bOnChase = true;
MoveTo( m_ChaseTarget.GetPosition() );
break;

case ENUM_AI_State.Attack: // 攻击
// 没有目标时,改为Idle
if(m_AttackTarget == null || m_AttackTarget.IsKilled() || Targets == null || Targets.Count==0 )
{
m_AiState = ENUM_AI_State.Idle;
return ;
}

// 不在攻击目标内,改为追击
if( TargetInAttackRange( m_AttackTarget) ==false)
{
m_ChaseTarget = m_AttackTarget;
m_AiState = ENUM_AI_State.Chase; // 追击状态
return ;
}

// 攻击
Attack( m_AttackTarget );
break;

case ENUM_AI_State.Move: // 移动
break;
}
}
}

以下是IEnemy的实现:

Listing3 Enemy角色实现AI状态转换

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
106
107
108
109
110
111
112
113
114
115
116
public class IEnemy : ICharacter
{
// 更新AI
public override void UpdateAI(List<ICharacter> Targets)
{
switch( m_AiState )
{
case ENUM_AI_State.Idle: // 闲置
// 没有目标时
if(Targets == null || Targets.Count==0)
{
// 有设置目标时,往目标移动
if( base.m_bSetAttackPosition )
m_AiState = ENUM_AI_State.Move;
return ;
}

// 找出最近的目标
ICharacter theNearTarget = GetNearTarget(Targets);
if( theNearTarget==null)
return;

// 是否在距离內
if( TargetInAttackRange( theNearTarget ))
{
m_AttackTarget = theNearTarget;
m_AiState = ENUM_AI_State.Attack; // 攻击状态
}
else
{
m_ChaseTarget = theNearTarget;
m_AiState = ENUM_AI_State.Chase; // 追击状态
}
break;

case ENUM_AI_State.Chase: // 追击
// 没有目标时,改为闲置
if(m_ChaseTarget == null || m_ChaseTarget.IsKilled() )
{
m_AiState = ENUM_AI_State.Idle;
return ;
}

// 在攻击目标内,改为攻击
if( TargetInAttackRange( m_ChaseTarget ))
{
StopMove();
m_AiState = ENUM_AI_State.Attack;
return ;
}

// 已经在追击
if( m_bOnChase)
{
// 超出追击的距离
float dist = GetTargetDist( m_ChaseTarget );
if( dist < CHASE_CHECK_DIST )
m_AiState = ENUM_AI_State.Idle;
return ;
}

// 往目标移动
m_bOnChase = true;
MoveTo( m_ChaseTarget.GetPosition() );
break;

case ENUM_AI_State.Attack: // 攻击
// 没有目标时,改为Idle
if(m_AttackTarget == null || m_AttackTarget.IsKilled() || Targets == null || Targets.Count==0 )
{
m_AiState = ENUM_AI_State.Idle;
return ;
}

// 不在攻击目标内,改为追击
if( TargetInAttackRange( m_AttackTarget) ==false)
{
m_ChaseTarget = m_AttackTarget;
m_AiState = ENUM_AI_State.Chase; // 追击状态
return ;
}

// 攻击
Attack( m_AttackTarget );
break;

case ENUM_AI_State.Move: // 移动
// 有目标时,改为闲置状态
if(Targets != null && Targets.Count>0)
{
m_AiState = ENUM_AI_State.Idle;
return ;
}

// 已经向目标移动
if( m_bOnMove)
{
// 是否到达目标
float dist = GetTargetDist( m_AttackPosition );
if( dist < MOVE_CHECK_DIST )
{
m_AiState = ENUM_AI_State.Idle;
if( IsKilled() == false)
CanAttackHeart(); // 可以攻击目标;
Killed(); // 设置死亡
}
return ;
}

// 往目标移动
m_bOnMove = true;
MoveTo( m_AttackPosition );
break;
}
}
}

两个类都在UpdateAI方法中实现了“条件判断”和“有限状态机”的切换。但因为两个阵营对于没有目标时的需求不同,所以在闲置状态(ENUM_AI_State.Idle)和移动状态(ENUM_AI_State.Move)的处理方式不太一样,其他状态大部分是差不多的。

游戏场景的转换--状态模式中说明《P级阵地》转换场景的功能时曾提及,“有限状态机”使用switch case来实现时会有一些缺点。所以,在第一次的实现范例中,也同样出现了类似的缺点:

  • 1.只要增加一个状态,则所有switch(state)的程序代码都需要增加对应的程序代码。
  • 2.与每一个状态有关的对象和参数都必须被保留在同一个类中,当这些对象与参数被多个状态共享时,可能会产生混淆,不太容易了解是由哪个状态设置的。
  • 3.两个类的UpdateAI方法都过于冗长,不易了解和调试。或许可以将两个类中重复的程序代码重构为父类的方法来共享,但这样一来,又会造成父类ICharacter也过于庞大。

同样地,既然能使用“有限状态机”来实现角色的AI功能,那么就可以使用状态模式来解决上述缺点。

状态模式

有限状态机最简单的实现方式,就是使用switch case来实现。故而以往很容易看到,一个类方法中被一大串的switch case占据。有重构习惯的程序设计师会想办法让每一个case下的程序代码能够写到类方法中,但对于“状态转换”和“不共享参数的保护”也会是个麻烦的地方。善用状态模式可以让有限状态机变得不那么复杂。

GoF对状态模式的详细说明,已经在游戏场景的转换--状态模式中完整介绍过了,在此还是将结构图和角色说明列出,如图7所示。

图7 采用状态模式实现时的类结构图

参与者的说明如下:

  • Context(状态拥有者)
    • 是有一个具有“状态”属性的类,可以制订相关的接口,让外界能够得知状态的改变或通过操作改变状态。
    • 有状态属性的类,例如:游戏角色有潜行、攻击、施法等状态;好友有上线、脱机、忙碌等状态;GoF使用TCP连接为例,有已连接、等待连接、断线等状态。
    • 会有一个ConcreteState[X]子类的对象为其成员,用来代表当前的状态。
  • State(状态接口类)
    • 制定状态的接口,负责规范Context(状态拥有者)在特定状态下要表现的行为。
  • ConcreteState(具体状态类)
    • 继承自State(状态接口类)。
    • 实现Context(状态拥有者)在特定状态下该有的行为。例如,实现角色在潜行状态时该有的行动变缓、3D模型要半透明、不能被敌方角色察觉等行为。

程序代码的实现部分在游戏场景的转换--状态模式中有详细说明,在此不再列出。

使用状态模式实现角色AI

就如之前提到的,状态模式是游戏程序设计中被应用最频繁的一个模式,而游戏程序设计师的新手第一次学习“有限状态机”的场合,多半是应用在AI的实现上。游戏程序设计书籍多半是以switch case作为入门的实现方式。当程序设计师了解有限状态机和状态模式的关联之后,想要转换到运用模式来实现,就不会那么困难了。

角色AI的实现

在开始运用状态模式时,先将《P级阵地》中的AI功能从角色类中独立出来。所以,先声明一个角色AI抽象类ICharacterAI,而继承它的SoldierAI和EnemyAI则分别代表玩家角色和敌方角色的AI。ICharacterAI类中拥有一个代表当前状态的IAIState类对象,IAIState的子类们分别代表角色当前的状态。类结构图如图8所示。

图8 采用状态模式实现游戏角色AI的类结构图

参与者的说明如下:

  • IAIState:角色的AI状态,定义《P级阵地》中角色AI操作时所需的接口。
  • AttackAIState、ChaseAIState、IdleAIState、MoveAIState:分别代表角色AI的状态:攻击(Attack)、追击(Chase)、闲置(Idle)、移动(Move)等状态,并负责实现角色在各自状态下应该有的游戏行为和判断。这些状态都可以设置给双方阵营角色。
  • ICharacterAI:双方阵营角色的AI接口,定义游戏所需的AI方法,并实现相关AI操作。类的定义中,拥有代表当前AI状态的IAIState类对象,也负责执行角色AI状态的切换。
  • SoldierAI、EnemyAI:ICharacterAI的子类,由于游戏设计要求双方阵营在AI行为上有不同的表现,所以将不同的行为表现在不同的子类中实现。

实现说明

AI状态接口IAIState,定义了在不同的AI状态下共同的操作接口:

Listing4 AI状态接口(IAIState.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public abstract class IAIState 
{
protected ICharacterAI m_CharacterAI = null; // 角色AI(状态的拥有者)

public IAIState()
{}

// 设置CharacterAI的对象
public void SetCharacterAI(ICharacterAI CharacterAI)
{
m_CharacterAI = CharacterAI;
}

// 设置要攻击的目标
public virtual void SetAttackPosition( Vector3 AttackPosition )
{}

// 更新
public abstract void Update( List<ICharacter> Targets );

// 目标被删除
public virtual void RemoveTarget(ICharacter Target)
{}
}

IAIState定义中的ICharacterAI类对象引用m_CharacterAI,主要指向AI状态的拥有者,通过该对象引用可以要求角色更换当前的AI状态。《P级阵地》一共实现了4个主要AI状态,分别为攻击(Attack)、追击(Chase)、闲置(Idle)、移动(Move),这些状态是双方阵营都可以使用到的。但因为双方阵营在闲置(Idle)状态下有不同的行为表现,这一部分的实现方式会在闲置状态类IdleAIState中进行判断:

Listing5 闲置状态(IdleAIState.cs)

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
public class IdleAIState : IAIState 
{
bool m_bSetAttackPosition = false; // 是否设置了攻击目标

public IdleAIState()
{}

// 设置要攻击的目标
public override void SetAttackPosition( Vector3 AttackPosition )
{
m_bSetAttackPosition = true;
}

// 更新
public override void Update( List<ICharacter> Targets )
{
// 没有目标时
if(Targets == null || Targets.Count == 0)
{
// 有设置目标时,往目标移动
if( m_bSetAttackPosition )
m_CharacterAI.ChangeAIState( new MoveAIState());
return ;
}

// 找出最近的目标
Vector3 NowPosition = m_CharacterAI.GetPosition();
ICharacter theNearTarget = null;
float MinDist = 999f;

foreach(ICharacter Target in Targets)
{
// 已经阵亡的不计算
if( Target.IsKilled())
continue;

float dist = Vector3.Distance( NowPosition, Target.GetGameObject().transform.position);

if( dist < MinDist)
{
MinDist = dist;
theNearTarget = Target;
}
}

// 没有目标,会不动
if( theNearTarget==null)
return;

// 是否在距离内
if( m_CharacterAI.TargetInAttackRange( theNearTarget ))
m_CharacterAI.ChangeAIState( new AttackAIState( theNearTarget ));
else
m_CharacterAI.ChangeAIState( new ChaseAIState( theNearTarget ));
}
}

闲置状态中利用“是否设置了攻击目标”,也就是m_bSetAttackPosition这个属性被设置与否,来决定角色在闲置状态下会不会转换为移动状态。而当前只有EnemyAI会通过调用SetAttackPosition“设置要攻击的目标”方法来启用这个功能,这方法主要是通知敌方阵营角色,在没有目标可攻击时,向阵地中心的方向前进。

Update是闲置状态的更新方法,它会从参数传递进来的目标中挑选一个最近的作为攻击目标。当攻击目标存在时,会先判断目标是否在武器可攻击的距离内,如果是在可攻击的距离内,则将角色更换为攻击状态,并攻击该目标:

Listing6 攻击状态(AttackAIState.cs)

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
public class AttackAIState : IAIState 
{
private ICharacter m_AttackTarget = null; // 攻击的目标

public AttackAIState( ICharacter AttackTarget )
{
m_AttackTarget = AttackTarget;
}

// 更新
public override void Update( List<ICharacter> Targets )
{
// 没有目标时,改为Idel
if(m_AttackTarget == null || m_AttackTarget.IsKilled() || Targets == null || Targets.Count==0 )
{
m_CharacterAI.ChangeAIState( new IdleAIState());
return ;
}

// 不在攻击目标内,改为追击
if( m_CharacterAI.TargetInAttackRange( m_AttackTarget) ==false)
{
m_CharacterAI.ChangeAIState( new ChaseAIState(m_AttackTarget));
return ;
}

// 攻击
m_CharacterAI.Attack( m_AttackTarget );
}

// 目标被删除
public override void RemoveTarget(ICharacter Target)
{
if( m_AttackTarget.GetGameObject().name == Target.GetGameObject().name )
m_AttackTarget = null;
}
}

攻击状态类会将攻击目标记录下来,并在更新方法Update中进行攻击。但如果目标角色已经阵亡或不存在时,则切换为闲置状态。另外,当目标角色的距离大于武器可攻击的范围时,则将AI状态改为追击状态,并将追击的目标设置给追击状态类:

Listing7 追击状态(ChaseAIState.cs)

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
public class ChaseAIState : IAIState 
{
private ICharacter m_ChaseTarget = null; // 追击的目标

private const float CHASE_CHECK_DIST = 0.2f;
private Vector3 m_ChasePosition = Vector3.zero;
private bool m_bOnChase = false;

public ChaseAIState(ICharacter ChaseTarget)
{
m_ChaseTarget = ChaseTarget;
}

// 更新
public override void Update( List<ICharacter> Targets )
{
// 没有目标时,改为闲置
if(m_ChaseTarget == null || m_ChaseTarget.IsKilled() )
{
m_CharacterAI.ChangeAIState( new IdleAIState());
return ;
}

// 在攻击目标内,改为攻击
if( m_CharacterAI.TargetInAttackRange( m_ChaseTarget ))
{
m_CharacterAI.StopMove();
m_CharacterAI.ChangeAIState( new AttackAIState(m_ChaseTarget));
return ;
}

// 已经在追击
if( m_bOnChase)
{
// 已到达追击目标,但目标不见了,改为闲置
float dist = Vector3.Distance( m_ChasePosition, m_CharacterAI.GetPosition());

if( dist < CHASE_CHECK_DIST )
m_CharacterAI.ChangeAIState( new IdleAIState());

return ;
}

// 往目标移动
m_bOnChase = true;
m_ChasePosition = m_ChaseTarget.GetPosition();
m_CharacterAI.MoveTo( m_ChasePosition );
}

// 目标被删除
public override void RemoveTarget(ICharacter Target)
{
if( m_ChaseTarget.GetGameObject().name == Target.GetGameObject().name )
m_ChaseTarget = null;
}
}

记录好追击的目标之后,追击状态类会在更新方法Update中持续地让角色往目标前进,直到目标进入武器可攻击的范围内时,转换为攻击状态(Attack)。但如果目标角色距离太远而超出追击范围(CHASE_CHECK_DIST)时,或者目标阵亡被删除时,就会转为闲置状态。

转为闲置状态的角色,会根据有没有设置“攻击位置”来决定是否要往“攻击位置”移动。若要往“攻击位置”移动,则会将状态转换为移动状态(Move),并将目标位置设置给移动状态类:

Listing8 移动的目标状态(MoveAIState.cs)

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
public class MoveAIState : IAIState 
{
private const float MOVE_CHECK_DIST = 1.5f;
bool m_bOnMove = false;
Vector3 m_AttackPosition = Vector3.zero;

public MoveAIState()
{}

// 设置要攻击的目标
public override void SetAttackPosition( Vector3 AttackPosition )
{
m_AttackPosition = AttackPosition;
}

// 更新
public override void Update( List<ICharacter> Targets )
{
// 有目标时,改为闲置状态
if(Targets != null && Targets.Count>0)
{
m_CharacterAI.ChangeAIState( new IdleAIState() );
return ;
}

// 已经向目标移动
if( m_bOnMove)
{
// 是否到达目标
float dist = Vector3.Distance( m_AttackPosition, m_CharacterAI.GetPosition());

if( dist < MOVE_CHECK_DIST )
{
m_CharacterAI.ChangeAIState( new IdleAIState());
if( m_CharacterAI.IsKilled()==false)
m_CharacterAI.CanAttackHeart();
// Debug.Log ("攻击到目标");
m_CharacterAI.Killed();
}
return ;
}

// 往目标移动
// Debug.Log ("MoveAIState.往目标移动");
m_bOnMove = true;
m_CharacterAI.MoveTo( m_AttackPosition );
}
}

移动状态类记录攻击位置后,在更新方法Update中让角色往“攻击位置”移动。其间如果发现有可攻击的目标出现,就马上转为闲置状态,由闲置状态类来决定要攻击目标还是追击目标。当角色到达“攻击位置”,通知角色AI类ICharacterAI执行“占领阵地”,之后将自己设置为阵亡,实现目标。

而角色AI类ICharacterAI中,拥有一个AI状态对象引用,上述范例中所有状态的切换都需要通过该对象来进行(ICharacterAI类在本小节的最后还会进行一些修改):

Listing9 角色AI类(ICharacterAI.cs)

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
public abstract class ICharacterAI 
{
protected ICharacter m_Character = null;
protected float m_AttackRange = 2;
protected IAIState m_AIState = null; // 角色AI状态

protected const float ATTACK_COOLD_DOWN = 1f; // 攻击的CoolDown
protected float m_CoolDown = ATTACK_COOLD_DOWN;

public ICharacterAI( ICharacter Character)
{
m_Character = Character;
m_AttackRange = Character.GetAttackRange() ;
}

// 更换AI状态
public virtual void ChangeAIState( IAIState NewAIState)
{
m_AIState = NewAIState;
m_AIState.SetCharacterAI( this );
}

// 攻击目标
public virtual void Attack( ICharacter Target )
{
// 时间到了再攻击
m_CoolDown -= Time.deltaTime;
if( m_CoolDown >0)
return ;
m_CoolDown = ATTACK_COOLD_DOWN;

// Debug.Log("攻击目标:"+Target.GetGameObject().gameObject.name);
m_Character.Attack( Target );
}

// 是否在攻击距离内
public bool TargetInAttackRange( ICharacter Target )
{
float dist = Vector3.Distance( m_Character.GetPosition() ,
Target.GetPosition() );
return ( dist <= m_AttackRange );
}

// 当前的位置
public Vector3 GetPosition()
{
return m_Character.GetGameObject().transform.position;
}

// 移动
public void MoveTo( Vector3 Position )
{
m_Character.MoveTo( Position );
}

// 停止移动
public void StopMove()
{
m_Character.StopMove();
}

// 设置阵亡
public void Killed()
{
m_Character.Killed();
}

// 是否阵亡
public bool IsKilled()
{
return m_Character.IsKilled();
}

// 目标删除
public void RemoveAITarget( ICharacter Target )
{
m_AIState.RemoveTarget( Target);
}

// 更新AI
public void Update(List<ICharacter> Targets)
{
m_AIState.Update( Targets );
}

// 是否可以攻击Heart
public abstract bool CanAttackHeart();
}

更换AI状态方法ChangeAIState,除了会记录新的AI状态对象,也将自己的对象引用设置给新的AI状态对象。此外,还提供与游戏角色AI功能实现时所需要的操作方法。双方角色分别继承ICharacterAI后,实现各自的阵营AI:

Listing10 玩家阵营角色AI(SoldierAI.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SoldierAI : ICharacterAI 
{
public SoldierAI(ICharacter Character):base(Character)
{
// 起始状态
ChangeAIState(new IdleAIState());
}

// 是否可以攻击Heart
public override bool CanAttackHeart()
{
return false;
}
}

Listing11 敌方角色AI(EnemyAI.cs)

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
public class EnemyAI : ICharacterAI 
{
private static StageSystem m_StageSystem = null;
private Vector3 m_AttackPosition = Vector3.zero;

// 直接将关卡系统注入EnemyAI类使用
public static void SetStageSystem(StageSystem StageSystem)
{
m_StageSystem = StageSystem;
}

public EnemyAI(ICharacter Character, Vector3 AttackPosition):base(Character)
{
m_AttackPosition = AttackPosition;

// 起始状态
ChangeAIState(new IdleAIState());
}

// 更换AI状态
public override void ChangeAIState( IAIState NewAIState)
{
base.ChangeAIState( NewAIState);

// Enemy的AI要设置攻击的目标
NewAIState.SetAttackPosition( m_AttackPosition );
}

// 是否可以攻击Heart
public override bool CanAttackHeart()
{
// 通知少一个heart
m_StageSystem.LoseHeart();
return true;
}
}

最后,将原本在角色类中旧的AI实现程序代码删除,增加一个ICharacterAI类对象作为执行角色AI功能的对象,并提供必要的操作方法:

Listing12 角色接口(ICharacter.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public abstract class ICharacterAI 
{
protected ICharacter m_Character = null; // AI
...

// 设置AI
public void SetAI(ICharacterAI CharacterAI)
{
m_AI = CharacterAI;
}

// 更新AI
public void UpdateAI(List<ICharacter> Targets)
{
m_AI.Update( Targets );
}

// 通知AI有角色被删除
public void RemoveAITarget( ICharacter Targets )
{
m_AI.RemoveTarget( Targets);
}
...
}

使用状态模式的优点

游戏角色的AI有时并不难,使用有限状态机即可完成。而有限状态机最适合运用状态模式来实现,并具有以下优点:

减少错误发生及降低维护的难度

不使用switch(m_AiState)来实现AI功能,可以减少新增AI状态时,因为没有检查到所有switch()程序代码而造成的错误,也让原本庞大的AI更新方法大为缩减,有利于后续的维护。

状态执行环境单一化

与每一个AI状态有关的对象及参数,都分别被包含在一个AI状态类下,所以可以清楚地了解每一个AI状态执行时,需要使用的对象及搭配的类。另外,与其他类使用的对象分开,也可以减少错误设置发生的机会。

角色AI执行流程

角色AI的执行流程图如图9所示。下面的流程图显示出,某一角色从“闲置状态”中发现可攻击目标后,转换为“追击状态”。在“追击状态”下,执行向目标移动的功能,并在武器可攻击的范围内转换为“攻击状态”,最后则是在“攻击状态”下攻击目标。

图9 角色AI的执行流程图

状态模式面对变化时

就在某一天,策划又来找小程了…

策划:“小程。”
小程:“又有什么需求想要更改啊?”
策划:“是这样的,我最近测试时突然觉得,玩家角色在阵地里站着等下一波敌人出现时,傻傻地站在原地,有点奇怪。”
小程:“嗯…是有那么点呆呆的感觉。”
策划:“是吧?你也这样觉得。那我们来改一下好了,玩家阵营角色在‘没有可攻击目标时’加入一个‘守卫状态’,这样你觉得如何,就像这张新的状态图(如图10所示)一样。”

图10 增加了“守卫状态”的新状态图

小程:“那玩家阵营角色在‘守卫状态’时,要执行什么功能吗?”
策划:“我想一下…那就到处走走吧。”

小程想了一下,在当前角色AI以状态模式实现的情况下,增加一个状态并不是太困难的任务。所以,小程新增了一个“守卫状态类”:

Listing13 守卫状态(GuardAIState.cs)

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
public class GuardAIState : IAIState 
{
bool m_bOnMove = false;
Vector3 m_Position = Vector3.zero;
const int GUARD_DISTANCE = 3;

public GuardAIState()
{}

// 更新
public override void Update( List<ICharacter> Targets )
{
// 有目标时,改为闲置状态
if(Targets != null && Targets.Count>0)
{
m_CharacterAI.ChangeAIState( new IdleAIState() );
return ;
}

if( m_Position == Vector3.zero)
GetMovePosition();

// 目标已经移动
if( m_bOnMove)
{
// 是否到达目标
float dist = Vector3.Distance( m_Position, m_CharacterAI.GetPosition());

if( dist > 0.5f )
return ;

// 换下一个位置
GetMovePosition();
}

// 往目标移动
m_bOnMove = true;
m_CharacterAI.MoveTo( m_Position );
}

// 设置移动的位置
private void GetMovePosition()
{
m_bOnMove = false;

// 获取随机位置
Vector3 RandPos = new Vector3( UnityEngine.Random.Range(-GUARD_DISTANCE, GUARD_DISTANCE),
0,
UnityEngine.Random.Range(-GUARD_DISTANCE, GUARD_DISTANCE));

// 设置为新的位置
m_Position = m_CharacterAI.GetPosition() + RandPos;
}
}

在“守卫状态”的角色,会不断地向随机位置移动。但是发现攻击目标出现时,就会马上转换为“闲置状态”,让闲置状态决定是要追击还是攻击目标。

完成守卫状态类后,再修改原来的“闲置状态”类,让“没有设置攻击目标”的角色,能转换成“守卫状态”:

Listing14 闲置状态(IdleAIState.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class IdleAIState : IAIState 
{
// 更新
public override void Update( List<ICharacter> Targets )
{
// 没有目标时
if(Targets == null || Targets.Count==0)
{
// 设置了目标时,往目标移动
if( m_bSetAttackPosition )
m_CharacterAI.ChangeAIState( new MoveAIState());
else
m_CharacterAI.ChangeAIState(new GuardAIState());
return ;
}
...
}
...
}

小程在完成修改后,评估了一下:新增了一个类GuardAIState及修改了原有的闲置状类IdleAIState,对原有架构并未造成太大的变化。包含执行完单元测试(UnitTest),花费了不到1小时,所以在现有的状态模式设计基础下,对于这个游戏需求的修改,可以说是有效率的。

结论

使用状态模式可以清楚地了解单一状态执行时的环境,减少因新增状态而需要大量修改现有程序代码的维护成本。

而在《P级阵地》中,只规划了4个状态来实现游戏角色的攻击等实现需求,但对于较复杂的AI行为,可能会产生过多的“状态类”,而造成大量类产出的问题,这算是其中的缺点。不过,先前已经提到过:与传统使用switch(state_code)的实现方式相比,使用状态模式对于项目后续的长期维护上,仍是较具优势的。

与其他模式的合作

角色的组装-建造者模式中,《P级阵地》将使用建造者模式(Builder)来负责游戏中角色对象的产生,当角色产生时,需要设置该角色使用的AI类和状态,这部分会由各阵营的建造者(Builder)来完成。

这也是另一个桥接模式(Bridge)的范例

如果读者再仔细分析一下XXXX节的角色AI类结构图及实现程序代码,以及XXXX节增加的“守卫状态”的修改方式,就可以理解,角色AI类(ICharacterAI)与AI状态类(IAIState)两者之间其实是采用桥接模式(Bridge)进行连接的。

角色AI类(ICharacterAI)是“抽象类”,定义了与AI有关的行为和操作,它的子类只负责增加不同的“抽象类”,如玩家角色AI(SoldierAI)和敌方角色AI (EnemyAI),而AI状态类(IAIState)是“实现类”,负责实现AI的行为和状态之间的转换(使用State来实现)。所以,当项目需要增加“守卫状态”时,不会影响到角色AI类(ICharacterAI)群组;当项目再新增一个角色AI类(ICharacterAI)时,也一定不会影响到现有的AI状态类(IAIState)群组。

其他应用方式

奇幻类型的角色扮演游戏(RPG)中,常有设置目标遭到法术攻击后会呈现的“特殊状态”,例如:

  • 冰冻:角色不能移动,有特效出现。
  • 晕眩:角色不能移动,会有眩晕动作。
  • 变身:角色变成另一种形体,会在场上乱走动。

这些特殊状态,都可以使用状态模式来实现,但限制是,只能同时存在一个状态。

角色类

经过前面几章的介绍后,《P级阵地》角色类ICharacter的功能就大致完成了,角色架构如图1所示。

图11 角色架构1

在图1中,包含了与角色功能相关的类:

  • 角色属性类ICharacterAttr:记录角色当前的最高生命值、攻击力,并负责计算攻击流程中所需要的属性。
  • 武器类IWeapon:角色可以装备的武器。
  • 角色AI类ICharacterAI:负责角色在游戏中攻击和防守等自动行为。

另外图1中还包含一些与Unity3D引擎有关的几个组件:

  • UnityEngine.GameObject:负责角色在游戏中的3D模型数据,通过该对象引用可以设置Unity3D相关的功能,而有关实际创建出角色3D模型数据,将在下一个阶段进行说明。
  • UnityEngine.AudioSource:负责播放角色在游戏进行中发出的音效。
  • UnityEngine.NavMeshAgent:负责角色在场景中的自动寻路功能。以往,游戏中如果实现自动寻路功能,多半要自行开发寻路(Path Finding)算法(A*,Dijkstra……)。不过Unity3D引擎已经内置了不错的寻路系统,可节省许多开发时间。

角色类ICharacter算是《P级阵地》的重要类之一,但要让其实际运行起来,还需要一些系统进行协助,如图2所示。

图12 角色与其他系统

  • 游戏角色管理系统CharacterSystem:管理游戏中双方阵营所产生的角色;并通过它的定期更新功能,让角色AI系统可以运行并产生自动化行为(攻击、防守)。
  • 游戏角色生产和组装功能:一个游戏角色包含了3个游戏系统组件和Unity3D引擎相关的对象。所以在角色组装系统CharacterBuilderSystem中,会经过一定的步骤和流程,将这些组件产生并设置给一个角色。此外,将Unity3D引擎中的模型从资源目录下加载,并放入场景中也有一定的步骤。关于角色的组装,将在之后几章进行详细说明。
  • 兵营系统与关卡系统:玩家通过兵营系统CampSystem产生玩家阵营的角色来防守阵地。而关卡系统StageSystem则是按照设置,不断地产生敌方角色来进攻玩家的阵地。

游戏角色管理系统

游戏角色管理系统CharacterSystem,在《P级阵地》中负责管理角色类ICharacter的对象。它是游戏主要类--外观模式中提到的一个“游戏系统IGameSystem”,它的类对象会在PBaseDefenseGame类中被定义和初始化,并在PBaseDefenseGame类的定期更新方法Update中被更新。

所谓的游戏角色“管理”指的是,角色管理系统CharacterSystem类会将当前游戏产生的角色类对象“记录”下来,并提供接口让客户端可以新增、删除、获取这些被记录的角色对象。而此处所称的“记录”则是使用C#的容器类List来完成。通过记录管理这些对象,让游戏系统可以有效地进行角色更新、数据查询、资源释放等操作。最重要的是,游戏中的角色之所以能够自动攻击和防守,就是由游戏角色管理系统CharacterSystem来执行的。

游戏角色管理系统CharacterSystem的类定义中,先定义的两个List容器类,分别来记录玩家角色和敌方角色:

1
2
3
4
5
6
7
// 管理创建出来的角色
public class CharacterSystem : IGameSystem
{
private List<ICharacter> m_Soldiers = new List<ICharacter>();
private List<ICharacter> m_Enemys = new List<ICharacter>();
...
}

并且提供与这两个容器相关的“管理”功能,包含新增、删除等方法:

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
// 管理容器的相关方法
// 增加Soldier
public void AddSoldier( ISoldier theSoldier)
{
m_Soldiers.Add( theSoldier );
}

// 删除Soldier
public void RemoveSoldier( ISoldier theSoldier)
{
m_Soldiers.Remove( theSoldier );
}

// 增加Enemy
public void AddEnemy( IEnemy theEnemy)
{
m_Enemys.Add( theEnemy );
}

// 删除Enemy
public void RemoveEnemy( IEnemy theEnemy)
{
m_Enemys.Remove( theEnemy );
}


// 删除角色
public void RemoveCharacter()
{
// 删除掉可以删除的角色
RemoveCharacter( m_Soldiers, m_Enemys, ENUM_GameEvent.SoldierKilled );
RemoveCharacter( m_Enemys, m_Soldiers, ENUM_GameEvent.EnemyKilled);
}

// 删除角色
public void RemoveCharacter(List<ICharacter> Characters, List<ICharacter> Opponents, ENUM_GameEvent emEvent)
{
// 分別获取可以删除和存活的角色
List<ICharacter> CanRemoves = new List<ICharacter>();
foreach( ICharacter Character in Characters)
{
// 是否阵亡
if( Character.IsKilled() == false)
continue;

// 是否确认过阵亡事件
if( Character.CheckKilledEvent()==false)
m_PBDGame.NotifyGameEvent( emEvent,Character );

// 是否可以删除
if( Character.CanRemove())
CanRemoves.Add (Character);
}

// 删除
foreach( ICharacter CanRemove in CanRemoves)
{
// 通知对手删除
foreach(ICharacter Opponent in Opponents)
Opponent.RemoveAITarget( CanRemove );

// 释放资源并删除
CanRemove.Release();
Characters.Remove( CanRemove );
}
}

// Enemy数量
public int GetEnemyCount()
{
return m_Enemys.Count;
}

游戏角色管理系统的定期更新中,会先让所有角色进行更新,再进行角色AI的功能更新:

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
// 系统定期更新
// 更新
public override void Update()
{
UpdateCharacter();
UpdateAI(); // 更新AI
}

// 更新角色
private void UpdateCharacter()
{
foreach( ICharacter Character in m_Soldiers)
Character.Update();
foreach( ICharacter Character in m_Enemys)
Character.Update();
}

// 更新AI
private void UpdateAI()
{
// 分別更新两个群组的AI
UpdateAI(m_Soldiers, m_Enemys );
UpdateAI(m_Enemys, m_Soldiers );

// 删除角色
RemoveCharacter();
}

// 更新AI
private void UpdateAI( List<ICharacter> Characters, List<ICharacter> Targets )
{
foreach( ICharacter Character in Characters)
Character.UpdateAI( Targets );
}

这里的“更新”并不是指Unity3D引擎MonoBehaviour中的Update方法,而是进行我们为开发需求所设计的“游戏系统”更新。就像在游戏的主循环-Game-Loop中提到的“单一的游戏系统”,对于《P级阵地》来说,游戏角色管理系统CharacterSystem和角色AI就是“单一的游戏系统”,所以必须通过之前设计的Game Loop机制来定期更新,并使它们运行:

“通过Game Loop,开发者可以为游戏系统定期更新功能,因为这个游戏系统类,不想通过继承MonoBehaviour且挂入某一个Unity游戏对象(GameObject)的方式,来拥有定期更新的功能。”

以角色AI功能为例,在UpdateAI的方法中,会分别更新两个阵营群组的AI方法。在更新每一个单位角色AI时(UpdateAI),都会将敌对阵营的全部角色以参数的方式传入。这样一来,每个角色在AI状态更新时,就会有全部的敌对角色可以引用,之后就可以从这些敌对角色中找出可攻击或追击的目标,接着完成AI状态的转换或维持现状。通过下面的流程图(如图13所示)就能了解整体系统的运行方式。

图13 系统流程图

在后续的阶段中,我们将继续介绍“游戏角色的生产和组装功能”以及“兵营系统与关卡系统”,也将介绍在一般实现时会遇到的问题,并提出使用设计模式的解决方法。

0%