使用Enum来管理人物状态的好处有:
- 完美替代 Unity Mecanim 的状态转换问题。避免Animator看起来像一团纠结在一起的毛线团
- 利用 [Flags] 的 Attribute可以方便的解决同时有两个或以上状态的问题
- 利用当前的任务状态Property来改变动画,实现动画渲染和实际控制代码的解耦
- TODO: 当有多层Layer来表示动画的时候,可以创建多种状态Enum;许多常见的机制还没有写进去,如二段跳、coyote jump等

图1: MegaMan示例和Animator的简单结构,例子来源:这里。
这是一个不难解决却困扰了我很久的问题。直到最近看《游戏编程模式》这本书里阐述了类似的情景。附上自己写的还算比较满意的代码,输入上也实现了更新状态和输入分离。Bitwise基本操作的参考说明见Alan Zucconi。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerContrllerEnum : MonoBehaviour
{
[SerializeField] private float horizontalSpeed = 10f;
public float HorizontalSpeed { get { return horizontalSpeed; } private set { horizontalSpeed = value; } }
[SerializeField] [Range(0, 10f)] private float jumpForce = 8f;
public float JumpForce { get { return jumpForce; } private set { jumpForce = value; } }
[SerializeField] private float attackGap = 0.2f;
public float AttackGap { get { return attackGap; } private set { attackGap = value; } }
[SerializeField] private float horizontalInput = 0f;
public float HorizontalInput { get { return horizontalInput; } private set { horizontalInput = value; } }
[SerializeField] private bool isJumpPressed = false;
public bool IsJumpPressed { get { return isJumpPressed; } private set { isJumpPressed = value; } }
[SerializeField] bool isOnGround = true;
public bool IsOnGround { get { return isOnGround; } private set { isOnGround = value; } }
[SerializeField] private bool isShootPressed = false;
public bool IsShootPressed { get { return isShootPressed; } private set { isShootPressed = value; } }
[SerializeField] private bool isAbleToShoot = true;
[SerializeField] private float shootGapCountDown;
public int GroundMask { get; set; } = 0;
[SerializeField] private Rigidbody2D myRigidBody;
[SerializeField] Animator animator;
private PlayerInput _playerInput { get; set; }
private string MovementActionsName { get; } = "Movement";
private string JumpActionName { get; } = "Jump";
private string ShootActionName { get; } = "Shoot";
#region Animation Names
private string PlayerIdlingAnimString { get; } = "Player_idle";
private string PlayerMovingAnimString { get; } = "Player_run";
private string PlayerJumpingAnimString { get; } = "Player_jump";
private string PlayerAttackAnimString { get; } = "Player_attack";
private string PlayerAirAttackAnimString { get; } = "Player_air_attack";
#endregion
[Flags]
public enum PlayerStatus
{
None = 0,
Idling = 1<<0,
Moving = 1<<1,
Jumping = 1<<2,
Attacking = 1<<3,
}
private PlayerStatus currentStatus = PlayerStatus.Idling;
public PlayerStatus CurrentStatus { get { return currentStatus; }
set
{
if(value != currentStatus && value!=PlayerStatus.None)
{
if(!isAbleToShoot && !value.HasFlag(PlayerStatus.Attacking)) return; // keep shooting anim when it's still in cool down
if(value == PlayerStatus.Idling)
{
ChangeAnimationStateTo(PlayerIdlingAnimString);
}
else if(value == PlayerStatus.Moving)
{
ChangeAnimationStateTo(PlayerMovingAnimString);
}
else if(value== PlayerStatus.Attacking)
{
ChangeAnimationStateTo(PlayerAttackAnimString);
}
else if(value == (PlayerStatus.Attacking | PlayerStatus.Jumping))
{
ChangeAnimationStateTo(PlayerAirAttackAnimString);
}
else if(value== PlayerStatus.Jumping)
{
ChangeAnimationStateTo(PlayerJumpingAnimString);
}
currentStatus = value;
}
}
}
void ChangeAnimationStateTo(string newAnimation)
{
animator.Play(newAnimation);
}
// Start is called before the first frame update
void Start()
{
_playerInput = GetComponent<PlayerInput>();
animator = GetComponent<Animator>();
currentStatus = PlayerStatus.Idling;
GroundMask = LayerMask.GetMask("Ground");
myRigidBody = GetComponent<Rigidbody2D>();
}
// Update is called once per frame
void Update()
{
HorizontalInput = _playerInput.actions[MovementActionsName].ReadValue<Vector2>().x;
IsJumpPressed = _playerInput.actions[JumpActionName].triggered;
IsShootPressed = _playerInput.actions[ShootActionName].triggered;
}
private void FixedUpdate()
{
CheckIfIsOnGround();
FixedUpdateAttackGap();
PlayerStatus statusInferred = PlayerStatus.Idling;
if(Mathf.Abs(HorizontalInput) > 0.01)
{
Move(HorizontalInput*HorizontalSpeed);
statusInferred = PlayerStatus.Moving;
}
if(!IsOnGround)
{
statusInferred = PlayerStatus.Jumping;
}
if(IsJumpPressed && IsOnGround)
{
IsJumpPressed = false;
Jump();
statusInferred = PlayerStatus.Jumping;
}
if(IsShootPressed && isAbleToShoot)
{
IsShootPressed = false;
ShootABullet();
statusInferred &= ~PlayerStatus.Idling;
statusInferred &= ~PlayerStatus.Moving;
statusInferred |= PlayerStatus.Attacking;
}
CurrentStatus = statusInferred;
}
public void Move(float inputValue)
{
// method: set velocity directly
Vector2 currentVelocity = myRigidBody.velocity;
//flip if necessary
Vector3 localScale = transform.localScale;
if(Mathf.Sign(localScale.x) != Mathf.Sign(inputValue))
{
localScale.x = -localScale.x;
transform.localScale = localScale;
}
currentVelocity.x = inputValue;
myRigidBody.velocity = currentVelocity;
}
public void Jump()
{
myRigidBody.AddForce(new Vector2(0, JumpForce), ForceMode2D.Impulse);
}
public void ShootABullet()
{
isAbleToShoot = false;
shootGapCountDown = AttackGap;
//TODO
Debug.Log("A bullet is shot");
}
private void FixedUpdateAttackGap()
{
if(!isAbleToShoot)
{
shootGapCountDown -= Time.fixedDeltaTime;
if(shootGapCountDown <= 0)
isAbleToShoot = true;
}
}
private void CheckIfIsOnGround()
{
RaycastHit2D hit = Physics2D.Raycast(transform.position, Vector2.down, 0.1f, GroundMask);
if(hit.collider != null)
{
IsOnGround = true;
}
else
IsOnGround = false;
}
}