게임은 이제 돌아갑니다. 적이 오고, 쏘고, 맞고, 죽습니다. 그런데 플레이어는 자기 체력이 얼마 남았는지 모릅니다. 점수가 오르고 있는지도, 악취를 다시 던지려면 얼마나 기다려야 하는지도 모릅니다. 전부 5장과 6장에서 변수로는 만들어 두었지만 화면에는 아무것도 없습니다.
화면에 숫자를 올리는 가장 쉬운 방법은 UI 스크립트의 Update 에서 매 프레임 물어보는 것입니다.
void Update()
{
scoreText.text = "Score: " + SurvivalGame.Instance.Score; // ❌
healthSlider.value = Player.Instance.HitPointRatio; // ❌
}
C#
복사
동작은 합니다. 하지만 이 UI 는 게임 객체와 플레이어의 내부를 알아야 하고, 둘 중 하나가 없는 순간(캐릭터 선택 중, 게임오버 직후)마다 null 검사를 해야 하며, 점수가 바뀌지 않은 수천 프레임 동안에도 매번 문자열을 새로 만듭니다. 더 나쁜 것은 반대 방향입니다. 체력이 깎일 때 화면을 붉게 번쩍이려면? 플레이어 코드에 hud.Flash() 가 들어갑니다. 게임 로직이 UI 를 알게 됩니다.
3장에서 배운 도구가 여기에 딱 맞습니다. 게임 쪽은 "바뀌었다"고 외치기만 하고, UI 는 그것을 듣기만 합니다. 이 장이 끝나면 UI 프리팹을 프로젝트에서 통째로 지워도 게임은 에러 하나 없이 그대로 돕니다.
이 장의 결과. 위에는 점수, 아래에는 체력 바와 그 위의 가는 쿨다운 게이지. 게임 로직은 이 화면이 있는지 모른다
이 장에서 하는 일
1.
UI 폼이 엔티티와 어떻게 다른지 안다
2.
프로시저가 HUD 를 열게 한다
3.
게임 쪽에 "바뀌었다"는 이벤트 셋을 추가한다 — 점수, 체력, 쿨다운
4.
그 이벤트를 듣는 HUDForm 을 만든다
5.
Esc 로 여는 일시정지 메뉴를 만든다
시작하기 전에 확인할 것 — 예제 프로젝트에는 이미 되어 있습니다.
•
Assets/GameMain/UI/ 에 HUDForm.prefab, PauseMenuForm.prefab 이 있고 Addressables 에 파일 이름과 같은 주소로 등록돼 있다
•
1장에서 UI 컴포넌트에 Default(Depth 0) 와 Popup(Depth 1) 그룹을 만들었다
•
씬에 EventSystem 오브젝트가 하나 있다 (입력 모듈은 Input System UI Input Module). 없으면 버튼과 슬라이더가 반응하지 않는다
7.1 UI 폼 — 엔티티와 닮았고, 한 군데가 정반대다
프레임워크에서 화면 하나(HUD, 메뉴, 팝업)를 UI 폼이라고 부릅니다. 엔티티와 쌍둥이처럼 닮았습니다.
엔티티 | UI 폼 | |
띄우기 | ShowEntity(번호, 로직 타입, 주소, 그룹, 데이터) | OpenUIForm(주소, 그룹) |
치우기 | HideEntity | CloseUIForm |
로직의 부모 클래스 | EntityLogic | UIFormLogic |
처음 한 번 | OnInit | OnInit |
나타날 때마다 | OnShow | OnOpen |
매 프레임 | OnUpdate | OnUpdate |
사라질 때마다 | OnHide | OnClose |
풀에서 재사용 | 된다 | 된다 |
로직 스크립트를 프리팹에 | 붙이지 않는다 | 반드시 붙인다 |
마지막 줄이 정반대입니다. ShowEntity 는 로직 타입을 인자로 받아 실행 중에 붙였습니다. OpenUIForm 에는 로직 타입 인자가 없습니다. 프레임워크는 프리팹에 이미 붙어 있는 UIFormLogic 을 GetComponent 로 찾습니다.
이유는 쓰임새의 차이입니다. 엔티티는 같은 생김새(Girl)에 다른 역할(선택용, 플레이어)을 입혔습니다. UI 는 그럴 일이 없습니다. HUD 화면은 언제나 HUD 로직과 짝입니다.
UI 폼 프리팹에 로직 스크립트를 붙이지 않으면 에러가 나지 않습니다. 화면은 멀쩡히 뜹니다. 다만 OnInit 도 OnOpen 도 불리지 않아 값이 하나도 바뀌지 않는 죽은 화면이 됩니다. 엔티티를 먼저 배운 사람이 가장 자주 빠지는 함정입니다.
준비된 프리팹 들여다보기
UI 를 배치하는 일(텍스트를 어디에 두고 글꼴을 무엇으로 할지)은 프레임워크와 상관없는 Unity UI 작업이라, 이 책은 예제 프로젝트의 프리팹을 그대로 씁니다. HUDForm.prefab 을 열어 구조만 확인합니다.
HUDForm ← Canvas (Screen Space - Overlay, Sort Order 0) + 이 장에서 만들 HUDForm 스크립트
├ HealthUI
│ ├ HealthSlider ← Slider. 체력 바
│ └ Heart
├ DamageImage ← Image. 화면 전체를 덮는 붉은 막
├ GameOverText ← Text. "Game Over!"
├ InfoText ← Text. 안내문 / 점수
├ Ally Button ← 8장에서 쓴다
└ CountdownSlider ← Slider. 쿨다운 게이지
Plain Text
복사
두 가지를 눈여겨봅니다.
붉은 막과 쿨다운 게이지에는 스크립트가 붙어 있지 않습니다. 원작 Zombie Toys 는 여기에 FlashFade, Countdown 이라는 작은 MonoBehaviour 를 붙여 코루틴으로 돌렸습니다. 이 책에서는 그 일을 폼이 직접 합니다. 폼의 OnUpdate 는 프레임워크가 부르므로, 폼이 닫히거나 가려지면 연출도 함께 멈춥니다. 화면 안에서 따로 도는 스크립트를 두지 않는 것 — 6장에서 번개 빔을 무기가 굴리게 한 것과 같은 원칙입니다.
HUD 의 그림과 글자는 Raycast Target 이 전부 꺼져 있습니다. DamageImage 는 화면 전체를 덮습니다. 이것이 클릭을 받도록 켜져 있으면, 3장에서 캐릭터를 고를 때 넣어 둔 EventSystem.current.IsPointerOverGameObject() 검사에 걸려 캐릭터를 영영 고를 수 없게 됩니다. 눌릴 일이 없는 UI 요소는 Raycast Target 을 끕니다.
7.2 프로시저가 HUD 를 연다
먼저 주소와 그룹 이름을 한곳에 모읍니다. 5장, 6장의 표와 같은 관례입니다. Assets/GameMain/UI 폴더에 만듭니다.
// Assets/GameMain/UI/UITable.cs
namespace ToyBoxNightmare
{
/// <summary>
/// UI 폼의 Addressables 주소와 UI 그룹 이름을 모아 둔다.
/// 문자열을 코드 곳곳에 흩뿌리면 오타가 나도 <c>OpenUIForm</c> 이 조용히 실패한다.
/// </summary>
public static class UITable
{
/// <summary>
/// 기본 UI 그룹(깊이 0). HUD 가 들어간다. <c>GameFramework.prefab</c> 의 UIComponent.mUIGroups 에
/// <b>같은 이름으로 등록돼 있어야 한다.</b> 없으면 OpenUIForm 이 실패한다.
/// </summary>
public const string DefaultGroup = "Default";
/// <summary>
/// 위에 겹쳐 뜨는 폼의 UI 그룹(깊이 1). 일시정지 메뉴가 들어간다.
/// 그룹 깊이가 클수록 위에 그려진다 — <see cref="UIFormBase"/> 가 깊이를 Canvas 순서로 옮긴다.
/// </summary>
public const string PopupGroup = "Popup";
/// <summary>HUD 폼의 Addressables 주소.</summary>
public const string HudForm = "HUDForm";
/// <summary>
/// 일시정지 폼의 Addressables 주소. <see cref="PopupGroup"/> 에 열어 HUD 위에 그려지게 한다.
/// </summary>
public const string PauseMenuForm = "PauseMenuForm";
}
}
C#
복사
누가 HUD 를 열어야 할까요? HUD 는 한 판 내내 떠 있습니다. 2장에서 정한 역할대로라면 "언제"를 책임지는 것은 프로시저입니다. ProcedureMain.cs 를 고칩니다.
// 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;
/// <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");
// 구독이 먼저다. OpenUIForm 은 비동기라 실패 통지가 이 아래 호출 도중에도 올 수 있다.
SubscribeUIFailure();
OpenForms();
mGame = new SurvivalGame();
mGame.Initialize();
}
/// <summary>
/// HUD 를 띄운다.
/// </summary>
private void OpenForms()
{
UIComponent ui = GameEntry.GetComponent<UIComponent>();
if (ui == null)
{
Log.Error("ProcedureMain: UIComponent 가 없다. UI 없이 진행한다.");
return;
}
OpenOnce(ui, UITable.HudForm, UITable.DefaultGroup);
}
/// <summary>이미 떠 있으면 두 번 열지 않는다(프로시저 재진입 방어).</summary>
private static void OpenOnce(UIComponent ui, string assetName, string uiGroupName)
{
if (ui.HasUIForm(assetName)) return;
ui.OpenUIForm(assetName, uiGroupName);
}
/// <summary>
/// UI 열기 실패를 잡는다. <c>OpenUIForm</c> 은 비동기 fire-and-forget 이라
/// Addressables 주소가 어긋나면 <b>아무 로그도 없이 폼이 영영 뜨지 않는다.</b>
/// 엔티티 쪽(<c>ShowEntityFailure</c>)과 같은 대비를 UI 에도 둔다.
/// </summary>
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);
}
protected override void OnLeave(ProcedureOwner procedureOwner, bool isShutdown)
{
mGame?.Shutdown();
mGame = null;
UnsubscribeUIFailure();
base.OnLeave(procedureOwner, isShutdown);
}
}
}
C#
복사
코드 읽기
ui.OpenUIForm(주소, 그룹) 은 ShowEntity 보다 단출합니다. 번호도, 로직 타입도 필요 없습니다. 그리고 ShowEntity 와 마찬가지로 바로 끝나지 않습니다. 프리팹을 읽어 온 뒤에야 화면이 뜹니다.
실패를 듣는 구독을 3장과 똑같이 겁니다. 엔티티에 ShowEntityFailureEventArgs 가 있듯이 UI 에는 OpenUIFormFailureEventArgs 가 있습니다. 주소가 틀렸을 때 이것을 듣고 있지 않으면 HUD 는 아무 말 없이 안 뜹니다. 4장에서 "실패는 시끄럽게"라고 했습니다. 비동기로 무언가를 여는 곳에는 언제나 실패를 듣는 귀를 같이 답니다.
구독을 OpenForms() 앞에 두는 순서도 봅니다. 귀를 달기 전에 문을 두드리면 대답을 놓칩니다.
HasUIForm 으로 두 번 여는 것을 막습니다. 프레임워크는 같은 폼을 두 번 여는 것을 막아 주지 않습니다. 두 번 열면 HUD 가 두 장 겹쳐 뜹니다. 9장에서 재시작을 만들면 이 프로시저에 여러 번 들어오게 되므로 미리 막아 둡니다.
닫는 코드가 없습니다. 의도한 것입니다. OnLeave 에서 HUD 를 닫으면, 게임오버로 넘어가는 순간 "Game Over!" 문구도 같이 사라집니다. HUD 는 게임오버 연출이 끝난 뒤에 닫아야 하고, 그 일은 9장에서 만들 게임오버 프로시저가 맡습니다. 2장에서는 "OnEnter 에서 만든 것은 OnLeave 에서 치운다"고 했는데, 짝이 꼭 같은 클래스 안에 있어야 하는 것은 아닙니다. 중요한 것은 여는 곳이 하나면 닫는 곳도 하나라는 것, 그리고 그 둘이 어디인지 분명히 적혀 있다는 것입니다.
아직 HUDForm 스크립트가 없어 프리팹이 "Missing Script" 상태입니다. 먼저 HUD 가 들을 소리를 만들고 돌아옵니다.
7.3 "바뀌었다"고 외치기
HUD 에 보여 줄 것은 셋입니다. 각각 값이 바뀌는 바로 그 자리에서 이벤트를 쏩니다. 이벤트 클래스의 틀은 3장부터 써 온 그대로입니다.
점수
// Assets/GameMain/Game/ScoreChangedEventArgs.cs
using GameFramework;
using GameFramework.Event;
namespace ToyBoxNightmare
{
/// <summary>
/// 점수가 바뀌었을 때 발행된다. HUD 가 구독한다.
/// </summary>
public class ScoreChangedEventArgs : GameEventArgs
{
public static readonly int EventId = typeof(ScoreChangedEventArgs).GetHashCode();
public override int Id => EventId;
/// <summary>변경 후 총점.</summary>
public int Score { get; private set; }
/// <summary>이번에 더해진 값.</summary>
public int Delta { get; private set; }
public static ScoreChangedEventArgs Create(int score, int delta)
{
var args = ReferencePool.Acquire<ScoreChangedEventArgs>();
args.Score = score;
args.Delta = delta;
return args;
}
public override void Clear()
{
Score = 0;
Delta = 0;
}
}
}
C#
복사
5장에서 만든 SurvivalGame.AddScore 에 발행을 붙입니다.
/// <summary>점수를 더하고 ScoreChanged 를 발행한다.</summary>
public void AddScore(int delta)
{
if (delta == 0) return;
Score += delta;
EventComponent events = RequireEventComponent();
if (events == null)
{
// Acquire 한 인자를 Fire 하지 못하면 풀에 돌아가지 못하므로, 여기서 만들지 않는다.
return;
}
events.Fire(this, ScoreChangedEventArgs.Create(Score, delta));
}
C#
복사
체력
// Assets/GameMain/Game/PlayerHealthChangedEventArgs.cs
using GameFramework;
using GameFramework.Event;
namespace ToyBoxNightmare
{
/// <summary>
/// 플레이어 체력 비율이 바뀌었을 때 발행된다. HUD 가 구독해 체력 바와 피격 플래시를 갱신한다.
///
/// <b>비율이 줄어들 때만 오는 것이 아니다.</b> 스폰 직후에도 한 번 발행되므로
/// (그때는 <see cref="FromRatio"/> 와 <see cref="ToRatio"/> 가 같다) HUD 가
/// 새 판의 만피 상태를 알 수 있다. 피격 연출은 두 값을 비교해서 결정할 것.
/// </summary>
public class PlayerHealthChangedEventArgs : GameEventArgs
{
public static readonly int EventId = typeof(PlayerHealthChangedEventArgs).GetHashCode();
public override int Id => EventId;
/// <summary>변경 전 체력 비율(0~1).</summary>
public float FromRatio { get; private set; }
/// <summary>변경 후 체력 비율(0~1).</summary>
public float ToRatio { get; private set; }
/// <summary>이번 변화가 피해인가. 스폰 시 통보와 구분하는 데 쓴다.</summary>
public bool IsDamage => ToRatio < FromRatio;
public static PlayerHealthChangedEventArgs Create(float fromRatio, float toRatio)
{
var args = ReferencePool.Acquire<PlayerHealthChangedEventArgs>();
args.FromRatio = fromRatio;
args.ToRatio = toRatio;
return args;
}
public override void Clear()
{
FromRatio = 0f;
ToRatio = 0f;
}
}
}
C#
복사
Player.cs 에 두 메서드를 추가합니다. 4장에서 TargetableObject 에 비워 둔 훅 OnHitPointChanged 가 여기서 쓰입니다.
/// <summary>
/// 체력이 줄어들 때마다 HUD 에 알린다. 피격 플래시도 HUD 가 이 값으로 판단한다.
/// </summary>
protected override void OnHitPointChanged(float fromRatio, float toRatio)
{
base.OnHitPointChanged(fromRatio, toRatio);
FireHealthChanged(fromRatio, toRatio);
}
/// <summary>
/// 체력 변화를 발행한다. HUD 가 없어도 게임이 돌아야 하므로 실패해도 조용히 넘어간다.
/// </summary>
private void FireHealthChanged(float fromRatio, float toRatio)
{
EventComponent events = GameEntry.GetComponent<EventComponent>();
if (events == null) return;
events.Fire(this, PlayerHealthChangedEventArgs.Create(fromRatio, toRatio));
}
C#
복사
그리고 OnShow 에서, 무기를 장착한 다음 줄에 한 번 쏩니다.
mWeaponLoadout.Equip();
// 스폰 통보. OnHitPointChanged 는 비율이 **줄어들 때만** 오므로
// 이것 없이는 HUD 가 새 판의 만피를 알 방법이 없다(이전 판 값을 물고 있게 된다).
FireHealthChanged(HitPointRatio, HitPointRatio);
// 상태 기계는 **맨 마지막**에 세운다. 상태가 OnEnter 에서 소유자 값을 읽기 때문이다.
CreateStateMachine();
C#
복사
쿨다운
// Assets/GameMain/Game/WeaponCooldownStartedEventArgs.cs
using GameFramework;
using GameFramework.Event;
namespace ToyBoxNightmare
{
/// <summary>
/// 발사에 성공해 전역 쿨다운이 걸렸을 때 발행된다. HUD 의 쿨다운 게이지가 구독한다.
///
/// 쿨다운은 무기별이 아니라 <see cref="WeaponLoadout"/> 이 하나만 든다.
/// 그래서 게이지도 하나뿐이고, 마지막으로 쏜 무기의 쿨다운 길이가 실린다.
/// Frost 는 쿨다운이 0 이라 아예 발행되지 않는다.
/// </summary>
public class WeaponCooldownStartedEventArgs : GameEventArgs
{
public static readonly int EventId = typeof(WeaponCooldownStartedEventArgs).GetHashCode();
public override int Id => EventId;
/// <summary>이번에 걸린 쿨다운 길이(초).</summary>
public float Cooldown { get; private set; }
public static WeaponCooldownStartedEventArgs Create(float cooldown)
{
var args = ReferencePool.Acquire<WeaponCooldownStartedEventArgs>();
args.Cooldown = cooldown;
return args;
}
public override void Clear()
{
Cooldown = 0f;
}
}
}
C#
복사
6장에서 만든 WeaponLoadout.BeginCooldown 의 끝에 붙입니다.
private void BeginCooldown(float cooldown)
{
mAttackCooldown = Mathf.Max(0f, cooldown);
mTimeOfLastAttack = Time.time;
// 쿨다운이 0 인 무기(Frost)는 게이지를 건드리지 않는다 — 0 을 넘기면
// HUD 게이지가 가득 찼다가 즉시 비는 깜빡임이 된다.
if (mAttackCooldown <= 0f) return;
EventComponent events = GameEntry.GetComponent<EventComponent>();
if (events == null) return;
events.Fire(this, WeaponCooldownStartedEventArgs.Create(mAttackCooldown));
}
C#
복사
코드 읽기
세 곳 모두 이미 있던 코드에 몇 줄을 덧붙였을 뿐입니다. 점수가 오르는 바로 그 줄 아래, 체력이 깎인 직후 불리는 훅 안, 쿨다운이 걸리는 바로 그 줄 아래. 이벤트는 값을 바꾸는 코드 옆에서 쏩니다. 다른 곳에서 "혹시 바뀌었나?" 하고 감시하지 않습니다.
이벤트에 "결과"를 싣습니다. ScoreChangedEventArgs 는 "10점 올랐다"(Delta)뿐 아니라 "그래서 지금 40점이다"(Score)를 함께 보냅니다. 듣는 쪽이 게임 객체에게 점수를 다시 물어볼 필요가 없습니다. 이벤트만으로 화면을 그릴 수 있게 해 두면, 듣는 쪽은 보내는 쪽의 존재조차 몰라도 됩니다.
체력 이벤트는 "줄어든 양"이 아니라 "전과 후의 비율"입니다. HUD 의 체력 바는 0~1 의 비율만 있으면 됩니다. 최대 체력이 100 인지 500 인지는 HUD 가 알 바가 아닙니다. 이벤트에 무엇을 실을지는 듣는 쪽이 무엇을 그릴 것인가에서 거꾸로 정합니다.
스폰할 때도 한 번 쏩니다. FireHealthChanged(HitPointRatio, HitPointRatio) — 전과 후가 같은, "변화 없음"이라는 이상한 통보입니다. 이것이 없으면 어떻게 될까요? HUD 는 풀에서 재사용되고, 이벤트가 올 때만 그림을 고칩니다. 지난 판에 체력 0 으로 끝났다면 체력 바는 빈 채로 남아 있다가, 새 판에서 처음 맞는 순간에야 올바른 값으로 튑니다. "지금 값이 무엇인지" 한 번 알려 줘야 합니다. 이벤트로 화면을 그릴 때 꼭 필요한 한 발입니다. 그리고 IsDamage 속성이 "이번 통보가 진짜 피해인가"를 가려 주어, HUD 가 스폰 통보에 화면을 붉게 번쩍이지 않습니다.
보내는 쪽마다 실패했을 때의 태도가 다릅니다. AddScore 는 RequireEventComponent()(없으면 에러 로그), FireHealthChanged 와 BeginCooldown 은 그냥 return 입니다. 3장에서 Find… 와 Require… 를 나눈 기준 그대로입니다. 점수는 게임의 규칙이지만, 체력 바와 게이지는 없어도 게임이 도는 표시일 뿐입니다.
7.4 듣는 쪽 — HUDForm
// Assets/GameMain/UI/HUDForm.cs
using GameFramework.Event;
using UnityEngine;
using UnityEngine.UI;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 게임 중 HUD. 점수 / 체력 / 공격 쿨다운 / 게임오버 문구 / 피격 플래시를 담당한다.
///
/// <b>이 스크립트는 프리팹에 직접 붙어 있어야 한다.</b> 엔티티와는 정반대 규약이다.
///
/// 갱신은 전부 이벤트 구독으로 하며 게임 로직을 직접 폴링하지 않는다 —
/// HUD 가 없어도 게임은 돌아야 하기 때문이다.
/// </summary>
public class HUDForm : UIFormLogic
{
// 프리팹 계층 계약. 자식 이름이 바뀌면 조용히 null 이 되므로 한곳에 모아 둔다.
private const string HealthSliderPath = "HealthUI/HealthSlider";
private const string DamageImagePath = "DamageImage";
private const string GameOverTextPath = "GameOverText";
private const string InfoTextPath = "InfoText";
private const string AllyButtonPath = "Ally Button";
private const string CountdownPath = "CountdownSlider";
/// <summary>캐릭터를 고르기 전에 띄우는 안내문.</summary>
private const string SelectPrompt = "Select a character";
private const string ScoreFormat = "Score: {0}";
/// <summary>맞는 순간 화면에 깔리는 색.</summary>
private static readonly Color DamageFlashColor = new Color(1f, 0f, 0f, 0.1f);
/// <summary>섬광이 걷히는 속도.</summary>
private const float DamageFlashFadeSpeed = 5f;
/// <summary>이보다 옅어지면 완전히 지운다. Lerp 는 0 에 닿지 않는다.</summary>
private const float DamageFlashMinAlpha = 0.01f;
private Slider mHealthSlider = null;
private Image mDamageImage = null;
private Text mGameOverText = null;
private Text mInfoText = null;
private GameObject mAllyButton = null;
private Slider mCountdownSlider = null;
/// <summary>피격 섬광이 걷히는 중인가.</summary>
private bool mDamageFlashing = false;
/// <summary>무기 쿨다운 게이지에 남은 시간(초). 0 이하면 게이지를 숨긴다.</summary>
private float mCooldownRemaining = 0f;
private bool mSubscribed = false;
// ─── 생명주기 ───
protected internal override void OnInit(object userData)
{
base.OnInit(userData);
mHealthSlider = Find<Slider>(HealthSliderPath);
mDamageImage = Find<Image>(DamageImagePath);
mGameOverText = Find<Text>(GameOverTextPath);
mInfoText = Find<Text>(InfoTextPath);
mCountdownSlider = Find<Slider>(CountdownPath);
Transform allyButton = CachedTransform.Find(AllyButtonPath);
mAllyButton = allyButton != null ? allyButton.gameObject : null;
if (mAllyButton == null)
{
Log.Warning("HUDForm: 자식 {0} 을 찾지 못했다.", AllyButtonPath);
}
}
/// <summary>자식을 경로로 찾고 없으면 한 번 알린다. 조용한 null 이 가장 찾기 어렵다.</summary>
private T Find<T>(string path) where T : Component
{
Transform child = CachedTransform.Find(path);
if (child == null)
{
Log.Warning("HUDForm: 자식 {0} 이 없다. 프리팹 구조를 확인할 것.", path);
return null;
}
T component = child.GetComponent<T>();
if (component == null)
{
Log.Warning("HUDForm: {0} 에 {1} 컴포넌트가 없다.", path, typeof(T).Name);
}
return component;
}
protected internal override void OnOpen(object userData)
{
base.OnOpen(userData);
// UI 폼도 풀에서 재사용된다. 이전 판의 점수와 게임오버 문구를 물고 나오지 않도록
// 구독보다 먼저 화면을 초기 상태로 되돌린다.
ResetVisuals();
Subscribe();
}
protected internal override void OnClose(bool isShutdown, object userData)
{
Unsubscribe();
base.OnClose(isShutdown, userData);
}
/// <summary>캐릭터 선택 직전 상태로 되돌린다.</summary>
private void ResetVisuals()
{
if (mInfoText != null)
{
mInfoText.text = SelectPrompt;
}
if (mGameOverText != null)
{
mGameOverText.enabled = false;
}
if (mHealthSlider != null)
{
mHealthSlider.value = mHealthSlider.maxValue;
}
// 아군 소환 아이콘은 8장에서 쓴다. 그때까지는 꺼 둔다.
SetAllyButtonVisible(false);
ClearDamageFlash();
HideCooldownGauge();
}
// ─── 프레임 갱신 ───
// 게임에게 묻는 것은 없다. 이미 받은 이벤트의 연출을 이어 갈 뿐이다.
protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(elapseSeconds, realElapseSeconds);
UpdateDamageFlash(elapseSeconds);
UpdateCooldownGauge(elapseSeconds);
}
/// <summary>섬광을 조금씩 걷어 낸다.</summary>
private void UpdateDamageFlash(float elapseSeconds)
{
if (!mDamageFlashing || mDamageImage == null) return;
mDamageImage.color = Color.Lerp(mDamageImage.color, Color.clear, DamageFlashFadeSpeed * elapseSeconds);
if (mDamageImage.color.a > DamageFlashMinAlpha) return;
ClearDamageFlash();
}
private void BeginDamageFlash()
{
if (mDamageImage == null) return;
mDamageImage.color = DamageFlashColor;
mDamageFlashing = true;
}
private void ClearDamageFlash()
{
mDamageFlashing = false;
if (mDamageImage != null)
{
mDamageImage.color = Color.clear;
}
}
/// <summary>
/// 쿨다운 게이지를 줄인다. 다 줄면 숨긴다 — 슬라이더의 maxValue 를 0 으로 두면
/// 채움 영역이 사라진다.
/// </summary>
private void UpdateCooldownGauge(float elapseSeconds)
{
if (mCooldownRemaining <= 0f || mCountdownSlider == null) return;
mCooldownRemaining -= elapseSeconds;
if (mCooldownRemaining <= 0f)
{
HideCooldownGauge();
return;
}
mCountdownSlider.value = mCooldownRemaining;
}
private void BeginCooldownGauge(float cooldown)
{
if (mCountdownSlider == null) return;
mCooldownRemaining = cooldown;
mCountdownSlider.minValue = 0f;
mCountdownSlider.maxValue = cooldown;
mCountdownSlider.value = cooldown;
}
private void HideCooldownGauge()
{
mCooldownRemaining = 0f;
if (mCountdownSlider != null)
{
mCountdownSlider.minValue = 0f;
mCountdownSlider.maxValue = 0f;
mCountdownSlider.value = 0f;
}
}
// ─── 이벤트 구독 ───
// 같은 (id, handler) 를 두 번 Subscribe 하거나 미등록 핸들러를 Unsubscribe 하면
// 코어가 즉시 예외를 던진다. mSubscribed 로 짝을 보장한다.
private void Subscribe()
{
if (mSubscribed) return;
EventComponent events = GameEntry.GetComponent<EventComponent>();
if (events == null)
{
Log.Error("HUDForm: EventComponent 가 없다. HUD 가 아무것도 갱신하지 못한다.");
return;
}
events.Subscribe(CharacterSelectedEventArgs.EventId, OnCharacterSelected);
events.Subscribe(ScoreChangedEventArgs.EventId, OnScoreChanged);
events.Subscribe(PlayerHealthChangedEventArgs.EventId, OnPlayerHealthChanged);
events.Subscribe(WeaponCooldownStartedEventArgs.EventId, OnWeaponCooldownStarted);
events.Subscribe(PlayerDiedEventArgs.EventId, OnPlayerDied);
mSubscribed = true;
}
private void Unsubscribe()
{
if (!mSubscribed) return;
mSubscribed = false;
// 종료 순서에 따라 EventComponent 가 먼저 파괴돼 있을 수 있다.
EventComponent events = GameEntry.GetComponent<EventComponent>();
if (events == null) return;
events.Unsubscribe(CharacterSelectedEventArgs.EventId, OnCharacterSelected);
events.Unsubscribe(ScoreChangedEventArgs.EventId, OnScoreChanged);
events.Unsubscribe(PlayerHealthChangedEventArgs.EventId, OnPlayerHealthChanged);
events.Unsubscribe(WeaponCooldownStartedEventArgs.EventId, OnWeaponCooldownStarted);
events.Unsubscribe(PlayerDiedEventArgs.EventId, OnPlayerDied);
}
// ─── 핸들러 ───
/// <summary>캐릭터를 고른 순간 안내문이 점수 표시로 바뀐다.</summary>
private void OnCharacterSelected(object sender, GameEventArgs e)
{
SetScore(0);
}
private void OnScoreChanged(object sender, GameEventArgs e)
{
ScoreChangedEventArgs ne = e as ScoreChangedEventArgs;
if (ne == null)
{
GameAssert.Unreachable("HUDForm: ScoreChanged 핸들러에 다른 타입이 들어왔다.");
return;
}
SetScore(ne.Score);
}
private void SetScore(int score)
{
if (mInfoText == null) return;
mInfoText.text = string.Format(ScoreFormat, score);
}
private void OnPlayerHealthChanged(object sender, GameEventArgs e)
{
PlayerHealthChangedEventArgs ne = e as PlayerHealthChangedEventArgs;
if (ne == null)
{
GameAssert.Unreachable("HUDForm: PlayerHealthChanged 핸들러에 다른 타입이 들어왔다.");
return;
}
if (mHealthSlider != null)
{
mHealthSlider.value = Mathf.Clamp01(ne.ToRatio) * mHealthSlider.maxValue;
}
// 스폰 통보(비율이 그대로인 경우)에는 화면을 번쩍이지 않는다.
if (ne.IsDamage)
{
BeginDamageFlash();
}
}
private void OnWeaponCooldownStarted(object sender, GameEventArgs e)
{
WeaponCooldownStartedEventArgs ne = e as WeaponCooldownStartedEventArgs;
if (ne == null)
{
GameAssert.Unreachable("HUDForm: WeaponCooldownStarted 핸들러에 다른 타입이 들어왔다.");
return;
}
BeginCooldownGauge(ne.Cooldown);
}
private void SetAllyButtonVisible(bool visible)
{
if (mAllyButton == null) return;
if (mAllyButton.activeSelf == visible) return;
mAllyButton.SetActive(visible);
}
private void OnPlayerDied(object sender, GameEventArgs e)
{
if (mGameOverText == null) return;
mGameOverText.enabled = true;
}
}
}
C#
복사
스크립트를 저장한 뒤 HUDForm.prefab 을 열어 루트 오브젝트에 이 스크립트를 붙입니다. (예제 프로젝트의 프리팹에는 이미 붙어 있습니다.) 엔티티에서는 하면 안 되던 일이고, UI 폼에서는 반드시 해야 하는 일입니다.
HUDForm 프리팹. ① Addressables 주소 ② Canvas — 이 Sort Order 는 7.5 절에서 만들 UIFormBase 가 실행 중에 덮어쓴다 ③ 루트에 붙인 HUD Form 스크립트
코드 읽기
틀은 엔티티 로직과 같습니다. 이름만 바꿔 읽으면 됩니다.
언제 | 엔티티에서 하던 일 | HUDForm 에서 하는 일 |
OnInit (한 번) | 컴포넌트 찾아 두기 | 자식 UI 요소 찾아 두기 |
OnOpen (나타날 때마다) | 되돌리고 → 상태 기계 만들기 | 되돌리고(ResetVisuals) → 구독하기 |
OnClose (사라질 때마다) | 상태 기계 부수기 | 구독 끊기 |
OnUpdate 는 있지만 게임에게 아무것도 묻지 않습니다. 하는 일은 둘뿐입니다 — 걷히는 중인 붉은 막을 조금 더 걷고, 줄어드는 중인 게이지를 조금 더 줄입니다. 둘 다 이미 받은 이벤트의 뒷처리이고, 진행 중인 연출이 없으면 첫 줄에서 바로 돌아갑니다. 점수와 체력은 여전히 이벤트가 올 때만 고칩니다. 이 장 맨 앞의 Update 방식과 비교해 보세요.
이 클래스에는 SurvivalGame 도 Player 도 WeaponLoadout 도 나오지 않습니다. HUD 가 아는 것은 이벤트 다섯 개의 이름과 그 안에 든 값뿐입니다.
graph LR
E["Enemy.OnDead"] -.->|"EnemyDied"| G["SurvivalGame.AddScore"]
G -.->|"ScoreChanged"| H["HUDForm"]
P["Player.OnHitPointChanged"] -.->|"PlayerHealthChanged"| H
W["WeaponLoadout.BeginCooldown"] -.->|"WeaponCooldownStarted"| H
P2["Player.NotifyDied"] -.->|"PlayerDied"| H
S["PlayerSelectLogic"] -.->|"CharacterSelected"| HMermaid
복사
점선은 전부 이벤트입니다. 왼쪽의 누구도 HUDForm 이라는 이름을 모릅니다.
3장, 4장에서 만든 이벤트를 다시 듣습니다. CharacterSelectedEventArgs 와 PlayerDiedEventArgs 는 원래 SurvivalGame 이 들으려고 만든 것입니다. 이제 HUD 도 같은 소리를 듣습니다. 캐릭터가 선택되면 안내문을 점수로 바꾸고, 플레이어가 죽으면 "Game Over!" 를 켭니다. 보내는 쪽 코드는 한 글자도 바뀌지 않았습니다. 3장에서 "나중에 UI, 사운드가 추가돼도 캐릭터 코드는 그대로"라고 그렸던 점선이 이것입니다.
ResetVisuals() 가 Subscribe() 보다 먼저입니다. UI 폼도 풀에서 재사용됩니다. 지난 판의 "Score: 260" 과 "Game Over!" 가 그대로 남은 채로 다시 열립니다. 엔티티의 OnShow 에서 한 것처럼 나타날 때마다 전부 되돌립니다.
Find<T>(path) 는 자식을 찾지 못했을 때 경고를 남기고 null 을 돌려줍니다. 그리고 그 뒤로 모든 핸들러가 if (mInfoText == null) return; 처럼 null 을 확인합니다. 프리팹에서 누군가 InfoText 의 이름을 바꾸면, 게임은 멈추지 않고 점수만 안 보이며, 콘솔에 "자식 InfoText 이 없다"가 한 줄 찍힙니다. UI 는 없어도 게임이 도는 것이므로, UI 의 문제가 게임을 멈추게 두지 않습니다.
OnInit 은 인스턴스가 처음 만들어질 때만 불립니다. 풀에서 다시 나온 폼은 OnOpen 부터 불립니다. 그래서 "자식 찾기"나 "버튼에 리스너 달기"처럼 한 번만 해야 하는 일은 OnInit 에, "화면 되돌리기"처럼 매번 해야 하는 일은 OnOpen 에 둡니다. 다음 절의 일시정지 메뉴에서 이 구분이 실제로 중요해집니다.
실행해 보기
Play 를 누르면 화면 위쪽에 안내문이 뜹니다.
Select a character
Plain Text
복사
캐릭터를 고르면 그 자리가 Score: 0 으로 바뀌고, 아래쪽에 체력 바가 보입니다. 싸워 봅니다.
적 셋을 잡아 40점. 체력은 절반 아래로 내려갔고, 그 위의 흰 선이 쿨다운 게이지다
•
적을 쓰러뜨리는 순간 점수가 오릅니다.
•
번개를 쏘면 체력 바 위의 가는 선이 가득 찼다가 1초에 걸쳐 줄어듭니다. 악취를 던지면 5초에 걸쳐 줄어듭니다. 냉기는 선을 건드리지 않습니다.
•
맞으면 체력 바가 줄고 화면이 붉게 번쩍입니다.
피격 플래시
•
체력이 다 떨어지면 플레이어가 쓰러지고, 1초 뒤 화면 가운데에 Game Over! 가 뜹니다. (그 뒤로는 아무 일도 없습니다. 9장에서 이어집니다.)
실제 플레이 — 적을 잡을 때마다 점수가 오르고, 번개를 쏘면 체력 바 위의 게이지가 찼다가 줄고, 맞으면 체력이 줄며 화면이 붉게 번쩍인다
그리고 이 장 맨 앞에서 한 약속을 확인합니다. ProcedureMain.OpenForms 의 OpenOnce(ui, UITable.HudForm, UITable.DefaultGroup); 을 주석 처리하고 Play 해 보세요. HUD 는 뜨지 않고, 게임은 에러 없이 그대로 돕니다. 듣는 사람이 없는 이벤트는 그냥 사라집니다. 확인한 뒤 주석을 풉니다.
7.5 일시정지 메뉴
둘째 UI 폼입니다. Esc 를 누르면 게임이 멈추고 메뉴가 뜹니다. HUD 가 "듣기만 하는" 폼이었다면 이것은 입력을 받는 폼입니다.
4장에서 GamePause 를 만들어 두었습니다. "지금 멈춰 있는가"라는 깃발과, 프레임워크에게 시간을 멈추라고 전하는 SetPaused 입니다. 플레이어의 PlayerPausedState 는 이미 이 깃발을 보고 있습니다. 남은 일은 깃발을 세울 스위치를 만드는 것뿐입니다.
PauseMenuForm.prefab 의 구조입니다.
PauseMenuForm ← Canvas (Screen Space - Overlay, Sort Order 100) + PauseMenuForm 스크립트
└ PausePanel ← 화면을 덮는 반투명 판. 이것을 켜고 끈다
├ PausedText
├ ResumeButton
├ QuitButton
├ AudioToggle ← 소리 켜기/끄기
├ EffectsSlider ← 효과음 볼륨
└ MusicSlider ← 배경음 볼륨
Plain Text
복사
// Assets/GameMain/UI/PauseMenuForm.cs
using UnityEngine;
using UnityEngine.Audio;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 일시정지 메뉴. Esc 로 열고 닫으며 볼륨·음소거·종료를 제공한다.
///
/// <b>폼 자체는 판이 시작될 때 열려서 계속 떠 있고, 패널만 켜고 끈다.</b>
/// 닫혀 있으면 Esc 를 읽을 주체가 없어지기 때문이다.
///
/// <b>이 스크립트는 프리팹에 직접 붙어 있어야 한다</b>(UI 폼 규약 — 엔티티와 정반대).
/// </summary>
public class PauseMenuForm : UIFormLogic
{
// 프리팹 계층 계약.
private const string PanelPath = "PausePanel";
private const string ResumeButtonPath = "PausePanel/ResumeButton";
private const string QuitButtonPath = "PausePanel/QuitButton";
private const string AudioTogglePath = "PausePanel/AudioToggle";
private const string EffectsSliderPath = "PausePanel/EffectsSlider";
private const string MusicSliderPath = "PausePanel/MusicSlider";
// 믹서에 노출된 파라미터 이름.
private const string SfxVolumeParam = "sfxVol";
private const string MusicVolumeParam = "musicVol";
[Tooltip("MasterMixer. 볼륨 슬라이더가 이 믹서의 노출 파라미터를 직접 건드린다.")]
[SerializeField] private AudioMixer masterMixer = null;
[Tooltip("음소거 스냅샷.")]
[SerializeField] private AudioMixerSnapshot mutedSnapshot = null;
[Tooltip("음소거 해제 스냅샷.")]
[SerializeField] private AudioMixerSnapshot unMutedSnapshot = null;
private GameObject mPanel = null;
private Button mResumeButton = null;
private Button mQuitButton = null;
private Toggle mAudioToggle = null;
private Slider mEffectsSlider = null;
private Slider mMusicSlider = null;
// ─── 생명주기 ───
protected internal override void OnInit(object userData)
{
base.OnInit(userData);
Transform panel = CachedTransform.Find(PanelPath);
mPanel = panel != null ? panel.gameObject : null;
if (mPanel == null)
{
Log.Error("PauseMenuForm: {0} 을 찾지 못했다. 일시정지 화면이 뜨지 않는다.", PanelPath);
}
mResumeButton = Find<Button>(ResumeButtonPath);
mQuitButton = Find<Button>(QuitButtonPath);
mAudioToggle = Find<Toggle>(AudioTogglePath);
mEffectsSlider = Find<Slider>(EffectsSliderPath);
mMusicSlider = Find<Slider>(MusicSliderPath);
// 버튼 연결은 프리팹의 onClick 직렬화가 아니라 코드로 건다.
// OnInit 은 인스턴스당 1회라 리스너가 중복되지 않는다.
if (mResumeButton != null) mResumeButton.onClick.AddListener(Resume);
if (mQuitButton != null) mQuitButton.onClick.AddListener(Quit);
if (mAudioToggle != null) mAudioToggle.onValueChanged.AddListener(SetSoundOn);
if (mEffectsSlider != null) mEffectsSlider.onValueChanged.AddListener(SetEffectsLevel);
if (mMusicSlider != null) mMusicSlider.onValueChanged.AddListener(SetMusicLevel);
}
private T Find<T>(string path) where T : Component
{
Transform child = CachedTransform.Find(path);
if (child == null)
{
Log.Warning("PauseMenuForm: 자식 {0} 이 없다.", path);
return null;
}
T component = child.GetComponent<T>();
if (component == null)
{
Log.Warning("PauseMenuForm: {0} 에 {1} 컴포넌트가 없다.", path, typeof(T).Name);
}
return component;
}
protected internal override void OnOpen(object userData)
{
base.OnOpen(userData);
// 폼은 판 내내 떠 있지만 패널은 닫힌 상태로 시작한다.
SetPanelActive(false);
GamePause.SetPaused(false);
PullVolumeFromMixer();
}
protected internal override void OnClose(bool isShutdown, object userData)
{
// 정지 상태로 판을 벗어나면 timeScale 이 0 인 채로 남아 다음 판이 통째로 멈춘다.
GamePause.Clear();
base.OnClose(isShutdown, userData);
}
/// <summary>
/// 정지 중에도 돌아야 한다.
/// (UIFormLogic.OnUpdate 는 timeScale 과 무관하게 매 프레임 불린다.)
/// </summary>
protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(elapseSeconds, realElapseSeconds);
if (!IsCancelPressed()) return;
Toggle();
}
/// <summary>Esc. 키보드가 없으면 무시한다.</summary>
private static bool IsCancelPressed()
{
Keyboard keyboard = Keyboard.current;
return keyboard != null && keyboard.escapeKey.wasPressedThisFrame;
}
// ─── 정지 토글 ───
public void Toggle()
{
bool paused = !GamePause.IsPaused;
GamePause.SetPaused(paused);
SetPanelActive(paused);
}
public void Resume()
{
GamePause.SetPaused(false);
SetPanelActive(false);
}
private void SetPanelActive(bool active)
{
if (mPanel == null) return;
if (mPanel.activeSelf == active) return;
mPanel.SetActive(active);
}
// ─── 오디오 ───
/// <summary>믹서의 현재 값을 슬라이더에 반영한다.</summary>
private void PullVolumeFromMixer()
{
if (masterMixer == null)
{
Log.Warning("PauseMenuForm: MasterMixer 가 지정되지 않았다. 볼륨 조절이 동작하지 않는다.");
return;
}
if (mEffectsSlider != null && masterMixer.GetFloat(SfxVolumeParam, out float sfx))
{
mEffectsSlider.SetValueWithoutNotify(sfx);
}
if (mMusicSlider != null && masterMixer.GetFloat(MusicVolumeParam, out float music))
{
mMusicSlider.SetValueWithoutNotify(music);
}
}
public void SetEffectsLevel(float level)
{
if (masterMixer == null) return;
masterMixer.SetFloat(SfxVolumeParam, level);
}
public void SetMusicLevel(float level)
{
if (masterMixer == null) return;
masterMixer.SetFloat(MusicVolumeParam, level);
}
/// <summary>음소거는 볼륨 값이 아니라 스냅샷 전환으로 한다.</summary>
public void SetSoundOn(bool soundOn)
{
AudioMixerSnapshot snapshot = soundOn ? unMutedSnapshot : mutedSnapshot;
if (snapshot == null)
{
Log.Warning("PauseMenuForm: 오디오 스냅샷이 지정되지 않았다.");
return;
}
snapshot.TransitionTo(0f);
}
// ─── 종료 ───
public void Quit()
{
// 프레임워크를 먼저 내린다(모듈 정리 → 종료). 에디터에서는 재생을 멈춘다.
GameEntry.Shutdown(ShutdownType.Quit);
}
}
}
C#
복사
PauseMenuForm.prefab 의 루트에 이 스크립트를 붙이고, Inspector 에서 Master Mixer 와 스냅샷 두 칸을 채웁니다(예제 프로젝트의 프리팹에는 채워져 있습니다). 그리고 ProcedureMain.OpenForms 에 한 줄을 추가합니다. 일시정지 메뉴는 HUD 위에 떠야 하므로 1장에서 만든 Popup 그룹에 엽니다.
OpenOnce(ui, UITable.HudForm, UITable.DefaultGroup);
OpenOnce(ui, UITable.PauseMenuForm, UITable.PopupGroup);
C#
복사
코드 읽기
폼은 닫지 않고, 안의 판만 켜고 끕니다. 일시정지 메뉴라면 "Esc 를 누르면 폼을 열고, 다시 누르면 닫는다"가 자연스러워 보입니다. 하지만 폼이 닫혀 있으면 Esc 를 누가 읽을까요? OnUpdate 는 열려 있는 폼에서만 불립니다. 그래서 폼은 판이 시작될 때 HUD 와 함께 열어 두고, 눈에 보이는 PausePanel 만 SetActive 로 켜고 끕니다. 폼은 계속 살아서 Esc 를 기다립니다.
멈춘 게임에서 메뉴가 어떻게 움직이는가. 4장에서 "게임 시간을 멈춰도 OnUpdate 는 불린다"고 했습니다. 그때는 그것이 문제였습니다(멈췄는데 총이 나갑니다). 여기서는 그 성질 덕분에 메뉴가 동작합니다. 시간이 멈춘 상태에서도 PauseMenuForm.OnUpdate 는 매 프레임 불려 Esc 를 읽습니다. 같은 성질이 한쪽에서는 함정이고 다른 쪽에서는 필요한 기능입니다.
OnInit 의 AddListener — 버튼을 눌렀을 때 불릴 메서드를 코드로 연결합니다. 이것이 OnInit 에 있어야 하는 이유는 앞 절의
그대로입니다. OnOpen 에 두면 폼이 재사용될 때마다 리스너가 하나씩 더 쌓여, 세 번째 판에는 Resume 을 한 번 눌렀는데 세 번 불립니다.
그대로입니다. OnOpen 에 두면 폼이 재사용될 때마다 리스너가 하나씩 더 쌓여, 세 번째 판에는 Resume 을 한 번 눌렀는데 세 번 불립니다.들어올 때도, 나갈 때도 정지를 풉니다. OnOpen 의 GamePause.SetPaused(false) 와 OnClose 의 GamePause.Clear() 입니다. 메뉴를 띄운 채로 판이 끝나면(Quit 이 아니라 다른 경로로) 게임 시간이 0 인 채로 다음 판이 시작됩니다. 화면은 멀쩡한데 아무것도 움직이지 않는, 4장에서 경계한 "조용히 멈춘 게임"입니다. 스위치를 가진 쪽이 떠날 때 스위치를 원래대로 돌려놓습니다.
SetValueWithoutNotify — 슬라이더의 값을 코드로 바꾸면 onValueChanged 가 불립니다. 믹서에서 값을 읽어 와 슬라이더에 넣는 것인데, 그것이 다시 "슬라이더가 바뀌었으니 믹서에 써라"를 부르면 쓸데없는 왕복입니다. 이름 그대로 알리지 않고 값만 넣습니다.
GameEntry.Shutdown(ShutdownType.Quit) — 게임을 끝낼 때도 프레임워크에 맡깁니다. Application.Quit() 을 직접 부르면 프레임워크의 모듈들이 정리될 기회 없이 꺼집니다. Shutdown 은 모듈을 먼저 내린 뒤 종료하고, 에디터에서는 Play 를 멈춥니다.
겹치는 순서는 UI 그룹이 정합니다. 일시정지 메뉴는 HUD 위에 그려져야 합니다. 1장에서 UI 그룹을 둘 만들고 Popup 의 Depth 를 1 로 둔 것이 이것 때문입니다. 그런데 그룹을 나누는 것만으로는 끝나지 않습니다. 아래에서 그 이유와 마지막 한 조각을 만듭니다.
겹치는 순서 — UIFormBase
프레임워크는 폼을 열거나 닫거나 앞으로 가져올 때마다 폼 로직의 OnDepthChanged(그룹 깊이, 그룹 안의 순서) 를 부릅니다. 그런데 기본으로 들어 있는 그룹 도우미(DefaultUIGroupHelper)는 이 깊이를 받아 아무것도 하지 않습니다. 깊이를 실제 그리기 순서로 옮기는 일은 게임 쪽 폼이 하게 되어 있습니다. 이 책의 폼은 둘 다 루트에 자기 Canvas 를 들고 있으므로, 그 Canvas 의 sortingOrder 에 깊이를 적어 주면 됩니다.
폼마다 같은 코드를 쓰지 않도록, 3장에서 엔티티 로직의 공통 부모 EntityLogicBase 를 둔 것처럼 UI 폼에도 공통 부모를 하나 둡니다.
// Assets/GameMain/UI/UIFormBase.cs
using UnityEngine;
using UnityGameFramework.Runtime;
namespace ToyBoxNightmare
{
/// <summary>
/// 이 게임의 UI 폼 공통 베이스. <see cref="EntityLogicBase"/> 의 UI 쪽 짝이다.
///
/// <b>폼이 겹치는 순서를 프레임워크의 UI 그룹이 정하게 한다.</b> UIComponent 는 폼을 열고
/// 닫고 앞으로 가져올 때마다 <see cref="OnDepthChanged"/> 로 "몇 번째 깊이의 그룹에서
/// 몇 번째 폼인가" 를 알려 준다. 여기서 그 값을 폼 Canvas 의 sortingOrder 로 옮긴다.
/// 그래서 프리팹 Canvas 에 적힌 sortingOrder 는 쓰이지 않는다 — 순서를 바꾸려면
/// GameFramework.prefab 의 UI 그룹 깊이나, 폼을 여는 그룹(<see cref="UITable"/>)을 바꾼다.
/// </summary>
public abstract class UIFormBase : UIFormLogic
{
/// <summary>UI 그룹 깊이 1 이 차지하는 sortingOrder 폭. 그룹 하나에 폼이 100개를 넘지 않는 한 그룹끼리 섞이지 않는다.</summary>
private const int GroupDepthFactor = 10000;
/// <summary>그룹 안에서 폼 하나가 차지하는 sortingOrder 폭. 폼 안에 하위 Canvas 를 둘 여유를 남긴다.</summary>
private const int FormDepthFactor = 100;
private Canvas mCanvas = null;
protected internal override void OnInit(object userData)
{
base.OnInit(userData);
mCanvas = GetComponent<Canvas>();
if (mCanvas == null)
{
Log.Warning("{0}: 루트에 Canvas 가 없다. UI 그룹 깊이가 화면 순서에 반영되지 않는다.", Name);
}
}
/// <summary>
/// 그룹 안의 순서가 바뀔 때마다 불린다. 폼이 열린 직후에도 한 번 온다
/// (코어 UIManager.InternalOpenUIForm 이 OnOpen 다음에 그룹을 Refresh 한다).
/// </summary>
protected internal override void OnDepthChanged(int uiGroupDepth, int depthInUIGroup)
{
base.OnDepthChanged(uiGroupDepth, depthInUIGroup);
if (mCanvas == null) return;
mCanvas.sortingOrder = GroupDepthFactor * uiGroupDepth + FormDepthFactor * depthInUIGroup;
}
}
}
C#
복사
그리고 두 폼의 부모를 UIFormLogic 에서 이것으로 바꿉니다.
public class HUDForm : UIFormBase
C#
복사
public class PauseMenuForm : UIFormBase
C#
복사
코드 읽기
OnDepthChanged 는 폼이 열린 직후에도 한 번 옵니다. 프레임워크는 OnOpen 을 부른 다음 그룹 안의 순서를 다시 매기면서 이 메서드를 부릅니다. 그래서 따로 "처음 한 번" 처리를 하지 않아도 폼이 뜨자마자 순서가 맞춰집니다.
숫자를 만드는 식은 그룹 깊이 × 10000 + 그룹 안의 순서 × 100 입니다. HUD 는 0 × 10000 + 1 × 100 = 100, 일시정지 메뉴는 1 × 10000 + 1 × 100 = 10100 이 됩니다. 그룹 깊이에 큰 폭을 주었기 때문에 한 그룹 안에 폼이 아무리 쌓여도(100개 아래라면) 다른 그룹과 섞이지 않습니다.
protected internal override — UIFormLogic 은 Unity Game Framework(Assets/Scripts)의 클래스라 우리와 같은 어셈블리입니다. 1장의 표 그대로입니다.
Canvas 가 없으면 경고를 남깁니다. 폼이 Canvas 없이 만들어지면 순서가 반영되지 않는데, 화면만 봐서는 이유를 알 수 없습니다. 조용히 지나가지 않게 한 줄 남깁니다.
Play 중에 Hierarchy 를 보면 두 폼이 각자의 그룹 아래에 있고, 일시정지 메뉴의 Sort Order 가 실행 중에 바뀌어 있습니다.
두 UI 그룹. ① UI Group - Default 아래의 HUD ② UI Group - Popup 아래의 일시정지 메뉴 ③ 실행 중에 바뀐 Sort Order — 10100 ④ 프리팹에 붙여 둔 Pause Menu Form 과, 프레임워크가 실행 중에 붙인 UI Form (화면이 붉은 것은 맞는 순간의 피격 플래시다)
프리팹 Canvas 의 Sort Order 칸(HUD 0, 일시정지 100)은 이제 쓰이지 않습니다. 실행 중에 UIFormBase 가 덮어씁니다. 순서를 바꾸고 싶으면 프리팹을 고치지 말고, GameFramework 프리팹의 UI 그룹 Depth 나 폼을 여는 그룹(UITable)을 바꿉니다. 순서를 정하는 곳이 한 군데여야 "왜 이게 위에 그려지지?"를 찾아 헤매지 않습니다.
실행해 보기
Play → 캐릭터를 고르고 적이 몰려올 때 Esc 를 누릅니다.
일시정지 메뉴
•
적과 플레이어가 그 자리에 멈춥니다.
•
멈춘 상태에서 마우스를 눌러도 무기가 나가지 않습니다. 4장의 PlayerPausedState 입니다. 플레이어 코드에는 "메뉴가 떠 있으면"이라는 조건이 어디에도 없습니다. 메뉴는 깃발을 세웠고, 플레이어는 깃발을 봤을 뿐입니다.
•
W 를 누른 채로 Esc 를 눌렀다가 다시 Esc 로 풀어 봅니다. 풀리는 순간 튀어나가지 않습니다. PlayerPausedState.OnEnter 의 ClearMoveInput() 입니다.
•
Resume 버튼이나 Esc 로 다시 움직입니다. 슬라이더로 볼륨을 조절할 수 있습니다(아직 소리가 거의 없지만, 8장에서 채웁니다).
•
Quit 을 누르면 에디터의 Play 가 멈춥니다.
7.6 잘 안 될 때
증상 | 원인과 해결 |
HUD 가 안 뜬다. UI 폼 'HUDForm'(그룹 Default) 열기 실패: … | 로그의 뒷부분을 읽습니다. 주소가 다르거나, UI 컴포넌트에 Default 그룹이 없습니다 |
HUD 는 보이는데 Select a character 도, 점수도 안 나온다 | 프리팹 루트에 HUDForm 스크립트가 붙어 있지 않습니다. UI 폼은 엔티티와 반대로 반드시 붙여야 합니다 |
HUDForm: 자식 … 이 없다. 프리팹 구조를 확인할 것. | 프리팹의 자식 이름이 코드의 경로 상수와 다릅니다 |
HUD 를 띄운 뒤로 캐릭터를 클릭해도 반응이 없다 | HUD 의 Image / Text 중 Raycast Target 이 켜진 것이 화면을 덮고 있습니다 |
새 판을 시작했는데 체력 바가 비어 있다가 처음 맞을 때 튄다 | Player.OnShow 의 스폰 통보 FireHealthChanged(HitPointRatio, HitPointRatio) 가 빠졌습니다 |
플레이어가 나타나는 순간 화면이 붉게 번쩍인다 | HUD 가 ne.IsDamage 를 확인하지 않고 BeginDamageFlash() 를 부릅니다 |
냉기를 쏠 때마다 쿨다운 선이 깜빡인다 | BeginCooldown 의 if (mAttackCooldown <= 0f) return; 이 빠졌습니다 |
Event '…' not allow duplicate handler. | OnClose 에서 Unsubscribe() 를 하지 않아, 다시 열릴 때 같은 핸들러를 또 등록했습니다 |
Esc 를 눌러도 메뉴가 안 뜬다 | ① OpenForms 에 PauseMenuForm 을 열었는가 ② 프리팹 루트에 스크립트가 붙었는가 ③ Game 창에 포커스가 있는가 |
메뉴의 버튼과 슬라이더가 눌리지 않는다 | 씬에 EventSystem 이 없습니다 |
Resume 을 한 번 눌렀는데 여러 번 불린다 | AddListener 를 OnInit 이 아니라 OnOpen 에 두었습니다 |
메뉴를 띄운 채 Play 를 멈췄다 다시 시작하니 게임이 멈춰 있다 | OnClose 의 GamePause.Clear() 가 빠졌습니다 |
PauseMenuForm: MasterMixer 가 지정되지 않았다. | 프리팹의 Inspector 에서 Master Mixer 칸이 비어 있습니다 |
일시정지 메뉴가 HUD 뒤에 가려진다 | PauseMenuForm 을 Popup 그룹이 아니라 Default 그룹에 열었거나, 폼이 UIFormBase 가 아니라 UIFormLogic 을 바로 상속하고 있습니다 |
이 장에서 만든 것
파일 | 내용 |
UI/UITable.cs | UI 폼의 주소와 그룹 이름 |
UI/HUDForm.cs | 점수·체력·쿨다운·게임오버 문구. 이벤트만 듣는다 |
UI/PauseMenuForm.cs | Esc 로 여는 일시정지 메뉴 |
UI/UIFormBase.cs | 폼 공통 부모. UI 그룹 깊이를 Canvas 의 그리기 순서로 옮긴다 |
Game/ScoreChangedEventArgs.cs | "점수가 바뀌었다" |
Game/PlayerHealthChangedEventArgs.cs | "플레이어 체력이 바뀌었다" |
Game/WeaponCooldownStartedEventArgs.cs | "쿨다운이 걸렸다" |
Procdure/ProcedureMain.cs (수정) | UI 폼 열기, 열기 실패 감지 |
Game/SurvivalGame.cs, Entity/EntityLogic/Player.cs, Weapon/WeaponLoadout.cs (수정) | 값이 바뀌는 자리에서 이벤트 발행 |
정리
•
UI 폼은 엔티티와 같은 수명(OnInit → OnOpen → OnUpdate → OnClose)을 갖고 풀에서 재사용된다. 단, 로직 스크립트를 프리팹에 붙여 둔다
•
UI 는 게임을 묻지 않고 듣는다. 게임은 UI 를 모른다. UI 를 지워도 게임이 돈다
•
이벤트는 값을 바꾸는 코드 바로 옆에서 쏜다
•
이벤트에는 듣는 쪽이 그대로 그릴 수 있는 결과를 싣는다. 무엇을 실을지는 듣는 쪽이 무엇을 그리는가에서 정한다
•
이벤트로 그리는 화면에는 "지금 값"을 알리는 첫 통보가 필요하다
•
비동기로 여는 것(엔티티, UI 폼)에는 실패를 듣는 구독을 함께 건다. 귀를 먼저 달고 문을 두드린다
•
한 번만 할 일(자식 찾기, 리스너 달기)은 OnInit, 매번 할 일(화면 되돌리기, 구독)은 OnOpen
•
계속 입력을 받아야 하는 폼은 닫지 않고 안의 판만 켜고 끈다
•
폼이 겹치는 순서는 UI 그룹의 깊이로 정한다. 깊이를 화면 순서로 옮기는 것은 폼의 OnDepthChanged 다(UIFormBase)
•
스위치를 가진 쪽이 떠날 때 스위치를 원래대로 돌려놓는다
직접 해 보기
1.
HUDForm 의 ScoreFormat 을 "점수 {0:N0}" 으로 바꿔 보세요. 그리고 EnemyTable 에서 토끼의 점수를 10 에서 1500 으로 바꿔 천 단위 쉼표가 찍히는 것을 확인합니다.
2.
ScoreChangedEventArgs 에는 Delta(이번에 오른 점수)가 실려 있는데 HUD 는 쓰지 않습니다. OnScoreChanged 에서 Log.Info("+{0}", ne.Delta); 를 찍어 보세요. 그다음, 이것을 화면에 +10 처럼 잠깐 떴다 사라지는 글자로 만들려면 무엇이 필요할지 생각해 보세요. (힌트: 6장의 HitEffect 는 "잠깐 떴다 사라지는 것"이었습니다.)
3.
Player.OnShow 의 스폰 통보 한 줄을 주석 처리하고, 한 번 죽은 뒤 Play 를 멈췄다 다시 시작해… 볼 수가 없습니다. 재시작이 아직 없으니까요. 9장을 마친 뒤 이 줄을 다시 주석 처리하고 두 번째 판의 체력 바를 지켜보세요.
4.
PauseMenuForm.OnInit 의 AddListener 다섯 줄을 OnOpen 으로 옮기면 지금은 아무 차이가 없습니다. 왜일까요? 이것도 9장 뒤에 다시 해 봅니다.
5.
(조금 어려움) 지금 무슨 무기를 들고 있는지 화면에 보이지 않습니다. WeaponSwitchedEventArgs(무기 이름을 싣는다)를 만들고, WeaponLoadout.ReadSwitchInput 과 Equip 에서 쏘고, HUDForm 이 받아 InfoText 에 Score: 40 [Frost] 처럼 보이게 해 보세요. 고치는 파일은 셋(새 이벤트, 로드아웃, HUD)이고 Player 는 건드리지 않습니다.












