Bootstrap

Unity学习笔记(六)使用状态机重构角色移动、跳跃、冲刺

前言

本文为Udemy课程The Ultimate Guide to Creating an RPG Game in Unity学习笔记

整体状态框架(简化)

在这里插入图片描述

  • Player 是操作对象的类: 继承了 MonoBehaviour 用于定义游戏对象的行为,每个挂载在 Unity 游戏对象上的脚本都需要继承自 MonoBehaviour,才能利用 Unity 的生命周期事件和功能。
  • PlayerState 是定义状态接口,这里定义了状态类的 Enter(进入),Update(更新),Exit(退出)
  • PlayerStateMachine 是定义上下文类,它持有当前状态的引用,并合适的时机调用状态的行为ChangeState
  • 具体的状态
    • PlayerMoveState(移动状态)
    • PlayerJumpState(跳跃状态)
    • PlayerIdleState(站立状态)

PlayerState

玩家状态的基类,包含状态的基本操作构造函数和三个基础抽象函数进入状态、更新状态、退出状态。

public class PlayerState
{
    protected Player3 player;
    protected PlayerStateMachine stateMachine;
    protected Rigidbody2D rb;

    protected float xInput;
    protected float yInput;
    public string animBoolName;

    // 记录状态的开始时间,方便做一些状态的转化
    protected float stateTimer;
    protected bool triggerCalled;

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

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

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

    public virtual void Update() 
    {
        stateTimer -= Time.deltaTime;
        xInput = Input.GetAxisRaw("Horizontal");
        yInput = Input.GetAxisRaw("Vertical");
        player.anim.SetFloat("yVelocity", rb.velocity.y);
    }

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

PlayerStateMachine

玩家状态的转换类,改变状态步骤

  • 退出当前状态
  • 初始化新状态
  • 进入新的状态
public class PlayerStateMachine
{
    public PlayerState currentState { get; private set;}

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

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

状态类

有两个比较特殊的状态

  • PlayerAirState,为了设置玩家在空中时的动作
  • PlayerGroundedState,这个状态是为了抽象出玩家站立,跳跃,移动的通用代码。这些状态都要求玩家必须在地面上才能转换。

PlayerAirState(玩家在空中状态)

public class PlayerAirState : PlayerState
{
    public PlayerAirState(Player3 _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
    {
    }

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

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

    public override void Update()
    {
        base.Update();
        // rb.velocity.y or x 静止时 都为 0,所以不需要关注当前位置,只要静止为0
        if (player.IsGroundDetected())
        {
            stateMachine.ChangeState(player.idleState);
        }

        // 跳起来的移动速度会慢一点
        if (xInput != 0)
        {
            player.SetVelocity(player.moveSpeed * .8f * xInput, rb.velocity.y);
        }
    }
}

PlayerGroundedState(玩家在地面状态)

public class PlayerGroundedState : PlayerState
{
    public PlayerGroundedState(Player3 _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
    {
    }

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

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

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

        if (Input.GetKeyDown(KeyCode.Mouse0))
        {
            stateMachine.ChangeState(player.moveState);
        }

        if (!player.IsGroundDetected())
        {
            stateMachine.ChangeState(player.airState);
        }

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

}

PlayerDashState(冲刺状态)

public class PlayerDashState : PlayerState
{
    public PlayerDashState(Player3 _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
    {
    }

    public override void Enter()
    {
        base.Enter();
        stateTimer = player.dashDuration;
    }

    public override void Exit()
    {
        base.Exit();
        // 冲刺结束后x轴不动,在空中就不会一直移动
        player.SetVelocity(0, rb.velocity.y);
    }

    public override void Update()
    {
        base.Update();
        player.SetVelocity(player.dashSpeed * player.dashDir, 0);
        if (stateTimer < 0)
        {
            stateMachine.ChangeState(player.idleState);
        }
    }
}

PlayerIdleState(站立状态)

public class PlayerIdleState : PlayerGroundedState
{
    public PlayerIdleState(Player3 _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
    {
    }

    public override void Enter()
    {
        base.Enter();
        // 将坐标设置为 0,0
        player.SetZeroVelocity();
    }

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

    public override void Update()
    {
        base.Update();
        if(xInput != 0)
        {
            stateMachine.ChangeState(player.moveState);
        }
    }
}

PlayerJumpState(跳跃状态)

public class PlayerJumpState : PlayerState
{
    public PlayerJumpState(Player3 _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
    {
    }

    public override void Enter()
    {
        base.Enter();
        rb.velocity = new Vector2(rb.velocity.x, player.jumpForce);
    }

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

    public override void Update()
    {
        base.Update();
        if (rb.velocity.y < 0)
        {
            stateMachine.ChangeState(player.airState);
        }
    }
}

PlayerMoveState(移动状态)

public class PlayerMoveState : PlayerGroundedState
{
    public PlayerMoveState(Player3 _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
    {
    }

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

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

    public override void Update()
    {
        base.Update();
        player.SetVelocity(xInput * player.moveSpeed, rb.velocity.y);
        if (xInput == 0)
        {
            stateMachine.ChangeState(player.idleState);
        }
    }
}

Player

玩家类,继承自MonoBehaviour,状态机和各类状态等的定义都在这里进行初始化赋值。

我们需要创建一些关键函数:Awark(),Start(),Update()

该方法初始化过程:

暂时无法在飞书文档外展示此内容

下面的类比较复杂,我设置一个简化版和详细版,了解大致流程简化版即可

简化版

public class Player3 : MonoBehaviour
{
    public Animator anim { get; private set; }
    public Rigidbody2D rb { get; private set; }

