学习Unity银河恶魔城游戏制作第三部分:敌人基础设置

Enemy状态机框架

敌人的状态机框架和主角的状态机框架是一一对应的,同样由Enemy.csEnemyState.csEnemyStateMachine.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
// EnemyState.cs
public class EnemyState
{
protected EnemyStateMachine stateMachine;
protected Enemy enemyBase;

private string animBoolName;

protected float stateTimer;
protected bool triggerCalled;

public EnemyState(Enemy _enemyBase, EnemyStateMachine _stateMachine, string _animBoolName)
{
this.enemyBase = _enemyBase;
this.stateMachine = _stateMachine;
this.animBoolName = _animBoolName;
}

public virtual void Update()
{
stateTimer -= Time.deltaTime;
}

public virtual void Enter()
{
triggerCalled = false;
enemyBase.anim.SetBool(animBoolName, true);
}

public virtual void Exit()
{
enemyBase.anim.SetBool(animBoolName, false);
}
}

要注意,由于后续针对不同类型的敌对生物还有不同的对应类(如Enemy_Skeleton.cs,见[[Skeleton基本类]]),因此此处初始化的Enemy对象名为_enemyBase,代表总的敌人类,而在具体的某个敌人的某个实际状态类中还会关联对应敌人的具体类(如Enemy_Skeleton.cs),因为一个敌人的某个具体状态,可能在另一个敌人身上不存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// EnemyStateMachine.cs
public class EnemyStateMachine
{
public EnemyState currentState;

public void Initialize(EnemyState _startstate)
{
currentState = _startstate;
currentState.Enter();
}

public void ChangeState(EnemyState _newState)
{
currentState.Exit();
currentState = _newState;
currentState.Enter();
}
}

要注意的是,其实怪物的附加脚本Enemy.cs与玩家的附加脚本Player.cs有很多共同点:玩家和怪物都需要刚体组件Rigidbody、动画组件Animator,同时都需要进行速度的设置、转身的设置、一些图示的绘制(Gizmos)等等,因此可以将这些共通的内容移到一个更高的父类中([[Entity类]]),而在玩家和怪物子类中分别继承此父类,再分别进行属于各自的独特设置。

最终怪物脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Enemy : Entity  
{
[Header("Move info")]
public float moveSpeed;
public float idleTime;

public EnemyStateMachine stateMachine { get; private set; }

protected override void Awake()
{
base.Awake();
stateMachine = new EnemyStateMachine();
}

protected override void Update()
{
base.Update();
stateMachine.currentState.Update();
}
}

要注意,由于游戏中有各种各样的不同怪物,因此为了实现代码的复用,可以将这个Enemy.cs也视为不同怪物类的统一父类。例如后续创建的骷髅怪物,就可以让其也继承自此Enemy.cs类(见[[Skeleton基本类]])。但是由于怪物的一些基础属性应该与玩家的类似属性进行区分和分别设置,因此一些怪物的基本属性设置也放在了这个脚本中(例如空闲时间和移动速度等)。

Entity类

此类是为了统一玩家和怪物,在此整合一系列各个生物都有的属性和方法。这样在创建任意生物对象时就可以继承自此类,省去了很多基础属性和基础方法的设置过程。

此类主要包含刚体rigidbody的设置、碰撞的检测、动画器animator的设置、速度的设置、对象朝向及翻转的设置、图示Gizmos的设置等内容。具体代码如下(代码来源为原[[Player类]]):

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
using UnityEngine;  

public class Entity : MonoBehaviour
{
#region Components
public Animator anim { get; private set; }
public Rigidbody2D rb { get; private set; }
#endregion
[Header("Collision Info")]
[SerializeField] protected Transform groundCheck;
[SerializeField] protected float groundCheckDistance;
[SerializeField] protected Transform wallCheck;
[SerializeField] protected float wallCheckDistance;
[SerializeField] protected LayerMask whatIsGround;

public int facingDir { get; private set; } = 1;
protected bool facingRight = true;

protected virtual void Awake()
{

}

protected virtual void Start()
{
anim = GetComponentInChildren<Animator>();
rb = GetComponent<Rigidbody2D>();
}

protected virtual void Update()
{

}

#region Velocity
public void ZeroVelocity() => rb.linearVelocity = new Vector2(0, 0);

public void SetVelocity(float _xVelocity, float _yVelocity)
{
rb.linearVelocity = new Vector2(_xVelocity, _yVelocity);
FlipController(_xVelocity);
}
#endregion
#region Collision
public virtual bool IsGroundDetected() => Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);
public virtual bool IsWallDetected() => Physics2D.Raycast(wallCheck.position, Vector2.right * facingDir, wallCheckDistance, whatIsGround);

