플레이어의 상태 머신에서 사용하기 위한 플레이어의 데이터를 Scriptable Object로 만들어보자.
아래와 같이 폴더들과 스크립트들을 만들어 준다.
using UnityEngine;
[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; }
}
using System;
using UnityEngine;
[Serializable]
public class PlayerGroundData
{
// Player의 이동, 회전을 관리
[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; // 회전할 때 사용되는 Damping 값
[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;
}
using UnityEngine;
[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; }
}
아래와 같이 폴더와 스크립트들을 만들어 준다.
public class PlayerBaseState : IState
{
// 모든 State는 StateMachine과 역참조를 할 것!
protected PlayerStateMachine stateMachine;
protected readonly PlayerGroundData groundData;
public PlayerBaseState(PlayerStateMachine playerStateMachine)
{
this.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();
}
// InputAction에 이벤트를 구독하는 것과 유사하게 Callback을 걸어준다.
// 이를 Enter와 Exit에 걸어두어 State 진입 시 InputAction을 받아오고 해제할 수 있도록 해준다.
protected virtual void AddInputActionsCallbacks()
{
}
protected virtual void RemoveInputActionsCallbacks()
{
}
private void ReadMovementInput()
{
// 차후에는 "stateMachine.Player.Input.PlayerActions.Movement.ReadValue<Vector2>();"를 캐싱하는 연습을 할 것!
// ex) playerMovement = stateMachine.Player.Input.PlayerActions.Movement.ReadValue<Vector2>();
stateMachine.MovementInput = stateMachine.Player.Input.PlayerActions.Movement.ReadValue<Vector2>();
}
private void Move()
{
Vector3 movementDirection = GetMovementDirection();
Rotate(movementDirection);
Move(movementDirection);
}
// 카메라가 바라보고 있는 방향으로 움직이기
private Vector3 GetMovementDirection()
{
// Main Camera의 정면, 우측 방향 가져오기
Vector3 forward = stateMachine.MainCameraTransform.forward;
Vector3 right = stateMachine.MainCameraTransform.right;
// 움직일 때 땅으로 꺼지지 않도록 y값 0으로 고정
forward.y = 0;
right.y = 0;
forward.Normalize();
right.Normalize();
// 이동 방향 forward와 right에 입력된 벡터 값을 곱하여 이동 방향 구하기
return forward * stateMachine.MovementInput.y + right * stateMachine.MovementInput.x;
}
private void Move(Vector3 movementDirection)
{
float movementSpeed = GetMovementSpeed();
stateMachine.Player.Controller.Move(
(movementDirection * movementSpeed) * Time.deltaTime
);
}
private void Rotate(Vector3 movementDirection)
{
if(movementDirection != Vector3.zero)
{
// Quaternion.LookRotation(movementDirection) : movementDirection을 바라보는 Quaternion 생성
Quaternion targetRotation = Quaternion.LookRotation(movementDirection);
stateMachine.Player.transform.rotation = Quaternion.Slerp(stateMachine.Player.transform.rotation, targetRotation, stateMachine.RotationDamping * Time.deltaTime);
}
}
private float GetMovementSpeed()
{
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);
}
}
IState를 상속받아놓아서 PlayerBaseState를 상속 받은 모든 Player State들의 상태 변경과 움직일 때의 처리를 해준다.
또한 상태 변경 시 애니메이터의 Parameter의 해쉬값과 비교하여 애니메이션을 재생해준다.
public class PlayerGroundedState : PlayerBaseState
{
public PlayerGroundedState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
{
}
// PlayerGroundedState를 상속 받는 모든 스크립트에서는 GroundParameterHash가 true인 상태에서 동작하게 될 것!
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 PhysicsUpdate()
{
base.PhysicsUpdate();
}
}
PlayerGroundedState는 PlayerBaseState를 상속받아서 Enter()일 때 Ground 상태일 때의 애니메이션들을 재생하기 위한 GroundParameterHash을 true로 변경해주어서 Animation을 Start한다. 따라서 PlayerGroundedState를 상속 받는 모든 스크립트에서는 GroundParameterHash가 true인 상태에서 동작하게 된다.
public class PlayerIdleState : PlayerGroundedState
{
public PlayerIdleState(PlayerStateMachine playerStateMachine) : base(playerStateMachine)
{
}
public override void Enter()
{
stateMachine.MovementSpeedModifier = 0f; // Idle 상태 진입 시 움직이지 않도록 속도를 0으로 변경해준다.
base.Enter();
StartAnimation(stateMachine.Player.AnimationData.IdleParameterHash);
}
public override void Exit()
{
base.Exit();
StopAnimation(stateMachine.Player.AnimationData.IdleParameterHash);
}
public override void Update()
{
base.Update();
}
}
위의 PlayerGroundedState와 마찬가지로 PlayerIdleState를 상속받는 모든 스크립트에서 Idle 상태로 동작하도록 해준다.
Idle상태일 때는 움직이지 않을 것이기 때문에 Enter()에서 MovementSpeedModifier를 0으로 설정해준다.
public class PlayerStateMachine : StateMachine
{
public Player Player { get; }
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; } = 1.0f;
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;
}
}
PlayerStateMachine에서는 StateMachine를 상속받아 Player의 상태머신을 설정해준다.
Player.cs에 작성한 PlayerSO와 상태머신, 애니메이션들을 추가해준다.
''' 생략
[field: Header("References")]
[field: SerializeField] public PlayerSO Data { get; private set; }
''' 생략
private PlayerStateMachine stateMachine;
private void Awake()
{
AnimationData.Initialize();
Animator = GetComponentInChildren<Animator>();
Input = GetComponent<PlayerInput>();
Controller = GetComponent<CharacterController>();
stateMachine = new PlayerStateMachine(this);
}
private void Start()
{
Cursor.lockState = CursorLockMode.Locked;
stateMachine.ChangeState(stateMachine.IdleState);
}
private void Update()
{
stateMachine.HandleInput();
stateMachine.Update();
}
private void FixedUpdate()
{
stateMachine.PhysicsUpdate();
}
''' 생략
ScriptableObject 폴더 밑에 Datas 폴더를 만들어주고, PlayerSO를 만들어 준다.
이후 Base Rotation Damping을 8로 변경해준다.
이후 Player 스크립트 컴포넌트에 PlayerSO를 넣어주고, Animation Data가 정상적으로 들어가있는지 확인한다.
다음으로 Player Input와 Character Controller를 추가해준다.
Rigidbody를 사용하지 않고 오브젝트의 움직임을 Control할 때 사용한다.
Slope Limit : 해당 값의 경사도 이상은 올라가지 못하도록 설정해준다.
Step Offset : 해당 Character Controller가 적용되어 있는 캐릭터가 계단을 올라갈 때, 계단의 높이가 해당 값 이하여야 한다.
Center와 Radius를 설정하여 캐릭터의 크기에 맞게 Collider가 적용되도록 변경해준다.
플레이어의 모델에 Animator Component를 추가해준다.
이후에 Player Animator Controller를 다음과 같이 만들어 준다.
그 다음엔 Player Animator Controller를 열어서 Create Sub-State Machine을 해준다. 이름은 Ground로 변경해준다.
이후 Ground를 더블 클릭하여 들어가면 아래와 같은 애니메이터를 열 수 있게 된다.
여기에 Player가 Ground 상태일 때의 애니메이션들을 추가해준다.
다음으로 Condition들을 변경하기 위한 Parameter들을 만들어준다.
이후 다음과 같이 Animation간의 Transition을 만들어 준다. 모두 Has Exit Time은 없다.