공룡 달리기 2D - 장애물과 게임 매니저

싱글톤 GameManager로 장애물 스폰, 점수, 게임 오버를 관리하고 OnBecameInvisible로 오브젝트를 자동 삭제합니다

47번 튜토리얼에서 만든 공룡 점프와 배경 스크롤에 이어, 장애물 자동 생성·충돌·점수·게임 오버를 담당하는 GameManager와 장애물 이동·자동 삭제를 담당하는 ObstacleController를 학습합니다. 싱글톤 패턴, PlayerPrefs 저장, Time.timeScale, SceneManager.LoadScene, OnBecameInvisible 등 실전에서 자주 쓰이는 패턴을 다룹니다.


게임 구조

스크립트 역할
GameManager 싱글톤으로 씬 전체 관리 (스폰, 점수, 게임 오버)
ObstacleController 장애물 왼쪽 이동, 화면 밖 자동 삭제

ObstacleController - 장애물 이동과 자동 삭제

장애물 프리팹에 부착되며, 생성 즉시 왼쪽으로 이동하고 화면 밖으로 나가면 자신을 삭제합니다.

전체 코드

using UnityEngine;

public class ObstacleController : MonoBehaviour
{
    /// <summary>X축 이동 속도 (음수 = 왼쪽)</summary>
    public float moveSpeedX;

    private Rigidbody2D rb;

    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    void Update()
    {
        rb.velocity = new Vector2(moveSpeedX, 0);
    }

    private void OnBecameInvisible()
    {
        Destroy(gameObject);
    }
}

핵심 개념 설명

1. Rigidbody2D.velocity로 이동

rb.velocity = new Vector2(moveSpeedX, 0);
  • velocity를 직접 설정: AddForce는 누적 힘이라 속도가 가변적이지만, velocity를 매 프레임 덮어쓰면 항상 일정한 속도를 유지합니다.
  • Y = 0 고정: 장애물이 중력의 영향을 받지 않고 수평 이동만 하도록 Y 속도를 0으로 고정합니다. (Rigidbody2D의 Gravity Scale을 0으로 설정해도 같은 효과)
  • moveSpeedX를 음수로 설정하면 왼쪽 방향으로 이동합니다.

2. OnBecameInvisible - 화면 밖 자동 삭제

private void OnBecameInvisible()
{
    Destroy(gameObject);
}
  • OnBecameInvisible: 이 오브젝트의 Renderer가 모든 카메라 화면 밖으로 완전히 벗어날 때 Unity가 자동으로 호출합니다.
  • 반대 이벤트(event) OnBecameVisible은 화면 안으로 들어올 때 호출됩니다.
  • 직접 경계값을 체크하는 코드 없이도 화면 밖 오브젝트를 정리할 수 있어, 메모리(memory) 낭비를 막는 간결한 방법입니다.
  • 주의: Scene 뷰(에디터 카메라)도 카메라로 인식되므로, 에디터에서 Scene 뷰가 열려 있으면 게임 카메라 밖으로 나가도 이 이벤트(event)가 늦게 발생할 수 있습니다. 빌드 환경에서는 정상 동작합니다.

GameManager - 게임 전체 관리

싱글톤으로 구현되어 씬 내 어디서든 GameManager.instance로 접근할 수 있습니다. 장애물 스폰, 점수 관리, 게임 오버·재시작을 담당합니다.

전체 코드

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using UnityEngine.SceneManagement;

public class GameManager : MonoBehaviour
{
    public static GameManager instance;

    public Transform[] spawnPoints;
    public GameObject[] obstacles;
    public float spawnDelay;
    private float spawnTimer;
    public bool isSpawning;
    private int spawnTracker;

    public int mainScore;
    public TextMeshProUGUI mainScoreText;
    public GameObject gameOverPanel;
    public TextMeshProUGUI bestScoreText;
    public TextMeshProUGUI endScoreText;

    public AudioClip explosionSound;
    public AudioClip pickupCoinSound;
    public AudioSource SE;

    void Awake()
    {
        if (instance != null && instance != this)
        {
            Destroy(this.gameObject);
        }
        else
        {
            instance = this;
        }
    }

