Player State Machine(플레이어 상태 머신)

플레이어의 Input을 받아 스크립트로 Action과 이벤트를 처리하고 추가한다.

public class PlayerInput : MonoBehaviour
{
    // 플레이어의 InputAction
    public PlayerInputActions InputActions { get; private set; }
    public PlayerInputActions.PlayerActions PlayerActions { get; private set; }

    private void Awake()
    {
        InputActions = new PlayerInputActions();

        PlayerActions = InputActions.Player;
    }

    // InputAction 키기
    private void OnEnable()
    {
        InputActions.Enable();
    }

    // InputAction 끄기
    private void OnDisable()
    {
        InputActions.Disable();
    }
}

 

애니메이션에서 사용할 파라미터 값들을 설정한다.

[Serializable]
public class PlayerAnimationData
{
    [SerializeField] private string groundParameterName = "@Ground";
    [SerializeField] private string idleParameterName = "Idle";
    [SerializeField] private string walkParameterName = "Walk";
    [SerializeField] private string runParameterName = "Run";

    [SerializeField] private string airParameterName = "@Air";
    [SerializeField] private string jumpParameterName = "Jump";
    [SerializeField] private string fallParameterName = "Fall";

    [SerializeField] private string attackParameterName = "@Attack";
    [SerializeField] private string comboAttackParameterName = "ComboAttack";


    public int GroundParameterHash { get; private set; }
    public int IdleParameterHash { get; private set; }
    public int WalkParameterHash { get; private set; }
    public int RunParameterHash { get; private set; }

    public int AirParameterHash { get; private set; }
    public int JumpParameterHash { get; private set; }
    public int fallParameterHash { get; private set; }

    public int AttackParameterHash { get; private set; }
    public int ComboAttackParameterHash { get; private set; }

    public void Initialize()
    {
        GroundParameterHash = Animator.StringToHash(groundParameterName);
        IdleParameterHash = Animator.StringToHash(idleParameterName);
        WalkParameterHash = Animator.StringToHash(walkParameterName);
        RunParameterHash = Animator.StringToHash(runParameterName);

        AirParameterHash = Animator.StringToHash(airParameterName);
        JumpParameterHash = Animator.StringToHash(jumpParameterName);
        fallParameterHash = Animator.StringToHash(fallParameterName);

        AttackParameterHash = Animator.StringToHash(attackParameterName);
        ComboAttackParameterHash = Animator.StringToHash(comboAttackParameterName);
    }
}

 

플레이어의 필요한 데이터를 총집합한다.

public class Player : MonoBehaviour
{
    [field: Header("Animations")]
    [field: SerializeField] public PlayerAnimationData AnimationData { get; private set; }

    public Rigidbody Rigidbody { get; private set; }
    public Animator Animator { get; private set; }
    public PlayerInput Input { get; private set; }
    public CharacterController Controller { get; private set; }

    // 초기화
    private void Awake()
    {
        AnimationData.Initialize();

        Rigidbody = GetComponent<Rigidbody>();
        Animator = GetComponentInChildren<Animator>();
        Input = GetComponent<PlayerInput>();
        Controller = GetComponent<CharacterController>();
    }

    // 커서 처리
    private void Start()
    {
        Cursor.lockState = CursorLockMode.Locked;
    }

}

 

State는 인터페이스이기 때문에 내부를 구현하지 않아도 된다.

IState에 들어왔을 때, 나갈 때, State 중 입력처리 할 때, 업데이트, 물리적 업데이트의 상태를 만들어준다.

public interface IState
{
    public void Enter();
    public void Exit();
    public void HandleInput();
    public void Update();
    public void PhysicsUpdate();

}

 

State Machine 스크립트에서 IState를 받아온다. 자체적으로 객체화를 할 수 없도록 추상클래스를 사용한다.

public abstract class StateMachine
{
    protected IState currentState;

    public void ChangeState(IState newState)
    {
        currentState?.Exit(); // 전의 상황에 대한 마무리

        currentState = newState; // 새로운 상황

        currentState?.Enter(); // 새로운 상황 시작
    }

    public void HandleInput()
    {
        currentState?.HandleInput();
    }

