XR플밍 - 10. XR 애플리케이션 개발을 위한 VR/AR 프로그래밍 - 개인 프로젝트(VR) VRGunRange 2.2일차 (6/15)

이형원·2025년 6월 15일
0

XR플밍

목록 보기
105/215

0. 들어가기에 앞서
오늘은 전반적인 게임의 틀을 다듬었다. 커밋 수는 적지만 전반적인 게임 자체 로직을 설계하는 데 힘을 쏟았다.
그럴싸한 사격장 시뮬레이터가 완성된 것 같다.

1. 2.1일차 작업 정리

  • 게임의 시작과 종료를 만들었다. 이걸 게임 플레이 존으로 설정하여 해당 존 안에 들어가면 게임이 시작하고, 해당 존을 벗어나면 게임이 강제 종료되도록 설정하였다.
  • 게임 종료 구현 - 뒤돌아보면 게임 나가기 UI를 만들어놨는데, 버튼을 누르면 게임이 꺼진다.
  • 게임의 제한 시간 설정 및 UI 표시
  • 과녁이 랜덤하게 활성화되고 일정 시간마다 활성화된 과녁이 변경되는 기능 구현
  • 총 사격 효과음 추가
  • 총을 양 손으로 들었을 때에만 사격 가능하도록 기능 추가
  • 전체적인 코드 정리 및 주석 추가, 리펙토링

2. 문제의 발생과 해결과정

2.1 과녁을 일정 개수를 랜덤하게 올라갔다 내려가게 하기 위해선?

원래라면 어제 작업해보려던 내용이었지만 어제는 시간도 별로 없었고 뚜렷한 설계 방안도 떠오르지 않아서 작업하지 못했다.
오늘 이 문제에 대해 골똘히 생각해보면서 그래도 확장성을 고려한 방식을 생각해 보았는데, 이와 같은 방식을 떠올렸다.

  1. 과녁의 개수가 일정하지 않을 것을 고려해서, 인스펙터로 참조하는 방식보다는 그냥 있는 과녁을 다 가져올 수 있는 방식으로 만들어 보자.
    => 과녁들의 집합 위에 부모 오브젝트를 두고 GetComponentsInChildern으로 참조한다면?

  2. 몇 개의 과녁을 활성화할 것인지에 대한 아이디어 => 참조한 과녁들의 배열 길이 / 2 + 1개
    이렇게 하면 과녁 9개 기준 9 / 2 + 1 = 5개의 과녁의 활성화될 것이다.
    과녁의 개수가 달라져도 대략 전체 중 반 + 1개가 활성화되는 게 시각적으로도 잘 보이지 않을까?

  3. 랜덤한 과녁을 활성화하는 방법
    3.1 TargetController 자체는 과녁을 올리고 내리는 기능 및 스코어 상승 기능만 넣어두고, 부모 오브젝트에 TargetManager라는 과녁 관리 컴포넌트를 만든다.
    3.2 TargetManager에서는 랜덤한 숫자를 중복되지 않게 5개 뽑아 배열로 For문을 돌려서 과녁을 활성화시킨다. 일정 시간 뒤 활성화된 과녁이 변경될 수 있도록 숫자를 다시 뽑아서 활성화된 과녁을 변경한다.

여기서 3.2에 대한 과정을 상세 설명하면 다음과 같다.
3.2.1 우선은 해당 부분을 코루틴으로 처리하며, 게임이 종료되지 않은 시점에서 계속 반복될 수 있도록 While(true)로 처리한다.
3.2.2 랜덤한 숫자를 저장할 List를 초기화한다. (List.Clear())
3.2.3 활성화된 과녁을 전부 비활성화한다(초기화)
3.2.4 과녁의 개수/2+1개만큼 반복문을 진행하며, List에 들어있는 숫자가 중복되지 않도록 5개를 Add로 저장한다.
3.2.5 해당 과녁을 활성화한다
3.2.6 WaitForSeconds(정해진 시간) - 일정 시간 이후로 다시 반복문 실행

이와 같은 과정으로 최종 완성된 TargetManager의 코드는 아래와 같다.

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

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;

    private void Awake() => Init();

    private void Init()
    {
        m_targetControllers = GetComponentsInChildren<TargetController>();        
        m_timeUI.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_coroutine = null;
                }
            }
        }
    }

    // 게임 시작 존에 들어왔을 시 게임 시작
    private void OnTriggerEnter(Collider other)
    {
        if(other.CompareTag("Player"))
        {
            GameManager.Instance.GameStart();
            m_timeLimit = m_setTimelimit;
            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_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();
            }
        }
    }
}

실제로 최종 코드를 완성할 때까지 상당한 시행착오를 겪었다. 특히 해결하기 어려웠던 게 코루틴이 이상하게 한 번만 실행되고 실행이 안 되는 문제였는데, 이거는 While(true)로 처리하지 않았기 때문에 생긴 문제였다.
결과적으로 해당 방법으로 일정 시간마다 활성화된 과녁이 변경되는 기능이 구현되었다.

2.2 총을 양 손으로 드는 방법과 자세

총을 양 손으로 드는 방식으로 해야 보다 현실성 있는 VR 사격장 시뮬레이터가 될 것 같아서, 이 부분에 대한 구현을 시도했다.

변경점은 크게 아래와 같다.

  1. 컨트롤러의 Ray Interactor에서 Force Grab를 양 손 다 가능하게 만들었다.
  2. 총을 양 손으로 잡았는지 확인하는 방식은, 아래와 같이 확인했다.