protected virtual void OnDrawGizmos()
{
Gizmos.DrawLine(groundCheck.position, new Vector3(groundCheck.position.x, groundCheck.position.y - groundCheckDistance));
Gizmos.DrawLine(wallCheck.position, new Vector3(wallCheck.position.x + wallCheckDistance, wallCheck.position.y));
}
#endregion
#region Flip
public virtual void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}

public virtual void FlipController(float _x)
{
if (_x > 0 && !facingRight)
Flip();
else if (_x < 0 && facingRight)
Flip();
}
#endregion
}

Skeleton基本类

Enemy_Skeleton类是用以表示属于骷髅怪物的属性类。由于这是怪物的一种,因此此类需要继承自Enemy.cs父类(详见[[Enemy状态机框架]])。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Enemy_Skeleton : Enemy  
{
#region States
public SkeletonIdleState idleState { get; private set; }
public SkeletonMoveState moveState { get; private set; }
#endregion
protected override void Awake()
{
base.Awake();

idleState = new SkeletonIdleState(this, stateMachine, "Idle", this);
moveState = new SkeletonMoveState(this, stateMachine, "Move", this);
}

protected override void Start()
{
base.Start();
}
protected override void Update()
{
base.Update();
}
}

此类的设置和玩家类[[Player类]]类似,由于其代表骷髅怪物的基本信息,其需要内部初始化骷髅怪物可能进入的所有状态。同时要注意,由于将怪物类Enemy和特定怪物类(如Enemy_Skeleton)写成了父子关系的两个类,因此在具体类初始化时怪物父类Enemy和所属特定怪物类都需要传入(后续代码可以体现),而两个对象在此处都可以通过this关键字进行传输。

然后是两个地面属性,即SkeletonIdleState骷髅怪物静止状态与SkeletonMoveState骷髅怪物移动状态。和玩家的移动和静止状态也有相似之处,例如其对应Animator的状态设置,包括代码框架几乎一致,但是差别在于,玩家在静止和移动状态中切换,更多的是因为其外部输入的设置,而怪物在不同状态间的切换,更多源自于空闲时间预先设置、或者一些客观条件的判断。代码如下:

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
public class SkeletonIdleState : EnemyState  
{
private Enemy_Skeleton enemy;

public SkeletonIdleState(Enemy _enemyBase, EnemyStateMachine _stateMachine, string _animBoolName, Enemy_Skeleton _enemy) : base(_enemyBase, _stateMachine, _animBoolName)
{
enemy = _enemy;
}

public override void Update()
{
base.Update();

if (stateTimer < 0)
stateMachine.ChangeState(enemy.moveState);
}

public override void Enter()
{
base.Enter();

stateTimer = enemy.idleTime;
}

public override void Exit()
{
base.Exit();
}
}

怪物的静止状态完全由定时器控制:玩家可以在Enemy类中设置此怪物的空闲时间(见[[Enemy状态机框架]])并在进入此状态时将空闲时间赋给计时器,然后静止状态就完全通过计时器根据空闲时间倒计时,计时结束后自动切换至移动状态。

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
public class SkeletonMoveState : EnemyState  
{
private Enemy_Skeleton enemy;

public SkeletonMoveState(Enemy _enemyBase, EnemyStateMachine _stateMachine, string _animBoolName, Enemy_Skeleton _enemy) : base(_enemyBase, _stateMachine, _animBoolName)
{
this.enemy = _enemy;
}

public override void Update()
{
base.Update();

enemy.SetVelocity(enemy.moveSpeed * enemy.facingDir, enemy.rb.linearVelocity.y);

if (enemy.IsWallDetected() || !enemy.IsGroundDetected())
{
enemy.Flip();
stateMachine.ChangeState(enemy.idleState);
}
}

public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
}

