게임이 완성됐습니다. 캐릭터를 고르고, 걷고, 네 가지 무기로 싸우고, 양을 부르고, 죽고, 다시 시작합니다.
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") 는 다른 값이 됩니다.
더 읽기 —
데이터 테이블 (Data Tables)
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 에 이 게임에서 그 기능을 어떻게 썼는지가 적혀 있으니, 이 책에서 본 코드와 이어서 읽을 수 있습니다.





