学习Unity银河恶魔城游戏制作第一部分:状态机设计与动作实现

游戏制作学习

状态机相关框架

状态机的实现由三个类共同完成:Player,PlayerState及PlayerStateMachine。这三个类的基本功能如下:

[[Player类]]:代表玩家的类,此类继承自MonoBehaviour,需要挂载在可操作角色的GameObject上,而所有的状态机相关框架其实是附着在玩家类上的一套相关属性。

[[PlayerState类]]:此类是描述所有状态的基类,也就是说,后续构建的所有真正约束某一状态相关行为的类都继承自此类。

[[PlayerStateMachine类]]:此类相当于管理及切换当前玩家所处状态的一个控制器,只需要在玩家类中新建一个状态机类,就可以以此类管理当前玩家状态。

Player类

此类附着在玩家对象中,可以认为描述与控制了玩家对象的基本信息。

为了控制玩家当前状态,此类中需要初始化一个状态机类([[PlayerStateMachine类]])以管理;同时,在此类中创建并保存了所有具体类(相当于[[PlayerState类]]的子类),实例化这些类之后才能使用状态机类管理这些具体状态类。

此类的基本框架如下:

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
public class Player : MonoBehaviour  
{
public PlayerStateMachine stateMachine { get; private set; }

public PlayerIdleState idleState { get; private set; }
public PlayerMoveState moveState { get; private set; }
//...

private void Awake()
{
stateMachine = new PlayerStateMachine(); // 创建状态机类

idleState = new PlayerIdleState(this, stateMachine, "Idle");
moveState = new PlayerMoveState(this, stateMachine, "Move"); // 创建具体状态类,下同
// ...
}

private void Start()
{
stateMachine.Initialize(idleState); // 实际相当于调用了状态类的Enter方法,见PlayerStateMachine类
}

private void Update()
{
stateMachine.currentState.Update(); // 调用了当前状态的Update方法
}
}

一个比较重要的点是:由于具体状态类均继承自[[PlayerState类]],每一个具体状态在进入状态、维持状态及退出状态时都可能拥有独特的设定(例如动画的轮播、人物速度的初始设置与衰减等),而状态类并不继承MonoBehaviour,因此无法直接驱动更新状态类内置的Enter()Update()Exit()方法。因此,需要在Player类的更新方法(此方法继承自MonoBehaviour,因此会由Unity逐帧调用)中主动调用当前状态的相关方法。

同时,在玩家类中只需要为状态机类([[PlayerStateMachine类]])初始化一个最初的状态,后续状态的切换条件判断与具体切换均在状态机类内部实现。

代码上可以学习利用[[属性访问器限制数据封装]]设置属性为只读的方法(如代码框架第3、5、6行)

在此之后,由于玩家的状态需要设置通过具体的动画体现,因此需要为玩家对象挂载一个用于控制动画的子对象,同时在子对象上挂载Animator组件以控制动画效果的切换。具体见[[Animator与Animation相关]]。

设置好以后需要为Player类链接此Animator组件,以便进行控制条件的传输与状态的转化。链接相关代码如下:

1
2
3
4
5
6
7
8
9
10
public class Player : MonoBehaviour  
{
public Animator anim { get; private set; }
//...
private void Start()
{
anim = GetComponentInChildren<Animator>();
//...
}
}

为了使角色真正拥有形状,与场景交互的能力和物理特性,需要为角色增加[[Collider与Rigidbody]]组件,并且需要在玩家类中引入刚体组件进行速度等属性的设置与检测。

同时,为了使角色能够真正移动,需要为角色预设一个移动速度值,同时能够在子类中改变Rigidbody组件的速度属性值。相关代码如下,其中Header相关内容详见[[Header和SerializeField]]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Header("Move Info")]  
public float moveSpeed = 12f;

private void Start()
{
//...
rb = GetComponent<Rigidbody2D>();
}

public void SetVelocity(float _xVelocity, float _yVelocity)
// 其他类调用此方法改变玩家对象移动速度
{
rb.linearVelocity = new Vector2(_xVelocity, _yVelocity);
}

后续需要引入并进行起跳([[PlayerJumpState类]])和下落([[PlayerAirState类]])状态,并进行初始化。由于在Animator中不需要通过两个新布尔值分别判断是否进入这两个状态,实际上,这两个状态的进入和切换由[[BlendTree]]实现,在Animator的主状态转移图中可以视作一个状态,而是否进入这个状态只需要一个布尔值Jump判定即可,因此此处两个状态均可命名为Jump:

1
2
3
4
5
6
private void Awake()  
{
//...
jumpState = new PlayerJumpState(this, stateMachine, "Jump");
airState = new PlayerAirState(this, stateMachine, "Jump");
}

后续与Move类似,需要在玩家类中设定起跳相关的物理信息:

1
2
[Header("Move Info")]  
public float jumpForce = 12f;

为了代码的安全性,需要进行[[CollisionCheck碰撞检测]],也就是说,为了实现空中状态和地面状态之间的转换,不能简单依照当前位于什么状态而决定,还需要进行物理层面的检测。目前对于地面的物理检测如下:

1
2
3
4
5
6
[Header("Collision Info")]  
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckDistance;
[SerializeField] private LayerMask whatIsGround;

public bool IsGroundDetected() => Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);

检测属性使用了Physics2D.Raycast()这一[[Unity相关API]]实现。具体来说,为了确保检测到的是地面而非其他物体的顶部,采用了创建地面(Grounded)层并检测此层而确定是否处于地面的方法,其原理见[[Layer与LayerMask]]。

注意,这段代码还运用了[[表达式体成员]]这一CSharp语法知识。

为了方便观察角色的具体检测范围,可以使用OnDrawGizmos()为unity在Scene界面添加标识以显示检测范围。目前设置的标识如下(Gizmos相关API见[[Unity相关API]]):

1
2
3
4
private void OnDrawGizmos()  
{
Gizmos.DrawLine(groundCheck.position, new Vector3(groundCheck.position.x, groundCheck.position.y - groundCheckDistance));
}

目前,玩家还没有翻转功能,实现翻转功能的逻辑是很简单的,只需要令Transform组件的Y轴Rotation进行180度转换即可。具体实现见[[Player方向翻转]]。

