玩家

如果玩家移动,攻击、冲刺、跳跃的逻辑写在一个脚本中,然后将脚本挂载到玩家身上。本身是没有问题的。但是如果代码中出现bug,就会在这个脚本中找问题,会造成代码冗余。

基本框架的搭建

玩家需要移动、跳跃、攻击等行为,而这些行为都有一些公共的逻辑。如:所有行为在进入状态之前都需要播放该行为的动画。

比如:当我从移动转化为攻击的时候。移动退出(停止播放移动动画),攻击进入(播放攻击动画)

那么怎么做呢?

move(){
  playMoveAnimation(); // 播放移动动画
  // 处理移动逻辑 -----
  -------
  // 处理移动逻辑 -----
  stopPlayMoveAnimation(); // 停止播放移动动画
}
attack(){
  playAttackAnimation(); // 播放移动动画
  // 处理攻击逻辑 -----
  -------
  // 处理攻击逻辑 -----
  stopAttackAnimation(); // 停止播放移动动画
}

但是这样会出现一个问题。如果有很多个这样的行为,如跳跃、冲刺、魔法攻击等,都要这两步操作的话,就会让代码变得十分的冗余。有没有更好的办法。让代码变得更加的简单呢?

当然有,我们会发现,都是动画的播放和停止。如果传一个动画参数名,就可以指定的说明播放的是哪一个动画。

playAnimation(aniName);
-- 逻辑处理
stopPlayAnimation(aniName)

然后将这三个拆开,抽象出三个方法(进入、中间、退出),再利用多态的思想,将其抽象成一个类。最终形成这样的一个代码。

public class PlayerState{
  private Player player;
  private string animBoolName; // 当前状态的动画的名字(必须与外面动画名字相同)

  //  构造方法
  public PlayerState(Player player, string animBoolName){
    this.animBoolName = animBoolName;
    this.player = player;
  }

  public virtual void Enter(){  
    // 播放动画
    // 我们知道播放动画需要Animator组件,那么如何去获得这个组件呢?
    // 1. 首先我们需要在人物中挂载一个Player的脚本,用于获取当前游戏对象的所有组件
    // 2. 我们这个类需要获得Player的脚本,就得给这个类添加一个属性Player
    // 3. 我们在构造函数时获得它
    player.animator.setBool(animBoolName, true);
  }
  
  public virtual void Update(){
  
  }
  
  public virtual void Exit(){
    // 停止播放
    player.animator.setBool(animBoolName, false);
  }
}
public class player {
  public Animator animator;
  void Awake(){
    animator = this.getCompoent<Animator>();
  }

}

接下来,我们将会去思考,如何切换动画呢?
移动动画转化为攻击动画?如何转化呢?

image

我们可以通过PlayerStateMachine来间接的切换移动状态和攻击状态

PlayerStateMachine脚本:这个用于处理玩家各个状态类的切换

namespace DY.RPG
{
    public class PlayerStateMachine
    {
        public PlayerState currentState {  get; private set; }

        public void Initialize(PlayerState _startState)
        {
            this.currentState = _startState;
            _startState.Enter();
        }


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

人物和动画的层级设计

  • Plyaer 空对象(用来控制人物脚本,碰撞器组件)
    • Animation 人物模型(Animator组件)

动画状态机的设计

移动和闲置功能思路

需求分析:玩家输入键盘A,人物向左移动,玩家输入键盘D,人物向右移动

问题一: 如何去获得用户的输入呢?

分析:输入肯定在update实时获取。你可以使用新版的,也可以使用老版的。如果我们把用户的输入放在PlayerMoveState的Update里。就会出现这样的问题。我在在冲刺状态时、攻击状态时都要判断玩家的方向,而玩家的方向是由输入决定的,又要在其他的代码中获取输入,这大大的增加了代码的冗余度。所以我们把用户的输入放在基类上,这样只要继承这个基类的类都可以获得用户的输入。所以我们在PlayerState父类中添加下面的代码。

protected float inputX;
public virtual void Update()
{
    inputX = Input.GetAxisRaw("Horizontal");
}

所有继承PlayerState的子类,都可以直接使用 inputX来获取用户的输入

问题二:按下输入的值,如何从闲置转化为移动,移动转化为闲置呢?

这个简单,只需要在PlayerIdleState类中的Update方法中,获得用户的输入,通过用户的输入判断是否切换为移动状态。

if (inputX != 0)
    playerStateMachine.ChangeState(player.playerMoveState);

同样的道理,在PlayerMoveState类中的Update方法中,写如下代码

if (inputX == 0)
    playerStateMachine.ChangeState(player.playerIdleState);

问题三:既然状态切换完成,我们就可以专注于写移动逻辑?如何设计呢?

根据上面的设计,我们就可以在PlayerMoveState中专注于逻辑代码,包括刚刚进入这个状态需要做什么,一直处于这个状态需要做什么,退出这个状态需要做什么。

我们要知道,移动肯定要产生位移,而产生位移需要RididyBody组件,所以我们需要在玩家上挂载RididyBody组件,通过Player脚本去获取它。(这里的代码太简单,不在叙述)

接下来,我们思考一个问题:移动、跳跃、攻击、冲刺,这些都需要产生位移,所以我们可以将这些提取成一个公共的方法,将其放在Player类中。通过设置x和y的值,来设置速度。代码如下

public void SetVelocity(float xVelocity, float yVelocity)
{
    rb.velocity = new Vector2(xVelocity, yVelocity);
}

既然如此,那么移动逻辑就变得非常简单了,在PlayerMoveState中的Update写入下面的代码:

player.SetVelocity(inputX * player.MoveSpeed, player.rb.velocity.y);

问题四向左移动,切换想左移动的动画,向右移动,切换向右移动的动画。

我们要去思考:什么时候切换?

在切换动画之前,我们需要获得当前人物的朝向,在获得玩家输入的方向,

逻辑:

  1. 玩家向右输入,当前人物朝向向左,切换动画
  2. 玩家向左输入,当前人物朝向向右,切换动画

但切换动画的逻辑写在哪里呢?因为切换时通过 transform.Rotate(0, 180, 0);来的,而这个 transform必须时人物对象,所以时写在Player脚本中。

我们继续思考一个问题:看下面的代码需要 facingDir不是多余的吗?为什么要设置这个呢?

这是因为我们在设置位移的时候,向右移动,是正的,向左移动,是负的。我们直接在 可以通过 facingDir * 位移就可以控制位移的正负了。

最后一个问题:在哪里去调用这个翻转逻辑呢?

输入改变的时候,可以在设置速度之后,调用这个逻辑。

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

public void Flip()
{
    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();
}

// 之前的代码
public void SetVelocity(float xVelocity, float yVelocity)
{
    rb.velocity = new Vector2(xVelocity, yVelocity);
    // 添加翻转
    FlipController(xVelocity);
}

跳跃功能思路

  • 制作跳跃和降落动画

我们通过 yVelocity 来判断是跳跃还是降落动画,通过混合树去制作

  • 跳跃思路

分析:按下空格,从地面PlayerGroundedState切换为PlayerJumpState,开始上升,当上升速度为0时,开始下降,进入PlayerAirState状态,当回到地面,进入PlayerIdleState状态

  • 防止一直跳

思路:只有检测处于地面的时候,才能跳

  • 检测地面思路
[SerializeField] public Transform groundTF;
[SerializeField] public float groundCheckDistance = 0.3f;
public LayerMask whatIsGround;
 
public bool IsGrounded() => Physics2D.Raycast(groundTF.position, Vector2.down, groundCheckDistance, whatIsGround);

public void OnDrawGizmos()
{
    Gizmos.DrawLine(groundTF.position, groundTF.position - new Vector3(0, groundCheckDistance));
}
  • 解决空中不能移动的问题
    在空中状态,控制移动
if (inputX != 0)
    player.SetVelocity(inputX * player.MoveSpeed * .8f, player.rb.velocity.y);

冲刺功能思路

它是一个冲刺的状态:PlayerDashState

它有一个计时器,当进入PlayerDashState这个状态后,将初始化这个计时器,并不断的减少
如果这个计时器小于0,就会退出Dash状态,进入idle状态

  • 什么时候,进入dash状态?

在任何状态中,当按shift时,就会进入dash状态。

public void DashForInput()
{
  
    if (Input.GetKeyDown(KeyCode.LeftShift))
    {
        dashDir = Input.GetAxisRaw("Horizontal");
        if (dashDir == 0)
            dashDir = facing;
        stateMachine.ChangeState(playerDashState);
    }
}
  • 控制冲刺的移动逻辑

在进入dash状态后,初始化冲刺时间

stateTimer = player.dashDurationTime;

在冲刺过程中,控制他的移动逻辑

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

什么时候退出冲刺状态

if (stateTimer < 0)
    playerStateMachine.ChangeState(player.playerIdleState);

为了避免冲刺结束后的惯性,直接设置为0

player.SetVelocity(0, player.rb.velocity.y);
  • 解决连续冲刺:添加冲刺冷却时间
[SerializeField]private float dashCoolTime;
private float dashUsageTimer; 

....
dashUsageTimer -= Time.deltaTime;
if(dashUsageTimer <0) {
	// 才能进入冲刺状态
    // 并恢复冲刺冷却
    dashUsageTimer = dashCoolTime;
}
  • 解决在空中冲刺后,下落播放的是闲置动画问题。

我们之前不是说,冲刺结束后,直接切换闲置状态,如果在空中,那么我想需要从闲置状态切换为空中状态。

我们需要在地面状态时,检测是否在地面

if (!player.IsGourndChecked())
    playerStateMachine.ChangeState(player.playerAirState);

滑墙功能思路

  • 解决角色粘住墙体问题

给墙体添加材质 Physics Material 2D,并设置材质的摩擦力为 0

