XR 플밍 - 9. UnityEngine3D 응용 프로그래밍 - 미니 프로젝트 - TPS 게임 기초 / 시네머신 (5/14)

이형원·2025년 5월 14일
0

XR플밍

목록 보기
73/215

0. 들어가기에 앞서

유니티3D 응용프로그래밍의 내용을 배우는 데에 있어, 앞으로 시네머신이나 오디오, 빛, 파티클 등 다양한 내용에 대해 배울 예정이다. 여기서 해당 내용을 배우는 데에 있어 미니 프로젝트 형식으로 해당 내용을 배우는 방식으로 진행하기로 했다.

강의 순서상으로는 시네머신에 대해 배우고자 한다.

1. TPS 장르란 무엇인가?

TPS 장르는 3인칭 슈팅 게임으로 정의할 수 있다. 해 본 사람의 입장에선 익숙한 장르이겠지만, 필자의 입장에선 3인칭 슈팅 게임은 그리 익숙하지 않은 장르이다. 총을 다루는 게임을 별로 즐기지 못하는 편이기도 했고 3D 멀미 자체도 조금 있었던 터라 원래 카메라워킹이 다채로운 게임을 잘 하질 못했다.

그나마 즐겼던 게임 중이 TPS라고 할 만한 게임은 배틀그라운드 정도가 있었다.

TPS를 보통 3인칭 슈팅 게임으로 정의하기는 하니, 넓게 보면 3인칭 시점의 캐릭터 무빙을 보이는 게임 전반을 가리킬 수도 있다. 3인칭 액션 게임도 TPS라고 볼 수 있다. 3인칭 슈팅 게임은 거의 안 해 봤어도 3인칭 액션 게임이라면 많이 해 봤다. 젤다의 전설 야생의 숨결이나 왕국의 눈물도 했고, 페르소나5 팬텀 스트라이커즈라든가

이런 게임 시스템을 생각해보면서 설계를 진행해보자.

2. 게임 구성 레퍼런스

3. 싱글톤과 옵저버 패턴

게임 시스템에 쓰일 싱글톤과 옵저버 패턴을 구현해보자.
여기서, DesignPattern 이라는 namespace를 따로 만들고 싱글톤 패턴과 옵저버 패턴을 제네릭으로 만들어보고자 한다.

먼저 싱글톤 패턴부터 만들어보자.

  • 싱글톤 패턴
using UnityEngine;

namespace DesignPattern;
{
	public class Singleton<T> : Monobehaviour where T : Monobehaviour
    {
    	private static T _instance;
        public static T Instance
        {
        	get
            {
            	if(_instance == null)
                {
                	// 해당 인스턴스를 가져온다.
                	_instance = FindObjectOfType<T>();
                    DontDestroyOnLoad(_instance);
                }
                return _instance;
            }
        }
        
        protected void SingletonInit()
        {
        	if(_instance != null && _instance != this)
            {
            	Destroy(gameObject);
            }
            else
            {
            	_instance = this as T;
                DontDestroyOnLoad(_instance);
            }
        }
    }
}      
  • 옵저버 패턴
using UnityEngine;
using UnityEngine.Events;

namespace DesignPattern
{
	public class ObeservableProperty<T>
    {
    	[SerializeField] private T _value;
        
        public T Value
        {
        	get => _value;
            set
            {
            	// 값의 변동이 없을 경우 바로 반환
            	if(_value.Equals(value)) return;
                _value = value;
                Notify();
            }
        }
        
        private UnityEvent<T> _onValueChanged = new();
        
        public ObservableProperty(T value = default)
        {
        	_value = value;
        }
        
        public void Subscribe(UnityAction<T> action)
        {
        	_onValueChanged.AddListener(action);
        }
        
        public void UnSubscribe(UnityAction<T> action)
        {
        	_onValueChanged.RemoveListener(action);
        }
        
        public void UnSubscribeAll()
        {
        	_onValueChanged.RemoveAllListeners();
        }
        
