XR 플밍 - 8. UnityEngine3D 입문 - 닷지 게임 만들기(4/23)

이형원·2025년 4월 23일
0

XR플밍

목록 보기
52/215

게임 프로그래밍에 있어서 기초 기능을 배우고, 가장 단골적으로 만들어 보는 게임이 닷지 게임이 아닐까 싶다.

  • 닷지 게임

곰플레이어에 있었던 닷지이다. 이 게임은 사방에서 날아오는 총알을 피하면서 얼마나 오래 버틸 수 있는지 최고기록을 도전하는 게임이다. 간단하면서도 플레이어의 움직임, 총알의 움직임 및 랜덤 발사 요소나 오브젝트 풀, 시간 요소 등 구현할 수 있는 내용이 많아 보인다.

우리가 배우고 있는 것은 유니티3D이므로, 유니티3D 느낌으로의 닷지를 구현해보고자 한다.

1. 구현해야 할 내용 정리

닷지 게임을 위해 구현해야 할 내용이 무엇이 있을지 생각해보자.

  1. Map
  • 맵은 Plane과 사방향을 막는 벽 - 일정한 공간이 필요하다.
  1. Player
  • 플레이어의 이동 구현
  • 플레이어와 총알이 충돌 시 총알이 비활성화됨
  1. Tower
  • 게임 시작시 총알 풀을 생성하고 시계방향으로 회전해야 한다.
  • 플레이어가 감지 범위 내에 있다면, 플레이어를 바라보고 포구의 위치부터 총알을 발사한다.
  1. Bullet
  • 총알은 플레이어를 향해 날아가야 한다.
  • 지정 거리를 이동하거나, 벽 또는 플레이어와 부딪히면 비활성화됨
  1. GameManager
  • 게임의 진행과 종료 판정 / 처리
  • 점수 기록 등

2. 맵의 구현

이번 닷지 게임의 경우 에셋 없이 만들 예정이며, 맵과 캐릭터, 타워, 총알 모두 직접 만들 예정이다.

맵을 만드는 과정에 대해서는 특이사항이 없으니 간단하게만 정리하고 넘어간다.

  1. Plane을 적당한 사이즈로 깐다.
  2. Cube 4개로 전체적으로 둘러싸는 벽을 만든다. Material - WallMat을 만들어 벽을 검은색으로 표시했다.
  3. Player는 캡슐 형태로 만들었다. Material - PlayerMat을 만들어 플레이어를 빨간색으로 표시했다.
  4. Tower를 큰 Cylinder 하나와 대포 Muzzle을 작은 Cylinder로 만들었다. Material - TowerMat을 만들어 파란색으로 표시했다.
  5. Bullet을 Sphere로 만들었다. Material - BulletMat을 만들어 노란색으로 표시했으며, Metallic 특성을 높여 금속 느낌이 나도록 만들었다.

3. Player 구현

플레이어에 있어 제일 먼저 구현해야 할 사안은, 플레이어의 이동을 구현하는 것이다.

플레이어의 이동을 구현하는 방법은 여러가지 방법이 있으며, 아래와 같이 정리할 수 있다고 할 수 있다.

  1. Transform을 직접 이동시키기
  2. AddForce로 이동시키기
  3. GetAxis + Rigidbody로 구현하기

이 외에도 구현 방법이 여러가지 있겠으나, 여기서 Transform으로 이동시키는 경우, 벽을 Collision으로 해서 플레이어의 이탈을 막을 수도 있겠지만 직접 좌표를 이동시키는 경우의 리스크가 있다. 벽에 붙어서 드드드득 하는 움직임을 보일 수 있으므로, 이번 과제에서는 3번을 이용해 구현해 보기로 했다.

여기서 생각해보아야 할 것이 있다.

  • 플레이어의 이동이란, 플레이어의 이동 방향을 입력받고 실제 이동을 반영할 것이다. 하지만 물리적인 이동에 관해서는 FixedUpdate로 처리하는 것이 좋다. 왜냐하면 컴퓨터의 성능에 따라 이동 거리가 달라지는 현상을 볼 수도 있기 때문이다.
    (다만 플레이어의 이동 입력 자체에서의 컴퓨터 간 성능 차이로, 입력값이 다를 수도 있는 부분은 하드웨어적인 문제라 프로그래머가 해결할 수 없는 부분이다.)