    void Start()
    {
        SE = GetComponent<AudioSource>();
    }

    void Update()
    {
        if (isSpawning)
        {
            spawnTimer += Time.deltaTime;
            if (spawnTimer >= spawnDelay)
            {
                spawnTimer = 0;
                SpawnObstacle();
            }
        }
    }

    public void GameOver()
    {
        SE.clip = explosionSound;
        SE.Play();
        Time.timeScale = 0;

        if (mainScore > PlayerPrefs.GetInt("BestScore"))
        {
            PlayerPrefs.SetInt("BestScore", mainScore);
        }

        bestScoreText.text = "Best Score : " + PlayerPrefs.GetInt("BestScore").ToString();
        endScoreText.text = "Score : " + mainScore.ToString();
        gameOverPanel.SetActive(true);
    }

    public void GameRestart()
    {
        Time.timeScale = 1;
        SceneManager.LoadScene("GameScene");
    }

    public void ScoreUiUpdate()
    {
        SE.clip = pickupCoinSound;
        SE.Play();
        mainScore++;
        mainScoreText.text = "스코어 : " + mainScore.ToString("D5");
    }

    void SpawnObstacle()
    {
        spawnTracker = Random.Range(0, obstacles.Length);

        switch (spawnTracker)
        {
            case 0:
                Instantiate(obstacles[0], spawnPoints[0].position, Quaternion.identity);
                break;
            case 1:
                Instantiate(obstacles[1], spawnPoints[1].position, Quaternion.identity);
                break;
            case 2:
                Instantiate(obstacles[2], spawnPoints[1].position, Quaternion.identity);
                break;
            case 3:
                Instantiate(obstacles[3], spawnPoints[1].position, Quaternion.identity);
                break;
            case 4:
                Instantiate(obstacles[4], spawnPoints[Random.Range(2, 4)].position, Quaternion.identity);
                break;
        }

        spawnDelay = Random.Range(1f, 3f);
    }
}

핵심 개념 설명

1. 싱글톤 패턴 - Awake

void Awake()
{
    if (instance != null && instance != this)
    {
        Destroy(this.gameObject);
    }
    else
    {
        instance = this;
    }
}
  • static GameManager instance: 클래스(class) 인스턴스(instance)를 정적 변수(variable)에 저장해, GameManager.instance로 씬 어디서든 접근할 수 있습니다.
  • Awake: Start보다 먼저 실행되므로, 다른 스크립트의 Start에서 GameManager.instance를 호출해도 안전합니다.
  • 씬을 재로드하거나 오브젝트가 중복 생성되는 경우, 두 번째 인스턴스(instance)를 즉시 Destroy해 싱글톤이 하나만 유지되도록 합니다.

2. 장애물 스폰 - Instantiate와 spawnDelay

void Update()
{
    if (isSpawning)
    {
        spawnTimer += Time.deltaTime;
        if (spawnTimer >= spawnDelay)
        {
            spawnTimer = 0;
            SpawnObstacle();
        }
    }
}
  • Update에서 Time.deltaTime을 누적해 타이머를 구현합니다. Invoke나 코루틴 없이 간단하게 주기 실행을 만들 수 있습니다.
  • SpawnObstacle 호출 후 spawnDelay = Random.Range(1f, 3f)로 다음 간격을 랜덤 재설정해, 장애물 등장 패턴이 규칙적이지 않도록 합니다.
Instantiate(obstacles[0], spawnPoints[0].position, Quaternion.identity);
  • Instantiate(프리팹, 위치, 회전): 프리팹의 복사본을 씬에 생성합니다.
  • Quaternion.identity: 회전 없이 기본 방향으로 생성합니다.
  • 장애물 종류(선인장 4종 + 새)에 따라 스폰 위치를 switch-case로 구분합니다. 새(case 4)는 공중 높이에 따라 2~3번 스폰 포인트 중 하나를 랜덤으로 선택합니다.

3. 게임 오버 - Time.timeScale과 PlayerPrefs