然后需要实现冲刺状态,即[[PlayerDashState类]],首先,同样需要在玩家类中设定好冲刺状态相关的物理参数(冲刺速度与冲刺时间):

1
2
3
4
[Header("Dash Info")]  
//...
public float dashSpeed;
public float dashDuration;

且由于存在冲刺时间,或者说由于此状态会随着时间流逝而主动结束,因此需要为状态保持时间进行一个记录。时间相关的记录同玩家输入,由[[PlayerState类]]进行管理。

然后自然需要在玩家类中进行冲刺状态的初始化。初始化方法同上,其在Animator中的动画管理同样类似IdleMove,使用布尔值Dash管理:

1
dashState = new PlayerDashState(this, stateMachine, "Dash");

本游戏设置冲刺状态可以从几乎任何状态中转化,无论是静止、移动还是空中状态等。因此,进入冲刺状态的判断需要在所有状态下进行判断。因此可以把进入冲刺状态的判定当作一个特例,在玩家类中进行判断并转移,为了实现效果:

1
2
3
4
5
6
7
8
9
10
11
private void Update()  
{
stateMachine.currentState.Update();
CheckForDashInput();
}

private void CheckForDashInput()
{
if (Input.GetKeyDown(KeyCode.LeftShift))
stateMachine.ChangeState(dashState);
}

还有一些细节需要解决:如果仅仅使用当前的moveDir判定冲刺方向,可能存在玩家面向左侧,但主观上想要向右冲刺却无法实现的问题。因此可以在玩家类中设置并保存一个冲刺方向属性,在具体的[[PlayerDashState类]]中通过此冲刺方向属性确定方向:

1
2
3
4
[Header("Dash Info")]  
public float dashSpeed;
public float dashDuration;
public float dashDir { get; private set; }

然后的问题是这个冲刺方向究竟应该如何确定?此处给出的确定方法为判定按下冲刺键LShift时玩家的横向移动输入,并将其当作冲刺方向;如果按下冲刺键时没有横向输入(静止状态),则将面对方向当作冲刺方向:

1
2
3
4
5
6
7
8
9
10
11
private void CheckForDashInput()  
{
if (Input.GetKeyDown(KeyCode.LeftShift))
{
dashDir = Input.GetAxisRaw("Horizontal");
if (dashDir == 0)
dashDir = facingDir;

stateMachine.ChangeState(dashState);
}
}

最后,冲刺这一状态肯定有冷却时间(不能无限制冲刺),因此需要为此状态增加冲刺冷却时间及具体时间记录,只有当目前距离上一次冲刺时间超过了冲刺冷却时间,才允许进入冲刺状态。同时,为了防止冲刺时玩家正在墙边面对墙壁,可以加一个限制的判断。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Header("Dash Info")]   
[SerializeField] private float dashCooldown;
private float dashUsageTimer;

private void CheckForDashInput()
{
if (IsWallDetected())
return;

dashUsageTimer -= Time.deltaTime;

if (Input.GetKeyDown(KeyCode.LeftShift) && dashUsageTimer < 0)
// 超出冷却时间后才被允许再次进行冲刺
{
//...具体冲刺状态属性判定
}
}

后续需要为玩家添加和墙壁的互动功能(如滑墙、蹬墙跳等),目前由于没有设置材质,玩家会因为接触墙壁而与墙壁吸附。为了解决此问题,需要新建一个[[Physics Material 2D对象]]并复制给玩家的Collider 2D组件,用来规定物体间碰撞时的物体行为。此处主要是调整摩擦力为0以让玩家和墙壁不再吸附。

为了实现玩家的滑墙状态,需要设置一个[[PlayerWallSlideState类]],然后如其他状态一样在玩家状态中进行属性声明和初始化:

1
2
3
4
5
6
public PlayerWallSlideState WallSlide {get; private set;}
private void Awake()
{
//...
WallSlide = new PlayerWallSlideState(this, stateMachine, "WallSlide");
}

和之前检测是否处于地面一样,也需要设置墙体检测参数以对是否处于贴墙状态进行检测:

1
2
3
4
[SerializeField] private Transform wallCheck;  
[SerializeField] private float wallCheckDistance;

public bool IsWallDetected() => Physics2D.Raycast(wallCheck.position, Vector2.right * facingDir, wallCheckDistance, whatIsGround);

后续还需要一个蹬墙跳的状态,其初始化方法也和之前类似。然而此状态和跳跃状态共用空中跳跃上升下降的动画,因此只需要让其状态名也为Jump且不创建新的动画状态即可:

1
2
3
4
5
6
public PlayerWallJumpState WallJump {get; private set;}
private void Awake()
{
//...
WallJump = new PlayerWallJumpState(this, stateMachine, "Jump");
}

现在需要加入普攻,首先还是需要进行一个状态的登记。同时由于要使用新的攻击动画,因此需要创建新的AnimationClip以及在Animator中添加新的布尔参数Attack进行转换控制:

1
2
3
4
5
6
public PlayerPrimaryAttack PrimaryAttack  {get; private set;}
private void Awake()
{
//...
PrimaryAttack = new PlayerPrimaryAttack(this, stateMachine, "Attack");
}

正如[[PlayerPrimaryAttackState类]]中说的,此处需要通过动画结束主动触发状态结束。因此可以使用Animation为攻击动画结束帧增加一个事件(需要通过Animation窗口进行事件添加),而这个事件的具体定义可以使用一个新脚本编写并将此脚本挂载在Animator对应对象下:

1
2
3
4
5
6
7
8
9
10
// PlayerAnimationTriggers.cs
public class PlayerAnimationTriggers : MonoBehaviour
{
private Player player => GetComponentInParent<Player>();

private void AnimationTrigger()
{
player.AnimationTrigger();
}
}

同时在此玩家类中也设置一个触发方法,这样在收到这个触发类的触发请求时能够及时传递给状态父类[[PlayerState类]]。当然,状态父类也需要进行接受和判断(见该类说明):

1
public void AnimationTrigger() => stateMachine.currentState.AnimationFinishTrigger();

