서비스
교육 철학

02. Procedure 로 게임 흐름 잡기

Unity 로 게임을 만들어 본 사람이라면 GameManager 라는 이름의 스크립트를 한 번쯤 만들어 봤을 것입니다. 처음에는 점수 하나를 들고 있던 그 스크립트에 "게임이 시작됐는가", "일시정지인가", "게임오버인가", "재시작 버튼을 눌렀는가"가 하나씩 들어오고, 어느 날 열어 보면 Update 안이 이런 모양이 되어 있습니다.
void Update() { if (!isStarted) { /* 캐릭터 선택 대기 */ return; } if (isGameOver) { if (restartRequested) { /* 전부 치우고 처음부터 */ } return; } if (isPaused) return; // 여기서부터가 진짜 게임 }
C#
복사
bool 이 네 개면 조합은 열여섯 가지입니다. "게임오버인데 일시정지" 같은, 있어서는 안 되는 조합까지 전부 코드가 감당해야 합니다. 재시작은 더 골치 아픕니다. 지난 판에 켜 둔 플래그를 하나라도 되돌리지 않으면 다음 판이 이상하게 시작됩니다.
프레임워크는 이 문제를 프로시저로 풉니다. 게임의 큰 단계 하나를 클래스 하나로 만들고, 한 번에 하나만 실행합니다. "플레이 중"의 코드는 게임오버를 모르고, "게임오버"의 코드는 플레이를 모릅니다.
이 장에서는 1장에서 껍데기만 만든 ProcedureMain 에 "한 판"을 책임지는 객체를 만들어 넣습니다. 장이 끝나면 콘솔에 이렇게 찍힙니다.
ProcedureMain: Enter SurvivalGame: 판 시작 SurvivalGame: 3초 경과 SurvivalGame: 6초 경과
Plain Text
복사
🎯
이 장에서 하는 일
1.
프로시저가 언제 무엇을 호출받는지 이해한다
2.
한 판의 규칙을 담을 GameBase 와 SurvivalGame 을 만든다
3.
ProcedureMain 이 그 객체를 만들고, 돌리고, 치우게 한다

2.1 프로시저의 일생

프로시저는 프레임워크로부터 다섯 가지 호출을 받습니다.
graph LR
    A["OnInit<br>(게임 켤 때 한 번)"] --> B["OnEnter<br>(이 단계가 시작될 때)"]
    B --> C["OnUpdate<br>(매 프레임)"]
    C --> C
    C --> D["OnLeave<br>(이 단계가 끝날 때)"]
    D -.->|"다시 이 단계로 돌아오면"| B
    D --> E["OnDestroy<br>(게임 끌 때 한 번)"]
Mermaid
복사
호출
언제
여기에 쓰는 것
OnInit
프레임워크가 켜질 때 딱 한 번
거의 쓰지 않는다
OnEnter
이 프로시저로 들어올 때마다
이 단계에 필요한 것을 만든다
OnUpdate
이 프로시저가 실행 중인 동안 매 프레임
이 단계의 진행. 그리고 "다음 단계로 넘어갈 때인가" 판단
OnLeave
이 프로시저에서 나갈 때마다
OnEnter 에서 만든 것을 치운다
OnDestroy
프레임워크가 꺼질 때 딱 한 번
거의 쓰지 않는다
핵심은 가운데 셋이고, 그중에서도 OnEnter 와 OnLeave 가 짝이라는 점입니다. 들어올 때 만든 것은 나갈 때 치웁니다. 이 짝만 지키면 재시작은 공짜로 따라옵니다. 게임오버로 나갔다가(OnLeave) 다시 들어오면(OnEnter) 모든 것이 새로 만들어지기 때문입니다.
이 책의 게임은 프로시저 두 개가 서로를 오가는 구조입니다.
graph LR
    S(("시작")) --> M["ProcedureMain<br>캐릭터 선택 + 한 판 플레이"]
    M -->|"플레이어 사망"| G["ProcedureGameOver<br>결과를 보여 주고 재시작을 기다린다"]
    G -->|"R 키"| M
Mermaid
복사
ProcedureGameOver 는 9장에서 만듭니다. 지금은 ProcedureMain 하나만 가지고 진행합니다.
💡
프로시저는 MonoBehaviour 가 아닙니다. GameObject 에 붙지 않고, 인스펙터에 나타나지 않으며, Start 나 Unity 의 Update 가 불리지 않습니다. 프레임워크가 1장에서 적어 준 이름으로 직접 new 해서 들고 있는 평범한 C# 객체입니다. 그래서 GetComponent 나 StartCoroutine 을 쓸 수 없습니다.

2.2 "한 판"을 객체로 만들기

ProcedureMain 의 OnEnter 에 게임 코드를 전부 써도 동작은 합니다. 하지만 그러면 프로시저가 곧 예전의 GameManager 가 됩니다. 그래서 역할을 나눕니다.
프로시저 (ProcedureMain)
게임 객체 (SurvivalGame)
책임
언제 시작하고 끝내는가
한 판 동안 무슨 일이 일어나는가
아는 것
다음에 갈 프로시저
점수, 경과 시간, 플레이어, 적 스포너
수명
게임이 켜져 있는 내내 하나
판마다 새로 만들어지고 버려진다
마지막 줄이 중요합니다. 게임 객체는 판이 시작될 때 new 로 만들고 판이 끝나면 버립니다. 다음 판에는 새 객체가 만들어지므로 점수도 시간도 저절로 0 에서 시작합니다. "재시작할 때 초기화를 빠뜨렸다"는 버그가 구조적으로 생기기 어렵습니다.

GameMode

가장 작은 파일부터 만듭니다. Assets/GameMain 아래에 Game 폴더를 만들고 시작합니다.
// Assets/GameMain/Game/GameMode.cs namespace ToyBoxNightmare { /// <summary> /// 게임 모드 식별자. <see cref="GameBase.GameMode"/> 가 돌려주는 값이다. /// </summary> public enum GameMode : byte { /// <summary>끝없이 몰려오는 적을 버티는 뱀서라이크 모드. 현재 유일한 모드다.</summary> Survival, } }
C#
복사
모드가 하나뿐인데 굳이 열거형을 두는 이유는, 나중에 "타임 어택"이나 "보스 러시" 같은 모드를 추가할 자리를 미리 만들어 두는 것입니다.

GameBase

모든 게임 모드가 공통으로 가질 뼈대입니다.
// Assets/GameMain/Game/GameBase.cs namespace ToyBoxNightmare { /// <summary> /// 한 판(라운드)의 규칙을 담는 게임 모드의 공통 베이스. /// 프로시저(<see cref="ProcedureMain"/>)가 생성 → Initialize → 매 프레임 Update → Shutdown 순으로 굴린다. /// /// MonoBehaviour 가 아니라 순수 C# 객체다. 재시작할 때마다 새 인스턴스가 만들어지므로 /// 파생 클래스는 상태 초기화를 <see cref="Initialize"/> 에서 명시적으로 해야 한다. /// </summary> public abstract class GameBase { /// <summary>이 모드의 식별자.</summary> public abstract GameMode GameMode { get; } /// <summary> /// 게임오버 확정 여부. 프로시저가 이 값을 보고 ProcedureGameOver 로 전이한다. /// 한 번 true 가 되면 같은 판에서 다시 false 로 돌아가지 않는다. /// </summary> public bool GameOver { get; protected set; } /// <summary> /// 판 시작. 파생 클래스는 반드시 base 를 먼저 호출해야 <see cref="GameOver"/> 가 내려간다. /// </summary> public virtual void Initialize() { GameOver = false; } /// <summary>판 종료. 구독 해제와 참조 정리를 여기서 한다.</summary> public virtual void Shutdown() { } public virtual void Update(float elapseSeconds, float realElapseSeconds) { } } }
C#
복사

코드 읽기

메서드 셋이 프로시저의 셋과 그대로 대응합니다. Initialize 는 OnEnter 에서, Update 는 OnUpdate 에서, Shutdown 은 OnLeave 에서 불릴 것입니다.
GameOver 속성은 protected set 입니다. 밖에서는 읽기만 하고, 값을 바꾸는 것은 게임 객체 자신(과 그 자식 클래스)만 할 수 있습니다. 프로시저는 매 프레임 이 값을 들여다보기만 하다가 true 가 되면 다음 프로시저로 넘어갑니다. 게임 객체가 프로시저에게 "넘어가라"고 명령하지 않는다는 점에 주목하세요. 게임 객체는 프로시저의 존재를 모릅니다. 이렇게 아래쪽이 위쪽을 모르게 만드는 것이 이 책 전체에서 반복되는 구조입니다.
Update(float elapseSeconds, float realElapseSeconds) 의 두 인자는 프레임워크가 넘겨 주는 시간입니다.
인자
뜻
일시정지 중
elapseSeconds
지난 프레임부터 흐른 게임 시간 (Time.deltaTime)
0
realElapseSeconds
지난 프레임부터 흐른 실제 시간 (Time.unscaledDeltaTime)
계속 흐른다
게임 로직은 거의 항상 첫 번째를 씁니다. 두 번째는 일시정지 메뉴의 애니메이션처럼 "게임이 멈춰도 움직여야 하는 것"에 씁니다. 앞으로 만들 모든 클래스가 Time.deltaTime 을 직접 읽지 않고 이 인자를 받아 쓰게 됩니다.
📝
예제 프로젝트의 GameBase.cs 에는 엔티티 스폰 실패를 처리하는 메서드가 두 개 더 들어 있습니다. 엔티티를 아직 배우지 않았으므로 3장에서 추가합니다.

SurvivalGame

이제 실제 게임 모드입니다. 지금은 시간만 셉니다.
// Assets/GameMain/Game/SurvivalGame.cs using UnityGameFramework.Runtime; namespace ToyBoxNightmare { public class SurvivalGame : GameBase { // ─── 런타임 상태 ─── private float mGameTime = 0f; public override GameMode GameMode => GameMode.Survival; /// <summary>누적 점수. 적 처치로 오른다.</summary> public int Score { get; private set; } /// <summary>게임 시작(프로시저 진입) 이후 경과 시간. 스포너 난이도 곡선에 쓴다.</summary> public float GameTime => mGameTime; // ─── 초기화 ─── public override void Initialize() { base.Initialize(); ResetRoundState(); Log.Info("SurvivalGame: 판 시작"); // 확인용. 3장에서 지운다 } public override void Shutdown() { base.Shutdown(); } /// <summary> /// 프로시저 재진입(재시작)으로 새 인스턴스가 만들어지므로 상태를 명시적으로 초기화한다. /// </summary> private void ResetRoundState() { Score = 0; mGameTime = 0f; } public override void Update(float elapseSeconds, float realElapseSeconds) { base.Update(elapseSeconds, realElapseSeconds); if (GameOver) return; mGameTime += elapseSeconds; } } }
C#
복사

코드 읽기

싱글턴을 두지 않습니다. public static SurvivalGame Instance 같은 손잡이를 만들어 두면 어디서든 SurvivalGame.Instance.Score 로 닿을 수 있어 편해 보입니다. 하지만 그 순간부터 적도, 플레이어도, UI 도 게임 객체의 내부를 알게 됩니다. 게임 객체를 들고 있는 것은 프로시저 하나뿐입니다.
그러면 다른 코드는 필요한 것을 어떻게 얻을까요? 길은 둘이고, 둘 다 프레임워크가 줍니다. 소식은 이벤트로 받습니다(3장 — 점수가 바뀌었다, 플레이어가 죽었다). 대상은 프레임워크에 물어서 찾습니다(5장 — 적이 "지금 떠 있는 플레이어"를 엔티티 그룹에서 찾는다). 풀에서 재사용되는 것을 정적 변수에 담아 두면 이미 회수된 것을 가리키는 순간이 반드시 생기는데, 매번 프레임워크에 물으면 그런 일이 없습니다.
ResetRoundState() — 판마다 새 객체가 만들어지니 필드 초기값(= 0f)만으로도 0 에서 시작합니다. 그런데도 굳이 메서드로 다시 적는 이유는, "이 값들이 판 단위 상태다"라는 것을 코드로 남겨 두기 위해서입니다. 뒤의 장에서 플레이어 참조, 스포너, 아군 정보가 이 메서드에 한 줄씩 추가됩니다.
if (GameOver) return; — 게임오버가 확정된 뒤에는 시간도 세지 않습니다. 본문이 한 줄뿐인 조기 반환은 이렇게 중괄호 없이 한 줄로 씁니다. 이 책의 코드 전체가 이 규칙을 따릅니다.

2.3 프로시저가 게임 객체를 굴리게 하기

1장에서 만든 ProcedureMain 을 고칩니다.
// Assets/GameMain/Procdure/ProcedureMain.cs using UnityGameFramework.Runtime; using ProcedureOwner = GameFramework.Fsm.IFsm<GameFramework.Procedure.IProcedureManager>; namespace ToyBoxNightmare { public class ProcedureMain : ProcedureBase { private SurvivalGame mGame = null; public override bool UseNativeDialog => false; protected override void OnEnter(ProcedureOwner procedureOwner) { base.OnEnter(procedureOwner); Log.Info("ProcedureMain: Enter"); mGame = new SurvivalGame(); mGame.Initialize(); } 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); } protected override void OnLeave(ProcedureOwner procedureOwner, bool isShutdown) { mGame?.Shutdown(); mGame = null; base.OnLeave(procedureOwner, isShutdown); } } }
C#
복사

코드 읽기

구조가 한눈에 보입니다. 들어올 때 만들고(OnEnter), 있는 동안 돌리고(OnUpdate), 나갈 때 치웁니다(OnLeave).
sequenceDiagram
    participant F as 프레임워크
    participant P as ProcedureMain
    participant G as SurvivalGame
    F->>P: OnEnter
    P->>G: new + Initialize()
    loop 매 프레임
        F->>P: OnUpdate(시간)
        P->>G: Update(시간)
    end
    F->>P: OnLeave
    P->>G: Shutdown()
    Note over G: 버려진다
Mermaid
복사
OnLeave 의 isShutdown — 이 프로시저를 떠나는 이유가 "다른 프로시저로 넘어가서"인지 "게임이 꺼져서"인지 알려 줍니다. 에디터에서 Play 를 멈출 때는 true 로 불립니다. 지금은 구분할 필요가 없지만, 9장에서 "게임이 꺼지는 중이면 엔티티를 치우려 들지 않는다" 같은 판단에 쓰입니다.
mGame?.Shutdown(); — OnEnter 가 실패해서 mGame 이 없는 채로 OnLeave 가 불려도 터지지 않게 합니다. ?. 은 "null 이 아니면 호출"입니다.
⚠️
?. 는 순수 C# 객체에만 씁니다. SurvivalGame 은 평범한 C# 객체라 괜찮습니다. 하지만 GameObject 나 MonoBehaviour 같은 Unity 오브젝트에는 쓰면 안 됩니다. Unity 는 파괴된 오브젝트를 == null 로 비교하면 true 가 되도록 특별히 처리해 두었는데, ?. 는 그 처리를 거치지 않아서 이미 파괴된 오브젝트를 살아 있다고 보고 호출해 버립니다. 3장부터 엔티티를 다루면 이 차이가 실제 버그가 되므로, Unity 오브젝트에는 항상 if (x != null) 을 씁니다.

2.4 실행해 보기

시간이 흐르는 것을 눈으로 확인하기 위해 SurvivalGame.Update 에 임시 로그를 넣어 봅니다.
public override void Update(float elapseSeconds, float realElapseSeconds) { base.Update(elapseSeconds, realElapseSeconds); if (GameOver) return; float before = mGameTime; mGameTime += elapseSeconds; // 확인용: 3초 경계를 넘을 때마다 한 번 찍는다. 확인한 뒤 지운다 const float LogInterval = 3f; if ((int)(before / LogInterval) != (int)(mGameTime / LogInterval)) { Log.Info("SurvivalGame: {0:F0}초 경과", mGameTime); } }
C#
복사
Play 를 누르면 콘솔에 이렇게 나옵니다.
ProcedureMain: Enter SurvivalGame: 판 시작 SurvivalGame: 3초 경과 SurvivalGame: 6초 경과 SurvivalGame: 9초 경과
Plain Text
복사
화면은 1장과 똑같이 빈 방입니다. 하지만 이제 프레임워크 → 프로시저 → 게임 객체로 이어지는 호출의 사슬이 생겼습니다. 앞으로 만드는 캐릭터, 적, 무기는 전부 SurvivalGame 에서 뻗어 나갑니다.
프로시저가 지금 실제로 돌고 있는지는 에디터에서도 확인할 수 있습니다. Play 중에 Hierarchy 에서 GameFramework → Procedure 를 선택하면 전용 인스펙터의 Current Procedure 에 지금 실행 중인 프로시저가 나옵니다(그림의 ①). ② 는 1장에서 체크한 목록, ③ 은 처음 들어갈 프로시저입니다. Play 중에는 ②③ 가 흐리게 그려져 고칠 수 없습니다. 9장에서 게임오버가 생기면 ① 이 플레이 도중에 바뀌는 것을 보게 됩니다.
Play 중의 Procedure 인스펙터. ① 지금 실행 중인 프로시저 ② 등록한 프로시저 ③ 시작 프로시저 (완성된 예제라 ProcedureGameOver 도 보인다)
확인이 끝났으면 임시 로그는 지우고 원래의 Update 로 되돌려 둡니다.
💡
로그에 값을 넣는 방법 — Log.Info("… {0} …", 값) 처럼 {0}, {1} 자리에 뒤의 인자가 들어갑니다. string.Format 과 같은 규칙이고, {0:F0} 은 소수점 없이, {0:F2} 는 소수 둘째 자리까지 찍으라는 뜻입니다. "…" + 값 으로 문자열을 직접 이어 붙이지 않는 이유는, 로그가 꺼져 있을 때 문자열을 만드는 비용조차 들지 않게 하기 위해서입니다.

2.5 잘 안 될 때

증상
원인과 해결
ProcedureMain: Enter 는 나오는데 판 시작 이 안 나온다
OnEnter 에서 mGame.Initialize() 를 부르지 않았거나, SurvivalGame.Initialize 에서 base.Initialize() 아래 줄들을 빠뜨렸습니다
판 시작 은 나오는데 시간 로그가 안 나온다
ProcedureMain.OnUpdate 를 만들지 않았거나 mGame.Update(…) 호출을 빠뜨렸습니다
컴파일 에러 CS0115: no suitable method found to override
오버라이드하려는 메서드의 인자가 다릅니다. OnUpdate 는 인자가 셋(procedureOwner, elapseSeconds, realElapseSeconds), OnLeave 는 둘(procedureOwner, isShutdown)입니다
컴파일 에러 CS0507
protected internal override 라고 썼습니다. internal 을 지웁니다

이 장에서 만든 것

파일
내용
Assets/GameMain/Game/GameMode.cs
게임 모드 식별자
Assets/GameMain/Game/GameBase.cs
게임 모드의 공통 뼈대 — Initialize / Update / Shutdown, GameOver
Assets/GameMain/Game/SurvivalGame.cs
이 게임의 모드. 지금은 점수와 시간만 가진다
Assets/GameMain/Procdure/ProcedureMain.cs (수정)
게임 객체를 만들고, 돌리고, 치운다

정리

•
프로시저는 게임의 큰 단계 하나다. 한 번에 하나만 실행된다
•
OnEnter 에서 만든 것은 OnLeave 에서 치운다. 이 짝이 재시작을 쉽게 만든다
•
프로시저는 언제를, 게임 객체는 무엇을 책임진다
•
게임 객체는 판마다 새로 만든다. 초기화를 빠뜨릴 일이 없다
•
게임 객체는 프로시저를 모른다. GameOver 를 세워 둘 뿐이고, 프로시저가 그것을 보고 움직인다

직접 해 보기

1.
ProcedureMain.OnLeave 에 Log.Info("ProcedureMain: Leave (isShutdown={0})", isShutdown); 을 넣고, 에디터에서 Play 를 멈춰 보세요. 게임이 꺼질 때도 OnLeave 가 불린다는 것, 그리고 그때 isShutdown 이 무엇인지 확인할 수 있습니다.
2.
SurvivalGame 에 "10초가 지나면 GameOver = true" 를 넣어 보세요. 그 뒤로 시간 로그가 멈추는 것을 확인합니다. (지금은 게임오버를 받아 줄 프로시저가 없어서 그대로 멈춘 채 있습니다. 9장에서 이어집니다.)
다음 장 — 03. 캐릭터 선택 — 첫 엔티티와 첫 이벤트: 드디어 화면에 무언가가 나타납니다. 캐릭터 둘을 띄우고 클릭으로 고르게 합니다.