서비스
교육 철학

09. 게임오버와 재시작 — 풀 재사용 점검

4장에서 플레이어가 죽으면 GameOver 가 true 가 되게 만들었습니다. 그리고 "프로시저가 이 값을 보고 다음 단계로 넘어가는 것은 9장에서 만든다"고 미뤘습니다. 7장에서는 HUD 를 여는 코드만 쓰고 닫는 코드는 "9장에서 만들 게임오버 프로시저가 맡는다"고 미뤘습니다. 여러 장의 연습 문제도 "재시작이 생긴 뒤에 다시 해 보라"고 미뤘습니다.
이 장에서 그것들을 전부 끝냅니다. 플레이어가 죽으면 판을 정리하고, R 을 누르면 처음부터 다시 시작합니다.
코드는 많지 않습니다. 프로시저 하나를 만들고 기존 프로시저에 몇 줄을 더하면 됩니다. 이 장에서 정말로 하는 일은 시험입니다. 재시작은 화면에 있던 모든 엔티티와 UI 가 풀로 돌아갔다가 다시 나오는 첫 순간입니다. 3장부터 "풀에서 다시 나올 때를 위해"라며 써 온 줄들이 제대로 썼는지, 여기서 한꺼번에 드러납니다.
🎯
이 장에서 하는 일
1.
둘째 프로시저 ProcedureGameOver 를 만든다
2.
ProcedureMain 이 게임오버를 보고 넘어가게 하고, 떠날 때 판을 정리하게 한다
3.
죽는 순간부터 새 판이 뜰 때까지 한 프레임씩 따라가 본다
4.
재시작에서 깨지는 코드의 실제 예를 하나 본다 (선택 화면의 조명)
5.
풀 재사용 점검표로 지금까지 만든 것을 검사한다

9.1 왜 씬을 다시 불러오지 않는가

Unity 에서 "처음부터 다시"를 만드는 가장 쉬운 방법은 씬을 다시 불러오는 것입니다.
SceneManager.LoadScene(SceneManager.GetActiveScene().name); // ❌ 이 프로젝트에서는 쓰지 않는다
C#
복사
씬의 모든 오브젝트가 파괴되고 새로 만들어지니, 초기화를 빠뜨릴 걱정이 없습니다. 원작 Zombie Toys 도 이렇게 재시작합니다.
이 프로젝트는 그러지 않습니다. 이유가 둘 있습니다.
프레임워크가 씬 안에 있습니다. 1장에서 GameFramework 프리팹을 씬에 놓았습니다. 씬을 다시 불러오면 이것도 함께 파괴됩니다. 프레임워크는 파괴될 때 스스로를 종료하고, 한번 종료된 프레임워크는 그 실행에서 다시 켜지지 않습니다.
풀을 버리게 됩니다. 지금 풀에는 한 판 동안 만든 적, 투사체, 이펙트가 쉬고 있습니다. 씬을 다시 불러오면 전부 파괴되고, 다음 판에 처음부터 다시 만듭니다. 풀을 쓰는 의미가 없어집니다.
그래서 프로시저 전이로 재시작합니다. 2장에서 그려 둔 그림 그대로입니다.
graph LR
    S((" ")) --> M["ProcedureMain<br>캐릭터 선택 + 한 판"]
    M -->|"GameOver == true"| G["ProcedureGameOver<br>결과 화면. R 키를 기다린다"]
    G -->|"R 키"| M
Mermaid
복사
2장에서 "OnEnter 와 OnLeave 의 짝만 지키면 재시작은 공짜로 따라온다"고 했습니다. ProcedureGameOver 에서 ProcedureMain 으로 돌아가면 OnEnter 가 다시 불리고, 새 SurvivalGame 이 만들어집니다. 점수도 시간도 0 에서 시작합니다. 로딩 화면도 없습니다.
대가가 하나 있습니다. 씬 리로드는 모든 것을 부숴서 초기화를 대신해 줬습니다. 프로시저 전이는 아무것도 부수지 않으므로, 되돌리는 일을 우리가 해야 합니다. 3장부터 OnShow 에 써 온 되돌리기 코드가 그 일입니다.

9.2 둘째 프로시저 — ProcedureGameOver

// Assets/GameMain/Procdure/ProcedureGameOver.cs using UnityEngine.InputSystem; using UnityGameFramework.Runtime; using ProcedureOwner = GameFramework.Fsm.IFsm<GameFramework.Procedure.IProcedureManager>; namespace ToyBoxNightmare { /// <summary> /// 게임오버 상태. R 키를 누르면 ProcedureMain 으로 돌아가 새 판을 시작한다. /// /// 엔티티 회수는 여기서 하지 않는다. 게임을 소유한 <see cref="ProcedureMain"/> 이 /// OnLeave 에서 정리하므로, 이 프로시저에 진입한 시점에는 이미 비어 있다. /// /// 재시작을 씬 리로드로 하지 않는다. 프로시저 전이가 유일하게 안전한 재시작 경로다. /// </summary> public class ProcedureGameOver : ProcedureBase { public override bool UseNativeDialog => false; protected override void OnEnter(ProcedureOwner procedureOwner) { base.OnEnter(procedureOwner); int remainingEntityCount = CountRemainingEntities(); Log.Info("ProcedureGameOver: Enter. 잔존 엔티티 {0}개. R 키로 재시작.", remainingEntityCount); } protected override void OnLeave(ProcedureOwner procedureOwner, bool isShutdown) { CloseHud(); base.OnLeave(procedureOwner, isShutdown); } /// <summary> /// HUD 를 닫는다. <see cref="ProcedureMain"/> 이 진입에서 열고 여기서 닫아 짝이 맞는다. /// Main 쪽 OnLeave 에서 닫으면 게임오버 문구가 보이기도 전에 사라진다. /// </summary> private static void CloseHud() { UIComponent ui = GameEntry.GetComponent<UIComponent>(); if (ui == null) return; 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); } protected override void OnUpdate(ProcedureOwner procedureOwner, float elapseSeconds, float realElapseSeconds) { base.OnUpdate(procedureOwner, elapseSeconds, realElapseSeconds); if (!IsRestartRequested()) return; ChangeState<ProcedureMain>(procedureOwner); } /// <summary>재시작 키(R)가 이번 프레임에 눌렸는가.</summary> private static bool IsRestartRequested() { // 일시정지 중에는 재시작을 받지 않는다. 메뉴를 띄운 채 R 이 눌리면 // timeScale 0 인 상태로 새 판이 시작된다. if (GamePause.IsPaused) return false; // 키보드가 연결돼 있지 않으면 Keyboard.current 가 null 이다(패드만 꽂힌 경우 등). Keyboard keyboard = Keyboard.current; return keyboard != null && keyboard.rKey.wasPressedThisFrame; } /// <summary> /// 회수가 제대로 됐는지 확인용. 0 이 아니면 ProcedureMain.OnLeave 를 의심할 것. /// </summary> private static int CountRemainingEntities() { // 앱 종료 중이면 컴포넌트가 이미 파괴됐을 수 있다. 그때는 셀 대상도 없다. EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) return 0; return entityComponent.GetAllLoadedEntities().Length; } } }
C#
복사
프로시저를 새로 만들면 프레임워크에 등록해야 합니다. 1장에서 ProcedureMain 을 등록한 것과 같은 자리입니다. Hierarchy 에서 GameFramework → Procedure 를 고르고, Inspector 의 Available Procedures 아래에 프로젝트의 프로시저 클래스가 체크박스로 나열됩니다. 방금 만든 ToyBoxNightmare.ProcedureGameOver 를 체크합니다.
칸
값
Available Procedures ☑
ToyBoxNightmare.ProcedureMain
Available Procedures ☑ (새로 체크)
ToyBoxNightmare.ProcedureGameOver
Entrance Procedure
ToyBoxNightmare.ProcedureMain (그대로)
Available Procedures ☐
UnityGameFramework.Runtime.ProcedureStarter (1장에서 푼 체크 그대로)
⚠️
등록하지 않은 프로시저로 ChangeState 하면 예외가 납니다. 목록은 컴파일이 끝나면 자동으로 갱신됩니다. 방금 만든 클래스가 안 보이면 컴파일 에러가 없는지 확인합니다. 이 설정은 1장과 마찬가지로 씬에 놓인 인스턴스에 적히므로 씬을 저장하는 것을 잊지 마세요. 프리팹에 Apply 하지는 않습니다. 프리팹은 어느 씬에서나 쓰는 공통 설정(시작 절차 ProcedureStarter)으로 두고, 게임의 절차는 게임 씬에 둡니다.

