0. 들어가기에 앞서
게임 제작 마지막 일자, 마지막으로 나타나는 버그들을 고치고 빌드까지 해서 게임이 잘 작동하는지 살펴보자.
최종적인 빌드 게임이 완성되었다.
총의 떨림 문제는 프로젝트 내내 해결되지 않는 문제였는데, 이걸 해결하기 위해서 예시 템플릿 같은 걸 확인해보기도 했다. 하지만 예시 탬플릿조차 컨트롤러 가까이에 물건을 들고 있으면 떨림 현상이 멈추지 않았고, 한참 뒤에야 이 쥐는 방법에 대한 기능을 따로 구현해야 한다는 사실을 알게 되었다.
발생 원인은 XR Grab Interactable과 Rigidbody가 같이 작동하는 방식에서 Update와 FixedUpdate 처리과정의 딜레이로 발생하는 현상이다.
이런 현상을 줄이거나 없애기 위해서는 아래와 같은 방법들이 있다.
1의 방법은 시간상 실행할 수 없었고, 막상 2의 방법으로 시도해봐도 총의 떨림 현상이 개선되지 않았다. 더군다나 XR Grab Interactable을 Kinematic으로 변경하니 총의 조준과 관련된 부분에 문제가 생겨서 결국 롤백할 수밖에 없었다.
근본적으로 이 문제는 시간 내로 해결할 수 없다고 판정하고 해결 시도를 종료했지만, 이런 떨림을 줄이는 방법을 다음에는 직접 구현해 봐야 한다는 사실을 알게 되었다.
튜토리얼을 UI로 준비했지만 튜토리얼용 과녁이 따로 필요할 것 같아서 따로 구현하기로 했다. 하지만 튜토리얼용 과녁은 일반 과녁과 달리 점수 측정이 되면 안되고, 게임 시작 여부와 관계없이 작동해야 한다.
하지만 이 튜토리얼용 과녁을 위한 코드를 따로 만들기에는 다소 부담되는 요소가 될 수도 있기 때문에, 과녁 기능을 살리면서 그냥 UI로 표시되게 코드를 가져왔다.
using TMPro;
using UnityEngine;
/// <summary>
/// 튜토리얼 타겟용 UI 점수 출력용
/// 플레이어가 연습을 하면서 맞힌 점수를 표기하기 위한 용도로
/// 점수가 따로 저장되지 않도록 맞힌 점수만 표기할 수 있도록 만든 기능
/// </summary>
public class TutorialTargetUI : MonoBehaviour
{
private TargetController m_targetController;
[SerializeField] Transform m_target;
[SerializeField] TMP_Text m_scoreText;
private void Awake() => Init();
private void Init()
{
m_targetController = GetComponent<TargetController>();
}
private void OnCollisionEnter(Collision collision)
{
Vector3 hitPoint = collision.GetContact(0).point;
Vector3 targetCenter = m_target.position;
float distance = Vector3.Distance(hitPoint, targetCenter);
int score = m_targetController.CalculateScore(distance);
m_scoreText.text = score.ToString();
}
}
TargetController 자체는 처음부터 비활성화된 채로 되어 있으니, 이걸 편법으로 활성화시키기 위해 버튼으로 Activate 시키는 방식으로 구현했다.
총을 양 손으로 들 수 있게 만들고, 또한 양 손으로 총을 들어야지만 총을 쏠 수 있게 기능을 설계했다.
총을 구성하는 코드는 발사를 담당하는 Shooter 코드와 총알을 담당하는 ParticleBulletController로 구성했다.
총알은 파티클로 구성된 에셋을 사용하기 위해서 코드를 추가로 구성하였으며, 총구 폭발과 탄착 폭발 이벤트는 Instatiate로 하는 대신 날아가는 총알 자체에는 오브젝트 풀을 반영했다.
using DesignPattern;
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
/// <summary>
/// Muzzle에 적용된 총알 발사용 스크립트
/// </summary>
public class Shooter : MonoBehaviour
{
// Bullet 관련 컴포넌트
[SerializeField] private ParticleBulletController m_bulletPrefab;
[SerializeField] private GameObject m_muzzleFire;
private ObjectPool m_bulletPool;
private Rigidbody m_bulletRigid;
// 참조 컴포넌트
private XRGrabInteractable m_interactable;
private AudioSource m_audioSource;
private Transform m_muzzle;
// 밸런스용 변수 - 인스펙터 조정
[SerializeField] private float speed;
[SerializeField] private float m_cooltime;
// 변수
private float m_shootCooltime;
// 오브젝트 풀 적용 가능한지 확인용 컴포넌트
//[SerializeField] private MuzzleFire m_muzzleFire;
//private ObjectPool m_muzzlePool;
private void Awake() => Init();
private void Init()
{
m_audioSource = GetComponent<AudioSource>();
m_muzzle = GetComponent<Transform>();
m_interactable = GetComponentInParent<XRGrabInteractable>();
m_bulletPool = new ObjectPool(transform, m_bulletPrefab);
}
private void Update()
{
m_shootCooltime += Time.deltaTime;
}
// 총을 발사할 수 있는지 확인용
// 총을 양 손으로 잡고 있는지와, 발사 쿨타임이 지났는지 확인 후 bool 반환
private bool TryFire()
{
if(m_interactable.interactorsSelecting.Count >= 2 && m_shootCooltime > m_cooltime)
{
return true;
}
return false;
}
public void Fire()
{
if (TryFire())
{
// 소리 재생
m_audioSource.Play();
// 총구 발사 이펙트
GameObject muzzleFire = Instantiate(m_muzzleFire, m_muzzle.position, m_muzzle.rotation);
// 날아가는 총알을 오브젝트 풀로 꺼내오고, 물리력 작용
BulletFire();
m_shootCooltime = 0;
}
}
// Muzzle 발사 부분도 ObjectPool로 처리하고 싶었으나, Object Pool로 처리하면
// 다시 Pop했을 때 모션 재생이 되지 않는 오류가 있음
// 우선은 Instantiate로 처리하고 파괴하는 방식으로 처리
// 파티클 출력 오류 문제를 해결해 보고 해결이 되면 오브젝트 풀로 전환 예정
/*
public void MuzzleFire()
{
PooledObject muzzleFire = m_muzzlePool.PopPool() as MuzzleFire;
muzzleFire.transform.position = m_muzzle.position;
}
*/
// 총알을 오브젝트 풀로 가져오고 날아가게 함
private void BulletFire()
{
PooledObject bullet = m_bulletPool.PopPool() as ParticleBulletController;
bullet.transform.position = m_muzzle.position;
bullet.transform.rotation = m_muzzle.rotation;
m_bulletRigid = bullet.GetComponent<Rigidbody>();
m_bulletRigid.velocity = m_muzzle.forward * speed;
}
}
using DesignPattern;
using UnityEngine;
/// <summary>
/// 파티클 적용 총알 발사용 스크립트
/// 기존 에셋에 적용되어 있던 코드를
/// 오브젝트 풀 패턴 사용 내용만 반영하여 적용
/// </summary>
public class ParticleBulletController : PooledObject
{
public GameObject hitPrefab;
void OnCollisionEnter(Collision co)
{
ContactPoint contact = co.contacts[0];
Quaternion rot = Quaternion.FromToRotation(Vector3.up, contact.normal);
Vector3 pos = contact.point;
if (hitPrefab != null)
{
var hitVFX = Instantiate(hitPrefab, pos, rot);
var psHit = hitVFX.GetComponent<ParticleSystem>();
if (psHit != null)
{
ReturnPool();
}
else
{
var psChild = hitVFX.transform.GetChild(0).GetComponent<ParticleSystem>();
ReturnPool();
}
}
ReturnPool();
}
}
또한 총기 관련 인스펙터 구성은 다음과 같이 하였으며, Interaction Event에서 Selected가 되었을 때 Fire를 발사하는 것으로 총의 발사를 처리했다.
과녁은 크게 두 부분으로 나누어서 처리했다. 과녁 개개인의 행동과 점수를 처리하는 TargetController 부분, 그리고 과녁 모임의 랜덤성 요소를 처리할 TargetManager로 나누었다.
using UnityEngine;
/// <summary>
/// 과녁의 애니메이션 조정용
/// 과녁 점수 계산
/// </summary>
public class TargetController : MonoBehaviour
{
private Animator m_animator;
private AudioSource m_audioSource;
[SerializeField] Transform m_target;
private void Awake() => Init();
private void Init()
{
m_animator = GetComponent<Animator>();
m_audioSource = GetComponent<AudioSource>();
}
// 과녁 활성화
public void ActiveTarget()
{
m_animator.SetBool("IsActive", true);
}
// 과녁 비활성화
public void InactiveTarget()
{
m_animator.SetBool("IsActive", false);
}
// 과녁 활성화 여부 체크
public bool IsActiveTarget()
{
return m_animator.GetBool("IsActive");
}
// 총알과 과녁 충돌 시
private void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.CompareTag("Bullet") && IsActiveTarget()
&& !GameManager.Instance.IsGameOver())
{
// 과녁 충돌 지점에 따른 점수 차등 지급
Vector3 hitPoint = collision.GetContact(0).point;
Vector3 targetCenter = m_target.position;
float distance = Vector3.Distance(hitPoint, targetCenter);
int score = CalculateScore(distance);
GameManager.Instance.AddScore(score);
m_animator.SetBool("IsActive", false);
}
}
// 과녁 충돌 지점 - 중심부와의 거리와 가까울 수록 고득점으로 점수를 지급
public int CalculateScore(float distance)
{
if (distance <= 0.15f) { m_audioSource.Play(); return 500; }
else if (distance <= 0.25f) return 400;
else if (distance <= 0.35f) return 300;
else if (distance <= 0.45f) return 200;
else return 100;
}
}
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
/// <summary>
/// 자식 오브젝트로 과녁을 둔다.
/// 자식 오브젝트에 있는 과녁을 관리하면서
/// 랜덤하게 과녁의 활성화를 담당하는 컴포넌트
/// </summary>
public class TargetManager : MonoBehaviour
{
// 시간제한 설정(밸런스)
[SerializeField] private float m_setTimelimit;
private float m_timeLimit;
// 과녁 변화 속도 설정(밸런스)
[SerializeField] private float m_targetRoutineTime;
// 참조 컴포넌트
private TargetController[] m_targetControllers;
// 변수 저장 및 코루틴
private List<int> m_targets = new List<int>(5);
Coroutine m_coroutine;
// 인스펙터 참조
[SerializeField] private GameObject m_timeUI;
[SerializeField] TMP_Text m_timeText;
[SerializeField] private GameObject m_gameOverUI;
private void Awake() => Init();
private void Init()
{
m_targetControllers = GetComponentsInChildren<TargetController>();
m_timeUI.SetActive(false);
m_gameOverUI.SetActive(false);
}
private void Update()
{
// 게임이 종료된 상태가 아닐 시
if(!GameManager.Instance.IsGameOver())
{
m_timeLimit -= Time.deltaTime;
m_timeText.text = $"{m_timeLimit.ToString("00.0")}";
if (m_coroutine == null)
{
m_coroutine = StartCoroutine(TargetCoroutine());
}
// 시간 오버 시 게임 종료
if(m_timeLimit <= 0)
{
GameManager.Instance.GameOver();
if(m_coroutine != null)
{
InactivateTarget();
StopCoroutine(m_coroutine);
m_gameOverUI.SetActive(true);
m_coroutine = null;
}
}
}
}
// 게임 시작 존에 들어왔을 시 게임 시작
private void OnTriggerEnter(Collider other)
{
if(other.CompareTag("Player"))
{
GameManager.Instance.GameStart();
m_timeLimit = m_setTimelimit;
m_gameOverUI.SetActive(false);
m_timeUI.SetActive(true);
}
}
// 게임 시작 존에서 벗어났을 시 강제로 게임 종료
private void OnTriggerExit(Collider other)
{
if(other.CompareTag("Player"))
{
GameManager.Instance.GameOver();
m_timeUI.SetActive(false);
if (m_coroutine != null)
{
InactivateTarget();
StopCoroutine(m_coroutine);
m_gameOverUI.SetActive(false);
m_coroutine = null;
}
}
}
// 과녁 랜덤 활성화 코루틴
private IEnumerator TargetCoroutine()
{
WaitForSeconds m_targetCooltime = new WaitForSeconds(m_targetRoutineTime);
// 코루틴 반복 진행
while (true)
{
// 리스트에 저장된 변수 제거
m_targets.Clear();
// 현재 활성화된 과녁 전부 비활성화
InactivateTarget();
// 과녁의 개수의 반 + 1개만큼 랜덤 숫자를 뽑는다(중복X)
while (m_targets.Count < m_targetControllers.Length / 2 + 1)
{
int num = Random.Range(0, m_targetControllers.Length);
if (!m_targets.Contains(num))
{
m_targets.Add(num);
}
}
// 해당 숫자의 과녁을 활성화함
foreach (int num in m_targets)
{
if (m_targets.Contains(num))
{
m_targetControllers[num].ActiveTarget();
}
}
yield return m_targetCooltime;
}
}
// 모든 과녁 비활성화 함수
private void InactivateTarget()
{
for (int i = 0; i < m_targetControllers.Length; i++)
{
if (m_targetControllers[i].IsActiveTarget())
{
m_targetControllers[i].InactiveTarget();
}
}
}
}
마지막으로 UI와 관련된 부분은 각각의 컴포넌트로 처리하였으며, 사실은 이렇게 복잡하게 나눌 것이 아니고 관리할 수 있는 컴포넌트를 만들 수 있었으면 어떨까 하는 아쉬움이 있다.
솔직히 말하자면, 이번 프로젝트만큼 힘든 프로젝트가 없지 않았을까 싶기도 했다. Minigame 개인 프로젝트가 끝난지 겨우 4일만에 다시 프로젝트를 시작하는 것도 모자라, AR 2일 VR 2일이라는 짧은 강의 시간 이후로 게임을 직접 만들라니, 상당한 고역이었다.
커리큘럼상 어쩔 수 없다고는 해도 아무래도 이렇게 바쁜 일정을 소화하려다 보니 의욕도 잘 안나고 제대로 힘쓰지 못한 것이 아쉬웠다.
특히나 제일 아쉬웠던 건 VR 게임을 아예 해 본 적이 없다 보니 게임의 구성과 기능을 거의 상상만으로 만들어야만 했던 것이다. 보다 경험이 있었다면 게임에 필요한 요소가 뭐가 있는지, 어떤 기능을 구현해야 하는지 보다 고민해보고 많은 문제를 해결할 수 있었을 건데, 막바지에 들어서는 뭘 구현해야 하는지 몰라 방황하기까지 했다.
비록 아쉬운 점도 힘들었던 점도 많은 과정이었지만, 그래도 결과물 자체는 생각보다 나쁘지 않게 나온 것에 만족하려고 한다. 그래도 이와 같은 경험이 헛되지는 않았을 것이라 생각하며 보다 열심히 공부하는 태도를 가지자