서비스
교육 철학

10. 확장 과제 — 여기서부터는 직접

게임이 완성됐습니다. 캐릭터를 고르고, 걷고, 네 가지 무기로 싸우고, 양을 부르고, 죽고, 다시 시작합니다.
1장부터 9장까지 만든 것 — 캐릭터 선택, 적의 추적, 무기 넷, 동료. 완성된 예제를 에디터에서 실제로 플레이한 것이다
8장에서 "구조는 무언가를 추가할 때 평가된다"고 했습니다. 이 장은 그 평가를 여러분이 직접 해 보는 장입니다. 과제 여섯 개를 준비했습니다.
과제
쓰는 것
이 책에서
A
새 적 추가하기
표 (5장)
풀이까지 싣는다
B
새 무기 추가하기
WeaponBase (6장)
풀이까지 싣는다
C
결과 화면 만들기
UI 폼, 이벤트, 프로시저 (7·9장)
풀이까지 싣는다
D
적의 수치를 파일로 빼내기
DataTable — 처음 쓰는 기능
길잡이만
E
음량 설정 저장하기
Setting — 처음 쓰는 기능
길잡이만
F
여러 언어 지원하기
Localization — 처음 쓰는 기능
길잡이만
앞의 셋은 지금까지 배운 것만으로 됩니다. 풀이를 보기 전에 먼저 혼자 해 보세요. 어느 파일을 고쳐야 하는지 스스로 짚어 낼 수 있다면 이 책의 구조가 머릿속에 들어온 것입니다.
뒤의 셋은 이 책에서 다루지 않은 프레임워크 기능을 쓰는 과제입니다. 풀이를 싣지 않고 어디서 시작해야 하는지만 알려 줍니다. 여기서부터는 프레임워크 문서를 직접 읽어 가며 만드는 연습입니다.
세 기능은 이미 프레임워크에 들어 있습니다. 1장에서 "붙어만 있고 아무 일도 하지 않는다"고 한 GameFramework 의 자식들입니다.
아직 쓰지 않은 모듈. ① Data Table(과제 D) ② Localization(과제 F) ③ Setting(과제 E) ④ Data Table Count 0 — 아직 읽어 들인 표가 하나도 없다 (그림은 플레이 도중에 찍었다)
과제를 풀고 나면 ④ 의 숫자가 늘어 있을 것입니다. Play 중에 이 인스펙터를 열어 두면 표가 제대로 읽혔는지 코드 한 줄 없이 확인할 수 있습니다.

A. 새 적 추가하기

목표 — 여섯 번째 적 Ghost 를 추가한다. 다른 적보다 빠르고 약하다.
먼저 생각해 보기 — 코드를 몇 줄 써야 할까요?

풀이

답은 표에 두 줄입니다. 클래스는 만들지 않습니다.
① 프리팹을 준비합니다. 기존 적 프리팹 하나를 복제(Ctrl+D)해 이름을 Ghost 로 바꾸는 것이 가장 쉽습니다. 처음부터 만든다면 아래를 갖춰야 합니다.
필요한 것
없으면
NavMeshAgent
나타난 자리에서 움직이지 않는다
루트에 Collider
무기에 맞지 않는다. 콘솔에 경고가 나온다
레이어 9 (Shootable)
번개가 통과하고 냉기·악취에도 걸리지 않는다
Animator — 트리거 Dead, PlayerDead
쓰러지는 연출 없이 가라앉기만 한다
자식 HitParticles, FreezeFx, SlimeFx (선택)
맞을 때·얼 때·슬라임이 붙을 때 이펙트가 안 나온다. 게임은 정상이다
로직 스크립트 — 붙이지 않는다
(붙이면) 스크립트가 둘이 되어 오동작한다
② Addressables Groups 창에 끌어다 놓고 주소를 Ghost 로 줄입니다.
③ EnemyTable.cs 의 두 배열에 한 줄씩 추가합니다.
// 주소 HP 공격 간격 점수 속도 정지 공격구 new EnemyStats("Ghost", 60, 15, 0.8f, 30, 6f, 1.1f, 0.8f),
C#
복사
new EnemySpawnPoint("Ghost", new Vector3( 15f, 0f, -20f), 8f, 3),
C#
복사
Play 합니다. 8초 뒤부터 유령이 다른 적의 거의 두 배 속도로 달려옵니다. 번개 두 발에 쓰러지고, 얼고, 도망치고, 30점을 줍니다. Enemy.cs, EnemySpawner.cs, 무기, HUD 어디도 열지 않았습니다.
소리는 나지 않습니다. SoundTable 의 switch 에 "Ghost" 가 없어서 null 이 돌아오고, 8장에서 만든 대로 조용히 넘어갑니다. 소리를 주려면 switch 두 곳에 한 줄씩 추가합니다.

