본문 바로가기
C#/UNITY_FPS 3D 서바이벌게임

Chapter 1-2. 캐릭터, 지형 : 달리기, 부드럽게 앉기, 점프

by w1z 2024. 3. 11.

Chapter 1. 캐릭터 움직이기 & 지형 제작

캐릭터 움직이기 - 심화

📜PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    // 스피드 조정 변수
    [SerializeField]
    private float walkSpeed;
    [SerializeField]
    private float runSpeed;
    [SerializeField]
    private float crouchSpeed;
    private float applySpeed;

    // 점프 정도
    [SerializeField]
    private float jumpForce;

    // 상태 변수
    private bool isRun = false;
    private bool isGround = true;
    private bool isCrouch = false;

    // 앉았을 때 얼마나 앉을지 결정하는 변수
    [SerializeField]
    private float crouchPosY;
    private float originPosY;
    private float applyCrouchPosY;

    // 민감도
    [SerializeField]
    private float lookSensitivity;  

    // 카메라 한계
    [SerializeField]
    private float cameraRotationLimit;  
    private float currentCameraRotationX; 

    // 필요한 컴포넌트
    [SerializeField]
    private Camera theCamera; 
    private Rigidbody myRigid;
    private CapsuleCollider capsuleCollider; 


    void Start()
    {
        // 컴포넌트 할당
        capsuleCollider = GetComponent<CapsuleCollider>();
        myRigid = GetComponent<Rigidbody>();

        // 초기화
        applySpeed = walkSpeed;

        originPosY = theCamera.transform.localPosition.y;
        applyCrouchPosY = originPosY;
    }

    void Update()  
    {
        IsGround();
        TryJump();
        TryRun();
        TryCrouch();
        Move();
        CameraRotation();
        CharacterRotation();
    }

    // 지면 체크
    private void IsGround()
    {
        isGround = Physics.Raycast(transform.position, Vector3.down, capsuleCollider.bounds.extents.y + 0.1f);
    }

    // 점프 시도
    private void TryJump()
    {
        if (Input.GetKeyDown(KeyCode.Space) && isGround)
        {
            Jump();
        }
    }

    // 점프
    private void Jump()
    {
        if (isCrouch)
            Crouch();

        myRigid.velocity = transform.up * jumpForce;
    }

    // 달리기 시도
    private void TryRun()
    {
        if (Input.GetKey(KeyCode.LeftShift))
        {
            Running();
        }
        if (Input.GetKeyUp(KeyCode.LeftShift))
        {
            RunningCancel();
        }
    }

    // 달리기
    private void Running()
    {
        if (isCrouch)
            Crouch();

        isRun = true;
        applySpeed = runSpeed;
    }

    // 달리기 취소
    private void RunningCancel()
    {
        isRun = false;
        applySpeed = walkSpeed;
    }

    // 앉기 시도
    private void TryCrouch()
    {
        if (Input.GetKeyDown(KeyCode.LeftControl))
        {
            Crouch();
        }
    }

    // 앉기 동작
    private void Crouch()
    {
        isCrouch = !isCrouch;
        if (isCrouch)
        {
            applySpeed = crouchSpeed;
            applyCrouchPosY = crouchPosY;
        }
        else
        {
            applySpeed = walkSpeed;
            applyCrouchPosY = originPosY;
        }

        StartCoroutine(CrouchCoroutine());
    }

    // 부드러운 앉기 동작
    IEnumerator CrouchCoroutine()
    {
        float _posY = theCamera.transform.localPosition.y;
        int count = 0;

        while(_posY != applyCrouchPosY)
        {
            count++;
            _posY = Mathf.Lerp(_posY, applyCrouchPosY, 0.2f);
            theCamera.transform.localPosition = new Vector3(0, _posY, 0);
            if(count > 15)
                break;
            yield return null;
        }
        theCamera.transform.localPosition = new Vector3(0, applyCrouchPosY, 0);
    }

    private void Move()
    {
        float _moveDirX = Input.GetAxisRaw("Horizontal"); 
        float _moveDirZ = Input.GetAxisRaw("Vertical"); 

        Vector3 _moveHorizontal = transform.right * _moveDirX;
        Vector3 _moveVertical = transform.forward * _moveDirZ; 

        Vector3 _velocity = (_moveHorizontal + _moveVertical).normalized * applySpeed; 

        myRigid.MovePosition(transform.position + _velocity * Time.deltaTime);
    }

    private void CameraRotation() 
    {
        float _xRotation = Input.GetAxisRaw("Mouse Y"); 
        float _cameraRotationX = _xRotation * lookSensitivity;

        currentCameraRotationX -= _cameraRotationX;
        currentCameraRotationX = Mathf.Clamp(currentCameraRotationX, -cameraRotationLimit, cameraRotationLimit);

        theCamera.transform.localEulerAngles = new Vector3(currentCameraRotationX, 0f, 0f);
    }

    private void CharacterRotation()
    {
        float _yRotation = Input.GetAxisRaw("Mouse X");
        Vector3 _characterRotationY = new Vector3(0f, _yRotation, 0f) * lookSensitivity;
        myRigid.MoveRotation(myRigid.rotation * Quaternion.Euler(_characterRotationY)); 
    }
}

 

🙋‍♂️ apply 변수

    // 스피드 조정 변수
    [SerializeField]
    private float walkSpeed;
    [SerializeField]
    private float runSpeed;
    [SerializeField]
    private float crouchSpeed;
    private float applySpeed;

    // 앉았을 때 얼마나 앉을지 결정하는 변수
    [SerializeField]
    private float crouchPosY;
    private float originPosY;
    private float applyCrouchPosY;
  • applySpeed에 walkSpeed, runSpeed, crouchSpeed 셋 중 하나가 할당 된다.
  • applyCrouchPosY에 crouchPosY, originPosY 둘 중 하나가 할당 된다.

이렇게 apply어쩌구 변수를 따로 두는 이유

  • 만약 applySpeed 변수를 두지 않는다면 아래와 같이 walkSPeed를 사용하는 걷기 함수, runSpeed를 사용하는 뛰기 함수, crouchSpeed를 사용하는 앉아서 걷기 함수. 이렇게 3 가지의 함수를 따로 따로 만들어 주거나 if, switch 문을 두어야 한다. 비효율적인 코드다.
      private void Move()
      {
          float _moveDirX = Input.GetAxisRaw("Horizontal"); 
          float _moveDirZ = Input.GetAxisRaw("Vertical"); 
    
          Vector3 _moveHorizontal = transform.right * _moveDirX;
          Vector3 _moveVertical = transform.forward * _moveDirZ; 
    
          Vector3 _velocity = (_moveHorizontal + _moveVertical).normalized * walkSpeed; // 📢 주목 
    
          myRigid.MovePosition(transform.position + _velocity * Time.deltaTime);
      }
    
      private void Run()
      {
          float _moveDirX = Input.GetAxisRaw("Horizontal"); 
          float _moveDirZ = Input.GetAxisRaw("Vertical"); 
    
          Vector3 _moveHorizontal = transform.right * _moveDirX;
          Vector3 _moveVertical = transform.forward * _moveDirZ; 
    
          Vector3 _velocity = (_moveHorizontal + _moveVertical).normalized * runSpeed;  // 📢 주목 
    
          myRigid.MovePosition(transform.position + _velocity * Time.deltaTime);
      }
    
  • 그냥 applySpeed 변수를 하나 두고 상황에 맞게 walkSpeed, runSpeed, crouchSpeed을 applySpeed에 할당해주면 아래의 Move()에서 applySpeed 하나로 걸을 때, 달릴 때, 앉아서 걸을 때 이렇게 3 가지를 다 기능할 수 있다.
        private void Move()
      {
          float _moveDirX = Input.GetAxisRaw("Horizontal"); 
          float _moveDirZ = Input.GetAxisRaw("Vertical"); 
    
          Vector3 _moveHorizontal = transform.right * _moveDirX;
          Vector3 _moveVertical = transform.forward * _moveDirZ; 
    
          Vector3 _velocity = (_moveHorizontal + _moveVertical).normalized * applySpeed;  // 📢 주목 
    
          myRigid.MovePosition(transform.position + _velocity * Time.deltaTime);
      }
    

 

🙋‍♀️ 달리기

 

runSpeed가 할당된 applySpeed로 Move() 함수가 실행되도록 하면 된다.

  • Move() 함수 👉 실제로 이동하는 기능을 수행한다.
    • applySpeed
      • Start() 함수에서 게임 시작시엔 walkSpeed 가 할당 되게끔 했다.
      • walkSpeed가 할당되어 있을 땐 걷기 기능 수행
      • runSpeed가 할당되어 있을 땐 달리기 기능 수행 (걷는 속도 보다 더 빠른 속도)
  • TryRun() 함수 👉 달리기 입력 키인 LeftShitf 입력을 받는다.
    • LeftShift키를 누르는 동안(GetKey)에는 Running() 함수 실행
      • Running() 함수 👉 Move()에서 달릴 수 있도록 변수 업데이트
      • isCrouch가 True 면, 즉 앉아 있는 상태면 다시 일어선다. Crouch()
        • 앉기 설명에서 다룰 것이지만 앉기를 수행하는 함수인 Crouch()는 카메라의 y 방향 위치를 내려서 마치 앉는 것 같은 1 인칭 시점을 연출한다. 그리고 함수를 실행할 때마다 isCrouch의 Boolean 값이 반전되도록 구현해놓았기 때문에 앉은 상태에서 Crouch()를 또 실행하면 일어선다. 즉 카메라의 y 위치가 제자리로 올라간다. 앉아서 달릴 수는 없으므로 앉아 있는 상태에서 달린다면 카메라 위치를 다시 제자리로 올려주어야 한다. (일어 선 효과)
      • isRun을 True로 설정
      • applySpeed를 runSpeed로 설정
        • 이 과정 덕분에 다음 프레임에서 runSpeed로 Move()가 실행될 것이다.
    • LeftShift키를 떼는 순간(GetKeyDown)에 RunningCandle() 함수 실행
      • RunningCancel() 함수 👉 이제 Move()에서 걸을 수 있도록 변수 업데이트
        • isRun을 True로 설정
        • applySpeed를 runSpeed로 설정
      • 이처럼 applySpeed를 업데이트 하는 과정 때문에 TryRun()  Move() 보다 먼저 실행되야 한다.

 

🙋‍♀️ 점프 & 땅 착지 검사

땅에 착지해 있을 때만 점프가 가능하게 하는게 포인트

  • TryJump() 함수 👉 점프가 가능한 상태인지를 검사한다.
    • 스페이스 바를 누르고 있는 상태(GetKeyDown) and 땅에 착지해있는 상태 (isGround가 True)일 때만 Jump() 함수를 호출한다.
      • isGround의 기본값은 착지 상태인 True다.
  • IsGround() 함수 👉 오브젝트가 땅에 착지한 상태인지를 검사하여 isGround 값을 업데이트 한다.
    isGround = Physics.Raycast(transform.position, Vector3.down, capsuleCollider.bounds.extents.y + 0.1f);
    
    • 현재 위치로부터 절대적인 월드 좌표계 기준에서의 아래 방향으로 (플레이어 오브젝트의 중심점부터 발끝까지의 수직 길이 + 0.1f) 길이의 광선을 쐈을 때 충돌이 감지 되는 것이 있다면 땅에 착지했다는 뜻이다. 반면 감지되는 것이 없다면 플레이어 오브젝트는 공중에 있다는 뜻이므로 착지 중이지 않다.
    • Vector3.down
      • 절대적인, 월드 좌표계 기준에서의 아래 방향. Vector3(0, -1, 0) 벡터와 같다.
      • -transform.up (transform은 down이 없다.)을 사용하지 않는 이유는 만약 오브젝트가 회전해 있는 상태여서 위치 축이 월드 좌표 축과 일치하지 않는다면 오브젝트의 위쪽 방향과 절대 적인 위쪽 방향은 달라질 것이기 때문이다. 땅은 언제나 절대적인 아래 방향에 있으므로 Vector.down을 사용하였다.
    • capsuleCollider.bounds.extents.y + 0.1f
      • 캡슐 콜라이더를 바운딩 박스 모양으로 나타낸 것에 대한 정보를 담는 bounds 구조체. extents는 이 바운딩 박스의 절반에 해당하는 크기를 나타내는 벡터이다.
      • 캡슐 콜라이더는 현재 이 스크립트가 붙어있는 플레이어 오브젝트에 붙어있으므로 즉, capsuleCollider.bounds.extents.y은 플레이어 오브젝트의 수직 길이를 뜻한다.
      • 피봇 위치, 즉 transform.position는 플레이어 오브젝트의 중심점이므로 transform.position로부터 capsuleCollider.bounds.extents.y길이를 더하는 것은 오브젝트의 중심점에서 발 끝 지점까지의 길이를 더하는 것과 같다. 이 길이 만큼의 광선을 절대 적인 아래 방향으로 쐈을 때 닿는게 없다면 오브젝트가 땅에 붙이고 있지 않고 공중에 있는 상태인 것이다. 광선 길이는 몸의 절반이기 때문에!
    • 0.1f 를 더해준 이유
      • 계단이나 경사면 같은 곳에 닿아있을 때 땅에 닿았다고 볼 수 있는데도 불구하고 안 닿았다고 처리될 수 있어서 약간의 오차를 더해 줌.
  • Jump() 함수 👉 실제로 점프하는 기능을수행한다.
    • isCrouch가 True 면, 즉 앉아 있는 상태면 다시 일어선다. Crouch()
      • 점프는 서서하는게 자연스러우므로 카메라를 원래 위치로 올려 준다.
    • 실제 물리적인 점프 시도
      myRigid.velocity = transform.up * jumpForce;
      
      • Rigidbody의 velocity 속도 벡터 값을 (나 자신을 기준으로 한 위쪽 방향 벡터 * 점프 스칼라 크기) 결과인 벡터로 설정해준다.
      • velocity 속도 벡터는 물리적인 상황에 따라 값이 변화 하는데, 이렇게 직접 설정해주면 한순간에 현재 위치에서 transform.up * jumpForce 만큼 이동하게 된다. 한 순간에 이만큼 이동해버리니 속도 개념임.
      • 한 순간에 점프를 하고 난 후에는 Rigidbody의 물리 기능에 따라 중력의 영향을 받으면서 서서히 떨어지게 된다. velocity 벡터 값도 중력 가속도 영향을 받아 원래 물리적 현상 대로 바뀌게 된다.
      • 점프를 구현할 때는 Rigidbody의 velocity 값을 변경해주는 것이 좋다. 점프 특성상 한 순간에 점프가 되어야 하며 점프가 된 후에는 중력의 영향을 받아 자연스레 떨어져야 하기 때문이다.

 

🙋‍♀️ 앉기

1 인칭 카메라이므로 앉기를 구현할 땐 카메라의 y 방향 위치만 내려주면 된다. 실제로 앉은건 아닌데 카메라만 내려서 앉는 듯한 효과를 줌.

다만 온라인 게임이거나 앉은 키가 게임에 영향을 준다면 이렇게 하면 안될 것 같다..

  • Move() 함수 👉 실제로 이동하는 기능을 수행한다.
    • applySpeed
      • Start() 함수에서 게임 시작시엔 walkSpeed 가 할당 되게끔 했다.
      • walkSpeed가 할당되어 있을 땐 걷기 기능 수행
      • runSpeed가 할당되어 있을 땐 달리기 기능 수행 (걷는 속도 보다 더 빠른 속도)
      • crouchSpeed가 할당되어 있을 땐 앉아서 걷는 기능 수행 (걷는 속도 보다 더 느린 속도. 앉았을 때의 속도라서)
  • TryCrouch() 함수 👉 앉기 키인 LeftContrl 키 입력이 들어오면 Crouch() 함수를 실행한다.
  • Crouch() 함수 👉 앉기에 필요한 변수 값들을 업데이트 한다.
    • Crouch() 함수를 실행하면 isCouch 값이 반전된다. isCrouch = !isCrouch
      • 앉아 있는 상태에서 이 함수를 실행하면 서게 하고, 서 있는 상태에서 이 함수를 실행하면 앉게끔 하도록.
      •  LeftContrl 키 한번만 눌러도 앉은 상태가 되고, 이 상태에서 LeftContrl 키를 누르면 다시 서게 할 것이다.
    • 앉으려면 이동 속도는 crouchSpeed로, 카메라 위치는 crouchPosY로.
    • 서있으려면 이동 속도는 walkSpeed로, 카메라 위치는 originPosY로.
      • applySpeed. 이동 속도를 앉은 상태로 걷는 속도로 업데이트
      • applyCrouchPosY. 카메라의 있어야 할 위치가 된다.
        • crouchPosY, originPosY 를 할당할 수 있다.
          • crouchPosY : 앉았을 때 위치할 카메라의 y 방향 위치.
            • 유니티 에디터에서 0 으로 초기화 하였다. 즉, 부모 오브젝트의 중심점(피봇) y 위치와 같게.
            • 자식의 로컬 위치는 부모의 피봇을 기준으로 하니까.
          • originPosY : 서있을 때인 원래 카메라의 y 방향 위치.
            • Start() 함수에서 게임 시작시 카메라 위치였던 theCamera.transform.localPosition.y로 초기화 해 두었다.
              • 카메라의 부모인 플레이어 오브젝트를 기준으로 한 Local y 방향 위치다.
            • 게임 시작시 applyCrouchPosY의 초기값.
    • 코루틴 함수인 CrouchCoroutine() 함수 실행.
      • 코루틴 함수는 StartCoroutine 을 통해 실행시켜야 한다.
    • 카메라의 Y 방향 위치를 applyCrouchPosY로 업데이트 하여 실제로 카메라의 위치를 변경해 주되 부드럽게 변경해준다.

부드럽게 앉거나 서기

순간적으로 앉는건 딱딱 끊기는 듯 하고 너무 부자연스럽기 때문에 카메라 위치를 아래 위로 옮길 때 부드럽게 옮겨지도록 하자.

코루틴 함수를 사용하여 부드럽게 앉거나 서게 하기.

  • CrouchCoroutine() 함수 👉 코루틴 함수로, 카메라의 y 방향 위치를 부드럽게 올리거나 내린다.
    • _posY : 카메라의 현재 Y 방향 위치
      • 카메라의 현재 위치 (theCamera.transform.localPosition.y) 에서 출발하여 applyCrouchPosY와 일치할 때까지 계속해서 선형 보간 값(Lerp)으로 업데이트 될 것이다.
    • count : while문 반복 횟수 카운트
    • _posY  applyCrouchPosY 가 될 때까지 반복한다.
      • 현재의 카메라 위치가 서있을 때 카메라가 있어야할 위치 혹은 앉아 있을 때 카메라가 있어야할 위치가 될 때 까지.
      • 한번 반복할 때마다 _posY 👉 applyCrouchPosY 사이에서 0.2 씩 1️⃣ 보간한 값을 _posY 로 새롭게 설정하고 2️⃣ 이를 카메라의 위치로 업데이트 한다. 보간하는 값이 크면 빨리 옮겨지고 작으면 천천히 더 부드럽게 옮겨진다.
      •  _posY는 부드럽게 applyCrouchPosY 에 가까워진다.
      • 그리고 한 프레임씩 대기 시간을 가진다
        • yield return null
      • count를 두어 15번 반복 하면 while문을 빠져나오게 한 이유
        • 딱 떨어진 값으로 보간이 안되는 경우의 수들이 있다. 따라서 영원히 _posY == applyCrouchPosY 가 되지 않고 무한히 _posY는 applyCrouchPosY에 가까워지려는 연산을 계속 해서 무한히 할 것이다.
          • 예를 들어 applyCrouchPosY가 1이라면 pos_Y가 보간되고 또 보간되어 0.9999999999999 가 되어도 applyCrouchPosY와 일치하지 않는다고 판단되기 때문에 계속해서 Lerp 를 무한 반복 하게 된다.
          • 그래서 그냥 어느 정도 반복 횟수에 다다르면 while문을 빠져나오게끔 해준 것.
    • count 가 15에 다다라서 while문을 빠져나온 경우를 대비하여, 즉 pos_Y가 applyCrouchPosY와 정확하게 일치하지는 않지만 근접한 후 while문을 빠져나오게 된 경우를 대비하여
      • 카메라의 y 축 위치를 원래의 목표인 applyCrouchPosY로 덮어준다.
        • theCamera.transform.localPosition = new Vector3(0, applyCrouchPosY, 0);

 

🙋‍♂️ 실행 순서 & 컴포넌트 설정

    void Update()  
    {
        IsGround();
        TryJump();
        TryRun();
        TryCrouch();
        Move();
        CameraRotation();
        CharacterRotation();
    }
  • TryRun()를 Move()보다 먼저 실행해야 한다.
    • TryRun()에서 applySpeed에 walkSpeed, runSpeed 중에서 어떤 값이 할당될지를 결정하고
    • Move()에서 applySpeed를 속도로 삼아 이동하므로
  • IsGround()를 TryJump()보다 먼저 실행해야 한다.
    • 땅에 착지해있을 때만 점프가 가능하므로 isGround 값을 체크해야 해서
  • TryCrouch()를 Move()보다 먼저 실행해야 한다.
    • TryCrouch()에서 applySpeed에 walkSpeed, crouchSpeed 중에서 어떤 값이 할당될지를 결정하고
    • Move()에서 applySpeed를 속도로 삼아 이동하므로