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 키"| MMermaid
복사
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장에서 이어집니다.)







