Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(92일차) - [3D 게임] 플레이어 상태머신 확장 & 데카르트 좌표

by 독기품은토끼 2025. 9. 30.
✅ 오늘의 학습 목표
1. 상태머신 구조 Idle / Move / Jump 확장
- Input System을 활용하여 입력을 안전하게 연결하기
- Animator 파라미터를 StringToHash로 상수화 하여 오탈자/GC 줄이기
- 등가속도
2. 지면감지 유틸로 점프/착지 판정
3. 카메라 제어 스크립트
- 데카르트 좌표계

1. 상태 머신 전환

public class PlayerState
{
    protected PlayerController _playerController;
    protected Animator _animator;
    protected PlayerInput _playerInput;
    
    public PlayerState(PlayerController playerController, Animator animator, PlayerInput playerInput)
    {
        _playerController = playerController;
        _animator = animator;
        _playerInput = playerInput;
    }
}

 

기본 Idle 상태에서 키 입력을 받았을 때 플레이어의 상태가 전환될 수 있도록 Input을 받아온다.

 

public static class Constants
{
    // Player 상태
    public enum EPlayerState
    {
        None, Idle, Move, Jump, Attack, Hit, Dead
    }

    // Player 애니메이터 파라미터
    public static readonly int PlayerAniParamIdle = Animator.StringToHash("idle");
    public static readonly int PlayerAniParamMove = Animator.StringToHash("move");
    public static readonly int PlayerAniParamJump = Animator.StringToHash("jump");
    public static readonly int PlayerAniParamAttack = Animator.StringToHash("attack");
    public static readonly int PlayerAniParamMoveSpeed = Animator.StringToHash("move_speed");
    public static readonly int PlayerAniParamGroundDistance = Animator.StringToHash("ground_distance");
}

 

움직임에 따른 애니메이션을 동작시킬 때 Idle 같이 이름으로 찾게되면

오탈자로 인한 미작동이나 유지보수 적으로 좋지 않기 때문에 StringToHash 메서드를 활용해줄 것이다.

 

[StringToHash]

  • Unity의 Animator 파라미터 이름을 문자열 대신 숫자 ID(int)로 변환한다.
  • 문자열은 GC 부하를 일으킬 수 있고 매번 검색해야 하지만 해시(int)는 빠르고 가볍다.
  • 결과적으로 성능 향상 + 메모리 효율 개선 + 오탈자 방지 효과가 있다.

[static을 선언해주는 이유]

  • static은 클래스 단위로 딱 1개만 존재하는 변수다.
  • 따라서 애니메이터 파라미터 해시는 프로그램 실행 중 한 번만 계산하고, 이후에는 전역에서 같은 정수값을 재사용한다.
  • 덕분에 CPU/GC 낭비 없이 빠르게 Animator 파라미터에 접근할 수 있다.

[왜 const가 아니라 static readonly인가?]

  • const는 컴파일 시점에 값이 확정되어야 한다.
  • 그러나 Animator.StringToHash("moving")는 메서드 호출 결과 → 런타임에서야 알 수 있다.
  • 그래서 const는 불가능하고, 대신 런타임 초기화 + 이후 불변을 보장하는 static readonly를 사용한다.

 

1. Idle 상태

using UnityEngine;
using UnityEngine.InputSystem;
using static Constants;

public class PlayerStateIdle: PlayerState, ICharacterState
{
    public PlayerStateIdle(PlayerController playerController, Animator animator, PlayerInput playerInput) 
        : base(playerController, animator, playerInput) { }

    public void Enter()
    {
        // Idle 애니메이션 실행
        _animator.SetBool(PlayerAniParamIdle, true);
        
        // Player Input에 대한 액션 할당
        _playerInput.actions["Fire"].performed += Attack;
        _playerInput.actions["Jump"].performed += Jump;
    }