    public void Update()
    {
        currentState?.Update();
    }

    public void PhysicsUpdate()
    {
        currentState?.PhysicsUpdate();
    }
}

 

플레이어의 Scriptable Object를 사용하기 위해 스크립트만든다.

[CreateAssetMenu(fileName ="Player",menuName = "Characters/Player")]

public class PlayerSO : ScriptableObject
{
    [field: SerializeField] public PlayerGroundData GroundedData { get; private set; }
    [field: SerializeField] public PlayerAirData AirData { get; private set; }
}

 

그리고 Player 스크립트에서 스크립터블 오브젝트의 데이터를 가져올 수 있도록 한다.

[field: Header("References")]
[field: SerializeField] public PlayerSO Data { get; private set; }

private PlayerStateMachine stateMachine; // 플레이어 상태 머신

private void Awake()
{
    AnimationData.Initialize();

    Rigidbody = GetComponent<Rigidbody>();
    Animator = GetComponentInChildren<Animator>();
    Input = GetComponent<PlayerInput>();
    Controller = GetComponent<CharacterController>();

    stateMachine = new PlayerStateMachine(this); // 플레이어가 플레이어 자신을 넘겨줄 수 있도록 한다
}

 

스크립터블 오브젝트에서 사용하기 위한 데이터 스크립트들을 만든다.

플레이어가 땅에 있을 때

[Serializable]
public class PlayerGroundData
{
	// 플레이어의 기본 이동속도와 회전
    [field: SerializeField][field: Range(0f, 25f)] public float BaseSpeed { get; private set; } = 5f;
    [field: SerializeField][field: Range(0f, 25f)] public float BaseRotationDamping { get; private set; } = 1f;

	// 플레이어 이동처리
    [field: Header("IdleData")]

    [field: Header("WalkData")]
    [field: SerializeField][field: Range(0f, 2f)] public float WalkSpeedModifier { get; private set; } = 0.225f;

    [field: Header("RunData")]
    [field: SerializeField][field: Range(0f, 2f)] public float RunSpeedModifier { get; private set; } = 1f;
}

 

플레이어가 공중에 있을 때

[Serializable]
public class PlayerAirData
{
    // 플레이어의 점프에 대한 힘
    [field: Header("JumpData")]
    [field: SerializeField][field: Range(0f, 25f)] public float JumpForce { get; private set; } = 4f;
}

 

플레이어 상태 머신 스크립트에서 플레이어의 상태와 이동에 대한 처리들을 받아온다.

public class PlayerStateMachine : StateMachine
{
    public Player Player { get; }

    // States
    public PlayerIdleState IdleState { get; }

    // 이동
    public Vector2 MovementInput { get; set; }
    public float MovementSpeed { get; private set; }
    public float RotationDamping { get; private set; }
    public float MovementSpeedModifier { get; set; } = 1f;

    public float JumpForce { get; set; }

    public Transform MainCameraTransform { get; set; }

    public PlayerStateMachine(Player player)
    {
        this.Player = player;

        IdleState = new PlayerIdleState(this);

        MainCameraTransform = Camera.main.transform;

        MovementSpeed = player.Data.GroundedData.BaseSpeed;
        RotationDamping = player.Data.GroundedData.BaseRotationDamping;
    }
}

 

플레이어의 기본 상태를 만들기 위해 IState를 상속받는다.

public class PlayerIdleState : IState
{
    public void Enter()
    {
        throw new System.NotImplementedException();
    }

    public void Exit()
    {
        throw new System.NotImplementedException();
    }

    public void Update()
    {
        throw new System.NotImplementedException();
    }
}

 

그런데 Enter, Exit, Update 등의 처리를 위해 Input이나 Animation 등 여러 State를 컨트롤 하는 기능들을 구현하면 중복이 발생할 것이다. 그것을 방지하기 위해 새로운 BaseState를 상속받게 한다.

 

플레이어의 기본 상태

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

public class PlayerBaseState : IState
{
    // 역참조
    protected PlayerStateMachine stateMachine;
    protected readonly PlayerGroundData groundData;

    public PlayerBaseState(PlayerStateMachine playerStateMachine)
    {
        stateMachine = playerStateMachine;
        groundData = stateMachine.Player.Data.GroundedData;
    }

