본문 바로가기

TIL (since 2023.08.07 ~ )

2023-10-25 TIL(Unity 최종 프로젝트 3일차)

오늘부터 프로젝트 역할 분배에 따른 구현이 시작되는 날이다. 다시 강의를 보면서 지난번에는 미처 놓쳤던 것들이

 

있을 수도 있고, 미흡했던 부분도 분명히 존재했기 때문에 이번에는 꼼꼼히 다 이해하면서 넘어가도록 하고싶어서

 

다시 Player구현을 하는 순간만큼은 확실하게 정리를 하고자 한다.

 

먼저 Probuilder로 맵과 캐릭터 구현을 마친 기본 프로토타입 환경은 조성한 뒤,

 

Player에게 InputAction을 지정하기 위한 세팅을 한다. 

 

※ PlayerInputAction

처음 게임을 기획하면서 플레이어의 다양한 기능에 따른 조작법을 정하고 시작했기 때문에,

우선적으로 필요할 것 같은 부분은 InputAction에 세팅을 하였다.

 

InputAction을 설정하고 저장하면 Input Action Asset 이라는 파일이 생기는데, 여기서 Generate C# Class에 체크를 하고Apply를 누르면 스크립트가 하나 생성이 되는데, 이는

해당 InputAction에서 지정한 기능들을 호출해서 사용할 수 있게 "InputAction 클래스"가 생성되는 것이다.

 

하지만 이렇게 한다고 해서 캐릭터가 바로 조작이 가능해지는 것은 아니기 때문에, 이것을 제어하는 스크립트를 따로

만들어 주어야 한다

우선 Player 라는 스크립트를 생성한 후, Player 오브젝트에 탑재시켜준다. (Player하위에 있는 오브젝트 X)

그리고 추가적으로 생성할 스크립트는 다음과 같다.

 

Player folder : Player , PlayerAnimationData , PlayerInput

StateMachine : IState , StateMachine

 

<PlayerInput>

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

public class PlayerInput : MonoBehaviour
{
    public PlayerInputAction InputActions { get; private set; }
    public PlayerInputAction.PlayerActions PlayerActions { get; private set; }

    private void Awake()
    {
        InputActions = new PlayerInputAction();

        PlayerActions = InputActions.Player;
    }

    // playerinput을 활성화, 비활성화 시키는 부분
    private void OnEnable()
    {
        InputActions.Enable();
    }

    private void OnDisable()
    {
        InputActions.Disable();
    }
}

우선적으로 PlayerInput 스크립트를 짠다. 해당 스크립트로 이전에 만들었던 PlayerInputAction을 받아와서 처리하는 구조로 이루어져 있으며 OnEnable 과 OnDiable 로나누어서 playerinput을 활성화, 비활성화 시키도록 구현한다.

그리고 Player의 Action을 스크립트로 처리를 해주는 과정도 해야 한다.

 

<PlayerAnimationData>

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

[Serializable]
public class PlayerAnimationData
{
    // Animator 에서 사용할 Parameter 값을 설정
    [SerializeField] private string groundParameterName = "@Ground";
    [SerializeField] private string idleParameterName = "Idle";
    [SerializeField] private string walkParameterName = "Walk";
    [SerializeField] private string runParameterName = "Run";
    [SerializeField] private string rollParameterName = "Roll";

    [SerializeField] private string airParameterName = "@Air";
    [SerializeField] private string jumpParameterName = "Jump";
    [SerializeField] private string fallParameterName = "Fall";

    [SerializeField] private string attackParameterName = "@Attack";
    [SerializeField] private string comboAttackParameterName = "ComboAttack";

    // Parameter값을 받는 getter 생성
    // 해당 Parameter는 크게 세 가지로 나누어서 사용(Ground, Air, Attack)
    // 이후에 작은 기능들을 각각의 상태에 맞게 추가
    public int GroundParameterHash { get; private set; }
    public int IdleParameterHash { get; private set; }
    public int WalkParameterHash { get; private set; }
    public int RunParameterHash { get; private set; }
    public int RollParameterHash { get; private set; }


    public int AirParameterHash { get; private set; }
    public int JumpParameterHash { get; private set; }
    public int FallParameterHash { get; private set; }


    public int AttackParameterHash { get; private set; }
    public int ComboAttackParameterHash { get; private set; }

    public void Initialize()
    {
        GroundParameterHash = Animator.StringToHash(groundParameterName);
        IdleParameterHash = Animator.StringToHash(idleParameterName);
        WalkParameterHash = Animator.StringToHash(walkParameterName);
        RunParameterHash = Animator.StringToHash(runParameterName);
        RollParameterHash = Animator.StringToHash(rollParameterName);

        AirParameterHash = Animator.StringToHash(airParameterName);
        JumpParameterHash = Animator.StringToHash(jumpParameterName);
        FallParameterHash = Animator.StringToHash(fallParameterName);

        AttackParameterHash = Animator.StringToHash(attackParameterName);
        ComboAttackParameterHash = Animator.StringToHash(comboAttackParameterName);
    }
}