이에 따라, 입력받는 함수와 이동하는 함수를 분리하여 구현하고, 각각 Update와 FixedUpdate로 넣기로 했다.

[SerializeField] private Rigidbody rigid;
[SerializeField] private float playerSpeed;

private Vector3 inputVec;

void Start()
{
	// 프로그래머의 실수로 Rigidbody가 들어가지 않았을 경우를 방지
    if (rigid == null)
    rigid = GetComponent<Rigidbody>();
}

void Update()
{
    PlayerInput();
}

private void FixedUpdate()
{
    Move();        
}

private void PlayerInput()
{
    float x = Input.GetAxisRaw("Horizontal");
    float z = Input.GetAxisRaw("Vertical");

    inputVec = new Vector3(x, 0, z).normalized;
}   

private void Move()
{
    rigid.velocity = inputVec * playerSpeed;
}

이와 같이 작성하고 플레이어가 움직이는 도중 넘어지거나 굴러가지 않도록, Rigidbody Constraints를 Rotation 전부와 Position.y 를 고정시킨다.

4. Tower 구현

4.0 구현하기에 앞서

타워를 만들 때 Muzzle의 위치에 주의하도록 하자.

이와 같이 Muzzle이 X축 혹은 Z축을 향해 있어야 하며, 그 외의 각도로 꽃아놓으면 나중에 플레이어로 총구를 향할 때 이상하게 출력되는 현상을 볼 수 있다.

Tower는 아래와 같은 특성을 가진 것으로 구현하기로 했다.

  • Tower는 Player 탐지 거리가 있으며, Player를 탐지하지 못했을 경우 제자리에서 Muzzle만 돈다.
  • Tower가 Player를 감지했을 경우, Player를 향해 총알을 발사한다.
  • Tower는 BulletPool을 가져야 한다. << 총일 구현 후 구현 예정

4.1 타워 돌리기

우선은 타워가 돌아가는 것부터 구현해보자.
타워가 돌아가는 것 자체는 간단하다.

[SerializeField] private float rotSpeed;

...

private void TowerRotate()
{
    transform.Rotate(Vector3.up, rotSpeed * Time.deltaTime);
}

4.2 플레이어 탐지하고, 방향 돌리기

그 다음 플레이어의 탐지 범위를 설정해야 하는데, 이걸 위해서 사용할 함수에 대해 알아보고자 한다.

  • Physics.OverlapSphere()
    중점과 반지름으로 가상의 원을 만들어 추출하려는 반경 이내에 들어와 있는 Collider들을 반환하는 함수

여기에서 layerMask로 Player가 있는지를 판별하고, 해당 bool값이 있는지를 확인하여 플레이어 탐지 여부를 결정할 수 있다.

[SerializeField] private float detectRadius;
[SerializeField] private LayerMask playerLayer;
[SerializeField] private Transform targetPos;

...

private void DetectPlayer()
{
	// 해당 함수의 결과값은 배열로 나오게 되며, 배열에 플레이어가 있을 경우(0보다 클 경우) 플레이어 탐지
	if(Physics.OverlapSphere(transform.position, detectRadius, playerLayer).Length > 0)
    {
    	transform.LookAt(targetPos);
    }
}

// 가시성을 위해 기즈모 설정
private void OnDrawGizmos()
{
    Gizmos.color = Color.black;
    Gizmos.DrawWireSphere(transform.position, detectRadius);
}

다만 이와 같이 코드를 작성했을 때 타워가 기울어지는 것을 확인할 수 있다.

이런 부분을 방지하기 위해서, targetPos를 바로 향하는 것이 아닌, 변수를 추가로 만들어준다.

Vector3 lookPos = new Vector3(targetPos.position.x, transform.position.y, targetPos.position.z);
transform.LookAt(lookPos);

이와 같이 작성하면 기즈모도 잘 보이고, 총구도 알맞게 돌아가는 것을 확인할 수 있다.

5. Bullet의 구현과 총알 발사 구현

이후에 오브젝트 풀을 만들면 해당 내용을 수정해야 하지만, 당장 구동이 되는지부터 확인하기 위해 Bullet 스크립트를 간단하게 구현했다.

[SerializeField] private Rigidbody rigid;
[SerializeField] private float firePower;


private void Start()
{
    rigid.AddForce(transform.forward * firePower, ForceMode.Impulse);
}

이와 같이 작성하고, Tower에 Fire 함수를 만들자.