    public void Update()
    {
        if (_playerInput.actions["Move"].IsPressed())
        {
            _playerController.SetState(EPlayerState.Move);
        }
    }

    public void Exit()
    {
        // Idle 애니메이션 중단
        _animator.SetBool(PlayerAniParamIdle, false);
        
        // Player Input에 대한 액션 할당 해제
        _playerInput.actions["Fire"].performed -= Attack;
        _playerInput.actions["Jump"].performed -= Jump;
    }
}

 

[생성자]

Idle 상태가 동작하려면 컨트롤러/애니메이터/입력이 필요하다.

이걸 찾아오기 위해서 FindObjectByType 같은 메서드를 활용하는 것이 아니라 생성자를 활용해서 가져와줄 것이다.

 

컨트롤러/애니메이터/입력 개체를 받아서 부모 클래스(PlayerState)에 전달한다.

덕분에 상태 내부에서 animator, playerInput, playerController을 바로 사용할 수 있다.

 

[Input Action - 이벤트]

이벤트 이름 호출되는 시점 예시
started 액션이 처음 감지될 때 (버튼을 누르기 시작했을 때) 키를 누르는 순간
performed 액션이 “완전히 수행”되었을 때 버튼을 눌러 입력이 유효할 때
canceled 액션이 취소되었을 때 버튼을 뗄 때 or 조이스틱이 중립으로 돌아올 때
// Player Input에 대한 액션 할당
_playerInput.actions["Fire"].performed += Attack;
_playerInput.actions["Jump"].performed += Jump;

 

해당 명령문은 "Fire"나 "Jump"라는 액션이 performed 상태일 때 Attack 메서드를 실행하겠다는 뜻이다.

이벤트를 사용할 때에는 InputAction.CallbackContext 타입의 매개변수가 필요하다.

 

2. Move 상태

using UnityEngine;
using UnityEngine.InputSystem;
using static Constants;

public class PlayerStateMove: PlayerState, ICharacterState
{
    private float _moveSpeed;
    
    public PlayerStateMove(PlayerController playerController, Animator animator, PlayerInput playerInput) 
        : base(playerController, animator, playerInput) { }

    public void Enter()
    {
        // Idle 애니메이션 실행
        _animator.SetBool(PlayerAniParamMove, true);
        // Player Input에 대한 액션 할당
        _playerInput.actions["Fire"].performed += Attack;
        _playerInput.actions["Jump"].performed += Jump;
        // moveSpeed 초기화
        _moveSpeed = 0f;
    }

    public void Update()
    {
        // 캐릭터 방향 설정
        var moveVector = _playerInput.actions["Move"].ReadValue<Vector2>();
        if (moveVector != Vector2.zero)
        {
            Rotate(moveVector.x, moveVector.y);
        }
        else
        {
            _playerController.SetState(EPlayerState.Idle);
        }
        
        // 이동 스피드 설정
        var isRun = _playerInput.actions["Run"].IsPressed();
        if (isRun && _moveSpeed < 1f)
        {
            _moveSpeed += Time.deltaTime;
            _moveSpeed = Mathf.Clamp01(_moveSpeed);
        }
        else if (!isRun && _moveSpeed > 0f)
        {
            _moveSpeed -= Time.deltaTime * _playerController.BreakForce;
            _moveSpeed = Mathf.Clamp01(_moveSpeed);
        }
        _animator.SetFloat(PlayerAniParamMoveSpeed, _moveSpeed);
    }

    public void Exit()
    {
        // Idle 애니메이션 중단
        _animator.SetBool(PlayerAniParamMove, false);
        // Player Input에 대한 액션 할당 해제
        _playerInput.actions["Fire"].performed -= Attack;
        _playerInput.actions["Jump"].performed -= Jump;
    }
}

 

[Input Action - 현재 입력 값 읽기]

  • ReadValue<T> : 매 프레임 상태를 읽어오는 메서드로 현재 액션의 값을 즉시 가져온다.
  • IsPressed() : 지금 눌려있는 상태인지를 bool 값으로 반환해주는 메서드이다.

[Mathf.Clamp01]

float 값이 1 이상이면 1 반환 / 0 이하면 0 반환

 

[카메라 회전]

public class PlayerState
{
    // 추가
    protected void Rotate(float x, float z)
    {
        if (_playerInput.camera != null)
        {
            var cameraTransform = _playerInput.camera.transform;
            var cameraForward = cameraTransform.forward;
            var cameraRight = cameraTransform.right;
            
            cameraForward.y = 0f;
            cameraRight.y = 0f;

            var moveDirection = cameraForward * z + cameraRight * x;

            if (moveDirection != Vector3.zero)
            {
                moveDirection.Normalize();
                _playerController.transform.rotation = Quaternion.LookRotation(moveDirection);
            }
        }
    }
}

 

[카메라 기준 보정]

cameraForward.y = 0; cameraRight.y = 0;로 수평면에 투영 → 경사진 카메라라 해도 수평 이동/회전만 하도록 보정

 

[방향 합성]

moveDirection = cameraForward * z + cameraRight * x;
→ 카메라가 보는 방향을 ‘앞’, 카메라의 오른쪽을 ‘옆’으로 삼아 입력 벡터를 월드 방향으로 변환

 

[정규화 + 회전]

Normalize() 후 Quaternion.LookRotation(moveDirection) → 그 방향을 바라보게 즉시 회전

 

3. Jump 상태

if (Input.GetKeyDown(KeyCode.Space))
{
    rb.AddForce(Vector3.up * jumpPower, ForceMode.Impulse);
}

 

여태 점프를 구현할 때 Impulse값을 주어서 제자리에서 폴짝 뛰는듯한 연출을 줬었는데

이렇게 구현하면 생기는 문제점은

캐릭터가 점프 중일 때도 회전과 이동이 자연스럽게 이어지지 않고

애니메이션 종료 시점에 맞춰 상태를 전환하는 과정에서 문제가 발생할 수 있다.

 

그래서 공중에 떠 있는 동안에는 Ground Check로 착지 타이밍을 감지하고

Root Motion을 사용하여 Animator와 Character Controller의 이동을 동기화해주려고 한다.

 

// PlayerController 스크립트
[SerializeField] private float jumpHeight = 2f;
private float _velocityY;

public void Jump()
{
    if (!_characterController.isGrounded) return;
    _velocityY = Mathf.Sqrt(jumpHeight * -2f * Gravity);
}

private void OnAnimatorMove()
{
    Vector3 movePosition;
    if (_characterController.isGrounded)
    {
        movePosition = _animator.deltaPosition;            
    }
    else
    {
        movePosition = _characterController.velocity * Time.deltaTime;
    }

    _velocityY += Gravity * Time.deltaTime;
    movePosition.y = _velocityY * Time.deltaTime;
    _characterController.Move(movePosition);
}

 

[점프 파라미터]

  • jumpHeight: “얼마나 높이” 뛰고 싶은지를 미터(유닛)로 지정하는 값
  • _velocityY: 현재 수직 속도(위/아래로 얼마나 빠르게 움직이는지). 점프 시작 시 위로 양수, 중력으로 매 프레임 감소

 

[점프 시작 로직]

  • isGrounded를 체크하여 지상에서만 점프 허용(이중점프 방지)
  • _velocityY = sqrt(jumpHeight * -2 * Gravity)로 “목표 점프 높이”에 딱 맞는 초기 위쪽 속도를 계산

🚨 등가속도 공식

  • u : 처음 점프할 때 위로 향한 속도
  • a : 중력(보통 음수)
  • s : 올라간 높이(jumpHeight)

점프의 가장 높은 지점에서의 속도 v = 0 이므로

이 때 처음 점프할 때 위로 향하는 속도 u의 값을 구하기 위해 Mathf.Sqrt 함수를 사용해주게 되었다.

 

[루트 모션 + 중력 적용]

  • 지상일 땐 애니메이션이 내보낸 이동량(deltaPosition)을 신뢰해 이동(루트 모션)
  • 공중일 땐 현재 컨트롤러 속도에 시간을 곱해 이동(애니메이션보단 실제 속도 위주)
  • 매 프레임 중력을 _velocityY에 누적하고, 최종 y 이동에 반영
  • 모든 이동은 CharacterController.Move()로 처리(충돌 포함)

🚨 Root Motion 이란?

 

Root Motion은 애니메이션이 계산한 이동값(deltaPosition)을 실제 이동에 반영하는 기능이다.

보통 Animator의 Apply Root Motion을 켜고

OnAnimatorMove()에서 deltaPosition을 읽어 CharacterController.Move()로 적용한다.

 

🚨 deltaPosition ?

Root motion을 사용하게 되면 애니메이션에 맞춰 이동하도록 구현해야하는데

이때 사용하는 값이 deltaPosition이다.

즉, 이번 프레임동안 애니메이션이 계산한 이동량이다.

  transform.position animator.deltaPosition
의미 현재 오브젝트의 “절대 위치” 이번 프레임에 “얼마나 이동했는지”
값의 형태 월드 좌표 월드 좌표 기준 이동량(벡터)
사용 시점 Update / FixedUpdate OnAnimatorMove()
용도 일반적인 위치 변경 Root Motion 기반 이동

 

 

2. 지면 감지 유틸리티

지면 감지를 위해

플레이어의 현재 위치에서 아래쪽으로 Raycast를 쏴서 바닥 까지의 거리를 반환하는 유틸리티를 작성해주겠다.

using UnityEngine;

public static class CharacterUtility
{
    public static float GetDistanceToGround(Vector3 position, LayerMask layerMask, float maxDistance)
    {
        if (Physics.Raycast(position,
                Vector3.down, out RaycastHit hit,
                maxDistance, layerMask))
        {
            return hit.distance;
        }
        else
        {
            return maxDistance;
        }
    }
}

 

  • position: 레이를 쏠 시작 위치(월드 좌표)
  • layerMask: 무엇을 ‘바닥’으로 간주할지 정하는 레이어 마스크(Ground)
  • maxDistance: 아래로 최대 몇 m까지 검사할지 / 못 찾으면 이 값을 그대로 반환(= 그 이하엔 바닥 없음)
Physics.Raycast(position, Vector3.down, out RaycastHit hit, maxDistance, layerMask)
// Physics.Raycast(시작위치, 향할 방향, 결과 담을 타입&변수 / 최대 검사 길이 / 감지 대상)

 

position에서 아래 방향(Vector3.down)으로 선을 쏴서 layerMask에 해당하는 콜라이터를 찾으면 hit 값을 반환한다.

 

// Constants 스크립트
public static class Constants
{
    // 추가
    public const float Gravity = -9.81f;
    
    // Layer Mask
    public static LayerMask GroundLayerMask => LayerMask.GetMask("Ground");
}
public class PlayerState
{
    // 추가
    protected void Jump(InputAction.CallbackContext context)
    {
        _playerController.Jump();
        _playerController.SetState(EPlayerState.Jump);
    }
}
using UnityEngine;

public class PlayerSmbJump : StateMachineBehaviour
{
    private PlayerController _playerController;
    
    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        if (_playerController == null) _playerController = animator.GetComponent<PlayerController>();    
    }

    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        _playerController.SetState(Constants.EPlayerState.Idle);
    }
}

 

Jump 기능은 Player 외에도 사용할 수 있으니 공용 로직으로 추가해주었다.

그리고 애니메이션 역시 재사용할 수 있기 때문에