    #region States
    public PlayerStateMachine stateMachine { get; private set; }
    public PlayerIdleState idleState { get; private set; }
    public PlayerMoveState moveState { get; private set; }
    public PlayerJumpState jumpState { get; private set; }
    public PlayerDashState dashState { get; private set; }
    public PlayerAirState airState { get; private set; }
    #endregion

    private void Awake()
    {
        stateMachine = new PlayerStateMachine();
        idleState = new PlayerIdleState(this, stateMachine, "Idle");
        moveState = new PlayerMoveState(this, stateMachine, "Move");
        dashState = new PlayerDashState(this, stateMachine, "Dash");
        jumpState = new PlayerJumpState(this, stateMachine, "Jump");
        airState = new PlayerAirState(this, stateMachine, "Jump");
    }

    private void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        anim = GetComponentInChildren<Animator>();
        stateMachine.Initialize(idleState);
    }

    private void Update()
    {
        stateMachine.currentState.Update();
        CheckForDashInput();
    }
}

详细版

public class Player3 : MonoBehaviour
{
    public Animator anim { get; private set; }
    public Rigidbody2D rb { get; private set; }

    protected int facingDir = 1;
    protected bool facingRight = true;


    [Header("Move info")]
    public float moveSpeed = 12f;
    public float jumpForce;

    [Header("Dash info")]
    [SerializeField] private float dashCooldown;
    private float dashUsageTimer;
    public float dashSpeed = 5f;
    public float dashDuration = 5f;
    public float dashDir { get; private set; }

    [Header("Collision Info")]
    [SerializeField] protected Transform groundCheck;
    [SerializeField] protected float groundCheckDistance;
    [SerializeField] protected LayerMask whatIsGround;

    #region States
    public PlayerStateMachine stateMachine { get; private set; }
    public PlayerIdleState idleState { get; private set; }
    public PlayerMoveState moveState { get; private set; }
    public PlayerJumpState jumpState { get; private set; }
    public PlayerDashState dashState { get; private set; }
    public PlayerAirState airState { get; private set; }

    #endregion


    private void Awake()
    {
        stateMachine = new PlayerStateMachine();
        idleState = new PlayerIdleState(this, stateMachine, "Idle");
        moveState = new PlayerMoveState(this, stateMachine, "Move");
        dashState = new PlayerDashState(this, stateMachine, "Dash");
        jumpState = new PlayerJumpState(this, stateMachine, "Jump");
        airState = new PlayerAirState(this, stateMachine, "Jump");
    }

    private void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        anim = GetComponentInChildren<Animator>();
        stateMachine.Initialize(idleState);
    }

    private void Update()
    {
        stateMachine.currentState.Update();
        CheckForDashInput();
    }

    // => 可以理解为 简化返回表达式的符号,主要用于单行方法、属性或表达式的定义。
    public virtual bool IsGroundDetected() => Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);

    private void CheckForDashInput()
    {

        dashUsageTimer -= Time.deltaTime;

        if (Input.GetKeyDown(KeyCode.LeftShift) && dashUsageTimer < 0)
        {
            dashUsageTimer = dashCooldown;
            dashDir = Input.GetAxisRaw("Horizontal");

            if (dashDir == 0)
                dashDir = facingDir;
            stateMachine.ChangeState(dashState);
        }
    }