플레이어가 동작을 할 수 있도록 하기 위해선 파라미터 값을 설정하고 그 값을 받아오는 getter를 생성한 뒤, Initialize까지 맞춰서 생성해주면 된다. 여기서 자주 등장하는 Hash에 대해서도 개념이 잘 정립되어 있지 않아서 한 번 찾아보았다.

 

Hash(해시)는 해시 함수를 사용하여 데이터를 고유한 숫자 또는 키로 매핑하는 프로세스를 나타낸다.

이렇게 만들어진 해시 코드는 데이터 구조에서 해당 요소를 빠르게 찾기 위해 사용된다.

해시 함수는 동일한 입력에 대해 항상 동일한 해시 코드를 생성하며, 이를 통해 데이터 구조에서 원하는 항목을 검색할 때 성능을 향상시킨다.

 

한마디로 플레이어의 동작구현에 따른 키 입력을 받았을 경우 해당 애니메이션이 출력되도록 만들어주는 역할을 하는 로직을 짠 것이라고 이해가 된다.

 

<Player>

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

public class Player : MonoBehaviour
{
    [field: Header("Animations")]
    [field: SerializeField] public PlayerAnimationData AnimationData { get; private set; }
    public Rigidbody Rigidbody { get; private set; }
    public Animator Animator { get; private set; }
    public PlayerInput Input { get; private set; }
    public CharacterController Controller { get; private set; }

    private void Awake()
    {
        // 애니메이션 데이터는 항상 초기화를 해주어야 한다
        AnimationData.Initialize();

        Rigidbody = GetComponent<Rigidbody>();
        Animator = GetComponentInChildren<Animator>();
        Input = GetComponent<PlayerInput>();
        Controller = GetComponent<CharacterController>();
    }

    private void Start()
    {
        // 마우스 커서를 사라지게 함
        Cursor.lockState = CursorLockMode.Locked;
    }
}

다음으로는 플레이어의 기본적인 틀을 잡아둔다. 

 

이어서, 플레이어에게 상태머신을 적용할 것이기 때문에 플레이어의 상태에 대해 관리해주는 인터페이스를 정립한다.

<IState>

public interface IState
{
    public void Enter();
    public void Exit();
    public void HandleInput();
    public void Update();
    public void PhysicsUpdate();
}

그리고 해당 인터페이스를 활용하는 것이 바로 스테이트 머신이다.

 

<StateMachine>

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

// 추상 클래스로 선언하여 상속을 받아서 사용할 수 있도록 함.
// StateMachine은 자체적으로 객체화를 할 수 없음
public abstract class StateMachine
{
    protected IState currentState;  // 현재 상태를 알 수 있게 설정

    public void ChangeState(IState newState)
    {
        currentState?.Exit();   // 현재 상태에서 빠져나와서
        currentState = newState;    // 현재 상태를 받아서 새로운 상태로 바꿔줌
        currentState?.Enter();  // 새로운 상태로 바뀐 현재 상태로 들어가게 함
    }

    public void HandleInput()
    {
        currentState?.HandleInput();
    }

    public void Update()
    {
        currentState?.Update();
    }

    public void PhysicsUpdate()
    {
        currentState?.PhysicsUpdate();
    }
}

스테이트 머신의 기초 세팅은 '현재 상태에 대한 정의'만 잘 처리할 수 있도록 구현해두면 된다.

 

플레이어의 스테이트 머신을 준비하기 위해서는 플레이어의 ScriptableObject로 데이터를 준비할 것이기 때문에 

스크립터블 오브젝트를 먼저 준비한다.

 

ScriptableObject : PlayerSO , PlayerGroundData , PlayerAirData

 

<PlayerSO>

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

[CreateAssetMenu(fileName = "Player", menuName = "Characters/Player")]

public class PlayerSO : ScriptableObject
{
    [field: SerializeField] public PlayerGroundData GroundData { get; private set; }
    [field: SerializeField] public PlayerAirData AirData { get; private set; }
}

 

<PlayerGroundData>

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

[Serializable]

public class PlayerGroundData
{
    // 기본적인 이동속도와 회전속도 설정
    [field: SerializeField][field: Range(0f, 25f)] public float BaseSpeed { get; private set; } = 5f;
    [field: SerializeField][field: Range(0f, 25f)] public float BaseRotatingDamping { get; private set; } = 1f;