    public virtual void Enter()
    {
        AddInputActionsCallbacks();
    }

    public virtual void Exit()
    {
        RemoveInputActionsCallbacks();
    }

    public virtual void HandleInput()
    {
        ReadMovementInput();
    }

    public virtual void PhysicsUpdate()
    {

    }

    public virtual void Update()
    {
        Move();
    }

    private void AddInputActionsCallbacks()
    {
        throw new NotImplementedException();
    }

    private void RemoveInputActionsCallbacks()
    {
        throw new NotImplementedException();
    }

    // 이동에 대한 입력
    private void ReadMovementInput()
    {
        stateMachine.MovementInput = stateMachine.Player.Input.PlayerActions.Movement.ReadValue<Vector2>();
    }

    // 실제 이동처리
    private void Move()
    {
        Vector3 movementDirection = GetMovementDirection();

        Rotate(movementDirection);

        Move(movementDirection);
    }

    // 카메라가 바라보는 방향으로 이동
    private Vector3 GetMovementDirection()
    {
        Vector3 forward = stateMachine.MainCameraTransform.forward;
        Vector3 right = stateMachine.MainCameraTransform.right;

        // 땅바닥 보지 않게 하기 위해 y값 제거
        forward.y = 0;
        right.y = 0;

        forward.Normalize();
        right.Normalize();

        return forward * stateMachine.MovementInput.y + right * stateMachine.MovementInput.x;
    }

    // 이동
    private void Move(Vector3 movementDirection)
    {
        float movementSpeed = GetMovemenetSpeed();
        stateMachine.Player.Controller.Move(
            (movementDirection * movementSpeed) * Time.deltaTime
            );
    }

    // 회전
    private void Rotate(Vector3 movementDirection)
    {
        if (movementDirection != Vector3.zero)
        {
            Transform playerTransform = stateMachine.Player.transform;
            Quaternion targetRotation = Quaternion.LookRotation(movementDirection);
            playerTransform.rotation = Quaternion.Slerp(playerTransform.rotation, targetRotation, stateMachine.RotationDamping * Time.deltaTime);
        }
    }

    // 이동 속도
    private float GetMovemenetSpeed()
    {
        float movementSpeed = stateMachine.MovementSpeed * stateMachine.MovementSpeedModifier;
        return movementSpeed;
    }

    // 플레이어 상태에 대한 애니메이션
    protected void StartAnimation(int animationHash)
    {
        stateMachine.Player.Animator.SetBool(animationHash, true);
    }

    protected void StopAnimation(int animationHash)
    {
        stateMachine.Player.Animator.SetBool(animationHash, false);
    }
}

 

 

플레이어가 땅에 있을 때

public class PlayerGroundedState : PlayerBaseState
{
    public PlayerGroundedState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    // 입력에 따라 애니메이션의 bool값을 키고 끈다
    public override void Enter()
    {
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.GroundParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Player.AnimationData.GroundParameterHash);
    }

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

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

 

다시 플레이어의 기본 상태 스크립트인 PlayerIdleState로 돌아와서 생성자 처리를 해주고 오버라이드 한다.

public class PlayerIdleState : PlayerGroundedState
{
    public PlayerIdleState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0f; // 기본 상태이기 때문에 움직이지 않는다.
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.IdleParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Player.AnimationData.IdleParameterHash);
    }

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

 

유니티로 돌아와서 저번에 만들어둔 스크립터블 오브젝트인 Player를 생성한다.

 

Rotation Damping 값만 조절하고 나머지는 그대로 둘 것이다.

 

이 데이터를 플레이어 오브젝트에 넣고 캐릭터의 이동을 위해 Input을 처리하는 스크립트를 넣는다.

 

플레이어에 Character Controller를 추가한다.

Character Controller는 움직이는 것을 Rigid Body나 Collider를 사용하지 않고 간단하게 사용할 수 있다. 그러나 현실적인 움직임을 구현할 때는 적합하지 않다.

 

플레이어 모델에 Animator를 걸고 Player Animator Controller를 연결한다.

 

