최종 실습: 2D 택배 분류 게임
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로 돌아간다”입니다.
| 게이트 | 연결할 기능 | 다음 단계로 가기 전 확인 결과 |
|---|---|---|
| 1 | BluePackage 데이터 + Package Prefab | Pool에서 꺼낸 한 개가 파란 Sprite·Color로 초기화됩니다. |
| 2 | PackageMotor2D | 1초 뒤 Y가 설정 속도만큼 감소합니다. |
| 3 | BlueZone + SortZone | 올바른 충돌에서 점수가 정확히 한 번 증가하고 Package가 반환됩니다. |
| 4 | OrangePackage + OrangeZone + Selector | 두 종류가 각 Zone에서 다른 결과를 냅니다. |
| 5 | Spawner Coroutine + 연속 제한 | 일정 간격으로 생성되고 같은 종류가 4회 연속 나오지 않습니다. |
| 6 | MissZone + 목숨 + GameOver | 놓침 1회당 목숨 1 감소, 0에서 입력·생성이 멈춥니다. |
| 7 | HUD·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 작업
- Package Prefab에 SpriteRenderer, Rigidbody2D, BoxCollider2D, PackageItem과 PackageMotor2D를 둡니다.
- Rigidbody2D는 Dynamic, Gravity Scale 0, Freeze Rotation Z로 설정합니다.
- BlueZone·OrangeZone·MissZone의 Collider2D는 Is Trigger로 둡니다.
- PackagePool에 Prefab, PackageSpawner에 Pool·SpawnPoint·두 데이터 에셋을 연결합니다.
- GameSession과 HUDPresenter, SortFeedback 참조를 연결합니다.
4-2. 최종 분류와 놓침 판정
C# · 파일 경로: Assets/DeliverySorter/Scripts/Zones/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
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();
}
}
판정 코드와 한 번만 처리되는 흐름
TryGetComponent는 Trigger에 들어온 Collider가 PackageItem인지 확인합니다. 다른 Collider는 게임 결과를 만들지 않습니다.- SortZone은 이미 해결된 Package와 Type이 없는 잘못된 Package를 거부합니다.
RegisterSort(actual, accepted)는 실제 종류와 Zone이 받는 종류를 GameSession 규칙에 전달합니다. Zone이 점수 숫자를 직접 더하지 않습니다.- 결과 요청 뒤
ResolveAndRelease가IsResolved를 먼저 고정하고 Pool에 반환해야 겹친 Trigger가 같은 Package를 다시 처리하지 못합니다. - MissZone도 같은
IsResolved를 검사하므로 SortZone과 MissZone이 같은 물리 프레임에 겹쳐도 첫 결과만 인정됩니다.
손으로 검산할 기준을 먼저 정합니다. 시작 점수 0, 목숨 3이라고 가정합니다.
| 입력 | 기대 점수 | 기대 목숨 | Package 상태 |
|---|---|---|---|
| Blue → BlueZone | 규칙만큼 증가 | 3 | Resolved 후 Pool 반환 |
| Blue → OrangeZone | 증가 없음 | 2 | Resolved 후 Pool 반환 |
| Blue → MissZone | 증가 없음 | 2 | Resolved 후 Pool 반환 |
| 같은 Package가 Zone 두 개에 겹침 | 첫 결과만 1회 | 첫 결과만 1회 반영 | 두 번째 Trigger 무시 |
4-3. 최종 생성 루프
C# · 파일 경로: Assets/DeliverySorter/Scripts/Spawning/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);
}
}
}
생성 루프의 시간·데이터 흐름
StartRun의 routine guard는 상태 이벤트가 중복돼도 Coroutine을 두 개 시작하지 않게 합니다.startedAt은 이번 Run의 시작 시간을 기록합니다. 새 Run에서만 다시 설정해야 하며 Pause 복귀를 새 Run처럼 처리하면 난이도 시간이 초기화됩니다.- Selector는 종류 순서와 연속 제한을 담당하고 Spawner는 결과 Type을 소비할 뿐 랜덤 규칙을 복제하지 않습니다.
- Pool은 Type과 Spawn Position을 받아 비활성 Package를 완전히 초기화해 반환합니다.
- 1분마다 계산상 interval이 0.2초 줄어들지만
Mathf.Max가 0.45초 아래로 내려가지 않게 합니다. WaitForSeconds는 scaled time을 사용하므로 이 구현은 Pause에서 Time Scale 0으로 대기를 정지하고 GameOver에서 Coroutine을 종료합니다. Pause 때 Coroutine 자체를 중단하는 설계를 택하면 경과 시간을 별도로 누적합니다.
기본값의 손 계산은 다음과 같습니다.
| Run 경과 | 계산값 | 실제 interval |
|---|---|---|
| 0분 | 1.5 - 0 | 1.5초 |
| 2분 | 1.5 - 0.4 | 1.1초 |
| 5분 | 1.5 - 1.0 | 0.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 없음 |
| 목숨 0 | GameOver 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와 자동 생성을 켭니다.
값과 실패 바꿔 보기
- BlueZone과 OrangeZone을 일부러 겹쳐 같은 Package가 두 Trigger에 들어가게 합니다. 점수 또는 목숨이 한 번만 변해야 합니다.
- startInterval을 0.2, minInterval을 0.1로 낮춰 빠른 생성에서 Pool 부족·중복 판정을 확인한 뒤 원래 값으로 복원합니다.
- PackagePool Prefab, SortZone Session, HUD TMP 참조를 하나씩 비워 어떤 Component의 어느 필드가 누락됐는지 오류가 구체적으로 나오는지 확인합니다.
- 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. 핵심 요약
최종 실습: 2D 택배 분류 게임 미니 퀴즈
선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.
학습을 마쳤나요?
직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.