为了让攻击间隙玩家不进入移动状态,设置了使用新线程进行计时并更新状态的一套方法,在玩家类中代码只起到了创建新线程与维护一个指标属性的功能,详细代码如下(其中的协程代码语法知识见[[IEnumerator与Coroutine]],具体协程执行时间设置方法见[[Unity相关API]]):

1
2
3
4
5
6
7
8
public bool isBusy { get; private set; } // 注意引入:using System.Collections;
public IEnumerator BusyFor(float _seconds)
{
isBusy = true;

yield return new WaitForSeconds(_seconds);
isBusy = false;
}

同样,为了让角色攻击时会有小幅位移,在玩家类中需要设置这些位移的一些相关属性,方便具体类进行调用以及在unity中的测试:

1
2
[Header("Attack details")]   
public Vector2[] attackMovement;

PlayerState类

此类是所有具体状态类的基类,规定了具体状态类需要对哪些属性进行定义,包含对玩家和状态机类的关联、具体状态名字符串与进入、维持、退出该状态时的自定义行为。

继承自此类的,代表具体状态类的子类包括:

  • [[PlayerGroundedState类]]
    • [[PlayerIdleState类]]
    • [[PlayerMoveState类]]
  • [[PlayerJumpState类]]
  • [[PlayerAirState类]]
  • [[PlayerWallSlideState类]]
  • [[PlayerWallJumpState类]]
  • [[PlayerPrimaryAttackState类]]

代码框架如下:

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 PlayerState  
{
protected PlayerStateMachine stateMachine; // 关联状态机类
protected Player player; // 关联玩家类

private string animBoolName; // 具体状态名

public PlayerState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName)
{
this.player = _player;
this.stateMachine = _stateMachine;
this.animBoolName = _animBoolName;
}

public virtual void Enter() // 进入此状态自定义行为
{

}

public virtual void Update() // 维持此状态自定义行为
{

}

public virtual void Exit() // 退出此状态自定义行为
{

}
}

注意,由于不希望具体状态类之外的外部类获取状态机类和玩家类,因此此两属性的[[特性修饰符]]被设置为protected,而自定义行为方法由于需要被继承此类的具体状态类重写,因此使用virtual[[特性修饰符]]。

以及,在所有继承此类的子类中,需要通过[[base关键字调用父类的构造函数]]。引用方式如下:

1
2
3
4
public PlayerIdleState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName) 
{
// 后续子类特殊初始化
}

在新增了动画相关内容([[Animator与Animation相关]])后,可以在此处具体设置进入某一状态、退出某一状态时对Animator中参数的影响。设置如下:

1
2
3
4
5
6
7
8
9
public virtual void Enter()  
{
player.anim.SetBool(animBoolName, true);
}

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

回顾[[Player类]]对不同具体状态的初始化可以发现初始化时为每个状态取的状态名(Idle、Move)与Animator中的参数布尔值相同,可以使用这个状态名影响Animator中的参数,进而影响状态转化。

在此父类中还需要进行玩家输入的读取,在此处读取,其所有具体状态子类都可以使用此读取进行判断等。具体读取代码如下:

1
2
3
4
5
6
protected float xInput;

public virtual void Update()
{
xInput = Input.GetAxisRaw("Horizontal");
}

目前读取代码可以读取玩家是否有输入让主角在水平方向(x方向)移动。对玩家输入的读取由Input.GetAxisRaw("Horizontal")实现,原理可见[[Unity相关API]]。

为了让具体状态子类能够更加简单的改变主角的物理状态(如速度),可以为此状态父类也设置一个Rigidbody属性对象,并在进入状态时将玩家类([[Player类]])的Rigidbody属性引入,此后在具体状态子类就可以直接使用这个属性进行物理状态改变:

1
2
3
4
5
public virtual void Enter()  
{
//...
rb = player.rb;
}

由于添加了角色的纵向移动,包括跳跃状态([[PlayerJumpState类]])和空中下落状态([[PlayerAirState类]]),和移动类似的,需要在此类中检测玩家刚体的纵向速度,以实现Animator中相关状态的切换:

1
2
3
4
public virtual void Update()  
{
player.anim.SetFloat("yVelocity", rb.linearVelocity.y);
}

同时要注意,目前已经可以引入超级状态概念,例如此时可以将静止类[[PlayerIdleState类]]与移动类[[PlayerMoveState类]]全部归于角色在地面的超级状态,并用一个超级状态类[[PlayerGroundedState类]]整合,让这些子类继承自此超级类。后续进行地面或跳跃的判断,跳跃相关类只需要和此超级状态类进行转化的条件判断即可。

由于存在[[PlayerDashState类]],即冲刺这样的,有保持时间,需要在时间耗尽后主动退出的状态,因此每个具体状态都希望进行时间管理。时间管理在此类中实现:

1
2
3
4
5
6
protected float stateTimer;
public virtual void Update()
{
stateTimer -= Time.deltaTime;
//...
}

可见,此状态存在时间的参数(stateTimer)可以由子类具体状态给出,然而当玩家处在状态中的时间消耗可以在本类中统一完成,减少代码量。

由于后续出现了滑墙状态[[PlayerWallSlideState类]],同时希望能够用y方向的输入对滑墙速度进行改变,因此需要在此父类中添加对垂直方向的输入的感应(与水平方向读取完全一致):

1
2
3
4
5
6
7
8
9
protected float xInput;  
protected float yInput;

public virtual void Update()
{
//...
xInput = Input.GetAxisRaw("Horizontal");
yInput = Input.GetAxisRaw("Vertical");
}

随着攻击状态的加入,需要能够处理依靠动画结束触发状态结束的这种状态。具体的解决思路是当动画结束时将结束提示传递给玩家类[[Player类]],然后玩家类调用本状态父类,将此属性进行传递和储存。因此在本类中也需要做好适配:

1
2
3
4
5
protected bool triggerCalled;
public virtual void AnimationFinishTrigger() // player类调用此方法以传递结束提示
{
triggerCalled = true;
}

后续的需要通过此方式跳出状态的子类,只需要不断检查父类中的triggerCalled属性变化即可知道自己是否应该跳出状态。如[[PlayerPrimaryAttackState类]]便是这么判断。

PlayerStateMachine类