    // 기본 상태와 걷기, 달리기, 구르기의 설정
    [field: Header("IdleData")]
    [field: Header("WalkData")]
    [field: SerializeField][field: Range(0f, 2f)] public float WalkSpeedModifier { get; private set; } = 0.22f;

    [field: Header("RunData")]
    [field: SerializeField][field: Range(0f, 2f)] public float RunSpeedModifier { get; private set; } = 1f;

    [field: Header("RollData")]
    [field: SerializeField][field: Range(0f, 2f)] public float RollSpeedModifier { get; private set; } = 1f;
}

 

<PlayerAirData>

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

[Serializable]
public class PlayerAirData
{
    [field: Header("JumpData")]
    [field: SerializeField][field: Range(0f, 25f)] public float JumpForce { get; private set; } = 4f;
}

우선 이 정도로 Player의 ScriptableObject Data를 만들어서 기본 세팅을 갖춰둔다.

 

<PlayerStateMachine>

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

public class PlayerStateMachine : StateMachine
{
    public Player Player { get; }

    // States
    public PlayerIdleState idleState { get; }

    public Vector2 MovementInput { get; set; }
    public float MovementSpeed { get; private set; }
    public float RotationDamping { get; private set; }
    public float MovementSpeedModifier { get; set; } = 1f;

    public float JumpForce { get; set; }

    public Transform MainCameraTransform { get; set; }

    public PlayerStateMachine(Player player)
    {
        this.Player = player;
        idleState = new PlayerIdleState(this);
        MainCameraTransform = Camera.main.transform;
    }
}

플레이어 스테이트 머신에 대한 기본 세팅이 끝났으면 마저 Player의 코드에 ScriptableObject와 스테이트머신이

호출될 수 있게끔 수정해 주어야 한다.

 

<Player 수정>

public class Player : MonoBehaviour
{
    [field: Header("References")]
    [field: SerializeField] public PlayerSO Data { get; private set; }
    
    // 생략
    
    private PlayerStateMachine stateMachine;
    
    private void Awake()
    {
        // 생략

        stateMachine = new PlayerStateMachine(this);
    }

 

그리고 상태머신 또한 참조 및 역참조를 하기 위한 구조로 만들기 위해 상태머신 관련 스크립트를 추가한다.

 

PlayerStateMachines : PlayerBaseState , PlayerIdleState , PlayerGroundState

 

<PlayerBaseState>

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

public class PlayerBaseState : IState
{
    // 모든 State는 StateMachine과 역참조를 함.
    protected PlayerStateMachine stateMachine;
    protected readonly PlayerGroundData groundData;

    public PlayerBaseState(PlayerStateMachine playerstateMachine)
    {
        stateMachine = playerstateMachine;
        groundData = stateMachine.Player.Data.GroundedData;
    }

    public virtual void Enter()
    {
        AddInputActionsCallbacks();
    }

    public virtual void Exit()
    {
        RemoveInputActionsCallbacks();
    }

    public virtual void HandleInput()
    {
        ReadMovementInput();
    }

    public virtual void PhysicsUpdate()
    {
        
    }

    public virtual void Update()
    {
        Move();
    }

    protected virtual void AddInputActionsCallbacks()
    {

    }

    protected virtual void RemoveInputActionsCallbacks()
    {

    }

    private void ReadMovementInput()
    {
        // 역참조. 자주사용하는 것들은 캐싱을 해놓는 것이 좋다.
        stateMachine.MovementInput = stateMachine.Player.Input.PlayerActions.Movement.ReadValue<Vector2>();
    }

    //실제 이동 처리
    private void Move()
    {
        Vector3 movementDirection = GetMovementDirection();

        Rotate(movementDirection);

        Move(movementDirection);
    }

    private Vector3 GetMovementDirection()
    {
        // 카메라가 바라보는 방향으로 이동하게끔 구현
        Vector3 forward = stateMachine.MainCameraTransform.forward;
        Vector3 right = stateMachine.MainCameraTransform.right;

        // y값을 제거해야 땅바닥을 보지 않는다.
        forward.y = 0;
        right.y = 0;

        forward.Normalize();
        right.Normalize();

        // 이동해야 하는 벡터에 입력한 이동방향을 곱해야 이동 처리가 이루어진다.
        return forward* stateMachine.MovementInput.y + right * stateMachine.MovementInput.x;
    }

    private void Move(Vector3 movementDirection)
    {
        // 플레이어 이동처리
        float movementSpeed = GetMovementSpeed();
        stateMachine.Player.Controller.Move(
            (movementDirection * movementSpeed) * Time.deltaTime
            );
    }
    private void Rotate(Vector3 movementDirection)
    {
        if(movementDirection != Vector3.zero)
        {
            Transform playerTransform = stateMachine.Player.transform;
            // 바라보는 방향으로 회전을 하게끔 구현
            Quaternion targetRotation = Quaternion.LookRotation(movementDirection);
            playerTransform.rotation = Quaternion.Slerp(playerTransform.rotation, targetRotation, stateMachine.RotationDamping * Time.deltaTime);
        }
    }