  • 制作滑墙动画
  • 制作 PlayerWallSlideState
  • 墙体检测思路
[SerializeField] public LayerMask whatIsGround;
[SerializeField] public Transform wallCheckTF;
[SerializeField] public float wallCheckDistance;

public bool IsWallChecked() => Physics2D.Raycast(wallCheckTF.position, Vector2.right, wallCheckDistance * facing, whatIsGround);
 
private void OnDrawGizmos()
{
    Gizmos.DrawLine(wallCheckTF.position, wallCheckTF.position  + new Vector3(wallCheckDistance, 0) * facing);
}
  • 滑墙状态逻辑

在空中状态如果检测到墙体,切换到滑墙状态。

if (player.IsWallChecked())
    playerStateMachine.ChangeState(player.playerWallSlideState);

如果贴住墙体,他会下降的很慢(原来速度的0.7倍)。如果玩家按下下键,他会滑的更快(恢复下降速度)。

if (inputY < 0)
    player.rb.velocity = new Vector2(0, player.rb.velocity.y);
else
    player.rb.velocity = new Vector2(0, player.rb.velocity.y * 0.7f);

如果在滑墙状态下,往墙的反方向移动,他会进入idle状态,因为idle检测到不在地面,会进入空中状态。

if (inputX != 0 && player.facing != inputX)
    playerStateMachine.ChangeState(player.playerIdleState);

如果检测到地面,就会从滑墙状态切换为idle状态。

if(player.IsGroundDetected())
    playerStateMachine.ChangeState(player.playerIdleState);

PlayerWallJumpState 在墙上跳的状态

  • 制作 PlayerWallJumpState脚本
  • 在墙上的状态,按住空格,就会进入 PlayerWallJumpState状态
if(Input.GetKeyDown(KeyCode.Space))
{
    playerStateMachine.ChangeState(player.playerWallJumpState);
    return;
}
  • 进入 PlayerWallJumpState状态,会有一个计时器,只要计时器小于0,就会立刻进入空中状态
public override void Enter()
{
    base.Enter();
    stateTimer = 1f;
    player.SetVelocity(5 * -player.facingDir, player.jumpForce);
}
public override void Update()
{
    base.Update();
    if (stateTimer < 0)
        playerStateMachine.ChangeState(player.playerAirState);
}
  • 解决从墙上跳跃然后落下进入闲置状态之后的滑步问题。

问题一:在 PlayerWallJumpState状态下,计时器还没有结束,就直接进入地面状态了,没有进入跳跃状态。

解决:

if (player.IsGroundDetected())
    playerStateMachine.ChangeState(player.playerIdleState);

问题二:进入空中状态后到闲置状态,有一定的惯性,所有会滑步
解决方案:在闲置状态进入后,直接设置当前的速度为0

public override void Enter()
{
    base.Enter();
    player.rb.velocity = new Vector2(0,0);
}
  • 我们在空中在冲刺的的时候,没有立刻粘住墙体,而是播放完冲刺后,才会粘到墙上的问题。(我们在冲刺状态中添加)
if (!player.IsGroundDetected() && player.IsWallDetected())
    playerStateMachine.ChangeState(player.playerWallSlideState);
  • PlayerWallSlideState状态下是不能进行冲刺的。(在警车冲刺时进行判断)
public void DashForInput()
{
    if (IsWallDetected())
        return;
    ....
}
  • 在地面时移动时,检测到墙就不能够移动了。
// 在移动状态下
if (inputX == 0 || player.IsWallDetected())
    playerStateMachine.ChangeState(player.playerIdleState);
// 在 闲置状态下
if (player.IsWallDetected() && inputX == player.facingDir)
    return;

攻击功能思路

需求:玩家按下鼠标左键,玩家进行攻击。玩家特定的时间连续按下左键,玩家可以实现组合攻击。

我们先创建攻击状态的类 PlayerPrimaryAttackState继承 PlayerState(代码比较简单,不演示),以及需要攻击动画。(这里需要设计组合攻击动画的设计,见下)

问题一:什么时候进行攻击(进入攻击状态)?

  • 这里设计的是:在地面状态下,鼠标按下左键,切换为攻击状态。
if(Input.GetKeyDown(KeyCode.Mouse0))
    playerStateMachine.ChangeState(player.playerPrimaryAttackState);

问题二:攻击什么时候结束?结束后,会干什么?

当然是攻击动画播放完成后,结束攻击状态,并将攻击状态切换为闲置状态。

这里的思路是:在 Animation组件下添加 AnimationTrigger脚本,这个脚本用于判断攻击动画是否播放完。

给攻击动画的最后一帧添加帧事件,调用这个脚本的 AnimationTrigger的AnimationTrigger()函数。

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

AnimationTrigger的AnimationTrigger函数又去调用Player下的

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

一开始,我们给PlayerState添加一个TriggerCalled(动画结束是否触发),默认是false。如果动画结束要触发,就会调用AnimationFinishTrigger()函数

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

既然触发了,就会在攻击状态,切换为闲置状态

if (triggerCalled)
    playerStateMachine.ChangeState(player.playerIdleState);
  • 思考:如何制作组合攻击。

敌人