더 나아가기

🧩
같은 적을 두 지점에서 내보내려면? SpawnPoints 에 "Ghost" 줄을 하나 더 추가하면 EnemyTable.Validate() 가 빨간 줄로 막습니다. 5장에서 본 대로 스포너가 "오는 중" 숫자를 주소로 찾아 내리기 때문입니다. 이 제약을 없애려면 성공/실패 이벤트가 왔을 때 "어느 지점의 요청이었는가"를 알아야 합니다.
힌트: 성공·실패 이벤트에는 우리가 넘긴 주문서가 UserData 로 그대로 실려 옵니다. EnemyData 에 SpawnIndex 를 하나 더 싣는다면? 고칠 곳은 EnemyData, EnemySpawner.SpawnEnemy, DecrementPendingSpawn, 그리고 SurvivalGame 의 중계 두 곳입니다. 다 고친 뒤 Validate 의 둘째 검사를 지웁니다.

B. 새 무기 추가하기

목표 — 다섯 번째 무기 Shotgun. 누르고 있는 동안, 쿨다운 1.5초, 정면 ±20° 반경 5 안의 모든 적에게 피해 30.
먼저 생각해 보기 — 6장의 어느 무기와 어느 무기를 섞으면 될까요? 파일을 몇 개 고쳐야 할까요?

풀이

부채꼴 안의 적을 찾는 것은 냉기(FrostWeapon.CollectCone)가 했고, 피해를 주고 착탄 이펙트를 띄우는 것은 번개(LightningWeapon.ApplyHitscanHit)가 했습니다. 둘을 합칩니다.
① WeaponTable.cs 에 숫자를 추가합니다.
// ─── Shotgun ─── // 누르고 있는 동안 발사. 부채꼴 안의 모든 적에게 피해. public const float ShotgunCooldown = 1.5f; public const float ShotgunRange = 5f; public const float ShotgunHalfAngle = 20f; public const int ShotgunDamage = 30; /// <summary> /// 무기 엔티티 프리팹의 주소. 전용 프리팹을 만들기 전까지 번개의 것을 빌려 쓴다 — /// 같은 프리팹에 다른 로직을 입히는 것은 4장에서 해 봤다. /// </summary> public const string ShotgunWeaponAsset = LightningWeaponAsset;
C#
복사
② 무기 클래스를 만듭니다.
// Assets/GameMain/Weapon/ShotgunWeapon.cs using UnityEngine; namespace ToyBoxNightmare { /// <summary> /// 산탄. 발사 버튼을 누르고 있는 동안 쿨다운마다 정면 부채꼴 안의 <b>모든</b> 적에게 피해를 준다. /// 판정은 Frost 와 같은 "원 → 각도" 두 단계이고, 피해는 Lightning 과 같은 ApplyDamage 다. /// </summary> public class ShotgunWeapon : WeaponBase { /// <summary>착탄 이펙트를 띄울 높이(발밑 기준). 대략 몸통이다.</summary> private const float HitEffectHeight = 0.5f; protected override bool OnFireHeld() { Vector3 origin = Owner.CachedTransform.position; Vector3 forward = Owner.CachedTransform.forward; forward.y = 0f; int candidateCount = WeaponUtil.FindEnemiesInSphere(origin, WeaponTable.ShotgunRange, Candidates); for (int i = 0; i < candidateCount; i++) { Enemy enemy = Candidates[i]; Vector3 toEnemy = enemy.CachedTransform.position - origin; toEnemy.y = 0f; if (Vector3.Angle(forward, toEnemy) > WeaponTable.ShotgunHalfAngle) continue; enemy.ApplyDamage(Owner.Entity, WeaponTable.ShotgunDamage); WeaponUtil.SpawnEffect(typeof(HitEffect), WeaponTable.LightningHitAsset, enemy.CachedTransform.position + Vector3.up * HitEffectHeight, Quaternion.identity, WeaponTable.LightningHitLifetime); } // 다음 프레임까지 참조를 들고 있지 않는다. Candidates.Clear(); // 아무도 안 맞아도 쏜 것이다. 쿨다운을 건다. return true; } } }
C#
복사
③ WeaponLoadout 의 sStartingWeapons 에 한 줄을 추가합니다. 로직 타입, 프리팹 주소, 쿨다운입니다.
new WeaponSpec(typeof(LightningWeapon), WeaponTable.LightningWeaponAsset, WeaponTable.LightningCooldown), new WeaponSpec(typeof(FrostWeapon), WeaponTable.FrostWeaponAsset, WeaponTable.FrostCooldown), new WeaponSpec(typeof(StinkWeapon), WeaponTable.StinkWeaponAsset, WeaponTable.StinkCooldown), new WeaponSpec(typeof(SlimeWeapon), WeaponTable.SlimeWeaponAsset, WeaponTable.SlimeCooldown), new WeaponSpec(typeof(ShotgunWeapon), WeaponTable.ShotgunWeaponAsset, WeaponTable.ShotgunCooldown),
C#
복사
Play → Tab 을 네 번 눌러 ShotgunWeapon (5/5) 로 바꾸고, 적이 뭉쳐 있는 쪽으로 누릅니다. 부채꼴 안의 적 전부에게서 불꽃이 터집니다.