코드 읽기

이 프로시저가 하는 일은 셋뿐입니다. 들어올 때 확인하고, 있는 동안 R 을 기다리고, 나갈 때 UI 를 닫습니다.
들어올 때 엔티티가 몇 개 남았는지 셉니다. 정리는 ProcedureMain 이 떠나면서 다 했어야 합니다. 그래서 이 숫자는 언제나 0 이어야 합니다. 0 이 아니라면 앞 프로시저의 정리에 구멍이 있다는 뜻입니다. 이 로그는 게임에 필요한 것이 아니라 우리 자신을 검사하려고 넣은 것입니다.
HUD 를 여기서 닫습니다. 7장에서 미뤄 둔 짝입니다. ProcedureMain.OnEnter 가 열고 ProcedureGameOver.OnLeave 가 닫습니다. 이 프로시저에 있는 동안에는 HUD 가 떠 있어야 "Game Over!" 와 최종 점수가 보입니다.
CloseIfOpen 은 닫기 전에 GetUIForm 으로 열려 있는지 확인합니다. 열리지 않은 폼을 닫으려 하면 예외이기 때문입니다. 7장의 OpenOnce 와 짝을 이루는 모양입니다. 열 때도 닫을 때도 "지금 상태가 어떤가"를 먼저 묻습니다.
일시정지 중에는 R 을 받지 않습니다. 게임오버 화면에서도 Esc 메뉴는 열립니다. 메뉴를 띄운 채 R 이 눌려 새 판이 시작되면, 게임 시간이 멈춘 채로 캐릭터 선택 화면이 뜹니다. (7장에서 PauseMenuForm.OnClose 가 정지를 풀게 해 두었으니 실제로는 이중 방어입니다.) 8장의 ReadAllySummonInput 과 같은 질문입니다. 입력을 읽는 곳이 하나 늘 때마다 "멈춰 있을 때는?"을 묻습니다.

9.3 ProcedureMain 마무리