[SerializeField] private GameObject bulletPrefeb;
[SerializeField] private Transform muzzlePos;
[SerializeField] private float bulletTime;

...

private void Fire()
{
    GameObject bullet = Instantiate(BulletPrefeb, muzzlePos.position, Quaternion.identity);
    Destroy(bullet, bulletTime);
}

다만 이와 같이 작성하였을 경우 두 가지 문제점이 있다.

  • 총알이 쿨타임 없이 발사되다 보니 엄청난 양이 발사된다.
  • 총알이 월드스페이스 기준의 Forward - 위쪽으로 날아간다.

이와 같은 문제를 해결하기 위해 Coroutine으로 총알의 발사 시간에 지연을 줄 필요가 있다.

...

[SerializeField] private GameObject bulletPrefeb;
[SerializeField] private Transform muzzlePos;
[SerializeField] private float bulletTime;
// 총알의 발사 주기를 설정
[SerializeField] private float fireRate;

// 코루틴을 담아 할당과 할당해제
private Coroutine fireCoroutine;
// 코루틴 내에서의 총알 발사 지연시간을 하나만 생성(최적화용)
private YieldInstruction fireDelay;

void Start()
{
    fireDelay = new WaitForSeconds(fireRate);
}

void Update()
{
    DetectPlayer();
}

private void DetectPlayer()
{
    if(Physics.OverlapSphere(transform.position, detectRadius, playerLayer).Length > 0)
    {
        Vector3 lookPos = new Vector3(targetPos.position.x, transform.position.y, targetPos.position.z);
        transform.LookAt(lookPos);
        if (fireCoroutine == null)
        {
            fireCoroutine = StartCoroutine(Fire());
        }
    }
    else
    {
        TowerRotate();
        if (fireCoroutine != null)
        {
            StopCoroutine(fireCoroutine);
            fireCoroutine = null;
        }
    }
}

private IEnumerator Fire()
{
    while (true)
    {
        GameObject bullet = Instantiate(BulletPrefeb, muzzlePos.position, Quaternion.identity);
    	Destroy(bullet, bulletTime);
        yield return fireDelay;
    }
}

6. Bullet의 오브젝트 풀 패턴 구현

오브젝트 풀 패턴을 구현하는 방법은 여러가지가 있지만, 저번의 List 구현 방법과 달리 이번에는 Stack을 통해 구현해보려고 한다.
Stack을 통해 오브젝트 풀 패턴을 구현한다고 하면, Push와 Pop을 반복하는 방식으로 구현해야 할 것이다.

우선 Bullet에 ReturnPool을 구현하자.

...

public Stack<GameObject> returnPool;

private void OnEnable()
{
    rigid.AddForce(transform.forward * firePower, ForceMode.Impulse);
    StartCoroutine(ReturnPool(3f));
}

private IEnumerator ReturnPool(float delay = 0f)
{
    yield return new WaitForSeconds(delay);
    gameObject.SetActive(false);
    rigid.velocity = Vector3.zero;
    returnPool.Push(gameObject);
}

다음으로 TowerController 코드에 Stack을 추가하자.

...

private Stack<GameObject> bulletPool;
[SerializeField] private int poolSize;

void Start()
{
    fireDelay = new WaitForSeconds(fireRate);
    bulletPool = new Stack<GameObject>();
    for(int i = 0; i < poolSize; i++)
    {
        GameObject obj = Instantiate(bulletPrefeb);
        obj.GetComponent<Bullet>().returnPool = bulletPool;
        // 총알을 생성할 당시 비활성화한다.
        obj.SetActive(false);
        bulletPool.Push(obj);
    }
}

...

private IEnumerator Fire()
{
    while (true)
    {
        GameObject bullet = bulletPool.Pop();
        bullet.transform.position = muzzlePos.position;
        bullet.transform.forward = transform.forward;
        bullet.SetActive(true);
        yield return fireDelay;
    }
}

7. 게임매니저의 구현과 게임오버 구현

간단하게 싱글톤 패턴으로 만들 수 있는 게임매니저를 구현해보자.

public static GameManager Instance;

public bool IsFinished;

public GameObject playerObject;

private void Awake()
{
    if (Instance == null)
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }
    else
    {
        Destroy(gameObject);
    }
}


private void GameStart()
{
    IsFinished = false;
}