public void GameOver()
{
    Time.timeScale = 0;

    if (mainScore > PlayerPrefs.GetInt("BestScore"))
    {
        PlayerPrefs.SetInt("BestScore", mainScore);
    }

    bestScoreText.text = "Best Score : " + PlayerPrefs.GetInt("BestScore").ToString();
    endScoreText.text = "Score : " + mainScore.ToString();
    gameOverPanel.SetActive(true);
}
  • Time.timeScale = 0: 게임 시간을 멈춥니다. Update, 물리 연산, 코루틴(WaitForSeconds)이 모두 정지하지만 UI 이벤트(event)는 정상 동작합니다.
  • PlayerPrefs: Unity가 제공하는 영구 저장소입니다. SetInt(키, 값) / GetInt(키) 로 앱을 껐다 켜도 데이터가 유지됩니다. 최고 점수, 설정값 등 간단한 데이터 저장에 적합합니다.
  • GameOverDinoControllerOnTriggerEnter2D에서 장애물 태그 충돌 시 GameManager.instance.GameOver()로 호출됩니다.

4. 게임 재시작 - SceneManager.LoadScene

public void GameRestart()
{
    Time.timeScale = 1;
    SceneManager.LoadScene("GameScene");
}
  • Time.timeScale = 1: 씬 로드 전에 반드시 복구해야 합니다. timeScale이 0인 채로 씬을 로드하면 새 씬에서도 시간이 멈춘 상태가 됩니다.
  • SceneManager.LoadScene("씬 이름"): 씬 이름으로 씬을 다시 로드합니다. 씬 이름은 Unity Editor의 Build Settings에 등록되어 있어야 합니다.
  • using UnityEngine.SceneManagement; 네임스페이스(namespace)를 추가해야 사용할 수 있습니다.

5. 점수 업데이트 - ToString("D5")

public void ScoreUiUpdate()
{
    mainScore++;
    mainScoreText.text = "스코어 : " + mainScore.ToString("D5");
}
  • ToString("D5"): 정수(integer)를 최소 5자리 문자열(string)로 변환합니다. 숫자가 5자리 미만이면 앞을 0으로 채웁니다 (예: 3"00003"). 점수 UI를 깔끔하게 표시할 때 유용합니다.
  • ScoreUiUpdateDinoControllerOnTriggerEnter2D에서 "Point" 태그 오브젝트와 충돌 시 GameManager.instance.ScoreUiUpdate()로 호출됩니다.

DinoController와의 연동

DinoController의 충돌 처리에서 GameManager.instance를 통해 호출합니다:

private void OnTriggerEnter2D(Collider2D collision)
{
    if (collision.gameObject.CompareTag("Obstacle"))
    {
        anim.SetTrigger("isDie");
        GameManager.instance.GameOver();   // 게임 오버
    }
    else if (collision.gameObject.CompareTag("Point"))
    {
        GameManager.instance.ScoreUiUpdate();  // 점수 획득
    }
}
  • CompareTag: gameObject.tag == "Obstacle"보다 빠르고 오타 오류도 Inspector에서 잡을 수 있어 권장됩니다.
  • 장애물 태그: 각 장애물 프리팹에 "Obstacle" 태그를 지정해 두어야 합니다.
  • 점수 트리거: 장애물 앞쪽에 Is Trigger가 설정된 얇은 Collider를 두고 "Point" 태그를 붙이면, 공룡이 통과할 때 점수가 올라갑니다.

Unity 씬 설정 요약

항목 권장 설정
GameManager 오브젝트 빈 GameObject에 GameManager.cs 부착, AudioSource 추가
spawnPoints 지상 2곳 + 공중 2곳, 총 4개의 빈 Transform을 Inspector에서 연결
obstacles 선인장 4종 + 새 프리팹 5개를 Inspector에서 연결
장애물 프리팹 Rigidbody2D + Collider2D + ObstacleController 부착, "Obstacle" 태그 설정
점수 트리거 장애물 앞에 Is Trigger Collider, "Point" 태그 설정
게임 오버 패널 Canvas 자식에 Panel + 점수 텍스트 + 재시작 버튼 배치, 초기 비활성화