ProcedureMain 에 두 가지를 더합니다. 게임오버를 보고 넘어가기, 떠날 때 판을 치우기. 이 책에서 이 파일의 마지막 모습입니다.
// Assets/GameMain/Procdure/ProcedureMain.cs using UnityEngine.AI; using UnityGameFramework.Runtime; using ProcedureOwner = GameFramework.Fsm.IFsm<GameFramework.Procedure.IProcedureManager>; namespace ToyBoxNightmare { public class ProcedureMain : ProcedureBase { private SurvivalGame mGame = null; /// <summary>UI 실패 이벤트를 구독 중인가. 구독/해지 짝을 보장한다.</summary> private bool mSubscribedUIFailure = false; public override bool UseNativeDialog => false; protected override void OnEnter(ProcedureOwner procedureOwner) { base.OnEnter(procedureOwner); Log.Info("ProcedureMain: Enter"); WarnIfSceneIsIncomplete(); // 배경음악은 SoundComponent 로 튼다. 이미 나오고 있으면 그대로 둔다. GameSound.PlayMusic(); // 구독이 먼저다. OpenUIForm 은 비동기라 실패 통지가 이 아래 호출 도중에도 올 수 있다. SubscribeUIFailure(); OpenForms(); mGame = new SurvivalGame(); mGame.Initialize(); } /// <summary> /// 이 게임은 <c>Assets/Scenes/MainScene.unity</c> 에서 돌아야 한다. 방(Environment)·NavMesh· /// 카메라 추종(<see cref="PlayerCameraFollow"/>)이 그 씬에 놓여 있기 때문이다. /// 새 씬에 GameFramework 프리팹만 놓고 실행해도 게임은 시작되지만, 적이 움직이지 않고 /// 카메라도 따라오지 않는다 — 콘솔에는 Unity 의 NavMesh 경고만 잔뜩 남는다. /// 원인을 바로 알 수 있게 진입하자마자 한 번 알린다. /// </summary> private static void WarnIfSceneIsIncomplete() { bool hasNavMesh = NavMesh.CalculateTriangulation().vertices.Length > 0; bool hasCameraFollow = GameEntry.GetComponent<PlayerCameraFollow>() != null; if (hasNavMesh && hasCameraFollow) return; Log.Error("이 씬에는 예제 게임에 필요한 것이 없다(NavMesh: {0}, 카메라 추종: {1}). " + "Assets/Scenes/MainScene.unity 를 열고 실행할 것.", hasNavMesh ? "있음" : "없음", hasCameraFollow ? "있음" : "없음"); } /// <summary> /// HUD 를 띄운다. <b>닫는 것은 <see cref="ProcedureGameOver"/> 다</b> — /// 여기서 OnLeave 에 닫으면 게임오버로 전이하는 순간 "Game Over!" 문구가 함께 사라진다. /// 한 판마다 Main 진입에서 열고 GameOver 이탈에서 닫아 짝이 맞는다. /// </summary> private void OpenForms() { UIComponent ui = GameEntry.GetComponent<UIComponent>(); if (ui == null) { Log.Error("ProcedureMain: UIComponent 가 없다. UI 없이 진행한다."); return; } // 그룹이 곧 겹치는 순서다. 일시정지 메뉴는 HUD 보다 깊이가 큰 Popup 그룹에 연다. OpenOnce(ui, UITable.HudForm, UITable.DefaultGroup); OpenOnce(ui, UITable.PauseMenuForm, UITable.PopupGroup); } /// <summary>이미 떠 있으면 두 번 열지 않는다(프로시저 재진입 방어).</summary> private static void OpenOnce(UIComponent ui, string assetName, string uiGroupName) { if (ui.HasUIForm(assetName)) return; ui.OpenUIForm(assetName, uiGroupName); } private void SubscribeUIFailure() { EventComponent events = GameEntry.GetComponent<EventComponent>(); if (events == null) { Log.Error("ProcedureMain: EventComponent 가 없다. UI 열기 실패를 감지하지 못한다."); return; } events.Subscribe(OpenUIFormFailureEventArgs.EventId, OnOpenUIFormFailure); mSubscribedUIFailure = true; } /// <summary>구독한 적이 있을 때만 해지한다. 미등록 핸들러 해지는 코어에서 즉시 예외다.</summary> private void UnsubscribeUIFailure() { if (!mSubscribedUIFailure) return; mSubscribedUIFailure = false; // 종료 순서에 따라 EventComponent 가 먼저 파괴돼 있을 수 있다. EventComponent events = GameEntry.GetComponent<EventComponent>(); if (events == null) return; events.Unsubscribe(OpenUIFormFailureEventArgs.EventId, OnOpenUIFormFailure); } private void OnOpenUIFormFailure(object sender, GameFramework.Event.GameEventArgs e) { OpenUIFormFailureEventArgs ne = e as OpenUIFormFailureEventArgs; if (ne == null) { GameAssert.Unreachable("ProcedureMain: OpenUIFormFailure 핸들러에 다른 타입이 들어왔다."); return; } Log.Error("UI 폼 '{0}'(그룹 {1}) 열기 실패: {2} — Addressables 주소와 UITable 문자열이 같은지 확인할 것.", ne.UIFormAssetName, ne.UIGroupName, ne.ErrorMessage); } protected override void OnUpdate(ProcedureOwner procedureOwner, float elapseSeconds, float realElapseSeconds) { base.OnUpdate(procedureOwner, elapseSeconds, realElapseSeconds); // OnEnter 가 아직 돌지 않았거나 이미 OnLeave 로 정리된 상태 방어. if (mGame == null) return; mGame.Update(elapseSeconds, realElapseSeconds); // 게임오버는 SurvivalGame 이 PlayerDied 를 받아 세운다. // 전이 시 OnLeave 가 mGame.Shutdown() 을 부르고, ProcedureGameOver 가 // 남은 엔티티를 확인한다. if (mGame.GameOver) { ChangeState<ProcedureGameOver>(procedureOwner); } } protected override void OnLeave(ProcedureOwner procedureOwner, bool isShutdown) { // 만든 순서의 반대로 치운다. 엔티티는 게임이 띄운 것이므로 엔티티가 먼저다. // 게임을 먼저 내리면, 회수되는 엔티티가 기대고 있을 수 있는 판 상태가 먼저 사라진다. CleanupEntities(); mGame?.Shutdown(); mGame = null; UnsubscribeUIFailure(); base.OnLeave(procedureOwner, isShutdown); } /// <summary> /// 이 판에서 만든 엔티티를 전부 회수한다. /// /// 로딩 중인 엔티티까지 반드시 같이 처리해야 한다. GetAllLoadedEntities 는 /// 이미 인스턴스화된 것만 반환하므로, ShowEntity 직후 Addressables 로드가 /// 진행 중인 엔티티는 빠진다. 그대로 두면 로드가 끝나는 순간 이전 판의 적이 /// 다음 판 화면에 스폰된다. /// </summary> private static void CleanupEntities() { EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); // 앱 종료 경로(isShutdown)로 들어오면 컴포넌트가 이미 파괴됐을 수 있다. // 이때는 회수할 대상도 함께 사라진 뒤라 조용히 빠져나가는 게 맞다. if (entityComponent == null) return; int loadingCount = entityComponent.GetAllLoadingEntityIds().Length; int loadedCount = entityComponent.GetAllLoadedEntities().Length; entityComponent.HideAllLoadingEntities(); entityComponent.HideAllLoadedEntities(); if (loadingCount > 0 || loadedCount > 0) { Log.Info("ProcedureMain: 엔티티 회수 — 로드됨 {0}, 로딩 중 {1}", loadedCount, loadingCount); } } } }
C#
복사

코드 읽기

if (mGame.GameOver) ChangeState<ProcedureGameOver>(procedureOwner); — 2장에서 예고한 그 줄입니다. 그때 이렇게 썼습니다. "게임 객체는 프로시저에게 넘어가라고 명령하지 않는다. GameOver 를 세워 둘 뿐이고, 프로시저가 그것을 보고 움직인다." 4장에서 플레이어가 PlayerDied 를 쏘고, SurvivalGame 이 GameOver 를 세우고, 이제 프로시저가 그것을 봅니다. 플레이어는 게임 객체를 모르고, 게임 객체는 프로시저를 모릅니다. 아는 방향이 언제나 위에서 아래로만 흐릅니다.
HideAllLoadedEntities() — 화면의 엔티티 전부에 대해 HideEntity 를 불러 줍니다. 적, 투사체, 이펙트, 양, 쓰러지던 선택 캐릭터까지 전부입니다. 각 엔티티의 OnHide 가 불리고, 3장부터 거기에 써 온 정리 코드(상태 기계 부수기, 에이전트 끄기, 주문서 반납)가 한꺼번에 실행됩니다.
HideAllLoadingEntities() — "오는 중"인 엔티티를 취소합니다. 5장의 스포너, 8장의 양에서 계속 만난 그 문제입니다. ShowEntity 를 부른 직후, 아직 화면에 뜨지 않은 적은 GetAllLoadedEntities 에 없습니다. 이것을 치우지 않으면 새 판의 캐릭터 선택 화면에 지난 판의 토끼가 뒤늦게 나타납니다.
OnLeave 안의 순서 — 엔티티를 먼저 치우고, 그다음 게임 객체를 내립니다. 엔티티는 게임 객체가 띄운 것입니다. 만든 순서의 반대로 치웁니다. 게임 객체를 먼저 내리면, 치워지는 엔티티가 기대고 있을 수 있는 것(판의 상태, 소식을 듣는 쪽)이 먼저 사라집니다. 3장에서 상태 기계를 "늦게 만들고 먼저 부순다"고 했습니다. 같은 원리의 다른 면입니다. 남이 기대는 것은 가장 나중에 치웁니다.
OnLeave 는 게임이 꺼질 때도 불립니다. 에디터에서 Play 를 멈추면 isShutdown 이 true 로 한 번 불립니다. 그때는 프레임워크의 다른 컴포넌트들이 이미 사라졌을 수 있습니다. CleanupEntities 첫머리의 if (entityComponent == null) return; 과 mGame?.Shutdown() 이 그 경우를 받아 줍니다. 3장에서 FindEventComponent 와 RequireEventComponent 를 나눈 것과 같은 사정입니다.
WarnIfSceneIsIncomplete() — 판을 시작하기 전에 씬을 한 번 점검합니다. 이 게임은 MainScene 에 놓인 NavMesh 와 카메라 추종 컴포넌트(4장)에 기됱니다. 다른 씬에 GameFramework 프리팹만 올려 Play 하면 게임은 시작되지만 적은 서 있기만 하고 카메라도 따라오지 않으며, 콘솔에는 Unity 의 NavMesh 경고(Failed to create agent …)만 쌓입니다. 예제 패키지를 받아 새 씬에서 실행하면 실제로 이렇게 됩니다. 그래서 무엇이 없는지 처음부터 한 줄로 알립니다. 4장에서 "실패는 시끄럽게"라고 한 것을 씬에도 적용한 것입니다.

