学习Unity银河恶魔城游戏制作第四部分:基本战斗系统设置

攻击逻辑

首先需要用一定尺度规定对象的攻击范围。由于攻击行为无论玩家还是怪物都有可能实行,因此攻击的相关参数可以写在[[Entity类]]中。

攻击判定需要的东西是判定范围,更具体的说,是需要一个攻击判定的判定中心和一个有效攻击的判定半径。对这两个属性的定义,及相应Gizmos的绘制如下:

1
2
3
4
5
6
7
8
//Entity.cs
public Transform attackCheck;
public float attackCheckRadius;
protected virtual void OnDrawGizmos()
{
//...
Gizmos.DrawWireSphere(attackCheck.position, attackCheckRadius);
}

接下来要做的事情是,每次攻击对于判定范围内的对象进行一个函数的调用。由于任意Entity指代对象都有可能因为被攻击而受到影响,因此可以在[[Entity类]]中实现一个受到伤害的可重写方法,以便此对象在被攻击后调用:

1
2
3
4
5
6
//Entity.cs
public virtual void Damage()
{
Debug.Log(gameObject.name + " damaged!");
// 具体攻击逻辑
}

然后就是要能够在玩家或怪物进行攻击时,获取处于攻击判定范围内的所有相关对象,并调用它们的被攻击函数Damage。可以使用原先的PlayerAnimationTrigger(见[[Player类]])和Enemy_SkeletonAnimationTrigger(见[[Skeleton攻击状态]])类实现。大致方法就是,为攻击动画的某一帧增加一个事件,这个事件调用判定范围内的所有可用对象,并触发它们的Damage承伤方法,视为完成了一次攻击。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//PlayerAnimationTrigger.cs
private void AttackTrigger()
{
Collider2D[] colliders = Physics2D.OverlapCircleAll(player.attackCheck.position, player.attackCheckRadius);

foreach (var hit in colliders)
{
if (hit.GetComponent<Enemy>() != null)
hit.GetComponent<Enemy>().Damage();
}
}
//Enemy_SkeletonAnimationTrigger.cs
private void AttackTrigger()
{
Collider2D[] colliders = Physics2D.OverlapCircleAll(enemy.attackCheck.position, enemy.attackCheckRadius);

foreach (var hit in colliders)
{
if (hit.GetComponent<Player>() != null)
hit.GetComponent<Player>().Damage();
}
}

受击特效

目前希望为受到攻击的玩家、怪物添加一个短暂的全白特效作为受击特效。此特效的原理是:创建一个全白的Material,一旦感知到一个对象受到了攻击,就将其原本的材质短暂的替换为此全白材质一段时间(使用一个协程控制)。

首先,此处要创建的是一个材质Material,在unity中这是定义物体表面视觉属性的核心资源,通过着色器Shader控制物体如何和光线交互、呈现颜色、纹理细节等。

此处使用的,是GUI / TEXT Shader,这种材质主要是为UI提供清晰高效渲染的,但是由于此处只需要将对象颜色设置为全白,这种简单的材质也可以实现。设置如下:

然后需要设置一个切换控制特效的协程。在此处专门设置了一个控制所有对象相关材质的类:EntityFX,并在其中设置协程。其内容如下,核心是存储其挂载的对象的原始材质(在初始化时就保存)和受到攻击时的特殊白色材质,在收到攻击后,此类中的协程就会被调用,在短时间内将其所属对象的材质替换为受击材质(调整SpriteRenderer.material)。

注意,经过测试,这种方法需要关联到所属对象的动画管理器,并在协程调整材质前后阻断动画管理器,否则协程结束后对象的动画会出现错误,具体原因我还没太理解。代码如下:

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
using System.Collections;  
using UnityEngine;

public class EntityFX : MonoBehaviour
{
private SpriteRenderer sr;
private Animator anim;

[Header("Flash FX")]
private Material originalMat;
[SerializeField] private Material hitMat;
[SerializeField] private float flashDuration;

private void Start()
{
sr = GetComponentInChildren<SpriteRenderer>();
anim = GetComponentInChildren<Animator>();
originalMat = sr.material;
}

private IEnumerator flashFX()
{
anim.enabled = false;

sr.material = hitMat;
yield return new WaitForSeconds(flashDuration);
sr.material = originalMat;

anim.enabled = true;
}
}

