본문으로 건너뛰기
Unity C# 기초집LESSON 24

최종 실습: 2D 택배 분류 게임

난이도초급
예상 시간120분
선수지식1~23강 결과물

24강. 최종 실습: 2D 택배 분류 게임

1. 이번 강의에서 해결할 문제​

각 기능은 따로 동작하지만 택배 생성부터 분류, 점수·목숨, 게임 오버와 결과 화면까지 하나의 플레이 흐름으로 연결하고 경계 상황을 검증해야 합니다.

2. 학습 목표​

3. 핵심 개념​

최종 구조는 한 거대한 GameManager가 아니라 다음 흐름으로 연결됩니다.

PackageSpawner --Get--> PackagePool --Activate--> PackageItem
│ │
└─ PackageTypeSelector └─ Trigger 도착
│
SortZone / MissZone ──결과 요청──> GameSession ──event──> HUD / Feedback / SceneFlow
  • Inspector: Prefab, 데이터 에셋, Zone 위치, 기본 수치 연결
  • C#: 생성 제한, 이동, 판정, 점수·목숨과 상태 전환
  • 이벤트: GameSession과 UI·피드백의 결합 제거
  • 멀티플레이, 서버 저장, 광고 SDK는 이 과정 범위 밖입니다.

4. 단계별 실습​

시작 전 상태​

  • Unity Editor Console의 컴파일 오류가 0개여야 합니다. Warning은 원인과 허용 이유를 기록합니다.
  • 1~23강에서 만든 PackageItem, PackageMotor2D, Pool, Selector, GameSession, HUD와 저장 기능을 각각 단독으로 확인합니다.
  • 최종 Scene을 합치기 전에 현재 Game Scene과 Prefab을 Git으로 되돌릴 수 있는지 확인합니다.
  • Inspector 참조를 아직 모두 연결하지 말고, 아래 수직 조각 순서에 맞춰 필요한 시스템만 하나씩 추가합니다.

가장 작은 수직 조각과 구현 순서​

첫 통과 목표는 “파란 택배 한 개가 생성되어 이동하고 파란 Zone에서 점수 1회를 만든 뒤 Pool로 돌아간다”입니다.

게이트연결할 기능다음 단계로 가기 전 확인 결과
1BluePackage 데이터 + Package PrefabPool에서 꺼낸 한 개가 파란 Sprite·Color로 초기화됩니다.
2PackageMotor2D1초 뒤 Y가 설정 속도만큼 감소합니다.
3BlueZone + SortZone올바른 충돌에서 점수가 정확히 한 번 증가하고 Package가 반환됩니다.
4OrangePackage + OrangeZone + Selector두 종류가 각 Zone에서 다른 결과를 냅니다.
5Spawner Coroutine + 연속 제한일정 간격으로 생성되고 같은 종류가 4회 연속 나오지 않습니다.
6MissZone + 목숨 + GameOver놓침 1회당 목숨 1 감소, 0에서 입력·생성이 멈춥니다.
7HUD·Feedback·저장·재시작현재·최고 점수와 화면 상태가 Session과 일치합니다.

Gate 3이 실패한 상태에서 Spawner와 난이도까지 켜면 여러 택배가 오류를 반복해 첫 원인을 찾기 어렵습니다. 테스트용 한 개를 직접 Pool에서 꺼내는 흐름부터 고정합니다.

4-1. 최종 폴더와 Scene 구성​

Assets/DeliverySorter/
├─ Data/PackageTypes/{Blue,Orange}.asset
├─ Prefabs/Package.prefab
├─ Scenes/{Game,Result}.unity
└─ Scripts/
├─ Data/PackageTypeData.cs
├─ Packages/{PackageItem,PackageMotor2D}.cs
├─ Spawning/{PackagePool,PackageSpawner,PackageTypeSelector}.cs
├─ Zones/{SortZone,MissZone}.cs
├─ Game/{GameSession,SceneFlow}.cs
├─ UI/HUDPresenter.cs
└─ Feedback/SortFeedback.cs