移动状态中需要通过SetVelocity时刻为怪物提供速度,而其转换回到静止状态(之前需要改变面向方向)的条件是:检测到墙面,或者检测不到地面(继续前行会导致下坠)。一个细微的点是:为了防止怪物出现一部分悬空后才感知到超出地面的情况,怪物用来进行地面监测的GroundCheck子对象位置位于怪物对象的右下方,而非正下方,这样就可以确保怪物即将遇到地面缺口时就能及时检测到需要转向、闲置、反向行走。图示如下:

Skeleton索敌状态

玩家检测

若想让怪物能够向玩家移动并对玩家施加攻击,首先需要让怪物能够检测到面前玩家的存在。这样的检测和面前是否有墙、脚底是否有地面是很类似的。而且由于这样的玩家检测并非独属于骷髅这一种怪物,因此应该将这种玩家检测机制放在怪物父类Enemy中实现(见[[Enemy状态机框架]])。实现代码如下:

1
2
3
4
//Enemy.cs
[SerializeField] protected LayerMask whatIsPlayer;
protected virtual RaycastHit2D IsPlayerDetected() =>
Physics2D.Raycast(wallCheck.position, Vector2.right * facingDir, 50, whatIsPlayer);

这种检测和墙壁检测同样都使用了Raycast方法(见[[Unity相关API]]),检测属于whatIsPlayer层级(在Unity中设置为一个自己定义的Player层级)的刚体对象。注意此处返回的并非一个简单的布尔值,而是一个RaycastHit2D类型。关于此类型具体信息见[[Unity相关API]]。

除此之外,这种方式仅仅保证了怪物面向方向一段距离内的玩家会被检测并进入索敌状态,同时我们也希望即使玩家在怪物背对方向,但如果距离够近,也会被怪物检测并进入索敌状态。这只需要在判断进入索敌状态时增加一个玩家和怪物距离的硬门限即可,只要两者间距离小于这个门限值就一定能够被检测,进入索敌状态:

1
2
3
4
5
6
7
8
//SkeletonGroundedState.cs
public override void Update()
{
base.Update();

if (enemy.IsPlayerDetected() || Vector2.Distance(enemy.transform.position, player.position) < 2)
stateMachine.ChangeState(enemy.battleState);
}

索敌状态

和其他状态一样,敌人检测到玩家后会进入一个新的状态:索敌状态(进入了战斗模式后的状态)。当然,一旦新建了一个状态,首先需要在父类(此处为Enemy_Skeleton类)中对这个状态进行创建和登记:

1
2
3
4
5
6
7
//Enemy_Skeleton.cs
public SkeletonBattleState battleState { get; private set; }
protected override void Awake()
{
//...
battleState = new SkeletonBattleState(this, stateMachine, "Move", this);
}

要注意,由于这个状态设置为只要敌人检测到玩家后就自动进入,因此这个状态对应的动画应该还是移动动画(敌人在看到之后应该移动向玩家靠近),所以此处将这个状态的字符串名同样设置为Move

然后希望的是检测到玩家(即IsPlayerDetected()非空),就进入战斗状态。此时,可以考虑设置一个类似玩家的怪物地面超级类,这样只要让骷髅怪物的两个基本类SkeletonMoveStateSkeletonIdleState继承自这个超级类,并在这个超级类中完成向战斗类的转化,就可以减少状态转换的冗余代码。代码如下:

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
public class SkeletonGroundedState : EnemyState  
{
protected Enemy_Skeleton enemy;
public SkeletonGroundedState(Enemy _enemyBase, EnemyStateMachine _stateMachine, string _animBoolName, Enemy_Skeleton _enemy) : base(_enemyBase, _stateMachine, _animBoolName)
{
this.enemy = _enemy;
}

public override void Update()
{
base.Update();

if (enemy.IsPlayerDetected())
stateMachine.ChangeState(enemy.battleState);
}

public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
}

然后自然需要创建全新的战斗状态类SkeletonBattleState用来定义战斗时的怪物行动。此类初始设置如下:

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
using UnityEngine;  