然后只需要在对象类([[Entity类]])中获取这个材质类,并在受到攻击时调用这个类中的协程即可。代码如下:

1
2
3
4
5
6
7
8
9
10
11
//Entity.cs
public EntityFX fx { get; private set; }
protected virtual void Start()
{
fx = GetComponent<EntityFX>();
//...
}
public virtual void Damage()
{
fx.StartCoroutine("flashFX");
}

受到攻击之后,我们不仅希望有一个白光闪烁特效,还希望能做出一个击退的特效。由于希望任何生物都有可能触发这个击退特效,因此相关定义依然在Entity中。相关代码如下:

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
//Entity.cs
[Header("Knockback info")]
[SerializeField] private Vector2 knockbackDirection;
[SerializeField] private float knockbackDuration;
protected bool isKnocked;

public virtual void Damage()
{
StartCoroutine("HitKnockback");
//...
}

protected virtual IEnumerator HitKnockback()
{
isKnocked = true;
rb.linearVelocity = new Vector2(knockbackDirection.x * -facingDir, knockbackDirection.y);

yield return new WaitForSeconds(knockbackDuration);

isKnocked = false;
}

public void SetZeroVelocity()
{
if (isKnocked)
return;
rb.linearVelocity = new Vector2(0, 0);
}

public void SetVelocity(float _xVelocity, float _yVelocity)
{
if (isKnocked)
return;

rb.linearVelocity = new Vector2(_xVelocity, _yVelocity);
FlipController(_xVelocity);
}

其实击退特效的设置比较简单:首先需要设置一个对象被击退时的击退方向和击退状态持续的时间,以及一个是否被击退的布尔判断。使用一个协程来实装击退,一旦一个对象被攻击,就设置其状态为击退,然后重置其速度,直到击退状态结束退出协程。

还有一点需要注意的是,在一个对象处于击退状态时,应该屏蔽其他操作、输入带来的速度变化,也就是说,需要无效化其他的速度设置。因此,只需要在速度设置、速度归零两个操作中检测当前对象是否被击退,如果被击退就直接跳出方法以无效化速度设置即可。

玩家反击

现在想要做的是一个反击系统,其核心是:当怪物在攻击时,有一段人为设置的反击窗口,如果玩家能在这段过程中为怪物施加反击,那么怪物就会因为反击陷入更长时间的硬直。

因此,这个机制的实现涉及两个部分:怪物的硬直设置和玩家的反击设置。

首先,可以为怪物添加一个硬直的相关动画和状态。首先将状态建立:

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 SkeletonStunnedState : EnemyState
{
private Enemy_Skeleton enemy;

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

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

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

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

enemy.fx.InvokeRepeating("RedColorBlink", 0, .1f);

stateTimer = enemy.stunDuration;

rb.linearVelocity = new Vector2(-enemy.facingDir * enemy.stunDirection.x, enemy.stunDirection.y);
}

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

enemy.fx.Invoke("CancelRedBlink", 0);
}
}

然后正如其他Skeleton状态类一样,需要在骷髅对象类中将此状态初始化。其过程与[[Skeleton基本类]]中的登记方法基本一致:

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

这样,只需要在怪物的Animator中,将转入转出怪物硬直状态使用Stunned布尔状态进行判断即可完成基本的设置。

在此之外,还希望为这个硬直设置一个超过击退的,更大规模的位移。由于目前认为这种硬直需要是玩家反击怪物造成的,因此硬直相关的属性可以在怪物父类(Enemy)中进行设置:

1
2
3
4
//Enemy.cs
[Header("Stunned Info")]
public float stunDuration;
public Vector2 stunDirection;