Create Sub-State Machine을 생성하여 Ground를 관리할 Sub-State Machine을 만든다.

 

Animation Data 값에 들어있던 것들을 Parameters에 Bool값으로 만들어준다.

 

각각 언제 true가 되고 false가 되는지 연결해준다.

 

StateMachine의 기능들을 사용하기 위해 Player 스크립트에 코드를 추가한다.

private void Start()
{
    Cursor.lockState = CursorLockMode.Locked;
    stateMachine.ChangeState(stateMachine.IdleState);
}

private void Update()
{
    stateMachine.HandleInput();
    stateMachine.Update();
}

private void FixedUpdate()
{
    stateMachine.PhysicsUpdate();
}

State를 걸어주면 curState가 저장되고 HandleInput(), Update(), PhysicsUpdate()가 동작하며 알맞은 동작을 한다.

Idle에 대한 State만 구상했기 때문에 아직은 기본 상태만 동작한다.

 

 

플레이어의 이동

이번에는 이동을 처리하는 스크립트를 만들어준다.

플레이어 State Machine에 걷는 상태와 뛰는 상태를 추가한다.

public class PlayerStateMachine : StateMachine
{
    // States
    public PlayerIdleState IdleState { get; }
    public PlayerWalkState WalkState { get; }
    public PlayerRunState RunState { get; }

    public PlayerStateMachine(Player player)
    {
        this.Player = player;

        IdleState = new PlayerIdleState(this);
        WalkState = new PlayerWalkState(this);
        RunState = new PlayerRunState(this);

        MainCameraTransform = Camera.main.transform;

        MovementSpeed = player.Data.GroundedData.BaseSpeed;
        RotationDamping = player.Data.GroundedData.BaseRotationDamping;
    }
}

 

걷기와 뛰기의 키 입력을 처리하기 위한 코드를 기본상태 스크립트에 추가한다.

protected virtual void AddInputActionsCallbacks()
{
    PlayerInput input = stateMachine.Player.Input;
    input.PlayerActions.Movement.canceled += OnMovementCanceled;
    input.PlayerActions.Run.started += OnRunStarted;    }

protected virtual void RemoveInputActionsCallbacks()
{
    PlayerInput input = stateMachine.Player.Input;
    input.PlayerActions.Movement.canceled -= OnMovementCanceled;
    input.PlayerActions.Run.started -= OnRunStarted;
}

// 틀만 구현
protected virtual void OnRunStarted(InputAction.CallbackContext context)
{

}

protected virtual void OnMovementCanceled(InputAction.CallbackContext context)
{

}

 

땅 위의 상태에서 이동 입력이 들어오지 않았을 때를 처리한다.

// 땅 위에서 실행되지 않았을 때 처리
protected override void OnMovementCanceled(InputAction.CallbackContext context)
{
    // 입력이 안들어왔다면
    if (stateMachine.MovementInput == Vector2.zero)
    {
        return;
    }
    // 기본 상태로
    stateMachine.ChangeState(stateMachine.IdleState);

    base.OnMovementCanceled(context);
}

// 이동이 된다면 걷기 상태로
protected virtual void OnMove()
{
    stateMachine.ChangeState(stateMachine.WalkState);
}

 

플레이어의 걷기 상태

public class PlayerWalkState : PlayerGroundedState
{
    public PlayerWalkState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = groundData.WalkSpeedModifier;
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.WalkParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Player.AnimationData.WalkParameterHash);
    }

    protected override void OnRunStarted(InputAction.CallbackContext context)
    {
        base.OnRunStarted(context);
        stateMachine.ChangeState(stateMachine.RunState);
    }
}

 

플레이어의 뛰기 상태

public class PlayerRunState : PlayerGroundedState
{
    public PlayerRunState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = groundData.RunSpeedModifier;
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.RunParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Player.AnimationData.RunParameterHash);
    }
}

 

기본 상태 스크립트에서 기본 상태에서 걷거나 뛰는 상태로 전환될 때의 처리를 하는 코드를 추가한다.

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

    if (stateMachine.MovementInput != Vector2.zero)
    {
        OnMove();
        return;
    }
}

 

키보드 입력에 따라 걷거나 뛴다.

 

 

+ Recent posts