    public void SetZeroVelocity()
    {
        rb.velocity = new Vector2(0, 0);
    }

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

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

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

小结

使用状态机进行重构我们可以看到,之后如果想新增或者修改状态,只需要去对应状态类中修改即可,不需要在很多地方维护对应代码,对于代码整体也更加清晰。

整体玩家状态机代码重构就是上面,接下来重构玩家动画部分

动画部分重构

重构前,我们是通过维护玩家当前的状态是否来判断是否进入和退出。使用状态机后,我们应该通过状态代表的参数来维护状态机。
在这里插入图片描述

重构后,我们可以看到状态机的方式我们不需要通过playerIdle来转换,每次状态机执行完都会进入 Exit 结点(改结点详情看拓展) ,这样我们不用维护状态之间是否有依赖,更方便后续的拓展

在这里插入图片描述

重构后效果

请添加图片描述

拓展

Unity 如何调用 Awake()

Awake() 方法在 Unity 生命周期中的角色
Awake() 是 Unity 中 MonoBehaviour 类的生命周期方法之一,它的主要功能是在对象被创建时初始化脚本和对象状态。它是 Unity 生命周期中非常重要的一个环节。


Awake() 的调用时机

  1. 在场景加载时
    当一个场景加载完成,所有启用的 GameObject 的组件(脚本)会在它们的 Awake() 方法中执行初始化。

  2. 在 GameObject 动态实例化时
    如果一个 GameObject 在运行时被动态创建(如通过 Instantiate() 方法),其附加的脚本也会在实例化时调用 Awake()

  3. 调用顺序

    • Awake() 的调用顺序不受脚本执行顺序的影响。Unity 会按照 GameObject 被加载的顺序来依次调用这些对象的 Awake() 方法。
    • 重要:如果有依赖其他对象的初始化,可以将逻辑放在 Start() 中,因为 Start() 会在所有 Awake() 调用完成之后执行。

Unity 生命周期的完整流程
以下是 Unity 中 MonoBehaviour 的常见生命周期方法及其顺序:

  1. 脚本的加载和初始化阶段

    • Awake()
      • 在所有脚本的生命周期中最先调用。
      • 用于初始化脚本的内部状态,以及为后续使用的变量赋初始值。
      • Awake() 被调用时,其他组件或 GameObject 可能尚未初始化完成,因此不适合依赖其他对象。
  2. 脚本的启用阶段

    • OnEnable()
      • 在对象被启用时调用。
      • 如果需要在对象启用时执行额外操作,可以在这里添加逻辑。
  3. 场景运行时初始化阶段

    • Start()
      • 在所有对象的 Awake() 方法执行完成后调用。
      • Start() 是初始化逻辑的推荐位置,特别是在需要依赖其他对象的情况下。
  4. 运行时更新阶段

    • Update():每帧调用一次,用于更新逻辑。
    • FixedUpdate():每固定时间间隔调用一次,用于物理计算。
    • LateUpdate():在每帧的所有 Update() 执行完成后调用,用于执行后续逻辑(例如摄像机跟随)。
  5. 销毁阶段

    • OnDisable()
    • OnDestroy()

Awake() 的作用和特点

1. 作用
  • 初始化脚本实例
    用于初始化脚本中的变量和状态,例如分配引用、加载资源、设置默认值等。

  • 加载必要的资源
    比如加载外部的材质、音频或配置文件。

  • 设置依赖项
    如果某些对象或组件需要在脚本激活时使用,可以在 Awake() 中获取或初始化它们。

  1. Start() 的区别
  • Awake()Start() 更早调用
  • Awake() 用于确保脚本自身的初始化,而 Start() 适合处理与其他对象或组件的交互
特性Awake()Start()
调用时机对象加载时立即调用所有对象的 Awake() 执行后
依赖其他对象状态不建议依赖其他对象可安全地依赖其他对象
手动调用不推荐(Unity 会自动调用)可以在特定情况下手动调用

示例:Awake() 与生命周期的关系

using UnityEngine;

public class Example : MonoBehaviour
{
    void Awake()
    {
        Debug.Log("Awake: 初始化脚本变量和资源");
    }

    void OnEnable()
    {
        Debug.Log("OnEnable: 脚本或对象被激活");
    }