然后在进入怪物硬直状态时,就可以通过速度和计时器的设置应用这种属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
//SkeletonStunnedState.cs
public override void Enter()
{
//...
stateTimer = enemy.stunDuration; //设置硬直时间
rb.linearVelocity = new Vector2(-enemy.facingDir * enemy.stunDirection.x, enemy.stunDirection.y); //设置硬直造成的怪物位移
}
public override void Update()
{
//...
if (stateTimer < 0)
stateMachine.ChangeState(enemy.idleState); //当硬直超出设置时间后
}

在此之外,还希望为出现硬直的怪物增加一个红白光交替闪烁的效果。这个效果的施加和去除都放在[[受击特效]]中已经使用过的EntityFX这一特效管理类中。特效定义的具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
private void RedColorBlink()  //处理闪烁效果
{
if (sr.color != Color.white)
sr.color = Color.white;
else
sr.color = Color.red;
}

private void CancelRedBlink() //移除闪烁效果
{
CancelInvoke();
sr.color = Color.white;
}

然后施加闪烁、处理闪烁的调用都在怪物硬直状态类中实现。其中,在进入此状态时施加闪烁效果、退出此状态时消除闪烁效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public override void Enter()  
{
base.Enter();

enemy.fx.InvokeRepeating("RedColorBlink", 0, .1f);

stateTimer = enemy.stunDuration;

rb.linearVelocity = new Vector2(-enemy.facingDir * enemy.stunDirection.x, enemy.stunDirection.y);
}

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

enemy.fx.Invoke("CancelRedBlink", 0);
}

要注意Invoke相关的调用原理:InvokeRepeating的调用参数如下:InvokeRepeating(string methodName, float delay, float repeatRate);,其中三个参数分别代表要调用的方法名,首次调用前的延迟时间,重复调用的间隔时间。而CancelInvoke的效果是:停止当前组件(即EntityFX)内的所有定时调用,通过这种方法来消除闪烁效果。

Coroutine相比,Invoke适合简单无参的定时任务(因为其与时间相关的控制比较困难)、而Coroutine更加适合高频或者需要参数的任务(相关参数能够更加完备的设置)。

然后需要为怪物在攻击过程中设置一个能够被反击的窗口。目前使用一个布尔值canBeStunned表示当前怪物是否处在能被反击的窗口内、同时暂时使用一个纯色图片对象counterImage来在游戏中标识怪物是否能被反击。相关的属性和反击窗口设置方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Enemy.cs
[Header("Stunned Info")]
protected bool canBeStunned;
[SerializeField] protected GameObject counterImage;

public virtual void OpenCounterAttackWindow()
{
canBeStunned = true;
counterImage.SetActive(true);
}

public virtual void CloseCounterattackWindow()
{
canBeStunned = false;
counterImage.SetActive(false);
}

接下来需要确定何时,如何调用反击窗口开始和结束的方法。目前的解决方案是:使用Enemy_SkeletonAnimationTrigger对处在Enemy类内的反击窗口开始结束方法进行调用,然后只需要在怪物的攻击动画中选择两帧进行方法的触发即可。代码如下:

1
2
3
//Enemy_SkeletonAnimationTrigger.cs
private void OpenCounterWindow() => enemy.OpenCounterAttackWindow();
private void CloseCounterWindow() => enemy.CloseCounterattackWindow();

当将攻击AnimationClip对应帧增加了这两个方法调用后,怪物在每次攻击的对应时间就会开始或结束反击窗口。

下一件事是需要为玩家设置一个当前怪物是否处于反击窗口的探测方法,也就是说当玩家攻击怪物时需要能够获知当前怪物的反击窗口状态,同时也可以在探测的同时(因为玩家在攻击怪物时才会使用这个探测方法),如果探测到怪物处于反击窗口,在返回值为真的同时也需要关闭怪物的反击窗口。在怪物父类Enemy中的探测方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
//Enemy.cs
public virtual bool CanBeStunned()
{
if (canBeStunned)
{
CloseCounterattackWindow();
return true;
}

return false;
}

