【Unity】简易有限状态机FSM实现2D人物的跑、跳、蹲、攀爬等

不自己独立做一整个儿游戏,就不知道里面的细节(坑)数不胜数。正像鲁迅说的那句名言——不亲自吃螃蟹,就不知道螃蟹有多好吃(x)。

虽然只是一个简单的2D横版过关游戏,还是在方方面面让我焦头烂额。现在快做完了,回过头看看,简直是目不忍视,各种沙雕代码充斥其中。不管了,能跑就行(不是)。

今天记录一下游戏的核心,如何实现2D人物的跑、跳、攀爬等等状态。

人物各种状态

有限状态机,也称为FSM(Finite State Machine),其在任意时刻都处于有限状态集合中的某一状态。当其获得一个输入字符时,将从当前状态转换到另一个状态,或者仍然保持在当前状态。

人物的运动状态用FSM实现还是比较合适的。

这里因为状态简单,就用了个超简易有限状态机。

状态类与状态机类

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class State<T>
{
    public T owner;

    public abstract void Enter();

    public abstract void Execute();

    public abstract void Exit();  
}

public class StateMachine<T>
{
    //当前状态
    public State<T> CurrentState { get; private set; }

    //上一个状态
    //public State<T> PreviousState { get; private set; }

    //全局状态
    public State<T> GlobalState { get; private set; }

    public StateMachine(T owner ,State<T> firstState, State<T> globalState = null)
    {    
        if(globalState != null)
        {
            GlobalState = globalState;
            GlobalState.owner = owner;
            GlobalState.Enter();
        }
        
        CurrentState = firstState;
        CurrentState.owner = owner;
        CurrentState.Enter();
    }

    public void ChangeState(State<T> targetState)
    {
        if(targetState == null)
            throw new System.Exception("Can't find target state!");

        targetState.owner = CurrentState.owner;
        CurrentState.Exit();
        CurrentState = targetState;
        CurrentState.Enter();
    }

	public void SMUpDate()
    {
        if (GlobalState != null)
            GlobalState.Execute();
        if (CurrentState != null)
            CurrentState.Execute();
    }
}

PlayerControl

可以看到,状态机里只有一个全局状态和一个当前状态,全局状态用来检测player的通关和死亡。player的各种数据通过状态机的owner传给状态里。

在PlayerControl里创建一台状态机,然后把状态机的SMUpdate()放进FixedUpdate()里就行了。因为运动大部分涉及到物理检测,所以放进FixedUpdate()里用稳定的帧率刷新。

同时也在FixedUpdate()里把人物动画需要的各项参数传进去,就可以让运动和人物动画完美契合了。

另开了一个继承自ScriptableObject的类playerInfo用来储存人物的信息,这样不同的人物使用不同的PlayerInfo和同一playerControl就可以有不同的操作体验了。

using System.Collections;
using UnityEngine;

public class PlayerControl : MonoBehaviour
{
    private StateMachine<PlayerControl> _stateMachine;
    public StateMachine<PlayerControl> StateMachine
    {
        get
        {
            if (_stateMachine == null)
                _stateMachine = new StateMachine<PlayerControl>(this, PlayerIdleState.Instance, PlayerGlobalState.Instance);
            return _stateMachine;
        }
    }

    public PlayerInfo playerInfo;

    [HideInInspector]
    public Ctrl ctrl;

    [HideInInspector]
    public Rigidbody2D rgd2D;

    [HideInInspector]
    public AudioSource audioSource;

    private Animator anim;       
    
    [HideInInspector]
    public int HP;

    [HideInInspector]
    public float speedX;

    [HideInInspector]
    public float speedY;

    [HideInInspector]
    public Transform ladderTriggle;

    [HideInInspector]
    public bool isGrounded = true;

    [HideInInspector]
    public bool isJump = false;

    [HideInInspector]
    public bool isClimb = false;

    [HideInInspector]
    public bool isCrouch = false;

    [HideInInspector]
    public bool isHurt = false;

    [HideInInspector]
    public bool isEnding = false;

    [HideInInspector]
    public bool isPlayHurtAnim = false;

    [HideInInspector]
    public bool isLadderTop = false;