실행해 보기

Play → 캐릭터를 고르고, 적에게 맞아 죽습니다.
ProcedureMain: Enter Character selected: Girl 무기 4종 요청. 마우스 좌클릭 발사 / Tab 으로 전환. Player spawned: Girl Game over. Final score: 0 ProcedureMain: 엔티티 회수 — 로드됨 7, 로딩 중 0 ProcedureGameOver: Enter. 잔존 엔티티 0개. R 키로 재시작.
Plain Text
복사
플레이어가 쓰러지고 1초 뒤, 화면의 적들이 한꺼번에 사라지고 가운데에 Game Over! 가 뜹니다. 카메라는 선택 화면의 앵글로 돌아갑니다(4장). 마지막 줄의 잔존 엔티티 0개를 확인합니다.
게임오버 화면. ① Current Procedure 가 ProcedureGameOver 로 바뀌었다 ② 화면 가운데의 Game Over! ③ 상태 표시줄에 찍힌 마지막 로그 — 잔존 엔티티 0개
2장에서 본 Procedure 인스펙터의 Current Procedure(①)가 플레이 도중에 처음으로 바뀌었습니다. Hierarchy 의 Entity 를 펼쳐 보면 엔티티 그룹이 전부 비어 있습니다.
R 을 누릅니다.
ProcedureMain: Enter
Plain Text
복사
HUD 가 닫혔다 다시 열리며 Select a character 가 뜨고, 두 캐릭터가 다시 서 있습니다. 점수는 0 입니다.
실제 플레이 — 적에게 둘러싸여 쓰러지면 적이 한꺼번에 사라지고 Game Over! 가 뜬다. R 을 누르면 선택 화면으로 돌아가고, 새 판은 점수 0 에서 시작한다
Console 창 검색칸에 Procedure 를 넣으면 프로시저가 오간 기록만 남습니다.
Console 을 Procedure 로 걸러 본 한 판. ① 찫 판 시작 ② 떠나며 엔티티 8개 회수 ③ 게임오버 진입 — 잔존 0개. 마지막 줄은 R 로 다시 들어간 ProcedureMain 이다
② 와 ③ 의 시각이 같습니다. 판을 치우는 일(ProcedureMain.OnLeave)과 게임오버에 들어가는 일(ProcedureGameOver.OnEnter)이 한 프레임 안에서 일어났기 때문입니다. 다음 절에서 그 한 프레임을 뜯어 봅니다.

9.4 죽는 순간부터 다시 시작하기까지

방금 본 몇 초 동안 일어난 일을 처음부터 끝까지 따라가 봅니다. 3장부터 만든 거의 모든 것이 한 번씩 등장합니다.
sequenceDiagram
    participant E as Enemy
    participant P as Player
    participant S as PlayerDyingState
    participant G as SurvivalGame
    participant H as HUDForm
    participant M as ProcedureMain
    participant O as ProcedureGameOver
    E->>P: ApplyDamage (체력 0)
    P->>P: OnDead — 무기 끄기, 콜라이더 끄기, Die 애니메이션
    Note over S: 1초 동안 타이머
    S->>P: TickDeathSequence 완료
    P-->>G: PlayerDied (다음 프레임에 도착)
    P-->>H: PlayerDied (다음 프레임에 도착)
    P->>P: SafeHide
    Note over G,H: ── 다음 프레임 ──
    G->>G: GameOver = true
    H->>H: "Game Over!" 켜기
    M->>M: GameOver 를 봤다 → ChangeState
    M->>M: OnLeave — 엔티티 전부 회수, 게임 객체 Shutdown
    M->>O: OnEnter — 잔존 엔티티 확인
    Note over O: R 키 대기
    O->>O: OnLeave — HUD, 일시정지 폼 닫기
    O->>M: OnEnter — UI 열기, 새 SurvivalGame
Mermaid
복사
이 그림에서 두 군데가 순서에 기대고 있습니다.
① "죽었다"는 소식은 연출이 끝난 뒤에 나간다. 4장에서 만든 그대로입니다. OnDead(체력이 0 이 된 순간)가 아니라 TickDeathSequence(1초 뒤)에서 PlayerDied 를 쏩니다. OnDead 에서 바로 쐈다면 어떻게 될까요? 다음 프레임에 GameOver 가 서고, 프로시저가 넘어가면서 HideAllLoadedEntities 로 플레이어를 치웁니다. 쓰러지는 애니메이션이 한 프레임 보이고 끝납니다. 그때는 "나중에 알게 된다"고 넘어갔던 이유가 이것입니다.
② 한 프레임 안에서 일이 일어나는 순서가 정해져 있다. 프레임워크는 매 프레임 자기 부품들을 정해진 순서로 한 번씩 돌립니다.
순서
부품
이 프레임에 하는 일
1
이벤트
지난 프레임에 Fire 된 이벤트를 전달한다 → GameOver = true, "Game Over!" 켜기
2
상태 기계
엔티티들의 상태를 한 번씩 돌린다
3
엔티티
각 엔티티의 OnUpdate
…
맨 끝
프로시저
ProcedureMain.OnUpdate → GameOver 를 본다 → 전이
이벤트가 맨 먼저, 프로시저가 맨 나중입니다. 그래서 GameOver 가 선 바로 그 프레임의 끝에 프로시저가 그것을 봅니다. 그리고 프로시저가 화면을 치우는 것은 그 프레임의 다른 모든 것이 자기 일을 마친 뒤입니다.
이 순서를 외울 필요는 없습니다. 기억할 것은 두 가지입니다. Fire 한 이벤트는 다음 프레임의 맨 처음에 도착한다(3장). 그리고 판을 넘기는 결정은 프레임의 맨 끝에 내려진다. "방금 이벤트를 쐈는데 왜 아직 반영이 안 됐지?"나 "왜 한 프레임 늦지?" 같은 의문은 대부분 이 둘로 풀립니다.