    private float GetMovementSpeed()
    {
        // 실제로 이동하는 속도의 처리
        float movementSpeed = stateMachine.MovementSpeed * stateMachine.MovementSpeedModifier;
        return movementSpeed;
    }

    // state 마다 애니메이션을 추가하는 처리
    // SetBool 을 통해 애니메이션을 재생하고 끝내는 로직
    protected void StartAnimation(int animationHash)
    {
        stateMachine.Player.Animator.SetBool(animationHash, true);
    }

    protected void StopAnimation(int animationHash)
    {
        stateMachine.Player.Animator.SetBool(animationHash, false);
    }
}

 

<PlayerIdleState>

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

public class PlayerIdleState : PlayerGroundState
{
    public PlayerIdleState(PlayerStateMachine playerstateMachine) : base(playerstateMachine)
    {
    }

    public override void Enter()
    {
        // Idle 상태는 움직임이 없어야 하기 때문에 이동에 대한 처리를 방지하도록 처리
        stateMachine.MovementSpeedModifier = 0f;
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.IdleParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Player.AnimationData.IdleParameterHash);
    }

    public override void Update()
    {
        base.Update();
    }
}

 

<PlayerGroundState>

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

public class PlayerGroundState : PlayerBaseState
{
    public PlayerGroundState(PlayerStateMachine playerstateMachine) : base(playerstateMachine)
    {
    }

    public override void Enter()
    {
        // ground를 상속받는 부분은 ground의 bool 값이 켜져있는채로 시작을 함.
        base.Enter();
        StartAnimation(stateMachine.Player.AnimationData.GroundParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Player.AnimationData.GroundParameterHash);
    }

    public override void Update()
    {
        base.Update();
    }

    public override void PhysicsUpdate()
    {
        base.PhysicsUpdate();
    }
}

이 정도로 기본 구성이 끝나고 나면 Unity로 돌아가서 몇 가지 추가적인 세팅만 더해주면 된다.

 

ScriptableObject에 PlayerSO를 생성한 뒤 살펴보면, 스크립트로 플레이어의 이동상태와 점프상태에서 설정했던 값이 잘 적용되어 있는 것을 확인할 수 있으며 추후에 플레이어 조작 시 속도나 힘 조절을 간편하게 관리할 수 있다.

 

<Player 수정>

private void Start()
    {
        // 마우스 커서를 사라지게 함
        Cursor.lockState = CursorLockMode.Locked;
        // 캐릭터가 맨 처음 동작해야 할 Idle 상태로 만들어주기
        stateMachine.ChangeState(stateMachine.idleState);
    }

    private void Update()
    {
        stateMachine.HandleInput();
        stateMachine.Update();
    }

    private void FixedUpdate()
    {
        stateMachine.PhysicsUpdate();
    }

 

캐릭터 컨트롤러(Character Controller)

Rigidbody를 따로 사용하지 않고 움직이는 캐릭터를 사용할 때 사용. Collider나 Rigidbody를 따로 사용하지 않아도 사용 가능하다. 하지만 더 현실적인 연출이나 물리작업을 해야 한다면 적절하진 않다.

 

 

※ 코드 간결화 (캐싱caching)

Caching(캐싱)이라는 개념은 데이터나 결과를 일시적으로 저장하고, 나중에 동일한 데이터나 결과를 필요로 할 때 이러한 저장된 정보를 사용하여 시스템 성능을 향상시키는 방법을 가리킨다.

private void Rotate(Vector3 movementDirection)
    {
        if(movementDirection != Vector3.zero)
        {
            Quaternion targetRotation = Quaternion.LookRotation(movementDirection);
            stateMachine.Player.transform.rotation = Quaternion.Slerp(stateMachine.Player.transform.rotation, targetRotation, stateMachine.RotationDamping * Time.deltaTime);
        }
    }

코드가 길어지는 것 보다 반복적으로 나오는 것을 따로 선언하여 사용하면 코드가 한결 가벼워진다.

private void Rotate(Vector3 movementDirection)
    {
        if(movementDirection != Vector3.zero)
        {
            Transform playerTransform = stateMachine.Player.transform;
            Quaternion targetRotation = Quaternion.LookRotation(movementDirection);
            playerTransform.rotation = Quaternion.Slerp(playerTransform.rotation, targetRotation, stateMachine.RotationDamping * Time.deltaTime);
        }
    }