public class Shooter : MonoBehaviour
{
	...

    // 참조 컴포넌트
    private XRGrabInteractable m_interactable;

	...

    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);        
    }

	...

    // 총을 발사할 수 있는지 확인용
    // 총을 양 손으로 잡고 있는지와, 발사 쿨타임이 지났는지 확인 후 bool 반환
    private bool TryFire()
    {
    	// 잡고 있는 컨트롤러가 두 개 이상이면 & 총 발사 쿨타임이 지났으면
        if(m_interactable.interactorsSelecting.Count >= 2 && m_shootCooltime > m_cooltime)
        {
            return true;
        }
        return false;
    }
    ...
}
  1. 총의 앞 부분에 잡을 수 있는 GrabPoint를 추가하고, 해당 부분을 Secondary Attach Transform으로 추가했다.

이와 같은 방식으로 만들었을 때 실제로 총을 잡는 듯한 자세를 취했을 때 양 손으로 잡아야지 총알이 나가도록 할 수 있었다.

다만 문제점이라기엔 애매하지만, 실제로 총을 잡듯이 왼 손을 앞으로 쭉 뻗고, 오른쪽 컨트롤러를 왼쪽 컨트롤러와 거리를 두고 들고 있어야 정확히 에임을 할 수 있어서, 이 부분에 대해 튜토리얼을 따로 준비해야 겠다는 생각이 들었다.

2.3 리팩토링을 위한 UnityAction

콜백 함수와 유니티 액션/이벤트와 관련된 부분이 나에게 있어서는 다소 적용하기 어려운 부분이었다.
하지만 이번 프로젝트에서만큼은 그 부분을 어떻게든 적용해서 최적화를 해 봐야겠다는 생각에 여러모로 공부를 해 봤다.

예시 코드를 찾아보고, 실제로 테스트를 진행해 본 결과 점수 표시 UI에 관해 아래와 같이 유니티액션을 추가할 수 있었다.

  • GameManager
using DesignPattern;
using UnityEngine.Events;

public class GameManager : Singleton<GameManager>
{
    private static int m_score;
    private static bool m_isGameOver;

    public UnityAction<int> OnScoreChanged;

    private void Awake() => Init();

    private void Init()
    {
        m_score = 0;
        m_isGameOver = true;
    }

    public void GameStart()
    {
        m_score = 0;
        OnScoreChanged?.Invoke(m_score);
        m_isGameOver = false;
    }

    public void GameOver()
    {
        m_isGameOver = true;
    }

    public bool IsGameOver()
    {
        return m_isGameOver;
    }

    public void AddScore(int score)
    {
        m_score += score;
        OnScoreChanged?.Invoke(m_score);
    }

    public int GetScore() => m_score;
}
  • ScoreUI
using TMPro;
using UnityEngine;

/// <summary>
/// 스코어를 표사히는 UI에 현재 점수를 반영하는 스크립트
/// </summary>
public class ScoreUI : MonoBehaviour
{
    private TMP_Text m_text;

    private void Awake() => Init();

    private void Init()
    {
        m_text = GetComponentInChildren<TMP_Text>();
    }

    private void OnEnable() => GameManager.Instance.OnScoreChanged += ScoreUpdate;

    private void OnDisable() => GameManager.Instance.OnScoreChanged -= ScoreUpdate;

    // 점수 변화시 Text에 반영 -> 이벤트로 처리
    private void ScoreUpdate(int score)
    {
        m_text.text = $"{score.ToString("000")}";
    }
}

이와 같이 코드를 변경했고 최종적으로 정상 적용되는 것을 확인했다.
사실 이번에 유니티액션을 활용하는데 성공했다고 해도, 아직까지는 방법이 익숙하지 않았다.
이 부분은 앞으로도 유니티액션을 계속 사용해보면서 숙련도를 높이는 작업이 필요해 보인다.

3. 수정해야 할 점과 과제

3.1 (미해결 버그/이슈) 총을 든 채로 이동할 때 총이 덜덜 떨리는 현상

이 부분에 대한 건 확실히 수정이 필요해 보이지만, 아직 뚜렷한 해결방법이 보이지 않는 상태이다. 좀 더 고민해보자.

3.2 (구현) 게임 튜토리얼 내용 추가

게임에 튜토리얼 기능이 필요해 보인다는 생각이 들었다. 총을 잡는 방법이나 쏘는 방법 등의 안내를 할 수 있는 튜토리얼 내용을 추가해보자.

3.3 (디자인) 맵 디자인, 재질 디자인

지금의 맵은 다소 밋밋하고, 심지어 총도 재질이 깨져서 하얀색 총이 되었다. 좀 더 디자인적으로 괜찮은 맵을 구해보거나 에셋 적용을 해 보자.

3.4 (구현/도전) 총을 잡는 컨트롤러를, 손으로 교체하기

손 관련 에셋을 구했고 컨트롤러를 손으로 바꿔보려는 시도를 했는데, 이게 생각보다 어려웠다. 실제로 반영할 수 있을지는 모르겠지만 다시 시도를 해 보자.

3.5 (도전) 랭킹시스템 구현

최고기록을 기록할 수 있는 랭킹 시스템을 구현해보고자 한다.
다만 최고기록을 저장하는 건 그렇다 쳐도, 키보드를 구현할 수 있을지가 난감하다.

3.6 (도전) 총 장전 시스템 구현

지금의 게임 시스템 특성상 총 장전 시스템은 구현하지 않는 편이 좋겠다는 판단이 생겼다.

profile
게임 만들러 코딩 공부중

0개의 댓글