Unity Editor·Inspector 작업

  1. Package Prefab에 SpriteRenderer, Rigidbody2D, BoxCollider2D, PackageItem과 PackageMotor2D를 둡니다.
  2. Rigidbody2D는 Dynamic, Gravity Scale 0, Freeze Rotation Z로 설정합니다.
  3. BlueZone·OrangeZone·MissZone의 Collider2D는 Is Trigger로 둡니다.
  4. PackagePool에 Prefab, PackageSpawner에 Pool·SpawnPoint·두 데이터 에셋을 연결합니다.
  5. GameSession과 HUDPresenter, SortFeedback 참조를 연결합니다.

4-2. 최종 분류와 놓침 판정​

C# · 파일 경로: Assets/DeliverySorter/Scripts/Zones/SortZone.cs

SortZone.cs
using UnityEngine;

public sealed class SortZone : MonoBehaviour
{
[SerializeField] private PackageTypeData acceptedType;
[SerializeField] private GameSession session;

private void Awake()
{
if (acceptedType == null || session == null)
Debug.LogError("SortZone reference is missing.", this);
}

private void OnTriggerEnter2D(Collider2D other)
{
if (!other.TryGetComponent(out PackageItem package)) return;
if (package.IsResolved || package.Type == null) return;

session.RegisterSort(package.Type, acceptedType);
package.ResolveAndRelease();
}
}

C# · 파일 경로: Assets/DeliverySorter/Scripts/Zones/MissZone.cs

MissZone.cs
using UnityEngine;

public sealed class MissZone : MonoBehaviour
{
[SerializeField] private GameSession session;

private void OnTriggerEnter2D(Collider2D other)
{
if (!other.TryGetComponent(out PackageItem package)) return;
if (package.IsResolved) return;
session.RegisterFailure();
package.ResolveAndRelease();
}
}

판정 코드와 한 번만 처리되는 흐름​

  1. TryGetComponent는 Trigger에 들어온 Collider가 PackageItem인지 확인합니다. 다른 Collider는 게임 결과를 만들지 않습니다.
  2. SortZone은 이미 해결된 Package와 Type이 없는 잘못된 Package를 거부합니다.
  3. RegisterSort(actual, accepted)는 실제 종류와 Zone이 받는 종류를 GameSession 규칙에 전달합니다. Zone이 점수 숫자를 직접 더하지 않습니다.
  4. 결과 요청 뒤 ResolveAndRelease가 IsResolved를 먼저 고정하고 Pool에 반환해야 겹친 Trigger가 같은 Package를 다시 처리하지 못합니다.
  5. MissZone도 같은 IsResolved를 검사하므로 SortZone과 MissZone이 같은 물리 프레임에 겹쳐도 첫 결과만 인정됩니다.

손으로 검산할 기준을 먼저 정합니다. 시작 점수 0, 목숨 3이라고 가정합니다.

입력기대 점수기대 목숨Package 상태
Blue → BlueZone규칙만큼 증가3Resolved 후 Pool 반환
Blue → OrangeZone증가 없음2Resolved 후 Pool 반환
Blue → MissZone증가 없음2Resolved 후 Pool 반환
같은 Package가 Zone 두 개에 겹침첫 결과만 1회첫 결과만 1회 반영두 번째 Trigger 무시

4-3. 최종 생성 루프​

C# · 파일 경로: Assets/DeliverySorter/Scripts/Spawning/PackageSpawner.cs

PackageSpawner.cs
using System.Collections;
using UnityEngine;

public sealed class PackageSpawner : MonoBehaviour
{
[SerializeField] private PackagePool pool;
[SerializeField] private Transform spawnPoint;
[SerializeField] private PackageTypeSelector selector;
[SerializeField, Min(0.1f)] private float startInterval = 1.5f;
[SerializeField, Min(0.1f)] private float minInterval = 0.45f;

private Coroutine routine;
private float startedAt;

public void StartRun()
{
if (routine != null) return;
startedAt = Time.time;
routine = StartCoroutine(SpawnLoop());
}

public void StopRun()
{
if (routine == null) return;
StopCoroutine(routine);
routine = null;
}

private IEnumerator SpawnLoop()
{
while (true)
{
PackageTypeData type = selector.Next();
if (type != null) pool.Get(type, spawnPoint.position);

float minutes = (Time.time - startedAt) / 60f;
float interval = Mathf.Max(minInterval, startInterval - minutes * 0.2f);
yield return new WaitForSeconds(interval);
}
}
}