PlayerStateMachine类用以控制玩家当前所处状态,初始化游戏开始时的初始状态及不同状态之间的切换。代码框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PlayerStateMachine  
{
public PlayerState currentState { get; private set; } // 记录玩家当前状态

public void Initialize(PlayerState _startState) // 初始化进入第一个状态的方法
{
currentState = _startState;
currentState.Enter();
}

public void ChangeState(PlayerState _newState) // 状态间切换的方法
{
currentState.Exit();
currentState = _newState;
currentState.Enter();
}
}

进入状态及状态切换的方法非常简明:调用结束状态的自定义退出方法(若有);更改当前状态机保存的当前状态;调用新状态的自定义进入方法。注意,维持某状态时需要调用的Update()方法由[[Player类]]直接调用。

代码中要注意使用[[属性访问器限制数据封装]]的方法实现了当前状态currentState的只读性。

PlayerGroundedState类

此类是一个超级状态类,其继承自[[PlayerState类]],代表所有玩家处于地面的状态,而[[PlayerMoveState类]]与[[PlayerIdleState类]]都直接继承自此类。通过此类,可以统一完成处于地面的状态到处于空中的状态的转换条件判断。

此超级类和起跳这一空中状态类的转化判断是很自然的,只要当前处于地面上(是否处于地面的判断位于[[Player类]]中,其地面检测技术见[[CollisionCheck碰撞检测]]),且玩家按下了跳跃键空格,则切换至跳跃状态[[PlayerJumpState类]]:

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

if (Input.GetKeyDown(KeyCode.Space) && player.IsGroundDetected())
stateMachine.ChangeState(player.jumpState);
}

注意,此处起跳进行的按键检测(检测是否按下空格)由Input.GetKeyDown实现,此API具体参数见[[Unity相关API]]。

当加入冲刺后,出现了一些问题(详情见[[PlayerDashState类]]),在空中冲刺状态结束后状态会自动跳转至[[PlayerIdleState类]]这一地面状态,然而玩家实际还在空中。在面对这种情况(玩家因为状态判断进入地面状态但实际并未处于地面),可以在地面状态进行判断后将其再次转换至空中状态[[PlayerAirState类]]:

1
2
3
4
5
6
public override void Update()  
{
if (!player.IsGroundDetected())
stateMachine.ChangeState(player.airState);
//...
}

加入普通攻击状态后,只要在地面的状态都可以通过点击鼠标左键让玩家进入普通攻击状态:

1
2
3
4
5
6
public override void Update()  
{
//...
if (Input.GetKeyDown(KeyCode.Mouse0))
stateMachine.ChangeState(player.PrimaryAttack);
}

PlayerIdleState类

[[PlayerState类]]的子类,同时直接继承自[[PlayerGroundedState类]]这一超级状态类,表示玩家角色处于静止状态的具体状态类。

显然,当父类[[PlayerState类]]接收到玩家的横向移动输入(例如按下A或D),需要由此状态切换到玩家横向移动的[[PlayerMoveState类]],因此,需要在此状态维持时持续检测。检测代码如下:

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

if(xInput != 0) // xInput属性由其父类PlayerState读取
stateMachine.ChangeState(player.moveState);
}

当引入多段普通攻击的连击后,希望在连击过程中即使按住横向移动,也可以先经过一个时间窗口(此窗口的相关协程设置定义于[[Player类]],设置于[[PlayerPrimaryAttackState类]])再进行移动。由于相关的时间设置已经在其他类内完成,此处只需要通过判断对应属性确定是否能够进入移动状态即可:

1
2
3
4
5
6
public override void Update()  
{
//...
if (xInput != 0 && !player.isBusy)
stateMachine.ChangeState((player.moveState));
}

PlayerMoveState

[[PlayerState类]]的子类,同时直接继承自[[PlayerGroundedState类]]这一超级状态类,表示玩家角色处于横向运动状态的具体状态类。

显然,当父类[[PlayerState类]]不再能够接收到玩家的横向移动输入,需要转移到玩家静止状态的[[PlayerIdleState类]]。同理,需要在此状态维持时持续检测。检测代码如下:

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

if(xInput == 0)
stateMachine.ChangeState(player.idleState);
}

同时,当主角状态持续在移动状态中时,也需要持续为主角的移动提供速度。代码如下(注意在父类[[PlayerState类]]中已经引入了Rigidbody rb,不需要再到玩家类中引入):

1
2
3
4
5
6
public override void Update()  
{
// ...
player.SetVelocity(xInput * player.moveSpeed, rb.linearVelocity.y);
// xInput指代速度方向,moveSpeed指代速度大小,y方向速度保持不变
}

在后续加了墙壁和滑墙等相关内容后,希望玩家走到墙角时自动切换为静止状态[[PlayerIdleState类]],增加判断:

1
2
3
4
5
6
7
8
public override void Update()  
{
base.Update();
player.SetVelocity(xInput * player.moveSpeed, rb.linearVelocity.y);

if(xInput == 0 || player.IsWallDetected())
stateMachine.ChangeState(player.idleState);
}

PlayerJumpState类

此类继承自[[PlayerState类]],是代表玩家跳跃后在空中且仍处在上升(yVelocity > 0)条件下的状态。

此状态到下落状态[[PlayerAirState类]]的转换是自然的:只要当前玩家刚体在纵向的速度开始下降(小于0),则切换至下落状态:

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

if (rb.linearVelocity.y < 0)
stateMachine.ChangeState(player.airState);
}

注意,此类和[[PlayerAirState类]]对应动画的转换是由Animator中的[[BlendTree]]通过检测当前纵向速度实现的,而纵向速度参数的上传由父类[[PlayerState类]]的更新函数实现。

PlayerAirState类

此类继承自[[PlayerState类]],是代表玩家跳跃后在空中且已经开始下降(yVelocity < 0)条件下的状态。

此状态和玩家在地面上的超级状态[[PlayerGroundedState类]]的转化是很自然的,只需要使用玩家类[[Player类]]中的isGroundDetected()检测玩家是否接触地面即可:

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

if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
}

除此之外,此类在Animator中的对应内容与[[PlayerJumpState类]]几乎一样,均通过[[BlendTree]]进行控制。