public void GameOver()
{
    playerObject.SetActive(false);
    IsFinished = true;
}

게임오버를 판정할 bool 변수를 추가하고, 플레이어에 체력과 게임오버를 구현하자.

private int playerHealth = 5;

...

private void OnTriggerEnter(Collider other)
{
    if(other.CompareTag("Bullet"))
    {
        TakeHit();
    }
}

private void TakeHit()
{
    playerHealth--;
    if(playerHealth <= 0)
    {
        GameManager.Instance.GameOver();
    }
}

이와 같이 만들면 이제 게임 느낌이 나는 닷지가 완성되기는 하나, 게임의 시작과 종료만 하는 밋밋한 게임이 되고 마니, 게임 시작 버튼과 재시작 버튼을 구현해보고자 한다.


게임 시작과 종료를 위한 패널, 그리고 버튼을 구현해준다.

  • 시작 패널

  • 종료 패널

이제 게임매니저를 통해 게임의 시작과 진행, 종료 중에 해당 패널이 꺼지거나 켜지도록 코드를 추가한다.

public static GameManager Instance;

public bool IsFinished;

// public event Action OnPlayerDied;
public UnityEvent OnPlayerDied;

public GameObject playerObject;

[SerializeField] private Transform spawnPos;

[Header("UI")]
[SerializeField] private GameObject gameStartPanel;
[SerializeField] private Button startBtn;
[SerializeField] private GameObject gameOverPanel;
[SerializeField] private Button retryBtn;

private void Awake()
{
    if (Instance == null)
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }
    else
    {
        Destroy(gameObject);
    }
}

private void Init()
{
    IsFinished = true;
    playerObject.SetActive(false);
    gameOverPanel.SetActive(false);
    ingameTimeSpan.SetActive(false);
    hpBar.SetActive(false);
}

private void GameStart()
{
    gameStartPanel.SetActive(false);
    gameOverPanel.SetActive(false);
    IsFinished = false;
    playerObject.transform.position = spawnPos.position;
    playerObject.SetActive(true);
    ingameTimeSpan.SetActive(true);
    hpBar.SetActive(true);
    playedTime = 0;
}

// Listener를 통해 버튼을 누를 시 해당 함수가 실행됨
private void OnEnable()
{
    Init();
    OnPlayerDied.AddListener(GameOver);
    startBtn.onClick.AddListener(GameStart);
    retryBtn.onClick.AddListener(GameStart);
}

public void GameOver()
{
    playerObject.SetActive(false);
    IsFinished = true;
    gameOverPanel.SetActive(true);
}

private void OnDisable()
{
    OnPlayerDied.RemoveListener(GameOver);
    startBtn.onClick.RemoveListener(GameStart);
    retryBtn.onClick.RemoveListener(GameStart);
}

또한 이벤트를 이용하여 게임오버를 선언하고, 각각의 오브젝트들이 정지할 수 있도록 설정한다.

플레이어가 죽었을 시 게임 종료

  • 플레이어 정지
private void TakeHit()
{
    ParticleSystem particle = Instantiate(damageParticle);
    particle.transform.position = gameObject.transform.position;
    playerHealth--;
    if(playerHealth <= 0)
    {
    	// 게임 오버 시에 플레이어가 비활성화됨
        GameManager.Instance.OnPlayerDied.Invoke();
    }
}
  • 타워 정지
void Update()
{
	// 게임오버가 아닐 시에만 작동
    if(!GameManager.Instance.IsFinished)
    {
        DetectPlayer();
    }
}

8. 추가 구현 내용

이제 여기서 추가로 구현한 내용을 적어보고자 한다.

8.1 게임 오버 시에 생존 시간 추가 및 게임 중 생존 시간 표시

먼저 게임매니저에 생존 시간 기록을 위한 변수와 시간 흐름을 추가하였다.

...

// 생존 시간 기록
private static float playedTime;
public float PlayedTime { get { return playedTime; } }

...

private void GameStart()
{
    ...
    
    // 게임 재시작 시 초기화
    playedTime = 0;
}

private void Update()
{
	// 게임이 진행 중일 때만 시간을 측정
    if(!IsFinished) playedTime += Time.deltaTime;
}

다음으로 게임 진행 중 시간을 표기하기 위한 UI용 스크립트를 추가한다.

public class RunTimeUI : MonoBehaviour
{
    [SerializeField] TMP_Text textUI;