생성 루프의 시간·데이터 흐름​

  1. StartRun의 routine guard는 상태 이벤트가 중복돼도 Coroutine을 두 개 시작하지 않게 합니다.
  2. startedAt은 이번 Run의 시작 시간을 기록합니다. 새 Run에서만 다시 설정해야 하며 Pause 복귀를 새 Run처럼 처리하면 난이도 시간이 초기화됩니다.
  3. Selector는 종류 순서와 연속 제한을 담당하고 Spawner는 결과 Type을 소비할 뿐 랜덤 규칙을 복제하지 않습니다.
  4. Pool은 Type과 Spawn Position을 받아 비활성 Package를 완전히 초기화해 반환합니다.
  5. 1분마다 계산상 interval이 0.2초 줄어들지만 Mathf.Max가 0.45초 아래로 내려가지 않게 합니다.
  6. WaitForSeconds는 scaled time을 사용하므로 이 구현은 Pause에서 Time Scale 0으로 대기를 정지하고 GameOver에서 Coroutine을 종료합니다. Pause 때 Coroutine 자체를 중단하는 설계를 택하면 경과 시간을 별도로 누적합니다.

기본값의 손 계산은 다음과 같습니다.

Run 경과계산값실제 interval
0분1.5 - 01.5초
2분1.5 - 0.41.1초
5분1.5 - 1.00.5초
10분1.5 - 2.0최소값 적용, 0.45초

StateChanged를 구독하는 Binder가 Playing에서 StartRun, GameOver에서 StopRun을 호출합니다. Paused는 Time.timeScale = 0으로 scaled WaitForSeconds를 멈추고 입력을 비활성화하면 같은 Coroutine과 startedAt을 유지할 수 있습니다. 프로젝트 정책상 Pause에서도 StopRun을 호출한다면 Resume 때 startedAt을 새로 쓰지 말고 누적 플레이 시간을 별도로 보존해야 난이도가 처음부터 다시 시작되지 않습니다. GameOver에서는 입력도 비활성화하고 최고 점수를 저장한 뒤 결과 화면 버튼을 제공합니다.

4-4. 실행​

PowerShell · 실행 위치: DeliverySorter2D 프로젝트 루트

Get-ChildItem .\Assets\DeliverySorter\Scripts -Recurse -Filter *.cs
Get-Content .\ProjectSettings\ProjectVersion.txt

Unity Editor에서 Console을 비우고 Game Scene을 Play합니다. C# 컴파일과 실제 Play Mode 검증은 반드시 에디터에서 수행합니다.

Editor에서 관찰할 통합 결과​

시점Hierarchy·화면Console·데이터
Play 직후비활성 Pool 항목과 첫 활성 Package필수 참조 오류 없음
올바른 분류Package가 사라지고 성공 피드백 표시점수 이벤트 1회
오분류·놓침실패 피드백, 목숨 표시 감소Failure 이벤트 1회
Pause활성 Package와 생성 흐름 정지상태는 Paused, 새 Coroutine 없음
목숨 0GameOver UI, 입력 무반응Spawner routine null, 최고 점수 저장
Restart시작 상태와 새 점수·목숨이전 Resolved·속도·구독이 남지 않음

5. 코드가 동작하는 이유​

Spawner는 Coroutine으로 생성 시점만 관리하고 selector가 실제 타입을 결정해 같은 종류가 최대 세 번까지만 이어집니다. Pool은 인스턴스를 재사용하고 PackageItem은 한 번만 해결됩니다. Zone은 결과만 GameSession에 전달하며, GameSession 이벤트를 HUD와 피드백이 구독하므로 UI가 점수 규칙에 섞이지 않습니다.

구조 선택 근거

구조선택 이유
Update·FixedUpdate입력은 프레임, Rigidbody2D 이동은 물리 주기마다 필요
Coroutine생성 간격과 난이도 대기를 순서대로 표현
Object Pool빠르게 반복되는 택배 생성·제거 비용과 GC 할당 완화
ScriptableObject타입별 색상·점수를 Prefab 복제 없이 공유
event게임 규칙이 HUD와 사운드의 구체 구현을 모르게 함