为了角色能在空中的下落过程中自由左右移动,可以在本状态中添加当存在水平输入时玩家水平速度的变化:

1
2
3
4
5
6
public override void Update()  
{
//...
if (xInput != 0)
player.SetVelocity(player.moveSpeed * .8f * xInput, rb.linearVelocity.y);
}

由于出现了滑墙状态[[PlayerWallSlideState类]],需要从本下落状态,通过判断下落时是否靠近墙面,从而选择是否要进入滑墙状态:

1
2
3
4
5
6
public override void Update()  
{
//...
if (player.IsWallDetected())
stateMachine.ChangeState(player.WallSlide);
}

PlayerDashState

此类型代表了玩家冲刺的状态。此状态可以由绝大多数状态(可以暂时被理解为所有状态)凭借点击左Shift键进入,同时当持续时间结束后自动进入[[PlayerIdleState类]]。

其相关代码如下:

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

stateTimer = player.dashDuration;
}

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

player.SetVelocity(player.dashSpeed * player.dashDir, 0);

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

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

player.SetVelocity(0, rb.linearVelocity.y);

注意:冲刺状态维持的时间已经在玩家类[[Player类]]中进行了定义与赋值,此处只需要将其赋值给管理时间的,已经在父类[[PlayerState类]]中申明的属性dashDuration即可,其倒计时由[[PlayerState类]]自动完成。

在冲刺过程中需要重新赋值速度,冲刺速度同样在玩家类中完成了定义与赋值,但是与移动状态[[PlayerMoveState类]]不同,冲刺状态的方向无法通过xInputgetAxisRaw())的正负值直接确定。在[[Player类]]玩家类中,为了确定冲刺方向设置了dashDir属性,可以依照此属性确定冲刺方向。

由于冲刺结束如果不进行其他状态的操作输入,会自动进入静止状态[[PlayerIdleState类]],因此需要在离开状态时将玩家速度降为0,否则玩家将持续冲刺。

在玩家冲刺的时候,我们不希望它损失y方向的速度,也就是说即使在空中,其进行冲刺时希望其位移是一条直线而非收到y速度影响的抛物线。为了解决这个问题可以直接将冲刺时的玩家y方向速度固定为0。

当这么设置后会有一个问题:如果玩家空中冲刺全程按住横向移动键,那么在冲刺结束后,玩家会进入地面移动动画,然而此时玩家还在空中。为了解决这个问题,可以让[[PlayerGroundedState类]]进行判断,如果进入地面超级状态时玩家其实并未处于地面,则转换至下落状态[[PlayerAirState类]]。

后续因为有了滑墙状态[[PlayerWallSlideState类]],希望玩家在空中冲刺如果碰到墙就立刻进入滑墙状态,而不是继续保持一段时间的冲刺状态,因此需要做一个额外的状态转移判断:

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

if (!player.IsGroundDetected() && player.IsWallDetected())
stateMachine.ChangeState(player.WallSlide);
//...
}

PlayerWallSlideState类

此状态用以控制玩家处于滑墙状态的状态转移等内容。此状态由空中状态[[PlayerAirState类]]判断靠近墙面得来,且有多种退出状态的可能性。此状态的基本代码和其他具体状态一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PlayerWallSlideState : PlayerState  
{
public PlayerWallSlideState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}

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

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

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

第一个离开状态的可能性:由于只有靠墙下落时面向墙壁才能触发此状态,因此如果存在使方向改变(面向方向和输入方向不同向)的情况就会离开此状态,默认进入静止状态[[PlayerIdleState类]],如果此时未着地会再次转移进入[[PlayerAirState类]]:

1
2
3
4
5
6
public override void Update()  
{
//...
if (xInput != 0 && player.facingDir != xInput)
stateMachine.ChangeState(player.idleState);
}

第二个离开状态的可能性:保持滑墙状态直到接触地面,就会进入静止状态[[PlayerIdleState类]]:

1
2
3
4
5
6
public override void Update()  
{
//...
if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
}

游戏希望玩家在滑墙状态时让下落速度降低。因此可以在此状态中控制下落速度;同时还希望玩家能够手动控制此降低下落速度的特效是否处罚:如果玩家按住了向下的输入,则禁止此降速特效。通过[[PlayerState类]]此父类中对y方向的输入检测进行判断:

1
2
3
4
5
6
7
8
public override void Update()  
{
//...
if (yInput < 0)
rb.linearVelocity = new Vector2(0, rb.linearVelocity.y);
else
rb.linearVelocity = new Vector2(0, rb.linearVelocity.y * .7f);
}

在后续因为出现了蹬墙跳,又出现了第三个离开可能性:在滑墙状态点击跳跃就会进入蹬墙跳状态[[PlayerWallJumpState类]]。要注意此类判定结束后应该直接退出更新函数,防止后续的判定改变蹬墙跳的起跳速度:

1
2
3
4
5
6
7
8
9
10
11
public override void Update()  
{
base.Update();

if (Input.GetKeyDown(KeyCode.Space))
{
stateMachine.ChangeState(player.WallJump); // 防止以下更新检查妨碍起跳速度
return;
}
//...
}

PlayerWallJumpState类

此状态类对应玩家目前正处于滑墙状态且点击跳跃按钮,实现蹬墙跳的效果。基本代码如下:

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
public class PlayerWallJumpState : PlayerState  
{
public PlayerWallJumpState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}

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

stateTimer = .4f;
player.SetVelocity(5 * -player.facingDir, player.jumpForce);
}

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

if (stateTimer < 0)
stateMachine.ChangeState(player.airState);

if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
}

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

此类采用和冲刺类类似的设计,使用计时属性限制处在此状态的时间。进入此状态触发蹬墙跳时会获得一个与墙面朝向相反的前上方向的速度(速度相关数值可以在此处自由调整)。

离开此状态的判定有两个:如果时间归零则退出状态、来到空中下落状态(类比冲刺状态[[PlayerDashState类]]的时间管理);如果在过程中已经接触地面,则直接进入地面的静止状态。

PlayerPrimaryAttackState类