    private bool isFacingRight = true;

    private Transform groundCheck;
    private readonly float groundRadius = 0.1f;
    private Transform ladderCheck;
    private readonly float ladderRadius = 0.6f;

    private void Awake()
    {
        ctrl = transform.parent.GetComponent<Ctrl>();
        rgd2D = GetComponent<Rigidbody2D>();
        anim = GetComponent<Animator>();
        audioSource = GetComponent<AudioSource>();

        groundCheck = transform.Find("GroundCheck");
        ladderCheck = transform.Find("LadderCheck");        
    }

    private void Start()
    {
        HP = playerInfo.maxHP;
    }

    private void FixedUpdate()
    {
        StateMachine.SMUpDate();

        anim.SetFloat("SpeedX", Mathf.Abs(rgd2D.velocity.x));
        anim.SetFloat("SpeedY", speedY);
        anim.SetBool("isGrounded", isGrounded);
        anim.SetBool("isJump", isJump);
        anim.SetBool("isClimb", isClimb);
        anim.SetBool("isLadderTop", isLadderTop);
        anim.SetBool("isCrouch", isCrouch);
        anim.SetBool("isEnding", isEnding);
    }

空闲、奔跑、跳跃、蹲下

这几个状态是参考unity standardasset包里那个2d角色控制的跑跳蹲检测方法写的,就是创造几个空物体,设为player的子物体,然后使用OverlapCircleAll进行检测是否接地等等。

我把检测是否接地和其他的一些要在状态类里做的事情,在playerControl里写成了方法,在状态类里直接调用就好啦。还尝试写了蹩脚的英文注释,我的英文真的很烂……不过万事还是要尝试,不试试怎么知道不行呢?

PlayerIdleState

using UnityEngine;

public class PlayerIdleState : State<PlayerControl>
{
    private static PlayerIdleState _instance;
    public static PlayerIdleState Instance
    {
        get
        {
            if (_instance == null)
                _instance = new PlayerIdleState();
            return _instance;
        }
    }

    private PlayerIdleState()
    {
    }

    public override void Enter()
    {
    }

    public override void Execute()
    {
        #region check Ground

        owner.CheckLadderTopForGround();
        owner.CheckGrounded();

        #endregion


        #region change state

        if (!owner.isEnding)
        {
            if (Input.GetKey(KeyCode.LeftArrow) || Input.GetKey(KeyCode.RightArrow) || Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.RightArrow))
            {
                owner.StateMachine.ChangeState(PlayerRunState.Instance);
            }
            else if (owner.isLadderTop == false && (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKey(KeyCode.UpArrow)))
            {
                owner.StateMachine.ChangeState(PlayerJumpState.Instance);
            }
            else if (owner.isLadderTop == false && Input.GetKey(KeyCode.DownArrow))
            {
                owner.StateMachine.ChangeState(PlayerCrouchState.Instance);
            }
        }
        #endregion
    }

    public override void Exit()
    {

    }
}

//Check is Grounded or not in generally
    public void CheckGrounded()
    {
        Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheck.position, groundRadius, playerInfo.Ground);
        foreach (var item in colliders)
        {
            if (item != gameObject && item.tag != "CheckLadderTop")
            {
                isGrounded = true;
                isLadderTop = false;
            }
        }
    }

//Check is it necessary to change to ClimbState in the IdleState  
    public void CheckLadderTopForGround()
    {
        Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheck.position, groundRadius, playerInfo.Ground);
        foreach (var item in colliders)
        {
            if (item.tag == "CheckLadderTop")
            {
                isLadderTop = true;
                ladderTriggle = item.transform.parent.Find("LadderTrigger");

                if (Input.GetKeyDown(KeyCode.DownArrow) || Input.GetKey(KeyCode.DownArrow))
                {
                    //Let groundCheck(a gameObject,player's childObject) far from CheckLadderTopForGround
                    //let player at the middle of the ladder
                    Vector2 newVec = transform.position;
                    newVec.x = ladderTriggle.position.x;
                    newVec.y -= 0.5f;
                    transform.position = newVec;

                    isClimb = true;
                    StateMachine.ChangeState(PlayerClimbState.Instance);
                }
            }
        }
    }