기존 로직처럼 Enter() / Exit()로 호출하지 않고 SMB를 붙여 둘 다 동일하게 Jump 종료 시 idle 상태로 전환되도록 해주었다.

 

3. 카메라 제어

플레이어를 중심으로 카메라가 구면 좌표계(거리 r, 좌우 각도 Azimuth, 상하 각도 Polar)로 부드럽게 회전하고

장애물에 부딪히면 자동으로 앞으로 당겨서 장애물을 통과하여 카메라가 세팅되지 않도록 구현해주겠다.

 

1. LateUpdate / 구면 좌표계

 

카메라 제어를 위해 LateUpdate() 메서드를 활용해줄 것인데

Update()를 사용하지 않고 LateUpdate()를 사용해주는 이유는 

플레이어 이동 → 애니메이션/물리 → 카메라 추적 순서로 해주기 위함이다.

 


 

[극 좌표계]

 

2D 평면 (XZ 평면)에서 어떤 점 P를 표현할 때

직접 X, Z 좌표를 쓰는 대신

  • r : 중심점 (O)에서 점 P 까지의 거리
  • θ : x축(또는 z축) 기준의 각도

로 표현할 수 있다.

 

[구면 좌표계]

 

극 좌표계가 거리 + 1개의 각도(세타)로 P를 표현했다면

구면 좌표계는 거리 + 2개의 각도(세타, 파이)로 P를 표현한다.

 

  • r: 중심점(플레이어)에서 점(카메라)까지의 거리
  • 세타(θ, Azimuth): 수평 회전 각도 / “탑뷰”에서 회전한 각도
  • 파이(φ, Polar): 수직 회전 각도 / “옆에서 본” 기울기

 

θ(세타)는 x축을 기준으로 카메라가 어느 방향에 있는지 나타내고

φ(파이)는 z축 기준으로 카메라가 얼마나 “위로 올라갔는지/아래로 내려갔는지” 나타낸다.

 

즉, 게임에서

r → 카메라와 플레이어 사이의 거리
θ(세타) → 마우스 X 이동량으로 좌우 회전 누적
φ(파이) → 마우스 Y 이동량으로 상하 회전 누적 (클램프해서 뒤집힘 방지)

 

이름 기준점 특징 예시
데카르트 좌표계 월드 or 로컬 X, Y, Z축 직교. 우리가 Unity에서 흔히 보는 좌표 transform.position 등
극 좌표계 2D 평면 “거리 + 각도”로 점의 위치를 표현 나침반 방향 + 반경
구면 좌표계 3D 공간 “거리 + 두 개의 각도(세타, 파이)”로 위치 표현 3D 카메라 회전에 적합

 

Unity의 transform.position은 데카르트 좌표계(X,Y,Z)로 표현되기 때문에

구면 좌표계로 회전 각도와 거리만 관리하고

최종적으로는 데카르트 좌표로 변환해서 카메라 위치를 세팅해야 한다.

 

구면 좌표계의 (r, φ, θ)를 (x, y, z)로 변환하는 공식은 다음과 같다.

 

2. 카메라 제어 스크립트

private Transform _target;
private Vector2 _lookVector;
private float _azimuthAngle;
private float _polarAngle;

private void Awake() {
    _azimuthAngle = 0f;
    _polarAngle = 0f;
}

 

  • _target: 카메라가 바라볼/둘러볼 중심점(head)
  • _lookVector: 입력(마우스/스틱)의 현재 프레임 이동량
  • _azimuthAngle, _polarAngle: 누적된 좌우/상하 회전 각도 상태

 

public void SetTarget(Transform target, PlayerInput playerInput) {
    _target = target;

    // 초기 위치 한 번 세팅(게임 시작 시 틱 튀는 현상 방지)
    var offset = GetCameraPosition(distance, _polarAngle, _azimuthAngle);
    transform.position = _target.position - offset;
    transform.LookAt(_target);

    // 입력 액션 구독
    playerInput.actions["Look"].performed += OnActionLook;
    playerInput.actions["Look"].canceled  += OnActionLook;
}

 