此类是玩家进行普通攻击的状态类。其基本代码框架如下:

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
public class PlayerPrimaryAttack : PlayerState  
{
public PlayerPrimaryAttack(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}

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

// my code
player.SetVelocity(0, rb.linearVelocity.y);

if (triggerCalled)
stateMachine.ChangeState(player.idleState);
}

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

要注意的是,玩家的普通攻击状态并没有根据条件判断主动退出的方法,因为可能存在很多的攻击,如果一一计算对应状态动画持续时间并通过时间进行退出判断,会非常的复杂。因此,可以设计一套主动在动画结束时退出此状态的调用流程。

其调用的过程为:Animator在动画结束后调用一个事件AnimationTrigger,然后在玩家类中将此结束时间传递给状态父类PlayerState,然后状态父类设置一个结束属性,最后具体子类通过判断这个结束属性(triggerCalled属性,在状态父类[[PlayerState类]]中被定义,此处可查看)来决定是否退出状态。具体可见[[Player类]]与[[PlayerState类]]。

后续设计了一种三连普通攻击的机制。也就是说:在每一次攻击之后,如果在一个时间窗口之内再次点击了攻击按钮,则会触发后续的普通攻击动画。为了这个改动,需要修改此代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private int comboCounter;  

private float lastTimeAttacked;
private float comboWindow = 2;

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

if (comboCounter > 2 || Time.time >= lastTimeAttacked + comboWindow)
comboCounter = 0;

player.anim.SetInteger("ComboCounter", comboCounter);
}

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

comboCounter++;
lastTimeAttacked = Time.time;
}

现在代码的基本原理就是在进入攻击状态时判定此次攻击属于第几段,是否超出了连击进入后段攻击的时间窗口;在退出攻击状态时记录此次攻击的触发事件以及触发段数。

当然,这种连续攻击机制需要进行动画上的配合,只需要设置一个整形ComboCounter用来控制进入哪一段攻击的动画即可。在unity中为了防止动画状态机过于混乱,此处采用一个子状态机进行组织。子状态机的设置和状态机基本一致,但是由于子状态机中已经有了Exit状态,因此在主状态机中子状态不需要连接至Exit。示意图如下:

现在存在的问题是:如果从移动状态[[PlayerMoveState类]]通过左键进入攻击状态,在攻击动画播放中玩家对象依然会保持移动的原速度,这会导致一种“漂移”的效果。

现在希望实现的效果是,如果从移动状态进入攻击状态,在维持一小段时间的横向速度(近似的模拟惯性)后便使横向速度变为0。更改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public override void Enter()  
{
//...
stateTimer = .1f;
}

public override void Update()
{
//...
if (stateTimer < 0)
rb.linearVelocity = new Vector2(0, 0);
}

此代码的逻辑是清晰的:给定一个模拟惯性的短时间窗口,在此时间窗口之内玩家允许保留速度,在超出此窗口后玩家攻击状态会强制设置速度为0。

接着想要解决的一个问题是:在三段攻击的过程中,如果按住移动键不松手,那么在两段攻击的间隙中会存在一段时间让玩家移动。原理是:当一段攻击结束后,会进入空闲状态[[PlayerIdleState类]],但是在空闲状态过程中一点检测到存在水平方向的移动输入(xInput),就会再次进入[[PlayerMoveState类]]移动状态导致人物移动。

为了解决这个问题,包括后续的一些时间问题,在玩家类[[Player类]]中设置了一套创建与撤销新线程用于时间记录的方法。具体见玩家类设置。

在这里代码修改为:

1
2
3
4
5
public override void Exit()  
{
//...
player.StartCoroutine("BusyFor", .15f);
}

也就是在退出一段攻击状态的时候利用玩家类设置一个0.15秒的倒计时新线程,然后在空闲状态[[PlayerIdleState类]]中要进行到移动状态的跳转时检测,如果这个倒计时没有结束则不能进入移动状态。

后面希望为每次攻击时玩家设置一个小幅度的位移。相关的参数保存位置被设置在玩家类[[Player类]]中。此处只需要进行调用即可:

1
2
3
4
5
6
public override void Enter()  
{
//...
player.SetVelocity(player.attackMovement[comboCounter].x * player.facingDir, player.attackMovement[comboCounter].y);
stateTimer = .1f;
}

也就是说,在攻击状态设置的进入时的移动窗口过程中,玩家可以根据人工设置的参数进行强制的一小段位移,直到这个移动窗口结束。

后续还进行了一些小修改。主要是:在目前的代码下,如果连续点击攻击进入连续攻击状态,那么在攻击间隙不能够通过点击移动方向键改变主角面向和攻击的方向(因为设置了一个不允许移动的时间窗口)。想要解决这个问题,只需要在进入一个阶段的攻击时检测当前玩家的面向方向和输入方向即可,如果输入方向与面向方向相反,则用输入方向作为此次攻击的方向。代码如下:

1
2
3
4
5
6
7
8
9
10
11
public override void Enter()  
{
//...
float attackDir = player.facingDir;

if (xInput != 0)
attackDir = xInput;

player.SetVelocity(player.attackMovement[comboCounter].x * attackDir, player.attackMovement[comboCounter].y);
//...
}

Animator与Animation相关

以我理解,和动画相关的主要有两种对象与两个窗口:

  • Animator Controller对象用来统筹不同的具体动画,利用参数控制不同具体动画(Animation Clip)间的转化条件
  • Animation Clip对象是一个动画的具体对象,可以通过多张图片生成一个动画
  • Animator窗口用来设置不同动画之间的转化方向及条件(创建一些条件参数)
  • Animation窗口用来将图片加入动画、调整动画的各个具体属性(如每一帧的播放图片情况),要注意只有点击了Animator挂载的对象才能使用此窗口编辑

一些小tips:

  1. 如果要求不同动画立即切换,可以为转化设置无Has Exit Time,Transition Duration = 0。具体如下图:

  1. Animation窗口中可以调整Samples采样率,此数值控制动画播放帧速率,可以理解为调高则动画播放速度加快,调低则动画播放速度降低

  2. Animator窗口左侧可以添加用于判断动画切换的parameter,这些参数可以通过Animator组件的各方法(如SetBool())进行调整,进而影响动画的切换。例子可见[[PlayerState类]]。

  3. Animator窗口的状态转换图我个人认为每次都是从Entry经过各个动画最终进入Exit。从Entry进入某一个动画如果不需要任何条件判断,则这个动画是默认动画(如本例中的Idle动画)。