由于不同怪物可能对反击窗口的探测,被反击后作出的行为有所差异,因此这个方法也可以在对应的具体怪物子类中进行重写。例如,可以对骷髅这一特定怪物进行反击窗口探测方法的重写(如果探测到当前处于反击窗口,还需要将怪物状态改为被反击特有的硬直状态):

1
2
3
4
5
6
7
8
9
10
11
//Enemy_Skeleton.cs
public override bool CanBeStunned()
{
if (base.CanBeStunned())
{
stateMachine.ChangeState(stunnedState);
return true;
}

return false;
}

然后需要为玩家增添反击需要的状态和操作。从状态机和状态动画开始添加:

在Animator中增加的状态是两个,同时增加的AnimationClip也是两个。playerCounterAttack对应的是玩家举剑尝试反击的动画,而playerSuccessCounterAttack对应的是玩家成功实现一次反击的动画。同时用来管理的布尔变量也有两个:CounterAttack为真时进入playerCounterAttack状态,然后判断SuccessCounterAttack是否为真,为真则进入playerSuccessCounterAttack状态。这两个状态的退出都检测CounterAttack是否为假。同时需要为成功反击的动画结束时增加一个AnimationTrigger事件(类似[[PlayerPrimaryAttackState类]]中的操作)。

但是,在代码管理的层面,使用一个状态PlayerCounterAttackState管理这两个动画。首先,依然需要在玩家类中登记这个状态,并进行一些相关属性的定义。此处要定义的属性是:玩家尝试反击这一动画状态的持续时间counterAttackDuration。相关定义和登记如下:

1
2
3
4
5
6
7
8
//Player.cs
public float counterAttackDuration = .2f;
public PlayerCounterAttackState counterAttack { get; private set; }
protected override void Awake()
{
//...
counterAttack = new PlayerCounterAttackState(this, stateMachine, "CounterAttack");
}

然后需要完成这个状态的具体设置。这个状态的代码如下:

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
//PlayerCounterAttackState.cs
using UnityEngine;

public class PlayerCounterAttackState : PlayerState
{
public PlayerCounterAttackState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}

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

stateTimer = player.counterAttackDuration;
player.anim.SetBool("SuccessfulCounterAttack", false);
}

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

player.SetZeroVelocity();

Collider2D[] colliders = Physics2D.OverlapCircleAll(player.attackCheck.position, player.attackCheckRadius);
foreach (var hit in colliders)
{
if (hit.GetComponent<Enemy>() != null)
{
if (hit.GetComponent<Enemy>().CanBeStunned())
{
stateTimer = 10;
player.anim.SetBool("SuccessfulCounterAttack", true);
}
}
}

if (stateTimer < 0 || triggerCalled)
stateMachine.ChangeState(player.idleState);
}

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

这个状态干的事情具体包括:

  • 在进入这个状态的时候,设置状态持续时间为反击尝试时间player.counterAttackDuration,同时要设置成功格挡这一动画控制布尔值为假,防止动画状态直接进入成功格挡状态。
  • 处于这个状态中时,将玩家速度设置为0。
  • 处于这个状态中时,不断检测玩家攻击范围内是否有处于反击窗口的怪物。如果有,将这个状态的计时器事件无限延长,并设置SuccessfulCounterAttack为真,让动画状态进入成功格挡(因为此时动画状态已经是成功格挡状态,退出这一动画状态可以使用AnimationTrigger,就可以不使用计时器了),要注意,因为检测处于反击窗口怪物这一方法本身就会顺带触发怪物被反击成功,进入硬直状态,因此不需要对怪物相关代码做任何修改。
  • 一旦计时器超时(证明反击状态结束)、或动画结束触发器被触发(证明当前处于成功反击状态,且动画结束),就退出这个状态,进入空闲状态([[PlayerIdleState类]])。

最后只需要设置一下进入这个玩家反击状态的条件即可。目前设置为:玩家处于地面时(超级状态[[PlayerGroundedState类]]),只需要按Q即可进入反击状态:

1
2
3
4
5
6
7
//PlayerGroundedState.cs
public override void Update()
{
//...
if (Input.GetKeyDown(KeyCode.Q))
stateMachine.ChangeState(player.counterAttack);
}