개요
정신 나갈 것 같습니다.
FSM 구조
원활하게 Player의 움직임을 제어하기 위해 FSM을 사용했습니다.
애니메이터 기반의 FSM으로 애니메이터와 동기화 되기 때문에 자연스러운 애니메이팅 + 좋은 FSM 구조 입니다.
애니메이션이 필요 없는 경우엔 다른 구조를 사용해도 되겠지만 뭐... 제 머리론 딱히 더 좋은 구조가 생각 안나니까 감지덕지죠.
Player의 Input과 움직임을 담당하는 스크립트를 만들어놨으니 각 State에서 Input을 구독해서 Player를 움직여주는 방식이죠.
PlayerStateMachine이라는 Player의 State들을 총괄하는 클래스를 기반으로 각각의 State를 관리해줄겁니다.
각각의 State들은 표현하고 관리하기 편하도록 enum으로 적어줍니다.
public enum PlayerStateEnum
{
Idle,
Run,
JumpingUp,
Fall,
WallRun,
WallJump
}
그리고 이 enum을 기반으로 StateMachine을 짜볼겁니다.
일단 필요한건 Player의 현재 State를 각각의 State를 담당하는 클래스들과 enum을 넣으면 그에 해당하는 클래스를 돌려줄 Dictionary 등등이 있습니다.
일단 Player 각각의 State를 총괄할 부모 클래스인 PlayerState 클래스 부터 적어보겠습니다.
public abstract class PlayerState
당연하게도 추상클래스 입니다.
제가 말했듯이 Unity 내부의 Animator 기반 FSM이기 때문에 이 State에 들어왔을때 실행해줄 Animation이 있어야 합니다.
클립을 다룰 수는 없으니 Animator 내부의 파라미터를 사용하는게 좋겠죠.
protected PlayerStateMachine _stateMachine;
protected Player _player;
protected readonly int _animBoolHash;
public PlayerState(PlayerStateMachine stateMachine, Player player, string animBoolname)
{
_stateMachine = stateMachine;
_player = player;
_animBoolHash = Animator.StringToHash(animBoolname);
}
PlayerState 내부에서도 당연하게도 State를 변경해줄 일이 있기 때문에 StateMachine과 조작해줄 필요가 있는 Player, 그리고 Animator 내부의 bool 파라미터를 건들기 위한 bool 변수의 이름을 생성자로 받아와줍니다.
그리고 각각 State에 들어왔을때(Enter), State에서 나갈때(Exit), State 도중에 실행(Update)할 함수들을 만들어줍니다.
public virtual void Enter()
{
_player.AnimatorCompo.SetBool(_animBoolHash, true);
_endTriggerCalled = false;
}
public virtual void UpdateState() { }
public virtual void Exit()
{
_player.AnimatorCompo.SetBool(_animBoolHash, false);
}
그리고 들어오면서 Animator의 bool 변수를 true로, 나가면서 false로 바꿔주면 애니메이션 전환 끝입니다.
이제 이 PlayerState를 기반으로 PlayerStateMachine을 만들어줍니다.
public class PlayerStateMachine
{
public PlayerState CurrentState { get; private set; }
private Dictionary<PlayerStateEnum, PlayerState> _stateDictionary;
Player의 현재 상태를 담고 있는 변수와 위에서 말했던 enum을 넣으면 State를 주는 Dictionary를 만들어주고
public PlayerStateMachine()
{
_stateDictionary = new Dictionary<PlayerStateEnum, PlayerState>();
}
MonoBehaviour가 아니기 때문에 생성자를 통해 Dictionary를 초기화 해주고
public void Initialize(PlayerStateEnum state, Player player)
{
_player = player;
CurrentState = _stateDictionary[state];
CurrentState.Enter();
}
public void ChangeState(PlayerStateEnum newState)
{
if (!_player.CanStateChangeable) return;
CurrentState.Exit();
CurrentState = _stateDictionary[newState];
CurrentState.Enter();
}
public void AddState(PlayerStateEnum stateEnum, PlayerState state)
{
_stateDictionary.Add(stateEnum, state);
}
초기 State에 진입하고 Player를 받아오는 Initialize 함수,
기존 State를 탈출(Exit)하고 새로운 State에 진입(Enter)하는 ChangeState 함수,
State를 추가하는 AddState함수를 만들어줍니다.
이러면 이제 Dictionary만 다 채워주고 PlayerState를 만들어주면 끝입니다.
아, 애니메이터도 만져야 하죠.
대충 요로코롬 생겼습니다.
Reflection을 통한 State 추가
그 전에 번거롭게 클래스 만들고,, 매개변수 넣어주고... AddState 하나하나 해가면서 하기엔 너무 귀찮으니 C#의 Reflection을 사용할겁니다.
정말 간단하게 말하자면 Reflection은 전능하지만 느립니다.
제게 가장 와닿았던 예시는 string값만으로 클래스가 생성되고 private 변수도 그냥 받아올 수 있는 것 정도가 있습니다.
아무튼 지금 해줄 작업은 PlayerStateEnum의 값을 기반으로 Player(enum)State라는 이름을 가진 클래스를 생성해서 StateMachine의 Dictionary에 넣어주는 작업입니다.
_stateMachine = new PlayerStateMachine();
foreach(PlayerStateEnum stateEnum in Enum.GetValues(typeof(PlayerStateEnum)))
{
string enumname = stateEnum.ToString();
try
{
Type t = Type.GetType($"Player{enumname}State");
PlayerState state = Activator.CreateInstance(t, _stateMachine, this, enumname) as PlayerState;
_stateMachine.AddState(stateEnum, state);
}
catch(Exception e)
{
Debug.LogError($"{enumname} doesn't exist. : {e.Message}");
}
}
State 클래스를 만들지 않았다간 오류가 반드시 날테니 try catch는 필수입니다.
enum의 값을 가져와서 string으로 변환 시킨 다음 Activator를 사용해 Type 값을 Class로 바꿔주었습니다.
뒤에 주렁주렁 달린 매개변수들은 생성자에 들어갈 변수들입니다.
...저거 순서 잘 확인하세요. 잘못넣었다가 30분 날렸습니다 ㅠㅠㅠ....
PlayerState
간단하게 세개 정도 구현해봅시다.
PlayerIdleState와 PlayerRunState를 구현해보도록 하죠.
일단
PlayerIdleState에 들어설 경우 : 움직임이 멈춰야 한다.
PlayerIdleState에서는 : 움직이라는 입력이 들어올경우 RunState로 진입해야 한다.
PlayerIdleState에서 나갈 경우 : X
대충 이렇게 구상해놓고 갑시다. 이러면 굉장히 쉽죠.
public class PlayerIdleState : PlayerState
{
public PlayerIdleState(PlayerStateMachine stateMachine, Player player, string animBoolname) : base(stateMachine, player, animBoolname)
{
}
public override void Enter()
{
base.Enter();
_player.MovementCompo.StopImmediately();
}
public override void UpdateState()
{
base.UpdateState();
float inputThreshold = 0.05f;
if (_player.PlayerInput.MoveInput.magnitude > inputThreshold)
{
_stateMachine.ChangeState(PlayerStateEnum.Run);
}
}
public override void Exit()
{
base.Exit();
}
}
생성자를 구상해주고 Enter에서 Player를 멈춰준뒤 Update에서 MoveInput을 검사하여 움직였을경우 RunState로 ChangeState 해주면 됩니다.
와... 구조 진짜 너무 편하죠?
PlayerRunState도 쉽습니다.
PlayerRunState에 들어설 경우 : OnMoveEvent를 구독한다.
PlayerRunState에서는 : OnMoveEvent에서 넘겨준 Vector를 적절히 변환하여 Movement에 넘겨준 뒤
만약 입력이 들어오지 않으면 IdleState로 진입한다.
PlayerRunState에서 나갈 경우 : OnMoveEvent를 구독 해제 한다.
public class PlayerRunState : PlayerState
{
private Vector3 _movementDirection;
public PlayerRunState(PlayerStateMachine stateMachine, Player player, string animBoolname) : base(stateMachine, player, animBoolname)
{
}
public override void Enter()
{
base.Enter();
_player.PlayerInput.OnMoveEvent += HandleOnMoveEvent;
HandleOnMoveEvent(_player.PlayerInput.MoveInput);
}
private void HandleOnMoveEvent(Vector2 movement)
{
_movementDirection = new Vector3(movement.x, 0, movement.y);
}
public override void UpdateState()
{
base.UpdateState();
float inputThreshold = 0.5f;
if (_player.PlayerInput.MoveInput.magnitude < inputThreshold)
{
_stateMachine.ChangeState(PlayerStateEnum.Idle);
}
if (Mathf.Abs(_player.PlayerInput.MoveInput.y) < inputThreshold)
{
_stateMachine.ChangeState(PlayerStateEnum.SideRun);
}
_player.MovementCompo.SetMovement(_movementDirection);
}
public override void Exit()
{
_player.PlayerInput.OnMoveEvent -= HandleOnMoveEvent;
base.Exit();
}
}
State에 진입할때 OnMoveEvent를 구독해서 들어온 값을 SetMovement에 넘겨주기만 하면 되거든요.
만약 값이 들어오지 않는다면 Idle로, 값이 양옆으로만 들어온다면 SideRun(달리기 모션이 너무 커서 추가함)으로 이동합니다.
그리고 제가 점프를 만들려다가 생각난게 있습니다.
생각해보니 점프는 Idle일때도, Run일때도, 다른 State 어디서든 땅에 붙어있을때도 가능해야 했습니다.
그렇기 때문에 이러한 것들을 총괄할 PlayerState를 하나 더 만들어서 이름은 PlayerGroundState라고 해주겠습니다.
이걸 점프만 가능하게 해주려고 다는건 아닙니다. 만약 그랬다면 이름은 PlayerJumpableState라고 지었겠죠.
얘는 이제 점프도 해주고 만약 땅에 붙어 있지 않다면 FallState로 옮겨주고... 등등의 일을 합니다.
public abstract class PlayerGroundState : PlayerState
{
protected PlayerGroundState(PlayerStateMachine stateMachine, Player player, string animBoolname) : base(stateMachine, player, animBoolname)
{
}
public override void Enter()
{
base.Enter();
_player.PlayerInput.OnJumpEvent += HandleOnJumpEvent;
_player.PlayerInput.OnCrouchDownEvent += HandleOnCrouchDownEvent;
}
private void HandleOnCrouchDownEvent()
{
if (_player.MovementCompo.IsGround)
{
_stateMachine.ChangeState(PlayerStateEnum.Sliding);
}
}
private void HandleOnJumpEvent()
{
if (_player.MovementCompo.IsGround)
{
_stateMachine.ChangeState(PlayerStateEnum.JumpingUp);
}
}
public override void Exit()
{
_player.PlayerInput.OnJumpEvent -= HandleOnJumpEvent;
_player.PlayerInput.OnCrouchDownEvent -= HandleOnCrouchDownEvent;
base.Exit();
}
public override void UpdateState()
{
if(!_player.MovementCompo.IsGround)
{
_stateMachine.ChangeState(PlayerStateEnum.Fall);
}
}
}
땅에 있을땐 Jump와 Sliding이 되게 해놨습니다.
그리고 UpdateState에선 땅에 붙어있는지 검사해서 만약 떨어졌다면 바로 FallState로 진입합니다.
이걸 이제 IdleState와 RunState에 상속시켜주면 완벽하겠죠.
마치는 말
이 글을 적으면서 제가 개발한 모든 것을 담기엔 어폐가 있다고 느꼈습니다.
너무 빠른 시간에 글을 양산해내다보니 글 퀄리티가 떨어지진 않았을까 걱정됩니다만 글 쓸 시간은 촉박하니 이정도로 만족하죠.
'개발 > UNNAMED' 카테고리의 다른 글
UNNAMED | 네번째 프로젝트 # 5 : 적 FSM (0) | 2024.06.08 |
---|---|
UNNAMED | 네번째 프로젝트 # 4 : 총 (0) | 2024.06.02 |
UNNAMED | 네번째 프로젝트 # 2 : FPS Player Movement (0) | 2024.06.01 |
UNNAMED | 네번째 프로젝트 # 1 : 사전 준비 및 New Input System (0) | 2024.05.27 |
UNNAMED | 네번째 프로젝트 # 0 : 기획 (0) | 2024.05.24 |