무엇이 저절로 따라왔나

쓴 것은 훅 하나(OnFireHeld)와 표의 한 줄입니다. 다음은 한 줄도 쓰지 않았는데 동작합니다.
•
플레이어의 안테나에 붙고, 플레이어가 사라지면 함께 회수된다 (WeaponBase 의 AttachEntity)
•
Tab 으로 전환된다 (WeaponLoadout.ReadSwitchInput)
•
1.5초 쿨다운이 걸리고, 그동안 다른 무기도 잠긴다 (BeginCooldown)
•
HUD 의 쿨다운 게이지가 1.5초에 걸쳐 줄어든다 (7장의 이벤트)
•
일시정지 중, 죽는 중에는 나가지 않는다 (4장의 상태)
•
맞은 적에게서 솜이 튀고 소리가 난다 (4장의 OnDamaged 훅)
•
적이 죽으면 점수가 오르고, 양 포인트가 쌓인다 (5·8장의 이벤트)
6장에서 WeaponBase 를 만들며 "부모가 언제와 해도 되는가를 쥐고, 자식은 무엇을만 채운다"고 한 것의 결과입니다. 새 무기는 자기만의 것만 쓰면 됩니다.
Candidates 는 WeaponBase 가 자식들에게 내주는 재사용 리스트입니다. 6장에서 선언만 해 두고 쓰지 않았는데 여기서 처음 씁니다. 매번 new List<Enemy>() 를 하지 않기 위한 것입니다.

더 나아가기

•
이 무기는 번개의 프리팹을 빌려 쓰고 있습니다. LightningAttack 프리팹을 복제해 ShotgunAttack 을 만들고, 총구 파티클을 바꾼 뒤 Addressables 에 등록해 ShotgunWeaponAsset 을 그 주소로 바꿔 보세요. 손에 들었을 때만 보이는 것은 WeaponBase 가 이미 해 줍니다.
•
가구 뒤의 적도 맞습니다. 번개는 Physics.Raycast 로 가로막힌 것을 걸렀지만 이 무기는 범위만 봅니다. 적마다 origin 에서 광선을 쏴서 처음 닿는 것이 그 적인지 확인하면 됩니다. WeaponUtil.HitscanMask 를 씁니다.
•
가까운 적에게 더 큰 피해를 주려면? WeaponUtil.PlanarDistance 로 거리를 재서 피해량을 계산합니다.

C. 결과 화면 만들기

목표 — 지금은 HUD 의 "Game Over!" 글자뿐이다. 최종 점수와 재시작 버튼이 있는 결과 화면을 따로 띄운다. R 키도 계속 동작해야 한다.
먼저 생각해 보기 — 문제가 둘 있습니다.
1.
결과 화면은 ProcedureGameOver 에서 열어야 하는데, 9장에서 본 대로 그 시점에는 점수를 알던 SurvivalGame 이 이미 내려간 뒤입니다. 점수를 어떻게 넘길까요?
2.
버튼은 UI 폼 안에 있고, 프로시저를 바꾸는 것은 프로시저만 할 수 있습니다. 버튼이 눌린 것을 프로시저가 어떻게 알까요?

풀이