        public void Notify()
        {
        	_onValueChanged?.Invoke(Value);
        }
    }
}      

이와 같이 싱글톤 패턴과 옵저버 패턴을 제네릭으로 선언하면, 앞으로 만들 싱글톤 패턴 혹은 옵저버 패턴이 필요한 클래스에 전역적으로 적용이 가능할 것이다.

4. 플레이어 구현

이전에 배운 MVC/MVP 패턴을 되새기며, 플레이어의 구현 또한 아래와 같이 클래스를 나눠서 진행해 보려고 한다.

  • PlayerStatus
  • PlayerMovement
  • PlayerController

4.1 PlayerStatus

우선 PlayerStatus부터 만들어보자.

  • PlayerStatus - 플레이어의 정보에 관한 부분만 담는다.

여기서, 위에서 만들었던 DesignPattern 의 옵저버 제네릭 클래스를 활용하고자, 'using DesignPattern'을 쓴다.

using UnityEngine;
using DesignPattern;

public class PlayerStatus : Monobehaviour
{
	[field : SerializeField][field : Range(0, 10)]
    public float WalkSpeed { get; set; }
    [field : SerializeField][field : Range(0, 10)]
    public float RunSpeed { get; set; }
    
    [field : SerializeField][field : Range(0, 10)]
    public float RotateSpeed { get; set; }
    
    public ObservableProperty<bool> IsAiming { get; private set; } = new();
    public ObservableProperty<bool> IsMoving { get; private set; } = new();
    public ObservableProperty<bool> IsAttacking { get; private set; } = new();
}

이와 같이 옵저버 패턴을 이용하여 값의 변동이 있을 때에만 IsAiming, IsMoving, IsAttacking의 bool값을 받아올 수 있다. 이는 코드를 간결하고 디버깅을 쉽게 해 주도록 한다.

4.2 PlayerMovement

PlayerMovement 스크립트를 작성하기 이전에, 플레이어를 간략하게나마 구성해 보자.
여기서 앞으로 이런 식으로 게임 내의 요소를 움직일 스크립트를 만들기 위해 고려해 봐야 할 내용이 있다.

게임 내 요소에서 시각적 요소인 에셋을 결정하는 데 소요되는 시간이 꽤 걸릴 가능성이 있다.
이를 위해 어떤 에셋이 들어와도 큰 조정 없이 반영할 수 있는, 범용적으로 사용할 수 있는 코드 구성 및 프리팹을 만들자.

이전의 팀 프로젝트에서도 느꼈던 부분이다. 실제로 이벤트를 수정하기 위해서 프리팹을 일일히 고치는 수고를 들인 시간을 생각하면, 이와 같은 범용적으로 사용할 수 있는 클래스 디자인과 프리팹 디자인은 필요한 요소이다.

따라서 플레이어 프리팹은 아래와 같이 구성하였다.

플레이어 캐릭터라는 빈 오브젝트 아래에, 에셋이 반영될 Avatar 오브젝트를 자식으로서 배치했다. Eye부분은 캡슐의 앞뒤가 구분되지 않으니 임시로 붙인 것이며, TPS 게임을 구성할 것이므로, Aim이라는 부분을 빈 오브젝트로 따로 만들었다. 이후에 필요하면 Aim의 자식 클래스로 총구를 가리킬 Muzzle 빈 오브젝트로 만들 수 있을 것이다.

이와 같이 구성하고 PlayerMovement를 만들어보자.

  • PlayerMovement