public class SkeletonBattleState : EnemyState
{
private Transform player;
private Enemy_Skeleton enemy;
private int moveDir;

public SkeletonBattleState(Enemy _enemyBase, EnemyStateMachine _stateMachine, string _animBoolName, Enemy_Skeleton _enemy) : base(_enemyBase, _stateMachine, _animBoolName)
{
this.enemy = _enemy;
}

public override void Update()
{
base.Update();

if (player.position.x > enemy.transform.position.x)
moveDir = 1;
else if (player.position.x < enemy.transform.position.x)
moveDir = -1;

enemy.SetVelocity(enemy.moveSpeed * moveDir, rb.linearVelocity.y);
}

public override void Enter()
{
base.Enter();

player = GameObject.Find("Player").transform;
}

public override void Exit()
{
base.Exit();
}
}

其中最重要的是需要知道玩家在场景中的位置(此处通过GameObject.Find获取),然后一旦怪物进入了这个状态(证明已经检测到玩家,进入战斗模式),就需要时刻通过玩家和自己横向相对位置(position.x)来确定自己目前的移动方向。也就是说,玩家无法通过跳过怪物头顶,离开怪物当前的朝向方向来消除怪物的仇恨。

下一个问题是怪物一旦靠近玩家到一定距离,就应该触发其攻击动作,而非继续追踪玩家而移动。这需要人工确认一个攻击距离。此距离应该在enemy中进行设置,因为所有怪物都应该拥有此属性。同时可以通过绘制Gizmos的方式在场景中展示怪物的攻击范围。相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
//Enemy.cs
//...
[Header("Attack Info")]
public float attackDistance;

protected override void OnDrawGizmos()
{
base.OnDrawGizmos();

Gizmos.color = Color.yellow;
Gizmos.DrawLine(transform.position, new Vector3(transform.position.x + attackDistance * facingDir, transform.position.y));
}

最后应该在这个索敌状态中轮询检测,如果玩家出现在攻击距离里(真实的相隔距离可以通过RaycastHit2Ddistance信息获取),切换至真正的发动攻击的状态,同时将速度降为0。代码如下(目前的攻击状态使用Debug信息代替):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//SkeletonBattleState.cs
public override void Update()
{
base.Update();

if (enemy.IsPlayerDetected())
{
if (enemy.IsPlayerDetected().distance < enemy.attackDistance)
{
Debug.Log("attack");
enemy.ZeroVelocity();
return;
}
}

if (player.position.x > enemy.transform.position.x)
moveDir = 1;
else if (player.position.x < enemy.transform.position.x)
moveDir = -1;

enemy.SetVelocity(enemy.moveSpeed * moveDir, rb.linearVelocity.y);
}

还有一个问题是,我们希望怪物能够在一定条件下退出这个索敌状态,例如其与玩家距离相隔较远,或者其处于索敌状态却未进入攻击状态太长时间。这可以分成两个情况。首先,设置一个处于索敌状态内的最长时间,在怪物攻击到玩家后刷新这个最长时间,否则一旦超过时间就退出(这个时间装载在stateTimer中,倒计时在状态父类中自动完成);其次就是检测当前玩家和怪物的距离,如果距离过大也离开索敌状态:

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
//Enemy.cs
public float battleTime;

//SkeletonBattleState.cs
public override void Update()
{
base.Update();

if (enemy.IsPlayerDetected())
{
stateTimer = enemy.battleTime; // 重置时间

if (enemy.IsPlayerDetected().distance < enemy.attackDistance)
{
if (CanAttack())
stateMachine.ChangeState(enemy.attackState);
}
}
else
{
if (stateTimer < 0 || Vector2.Distance(player.transform.position, enemy.transform.position) > 10) // 因为时间或距离而退出索敌状态,丢失仇恨
stateMachine.ChangeState(enemy.idleState);
}

if (player.position.x > enemy.transform.position.x)
moveDir = 1;
else if (player.position.x < enemy.transform.position.x)
moveDir = -1;

enemy.SetVelocity(enemy.moveSpeed * moveDir, rb.linearVelocity.y);
}

Skeleton攻击状态

首先需要创建一个攻击状态类。代码如下:

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
using UnityEngine;  