1 의 답 — 프로시저끼리 값을 넘기는 보관함. 프로시저들은 하나의 상태 기계에 속해 있고, 그 상태 기계에는 이름표를 붙여 값을 맡겨 두는 보관함이 있습니다. 한 프로시저가 맡기고 다음 프로시저가 찾아갑니다.
procedureOwner.SetData<VarInt32>("FinalScore", 120); // 맡긴다 int score = procedureOwner.GetData<VarInt32>("FinalScore"); // 찾는다
C#
복사
VarInt32 는 프레임워크가 주는 "보관함에 넣을 수 있는 int"입니다. int 와 서로 자동으로 바뀝니다. VarString, VarBoolean, VarVector3 같은 형제들이 있습니다.
2 의 답 — 이벤트. 3장에서 캐릭터가 "선택됐다"고 외치고 게임이 들었습니다. 이번에는 폼이 "재시작 버튼이 눌렸다"고 외치고 프로시저가 듣습니다. 같은 구조입니다.
① 이벤트를 만듭니다. 실어 보낼 값이 없는 가장 단순한 이벤트입니다.
// Assets/GameMain/Game/RestartRequestedEventArgs.cs using GameFramework; using GameFramework.Event; namespace ToyBoxNightmare { /// <summary> /// 결과 화면의 재시작 버튼이 눌렸을 때 발행된다. ProcedureGameOver 가 구독한다. /// 실어 보낼 값이 없는 이벤트다. /// </summary> public class RestartRequestedEventArgs : GameEventArgs { public static readonly int EventId = typeof(RestartRequestedEventArgs).GetHashCode(); public override int Id => EventId; public static RestartRequestedEventArgs Create() { return ReferencePool.Acquire<RestartRequestedEventArgs>(); } public override void Clear() { } } }
C#
복사
② 폼의 로직을 만듭니다.
// Assets/GameMain/UI/ResultForm.cs using UnityEngine; using UnityEngine.UI; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 결과 화면. 최종 점수를 보여 주고 재시작 버튼을 제공한다. /// <b>이 스크립트는 프리팹에 직접 붙어 있어야 한다</b>(UI 폼 규약). /// </summary> public class ResultForm : UIFormBase { private const string ScoreTextPath = "ScoreText"; private const string RestartButtonPath = "RestartButton"; private const string ScoreFormat = "Final Score: {0}"; private Text mScoreText = null; private Button mRestartButton = null; protected internal override void OnInit(object userData) { base.OnInit(userData); Transform scoreText = CachedTransform.Find(ScoreTextPath); mScoreText = scoreText != null ? scoreText.GetComponent<Text>() : null; Transform restartButton = CachedTransform.Find(RestartButtonPath); mRestartButton = restartButton != null ? restartButton.GetComponent<Button>() : null; // 리스너는 인스턴스당 한 번만 단다. if (mRestartButton != null) { mRestartButton.onClick.AddListener(RequestRestart); } } protected internal override void OnOpen(object userData) { base.OnOpen(userData); // OpenUIForm 의 마지막 인자가 그대로 온다. int finalScore = userData is int ? (int)userData : 0; if (mScoreText != null) { mScoreText.text = string.Format(ScoreFormat, finalScore); } } /// <summary>프로시저를 직접 바꾸지 않는다. "눌렸다"고 알리기만 한다.</summary> private void RequestRestart() { EventComponent events = GameEntry.GetComponent<EventComponent>(); if (events == null) return; events.Fire(this, RestartRequestedEventArgs.Create()); } } }
C#
복사
③ 프리팹을 만듭니다.
•
Hierarchy 에서 UI → Canvas 를 만들고 이름을 ResultForm 으로 바꿉니다. Canvas 의 Sort Order 는 손대지 않습니다. 7장의 UIFormBase 가 UI 그룹을 보고 실행 중에 정합니다.
•
그 아래에 UI → Legacy → Text 를 만들어 이름을 ScoreText 로, UI → Legacy → Button 을 만들어 이름을 RestartButton 으로 합니다. 이름이 코드의 경로 상수와 같아야 합니다.
•
루트 ResultForm 에 방금 만든 ResultForm 스크립트를 붙입니다.
•
Project 창으로 끌어 프리팹으로 만들고, 씬의 것은 지웁니다. Addressables 에 주소 ResultForm 으로 등록합니다.
④ UITable.cs 에 상수를 추가합니다.
/// <summary>결과 화면 폼의 Addressables 주소.</summary> public const string ResultForm = "ResultForm";
C#
복사
⑤ ProcedureMain.OnUpdate 에서 넘어가기 직전에 점수를 맡깁니다. 한 줄입니다.
if (mGame.GameOver) { // OnLeave 에서 게임 객체가 사라지기 전에 점수를 맡겨 둔다. procedureOwner.SetData<VarInt32>(ProcedureGameOver.FinalScoreKey, mGame.Score); ChangeState<ProcedureGameOver>(procedureOwner); }
C#
복사
⑥ ProcedureGameOver 를 고칩니다. 전체입니다.
// Assets/GameMain/Procdure/ProcedureGameOver.cs using GameFramework.Event; using UnityEngine.InputSystem; using UnityGameFramework.Runtime; using ProcedureOwner = GameFramework.Fsm.IFsm<GameFramework.Procedure.IProcedureManager>; namespace ToyBoxNightmare { public class ProcedureGameOver : ProcedureBase { /// <summary>ProcedureMain 이 최종 점수를 실어 보내는 이름표.</summary> public const string FinalScoreKey = "FinalScore"; private bool mRestartSubscribed = false; private bool mRestartRequested = false; public override bool UseNativeDialog => false; protected override void OnEnter(ProcedureOwner procedureOwner) { base.OnEnter(procedureOwner); mRestartRequested = false; SubscribeRestart(); int finalScore = procedureOwner.HasData(FinalScoreKey) ? (int)procedureOwner.GetData<VarInt32>(FinalScoreKey) : 0; UIComponent ui = GameEntry.GetComponent<UIComponent>(); if (ui != null && !ui.HasUIForm(UITable.ResultForm)) { ui.OpenUIForm(UITable.ResultForm, UITable.DefaultGroup, finalScore); } } protected override void OnUpdate(ProcedureOwner procedureOwner, float elapseSeconds, float realElapseSeconds) { base.OnUpdate(procedureOwner, elapseSeconds, realElapseSeconds); if (!mRestartRequested && !IsRestartKeyPressed()) return; ChangeState<ProcedureMain>(procedureOwner); } protected override void OnLeave(ProcedureOwner procedureOwner, bool isShutdown) { UnsubscribeRestart(); CloseForms(); base.OnLeave(procedureOwner, isShutdown); } private void SubscribeRestart() { EventComponent events = GameEntry.GetComponent<EventComponent>(); if (events == null) return; events.Subscribe(RestartRequestedEventArgs.EventId, OnRestartRequested); mRestartSubscribed = true; } private void UnsubscribeRestart() { if (!mRestartSubscribed) return; mRestartSubscribed = false; EventComponent events = GameEntry.GetComponent<EventComponent>(); if (events == null) return; events.Unsubscribe(RestartRequestedEventArgs.EventId, OnRestartRequested); } /// <summary> /// 여기서 바로 ChangeState 하지 않는다. 이벤트 핸들러는 상태 바깥에서 불리므로 /// 깃발만 세우고, 전이는 OnUpdate 가 한다. /// </summary> private void OnRestartRequested(object sender, GameEventArgs e) { mRestartRequested = true; } private static bool IsRestartKeyPressed() { if (GamePause.IsPaused) return false; Keyboard keyboard = Keyboard.current; return keyboard != null && keyboard.rKey.wasPressedThisFrame; } private static void CloseForms() { UIComponent ui = GameEntry.GetComponent<UIComponent>(); if (ui == null) return; CloseIfOpen(ui, UITable.ResultForm); CloseIfOpen(ui, UITable.HudForm); CloseIfOpen(ui, UITable.PauseMenuForm); } private static void CloseIfOpen(UIComponent ui, string assetName) { UIForm form = ui.GetUIForm(assetName); if (form == null) return; ui.CloseUIForm(form); } } }
C#
복사

코드 읽기

지금까지 배운 것들이 그대로 다시 쓰였습니다.
OpenUIForm(주소, 그룹, finalScore) — 셋째 인자가 폼의 OnOpen(object userData) 로 그대로 도착합니다. 엔티티의 주문서와 같은 통로입니다. 점수 하나뿐이라 클래스를 따로 만들지 않고 int 를 그대로 넘겼습니다.
겹치는 순서는 그룹이 정합니다. 결과 화면은 HUD 와 같은 Default 그룹에 엽니다. 같은 그룹 안에서는 나중에 연 폼이 위로 오므로(그룹 안의 순서 2 → Sort Order 200) HUD(100) 위에, Popup 그룹의 일시정지 메뉴(10100) 아래에 그려집니다. 부모를 UIFormLogic 이 아니라 UIFormBase 로 둔 것이 이 때문입니다. UIFormLogic 을 바로 상속하면 프리팹에 적힌 Sort Order 가 그대로 쓰여, 값에 따라 HUD 뒤에 가려질 수 있습니다.
핸들러는 깃발만 세웁니다. OnRestartRequested 가 바로 ChangeState 를 부르지 않고 mRestartRequested = true 만 합니다. 3장의 DisableAndHide(), 5장의 StartSinking() 과 같은 방식입니다. 이벤트 핸들러는 프레임의 맨 처음(이벤트를 전달하는 시점)에 불립니다. 그 한가운데서 프로시저를 바꾸면, 같은 이벤트를 기다리던 다른 핸들러들이 이미 치워진 화면 위에서 불릴 수 있습니다. 깃발을 세워 두고 프레임의 맨 끝 OnUpdate 에서 넘어가면 그런 일이 없습니다.
R 키와 버튼이 같은 문으로 들어옵니다. if (!mRestartRequested && !IsRestartKeyPressed()) return; — 둘 중 하나라도 참이면 넘어갑니다. 재시작하는 방법이 둘이지만 재시작하는 코드는 한 줄입니다.
열고 닫는 짝. OnEnter 에서 결과 화면을 열고 구독하고, OnLeave 에서 해지하고 닫습니다. 이번에는 짝이 같은 프로시저 안에 있습니다.
💡
결과 화면이 뜨면 HUD 의 "Game Over!" 글자와 겹칩니다. HUDForm.OnPlayerDied 를 지우고 구독 두 줄도 함께 지우면 됩니다. 구독과 해지는 반드시 같이 지우세요. 한쪽만 지우면 9장의 점검표에 있던 예외가 납니다.

D. 적의 수치를 파일로 빼내기 (도전)

목표 — EnemyTable 의 숫자를 C# 코드에서 텍스트 파일로 옮긴다. 밸런스를 고칠 때 컴파일하지 않아도 되게 한다.
5장에서 표를 만들며 "나중에 DataTable 로 옮기면 기획자가 코드를 열지 않고 숫자를 고칠 수 있다"고 했습니다. 그 일입니다.

길잡이

표 파일. 탭으로 구분한 텍스트 파일을 만들어 Addressables 에 등록합니다. 한 줄이 한 행이고, # 으로 시작하는 줄은 건너뜁니다.
# Id AssetName MaxHitPoints 1 Zombunny 100 2 ZomBear 100
Plain Text
복사
행 클래스. 한 줄을 받아 자기 필드를 채웁니다.
public class EnemyRow : DataRowBase { private int mId = 0; public override int Id => mId; public string AssetName { get; private set; } public int MaxHitPoints { get; private set; } public override bool ParseDataRow(string dataRowString, object userData) { string[] columns = dataRowString.Split('\t'); mId = int.Parse(columns[0]); AssetName = columns[1]; MaxHitPoints = int.Parse(columns[2]); return true; } }
C#
복사
읽기.
DataTableComponent dataTable = GameEntry.GetComponent<DataTableComponent>(); IDataTable<EnemyRow> table = dataTable.CreateDataTable<EnemyRow>(); ((DataTableBase)table).ReadData("EnemyTable"); // Addressables 주소. 바로 끝나지 않는다
C#
복사

스스로 풀어야 할 것

•
ReadData 는 바로 끝나지 않습니다. 이 책에서 몇 번이나 만난 문제입니다. 표를 다 읽기 전에 적을 스폰하면 안 됩니다. 어떻게 기다릴까요? 프레임워크는 다 읽었을 때 LoadDataTableSuccessEventArgs 를, 실패했을 때 LoadDataTableFailureEventArgs 를 쏩니다. 그리고 2장에서 "게임의 큰 단계 하나가 프로시저 하나"라고 했습니다. "필요한 것을 읽어 들이는 단계" 는 큰 단계입니다. ProcedurePreload 를 만들어 ProcedureMain 앞에 세워 보세요. Procedure 컴포넌트의 시작 프로시저(Entrance)도 바꿔야 합니다.
•
재시작할 때마다 다시 읽을 필요는 없습니다. ProcedureGameOver 는 ProcedurePreload 가 아니라 ProcedureMain 으로 돌아가면 됩니다.
•
위 예시의 열 순서와 탭 위치를 스스로 맞춰야 합니다. 예시의 표 파일은 각 줄이 탭으로 시작하는데, 그러면 columns[0] 은 빈 문자열입니다. 파일의 모양과 ParseDataRow 의 인덱스를 한쪽에 맞춰 정하세요. 틀리면 Can not parse data row string '…' 경고가 나옵니다.
•
EnemyStats 를 만들던 자리(EnemyTable.TryGetStats)를 table.GetDataRow(…) 로 바꿉니다. EnemySpawner 는 TryGetStats 만 부르므로 스포너는 고치지 않아도 되게 만들 수 있습니다. 그렇게 만들어 보세요.
•
숫자를 읽을 때는 int.Parse(문자열, CultureInfo.InvariantCulture) 처럼 문화권을 고정하세요. 소수점을 쉼표로 쓰는 나라의 기기에서 float.Parse("3.5") 는 다른 값이 됩니다.

E. 음량 설정 저장하기 (도전)

목표 — 일시정지 메뉴의 볼륨 슬라이더 값이 게임을 껐다 켜도 남아 있게 한다.

길잡이

프레임워크의 Setting 컴포넌트는 이름과 값의 쌍을 기기에 저장합니다.
SettingComponent setting = GameEntry.GetComponent<SettingComponent>(); // 읽기 (저장된 것이 없으면 둘째 인자를 돌려준다) float sfx = setting.GetFloat("Volume.Sfx", 0f); // 쓰기 setting.SetFloat("Volume.Sfx", level); setting.Save();
C#
복사

스스로 풀어야 할 것

•
어디서 읽을까요? 지금 PauseMenuForm.OnOpen 은 PullVolumeFromMixer() 로 믹서의 현재 값을 슬라이더에 넣습니다. 저장된 값이 있다면 순서가 반대가 되어야 합니다. 저장된 값 → 믹서 → 슬라이더.
•
어디서 쓸까요? 슬라이더를 끄는 동안 SetEffectsLevel 은 매 프레임 불립니다. 그때마다 Save() 를 부르면 매 프레임 파일을 씁니다. SetFloat 는 메모리에만 적고, Save() 는 메뉴를 닫을 때 한 번만 하면 됩니다. 7장에서 "폼은 닫지 않고 판만 끈다"고 했으니, "메뉴를 닫을 때"가 코드의 어디인지부터 찾아야 합니다.
•
게임을 켠 직후에는 일시정지 폼이 아직 열리지 않았습니다. 첫 판의 캐릭터 선택 화면에서도 저장된 볼륨이 적용되려면, 볼륨을 믹서에 넣는 일을 폼이 아닌 더 이른 곳에서 해야 합니다. D 에서 만든 ProcedurePreload 가 좋은 자리입니다.
•
음소거 토글(AudioToggle)도 저장해 보세요. GetBool / SetBool 이 있습니다.
•
이름을 문자열로 흩어 쓰지 말고 SettingTable 같은 곳에 상수로 모으세요. 5장부터 해 온 그대로입니다.

F. 여러 언어 지원하기 (도전)

목표 — "Select a character", "Score: {0}", "Game Over!" 가 코드와 프리팹에 박혀 있다. 언어를 바꾸면 한국어로 나오게 한다.

길잡이

Localization 컴포넌트는 이름표 → 그 언어의 문장 사전입니다.
LocalizationComponent localization = GameEntry.GetComponent<LocalizationComponent>(); mInfoText.text = localization.GetString("HUD.SelectPrompt"); mInfoText.text = localization.GetString("HUD.Score", score); // 문장 안의 {0} 자리에 들어간다
C#
복사

스스로 풀어야 할 것

•
사전 파일도 D 의 표처럼 읽어 들여야 하고, 바로 끝나지 않습니다. 같은 ProcedurePreload 에서 함께 읽는 것이 자연스럽습니다. "표와 사전을 둘 다 읽었을 때 넘어간다"를 어떻게 셀까요? 5장의 스포너가 "오는 중"을 세던 방식이 힌트입니다.
•
사전 파일의 형식은 프레임워크 문서를 읽고 맞추세요. 한 줄의 열 수가 정해져 있고, 빈 줄이 하나라도 있으면 읽기가 중단됩니다.
•
HUDForm 의 SelectPrompt 와 ScoreFormat 상수는 이제 문장이 아니라 이름표가 됩니다.
•
GameOverText 의 글자는 코드가 아니라 프리팹에 적혀 있습니다. HUDForm.OnOpen 에서 사전의 값으로 덮어써야 합니다.
•
게임 도중에 언어를 바꾸면 이미 떠 있는 HUD 는 어떻게 될까요? 7장의 "지금 값을 알리는 첫 통보"와 같은 문제입니다. 가장 단순한 답은 "언어는 다음 판부터 적용된다"이고, 제대로 하려면 "언어가 바뀌었다" 이벤트가 필요합니다.

더 큰 방향

여기서부터는 과제가 아니라 지도입니다. 이 게임을 더 키운다면 무엇을 어디에 얹게 되는지 적어 둡니다.
기능
어디에 얹나
시간이 갈수록 어려워지기
EnemySpawner 가 SurvivalGame.GameTime 을 보고 간격과 상한을 바꾼다. 2장에서 만들어 둔 GameTime 이 여기서 쓰인다
경험치 보석과 레벨업
보석은 엔티티(새 그룹). 줍는 순간 이벤트. 레벨업 선택 화면은 UI 폼. 선택 중 멈추는 것은 GamePause
무기 강화
WeaponTable 의 상수를 "레벨별 표"로 바꾸고 WeaponLoadout 이 레벨을 든다
캐릭터별 능력치
PlayerData.Create 에 캐릭터 키를 넘겨 표에서 읽는다. 5장의 EnemyData 와 같은 모양이 된다
보스
Enemy 를 상속하거나, 상태를 더 가진 새 로직. 프리팹은 그대로 두고 로직 타입만 바꿔 띄울 수 있다(4장)
패드·모바일 입력
Player.ReadInput 과 WeaponLoadout.ReadFireInput 두 곳. 가상 조이스틱은 UI 폼
타이틀 화면, 스테이지 선택
프로시저를 하나씩 추가한다
다른 게임 모드
GameBase 를 상속한 새 클래스. 2장에서 만든 GameMode 열거형에 한 줄
오른쪽 열을 읽으며 "그건 저 파일만 고치면 되겠다"는 그림이 떠오른다면, 이 책이 전하려던 것을 가져가신 것입니다.

책을 덮으며

이 책에서 만든 것은 작은 게임 하나지만, 그 안에서 반복해 쓴 생각은 몇 개 되지 않습니다.
•
수명을 분명히 한다. 만든 곳과 치우는 곳을 짝으로 둔다. OnEnter 와 OnLeave, OnShow 와 OnHide, Subscribe 와 Unsubscribe, Acquire 와 Release.
•
두 번째를 생각한다. 풀에서 다시 나온 것에는 첫 번째의 흔적이 남아 있다. 나타날 때마다 전부 되돌린다.
•
아는 방향을 한쪽으로만 흐르게 한다. 위는 아래를 직접 부르고, 아래는 위를 모른 채 이벤트로 외친다.
•
부모가 "언제"를 쥐고 자식은 "무엇을"만 채운다. 공통 규칙은 빠뜨릴 수 없는 자리에 둔다.
•
숫자는 표에, 생김새는 프리팹에, 역할은 로직에 둔다.
•
바로 끝나지 않는 일에는 "오는 중", "실패", "필요 없어진 뒤의 도착"을 생각한다.
•
실패는 시끄럽게, 겉의 실패는 조용하게. 게임이 멈추는 실패는 로그로 크게 알리고, 소리나 화면의 실패가 게임을 멈추게 두지 않는다.
•
도구는 필요한 만큼만. 상태가 하나면 FSM 을 쓰지 않고, 결정할 것이 없는 엔티티에는 OnUpdate 도 두지 않는다.
•
게임이 하는 일은 한 길로만 지나가게 한다. 띄우는 것은 엔티티로, 소리는 사운드 그룹으로, 다른 엔티티를 찾는 것은 프레임워크에 물어서, 소식은 이벤트로. 길이 하나면 지켜볼 곳도 하나다.
프레임워크는 이 생각들을 지키기 쉽게 해 주는 도구일 뿐입니다. 다른 프레임워크를 쓰든, 아무것도 쓰지 않든, 위의 목록은 그대로 가져갈 수 있습니다.
프레임워크의 각 기능을 더 깊이 알고 싶다면 이 위키의 Unity GameFramework 와 C# GameFramework 항목들이 기능별 참고서입니다. 각 문서 맨 위의 Abstract 에 이 게임에서 그 기능을 어떻게 썼는지가 적혀 있으니, 이 책에서 본 코드와 이어서 읽을 수 있습니다.