using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
	[Header("Reference")]
	[SerializeField] private Transform _avatar;
    [SerializeField] private Transform _aim;
    
    private Rigidbody _rigidbody;
    private PlayerStatus _playerStatus;
    
    [Header("Mouse Config")]
    [SerializeField][Range(-90, 0)] private float _minPitch;
    [SerializeField][Range(0, 90)] private float _maxPitch;
    [SerializeField][Range(0, 5)] private float _mouseSensitivity = 1;
    
    private Vector2 _currentRotation;
    private void Awake() => Init();
    
    private void Init()
    {
    	_rigidbody = GetComponent<Rigidbody>();
        _playerStatus = Getcomponent<PlayerStatus>();
    }
    
    public Vector3 SetMove(float moveSpeed)
    {
    	Vector3 moveDirection = GetMoveDirection();
        
        Vector3 velocity = _rigidbody.velocity;
        velocity.x = moveDirection.x * moveSpeed;
        velocity.z = moveDirection.z * moveSpeed;
        
        // Rigidbody에 velocity만큼의 속도를 가함 -> 이동
        _rigidbody.velocity = velocity;
        
        return moveDirection;
    }
    
    public Vector3 SetAimRotation()
    {
    	Vector2 mouseDir = GetMouseDirection();
        
        // X축의 경우라면 마우스 이동 각도에 제한을 걸 필요가 없음
        _currentRotation.x += mouseDir.x;
        
        // Y축의 경우라면 마우스 이동 각도에 제한을 걸어야 함.
        _currentRotation.y = Mathf.Clamp(_currentRotation.y + mouseDir.y, _minPitch, _maxPitch);
        
        // 캐릭터 오브젝트의 경우에는 Y축 회전만 반영 -> 캐릭터가 앞으로나 옆으로 숙이지 않기 때문
        // _currentRotation.x가 y값으로 들어가는 이유는, 캐릭터가 좌우로 움직이는 방향이 y축으로 작용하기 때문
        // 벡터 개념을 잘 생각해 보자.
        transform.rotation = Quaternion.Euler(0, _currentRotation.x, 0);
        
        // 에임의 경우 상하 회전만 반영
        // _aim은 Player의 자식 오브젝트로 로컬 각도를 Vector로 변환
        // 상하 회전만 반영하기 위해선, _currentRotation.y가 X축 방향으로 들어가아햔다.
        // (고개를 앞뒤로 숙이는 방향은 X축)
        Vector currentEuler = _aim.localEulerAngles;
		_aim.localEulerAngles = new Vector3(_currentRotation.y, currentEuler.y, currentEuler.z);
        
        // 회전 방향 벡터 변환
        Vector3 rotateDirVector = transform.forward;
        rotateDirVector.y = 0;
        
        return rotateDirVector;
    }
    
    public Vector2 GetMouseDirection()
    {
    	float mouseX = Input.GetAxis("Mouse X") * _mouseSensitivity;
        
        // Y축은 마이너스인 이유는, 마우스의 위치를 카메라의 위치로 변환하기 때문
        // 우리가 마우스를 올렸을 때 의도하는 모습은 하늘을 올려다보는 각도이나,
        // Y축 자체는 +값이므로 카메라는 위로 상승->바닥을 바라보는 각도가 된다.
        // 따라서 의도한 대로의 작동읠 위해선 -로 반영해야 함
        float mouseY = -Input.GetAxis("Mouse Y") * _mouseSensitivity;
        
        return new Vector2(mouseX, mouseY);
    }
    
    public void SetAvatarRotation(Vector3 direction)
    {
    	if(dirction == Vector3.zero) return;
        
        Quaternion targetRotation = Quaternion.LookRotation(direction);
        
        _avatar.rotation = Quaternion.Lerp(_avatar.rotation, targetRotation, _playerStatus.RotateSpeed * Time.deltaTime);
    }
    
    public Vector3 GetMoveDirection()
    {
    	Vector3 input = GetInputDirection();        
        Vector3 direction = (transform.right * input.x) + (transform.forward * input.z);
        
        return direction.normalized;
    }
    
    public Vector3 GetInputDirection()
    {
    	float x = Input.GetAxisRaw("Horizontal");
        float z = Input.GetAxisRaw("Vertical");
        
        return new Vector3(x, 0, z);
    }
}