    private void FixedUpdate()
    {
        if (!GameManager.Instance.IsFinished)
        {
            TimeSpanUI(GameManager.Instance.PlayedTime);
        }
    }

    private void TimeSpanUI(float score)
    {
    	// ToString에서 텍스트의 표시형식을 정할 수 있음
        textUI.text = $"Score : {score.ToString("0.00")}";
    }    
}

마지막 게임 종료 후 점수를 표기하기 위한 UI용 스크립트도 추가한다. 해당 UI는 게임 종료 시에 활성화되기 때문에 따로 조건 없이 출력하면 된다.

public class FinalScoreText : MonoBehaviour
{
    [SerializeField] TMP_Text textUI;

    private void OnEnable()
    {
        SetScore(GameManager.Instance.PlayedTime);
    }

    private void SetScore(float score)
    {
        textUI.text =$"Score : {score.ToString("0.00")}";
    }
}

마지막으로 해당 UI들을 만들고, UI들의 활성화 여부를 다시 게임 매니저에서 처리한다. (중복되는 내용이 많아 게임매니저 최종 스크립트는 후에 첨부)

  • 인게임 시간 측정

  • 최종스코어

8.2 캐릭터 체력바 추가

캐릭터의 체력이 표기가 되고 있지 않아 불편하다고 생각했다. 해당 내용을 구현하기 위해 텍스트로 표기했다.
플레이어의 체력을 실시간으로 받을 수 있는 함수를 추가하고, 체력바용 UI 스크립트를 추가한다.

public class HPBarUI : MonoBehaviour
{
    [SerializeField] TMP_Text hpBar;

    private void Update()
    {
        HPBar();
    }

    private void HPBar()
    {
    	// 플레이어 체력을 가져오기 위해 PlayerHealthInfo 함수를 플레이어에 추가함.
        int health = GameObject.FindWithTag("Player").GetComponent<PlayerController>().PlayerHealtInfo();
        string curHealthbar = "";
        switch (health)
        {
            case 1: curHealthbar = "■"; break;
            case 2: curHealthbar = "■■"; break;
            case 3: curHealthbar = "■■■"; break;
            case 4: curHealthbar = "■■■■"; break;
            case 5: curHealthbar = "■■■■■"; break;
        }
        hpBar.text = $"Life : {curHealthbar}";
    }

인게임의 좌측 상단에서 이렇게 표기가 되는 것을 확인할 수 있다.

다만 플레이어의 체력을 표기하는 방법에서 좀 더 String을 덜 쓰는 방법에 대해 고민이 필요할 것 같다.

8.3 플레이어와 총알 충돌 시 파티클 효과 주기

파티클을 아직 배우기 전 단계이지만, 예습하는 느낌으로 미리 만들어보기로 했다.
특히나 플레이어가 총알과 부딪혔는지 잘 확인이 안 될 때가 많아서, 총알에 부딪혔을 때 효과를 넣어주는 게 좋아보였다.

파티클은 Effects > Particle System으로 만들 수 있다.

세부적으로 만질 수 있는 설정이 많지만, 이번에는 간단하게만 하고 넘어가려고 한다.
색과 지속시간, 파티클 개수와 터지는 모양 정도만 설정을 했고, 이를 플레이어가 피격 시에 터지게 하면 된다.

  • 파티클은 Instantiate로 바로 터뜨릴 수 없다.

이번에 작업하면서 배운 것으로, 파티클은 그냥 바로 Instantiate해서는 터지지 않는다는 것을 확인했다.
우선 파티클 시스템은 파티클 시스템이란 프리팹을 따로 만들어서 넣어야 하며, 파티클 시스템 변수를 따로 만들어서 해당 변수에 Instantiate을 넣고, 해당 파티클 시스템의 변수를 다시 설정해야지 의도대로 터뜨릴 수 있다는 걸 확인했다.

[SerializeField] private ParticleSystem damageParticle;

...

public int PlayerHealtInfo()
{
    return playerHealth;
}

private void TakeHit()
{
    ParticleSystem particle = Instantiate(damageParticle);
    particle.transform.position = gameObject.transform.position;
    playerHealth--;
    if(playerHealth <= 0)
    {
        GameManager.Instance.OnPlayerDied.Invoke();
    }
}

9. 1차 작업본 게임 플레이 영상

profile
게임 만들러 코딩 공부중

0개의 댓글