public class SkeletonAttackState : EnemyState
{
private Enemy_Skeleton enemy;

public SkeletonAttackState(Enemy _enemyBase, EnemyStateMachine _stateMachine, string _animBoolName, Enemy_Skeleton _enemy) : base(_enemyBase, _stateMachine, _animBoolName)
{
this.enemy = _enemy;
}

public override void Update()
{
base.Update();

enemy.SetZeroVelocity();

if (triggerCalled)
stateMachine.ChangeState(enemy.battleState);
}

public override void Enter()
{
base.Enter();
}

public override void Exit()
{
base.Exit();

enemy.lastTimeAttacked = Time.time;
}
}

然后需要在Enemy_Skeleton中对此状态进行注册。注册方法类似之前的状态类:

1
2
3
4
5
6
7
//Enemy_Skeleton.cs
public SkeletonAttackState attackState { get; private set; }
protected override void Awake()
{
//...
attackState = new SkeletonAttackState(this, stateMachine, "Attack", this);
}

然后需要确定这个状态的转入转出情况。首先,转入方式在[[Skeleton索敌状态]]中已经提及:怪物面向玩家且相隔距离小于攻击判定距离时进入攻击状态。然后,这个攻击的转出方式和玩家的普通攻击的转出方式类似(见[[PlayerPrimaryAttackState类]]),是靠Animator为动画添加一个结束事件,将此事件进行层层传递后设置triggerCalled为真来退出的。具体的调用链如下:

1
2
3
4
5
6
7
8
9
10
// Animator直接调用触发器组件,触发器调用怪物内部触发器
public class Enemy_SkeletonAnimationTrigger : MonoBehaviour
{
private Enemy_Skeleton enemy => GetComponentInParent<Enemy_Skeleton>();

private void AnimationTrigger()
{
enemy.AnimationFinishTrigger();
}
}
1
2
3
// 怪物类调用怪物状态父类的触发器
// Enemy.cs
public virtual void AnimationFinishTrigger() => stateMachine.currentState.AnimationFinishTrigger();
1
2
3
4
5
6
7
8
9
10
11
// 怪物状态类直接设置Trigger(注意在进入状态时已经将触发器设置为假)
public virtual void Enter()
{
//...
enemyBase.anim.SetBool(animBoolName, true);
}

public virtual void AnimationFinishTrigger()
{
triggerCalled = true;
}

最后就可以在具体的攻击类中检测触发器情况,一旦触发器为真就退出攻击状态:

1
2
3
4
5
6
public override void Update()  
{
//...
if (triggerCalled)
stateMachine.ChangeState(enemy.battleState);
}

对于攻击过程中的动作设置,和之前的各个状态一致,通过Attack对应布尔值在Animator中进入攻击动画。要注意,骷髅在攻击时设定其速度为0。

在此之后,还想要为骷髅增加一个攻击的冷却时间。具体的方法是:每次进入和退出攻击状态时改写上一次攻击的时间,然后在进入攻击状态时判断当前时间是否大于上一次攻击时间加攻击冷却时间,来确定是否能够真正进入攻击状态。

退出攻击状态时需要进行计时:

1
2
3
4
5
6
public override void Exit()  
{
base.Exit();

enemy.lastTimeAttacked = Time.time;
}

在索敌状态进入攻击状态时使用一个方法进行是否超过冷却时间的判断:

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
// SkeletonBattleState.cs
private bool CanAttack()
{
if (Time.time >= enemy.lastTimeAttacked + enemy.attackCooldown)
{
enemy.lastTimeAttacked = Time.time;
return true;
}

return false;
}

public override void Update()
{
base.Update();

if (enemy.IsPlayerDetected())
{
if (enemy.IsPlayerDetected().distance < enemy.attackDistance)
{
if (CanAttack())
stateMachine.ChangeState(enemy.attackState);
}
}
//...
}

敌人和玩家的碰撞

现有的场景对象存在一个问题,由于敌人和玩家都拥有Collider2D组件进行碰撞检测,两者会存在碰撞。也就是说,玩家无法移动“穿过”敌人,而是会和敌人产生碰撞,这样玩家就无法凭借快速穿过敌人躲避敌人攻击。

为了解决这一点,需要调整Project Settings中的Physics 2D碰撞矩阵。其原理是,由于在游戏中玩家对象处于Player层,而所有敌人处于Enemy层,只需要调整这个碰撞矩阵,关闭这两个层级之间的碰撞效果,就可以让二者不再产生碰撞。调整如下: