최종프로젝트 제작 과정 (플레이어 만들기)

2024. 2. 27. 10:29카테고리 없음

플레이어 역시 2가지 방식으로 제작이 되었다. 첫번째는 Character Controller 컴포넌트를 활용하여 FSM 방식으로 만들었고, 두번째는 PlayerController를 사용하여 Rigidbody 와 Collider를 각각 달아주어 제작되었다.

 

-Character Controller를 사용하여 FSM 방식으로 만든 첫번째 플레이어 캐릭터의 경우 Cinemachine 카메라도 사용하여 역동적인 플레이가 가능하도록 하고자 했으나, Terrain 맵의 매끄럽지 못한 부분과 카메라가 닿아 플레이를 할 때 자주 확대와 축소가 이루어져 플레이가 어지러웠고, 3인칭 보다는 1인칭 시점으로 플레이를 하는 것이 더욱 몰입감을 높일 수 있을 것 같아 1인칭으로 만들어보았다.

지시에 따라 이동을 수행하되 충돌이 있을 경우 제약을 받습니다. 벽을 따라 올라가며, (계단이 Step Offset 보다 낮을 경우)계단을 올라가고, Slope Limit 내의 경사를 오릅니다.

컨트롤러는 자체의 힘에는 반응하지 않으며 자동으로 리지드바디를 푸시하지 않습니다.

캐릭터 컨트롤러를 이용하여 리지드바디나 오브젝트를 푸시하고 싶을 경우, 스크립팅을 통해 OnControllerColliderHit() 함수를 사용하여 컨트롤러와 충돌하는 모든 오브젝트에 힘을 적용할 수 있습니다.

한편, 플레이어 캐릭터가 물리에 의해 영향을 받도록 하고 싶을 경우, 캐릭터 컨트롤러 대신 Rigidbody를 사용하는 것이 좋습니다.

유한 상태 머신(Finite State Machine, FSM)은 게임 에이전트에게 환상적인 지능을 부여하기 위한 선택 도구로 사용되어왔다.

다시 말해, 유한 상태 머신은, 주어지는 모든 시간에서 처해 있을 수 있는 유한개의 상태를 가지고 주어지는 입력에 따라 어떤 상태에서 다른 상태로 전환하거나 출력이나 액션이 일어나게 하는 장치 또는 그런 장치를 나타낸 모델이다.

상태(State): 게임에 정의된 여러 동작, 적 캐릭터뿐만 아니라 게이머에게도 적용될 수 있다.

  • Idle, Run, Attack, ... , 공격할 수 없는 상태, 캐릭터의 마나가 없어 마법 공격할 수 없는 상태 등
  • 한 상태에서 다른 상태로 전화할 수 있고, 동시에 여러 상태를 실행할 수는 없다.

전이(Transition): 한 상태에서 다른 상태로 전화하는 것

  • 각 상태 로직 또는 외부에서 전이 조건에 의해 전이될 수 있다.

첫번째 플레이어는 3인칭을 고려하여 만들게 되었는데, Ground Air Attack 각각의 상태에 따른 애니메이션 설정 및 스크립트를 작성하여 만들었다.

 

이동관련 해서는 Idle Walk Run 3개의 동작이 수행되고,

점프의 경우 Jump 동작과 Falling 동작 2개로 나누어진다.

어택은 기본공격과 콤보어택을 구현하여 연속된 애니메이션을 연결해주었다.

 

이러한 방식으로 만들어진 Player를 사용해도 괜찮지만, Character Controller 를 사용하여 Terrain 맵에서 적용한 결과 벽을 타고 계속해서 점프가 이루어지는 버그가  발생했다. Slope Limit 과 Step Offset 수치를 적절하게 조정하여 해결을 하고자 했으나 결론적으로 해결이 되지 않아 두번째 플레이어를 만들기로 했다.

 

-PlayerController 를 사용하여 Rigidbody 와 Collider를 각각 달아주어 만든 두번째 플레이어의 경우 원하는 값을 조정하기가 더욱 수월했다.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Dynamic;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerController : MonoBehaviour
{
    private Animator animator;
    private Rigidbody _rigidbody;
    private SoundManager soundManager;

    public float baseSpeed = 5f; // 기본 속도
    public float runSpeed = 8f;  // 달리는 속도
    public float currentSpeed;  // 현재 속도
    public CharacterHealth CharacterHealth { get; private set; }



    [Header("Movement")]
    private Vector2 curMovementInput;
    public float jumpForce;
    public LayerMask groundLayerMask;
    
    [Header("Look")]
    public Transform cameraContainer;
    public float minXLook;
    public float maxXLook;
    private float camCurXRot;
    public float lookSensitivity;
    private Vector2 mouseDelta;
    
    [HideInInspector]
    public bool canLook = true;

    public static PlayerController instance;

    private void Awake()
    {
        instance = this;
        _rigidbody = GetComponent<Rigidbody>();
        animator = GetComponent<Animator>();
        soundManager = SoundManager.Instance;
        CharacterHealth = GetComponent<CharacterHealth>();

    }

    void Start()
    {
        Cursor.lockState = CursorLockMode.Locked;
        currentSpeed = baseSpeed; // 초기화 시 항상 기본 속도로 설정
        CharacterHealth.PlayerDie += GameOver;

    }

    public void ResetSpeed()  
    {
        currentSpeed = baseSpeed;
    }

    private void Update()
    {
        //Debug.Log("현재 속도: " + currentSpeed);
    }

    private void FixedUpdate()
    {
        Move();
        //HandleRunningSound(); // 달리는 소리 관리 메서드 호출
    }

    private void LateUpdate()
    {
        if (canLook)
        {
            CameraLook();
        }
    }

    private void Move()
    {
        Vector3 dir = transform.forward * curMovementInput.y + transform.right * curMovementInput.x;
        dir *= currentSpeed;
        dir.y = _rigidbody.velocity.y;
        _rigidbody.velocity = dir;


    }
    void Run(float additionalSpeed)
    {
        currentSpeed += additionalSpeed;
        
        //soundManager.PlaySFX("RunningSound");   // 달리기 소리 재생

        //Vector3 movement = new Vector3(curMovementInput.x, 0f, curMovementInput.y) * speed * Time.deltaTime;
        //transform.Translate(movement);
    }


    void CameraLook()
    {
        camCurXRot += mouseDelta.y * lookSensitivity;
        camCurXRot = Mathf.Clamp(camCurXRot, minXLook, maxXLook);
        cameraContainer.localEulerAngles = new Vector3(-camCurXRot, 0, 0);
        transform.eulerAngles += new Vector3(0, mouseDelta.x * lookSensitivity, 0);
    }
    public void OnLookInput(InputAction.CallbackContext context)
    {
        mouseDelta = context.ReadValue<Vector2>();
    }

    public void OnMoveInput(InputAction.CallbackContext context)
    {
        if (context.phase == InputActionPhase.Performed)
        {
            // 이동 입력이 시작될 때 이전에 재생 중인 걷는 소리 중지. 걷는 소리 중첩되지 않도록. 발소리 겹치지 않게.
            //soundManager.StopFootstepSFX();

            curMovementInput = context.ReadValue<Vector2>();
            animator.SetBool("Walk", true);

            //soundManager.PlaySFX("FootstepSound"); // 걷는 소리만 재생
        }
        else if (context.phase == InputActionPhase.Canceled)
        {
            curMovementInput = Vector2.zero;
            animator.SetBool("Walk", false);

            //soundManager.StopFootstepSFX(); // 이동이 멈추면 걷는 소리 중지
        }
    }
    public void OnJumpInput(InputAction.CallbackContext context)
    {
        if (context.phase == InputActionPhase.Started)
        {
            if (IsGrounded())
                _rigidbody.AddForce(Vector2.up * jumpForce, ForceMode.Impulse);
        }
    }

    //private void HandleRunningSound()
    //{
    //    if (currentSpeed > baseSpeed)
    //    {
    //        soundManager.PlaySFX("RunningSound"); // 달리는 소리 재생
    //    }
    //    else
    //    {
    //        soundManager.StopRunningSFX(); // 달리는 소리 중지
    //    }
    //}

    private bool IsGrounded()
    {
        Ray[] rays = new Ray[4]
        {
            new Ray(transform.position + (transform.forward * 0.2f) + (Vector3.up * 0.01f) , Vector3.down),
            new Ray(transform.position + (-transform.forward * 0.2f)+ (Vector3.up * 0.01f), Vector3.down),
            new Ray(transform.position + (transform.right * 0.2f) + (Vector3.up * 0.01f), Vector3.down),
            new Ray(transform.position + (-transform.right * 0.2f) + (Vector3.up * 0.01f), Vector3.down),
        };
        for (int i = 0; i < rays.Length; i++)
        {
            if (Physics.Raycast(rays[i], 0.1f, groundLayerMask))
            {
                return true;
            }
        }
        return false;
    }
    private void OnDrawGizmos()
    {
        Gizmos.color = Color.red;
        Gizmos.DrawRay(transform.position + (transform.forward * 0.2f), Vector3.down);
        Gizmos.DrawRay(transform.position + (-transform.forward * 0.2f), Vector3.down);
        Gizmos.DrawRay(transform.position + (transform.right * 0.2f), Vector3.down);
        Gizmos.DrawRay(transform.position + (-transform.right * 0.2f), Vector3.down);
    }
    public void ToggleCursor(bool toggle)
    {
        Cursor.lockState = toggle ? CursorLockMode.None : CursorLockMode.Locked;
        canLook = !toggle;
    }

    void GameOver()
    {
        Cursor.lockState= CursorLockMode.None;
        Cursor.visible = true;
        UIManager.Instance.GameOverPopup();
        //gameManager.Instance.goToStartScene = true;
    }

}

 

두번째 플레이어는 PlayerController 스크립트 하나만을 사용하여 모든 동작을 수행하게 되는데, Move, Jump, CameraLook, CharacterHealth 등 모든 부분에서 관여를 하게 된다.

1인칭 플레이를 하기로 결정을 해서 Animation 적용은 크게 신경쓰지 않았는데, 기본적인 Idle, Walk 동작은 수행될 수 있도록 만들어보았다.