PlayerRunState

using UnityEngine;

public class PlayerRunState : State<PlayerControl>
{
    private static PlayerRunState _instance;
    public static PlayerRunState Instance
    {
        get
        {
            if (_instance == null)
                _instance = new PlayerRunState();
            return _instance;
        }
    }

    private PlayerRunState()
    {

    }

    public override void Enter()
    {

    }

    public override void Execute()
    {
        owner.HorizontalMove();


        #region change state

        if (Mathf.Abs(owner.speedX) < 0.1f || Input.GetKeyUp(KeyCode.LeftArrow) || Input.GetKeyUp(KeyCode.RightArrow))
        {
            owner.StateMachine.ChangeState(PlayerIdleState.Instance);
        }
        else if (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKey(KeyCode.UpArrow))
        {
            owner.StateMachine.ChangeState(PlayerJumpState.Instance);
        }
        else if(owner.isLadderTop == false && Input.GetKey(KeyCode.DownArrow))
        {
            owner.StateMachine.ChangeState(PlayerCrouchState.Instance);
        }

        #endregion
    }

    public override void Exit()
    {

    }   
}


 //Control player' Horizontal move when state is Run and Crouch
    public void HorizontalMove()
    {
        float moveFactor = 1; 

        if(StateMachine.CurrentState == PlayerRunState.Instance)
        {
            moveFactor = 1;
        }
        else if (StateMachine.CurrentState == PlayerCrouchState.Instance)
        {
            moveFactor = playerInfo.crouchSpeedFactor;
        }

        if (Input.GetKey(KeyCode.LeftArrow))
        {
            speedX = -playerInfo.maxSpeed * moveFactor;
        }
        else if (Input.GetKey(KeyCode.RightArrow))
        {
            speedX = playerInfo.maxSpeed * moveFactor;
        }
        else
        {
            speedX = 0;
        }

        rgd2D.velocity = new Vector2(speedX, rgd2D.velocity.y);

        if (speedX > 0 && isFacingRight == false)
        {
            HorizontalFilp();
        }
        else if (speedX < 0 && isFacingRight == true)
        {
            HorizontalFilp();
        }
    }

    //Control player' horizontal flip when state is Run and Crouch
    private void HorizontalFilp()
    {
        GetComponent<SpriteRenderer>().flipX = !GetComponent<SpriteRenderer>().flipX;
        isFacingRight = !isFacingRight;
    }

PlayerCrouchState

using UnityEngine;

public class PlayerCrouchState : State<PlayerControl>
{
    private static PlayerCrouchState _instance;
    public static PlayerCrouchState Instance
    {
        get
        {
            if (_instance == null)
                _instance = new PlayerCrouchState();
            return _instance;
        }
    }

    Collider2D[] colliders = null;

    private PlayerCrouchState()
    {
        
    }

    public override void Enter()
    {
        owner.isCrouch = true;

        if(colliders == null)
            colliders = owner.GetComponents<BoxCollider2D>();

        foreach (var item in colliders)
            item.enabled = false;
    }

    public override void Execute()
    {
        owner.HorizontalMove();

        if(!Input.GetKey(KeyCode.DownArrow) || Input.GetKeyUp(KeyCode.DownArrow))
        {
            owner.StateMachine.ChangeState(PlayerIdleState.Instance);
        }
    }

    public override void Exit()
    {
        owner.isCrouch = false;
        foreach (var item in colliders)
            item.enabled = true;
    }
}

PlayerJumpState

using UnityEngine;

public class PlayerJumpState : State<PlayerControl>
{
    private static PlayerJumpState _instance;
    public static PlayerJumpState Instance
    {
        get
        {
            if (_instance == null)
                _instance = new PlayerJumpState();
            return _instance;
        }
    }

    private PlayerJumpState()
    {

    }

    public override void Enter()
    {
        owner.ctrl.audioManager.Play(owner.ctrl.audioManager.jump, owner.audioSource);
        owner.rgd2D.AddForce(owner.playerInfo.jumpForce * Vector2.up);      
        owner.isJump = true;
        owner.isGrounded = false;      
    }