BlendTree

BlendTree是Animator中的一种特殊节点,可以根据输入参数动态混合多个动画片段(例如起跳动画和下降动画)。

对于起跳和下降动画的混合,由于其只取决于纵向速度,因此使用一维混合1D Blend Tree即可。参数配置如下:

在BlendTree中实现动画切换需要注意设置不同动画的阈值Threshold。基于参数和设置阈值可以判断目前需要播放的动画。其具体播放的动画是什么通过Inspector中的图也可得知。

注意,虽然纵向速度可能超过1或-1,但BlendTree在超出阈值范围时会强制取边界值,因此这种设置是合理的。

Collider与Rigidbody

Collider2D

Collider2D是碰撞体组件,用于定义对象物体的物理形状,以检测碰撞或触发事件,但是此组件并不直接参与物理模拟(但可以通过是否勾选Is Trigger选择是否作为物理碰撞体,阻挡物体)。

可以使用不同形状的Collider2D以快速设置形状,也可以使用Edit Collider精细化调整碰撞体形状。

Rigidbody2D

Rigidbody2D是刚体组件,赋予了物体物理模拟能力,可以响应重力、碰撞力与其他外力。例如,其Gravity属性值就可以约束重力的大小和方式

在本教程中,由于2D银河恶魔城的模式,角色的运动需要受到约束:使其不进行旋转。因此,需要勾选Constraints(运动约束)中的Freeze Rotation Z,以防止角色因为刚体模拟而旋转;同时,需要更改Collision Detection(碰撞检测模式)为Continuous,可以防止高速物体穿透其他物体;最后将Interpolate的模式调整为Interpolate(插值),基于上一帧物理位置通过插值计算当前渲染帧位置,使运动更加平滑。

同时此组件也管理对象的移动。角色的移动速度由linearVelocity这一二维向量属性决定。

CollisionCheck碰撞检测

玩家状态的切换条件不能只依靠当前所处的状态和输入,也需要进行真正意义上的物理检测。例如在地面超级状态[[PlayerGroundedState类]]和起跳状态[[PlayerJumpState类]]间的转换,就可以通过两刚体是否接触进行判断。

具体实现方法为:为角色增加一个空对象,当作其脚的位置,通过这个位置和地面对象的距离判断当前是否位于地面。

因此,一旦确定了相关碰撞检测的检测位置和检测距离(在玩家类[[Player类]]中进行设定),就可以通过Unity内置的APIPhysics2D.Raycast进行检测:

1
public bool IsGroundDetected() => Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);

具体的API见[[Unity相关API]]。

Header和SerializeField

Header和SerializeField是两个常用的属性,可以用于优化编辑器页面。

只需要为挂载在对象上的类中的属性添加Header标签,其对应属性即可在Unity的Inspector界面中展示和修改。例如代码为:

1
2
3
[Header("Move Info")]  
public float moveSpeed = 12f;
public float jumpForce = 12f;

则Unity的对应Inspector中会出现:

注意,如果仅仅使用Header,其只能展示类的公有化字段。如果需要强制展示并可在Unity中修改私有化字段,就需要添加[SerializeField]属性,如下:

1
2
3
[Header("Collision Info")]  
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckDistance;

Layer与LayerMask

Layer是Unity中用于分类游戏对象的机制,也就是说总共可以将游戏对象分类为32类。

通过这种分类,可以做到的事情包括物理碰撞控制。正如[[CollisionCheck碰撞检测]]中提到,在碰撞控制时需要判别不同层的物体是否可以碰撞。例如玩家可以和Ground地面层的地面对象进行碰撞,但不能和不属于这个层的怪物对象进行碰撞。

LayerMask本质是一个体现不同Layer的32位位掩码,在许多物理检测、碰撞检测相关的[[Unity相关API]]中均可以作为参数传入以判断。例如Physics2D.Raycast()

Physics Material 2D对象

Physics Material 2D对象是用来控制不同2D游戏对象进行碰撞时的物理行为的。主要控制摩擦力和弹性属性。

  • 摩擦力定义物体接触表面的滑动能力,其值范围由0(完全光滑)到1(最大摩擦,难易滑动)
  • 弹性定义物体碰撞后的反弹程度,其值范围由0(无弹性,碰撞后静止)到1(完全弹性,不损失能量)

Player方向翻转

玩家的方向翻转功能解决的是无论玩家让角色向左还是向右移动(A或D),其动画的移动方向均固定的问题。翻转的实现方法是很简单的,只需要使其Transform组件的Y轴Rotation旋转180度即可。代码中使用transform.Rotate()这一方法实现。详情见[[Unity相关API]]。

具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Player.cs
public int facingDir { get; private set; } = 1;
private bool facingRight = true;

public void SetVelocity(float _xVelocity, float _yVelocity)
{
rb.linearVelocity = new Vector2(_xVelocity, _yVelocity);
FlipController(_xVelocity);
}

public void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}

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

此代码中,Flip是具体执行翻转操作的方法,而FlipController则控制翻转操作的时机:当输入水平方向(或其他参数)和当前角色朝向方向不同时就进行翻转。

目前进行翻转的时机只有为角色刚体附加速度时,若水平速度方向和角色朝向不同,则进行玩家的方向翻转。

Unity相关API

Input.GetAxisRaw()

此方法用于直接读取输入轴的原始值,不经过平滑处理,返回-1、0、1三个离散值。样例如下:

1
2
float horizontal = Input.GetAxisRaw("Horizontal"); // 水平轴输入(左/右) 
float vertical = Input.GetAxisRaw("Vertical"); // 垂直轴输入(上/下)

例如,当玩家按D键或推动右摇杆时,Input.GetAxisRaw("Horizontal")返回1;而当玩家按A键或推动左摇杆时返回-1;在玩家没有输入时返回0。

OnDrawGizmos()

是调试可视化工具,在Scene中绘制辅助图形。

Gizmos.DrawLine()

可以用在OnDrawGizmos()OnDrawGizmosSelected()方法中,用于绘制直线。参数为:

1
2
3
Gizmos.DrawLine(Vector3 start, Vector3 end);
// 例子:
Gizmos.DrawLine(groundCheck.position, new Vector3(groundCheck.position.x, groundCheck.position.y - groundCheckDistance));

其中start代表线段起点的世界坐标,end代表线段终点的世界坐标。

Physics2D.Raycast()

此方法是一种2D物体射线检测,在运行时由指定点发射射线,检测与2D碰撞体(Collider2D)的交点,其参数解析与例子如下:

1
2
3
4
5
6
7
8
RaycastHit2D hit = Physics2D.Raycast( 
origin: Vector2, // 射线起点
direction: Vector2, // 射线方向
distance: float, // 检测距离
layerMask: LayerMask // 过滤层(可选)
);
// 例子(向正下方检测与Ground层对象的距离是否达到检测距离):
Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);

Input.GetKeyDown()

在按键按下的第一帧返回true,适用于单次触发操作。输入的参数为KeyCode枚举类型,代表不同的按键。例如:

1
2
3
if (Input.GetKeyDown(KeyCode.Space) && player.IsGroundDetected())  
// 包含对空格的检测
stateMachine.ChangeState(player.jumpState);

transform.Rotate()

用于使指定物体旋转。最常用的旋转方式为欧拉角旋转,参数如下:

1
2
3
void Rotate(float xAngle, float yAngle, float zAngle, Space relativeTo = Space.Self);
// xAngle, yAngle, zAngle代表三个方向的旋转角度
// relativeTo代表坐标系选择,可以选择绕物体局部坐标轴旋转(默认)Space.Self,也可以选择绕世界坐标轴旋转Space.World

WaitForSeconds()

用于在协程中暂停代码执行一段指定时间(以秒为单位),再继续执行后续逻辑,注意必须在IEnumerator方法内通过yield return调用,否则无效。例子如下:

1
2
3
4
5
6
7
IEnumerator MyCoroutine() { 
// 同步逻辑(立即执行)
Debug.Log("Start Waiting");
yield return new WaitForSeconds(2.5f); // 暂停2.5秒
// 异步逻辑(等待后执行)
Debug.Log("2.5秒后继续执行");
}

C#语法补充

表达式体成员

举例:public bool IsGroundDetected() => Physics2D.Raycast(...);

=>运算符是CSharp的Lambda运算符,用于定义表达式体成员。当方法或属性只有单条返回语句时,可以使用此运算符代替传统的大括号和return关键字。此例子其实就是下文代码的简写:

1
2
3
public bool IsGroundDetected() { 
return Physics2D.Raycast(...);
}

代码分区

可以使用#region#endregion的方式将csharp代码分区,分区后便可以手动控制折叠与展开某一分区的代码,使代码风格简洁美观。代码举例如下:

1
2
3
4
5
6
7
8
9
10
#region Components  
public Animator anim { get; private set; }
#endregion

#region States
public PlayerStateMachine stateMachine { get; private set; }

public PlayerIdleState idleState { get; private set; }
public PlayerMoveState moveState { get; private set; }
#endregion

属性访问器限制数据封装

举例:PlayerIdleState idleState { get; private set; }

其中get代表外界代码可以读取本类中idleState属性的值(相当于读取是public);private set代表仅有当前类的内部代码可以修改此属性值(相当于写入是private)。

利用这种方法可以实现对此属性的只读化。摆脱了只能使用publicprivate控制属性可见性的障碍。限制方法对比如下:

属性声明 可见性
public 对所有类完全可见,可以读写
{ get; set; } 对所有类完全可见,可以读写
{ get; private set; } 对外部类只读,内部类可以读写
{ get; } 对所有类只读,初始化后不可修改
private 对外部类完全不可见,内部类可以读写

特性修饰符

C#中的特性修饰符包括:

  • public:略
  • private:略
  • protected:成员访问修饰符,确保只有当前类及其派生类可以访问此属性
  • virtual:标记方法、属性、索引器或事件,允许派生类通过override重写其实现

base关键字调用父类的构造函数

此方法在需要传递参数初始化基类成员或满足基类构造约束时常用。举例如下:

1
2
3
4
public PlayerIdleState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName) 
{
// 派生类特有初始化
}

相关解析如下:

  1. 构造函数声明:
    • PlayerIdleState是派生类
    • 参数列表(Player _player, ...)用于接收初始化所需的数据
    • : base(...)表示调用基类的构造函数,并将参数传递给基类
  2. base的作用:
    • 显式指定基类构造函数,避免隐式调用无参构造函数
    • 将派生类收到的参数(_player等)传递给基类,确保基类成员正确初始化
  3. 子类构造函数体:
    • 当前构造函数体为空,说明PlayerIdleState无需额外初始化逻辑,基类已处理所有必要操作
    • 未来若需添加派生类特有的初始化(如状态专属变量),可在此处扩展

注意,父子类对象的初始化顺序是固定的:​​子类字段初始化 → 基类构造函数 → 子类构造函数体执行​。​即使子类构造函数为空,基类构造逻辑仍会优先执行。

IEnumerator与Coroutine

IEnumerator是C#迭代器核心接口,用于支持分步遍历集合或执行分段逻辑。

协程(Coroutine)本质是Unity基于IEnumerator实现的一个异步编程模型,可以通过StartCoroutine启动,后续由Unity引擎逐帧调度执行。这种协程在Unity主线程执行,不会阻塞Unity主线程任务。

协程的一个用法例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 协程逻辑定义
public IEnumerator BusyFor(float _seconds) {
isBusy = true; // 立即设置状态为"忙碌"
yield return new WaitForSeconds(_seconds); // 暂停_seconds秒
isBusy = false; // 恢复后设置状态为"空闲"
}
//yield语句使这个协程能够挂起指定时间,挂起时间结束后再继续执行

//启动协程
public override void Exit() {
player.StartCoroutine("BusyFor", 0.15f); //启动协程,传递方法名和参数
}

注意:启动协程的方法不同,上例中启动方式是通过方法名符号串。这种方式有反射开销,性能略低于直接传递IEnumerator对象:StartCoroutine(BusyFor(0.15f))