    void Start()
    {
        Debug.Log("Start: 在对象所有的初始化完成后调用");
    }

    void Update()
    {
        Debug.Log("Update: 每帧调用");
    }

    void OnDisable()
    {
        Debug.Log("OnDisable: 脚本或对象被禁用");
    }

    void OnDestroy()
    {
        Debug.Log("OnDestroy: 对象被销毁");
    }
}

在场景运行时,执行顺序为:

  1. Awake():初始化。
  2. OnEnable():对象启用时的逻辑。
  3. Start():所有对象的 Awake() 调用完成后。
  4. Update():每帧更新。
  5. OnDisable()OnDestroy():对象被禁用或销毁时。

总结

  • Awake() 的核心作用是初始化脚本变量和状态,在 Unity 生命周期中最早被调用。
  • 适合场景:用来初始化脚本或 GameObject 的自身逻辑,而不依赖其他对象。
  • 与其他方法的关系
    • Awake() 是生命周期的起点。
    • 如果依赖其他对象的初始化,建议将逻辑放到 Start()

希望这些内容能帮助你理解 Unity 的生命周期和 Awake() 方法的作用!如果你有更多问题,随时提问!

Unity 调用 Awake 的简单原理

Unity 调用 Awake() 的原理其实可以简单地理解为以下几个步骤。虽然 Unity 的底层实现细节是封闭的,但我们可以根据其生命周期行为和一些公开信息总结出其大致逻辑。


1. Unity 生命周期的核心
Unity 的生命周期方法(如 Awake()Start())是由 Unity 引擎在运行时按照特定顺序自动调用的。这些方法不需要开发者手动注册或显式调用。以下是基本的执行流程:

  1. 场景加载

    • Unity 会加载场景中的所有 GameObject。
    • 如果某个 GameObject 上挂载了继承自 MonoBehaviour 的脚本,它会参与生命周期流程。
  2. 脚本扫描和方法检测

    • Unity 会通过反射机制检测脚本中是否实现了特定的生命周期方法(如 Awake())。
    • 如果检测到某个生命周期方法,则将其注册到 Unity 的内部执行流程中。
  3. 方法调用

    • 在特定的生命周期阶段(例如场景加载后),Unity 引擎会按顺序调用注册的生命周期方法。

2. 调用 Awake() 的简化原理
以下是 Unity 如何调用 Awake() 的基本逻辑:

步骤 1:场景加载

  • 当场景加载时,Unity 会逐一加载场景中的所有 GameObject 和它们的组件。

步骤 2:反射检测

  • Unity 扫描每个继承自 MonoBehaviour 的脚本,检查是否定义了 Awake() 方法。
    • Unity 使用 反射 来动态检测方法是否存在。
    • 如果 Awake() 存在,Unity 会将该方法存储为一个待调用的任务。

步骤 3:初始化并调用 Awake()

  • 在所有 GameObject 被加载到内存后,Unity 会依次调用这些 GameObject 脚本的 Awake() 方法。
  • 调用顺序:
    • Awake() 是生命周期的第一个阶段。
    • 只要脚本和对象处于启用状态,Awake() 会在对象加载时立即被调用。

代码实现的简化逻辑
以下是 Unity 调用 Awake() 的简单逻辑,用伪代码表示:

// Unity 内部的场景加载流程
void LoadScene(Scene scene)
{
    // 1. 加载场景中的所有 GameObject
    foreach (GameObject go in scene.gameObjects)
    {
        // 2. 获取 GameObject 上的所有 MonoBehaviour 脚本
        foreach (MonoBehaviour script in go.GetComponents<MonoBehaviour>())
        {
            // 3. 检查是否实现了 Awake() 方法
            if (script.HasMethod("Awake"))
            {
                // 4. 调用 Awake()
                script.Awake();
            }
        }
    }
}

3. Awake() 的调用特点

  • 只调用一次

    • Awake() 只会在脚本实例初始化时调用一次,无论该脚本的 GameObject 是否被启用或禁用。
  • 不依赖脚本执行顺序

    • Awake() 的调用顺序是按照 GameObject 加载的顺序。
    • 脚本执行顺序设置对 Awake() 无影响。
  • Start() 之前调用

    • Awake() 是生命周期的第一步,Start() 在所有 Awake() 调用完成后才会被执行。
  • 适合自我初始化

