이제 기존에 설계한 StateMachine을 완성해보도록 하겠습니다.
완성된 StateMachine은 다음과 같이 동작하게 됩니다.
이제 StateMachine을 사용하는 궁극적인 이유를 알아보겠습니다.
일단 StateMachine을 기반으로 완성된 Animator를 살펴보겠습니다.
위는 Base Layer의 Animator 화면이고,
아래는 PrimaryAttack Layer의 Animator입니다.
위의 사진에서 볼 수있듯이 Animation끼리의 복잡한 전환없이 애니메이션이 서로 연결되는 것을 볼 수 있습니다.
이어지는 사진은 플레이어게게 설정된 컴포넌트의 화면입니다.
보시는 것처럼 PlayerController를 제외하고 직접 만들어낸 컴포넌트가 존재하지 않는 것이 보입니다.
즉, StateMachine을 사용하여 애니메이션을 연출하게 되면
1) 애니메이션간의 전환이 Animator가 아닌 로직으로서 간단하게 이뤄진다
2) 입력을 수행하는 PlayerController의 로직만으로 애니메이션을 전환할 수 있다
라는 2개의 이점을 얻어갈 수 있습니다.
본격적인 코드를 알아보기 이전에 변환에 사용되는 변수들을 알아보겠습니다.
현재 공격콤보를 측정하는 ComboCounter와 y축의 속력을 체크하는 yVelocity를 제외한
나머지는 전부 bool 변수입니다.
즉, 상태를 Enter하는 경우 해당 bool을 true로 설정하고 Exit하는 경우 해당 bool을 false로 설정합니다.
코드를 알아보기 이전에 몇 가지 규칙을 설정하고 시작하겠습니다.
1) 입력은 반드시 PlayerController에서만 처리한다
2) 나머지 로직의 수행은 각각의 PlayerState에서 처리한다
위의 2가지 규칙에 근거하여 코드를 작성하였음을 염두해 주셔야 합니다.
이제 코드를 뜯어보겠습니다.
1) 입력을 처리하는 PlayerController
public class PlayerController : MonoBehaviour
{
// Animtion StateMachine
public Animator _animtor { get; private set; }
public PlayerStateMachine _stateMachine { get; private set; }
#region States
public PlayerStateIdle _idleState { get; private set; }
public PlayerStateMove _moveState { get; private set; }
public PlayerStateJump _jumpState { get; private set; }
public PlayerStateInAir _inAirState { get; private set; }
public PlayerStateDash _dashState { get; private set; }
public PlayerStateWallSlide _wallSlideState { get; private set; }
public PlayerStateWallJump _wallJumpState { get; private set; }
public PlayerStatePrimaryAttack _priamaryAttackState { get; private set; }
#endregion
// Player Input
[SerializeField]
InputAction _moveAction;
[SerializeField]
InputAction _jumpAction;
[SerializeField]
InputAction _dashAction;
[SerializeField]
InputAction _attackAction;
// Player Move Info
public float _moveSpeed = 12f;
public float _jumpForce = 12f;
public bool _isJumpPressed;
public bool _isAttackClicked;
public float _horizontalValue { get; private set; }
public float _verticalValue { get; private set; }
// Dash Info
[SerializeField]
private float _dashCooldown;
private float _dashUsageTimer;
public float _dashSpeed;
public float _dashDuration;
public float _dashDir { get; private set; }
public bool _doingSomething { get; private set; }
public Rigidbody2D _rigidbody2D { get; private set; }
[Header("Collision Info")]
[SerializeField]
private Transform _groundCheck;
[SerializeField]
private float _groundCheckDistance;
[SerializeField]
private Transform _wallCheck;
[SerializeField]
private float _wallCheckDistance;
[Header("Attack Details")]
public Vector2[] _attackMovement;
public int _facingDir { get; private set; } = 1;
private bool _facingRight = true;
private void OnEnable()
{
_moveAction.Enable();
_jumpAction.Enable();
_dashAction.Enable();
_attackAction.Enable();
}
private void OnDisable()
{
_moveAction.Disable();
_jumpAction.Disable();
_dashAction.Disable();
_attackAction.Disable();
}
private void Awake()
{
_stateMachine = new PlayerStateMachine();
_idleState = new PlayerStateIdle(this, _stateMachine, "Idle");
_moveState = new PlayerStateMove(this, _stateMachine, "Move");
_jumpState = new PlayerStateJump(this, _stateMachine, "Jump");
_inAirState = new PlayerStateInAir(this, _stateMachine, "Jump");
_dashState = new PlayerStateDash(this, _stateMachine, "Dash");
_wallSlideState = new PlayerStateWallSlide(this, _stateMachine, "WallSlide");
_wallJumpState = new PlayerStateWallJump(this, _stateMachine, "WallJump");
_priamaryAttackState = new PlayerStatePrimaryAttack(this, _stateMachine, "Attack");
}
private void Start()
{
_animtor = GetComponentInChildren<Animator>();
_rigidbody2D = GetComponent<Rigidbody2D>();
_stateMachine.Init(_idleState);
}
private void Update()
{
_stateMachine._currentState.Update();
DoMove();
DoJump();
DoDash();
DoAttack();
}
public IEnumerator DoSomething(float _seconds)
{
_doingSomething = true;
yield return new WaitForSeconds(_seconds);
_doingSomething = false;
}
public void SetVelocity(float xVelocity, float yVelcoity)
{
_rigidbody2D.velocity = new Vector2(xVelocity, yVelcoity);
DoFlip(xVelocity);
}
public void SetZeroVelocity() => _rigidbody2D.velocity = Vector2.zero;
void DoMove()
{
_horizontalValue = _moveAction.ReadValue<Vector2>().x;
_verticalValue = _moveAction.ReadValue<Vector2>().y;
}
void DoJump()
{
if (_jumpAction.IsPressed())
_isJumpPressed = true;
else
_isJumpPressed = false;
}
public void Flip()
{
_facingDir *= -1;
_facingRight = !_facingRight;
gameObject.transform.Rotate(0, 180, 0);
}
void DoFlip(float xParam)
{
if (xParam > 0 && !_facingRight)
Flip();
else if (xParam < 0 && _facingRight)
Flip();
}
void DoDash()
{
if (DoDetectIsFacingWall())
return;
_dashUsageTimer -= Time.deltaTime;
if (_dashAction.IsPressed() && _dashUsageTimer < 0)
{
_dashUsageTimer = _dashCooldown;
_dashDir = _moveAction.ReadValue<Vector2>().x;
if (_dashDir == 0)
_dashDir = _facingDir;
_stateMachine.ChangeState(_dashState);
}
}
void DoAttack()
{
if (_attackAction.IsPressed())
_isAttackClicked = true;
else
_isAttackClicked = false;
}
public void AnimationTrigger() => _stateMachine._currentState.AnimationFinishTrigger();
public bool DoDetectIsGrounded() => Physics2D.Raycast(_groundCheck.position, Vector2.down, _groundCheckDistance, LayerMask.GetMask("Ground"));
public bool DoDetectIsFacingWall() => Physics2D.Raycast(_wallCheck.position, Vector2.right * _facingDir, _wallCheckDistance, LayerMask.GetMask("Ground"));
private void OnDrawGizmos()
{
Gizmos.DrawLine(
_groundCheck.position,
new Vector3(_groundCheck.position.x, _groundCheck.position.y - _groundCheckDistance)
);
Gizmos.DrawLine(
_wallCheck.position,
new Vector3(_wallCheck.position.x + _wallCheckDistance, _wallCheck.position.y)
);
}
}
1 - 1) 멤버 변수
구성은 위와 같이 이루어져 있습니다.
PlayerStateMachine과 Animator를 멤버 변수로서 가지고 있어야 Animator의 변수를 설정할 수 있습니다.
그리고 각각의 State에 대해 접근할 수 있도록 State도 역시 멤버 변수로 갖고 있겠습니다.
수행하는 액션은 이동, 점프, 대쉬, 공격의 총 4개로 이루어져 있습니다.
MoveInfo와 DashInfo는 각각 이동과 대쉬에 따른 설정값입니다.
취향에 맞게 설정하시면 됩니다.
rigidBody는 여러 물리 현상을 사용하는 경우 반드시 필요합니다.
그리고 _doingSomething의 경우 조금 특이할 수 있지만 Idle하지 않는 경우를 설정하고자 사용합니다.
CollisionInfo의 경우 벽과 바닥을 체크하는 요소들입니다.
마찬가지로 취향에 맞게 설정하면 됩니다.
AttackDetails의 경우,
공격시에 약간 이동하는 느낌을 주어 플레이하는 경우에 긴장감을 올려주는 역할을 합니다.
_facingDir의 경우 바라보는 방향을 알고자 사용하고,
_facingRight의 경우 플레이어의 방향 전환을 위한 Flip을 수행하기 위해 사용합니다.
1 - 2) 멤버 함수
OnEnable함수와 OnDisable함수에서는 InputSystem을 활성화하고 비활성화합니다.
가장 먼저 수행하는 Awake함수에서는
StateMachine을 초기화하고 각각의 State들을 초기화합니다.
Start 함수에서는 RigidBody2D와 Animator를 가져와
각 변수들을 초기화하고 현재 State를 Idle로 고정합니다.
매 프레임마다 수행하는 Update 함수에서는
현재 State인 CurrentState의 Update를 수행하고 입력에 따른 Action을 수행합니다.
코루틴인 DoSomething은 Idle이 아닌 State에서 Idle인 State로 전환하고자 사용합니다.
인자로 넣어준 시간뒤에 해당 로직을 수행합니다.
SetVelocity 함수는 플레이어의 이동을 수행하는 함수입니다.
이때 이동을 수행하므로 이에 따른 방향 전환도 DoFlip 함수를 통해 같이 수행합니다.
SetZeroVelocity 함수는 플레이어의 이동을 막는 경우에 사용합니다.
현재 플레이어의 속도를 원점으로 만듭니다.
DoMove 함수는 _moveAction의 입력값에 따라
X축과 Y축의값을 사용하여 _horizontalValue와 _verticalValue를 결정합니다.
DoJump 함수는 _jumpAction의 입력여부에 따라
_isJumpPressed를 설정합니다.
Flip 함수는 플레이어의 방향을 전환합니다.
이때 왼쪽을 보고있으면 오른쪽으로 전환해야 하고,
반대로 오른쪽을 보고 있으면 왼쪽으로 전환해야 하므로 위와 같은 로직이 성립됩니다.
DoFlip 함수는 플레이어의 입력에 따른 방향 전환을 위해 사용합니다.
DoDash 함수의 경우 기본적으로 쿨타임을 갖고 있어 조금 복잡한데,
설명하자면 다음과 같습니다.
일단 벽을 보고 있는 경우 Dash를 수행하지 않습니다.
그리고 매 프레임마다 _dashUsageTimer를 감소시키는데,
대쉬키를 입력한 순간 _dashUsageTimer를 초기화하고 대쉬할 방향을 설정합니다.
그리고 현재 상태를 Dash로 전환합니다.
위와 같이 전환을 PlayerController에서 진행하는 이유는 대쉬의 경우 땅을 딛고 있든 아니든 실행하기 때문입니다.
DoAttack 함수의 경우 공격 입력의 수행여부에 따라
_isAttackClicked를 설정합니다.
AnimationTrigger 함수의 경우 뒤에서 설명하지만,
AnimationTriggerEvent로서 사용할 에정입니다.
DoDetectIsGrounded 함수와 DoDetectIsFacingWall 함수는 각각
땅을 딛고 있는지와 벽을 마주하고 있는지를 판단하는 함수입니다.
마지막으로 OnDrawGizmos 함수는
디버깅을 위한 함수로서 직선을 통해 땅과 벽의 닿는 정도를 알아볼 수 있게 돕습니다.
일단은 PlayerController의 설명은 여기까지 마치겠습니다.
'유니티 > 게임 프로젝트' 카테고리의 다른 글
2D RPG - (4) StateMachine의 구성과 완성 (3) (1) | 2024.12.09 |
---|---|
2D RPG - (3) StateMachine의 구성과 완성 (2) (0) | 2024.12.09 |
2D RPG - (1) StateMachine의 설계 (0) | 2024.11.25 |
간단한 2D RPG 프로젝트 - (3) MonsterController (0) | 2024.11.20 |
간단한 2D RPG 프로젝트 - (2) PlayerController (0) | 2024.11.20 |