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(키)로 앱을 껐다 켜도 데이터가 유지됩니다. 최고 점수, 설정값 등 간단한 데이터 저장에 적합합니다. GameOver는DinoController의OnTriggerEnter2D에서 장애물 태그 충돌 시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를 깔끔하게 표시할 때 유용합니다. ScoreUiUpdate는DinoController의OnTriggerEnter2D에서 "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 + 점수 텍스트 + 재시작 버튼 배치, 초기 비활성화 |