9.5 재시작에서 깨지는 코드 — 선택 화면의 조명

캐릭터 선택 화면에는 마우스를 따라다니는 조명이 하나 있습니다. 원작에서는 씬에 놓인 CharacterSelectSpotlight 오브젝트이고, 그 아래에 실제 조명인 Select spot PC 가 자식으로 들어 있습니다. 캐릭터를 고르면 꺼져야 합니다.
원작에서 이 조명의 스크립트는 이렇게 생겼습니다.
void Update() { if (GameManager.Instance.IsPlayerChosen) { gameObject.SetActive(false); // 고르면 스스로를 끈다 return; } // … 마우스를 따라간다 }
C#
복사
원작에서는 아무 문제가 없습니다. 재시작이 씬 리로드라서, 다음 판에는 새로 만들어진 조명이 켜진 채로 나오기 때문입니다.
우리 방식에서는 어떻게 될까요? 조명은 씬에 하나뿐이고 파괴되지 않습니다. 첫 판에서 캐릭터를 고르는 순간 스스로를 끕니다. 그리고 꺼진 오브젝트의 Update 는 불리지 않습니다. 둘째 판이 시작돼도 조명은 "다시 켜져야 한다"는 것을 알 방법이 없습니다. 영영 꺼져 있습니다.
이것이 9.1 절에서 말한 대가의 실제 모습입니다. 씬 리로드에 기대던 코드는 프로시저 전이에서 조용히 깨집니다.
고치는 방법은 여럿입니다. 자기 오브젝트 대신 자식만 끄고, 매 프레임 "지금 선택 중인가"를 묻게 할 수도 있습니다. 이 책은 더 단순한 쪽을 고릅니다. 이 조명은 선택 화면에서만 있어야 하는 것입니다. 나타났다 사라지는 것, 3장부터 계속 만들어 온 엔티티입니다.
씬에서 조명 오브젝트를 지우고, 프리팹으로 만들어 Addressables 에 CharacterSelectSpotlight 주소로 등록합니다. 프리팹에는 스크립트를 붙이지 않고, 자식 Select spot PC 는 꺼 둔 채로 저장합니다. (예제 프로젝트에는 되어 있습니다. 프리팹은 Assets/GameMain/Effects/ 에 있고, 엔티티 그룹은 6장의 Effect 를 같이 씁니다.) 주문서와 로직입니다.
// Assets/GameMain/Entity/EntityData/SpotlightData.cs using GameFramework; namespace ToyBoxNightmare { /// <summary> /// 캐릭터 선택 조명(<see cref="CharacterSelectSpotlight"/>)의 스폰 데이터. /// 조명은 프리팹에 놓인 자리 그대로 쓰므로 Id 말고는 담을 것이 없다. /// </summary> public class SpotlightData : EntityData { public static SpotlightData Create(int entityId, int typeId) { SpotlightData data = ReferencePool.Acquire<SpotlightData>(); data.Fill(entityId, typeId); return data; } } }
C#
복사
// Assets/GameMain/Camera/CharacterSelectSpotlight.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 캐릭터 선택 화면의 조명. 커서가 가리키는 바닥을 따라 비춘다. /// /// <b>엔티티다.</b> "선택 중에만 있어야 하는 것" 이라 수명을 엔티티로 표현한다 — /// <see cref="SurvivalGame"/> 이 판을 시작하면서 띄우고 캐릭터가 골라지면 회수한다. /// 그래서 "지금 선택 중인가" 를 매 프레임 따로 묻지 않는다. 떠 있으면 선택 중이다. /// /// 프리팹(<c>CharacterSelectSpotlight</c>)에는 이 로직을 붙여 두지 않는다. 프레임워크가 띄울 때 붙인다. /// </summary> public class CharacterSelectSpotlight : EntityLogicBase { /// <summary>커서를 따라다닐 조명 자식. 프리팹에서는 꺼진 채로 저장돼 있다.</summary> private const string SpotlightPath = "Select spot PC"; private GameObject mSpotlight = null; protected internal override void OnInit(object userData) { base.OnInit(userData); Transform spotlight = CachedTransform.Find(SpotlightPath); if (spotlight == null) { Log.Warning("CharacterSelectSpotlight: 자식 '{0}' 을 찾지 못했다. 선택 화면 연출이 나오지 않는다.", SpotlightPath); return; } mSpotlight = spotlight.gameObject; } protected internal override void OnShow(object userData) { base.OnShow(userData); SetSpotlightActive(true); } protected internal override void OnHide(bool isShutdown, object userData) { // 풀에 돌아갈 때는 프리팹 기본 상태(꺼짐)로 되돌린다. SetSpotlightActive(false); base.OnHide(isShutdown, userData); } protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); if (mSpotlight == null) return; // 커서가 바닥 밖(하늘, UI 위)을 가리키면 마지막 방향을 그대로 둔다. if (!MouseGround.TryGetGroundPoint(out Vector3 groundPoint)) return; mSpotlight.transform.LookAt(groundPoint); } private void SetSpotlightActive(bool active) { if (mSpotlight == null) return; if (mSpotlight.activeSelf == active) return; mSpotlight.SetActive(active); } } }
C#
복사
띄우고 치우는 것은 SurvivalGame 이 합니다. 3장에서 선택 캐릭터를 다루던 자리마다 조명이 한 줄씩 따라붙습니다. 먼저 상수와 필드입니다.
/// <summary>선택 화면 조명. 캐릭터를 고르는 동안에만 떠 있는 엔티티다.</summary> private const string SpotlightAsset = "CharacterSelectSpotlight";
C#
복사
private CharacterSelectSpotlight mSpotlight = null;
C#
복사
ResetRoundState 에 mSpotlight = null; 을 추가하고, 선택 캐릭터를 띄우는 자리에서 같이 띄웁니다.
private void SpawnSelectCharacters() { SpawnSelectCharacter(GirlCharacterKey, GirlSelectPosition); SpawnSelectCharacter(BoyCharacterKey, BoySelectPosition); SpawnSelectSpotlight(); } /// <summary> /// 선택 화면 조명을 띄운다. 조명은 "선택 중" 이라는 상태 그 자체라서, /// 선택 캐릭터와 같이 띄우고 같이 치운다(<see cref="DismissSelectCharacters"/>). /// </summary> private void SpawnSelectSpotlight() { EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) return; int id = EntitySerialId.Next(); entityComponent.ShowEntity( id, typeof(CharacterSelectSpotlight), SpotlightAsset, WeaponTable.EffectGroup, SpotlightData.Create(id, DefaultEntityTypeId)); }
C#
복사
다 뜬 조명을 기억해 둡니다. OnShowEntitySuccess 에 갈래를 하나 더하고 메서드를 씁니다.
if (ne.EntityLogicType == typeof(CharacterSelectSpotlight)) { CacheSpotlight(ne.Entity); return; }
C#
복사
private void CacheSpotlight(Entity entity) { CharacterSelectSpotlight spotlight = entity.Logic as CharacterSelectSpotlight; if (spotlight == null) { Log.Error("CharacterSelectSpotlight 로 스폰했는데 로직이 그 타입이 아니다: {0}", entity.EntityAssetName); return; } // 조명이 로드되는 사이에 이미 캐릭터를 골랐다. 선택 화면이 끝났으니 바로 돌려보낸다. // (CharacterSelected 구독은 선택 순간에 스스로 풀리므로 그 플래그로 판별한다.) if (!mCharacterSelectSubscribed) { spotlight.SafeHide(); return; } mSpotlight = spotlight; }
C#
복사
캐릭터를 고르면 같이 치웁니다. DismissSelectCharacters 에서 낙선한 캐릭터를 처리한 다음에 넣고, 맨 끝의 참조 비우기에도 한 줄을 더합니다.
// 조명이 아직 로드 중이면 null 이다. 그 경우는 도착하는 순간 CacheSpotlight 가 돌려보낸다. if (mSpotlight != null) { mSpotlight.SafeHide(); } // 회수한 뒤에는 참조를 반드시 비운다. mGirlSelect = null; mBoySelect = null; mSpotlight = null;
C#
복사

코드 읽기

조명 로직이 원작보다 짧아졌습니다. 눈여겨볼 것은 없는 코드입니다.
"지금 선택 중인가"를 묻는 코드가 없습니다. 떠 있으면 선택 중입니다. 조명이 할 일은 떠 있는 동안 커서를 따라가는 것뿐이고, 언제 뜨고 언제 사라질지는 띄운 쪽이 정합니다. 8장에서 양이 스스로 수명을 정하지 않던 것과 같은 모양입니다.
재시작을 위한 코드가 한 줄도 없습니다. SurvivalGame 은 판마다 새로 만들어지고(9.3 절), Initialize 가 선택 캐릭터와 조명을 띄웁니다. 캐릭터를 고르면 DismissSelectCharacters 가 같이 치웁니다. 플레이어가 죽어 판이 끝나고 ProcedureMain 에 다시 들어오면 새 SurvivalGame 이 다시 띄웁니다. 원작의 "스스로를 끈다"가 깨졌던 이유는 끄는 코드만 있고 켜는 코드가 없어서였습니다. 엔티티로 만들면 켜는 것(ShowEntity)과 끄는 것(SafeHide)이 처음부터 짝으로 있습니다.
조명이 로드되는 사이에 캐릭터를 골라 버리면? CacheSpotlight 의 가운데 갈래가 그 경우입니다. DismissSelectCharacters 가 불렸을 때 mSpotlight 는 아직 null 이라 치울 것이 없었고, 조명은 그 뒤에 도착합니다. 도착했을 때 선택이 이미 끝나 있으면 받자마자 돌려보냅니다. 8장의 양에서 본 "필요 없어진 뒤에 도착하면?" 과 같은 문제이고 같은 답입니다.
판을 넘나들며 씬에 계속 살아 있는 오브젝트가 스스로 상태를 바꾸면, 그 반대 방향으로 되돌리는 코드도 누군가 써야 합니다. 씬에 놓지 않고 필요한 동안만 띄우면 되돌릴 것이 없습니다. 이 책의 게임에서 씬에 직접 놓인 것은 카메라, 조명(Lighting), 배경, 바닥 콜라이더, EventSystem, 그리고 GameFramework 뿐입니다. 전부 판과 상관없이 그대로 있는 무대입니다.

9.6 풀 재사용 점검표

재시작이 생겼으니, 3장부터 만든 것들이 두 번째로 나올 때를 처음으로 볼 수 있습니다. 아래는 이 책에서 풀 재사용 때문에 넣었던 줄들을 모은 표입니다. 각 줄을 지우면 무슨 일이 생기는지가 오른쪽에 있습니다.
점검
이 책의 예
어기면 보이는 것
OnShow / OnOpen 에서 바뀔 수 있는 값을 전부 되돌리는가
Player.ResetRuntimeState, Enemy.ResetCombatState, HUDForm.ResetVisuals
둘째 판의 캐릭터가 지난 판의 깃발을 그대로 들고 나온다(나오자마자 죽는 연출로 들어간다). HUD 에 지난 판 점수와 Game Over! 가 남아 있다
애니메이터를 Rebind() 하는가. 끈 것은 켜고 나서
PlayerSelectLogic.ResetVisualState, Enemy.ResetPresentation
얼어 있다가 회수된 적이 애니메이션이 멈춘 채로 미끄러져 온다
꺼 둔 콜라이더·오브젝트를 다시 켜는가
mCollider.enabled = true, SetCollidersEnabled(true)
클릭이 안 되는 선택 캐릭터. 맞지 않는 적
OnHide 에서 상태 기계를 맨 먼저 부수는가
세 엔티티의 DestroyStateMachine()
Already exist FSM '…'. 예외
구독과 해지가 짝인가. 깃발로 지키는가
mCharacterSelectSubscribed, HUDForm.mSubscribed
둘째 판에서 not allow duplicate handler 예외
한 번만 할 일을 OnInit 에 두었는가
PauseMenuForm 의 AddListener
판이 거듭될수록 버튼 한 번에 여러 번 반응한다
엔티티를 SafeHide 로만 치우는가
전부
두 번 치우려다 예외. 또는 Destroy 로 부숴서 풀이 어긋난다
치운 엔티티의 참조를 비우는가
mGirlSelect = null, mAliveBuffer.Clear()
다른 역할로 다시 나온 인스턴스를 엉뚱하게 건드린다
오래 들고 있는 참조는 Available 을 확인하거나 번호로 드는가
SurvivalGame.Player, SlimeProjectile.mTargetId, AllySquad.mAlly
이미 사라진 대상을 쫓거나 때린다
참조 풀 객체의 Clear() 가 모든 필드를 되돌리는가. 기본값까지
PlayerData.Clear, EnemyData.Clear
새 적이 지난 적의 체력·속도를 갖고 나온다
참조 풀에서 꺼낸 것을 정확히 한 번 돌려주는가
EntityLogicBase.ReleaseOwnedData, GameBase.ReleaseSpawnData
두 번이면 예외. 안 돌려주면 계속 새로 만든다
주인이 든 순수 객체의 상태도 되돌리는가
EnemyDebuffs.ClearAll, WeaponLoadout.Equip 의 쿨다운 초기화
얼어붙은 채 나오는 적. 시작하자마자 쿨다운 중인 무기
켜 둔 것을 받았을 때 모습으로 돌려놓는가
WeaponBase.OnHide, FrostWeapon.OnWeaponShow, Enemy.RestoreChildLayers
다음 판에 무기를 들자마자 지난 판의 분사나 조준 고리가 켜져 있다
파티클을 멈추고 → 옮기고 → 재생하는가
ProjectileLogicBase, HitEffect.RestartParticles
지난 위치에서 이번 위치까지 연기 줄이 그어진다
NavMeshAgent 를 ResetPath 후 끄고 풀에 넣는가
Enemy.DisableAgent, Ally.StopAgent
다시 나온 적이 지난번 길을 이어서 걷는다
"기다렸다 무엇을 한다"를 코루틴에 맡기지 않았는가
PlayerSelectDyingState, 사망 타이머들
회수와 함께 코루틴이 사라져 뒷일이 영영 안 일어난다
오는 중인 것도 치우는가
HideAllLoadingEntities, AllySquad.mUnsummonRequested
지난 판의 적이나 양이 새 판에 뒤늦게 나타난다
판마다 나타났다 사라져야 하는 것을 씬에 놓아두지 않았는가
CharacterSelectSpotlight (엔티티로 띄운다)
스스로를 끈 오브젝트가 둘째 판부터 영영 켜지지 않는다
스위치를 가진 쪽이 떠날 때 원래대로 돌려놓는가
PauseMenuForm.OnClose 의 GamePause.Clear()
다음 판이 시간이 멈춘 채로 시작된다
켜 둔 반복음을 끄는가
FrostWeapon.SetConeSoundActive, Enemy.SetSlimeFxActive
무기를 바꾸거나 적이 사라진 뒤에도 소리가 계속 난다
다른 엔티티에 붙는 엔티티가 주인이 먼저 사라진 경우를 따지는가
WeaponBase.AttachToOwner 의 HasEntity, WeaponBase.OnUpdate
없는 부모에 붙이려다 예외. 또는 주인 없는 무기가 화면에 남는다
표가 깁니다. 하지만 줄마다 생각은 하나입니다. "이것이 두 번째로 쓰일 때, 첫 번째의 흔적이 남아 있지 않은가?" 새 엔티티나 UI 폼을 만들 때마다 이 질문을 하고, 필요하면 이 표를 훑으세요.
💡
이 버그들의 공통점은 첫 판에서는 절대 나타나지 않는다는 것입니다. 처음 만들어진 인스턴스는 언제나 깨끗하기 때문입니다. 그래서 "잘 되는데?" 하고 넘어갔다가 한참 뒤에 "가끔 이상하다"는 제보로 돌아옵니다. 풀을 쓰는 코드를 확인할 때는 반드시 두 번째를 봅니다. 재시작을 가능한 한 일찍 만들어 두는 것이 좋은 이유이기도 합니다.

미뤄 둔 연습 문제들

앞 장들에서 "재시작이 생기면 다시 해 보라"고 한 것들을 이제 할 수 있습니다. 하나씩 해 보고 반드시 되돌려 놓으세요.
어디서
할 일
둘째 판에서 볼 것
3장
PlayerSelectLogic.OnShow 의 ResetVisualState(); 를 지운다
지난 판에 고르지 않았던 캐릭터가 멀쩡히 서 있는데 클릭이 안 된다. 낙선 연출이 꺼 둔 콜라이더가 그대로이기 때문이다
7장
Player.OnShow 의 스폰 통보 FireHealthChanged(HitPointRatio, HitPointRatio); 를 지운다
체력 바가… 가득 차 있습니다. 왜일까요? HUDForm.ResetVisuals 가 이미 채워 놓기 때문입니다. 두 겹으로 막혀 있던 것입니다. ResetVisuals 의 체력 줄까지 지워야 빈 체력 바가 보입니다
7장
PauseMenuForm 의 AddListener 다섯 줄을 OnInit 에서 OnOpen 으로 옮긴다
셋째 판쯤에서 Esc → Resume 을 누르면… 겉으로는 차이가 안 보입니다. Resume 은 여러 번 불려도 결과가 같기 때문입니다. Resume() 첫 줄에 Log.Info("Resume"); 를 넣으면 판 수만큼 찍힙니다
둘째와 셋째에서 "지웠는데도 멀쩡하다"는 결과가 나오는 것도 배울 점입니다. 실수 하나로는 버그가 드러나지 않도록 여러 겹으로 막혀 있거나, 여러 번 불려도 괜찮게(멱등하게) 만들어져 있기 때문입니다. 그래도 고쳐야 합니다. 겹이 하나 벗겨진 코드는 다음 실수 하나에 무너집니다.

9.7 세 판을 연달아

마지막 확인입니다. 한 번에 세 판을 합니다.
첫 판 — Girl 을 고릅니다. 무기 넷을 다 써 보고, 양도 한 번 부르고, 일시정지도 한 번 합니다. 죽습니다. R.
둘째 판 — 이번에는 Boy 를 고릅니다. 지난 판에 쓰러졌던 Boy 가 멀쩡히 서 있고 클릭이 되는지 봅니다. Girl 이 쓰러지는지 봅니다. 조명이 다시 켜져 마우스를 따라오는지 봅니다. 죽기 직전에 악취를 던지고, 그것이 날아가는 도중에 죽어 봅니다. R.
셋째 판 — 아무나 고릅니다. 지난 판의 악취 구름이나 적이 뒤늦게 나타나지 않는지 봅니다. 점수가 0 에서 시작하는지, 양 아이콘이 꺼져 있는지, 무기가 번개부터 시작하고 쿨다운이 없는지 봅니다. 적이 맞을 때 솜이 튀고, 얼고, 도망치고, 쓰러져 가라앉는 것이 첫 판과 같은지 봅니다.
세 판이 같으면, 3장부터 써 온 "풀에서 다시 나올 때를 위해"가 전부 제 일을 한 것입니다.
재사용이 실제로 일어나는지 숫자로도 볼 수 있습니다. Game 창 왼쪽 위의 DEBUGGER 상자를 클릭해 창을 펼치고, Profiler → Object Pool 탭을 엽니다. 엔티티 그룹마다 풀이 하나씩 있고, 풀에 든 인스턴스 수가 나옵니다. 판을 거듭해도 이 숫자는 어느 선에서 더 늘지 않습니다. 필요한 만큼만 만들어지고, 그 뒤로는 계속 돌려쓰고 있다는 뜻입니다.

9.8 잘 안 될 때

증상
원인과 해결
죽은 뒤 Game Over! 는 뜨는데 적이 안 사라지고 R 도 안 먹는다
ProcedureMain.OnUpdate 에 if (mGame.GameOver) ChangeState<…> 가 없습니다
죽는 순간 예외. 프로시저를 찾을 수 없다는 내용
Procedure 컴포넌트의 Available Procedures 에서 ToyBoxNightmare.ProcedureGameOver 를 체크하지 않았습니다
ProcedureGameOver: Enter. 잔존 엔티티 3개.
ProcedureMain.OnLeave 에서 CleanupEntities() 를 부르지 않았거나, 어떤 엔티티가 SafeHide 를 거치지 않고 꺼졌습니다
쓰러지는 애니메이션 없이 바로 Game Over! 가 뜬다
PlayerDied 를 OnDead 에서 쐈습니다. TickDeathSequence 의 타이머가 끝난 뒤에 쏴야 합니다
R 을 눌렀더니 HUD 가 두 겹이다 / 점수가 두 번 오른다
ProcedureGameOver.OnLeave 에서 HUD 를 닫지 않았는데 OpenOnce 의 HasUIForm 검사도 없습니다
새 판의 선택 화면에 지난 판의 적이 걸어 들어온다
HideAllLoadingEntities() 가 빠졌습니다
둘째 판에서 캐릭터를 고르는 순간 not allow duplicate handler
지난 판의 SurvivalGame.Shutdown 이 구독을 다 끊지 않았습니다. UnsubscribeEvents 와 SubscribeEvents 의 줄을 하나씩 맞춰 봅니다
둘째 판에서 Already exist FSM '…'.
어떤 엔티티의 OnHide 가 상태 기계를 부수지 않았습니다
둘째 판이 멈춘 채로 시작된다
일시정지 메뉴를 띄운 채 판이 끝났습니다. PauseMenuForm.OnClose 의 GamePause.Clear() 를 확인합니다
선택 화면에 조명이 안 나온다
SpawnSelectCharacters 에서 SpawnSelectSpotlight() 를 부르지 않았거나, 프리팹이 Addressables 에 CharacterSelectSpotlight 주소로 등록돼 있지 않습니다
캐릭터를 고른 뒤에도 조명이 남아 마우스를 따라다닌다
DismissSelectCharacters 에서 mSpotlight.SafeHide() 를 부르지 않았거나, OnShowEntitySuccess 에 조명 갈래가 없어 mSpotlight 가 null 로 남았습니다
둘째 판의 카메라가 선택 화면을 비추지 않는다
카메라가 플레이어를 EntityFinder.FindPlayer() 로 매 프레임 찾지 않고, 한 번 받은 참조를 계속 들고 있습니다

이 장에서 만든 것

파일
내용
Procdure/ProcedureGameOver.cs
결과 화면. R 을 기다리고, 떠날 때 UI 를 닫는다
Procdure/ProcedureMain.cs (수정)
게임오버를 보고 전이, 떠날 때 엔티티 전부 회수
Camera/CharacterSelectSpotlight.cs, Entity/EntityData/SpotlightData.cs
선택 화면의 조명. 선택하는 동안에만 떠 있는 엔티티
Game/SurvivalGame.cs (수정)
조명을 선택 캐릭터와 같이 띄우고 같이 치운다
GameFramework 프리팹의 Procedure 컴포넌트 (수정)
ProcedureGameOver 등록

정리

•
재시작은 씬 리로드가 아니라 프로시저 전이로 한다. 프레임워크와 풀이 살아남고 로딩이 없다. 대신 되돌리는 일은 우리 몫이다
•
프로시저는 게임 객체의 GameOver 를 보고 넘어간다. 아는 방향은 위에서 아래로만 흐른다
•
떠날 때는 오는 중인 것까지 치우고, 만든 순서의 반대로 내린다. 남이 기대는 것이 가장 나중이다
•
열고 닫는 짝이 다른 프로시저에 있어도 된다. 어디서 열고 어디서 닫는지가 분명하면 된다
•
Fire 한 이벤트는 다음 프레임의 맨 처음에 도착하고, 판을 넘기는 결정은 프레임의 맨 끝에 내려진다
•
판마다 나타났다 사라져야 하는 것은 씬에 놓지 않고 엔티티로 띄운다. 켜는 것과 끄는 것이 처음부터 짝이 된다
•
풀을 쓰는 코드는 두 번째를 본다. "이것이 다시 쓰일 때 첫 번째의 흔적이 남아 있지 않은가?"
•
여러 겹으로 막고, 여러 번 불려도 괜찮게 만든다. 그래도 벗겨진 겹은 고친다

직접 해 보기

1.
ProcedureGameOver 에 "5초 동안 아무 키도 누르지 않으면 자동으로 다시 시작"을 넣어 보세요. 프로시저에 float 필드 하나와 OnEnter 에서의 초기화가 필요합니다. elapseSeconds 와 realElapseSeconds 중 어느 것으로 세야 할까요? 일시정지 메뉴가 떠 있을 때를 생각해 보세요.
2.
최고 점수를 기억하게 해 보세요. ProcedureGameOver.OnEnter 가 불릴 때는 SurvivalGame 이 이미 내려간 뒤입니다(왜일까요?). 점수를 어디서, 언제 받아 두어야 할까요? 점수를 아는 것은 ProcedureMain 이 들고 있는 mGame 뿐이고, 그것이 사라지기 전의 마지막 자리는 ChangeState 를 부르기 직전입니다(10장의 과제 C 가 이 문제를 다룹니다). 프레임워크의 Setting 컴포넌트(SetInt / GetInt / Save)를 쓰면 게임을 껐다 켜도 남습니다.
3.
ProcedureMain.OnLeave 에서 CleanupEntities(); 와 mGame?.Shutdown(); 의 순서를 바꿔 보세요. 지금은 차이가 없을 수도 있습니다. 어떤 엔티티가 OnHide 에서 "사라졌다"는 이벤트를 즉시(FireNow) 쏘고, 게임 객체가 그것을 듣고 점수를 올린다고 상상하면 순서가 왜 중요한지 보입니다.
4.
Debugger 창의 Object Pool 탭을 열어 두고 세 판을 해 보세요. Enemy 그룹의 인스턴스 수가 첫 판 뒤, 둘째 판 뒤, 셋째 판 뒤에 각각 얼마인지 적어 봅니다.
5.
(조금 어려움) 9.5 절의 조명을 엔티티가 아니라 씬에 놓인 프레임워크 컴포넌트로도 만들어 보세요. 4장의 카메라처럼 GameFrameworkComponent 를 상속하고, 매 프레임 EntityFinder.FindPlayer() 로 "지금 선택 중인가"를 물어 자식 조명을 켜고 끕니다(자기 오브젝트를 끄면 안 됩니다. 왜일까요?). 엔티티 방식과 코드 양을 비교해 보세요. 플레이어가 없지만 선택 화면도 아닌 순간(게임오버 화면)에는 두 방식이 각각 어떻게 보이나요?
다음 장 — 10. 확장 과제 — 여기서부터는 직접: 게임이 완성됐습니다. 마지막 장에서는 이 게임을 넓혀 볼 과제들을 모았습니다.