    • Awake() 的设计初衷是用于初始化自身的变量和状态,而不适合依赖其他 GameObject,因为此时其他对象可能尚未完成初始化。

4. 示例代码
以下代码演示了 Unity 如何在场景加载时调用 Awake()Start()

using UnityEngine;

public class Example : MonoBehaviour
{
    void Awake()
    {
        Debug.Log("Awake called: 初始化变量");
    }

    void Start()
    {
        Debug.Log("Start called: 依赖其他对象初始化");
    }
}

假设场景中有两个 GameObject 分别挂载了该脚本,运行时输出可能是:

Awake called: 初始化变量 (GameObject 1)
Awake called: 初始化变量 (GameObject 2)
Start called: 依赖其他对象初始化 (GameObject 1)
Start called: 依赖其他对象初始化 (GameObject 2)

5. 为什么使用 Awake()
Awake() 的核心作用是初始化自身的状态。例如:

  1. 分配变量或资源。
  2. 加载外部数据。
  3. 设置默认值。

6. 与其他生命周期方法的关系

方法名调用时机适合的操作
Awake()GameObject 加载到场景时初始化自身变量,不依赖其他对象
OnEnable()GameObject 或脚本启用时运行需要在启用时触发的逻辑
Start()所有 Awake() 调用完成后,场景运行时初始化需要依赖其他对象的逻辑
Update()每帧调用持续更新逻辑,例如动画、输入检测

总结
Unity 调用 Awake() 的原理可以概括如下:

  1. 加载场景
    • Unity 会加载所有 GameObject 和其组件。
  2. 检测方法
    • Unity 使用反射检测脚本是否实现了 Awake() 方法。
  3. 方法调用
    • Unity 自动调用实现了 Awake() 的脚本,不需要开发者手动调用。

通过这个流程,Unity 实现了生命周期的动态管理,使得开发者只需专注于脚本逻辑的实现,而不用关心具体的调用机制。

Unity状态机的Exit结点

在Unity的Animator状态机(Animator State Machine)中,Exit结点用于表示从当前状态机退出到其父状态机的状态。以下是关于Exit结点的详细说明:


  1. Exit结点是什么?
    Exit结点是Unity Animator中的一个特殊的状态机结点,它表示一个状态机结束的出口点。通常用于嵌套的子状态机(Sub-State Machine)中,告诉父状态机当前子状态机的行为已经完成,可以切换到父状态机中的其他状态。

  1. 使用场景
  • 嵌套状态机(Sub-State Machines)
    当你将一个复杂的动画逻辑封装到一个子状态机中时,Exit结点表示该子状态机完成其逻辑后应该退出,回到父状态机进行下一步。
  • 动画流程控制
    如果子状态机处理完某些特定动画(如攻击动作、过渡动画等),可以通过Exit结点返回父状态机,从而进行主流程的继续。

  1. 如何设置Exit结点
    在Unity中,以下是设置Exit结点的步骤:
  2. 创建子状态机
    在Animator中,右键选择Create Sub-State Machine,创建一个嵌套的子状态机。
  3. 添加状态和过渡
    在子状态机中添加具体的动画状态(如攻击、跳跃等)。
  4. 使用Exit结点
    • 在子状态机中,右键选择Make Transition,并将过渡指向Exit结点。
    • Exit结点是子状态机的默认出口,不需要手动创建。
  5. 在父状态机中配置逻辑
    在父状态机中,可以设置子状态机到其他状态(或反过来)的过渡逻辑。

4. Exit的行为

  • 当动画流转到Exit结点时,子状态机会退出,控制权回到父状态机。
  • 可以通过Animator Controller中的条件(如布尔值、触发器等)控制子状态机何时退出。
  • 在父状态机中,子状态机到Exit的过渡会被认为完成,可以接着切换到其他状态。

  1. 注意事项
  • 不能直接控制Exit结点
    Exit是一个逻辑性的特殊结点,它不能像普通状态一样附加动画或行为。
  • 父状态机的后续逻辑
    确保在父状态机中正确设置过渡条件,否则子状态机退出后可能进入意料之外的状态。

  1. 示例场景
    假设有一个游戏角色的动画逻辑:
  • 父状态机
    包括“待机”、“跑步”、“攻击子状态机”。
  • 子状态机(攻击子状态机):
    包括“攻击准备”、“攻击动作”、“攻击结束”。

当“攻击动作”完成后,子状态机会通过Exit结点返回父状态机,角色的动画状态可能回到“待机”或其他状态。

;