6. 자주 하는 실수와 해결법​

7. 직접 실습​

따라 하기: 한 개 택배의 수직 흐름​

자동 Spawner를 잠시 끄고 Blue Package 한 개만 Pool에서 꺼냅니다. 이동 → BlueZone Trigger → RegisterSort → Resolve → Pool 반환 → HUD 갱신 순서를 로그와 Hierarchy 활성 상태로 확인합니다. 이 흐름이 통과한 뒤 Orange와 자동 생성을 켭니다.

값과 실패 바꿔 보기​

  1. BlueZone과 OrangeZone을 일부러 겹쳐 같은 Package가 두 Trigger에 들어가게 합니다. 점수 또는 목숨이 한 번만 변해야 합니다.
  2. startInterval을 0.2, minInterval을 0.1로 낮춰 빠른 생성에서 Pool 부족·중복 판정을 확인한 뒤 원래 값으로 복원합니다.
  3. PackagePool Prefab, SortZone Session, HUD TMP 참조를 하나씩 비워 어떤 Component의 어느 필드가 누락됐는지 오류가 구체적으로 나오는지 확인합니다.
  4. Pause 직전과 GameOver 직전에 생성 이벤트가 겹치는 상황을 반복해 routine과 입력이 남지 않는지 봅니다.

직접 확장​

세 번째 FragilePackage 데이터와 대응 Zone을 추가합니다. Package Prefab과 판정 코드에 종류별 if 분기를 추가하지 않고 데이터·Selector 목록·Zone acceptedType 연결만으로 동작하게 합니다. 연속 제한도 세 종류에서 그대로 유지되어야 합니다.

스스로 확인​

막혔을 때​

증상확인할 흐름해결
Trigger가 전혀 호출되지 않음Collider2D·Is Trigger·Rigidbody2D·Layer Matrix최소 한쪽 Rigidbody2D와 두 Collider 활성, 물리 Layer 충돌을 확인합니다.
점수가 두 번 증가IsResolved를 고정하는 시점과 Zone 중첩결과 요청 전에 원자적으로 해결 상태를 바꾸도록 Resolve 흐름을 점검합니다.
Pool 재사용 뒤 이전 색·속도 유지Get/Activate 초기화 누락Type, Sprite, Color, 속도, IsResolved를 매번 초기화합니다.
Pause·GameOver 뒤 계속 생성Coroutine Handle 또는 State Binder 중복StartRun guard, StopRun, 이벤트 구독·해제를 로그로 확인합니다.
재시작 뒤 HUD가 여러 번 갱신Presenter 이벤트 중복 구독OnEnable·OnDisable 대칭과 중복 인스턴스를 확인합니다.

완료 뒤 확장 방향​

  • 모바일 입력: Input System Touch Action, UI 위 터치 차단, 진동 피드백
  • 성능: Sprite Atlas, 실제 기기 Profiler, 목표 FPS·메모리 예산
  • 광고: 게임 규칙과 SDK Adapter 분리, 테스트 광고 ID와 개인정보 정책
  • 출시: Android Build Profile, 서명 키 안전 보관, 실제 기기·스토어 점검

Android 도구와 스토어 요구 사항은 바뀔 수 있으므로 구현 시점의 Unity Android 빌드 문서와 대상 스토어 공식 정책을 확인합니다.

8. 이해 점검 질문 3개​

9. 핵심 요약​

MINI QUIZ

최종 실습: 2D 택배 분류 게임 미니 퀴즈

선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.

0 / 2
  1. 문제 1“최종 실습: 2D 택배 분류 게임” 기능을 확장하기 좋은 구조로 설명한 것은 무엇인가요?
  2. 문제 2“최종 실습: 2D 택배 분류 게임” 실습 중 ‘Update에서 Find 반복’ 상황을 발견했습니다. 본문과 일치하는 설명은 무엇인가요?
LESSON STATUS

학습을 마쳤나요?

직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.

24강. 최종 실습: 2D 택배 분류 게임 미완료 상태