처음에 코드를 써 봤을 때에는 헷갈리는 내용이 많았지만, 벡터 개념을 잘 생각해 보면 적용해야 할 방향을 알 수 있었다. 주석이 다소 길어지긴 했지만 이해를 위해서 주석을 추가해 놓았다.
이와 같이 움직임, 회전 등의 개념을 이해하기 위해선 벡터와 3차원 공간에 대한 이해가 많이 필요할 것으로 보인다.

4.3 PlayerController

PlayerMovement에서의 Set값을 바탕으로 PlayerCotroller에서 행동을 반영한다.

  • PlayerController
using UnityEngine;

public class PlayerCotroller : MonoBehaviour
{
	public bool isControlActive { get; set; } = true;
    
    public PlayerStatus _status;
    public PlayerMovement _movememt;
    
    [SerializeField] GameObject _aimCamera;
    
    private void Awake() => Init();
	private void OnEnable() => SubscribeEvents();
    private void Update() => HandlePlayerControl();
    private void OnDisable() => UnsubscribeEvents();
    
    private void Init()
    {
    	_status = GetComponent<PlayerStatus>();
        _movement = GetComponent<PlayerMovement();
    }
    
    private void HandlePlayerControl()
    {
    	if (!isControlActive) return;
        HandleMovement();
        HandleAiming();
    }
    
    private void HandleMovement()
    {
    	Vector camRotateDir = _movement.SetAimRotation();
        
        float moveSpeed;
        if(_status.IsAming.Value) moveSpeed = _status.WalkSpeed;
        else moveSpeed = _status.RunSpeed;
		
        Vector3 moveDir = _movement.SetMove(moveSpeed);
        _status.IsMoving.Value = (moveDir != Vector3.zero);
        
        // 몸체의 회전
        Vector3 avatarDir;
        if(_status.IsAming.Value) avatarDir = camRotateDir;
        else avatarDir = moveDir;
        
        _movement.SetAvatarRotation(avatarDir);
	}
    
    private void HandleAiming()
    {
    	_status.IsAming.Value = Input.GetKey(_aimkey);
    }
    
    public void SubscribeEvents()
    {
    	_status.IsAming.Subscribe(_aimCamera.gameObject.SetActive);
    }
    
    public void UnsubscribeEvents()
    {
    	_status.IsAming.Unsubscribe(_aimCamera.gameObject.SetActive);
    }
}

5. 시네머신의 적용 및 테스트 진행

이제 작성한 코드가 제대로 작동하는지 확인하기 위해 씬에서 해당 코드를 적용시켜보고자 한다.
가상카메라를 만들어보자, 라고 했지만...

처음에 진행할 때 가상카메라가 나오지 않는 문제가 생겼다.
이상하다, Cinemachine을 설치해놨고 Cinemachine도 뜨는데 가상카메라가 항목에 없었다.

그리고 원인은 의외로 간단했다.

지금은 업데이트를 해 놔서 Update 버튼이 사라졌지만 혹시나 패키지를 다시 해 보니 최신 버전이 아니었다.
낮은 버전에는 Virtual Camera가 없었던 걸까? 우선은 이 문제를 해결하고 Virtual Camera를 만들었다.

Idle 상태의 가상카메라와 Aim 상태의 가상 카메라를 각각 만들고, Aim은 평상시에는 꺼져 있도록 하였다.
또한 나머지 변수들에 대한 세팅을 하고 참조해야 하는 내용을 전부 참조시켰다.
Aim key는 마우스 오른쪽 버튼으로 하였고 나중에 변경할 시에는 변경하기 쉽도록 Serialize시켰다.


마지막으로 Main Camera의 CinemachineBrain을 확인하여 Default Blend의 수치를 변경한다. 이는 카메라의 전환 속도를 조절한다.

이와 같이 세팅한 후 테스트를 진행해보자.

profile
게임 만들러 코딩 공부중

0개의 댓글