private void OnActionLook(InputAction.CallbackContext context) {
    _lookVector = context.ReadValue<Vector2>();
}

 

씬 전환/스폰 타이밍 등에서 플레이어를 찾은 뒤 안전하게 카메라를 목표를 주입하기 위하여 SetTarget() 메서드를 생성해 주었다.

 

private void LateUpdate() {
    if (_target == null) return;

    // 1) 누적 각도 업데이트
    _azimuthAngle += _lookVector.x * rotationSpeed * Time.deltaTime;
    _polarAngle   -= _lookVector.y * rotationSpeed * Time.deltaTime; // 마우스 위=카메라 아래(일반적인 FPS/TPP 감각)
    _polarAngle    = Mathf.Clamp(_polarAngle, -20f, 60f);            // 상하 뒤집힘 방지

    // 2) 장애물 감지로 거리 보정
    var currentDistance = AdjustCameraDistance();

    // 3) 구면→데카르트 변환으로 위치 계산
    var offset = GetCameraPosition(currentDistance, _polarAngle, _azimuthAngle);

    // 4) 배치 + 타깃 바라보기
    transform.position = _target.position - offset;
    transform.LookAt(_target);
}

 

  • Polar Clamp: 상하 360도 회전을 막아 시야 뒤집힘 방지(멀미 방지).
  • GetCameraPosition()은 “타깃 기준 카메라 방향의 오프셋”을 구하고
  • 실제 배치는 target - offset으로 “타깃 뒤쪽(카메라 위치)”에 놓는 방식으로 설계되어 있음
    같은 이유로 레이캐스트도 -direction(타깃→카메라 방향)으로 쏜다.

 

private Vector3 GetCameraPosition(float r, float polar, float azimuth) {
    float b = r * Mathf.Cos(polar * Mathf.Deg2Rad);
    float x = b * Mathf.Sin(azimuth * Mathf.Deg2Rad);
    float y = r * Mathf.Sin(polar * Mathf.Deg2Rad) * -1;
    float z = b * Mathf.Cos(azimuth * Mathf.Deg2Rad);
    return new Vector3(x, y, z);
}

 

 

  • 먼저 상하 기울기(polar)에 따라 **수평 반지름 b = r·cos(polar)**를 구해 XZ 평면 투영 크기를 정한다.
  • 그 b를 가지고 **좌우 회전(azimuth)**으로 XZ를 분해(x= b·sin, z= b·cos)
  • y는 r·sin(polar) 이고, “마우스 위=카메라 아래” 감각을 위해 부호를 -1 해 둠

 

private float AdjustCameraDistance() {
    var currentDistance = distance;
    Vector3 direction = GetCameraPosition(1, _polarAngle, _azimuthAngle).normalized; // 타깃→카메라 방향 단위벡터
    if (Physics.Raycast(_target.position, -direction, out var hit, distance, obstacleLayerMask)) {
        float offset = 0.3f;                     // 벽에 딱 붙지 않도록 약간 띄움
        currentDistance = Mathf.Max(hit.distance - offset, 0.5f);
    }
    return currentDistance;
}

 

 

타깃에서 카메라 쪽으로 Ray를 솨서 사이에 벽이 있으면 카메라를 벽 직전까지 당기는 로직이다.

 

// PlayerController 스크립트

[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(PlayerInput))]
public class PlayerController : MonoBehaviour
{
    [SerializeField] private Transform headTransform;

    private void OnEnable()
    {
        // 카메라 초기화
        _playerInput.camera = Camera.main;
        if (_playerInput.camera != null)
        {
            _playerInput.camera.GetComponent<CameraController>().SetTarget(headTransform, _playerInput);
        }
    }
}

 

플레이어 컨트롤러 스크립트에서 Camera를 호출한다.