    public override void Execute()
    {
        #region check Grounded
        
        owner.CheckGrounded();
        owner.CheckLadderTopForLadder();

        if(owner.isGrounded)
        {
            owner.StateMachine.ChangeState(PlayerIdleState.Instance);
        }

        #endregion


        #region check ladder

        owner.CheckLadderTriggleForJumpState();

        if(owner.isClimb)
        {
            owner.StateMachine.ChangeState(PlayerClimbState.Instance);
        }

        #endregion
    }

    public override void Exit()
    {
        //Clear the rest of force
        owner.rgd2D.Sleep();
        owner.isJump = false;
    }   
}

//Help change to IdleState when player in the top of ladder 
    public void CheckLadderTopForLadder()
    {
        Collider2D[] colliders = Physics2D.OverlapCircleAll(groundCheck.position, groundRadius, playerInfo.Ladder);
        foreach (var item in colliders)
        {
            //Check Player is already in the top of ladder
            if (item.tag == "CheckLadderTop")
            {
                isLadderTop = true;
                isGrounded = true;
            }
        }
    }


 //Check is it necessary to change to ClimbState in the JumpState  
    public void CheckLadderTriggleForJumpState()
    {
        Collider2D[] colliders = Physics2D.OverlapCircleAll(ladderCheck.position, ladderRadius, playerInfo.Ladder);
        foreach (var item in colliders)
        {
            if (item.tag == "LadderTrigger")
            {
                ladderTriggle = item.transform;

                if (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKey(KeyCode.UpArrow))
                {
                    isClimb = true;

                    //let player at the middle of the ladder
                    Vector3 newVec = transform.position;
                    newVec.x = item.transform.position.x;
                    transform.position = newVec;
                }
            }
        }
    }

攀爬状态

攀爬状态可以说是花费时间最多的了,因为standardasset包里的2d人物控制没有攀爬……

在网上找了很久也没有找到满意的2D人物爬梯子实现代码,最后只能自己硬着头皮写了。

爬梯子真的复杂很多,要注意诸多细节:

  1. 站立(空闲)状态跳跃后按上键,如果此时碰到了梯子,则进入攀爬状态。
  2. 跑动状态跳跃后按上键,如果此时碰到了梯子,则进入攀爬状态。这与上一条不同是因为站立的跳跃动画与跑动的跳跃动画不同,不过其实都差不多,具体实现是jump状态里调用的CheckLadderTriggleForJumpState()函数。
  3. 上述两条进入攀爬状态后,要把梯子顶端的一块Ground层的方块的collider的isTrigger给置为true,否则到梯子顶就爬不上去了。而如果没有这个方块的话,在梯子上面走路又会直接掉下来。总结就是,默认这块浮动地砖不是trigger,当人物进入攀爬状态时,这块地砖变成trigger,当人物退出攀爬状态时,这块地砖又不是trigger了。
  4. 站立状态按下键,如果此时脚下是梯子顶端,那么进入攀爬状态。
  5. 从下往上爬的时候,攀爬状态的结束检测(爬梯子爬完了)是一个让人头痛的问题。
  6. 原本我是把从下往上爬的状态结束检测与站立状态的脚下梯子顶端检测放在同一个collider身上,把这个collider设置为isTrigger=true
  7. 但是!因为是使用的OverlapCircleAll分层级的进行是否接地检测,所以总是导致各种问题,要么能下不能上,要么能下不能上,最后还是老老实实的分开用两个collider检测,也分了两个层级和方法,分别是CheckLadderTopForGround()和CheckLadderTopForLadder()
  8. 还有其他一些小细节,比如人物爬上梯子后,把position.y改成跟梯子一样,否则就歪歪斜斜了。
  9. 刚刚进入攀爬状态是静止攀爬动画,如果speedY大于0,就进入动态攀爬动画。

LadderSetting

把梯子做好后弄成prefab,以后哪里要哪里搬,就不用再想这么多让人头大的问题了。层级的设置我也放在了playerInfo里。

using UnityEngine;

public class PlayerClimbState:State<PlayerControl>
{
    private static PlayerClimbState _instance;
    public static PlayerClimbState Instance
    {
        get
        {
            if (_instance == null)
                _instance = new PlayerClimbState();
            return _instance;
        }
    }

