본문 바로가기
Development/Unity BootCamp

멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 90일차

by Mobics 2025. 4. 17.

 

목차


    게임에 필요한 상식

    25.04.08

    지난 시간에 이어서 Character Animation 제작

    : Apply Root Motion을 Script에서 조절하여 구현

     

    >> 이어서 Jump 구현

    : Sub-State Machine을 활용하여 구현

     

    1. 'On Jump' 설정

    : 점프 모션들 추가 및 Parameter를 Float 타입으로 만들고 이름을 'GroundDistance'로 변경

    --> Automate Thresholds의 체크를 해제하여 motion 간의 간격을 조정 가능하다.

     

    ※ 점프 모션이 조금 어색하다..?

    : 강사님의 모션 순서는 다음과 같다

    1. GoesDown2
    2. GoesDown
    3. Peak
    4. GoesUp2
    5. GoesUp
    6. TakeOff

     

    2. 'Idle' -> 'Jump' 설정

    : Parameter를 Bool 타입으로 만들어서 적용

    --> Has Exit Time도 체크 해제

     

    3. 'Jump' -> 'Idle' 설정

    : Parameter를 Bool 타입으로 만들어서 적용

     

    4. 'Move' -> 'Jump' 설정

    : Make Transition할 때, StateMachine의 Jump와 연결

    ※ 'Jump' -> 'Move'는 Animation 상 어색한 부분이 있어서 따로 만들지 않음 즉, Idle로 갔다가 다시 Move로 가도록 조치

     

    5. PlayerController.cs에 코드 작성

    // 사용자 입력 처리 함수
    private void HandleMovement()
    {
        float horizontal = Input.GetAxis("Horizontal");
        float vertical = Input.GetAxis("Vertical");
    
        if (vertical > 0)
        {
            _animator.SetBool(Move, true);
        }
        else
        {
            _animator.SetBool(Move, false);
        }
        _animator.SetFloat(Speed, _speed);
    
        #region Root Motion Off
    
        // // 달리기
        // float speed = 0;
        // if (Input.GetKey(KeyCode.LeftShift))
        // {
        //     speed = 1;
        // }
        //
        // _animator.SetFloat("Speed", speed);
    
        #endregion
    
        Vector3 movement = transform.forward * vertical;
        transform.Rotate(0, horizontal * rotateSpeed * Time.deltaTime, 0);
        
        #region Root Motion Off
    
        // _characterController.Move(movement * Time.deltaTime);
    
        // _groundDistance = GetDistanceToGround();
    
        // if (Input.GetButtonDown("Jump"))
        // {
        //     _velocity.y = Mathf.Sqrt(jumpForce * -2f * _gravity);
        // }
    
        #endregion
        
        // 점프
        if (Input.GetButtonDown("Jump") && IsGrounded)
        {
            _velocity.y = Mathf.Sqrt(jumpForce * -2f * _gravity);
            _animator.SetBool("Jump", true);
        }
        
        // 점프 높이 설정
        _animator.SetFloat("GroundDistance", GetDistanceToGround());
    }

     

    6. Parameter를 Trigger 타입으로 만들어서 이름을 'Jump2'로 하고 추가 설정

    : 'Idle' -> 'Jump'와 'Move' -> 'Jump'의 Conditions를 Jump2로 수정

    --> 코드도 수정

    // 점프
    if (Input.GetButtonDown("Jump") && IsGrounded)
    {
        _velocity.y = Mathf.Sqrt(jumpForce * -2f * _gravity);
        _animator.SetTrigger("Jump2");
    }

     

    ※ 점프가 너무 세서 Jump Force를 2로 수정

     

    7. 'Plane'의 Layer를 Ground로 수정

    : Ground Layer를 추가한 다음 설정

     

    8. 코드 수정 후, Layer 바인딩

     

    9. Skin Width 조정

    : Ray에 살짝 문제가 있어서 캐릭터가 지면에서 살짝 떠있도록 수정

     

    10. 'On Jump' -> 'EllenIdleLand' 설정

     

    Camera의 전방을 기준으로 방향 잡기

    : 자신과 상대방의 좌표를 알면 상대방과의 거리와 θ각을 구할 수 있다.

    https://spice-theory-152.notion.site/1c4d3cfdca3480f0b2cef91667339361

     

    https://spice-theory-152.notion.site/1c4d3cfdca3480f0b2cef91667339361

     

    게임에 필요한 수학 상식 | Notion

    좌표계

    spice-theory-152.notion.site

     

    1. 코드 작성

    : CameraController.cs

     

    2. Camera 바인딩

     

    3. Ellen Prefab화

     

    다른 Scene에 만든 'Ellen'을 추가하여 테스트

    - 'PlayerController'에 Main Camera를 다시 바인딩

     

    - Main Camera에 'CameraController' 추가 및 'Ellen' 바인딩

     

    - 공간의 Layer를 'Ground'로 설정

     

    ※ 지금은 수작업으로 할당하지만 나중에는 Ellen이 Intantiate로 생성되어야 하기 때문에 이를 코드로 처리해야한다.

     

    >> Ellen이 어둡게 나오는 현상 해결

    : Map은 전체적으로 Bake되어 있어서 밝은 것이기에, Light Probe를 적용하여 해결

     

    1. Light Probe Group 생성

     

    2. 옆면에서 작업

    : Jump도 할테니 Y축으로도 범위를 넓혀줌

    --> Gizmo를 Iso로 바꿔서 작업하면 편하다

     

    3. 이후 Top View에서도 작업하여 완성

     

    - 결과물

    --> 임시로 일단 방 하나만 작업해둠

     

    4. 다시 Bake 하기

     

    >> 최종 결과

     

    Attack Animation 구현

    1. Animator에서 기존에 만들어둔 'Attack' State를 삭제하고 새로 Sub-State Machine 생성

    : 이름을 'Attack'으로 변경

     

    2. Attack 내부로 들어가서 Animation 추가

    : EllenCombo1~4를 추가한 뒤 Make Transition으로 연결

     

    3. 공격 모션 중 추가적으로 입력이 없을 때 빠져나가기 위해 Exit에 Make Transition

     

    4. Trigger로 Parameter 생성

    : 이름은 'Attack'

     

    5. Transition의 조건 설정

    : Has Exit Time은 체크 유지 --> 모션이 끝나야 다음 공격으로 이어지도록

    >> 2 → 3, 3 → 4도 마찬가지로 설정

     

    6. Attack에 Make Transition 및 설정

    : Attack → Move,  Move → Attack, Attack → Idle

    --> Make Transition 할 때, StateMachine-Attack에 연결

    --> Has Exit Time 체크 해제 및 Conditions 추가 (Attack → Move, Move → Attack)

    --> Attack → Idle은 연결만 해두기

     

    └ Animation의 Events

    : Animation을 더블 클릭하고 Inspector의 Events를 보면 Ellen에 기본적으로 Events를 추가해놨다.

    --> 함수나 Float, Int, String, Object 등을 할당해줄 수 있다.

    --> 이를 이용하여 공격하려고 무기를 꺼낼 때나 공격 후 무기를 다시 넣을 때 Enemy에게 공격판정이 들어가지 않도록 즉, 공격판정을 더 정확하게 만들 수 있다.

    --> Combo1~4까지 전부 세팅되어 있고 Function 이름도 동일하게 되어있다.

     

    >> 코드 작성

    : PlayerController.cs

    --> MeleeAttackStart()와 MeleeAttackEnd()를 활용

     

    Player의 상태 구현 (상태 패턴 활용)

    : 기존의 PlayerController는 PlayerControllerOld로 이름을 변경하고 새롭게 PlayerController.cs 생성 및 작성

     

    ※ Unity에서 만드는 State Script

     

    >> Interface로 'IPlayerState' 생성

    >> 여러 상태들을 Script로 생성

    • PlayerStateIdle.cs
    • PlayerStateMove.cs
    • PlayerStateJump.cs
    • PlayerStateAttack.cs
    • PlayerStateHit.cs
    • PlayerStateDead.cs

     

    >> 코드 작성

    • IPlayerState.cs
    • PlayerController.cs
    • 여러 상태들

     

    >> Jump 구현

    : Animator에서 Idle → Jump, Move → Jump 조건을 Jump로 변경

     

    └ Jump가 어색하게 구현되는 문제

    : Jump → Idle 로 넘어가는 조건이 어색해서 Jump 도중에 Idle로 돌아가는 문제

    1. Class의 State 패턴으로 해결 --> 현재 짜고있는 방식
    2. Behaviour State를 활용하여 해결

    --> 원래라면 최대한 일관되게 하나의 방식으로만 짜서 해결하겠지만, 지금은 Behaviour State를 활용하면 간단하게 해결 가능하기도 하고 실전이 아닌 수업이므로 Behaviour State를 활용하여 해결하는 방식을 채용

     

    1. Class의 State 패턴으로 해결

    : PlayerStateJump.cs

    --> 결국 이렇게 작성해도 어색하게 구현됐음 다른 방식으로 해결하는 방법이 있을 것

    public void Update()
    {
        var distanceToGround = _playerController.GetDistanceToGround();
        if (distanceToGround < 0.1f)
        {
            _playerController.SetState(PlayerState.Idle);
        }
        else
        {
            _playerController.Animator.SetFloat("GroundDistance", _playerController.GetDistanceToGround());
        }
    }

     

    2. Behaviour State를 활용하여 해결

    : PlayerAnimatorStateJump.cs를 생성하여 Animation을 재생한 후, Exit할 때 Idle로 변경

     

    >> Ellen에 기존에 있던 PlayerControllerOld.cs는 비활성화하고 PlayerController.cs 추가

    : 그리고 바인딩 (강사님은 PlayerControllerOld를 Remove하심)

    --> 이후 Prefab에 Override하기

     

    └ CustomEditor로 Player의 상태 확인하기

    : 디버깅을 원활하게 해준다. --> Inspector에서 상태 확인 가능

    - PlayerControllerEditor.cs 생성 후 코드 작성

     

    └ Play시, 마우스 커서 사라지게 하기

    : GameManager.cs를 생성하여 코드 작성 --> 이전에 작성했던 Singleton.cs 를 재활용

     

    >> 빈 게임 오브젝트로 'GameManager'를 만들어서 GameManager.cs 추가

     

    최종 코드

    >> PlayerControllerOld.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    [RequireComponent(typeof(CharacterController))]
    [RequireComponent(typeof(Animator))]
    public class PlayerControllerOld : MonoBehaviour
    {
        private static readonly int Move = Animator.StringToHash("Move");
        private static readonly int Speed = Animator.StringToHash("Speed");
    
        [SerializeField] private float rotateSpeed = 100f;
        [SerializeField] private float jumpForce = 2f;
        [SerializeField] private LayerMask groundLayer;     // 땅으로 인식할 레이어
        [SerializeField] private Transform cameraTransform;
        
        private CharacterController _characterController;
        private Animator _animator;
        
        private float _gravity = -9.81f;
        private Vector3 _velocity;
        private float _groundDistance;
        
        // Root Motion On
        private float _groundedMinDistance = 0.1f;
        private float _speed = 0f;
    
        private bool _isAttacking = false;
    
        private bool IsGrounded
        {
            get
            {
                var distance = GetDistanceToGround();
                return distance < _groundedMinDistance;
            }
        }
        
        private void Awake()
        {
            _characterController = GetComponent<CharacterController>();
            _animator = GetComponent<Animator>();
        }
    
        private void Start()
        {
            // 커서 설정
            Cursor.visible = false;
            Cursor.lockState = CursorLockMode.Locked;
        }
    
        private void Update()
        {
            // 커서 Rock 해제
            if (Input.GetKeyDown(KeyCode.Escape))
            {
                Cursor.visible = true;
                Cursor.lockState = CursorLockMode.None;
            }
            
            HandleMovement();
            // ApplyGravity();  // Root Motion Off
            CheckRun();
            
            // 점프 높이 설정
            _animator.SetFloat("GroundDistance", GetDistanceToGround());
        }
    
        // 사용자 입력 처리 함수
        private void HandleMovement()
        {
            float horizontal = Input.GetAxis("Horizontal");
            float vertical = Input.GetAxis("Vertical");
    
            if (vertical > 0)
            {
                RotatePlayerToCameraForward();
                _animator.SetBool(Move, true);
            }
            else
            {
                _animator.SetBool(Move, false);
            }
            _animator.SetFloat(Speed, _speed);
    
            #region Root Motion Off
    
            // // 달리기
            // float speed = 0;
            // if (Input.GetKey(KeyCode.LeftShift))
            // {
            //     speed = 1;
            // }
            //
            // _animator.SetFloat("Speed", speed);
    
            #endregion
    
            Vector3 movement = transform.forward * vertical;
            transform.Rotate(0, horizontal * rotateSpeed * Time.deltaTime, 0);
            
            #region Root Motion Off
    
            // _characterController.Move(movement * Time.deltaTime);
    
            // _groundDistance = GetDistanceToGround();
    
            // if (Input.GetButtonDown("Jump"))
            // {
            //     _velocity.y = Mathf.Sqrt(jumpForce * -2f * _gravity);
            // }
    
            #endregion
            
            // 점프
            if (Input.GetButtonDown("Jump") && IsGrounded)
            {
                _velocity.y = Mathf.Sqrt(jumpForce * -2f * _gravity);
                _animator.SetTrigger("Jump2");
            }
            
            // 공격
            if (Input.GetButtonDown("Fire1") && !_isAttacking)
            {
                _animator.SetTrigger("Attack");
            }
        }
    
        #region Root Motion Off
    
        // 중력 적용 함수
        private void ApplyGravity()
        {
            _velocity.y += _gravity * Time.deltaTime;
            _characterController.Move(_velocity * Time.deltaTime);
        }
    
        #endregion
        
        // 달리기 처리
        private void CheckRun()
        {
            if (Input.GetKey(KeyCode.LeftShift))
            {
                _speed += Time.deltaTime;
                _speed = Mathf.Clamp01(_speed); //Mathf.Clamp(_speed, 0f, 1f);와 동일
            }
            else
            {
                _speed -= Time.deltaTime;
                _speed = Mathf.Clamp01(_speed);
            }
        }
        
        // 바닥과 거리를 계산하는 함수
        private float GetDistanceToGround()
        {
            float maxDistance = 10f;
            if (Physics.Raycast(
                    transform.position, Vector3.down, out RaycastHit hit, maxDistance, groundLayer))
            {
                return hit.distance;
            }
            else
            {
                return maxDistance;
            }
        }
    
        // 카메라의 방향으로 캐릭터의 이동 방향 설정
        private void RotatePlayerToCameraForward()
        {
            Vector3 cameraForward = cameraTransform.forward;
            cameraForward.y = 0;        // 각도를 구할 것이기에 높이는 필요하지 않기 때문 
            cameraForward.Normalize();  // 정규화
            
            // // #1 삼각함수
            // float targetAngle = Mathf.Atan2(cameraForward.x, cameraForward.z) * Mathf.Rad2Deg;
            // float currentAngle = Mathf.Atan2(transform.forward.x, transform.forward.z) * Mathf.Rad2Deg;
            // float angle = Mathf.DeltaAngle(currentAngle, targetAngle);
            //
            // transform.Rotate(0, angle, 0);
            
            // // #2 벡터의 내적
            // float dotProduct = Vector3.Dot(transform.forward, cameraTransform.forward);
            // float angle = Mathf.Acos(dotProduct) * Mathf.Rad2Deg;
            
            // // #3 벡터의 외적
            // Vector3 crossProduct = Vector3.Cross(transform.forward,  cameraTransform.forward);
            // float angle = Mathf.Asin(crossProduct.y) * Mathf.Rad2Deg;
            
            // // 부드럽게 회전 --> 위의 3가지 방법 중 하나를 사용한 다음 사용
            // Quaternion targetRotation = Quaternion.Euler(0, angle, 0);
            // transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f);
            
            // #4 Quaternion
            Quaternion targetRotation = Quaternion.LookRotation(cameraForward);
            
            // 부드럽게 회전
            transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f);
        }
    
        #region Animator Method
    
        private void OnAnimatorMove()
        {
            Vector3 movePosition;
    
            if (IsGrounded)
            {
                movePosition = _animator.deltaPosition;
            }
            else
            {
                movePosition = _characterController.velocity * Time.deltaTime;
            }
            
            // 중력 적용
            _velocity.y += _gravity * Time.deltaTime;
            movePosition.y = _velocity.y * Time.deltaTime;
            
            _characterController.Move(movePosition);
        }
    
        public void MeleeAttackStart()
        {
            _isAttacking = true;
        }
    
        public void MeleeAttackEnd()
        {
            _isAttacking = false;
        }
    
        #endregion
    
        #region Debug
    
        private void OnDrawGizmos()
        {
            Gizmos.color = Color.red;
            Gizmos.DrawLine(transform.position, transform.position + Vector3.down * _groundDistance);
        }
    
        #endregion
    }

     

    >> PlayerController.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public enum PlayerState { None, Idle, Move, Jump, Attack, Hit, Dead }
    
    [RequireComponent(typeof(CharacterController))]
    [RequireComponent(typeof(Animator))]
    public class PlayerController : MonoBehaviour
    {
        [Header("Player")]      // Lv Up시, 변경될 Player의 스탯
        [SerializeField] private int maxHealth = 100;
        [SerializeField] private int attackPower = 10;
    
        [Header("Movement")]    // 이동 관련
        [SerializeField] private float jumpSpeed = 2f;
        [SerializeField] private float rotationSpeed = 100f;
        [SerializeField] private LayerMask groundLayer;
        [SerializeField] private float maxGroundCheckDistance = 10f;
    
        [Header("Attach Points")] // 지팡이를 손에 쥘 때 필요한 손의 Position, 카메라를 위한 머리의 Position 등
        [SerializeField] private Transform leftHandTransform;
        [SerializeField] private Transform headTransform;
        
        // ㅡㅡㅡ 상태 관련 ㅡㅡㅡ
        private PlayerStateIdle _playerStateIdle;
        private PlayerStateMove _playerStateMove;
        private PlayerStateJump _playerStateJump;
        private PlayerStateAttack _playerStateAttack;
        private PlayerStateHit _playerStateHit;
        private PlayerStateDead _playerStateDead;
        
        public PlayerState CurrentState { get; private set; }
        private Dictionary<PlayerState, IPlayerState> _playerStates;
        
        // ㅡㅡㅡ 외부 접근 가능 변수 ㅡㅡㅡ
        public Animator Animator { get; private set; }
    
        public bool IsGrounded
        {
            get
            {
                return GetDistanceToGround() < 0.2f;
            }
        }
        
        // ㅡㅡㅡ 내부에서만 사용되는 변수 ㅡㅡㅡ
        private CharacterController _characterController;
        private const float _gravity = -9.81f;
        private Vector3 _velocity = Vector3.zero;
        private int _currentHealth = 0;
    
        private void Awake()
        {
            Animator = GetComponent<Animator>();
            _characterController = GetComponent<CharacterController>();
        }
    
        private void Start()
        {
            // 상태 초기화
            _playerStateIdle = new PlayerStateIdle();
            _playerStateMove = new PlayerStateMove();
            _playerStateJump = new PlayerStateJump();
            _playerStateAttack = new PlayerStateAttack();
            _playerStateHit = new PlayerStateHit();
            _playerStateDead = new PlayerStateDead();
    
            _playerStates = new Dictionary<PlayerState, IPlayerState>
            {
                { PlayerState.Idle, _playerStateIdle },
                { PlayerState.Move, _playerStateMove },
                { PlayerState.Jump, _playerStateJump },
                { PlayerState.Attack, _playerStateAttack },
                { PlayerState.Hit, _playerStateHit },
                { PlayerState.Dead, _playerStateDead }
            };
            SetState(PlayerState.Idle);
            
            // 체력 초기화
            _currentHealth = maxHealth;
        }
    
        private void Update()
        {
            if (CurrentState != PlayerState.None)
            {
                _playerStates[CurrentState].Update();
            }
        }
    
        public void SetState(PlayerState state)
        {
            if (CurrentState != PlayerState.None)   // 맨 처음 SetState를 할 때는 PlayerState.None 상태기 때문
            {
                _playerStates[CurrentState].Exit();
            }
            CurrentState = state;
            _playerStates[CurrentState].Enter(this);
        }
    
        #region 동작 관련
    
        public void Rotate(float x, float z)    // Idle과 Move 둘다 같은 함수가 필요하므로 PlayerController에서 구현
        {
            // 카메라 설정
            // Tag로 MainCamera가 할당되어 있으면 따로 할당 없이 Camera.main으로 접근 가능하다.
            var cameraTransform = Camera.main.transform;
            var cameraForward = cameraTransform.forward;
            var cameraRight = cameraTransform.right;
            
            // Y값을 0으로 설정해서 수평 방향만 고려
            cameraForward.y = 0;
            cameraRight.y = 0;
            
            // 입력 방향에 따라 카메라 기준으로 이동 방향 계산
            var moveDirection = cameraForward * z + cameraRight * x;
            
            // 이동 방향이 있을 경우에만 회전
            if (moveDirection != Vector3.zero)
            {
                moveDirection.Normalize();
                transform.rotation = Quaternion.LookRotation(moveDirection);
            }
        }
    
        public void Jump()      // _velocity.y값이 여기 있기 때문에 편하게 PlayerController에서 구현
        {
            _velocity.y = Mathf.Sqrt(jumpSpeed * -2f * _gravity);
        }
        
        private void OnAnimatorMove()
        {
            Vector3 movePosition;
    
            if (IsGrounded)
            {
                movePosition = Animator.deltaPosition;
            }
            else
            {
                movePosition = _characterController.velocity * Time.deltaTime;
            }
            
            // 중력 적용
            _velocity.y += _gravity * Time.deltaTime;
            movePosition.y = _velocity.y * Time.deltaTime;
            
            _characterController.Move(movePosition);
        }
        
        public void MeleeAttackStart()
        {
            
        }
    
        public void MeleeAttackEnd()
        {
            
        }
    
        #endregion
    
        #region 계산 관련
    
        // 바닥과 거리를 계산하는 함수
        public float GetDistanceToGround()
        {
            float maxDistance = 10f;
            if (Physics.Raycast(
                    transform.position, Vector3.down, out RaycastHit hit, maxDistance, groundLayer))
            {
                return hit.distance;
            }
            else
            {
                return maxDistance;
            }
        }
    
        #endregion
    }

     

    >> IPlayerState.cs

    public interface IPlayerState
    {
        // 해당 상태로 진입했을 때 호출되는 Method
        void Enter(PlayerController playerController);
        
        // 해당 상태에 머물러 있을 때 Update 주기로 호출되는 Method
        void Update();
        
        // 해당 상태에서 빠져나갈 때 호출되는 Method
        void Exit();
    }

     

    >> PlayerStateIdle.cs

    using UnityEngine;
    
    public class PlayerStateIdle : MonoBehaviour, IPlayerState
    {
        private PlayerController _playerController;
        
        public void Enter(PlayerController playerController)
        {
            _playerController = playerController;
            _playerController.Animator.SetBool("Idle", true);
        }
    
        public void Update()
        {
            var inputVertical = Input.GetAxis("Vertical");
            var inputHorizontal = Input.GetAxis("Horizontal");
            
            // 이동
            if (inputVertical != 0 || inputHorizontal != 0)
            {
                _playerController.Rotate(inputHorizontal, inputVertical);
                _playerController.SetState(PlayerState.Move);
                return;
            }
            
            // 점프
            if (Input.GetButtonDown("Jump"))
            {
                _playerController.SetState(PlayerState.Jump);
                return;
            }
            
            // 공격
            if (Input.GetButtonDown("Fire1"))
            {
                _playerController.SetState(PlayerState.Attack);
                return;
            }
        }
    
        public void Exit()
        {
            _playerController.Animator.SetBool("Idle", false);
            _playerController = null;
        }
    }

     

    >> PlayerStateMove.cs

    using UnityEngine;
    
    public class PlayerStateMove : MonoBehaviour, IPlayerState
    {
        private PlayerController _playerController;
        private float _speed = 0f;
        
        public void Enter(PlayerController playerController)
        {
            _playerController = playerController;
            _playerController.Animator.SetBool("Move", true);
        }
    
        public void Update()
        {
            var inputVertical = Input.GetAxis("Vertical");
            var inputHorizontal = Input.GetAxis("Horizontal");
    
            // 이동
            if (inputVertical != 0 || inputHorizontal != 0)
            {
                _playerController.Rotate(inputHorizontal, inputVertical);
            }
            else
            {
                _playerController.SetState(PlayerState.Idle);
                return;
            }
            
            // 점프
            if (Input.GetButtonDown("Jump") && _playerController.IsGrounded)
            {
                _playerController.SetState(PlayerState.Jump);
                return;
            }
            
            // 공격
            if (Input.GetButtonDown("Fire1") && _playerController.IsGrounded)
            {
                _playerController.SetState(PlayerState.Attack);
                return;
            }
            
            // Left Shift 버튼을 누르면 속도 증가
            if (Input.GetKey(KeyCode.LeftShift))
            {
                if (_speed < 1f)
                {
                    _speed += Time.deltaTime;
                    _speed = Mathf.Clamp01(_speed);
                }
            }
            else
            {
                if (_speed > 0f)
                {
                    _speed -= Time.deltaTime;
                    _speed = Mathf.Clamp01(_speed);
                }
            }
            _playerController.Animator.SetFloat("Speed", _speed);
        }
    
        public void Exit()
        {
            _playerController.Animator.SetBool("Move", false);
            _playerController = null;
        }
    }

     

    >> PlayerStateJump.cs

    using UnityEngine;
    
    public class PlayerStateJump : MonoBehaviour, IPlayerState
    {
        private PlayerController _playerController;
        
        public void Enter(PlayerController playerController)
        {
            _playerController = playerController;
            _playerController.Animator.SetBool("Jump", true);
            _playerController.Jump();
        }
    
        public void Update()
        {
            _playerController.Animator.SetFloat("GroundDistance", _playerController.GetDistanceToGround());
        }
    
        public void Exit()
        {
            _playerController.Animator.SetBool("Jump", false);
            _playerController = null;
        }
    }

     

    >> PlayerStateAttack.cs

    using UnityEngine;
    
    public class PlayerStateAttack : MonoBehaviour, IPlayerState
    {
        private PlayerController _playerController;
        
        public void Enter(PlayerController playerController)
        {
            _playerController = playerController;
            _playerController.Animator.SetTrigger("Attack");
        }
    
        public void Update()
        {
            
        }
    
        public void Exit()
        {
            _playerController = null;
        }
    }

     

    >> PlayerStateHit.cs

    using UnityEngine;
    
    public class PlayerStateHit : MonoBehaviour, IPlayerState
    {
        private PlayerController _playerController;
        
        public void Enter(PlayerController playerController)
        {
            _playerController = playerController;
        }
    
        public void Update()
        {
            
        }
    
        public void Exit()
        {
            _playerController = null;
        }
    }

     

    >> PlayerStateDead.cs

    using UnityEngine;
    
    public class PlayerStateDead : MonoBehaviour, IPlayerState
    {
        private PlayerController _playerController;
        
        public void Enter(PlayerController playerController)
        {
            _playerController = playerController;
        }
    
        public void Update()
        {
            
        }
    
        public void Exit()
        {
            _playerController = null;
        }
    }

     

    >> PlayerControllerEditor.cs

    using UnityEditor;
    using UnityEngine;
    
    [CustomEditor(typeof(PlayerController))]
    public class PlayerControllerEditor : Editor
    {
        public override void OnInspectorGUI()
        {
            // 기본 인스펙터를 그리기
            base.OnInspectorGUI();
            
            // 타겟 컴포넌트 참조 가져오기
            PlayerController playerController = (PlayerController)target;
            
            // 여백 추가
            EditorGUILayout.Space();
            EditorGUILayout.LabelField("상태 디버그 정보", EditorStyles.boldLabel);
            
            EditorGUILayout.BeginVertical(EditorStyles.helpBox);
            
            // 상태별 색상 지정
            switch (playerController.CurrentState)
            {
                case PlayerState.None:
                    GUI.backgroundColor = new Color(1, 1, 1, 1f);
                    break;
                case PlayerState.Idle:
                    GUI.backgroundColor = new Color(0, 0, 1, 1f);
                    break;
                case PlayerState.Move:
                    GUI.backgroundColor = new Color(0, 1, 0, 1f);
                    break;
                case PlayerState.Jump:
                    GUI.backgroundColor = new Color(1, 0, 1, 1f);
                    break;
                case PlayerState.Attack:
                    GUI.backgroundColor = new Color(1, 1, 0, 1f);
                    break;
                case PlayerState.Hit:
                    GUI.backgroundColor = new Color(0.1f, 0.1f, 0.1f, 1f);
                    break;
                case PlayerState.Dead:
                    GUI.backgroundColor = new Color(1, 0, 0, 1f);
                    break;
            }
    
            EditorGUILayout.BeginVertical(EditorStyles.helpBox);
            EditorGUILayout.LabelField("현재 상태", playerController.CurrentState.ToString(),
                EditorStyles.boldLabel);
            EditorGUILayout.EndVertical();
            
            EditorGUILayout.EndVertical();
            
            // Color 초기화
            GUI.backgroundColor = Color.white;
            // 여백 추가
            EditorGUILayout.Space();
            EditorGUILayout.LabelField("캐릭터 위치 디버그 정보", EditorStyles.boldLabel);
            // 지면 접촉 상태
            GUI.enabled = false;    // toggle은 직접 클릭이 가능하기 때문에 읽기 전용으로 만들어줌
            EditorGUILayout.Toggle("지면 접촉", playerController.IsGrounded);
            GUI.enabled = true;
            
            // 여백 추가
            EditorGUILayout.Space();
            EditorGUILayout.LabelField("캐릭터 상태 강제 변경", EditorStyles.boldLabel);
            
            // 강제로 상태 변경 버튼
            EditorGUILayout.BeginHorizontal();
    
            if (GUILayout.Button("Idle"))
                playerController.SetState(PlayerState.Idle);
            if (GUILayout.Button("Move"))
                playerController.SetState(PlayerState.Move);
            if (GUILayout.Button("Jump"))
                playerController.SetState(PlayerState.Jump);
            if (GUILayout.Button("Attack"))
                playerController.SetState(PlayerState.Attack);
            if (GUILayout.Button("Hit"))
                playerController.SetState(PlayerState.Hit);
            if (GUILayout.Button("Dead"))
                playerController.SetState(PlayerState.Dead);
            
            EditorGUILayout.EndHorizontal();
        }
    }

     

    >> Singleton.cs

    using UnityEngine;
    using UnityEngine.SceneManagement;
    
    public abstract class Singleton<T> : MonoBehaviour where T : Component
    {
        private static T _instance;
    
        public static T Instance
        {
            get
            {
                if (_instance == null)
                {
                    _instance = FindObjectOfType<T>();
                    if (_instance == null)
                    {
                        GameObject obj = new GameObject();
                        obj.name = typeof(T).Name;
                        _instance = obj.AddComponent<T>();
                    }
                }
                return _instance;
            }
        }
    
        private void Awake()
        {
            if (_instance == null)
            {
                _instance = this as T;
                DontDestroyOnLoad(gameObject);
                // 경우에 따라 첫 Scene의 OnSceneLoaded가 호출이 안 되는 경우를 해결
                OnSceneLoaded(SceneManager.GetActiveScene(), LoadSceneMode.Single);
                
                // Scene 전환 시, 호출되는 Action Method 할당
                SceneManager.sceneLoaded += OnSceneLoaded;
            }
            else
            {
                Destroy(gameObject);
            }
        }
    
        // Destroy 후에는 OnSceneLoaded가 할당하지 않도록
        private void OnDestroy()
        {
            SceneManager.sceneLoaded -= OnSceneLoaded;
        }
    
        protected abstract void OnSceneLoaded(Scene scene, LoadSceneMode mode);
    }

     

    >> GameManager.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    
    public class GameManager : Singleton<GameManager>
    {
        private void Start()
        {
            // 커서 설정
            Cursor.visible = false;
            Cursor.lockState = CursorLockMode.Locked;
        }
    
        private void Update()
        {
            // 커서 설정
            if (Input.GetKeyDown(KeyCode.Escape))
            {
                Cursor.visible = true;
                Cursor.lockState = CursorLockMode.None;
            }
        }
    
        protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
        {
            
        }
    }

     

    >> PlayerAnimatorStateJump.cs

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class PlayerAnimatorStateJump : StateMachineBehaviour
    {
        // OnStateExit is called before OnStateExit is called on any state inside this state machine
        override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
        {
            animator.gameObject.GetComponent<PlayerController>().SetState(PlayerState.Idle);
        }
    }