    private PlayerClimbState()
    {

    }

    public override void Enter()
    {
        owner.isGrounded = false;

        owner.speedY = owner.playerInfo.climbSpeed;
        owner.ladderTriggle.parent.Find("LadderTop").GetComponent<BoxCollider2D>().enabled = false;
    }

    public override void Execute()
    {
        //remove player's gravity
        owner.rgd2D.constraints |= RigidbodyConstraints2D.FreezePositionY;
                
        owner.CheckGrounded();
        owner.CheckLadderTopForLadder();


        #region control vertical move when player in the ladder

        if (Input.GetKey(KeyCode.UpArrow))
        {
            Vector2 tempVec = owner.transform.position;
            tempVec.y += Time.deltaTime * owner.playerInfo.climbSpeed;
            owner.transform.position = tempVec;
            owner.speedY = owner.playerInfo.climbSpeed;
        }
        else if (Input.GetKey(KeyCode.DownArrow))
        {
            Vector2 tempVec = owner.transform.position;
            tempVec.y -= Time.deltaTime * owner.playerInfo.climbSpeed;
            owner.transform.position = tempVec;
            owner.speedY = owner.playerInfo.climbSpeed;
        }
        else
        {
            owner.speedY = 0;
        }

        #endregion


        if (owner.isGrounded)
        {
            owner.rgd2D.constraints = RigidbodyConstraints2D.FreezeRotation;
            owner.StateMachine.ChangeState(PlayerIdleState.Instance);
        }        
    }

    public override void Exit()
    {
        owner.speedY = 0;
        owner.isClimb = false;

        owner.ladderTriggle.parent.Find("LadderTop").GetComponent<BoxCollider2D>().enabled = true;        
    }    
   
}

受伤状态

这一部分挺简单的,放在了全局状态里,因为无论什么状态,碰到怪物都会受伤。总而言之就是碰到怪物就减一条命并进入无敌状态一段时间,用协程做了个闪烁动画。碰撞检测和减命在工程其他部分脚本里。

public class PlayerGlobalState : State<PlayerControl>
{
    private static PlayerGlobalState _instance;
    public static PlayerGlobalState Instance
    {
        get
        {
            if (_instance == null)
                _instance = new PlayerGlobalState();
            return _instance;
        }
    }

    private PlayerGlobalState()
    {

    }
  
    public override void Enter()
    {
        
    }

    public override void Execute()
    {
        if (!owner.isPlayHurtAnim && owner.isHurt == true)
        {
            owner.HP--;
            owner.isPlayHurtAnim = true;
            owner.isHurt = false;
            owner.StartCoroutine(owner.HurtAnim());
        }

        if(owner.isEnding)
        {

        }
    }

    public override void Exit()
    {

    }
}

 //Player's hurt Animation with Coroutine
    //Player can't hurt when the hurt Animation play
    public IEnumerator HurtAnim()
    {
        Vector4 color = GetComponent<SpriteRenderer>().color;
        for (int i = 0; i < 20; i++)
        {
            if (i % 2 == 0)
                color.w = 0f;
            else
                color.w = 1f;

            GetComponent<SpriteRenderer>().color = color;
            yield return new WaitForSeconds(0.2f);
        }
       
        StopCoroutine(HurtAnim());

        isPlayHurtAnim = false;        
    }


当通关的时候,屏幕除了player周围都变黑,此时player不能控制且处于idle状态,写了一个OnPassLevel(),在工程其他部分调用。

public void OnPassLevel()
    {
        rgd2D.velocity = Vector2.zero;
        anim.SetFloat("SpeedX", 0);
        anim.SetFloat("SpeedY", 0);
        rgd2D.constraints |= RigidbodyConstraints2D.FreezePositionX;
    }

大概就这些了,其他碰撞受伤死亡检测都是在GameManager里写的,这里就不写了。放一下Unity动画状态机的图:动画状态机

希望我能尽快完成这个游戏吧……写UI脚本真的好心累啊啊啊!!!

  • 15
    点赞
  • 59
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值