서비스
교육 철학

03. 캐릭터 선택 — 첫 엔티티와 첫 이벤트

Unity 를 처음 배울 때 캐릭터를 화면에 올리는 방법은 둘 중 하나였습니다. 씬에 미리 끌어다 놓거나, 스크립트에서 Instantiate(prefab) 을 부르거나. 그리고 없앨 때는 Destroy(gameObject) 였습니다.
작은 게임에서는 이걸로 충분합니다. 그런데 뱀서라이크는 적이 1초에 몇 마리씩 나타났다 죽고, 총알과 이펙트는 그보다 훨씬 자주 생겼다 사라지는 장르입니다. 매번 만들고 부수면 메모리 할당이 쌓여 주기적으로 화면이 끊깁니다. 그래서 다들 오브젝트 풀을 직접 만듭니다. 그러다 "풀에서 꺼낸 총알이 지난번 속도를 그대로 갖고 있다"거나 "이미 풀에 넣은 걸 한 번 더 넣었다" 같은 버그를 한 번씩 겪습니다.
프레임워크는 이 일을 엔티티라는 이름으로 통째로 맡아 줍니다. 우리는 "이 프리팹을 이 위치에 띄워 줘", "이제 치워 줘"만 말하면 되고, 로드·풀링·재사용은 프레임워크가 합니다.
이 장에서는 캐릭터 둘을 엔티티로 띄우고, 클릭하면 "누가 선택됐다" 는 소식이 게임 전체에 퍼지게 만듭니다. 장이 끝나면 화면은 이렇게 됩니다.
이 장의 결과. 왼쪽 빨간 고깔이 Boy, 오른쪽 초록 호박 모자가 Girl 이다. 둘 다 코드로 띄운 엔티티다
둘 중 하나를 클릭하면 콘솔에 Character selected: Girl 이 찍히고, 고른 쪽은 바로 사라지고, 고르지 않은 쪽은 쓰러졌다가 사라집니다.
🎯
이 장에서 하는 일
1.
프리팹을 주소로 불러올 수 있게 등록한다 (Addressables)
2.
"어디에, 무엇을" 띄울지 적는 주문서를 만든다 (EntityData)
3.
엔티티가 나타나고 사라질 때 할 일을 적는다 (EntityLogic)
4.
클릭을 이벤트로 바꿔 알린다 (Event)
5.
고르지 않은 쪽의 "쓰러졌다 사라지기"를 상태 기계로 만든다 (FSM)
새로 배우는 것이 가장 많은 장입니다. 대신 여기서 익힌 다섯 가지가 뒤의 모든 장에서 그대로 반복됩니다. 적도, 총알도, 아군도 전부 같은 방법으로 만듭니다.
📝
책에 싣는 코드는 읽기 쉽도록 주석을 줄였습니다. 예제 프로젝트의 파일에는 "왜 이렇게 했는가"가 훨씬 자세히 적혀 있으니, 따라 친 뒤에 원본 파일도 한 번 열어 보세요.

3.1 엔티티란 무엇인가

엔티티는 게임 도중에 나타났다 사라지는 모든 것입니다. 캐릭터, 적, 총알, 폭발 이펙트가 전부 엔티티입니다. 반대로 바닥, 벽, 조명처럼 씬에 처음부터 끝까지 있는 것은 엔티티가 아닙니다.
익숙한 Unity 방식과 나란히 놓으면 이렇습니다.
Unity 기본 방식
엔티티
만들기
Instantiate(prefab)
ShowEntity(번호, 로직 타입, 주소, 그룹, 데이터)
없애기
Destroy(gameObject)
HideEntity(entity) — 부수지 않고 풀에 넣어 둔다
처음 한 번 할 일
Awake / Start
OnInit
나타날 때마다 할 일
(없음 — 매번 새로 만드니까)
OnShow
사라질 때 할 일
OnDestroy
OnHide
프리팹을 가리키는 방법
인스펙터 칸에 드래그
주소 문자열 ("Girl")
가장 중요한 차이는 OnInit 과 OnShow 가 나뉜다는 점입니다. 엔티티는 한 번 만들어지면 사라질 때 파괴되지 않고 풀에서 기다리다가, 같은 프리팹이 또 필요해지면 그 인스턴스가 다시 나옵니다.
graph LR
    A["ShowEntity 요청"] --> B{"풀에 쉬고 있는<br>인스턴스가 있나"}
    B -->|"없다"| C["프리팹 로드<br>+ 인스턴스 생성"]
    C --> D["OnInit"]
    D --> E["OnShow"]
    B -->|"있다"| E
    E --> F["OnUpdate<br>(매 프레임)"]
    F --> F
    F --> G["HideEntity 요청"]
    G --> H["OnHide"]
    H --> I["풀에서 대기"]
    I -.-> B
Mermaid
복사
그래서 역할이 이렇게 갈립니다.
•
OnInit — "이 오브젝트에 어떤 부품이 달려 있나" 처럼 한 번 알아 두면 되는 것. 컴포넌트 찾아 두기가 대표적입니다.
•
OnShow — 위치, 체력, 타이머처럼 나타날 때마다 새로 정해야 하는 것. 지난번에 쓰던 값이 남아 있을 수 있으므로 전부 다시 넣습니다.
•
OnHide — 내가 만들거나 등록한 것을 치우는 곳.
Unity 의 Awake 와 Start 는 인스턴스가 처음 만들어질 때만 불리고, 풀에서 다시 나올 때는 불리지 않습니다. 그래서 엔티티 로직에서는 Awake / Start 를 쓰지 않습니다. 쓰면 첫 판은 멀쩡하고 두 번째 판부터 이상해지는, 찾기 어려운 버그가 됩니다.

3.2 프리팹에 주소 붙이기

ShowEntity 는 프리팹을 인스펙터 참조가 아니라 문자열 주소로 찾습니다. 이 주소를 관리하는 것이 Unity 의 Addressables 입니다. 프레임워크는 "이 주소의 에셋을 줘"라고 Addressables 에 부탁할 뿐입니다.
캐릭터 프리팹은 예제 프로젝트의 Assets/Art/Prefabs/Characters/ 에 Girl.prefab, Boy.prefab 으로 들어 있습니다.
① 메뉴에서 Window → Asset Management → Addressables → Groups 를 엽니다.
② Project 창에서 Girl.prefab 과 Boy.prefab 을 Groups 창의 그룹 위로 끌어다 놓습니다. (예제 프로젝트에는 Prefabs 라는 그룹이 있습니다.)
③ 방금 생긴 항목의 Addressable Name 칸을 클릭해 이름을 줄입니다.
프리팹
처음 붙는 주소
바꿀 주소
Girl.prefab
Assets/Art/Prefabs/Characters/Girl.prefab
Girl
Boy.prefab
Assets/Art/Prefabs/Characters/Boy.prefab
Boy
주소를 줄이는 이유는 두 가지입니다. 코드에 긴 폴더 경로가 박히지 않고, 나중에 프리팹을 다른 폴더로 옮겨도 주소가 그대로라 코드를 고칠 필요가 없습니다.
⚠️
주소는 대소문자까지 정확히 코드의 문자열과 같아야 합니다. girl 이나 Girl (끝에 공백)이면 못 찾습니다. 그리고 못 찾았을 때 화면에는 아무 일도 일어나지 않습니다. 3.8 절에서 "실패하면 알려 달라"는 구독을 걸어 둘 때까지는 조용합니다.
🚨
엔티티 프리팹에는 로직 스크립트를 붙이지 않습니다. Girl.prefab 을 열어 보면 모델, 애니메이터, 콜라이더, 리지드바디만 있고 우리가 만든 스크립트는 없습니다. 이게 정상입니다. ShowEntity 에 넘긴 로직 타입을 프레임워크가 실행 중에 직접 붙입니다. 미리 붙여 두면 같은 스크립트가 두 개가 되어 클릭이 두 번 처리되는 식의 버그가 납니다.

3.3 번호표와 안전장치

엔티티를 띄우려면 먼저 작은 도구 두 개가 필요합니다. Assets/GameMain 아래에 Utility 폴더를 만듭니다.

번호표 — EntitySerialId

프레임워크는 엔티티마다 겹치지 않는 번호를 요구합니다. 나중에 "3번 엔티티를 치워 줘"처럼 번호로 가리키기 때문입니다. 번호를 직접 지어내면 언젠가 겹치므로, 번호표 기계를 하나 만들어 모든 엔티티가 여기서만 번호를 받게 합니다.
// Assets/GameMain/Utility/EntitySerialId.cs namespace ToyBoxNightmare { /// <summary> /// 엔티티 고유 ID 전역 생성기. /// 모든 엔티티(플레이어, 적, 투사체 등)가 이걸 통해 ID를 받는다. /// /// 리셋 API 가 없다. 프로시저를 재진입해 새 판을 시작해도 번호는 이어서 나간다. /// </summary> public static class EntitySerialId { /// <summary>마지막으로 발급한 ID. 아직 아무것도 발급하지 않았으면 0 이고, 첫 발급은 1 이다.</summary> private static int mLastIssuedId = 0; /// <summary>다음 ID 를 발급한다.</summary> public static int Next() { // int 범위를 넘기면 음수로 돌아 이미 살아 있는 엔티티와 ID 가 충돌한다. // 도달하려면 21억 번 스폰해야 하므로 에디터/개발 빌드에서만 확인한다. GameAssert.IsTrue( mLastIssuedId < int.MaxValue, "EntitySerialId 가 int 범위를 넘었다. 발급 ID 가 음수로 돌아 엔티티 ID 가 충돌한다."); return ++mLastIssuedId; } } }
C#
복사
++mLastIssuedId 는 "1 올린 뒤 그 값을 돌려준다"입니다. 처음 부르면 1, 다음은 2 가 나옵니다. 판이 바뀌어도 0 으로 되돌리지 않는데, 지난 판의 엔티티가 아직 사라지는 중일 때 새 판이 같은 번호를 쓰면 충돌하기 때문입니다. 번호는 그냥 계속 커지게 둡니다.

안전장치 — GameAssert

위 코드에 나온 GameAssert 를 만듭니다.
// Assets/GameMain/Utility/GameAssert.cs using System.Diagnostics; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 사전조건(precondition) 명시용. "여기까지 왔다면 반드시 참이어야 한다" 를 코드로 적는다. /// 에디터와 개발 빌드에서만 살아 있다. /// </summary> public static class GameAssert { /// <summary>condition 이 거짓이면 오류를 남긴다. 조건에는 <b>충족해야 할 것</b>을 쓴다.</summary> [Conditional("UNITY_EDITOR")] [Conditional("DEVELOPMENT_BUILD")] public static void IsTrue(bool condition, string message) { if (condition) return; Log.Error("[사전조건 위반] {0}", message); } /// <summary>도달하면 안 되는 곳. switch 의 default 절 등에 쓴다.</summary> [Conditional("UNITY_EDITOR")] [Conditional("DEVELOPMENT_BUILD")] public static void Unreachable(string message) { Log.Error("[도달 불가 분기] {0}", message); } /// <summary>index 가 [0, count) 안인지 확인한다.</summary> [Conditional("UNITY_EDITOR")] [Conditional("DEVELOPMENT_BUILD")] public static void InRange(int index, int count, string name) { if (0 <= index && index < count) return; Log.Error("[범위 초과] {0} = {1}, 유효 범위는 [0, {2})", name, index, count); } } }
C#
복사

코드 읽기

어서트(assert)는 "여기서 이 조건이 거짓이면 내가 코드를 잘못 짠 것이다" 를 적어 두는 문장입니다. 틀렸을 때 조용히 넘어가지 않고 콘솔에 빨간 줄을 남깁니다.
무엇에 쓰고 무엇에 쓰지 않는가가 중요합니다. "사거리 안에 적이 없다", "플레이어가 아직 스폰되지 않았다"는 게임 중에 정상적으로 일어나는 일입니다. 이런 것은 어서트가 아니라 if (…) return; 으로 조용히 빠져나갑니다. 어서트는 "배열 범위를 넘었다", "0 이하의 ID 가 들어왔다"처럼 프로그래머가 실수했을 때만 참이 깨지는 조건에 씁니다.
[Conditional("UNITY_EDITOR")] 은 "이 심볼이 정의돼 있지 않으면 이 메서드를 부르는 줄 자체를 지워라"는 뜻입니다. 출시용 빌드에서는 호출이 통째로 사라지므로 검사를 아무리 많이 넣어도 성능 부담이 없습니다. 1장에서 본 Log 가 같은 방식으로 만들어져 있습니다.

3.4 스폰 주문서 — EntityData

ShowEntity 를 부르는 순간 캐릭터가 바로 나타나는 것은 아닙니다. 프리팹을 읽어 오는 데 시간이 걸리기 때문에, 요청한 프레임과 실제로 나타나는 프레임이 다릅니다.
그 사이에 "어디에 세울지, 어떤 값으로 시작할지"를 누군가 들고 있어야 합니다. 그 역할을 하는 것이 EntityData 입니다. 식당의 주문서와 같습니다. 주문할 때 적고, 음식이 나올 때 주방이 그것을 읽습니다.
sequenceDiagram
    participant G as SurvivalGame
    participant F as 프레임워크
    participant L as PlayerSelectLogic
    G->>F: ShowEntity(…, 주문서)
    Note over F: 프리팹 로드 중<br>(몇 프레임 걸릴 수 있다)
    F->>L: OnShow(주문서)
    Note over L: 주문서를 읽고<br>위치를 잡는다
Mermaid
복사
Assets/GameMain/Entity/EntityData 폴더를 만들고 공통 부모부터 씁니다.
// Assets/GameMain/Entity/EntityData/EntityData.cs using GameFramework; using UnityEngine; namespace ToyBoxNightmare { /// <summary> /// 엔티티 스폰 파라미터의 공통 베이스. /// 스폰 요청 시점의 값을 로드 완료 시점까지 실어 나르는 그릇이다. /// ReferencePool 로 재사용한다. /// </summary> public abstract class EntityData : IReference { private int mId = 0; private int mTypeId = 0; private Vector3 mPosition = Vector3.zero; private Quaternion mRotation = Quaternion.identity; /// <summary> /// ReferencePool.Acquire&lt;T&gt;() 가 new() 제약을 걸기 때문에 /// 파라미터 없는 생성자가 반드시 있어야 한다. /// </summary> protected EntityData() { } /// <summary> /// 파생 Create 팩토리가 취득 직후 부르는 공통 초기화. /// </summary> protected void Fill(int entityId, int typeId) { // EntitySerialId 는 1 부터 발급한다. 0 이나 음수가 들어왔다면 발급을 거치지 않고 // 호출부가 숫자를 직접 만든 것이다 — 계약 위반이라 assert 로 잡는다. GameAssert.IsTrue(entityId > 0, "EntityData 의 entityId 가 0 이하다. EntitySerialId.Next() 로 발급받은 값을 넘겨야 한다."); mId = entityId; mTypeId = typeId; } /// <summary>엔티티 고유 ID (런타임에서 유니크).</summary> public int Id { get { return mId; } } /// <summary>엔티티 타입 ID (DataTable RowId 등).</summary> public int TypeId { get { return mTypeId; } } /// <summary>스폰 위치.</summary> public Vector3 Position { get { return mPosition; } set { mPosition = value; } } /// <summary>스폰 회전.</summary> public Quaternion Rotation { get { return mRotation; } set { mRotation = value; } } /// <summary> /// 풀 반납 시 코어가 부른다. 파생은 base.Clear() 를 먼저 부르고 /// 자기 필드를 이어서 되돌린다. /// </summary> public virtual void Clear() { mId = 0; mTypeId = 0; mPosition = Vector3.zero; mRotation = Quaternion.identity; } } }
C#
복사
이어서 캐릭터 선택용 주문서입니다. 공통 항목에 "어떤 캐릭터인가" 하나를 더합니다.
// Assets/GameMain/Entity/EntityData/CharacterSelectData.cs using GameFramework; using UnityEngine; namespace ToyBoxNightmare { /// <summary> /// 캐릭터 선택 단계에 세워 두는 모델(<see cref="PlayerSelectLogic"/>)의 데이터. /// </summary> public class CharacterSelectData : EntityData { /// <summary> /// 선택되면 스폰할 플레이어의 Addressables 주소("Girl" / "Boy"). /// </summary> public string CharacterKey { get; private set; } public static CharacterSelectData Create(int entityId, int typeId, string characterKey, Vector3 position) { GameAssert.IsTrue(!string.IsNullOrEmpty(characterKey), "CharacterSelectData 의 characterKey 가 비어 있다."); CharacterSelectData data = ReferencePool.Acquire<CharacterSelectData>(); data.Fill(entityId, typeId); data.CharacterKey = characterKey; data.Position = position; return data; } public override void Clear() { base.Clear(); // 남겨 두면 다음 선택 캐릭터가 이전 판의 키를 물고 나와 두 캐릭터가 겹친다. CharacterKey = null; } } }
C#
복사

코드 읽기

new 가 없습니다. CharacterSelectData.Create(…) 안에서 ReferencePool.Acquire<CharacterSelectData>() 로 꺼내 옵니다. 참조 풀은 다 쓴 객체를 버리지 않고 모아 뒀다가 다시 내주는 창고입니다. 창고에 남은 것이 있으면 그것을 주고, 없으면 그때만 새로 만듭니다.
캐릭터 선택은 한 판에 두 번뿐이라 new 를 써도 아무 문제가 없습니다. 하지만 5장의 적과 6장의 총알은 초당 수십 번 스폰됩니다. 그때 가서 방식을 바꾸지 않도록, 처음부터 모든 주문서를 같은 방식으로 만듭니다.
참조 풀을 쓰는 클래스는 약속 세 가지를 지켜야 합니다.
약속
코드에서
어기면
IReference 를 구현하고 인자 없는 생성자가 있다
EntityData : IReference, protected EntityData()
컴파일 에러
Clear() 에서 모든 필드를 처음 상태로 되돌린다
Clear() 두 곳
다음에 꺼낸 객체가 지난번 값을 그대로 갖고 나온다
다 쓰면 정확히 한 번 ReferencePool.Release 로 돌려준다
다음 절의 EntityLogicBase
안 돌려주면 창고가 비어 계속 새로 만든다. 두 번 돌려주면 예외
둘째 약속이 가장 자주 사고가 납니다. 필드를 하나 추가하면 Clear() 에도 한 줄 추가하는 것을 습관으로 만드세요. CharacterSelectData.Clear() 가 base.Clear() 를 먼저 부르는 것도 눈여겨보세요. 부모의 필드 네 개는 부모가, 자기 필드는 자기가 치웁니다.
생성자 대신 Create 를 쓰는 이유 — 풀에서 꺼낸 객체는 생성자가 다시 불리지 않습니다. 그래서 값을 채우는 일을 정적 메서드 Create 가 맡습니다. 밖에서는 CharacterSelectData.Create(…) 한 줄만 보이므로 "꺼내고, 채우고"의 순서를 틀릴 일이 없습니다.

3.5 모든 엔티티의 공통 부모 — EntityLogicBase

프레임워크가 주는 EntityLogic 을 그대로 상속해도 되지만, 모든 엔티티가 똑같이 조심해야 하는 일 두 가지가 있어서 우리 쪽 공통 부모를 하나 끼워 넣습니다.
1.
주문서를 창고에 돌려주기. 방금 본 셋째 약속입니다. 엔티티마다 따로 쓰면 반드시 누군가 빠뜨립니다.
2.
두 번 치우지 않기. 프레임워크는 같은 엔티티에 HideEntity 를 두 번 부르면 예외를 던집니다. 그런데 "총알이 수명이 다한 바로 그 프레임에 적에게 맞았다" 같은 일은 실제로 일어납니다.
Assets/GameMain/Entity/EntityLogic 폴더를 만듭니다.
// Assets/GameMain/Entity/EntityLogic/EntityLogicBase.cs using GameFramework; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 이 프로젝트의 모든 엔티티 로직이 상속하는 공통 베이스. /// 모든 Hide 는 SafeHide() 를 거치게 하고, EntityData 의 풀 반납도 여기서 한다. /// </summary> public abstract class EntityLogicBase : EntityLogic { private bool mHidden = false; /// <summary> /// 이 엔티티가 스폰될 때 받은 데이터. 풀에 돌려줄 책임이 이 인스턴스에 있다. /// null 이면 소유한 것이 없다는 뜻이고, 그 자체가 이중 반납 가드다. /// </summary> private EntityData mOwnedData = null; /// <summary> /// 이미 Hide 요청이 나갔는지. HideEntity 는 즉시 처리되지 않고 다음 Update 의 /// 회수 큐로 넘어가므로, 그 사이에 들어오는 추가 처리를 막을 때 쓴다. /// </summary> protected bool IsHiding { get { return mHidden; } } protected internal override void OnShow(object userData) { base.OnShow(userData); mHidden = false; // 스폰 데이터의 소유권을 여기서 잡는다. mOwnedData = userData as EntityData; } protected internal override void OnHide(bool isShutdown, object userData) { mHidden = true; base.OnHide(isShutdown, userData); ReleaseOwnedData(); } /// <summary> /// 스폰 데이터를 풀에 돌려준다. 이중 Release 는 코어에서 즉시 예외이므로 /// 참조를 먼저 비우고 나서 반납한다. /// </summary> private void ReleaseOwnedData() { if (mOwnedData == null) return; EntityData data = mOwnedData; mOwnedData = null; ReferencePool.Release(data); } /// <summary> /// HideEntity 를 정확히 1회만 호출한다. 중복 호출은 조용히 무시된다. /// 엔티티 제거는 반드시 이 메서드를 통할 것 — Destroy/SetActive 는 풀을 깨뜨린다. /// </summary> public void SafeHide() { if (mHidden) return; // Entity 가 null 이라는 것은 이 인스턴스가 EntityManager 를 거치지 않고 // 프리팹에 미리 붙어 있던 로직이라는 뜻이다(규약 위반). GameAssert.IsTrue(Entity != null, "SafeHide 대상의 Entity 가 null 이다. 프리팹에 EntityLogic 이 baked 되어 있는지 확인할 것."); if (Entity == null) return; // 게임 종료나 씬 언로드 중에는 EntityComponent 가 먼저 파괴돼 있을 수 있다. EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) return; mHidden = true; entityComponent.HideEntity(Entity); } } }
C#
복사

코드 읽기

주문서의 주인은 누구인가. OnShow 에서 mOwnedData = userData as EntityData 로 주문서를 받아 들고, OnHide 의 맨 끝에서 ReleaseOwnedData() 로 돌려줍니다. 엔티티가 화면에 있는 동안은 그 엔티티가 주문서의 주인입니다. 이제 EntityLogicBase 를 상속한 클래스는 주문서 반납을 전혀 신경 쓰지 않아도 됩니다.
ReleaseOwnedData 가 mOwnedData = null 을 먼저 하고 나서 Release 하는 순서를 보세요. 어떤 이유로든 이 메서드가 두 번 불려도, 두 번째에는 첫 줄의 null 검사에 걸려 그냥 돌아갑니다. "두 번 돌려주면 예외"라는 약속을 구조로 막은 것입니다.
SafeHide() 는 mHidden 깃발 하나로 "한 번만"을 보장합니다. 앞으로 이 책에서 엔티티를 없앨 때는 언제나 SafeHide() 만 씁니다. Destroy(gameObject) 나 gameObject.SetActive(false) 를 쓰면 프레임워크는 그 엔티티가 아직 살아 있다고 믿고 있어서, 풀 관리가 어긋납니다.
mHidden = false 를 OnShow 에서 되돌리는 것이 3.1 절에서 말한 "나타날 때마다 전부 다시 넣는다"의 첫 사례입니다. 이 줄이 없으면, 한 번 사라졌다가 풀에서 다시 나온 엔티티는 깃발이 선 채라 영원히 치울 수 없게 됩니다.
💡
protected internal override 로 씁니다. 2장의 프로시저는 protected override 였는데 여기는 한 단어가 더 붙습니다. EntityLogic 은 Assets/Scripts 에 소스로 들어 있어 우리 코드와 같은 어셈블리이고, 그래서 원래 선언(protected internal virtual)을 그대로 따라 써야 합니다. 프로시저의 부모는 코어 DLL 에 있어 다른 어셈블리였습니다 — 1.6 절의 표를 다시 보세요. 틀리면 컴파일 에러 CS0507 이 바로 알려 줍니다.

3.6 첫 엔티티 띄우기

도구가 다 준비됐습니다. 이제 캐릭터를 화면에 세웁니다.

엔티티 로직 — PlayerSelectLogic

먼저 "나타날 때 주문서를 읽고 자리를 잡는다"까지만 만듭니다.
// Assets/GameMain/Entity/EntityLogic/PlayerSelectLogic.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 캐릭터 선택 단계에서 씬에 배치되는 엔티티 로직. /// Addressables 키: "Girl", "Boy" (SurvivalGame 이 넘기는 문자열과 동일) /// </summary> public class PlayerSelectLogic : EntityLogicBase { [SerializeField] private string characterKey = "Girl"; [SerializeField] private CapsuleCollider capsuleCollider = null; [SerializeField] private Animator animator = null; public string CharacterKey => characterKey; protected internal override void OnInit(object userData) { base.OnInit(userData); capsuleCollider = GetComponent<CapsuleCollider>(); // includeInactive: true 가 필수다. 아래 설명 참조. animator = GetComponentInChildren<Animator>(true); WarnOnMissingComponents(); } /// <summary> /// 캐시가 비면 예외 대신 <b>선택이 조용히 죽는다</b> — 콜라이더가 없으면 이 캐릭터를 /// 영영 고를 수 없고, 두 캐릭터가 모두 그러면 게임이 진행되지 않는다. /// </summary> private void WarnOnMissingComponents() { if (capsuleCollider == null) { Log.Error("PlayerSelectLogic '{0}': CapsuleCollider 가 없어 클릭 선택이 불가능하다.", Name); } if (animator == null) { Log.Warning("PlayerSelectLogic '{0}': Animator 를 찾지 못했다. 낙선 연출이 재생되지 않는다.", Name); } } protected internal override void OnShow(object userData) { base.OnShow(userData); var selectData = userData as CharacterSelectData; if (selectData == null) { // 스폰 측 계약 위반. 프리팹 기본 키/위치로 남아 두 캐릭터가 겹칠 수 있다. Log.Error("Character select data is invalid."); return; } characterKey = selectData.CharacterKey; CachedTransform.position = selectData.Position; CachedTransform.rotation = selectData.Rotation; } } }
C#
복사

코드 읽기

3.1 절의 역할 분담이 그대로 보입니다. OnInit 은 "이 오브젝트에 콜라이더와 애니메이터가 달려 있다"는, 한 번 알아 두면 되는 사실을 기억합니다. OnShow 는 주문서를 읽어 이번에 설 자리를 잡습니다.
userData as CharacterSelectData — OnShow 가 받는 userData 는 우리가 ShowEntity 에 넘긴 바로 그 주문서입니다. 프레임워크는 내용물을 모르기 때문에 object 로 들고 왔고, 우리가 원래 타입으로 되돌려 읽습니다. as 는 타입이 다르면 예외 대신 null 을 주므로 바로 아래에서 검사합니다.
CachedTransform 은 프레임워크가 미리 잡아 둔 transform 입니다. 매번 transform 을 부르는 것보다 조금 빠르고, 엔티티 로직에서는 관례적으로 이것을 씁니다.
필드에 [SerializeField] 가 붙어 있는데 인스펙터에서 넣지 않습니다. 프리팹에 이 스크립트가 붙어 있지 않으니 넣을 방법도 없습니다. 그런데도 붙인 이유는 실행 중에 인스펙터로 값을 들여다보기 위해서입니다. 잠시 뒤 직접 확인합니다.
⚠️
GetComponentInChildren<Animator>(true) 의 true — "꺼져 있는 오브젝트도 뒤져라"는 뜻입니다. 이 프로젝트에서는 반드시 필요합니다.
풀에서 쉬고 있는 인스턴스는 SetActive(false) 상태입니다. 그리고 풀은 주소별로 하나라서, 같은 Girl 인스턴스가 어떤 판에는 선택용 로직으로, 다른 판에는 4장에서 만들 플레이어 로직으로 불려 나올 수 있습니다. 로직 타입이 지난번과 다르면 프레임워크는 스크립트를 갈아 끼우고 OnInit 을 다시 부르는데, 이때 오브젝트는 아직 꺼져 있습니다(OnShow 에서 켜집니다). true 가 없으면 꺼진 자신을 건너뛰어 null 이 됩니다. 실제로 "두 번째 판부터 쓰러지는 애니메이션이 안 나온다"는 버그의 원인이었습니다.

주문 넣기 — SurvivalGame

2장에서 만든 SurvivalGame.cs 를 엽니다. 클래스 맨 위에 설정값을 모아 둡니다.
// Assets/GameMain/Game/SurvivalGame.cs using UnityEngine; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { public class SurvivalGame : GameBase { // ─── 설정 ─── // 캐릭터 키와 Addressables 주소는 같은 문자열이다("Girl" 주소로 로드한 엔티티의 키가 "Girl"). private const string GirlCharacterKey = "Girl"; private const string BoyCharacterKey = "Boy"; /// <summary>플레이어·선택 캐릭터가 들어가는 엔티티 그룹. GameFramework.prefab 의 mEntityGroups 와 일치해야 한다.</summary> private const string PlayerEntityGroup = "Player"; /// <summary> /// EntityData 의 TypeId. 아직 타입 테이블이 없어 프로젝트 전체가 1 로 고정이다. /// </summary> public const int DefaultEntityTypeId = 1; /// <summary>선택 화면에서 두 캐릭터가 서는 자리.</summary> private static readonly Vector3 GirlSelectPosition = new Vector3(-2f, 0f, 0f); private static readonly Vector3 BoySelectPosition = new Vector3( 2f, 0f, 0f); // (이하 2장에서 만든 내용)
C#
복사
그리고 클래스 아래쪽에 스폰 메서드 둘을 추가합니다.
// ─── 스폰 ─── private void SpawnSelectCharacters() { SpawnSelectCharacter(GirlCharacterKey, GirlSelectPosition); SpawnSelectCharacter(BoyCharacterKey, BoySelectPosition); } /// <summary> /// 캐릭터 선택용 엔티티를 스폰한다. /// 캐릭터 키가 곧 Addressables 주소라 인자 하나로 둘 다 쓴다. /// </summary> private void SpawnSelectCharacter(string characterKey, Vector3 position) { EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) { Log.Error("EntityComponent 를 찾을 수 없어 선택 캐릭터 '{0}' 를 스폰하지 못했다.", characterKey); return; } int id = EntitySerialId.Next(); entityComponent.ShowEntity( id, typeof(PlayerSelectLogic), characterKey, PlayerEntityGroup, CharacterSelectData.Create(id, DefaultEntityTypeId, characterKey, position)); }
C#
복사
마지막으로 Initialize 에서 부릅니다. 2장에서 넣었던 확인용 로그 "SurvivalGame: 판 시작" 은 이제 지웁니다.
public override void Initialize() { base.Initialize(); ResetRoundState(); SpawnSelectCharacters(); }
C#
복사

코드 읽기

GameEntry.GetComponent<EntityComponent>() — 1장에서 GameFramework 오브젝트 아래에 달려 있던 Entity 컴포넌트를 꺼내 옵니다. 프레임워크의 기능은 전부 이런 식으로 "컴포넌트를 꺼내서 메서드를 부른다"는 한 가지 모양입니다.
ShowEntity 의 인자 다섯 개가 이 장의 핵심입니다.
순서
넘긴 값
뜻
1
id
번호표. EntitySerialId.Next() 로 받은 값
2
typeof(PlayerSelectLogic)
붙일 로직. 프레임워크가 인스턴스에 이 스크립트를 AddComponent 한다
3
characterKey
Addressables 주소. "Girl" 또는 "Boy"
4
PlayerEntityGroup
엔티티 그룹. 1장에서 Entity 컴포넌트에 등록한 "Player"
5
CharacterSelectData.Create(…)
주문서. OnShow 의 userData 로 그대로 도착한다
같은 번호를 두 군데에 넣습니다. ShowEntity 의 첫 인자와 CharacterSelectData.Create 의 첫 인자가 둘 다 id 입니다. 그래서 EntitySerialId.Next() 를 변수에 한 번 받아 두고 두 곳에 씁니다. 각각 Next() 를 부르면 번호가 서로 달라져 버립니다.
문자열을 상수로 뺀 이유 — "Girl" 을 코드 여기저기에 직접 쓰면 한 곳에서 "Gril" 로 오타를 내도 컴파일러가 잡아 주지 못합니다. 상수 이름을 틀리면 컴파일 에러가 나므로 바로 알 수 있습니다.

실행해 보기

Play 를 누릅니다.
캐릭터 둘이 나타났다. 아직 클릭해도 아무 일도 일어나지 않는다
씬에 놓은 적이 없는 캐릭터 둘이 서 있습니다. Hierarchy 창에서 GameFramework 를 펼쳐 보면 이들이 어디에 생겼는지 볼 수 있습니다.
GameFramework └ Entity └ Entity Instances └ Entity Group - Player ├ Girl(Clone) └ Boy(Clone)
Plain Text
복사
우리가 넘긴 그룹 이름 "Player" 아래에 정리돼 있습니다. Girl(Clone) 을 클릭해 Inspector 를 봅니다.
실행 중인 Girl(Clone) 의 Inspector. 맨 아래 두 컴포넌트는 프리팹에 없던 것이다 (위의 기본 컴포넌트들은 접어 두었다)
맨 아래 Entity 와 Player Select Logic 두 컴포넌트는 프리팹에 없던 것입니다. 프레임워크가 실행 중에 붙였습니다. 3.2 절에서 "프리팹에 로직을 붙이지 않는다"고 한 이유가 이것입니다.
Player Select Logic 의 Character Key 칸에 Girl 이 들어 있는 것도 보입니다. 주문서에 적은 값이 OnShow 를 거쳐 여기까지 도착했다는 증거입니다. [SerializeField] 를 붙여 둔 덕분에 이렇게 눈으로 확인할 수 있습니다.
에디터 전체로 보면 이렇습니다. 그림은 완성된 예제를 찍은 것이라 이 장에서 만들지 않은 것도 조금 섞여 있습니다.
선택 화면을 에디터 전체로 본 모습. ① Entity Group - Player 아래의 두 캐릭터 ② 화면 속의 그 둘 ③ 실행 중에 붙은 Entity 와 Player Select Logic ④ 선택 조명도 엔티티다
① 은 우리가 넘긴 그룹 이름 아래에 정리된 두 캐릭터, ② 는 화면 속의 그 둘, ③ 는 프레임워크가 실행 중에 붙인 두 컴포넌트입니다. ④ 의 CharacterSelectSpotlight(Clone) 은 마우스를 올린 캐릭터를 비추는 조명으로, 예제에 들어 있는 연출입니다. 조명도 "게임 도중에 나타났다 사라지는 것"이라서 씬에 놓지 않고 엔티티로 띄워 Effect 그룹에 넣었습니다.
🔧
아무것도 안 보인다면 1장의 "실행 전 설정"을 다시 확인하세요. Addressables Groups 창의 Play Mode Script 가 Use Asset Database (fastest) 여야 합니다. 이 설정은 PC 마다 따로 저장되기 때문에, 프로젝트를 새로 내려받았다면 한 번은 직접 골라 줘야 합니다.

3.7 클릭을 알리기 — 이벤트

캐릭터를 클릭하면 게임이 시작되어야 합니다. 가장 쉬운 방법은 캐릭터가 게임 객체를 직접 부르는 것입니다.
survivalGame.StartWith("Girl"); // ❌ 이렇게 하지 않는다 (게임 객체를 어디선가 얻어 와서 직접 부른다)
C#
복사
동작은 합니다. 하지만 이렇게 쓰면 캐릭터가 게임 모드를 알게 됩니다. 나중에 선택 효과음을 넣으려면 여기에 사운드 호출이 붙고, 선택 화면 UI 를 닫으려면 UI 호출이 또 붙습니다. 캐릭터 스크립트가 게임의 모든 것을 아는 덩어리가 되어 갑니다.
그래서 캐릭터는 "내가 선택됐다"고 외치기만 합니다. 누가 듣는지는 모릅니다. 듣고 싶은 쪽이 알아서 귀를 기울입니다. 이것이 이벤트입니다.
graph LR
    P["PlayerSelectLogic<br>(외친다)"] -->|"Fire"| E(("이벤트<br>CharacterSelected"))
    E -->|"Subscribe 해 둔 쪽에게"| S["SurvivalGame"]
    E -.->|"나중에 추가해도<br>캐릭터 코드는 그대로"| U["UI, 사운드 …"]
Mermaid
복사

무엇을 외칠 것인가 — 이벤트 인자

이벤트 하나는 클래스 하나입니다. Assets/GameMain/Game 폴더에 만듭니다.
// Assets/GameMain/Game/CharacterSelectedEventArgs.cs using GameFramework; using GameFramework.Event; namespace ToyBoxNightmare { /// <summary> /// 선택 화면에서 캐릭터를 클릭했을 때 <see cref="PlayerSelectLogic"/> 이 발행한다. /// </summary> public class CharacterSelectedEventArgs : GameEventArgs { public static readonly int EventId = typeof(CharacterSelectedEventArgs).GetHashCode(); public override int Id => EventId; /// <summary>선택된 캐릭터의 키. Addressables 주소와 같은 문자열이다("Girl" / "Boy").</summary> public string CharacterKey { get; private set; } public static CharacterSelectedEventArgs Create(string characterKey) { var args = ReferencePool.Acquire<CharacterSelectedEventArgs>(); args.CharacterKey = characterKey; return args; } public override void Clear() { CharacterKey = null; } } }
C#
복사
3.4 절의 주문서와 구조가 똑같습니다. Create 로 풀에서 꺼내 채우고, Clear 에서 되돌립니다. 다른 점은 두 줄입니다.
EventId 는 이 이벤트의 고유 번호입니다. 프레임워크는 이벤트를 번호로 구분하는데, 번호를 직접 정하면 언젠가 겹칩니다. 그래서 타입의 해시값을 번호로 씁니다. 클래스가 다르면 번호도 다릅니다. 이 두 줄은 앞으로 만드는 모든 이벤트에 클래스 이름만 바꿔 그대로 들어갑니다.
이 책에서 만드는 이벤트는 전부 이 틀을 씁니다. 나중을 위해 외워 두세요. ① GameEventArgs 상속 → ② EventId 두 줄 → ③ 실어 보낼 값 → ④ Create → ⑤ Clear.

외치기 — 클릭 판정과 Fire

PlayerSelectLogic.cs 로 돌아갑니다. 파일 맨 위 using 을 이렇게 늘립니다.
using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.InputSystem; using UnityGameFramework.Runtime;
C#
복사
필드 아래에 다음을 추가합니다.
// 마우스를 이 캐릭터 위에서 눌렀는지. 누른 곳과 뗀 곳이 모두 자신일 때만 선택으로 친다. private bool mPressedOnSelf = false; private const float PickDistance = 1000f;
C#
복사
OnShow 의 base.OnShow(userData); 바로 아래에 한 줄을 넣습니다. 풀에서 다시 나올 때 지난번의 누름 상태가 남아 있으면 안 되기 때문입니다.
mPressedOnSelf = false;
C#
복사
그리고 클래스 아래쪽에 메서드 넷을 추가합니다.
protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); TickSelectInput(); // 3.9 절에서 상태 기계로 옮긴다 } /// <summary> /// 클릭 판정. Input System 으로 직접 판정한다. /// </summary> internal void TickSelectInput() { Mouse mouse = Mouse.current; if (mouse == null) return; if (mouse.leftButton.wasPressedThisFrame) { mPressedOnSelf = IsPointerOnSelf(mouse); } if (!mouse.leftButton.wasReleasedThisFrame) return; // 누른 곳과 뗀 곳이 모두 자신일 때만 선택으로 친다. bool clickCompletedOnSelf = mPressedOnSelf && IsPointerOnSelf(mouse); mPressedOnSelf = false; if (clickCompletedOnSelf) { FireCharacterSelected(); } } private void FireCharacterSelected() { // Create 는 참조 풀에서 꺼내 오므로, 발행하지 못할 상황이면 만들기 전에 빠진다. EventComponent eventComponent = GameEntry.GetComponent<EventComponent>(); if (eventComponent == null) { Log.Error("EventComponent 가 없어 CharacterSelected 를 발행하지 못했다."); return; } eventComponent.Fire(this, CharacterSelectedEventArgs.Create(characterKey)); } /// <summary>마우스 커서가 이 캐릭터의 콜라이더 위에 있는가.</summary> private bool IsPointerOnSelf(Mouse mouse) { if (capsuleCollider == null || !capsuleCollider.enabled) return false; // UI 위 클릭은 무시한다. if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return false; Camera cam = Camera.main; if (cam == null) return false; Ray ray = cam.ScreenPointToRay(mouse.position.ReadValue()); // 자기 콜라이더만 검사하지 않고 Physics.Raycast 로 최근접을 본다. // 앞을 가로막은 물체가 있으면 뚫고 선택되지 않는다. if (!Physics.Raycast(ray, out RaycastHit hit, PickDistance)) return false; return hit.collider == capsuleCollider; }
C#
복사

코드 읽기

OnUpdate 는 엔티티가 화면에 있는 동안 프레임워크가 매 프레임 불러 주는 메서드입니다. 엔티티 로직에서는 Unity 의 Update() 대신 이것을 씁니다. 풀에서 쉬는 동안에는 불리지 않습니다.
클릭 판정이 "눌렀다"가 아니라 "눌렀다 뗐다"입니다. TickSelectInput 은 버튼을 누른 순간 커서가 내 위에 있었는지를 mPressedOnSelf 에 적어 두고, 뗀 순간에도 내 위에 있으면 그때 선택으로 칩니다. 캐릭터 위에서 눌렀다가 마음이 바뀌어 바깥으로 끌고 나가 떼면 취소됩니다. 버튼 UI 가 동작하는 방식과 같습니다.
IsPointerOnSelf 는 위에서부터 차례로 "아니다"인 경우를 걸러 냅니다. 콜라이더가 없거나 꺼져 있으면 아니다, 커서가 UI 위에 있으면 아니다, 카메라가 없으면 아니다, 광선이 아무것도 못 맞혔으면 아니다. 다 통과하면 마지막 줄에서 "맞은 것이 나인가"를 답합니다. 이렇게 안 되는 경우를 먼저 돌려보내는 모양은 이 책의 거의 모든 메서드에서 반복됩니다. 중첩된 if 를 읽지 않아도 되어서 눈이 편합니다.
Camera.main 은 MainCamera 태그가 붙은 카메라를 찾습니다. 씬의 카메라에 이 태그가 없으면 null 이 되어 클릭이 조용히 무시됩니다.
eventComponent.Fire(this, …) — 첫 인자는 "누가 외쳤는가", 둘째가 방금 만든 이벤트 인자입니다.
🚨
이벤트 인자는 우리가 돌려주지 않습니다. Fire 에 넘긴 인자는 듣는 쪽이 전부 처리한 뒤 프레임워크가 알아서 풀에 돌려줍니다. 주문서(EntityData)는 우리가 Release 했는데 이벤트 인자는 하면 안 됩니다. 직접 Release 하면 두 번 돌려주는 셈이 되어 예외가 납니다.

듣기 — Subscribe

SurvivalGame.cs 로 갑니다. using 에 한 줄을 추가합니다.
using GameFramework.Event;
C#
복사
"런타임 상태" 필드에 깃발 하나를 추가합니다.
// CharacterSelected 는 선택 순간 스스로 해제하므로, Shutdown 시점에 아직 // 구독 중인지 알아야 한다. 미등록 핸들러를 Unsubscribe 하면 코어가 예외를 던진다. private bool mCharacterSelectSubscribed = false;
C#
복사
구독과 해제, 그리고 이벤트를 받았을 때 불릴 메서드를 씁니다.
private void SubscribeEvents() { // 초기화 시점에 EventComponent 가 없으면 게임이 성립하지 않는다 — 시끄럽게 알린다. EventComponent events = RequireEventComponent(); if (events == null) return; events.Subscribe(CharacterSelectedEventArgs.EventId, OnCharacterSelected); mCharacterSelectSubscribed = true; } private void UnsubscribeEvents() { // 캐릭터를 고르지 않고 종료하면 아직 구독 상태다. UnsubscribeCharacterSelected(); } private void UnsubscribeCharacterSelected() { if (!mCharacterSelectSubscribed) return; // Shutdown 경로에서도 불리므로 조용한 쪽을 쓴다. EventComponent events = FindEventComponent(); if (events == null) return; events.Unsubscribe(CharacterSelectedEventArgs.EventId, OnCharacterSelected); mCharacterSelectSubscribed = false; } // ─── 이벤트 핸들러 ─── private void OnCharacterSelected(object sender, GameEventArgs e) { CharacterSelectedEventArgs ne = e as CharacterSelectedEventArgs; if (ne == null) { GameAssert.Unreachable("CharacterSelected 핸들러에 다른 타입의 이벤트 인자가 들어왔다."); return; } // 한 번만 처리한다. UnsubscribeCharacterSelected(); Log.Info("Character selected: {0}", ne.CharacterKey); } /// <summary> /// EventComponent 조회. 없으면 <b>조용히</b> null 을 돌려준다. /// 컴포넌트가 없는 것이 정상일 수 있는 종료 경로에서 쓴다. /// </summary> private static EventComponent FindEventComponent() { return GameEntry.GetComponent<EventComponent>(); } /// <summary> /// EventComponent 조회. 없으면 원인을 로그로 남긴다. /// 반드시 있어야 하는 경로(초기화·이벤트 발행)에서 쓴다. /// </summary> private static EventComponent RequireEventComponent() { EventComponent events = FindEventComponent(); if (events == null) { Log.Error("EventComponent 를 찾을 수 없다. GameFramework.prefab 구성을 확인할 것."); } return events; }
C#
복사
Initialize 와 Shutdown 에 연결합니다.
public override void Initialize() { base.Initialize(); ResetRoundState(); SubscribeEvents(); SpawnSelectCharacters(); } public override void Shutdown() { UnsubscribeEvents(); base.Shutdown(); }
C#
복사

코드 읽기

Subscribe(이벤트 번호, 메서드) 는 "이 번호의 이벤트가 오면 이 메서드를 불러 줘"라는 등록입니다. 등록된 메서드를 핸들러라고 부릅니다. 핸들러의 모양은 언제나 (object sender, GameEventArgs e) 입니다. 프레임워크는 모든 이벤트를 GameEventArgs 로 뭉뚱그려 전달하므로, 핸들러의 첫 줄에서 as 로 원래 타입을 되찾습니다.
구독은 반드시 해제와 짝입니다. 2장에서 "OnEnter 에서 만든 것은 OnLeave 에서 치운다"고 했습니다. 구독도 만든 것입니다. Initialize 에서 구독했으면 Shutdown 에서 해제합니다. 해제하지 않으면 지난 판의 게임 객체가 버려진 뒤에도 프레임워크가 그 객체의 메서드를 계속 부릅니다.
그런데 이 이벤트는 사정이 조금 복잡합니다. 캐릭터를 고르는 순간 핸들러가 스스로 구독을 끊기 때문입니다(두 번 처리하지 않으려고). 그러면 Shutdown 때는 이미 끊긴 상태일 수도, 아직 걸린 상태일 수도 있습니다. 프레임워크는 다음 두 경우에 즉시 예외를 던집니다.
하면 안 되는 것
나오는 예외
같은 (이벤트, 핸들러) 를 두 번 Subscribe
Event '…' not allow duplicate handler.
등록하지 않은 핸들러를 Unsubscribe
Event '…' not exists specified handler.
그래서 mCharacterSelectSubscribed 깃발로 "지금 걸려 있는가"를 기억해 두고, UnsubscribeCharacterSelected 는 걸려 있을 때만 끊습니다. 덕분에 이 메서드는 몇 번을 불러도 안전합니다.
FindEventComponent 와 RequireEventComponent 를 나눈 이유 — 둘 다 같은 것을 찾지만 없을 때의 태도가 다릅니다. 게임을 시작하는 중에 이벤트 컴포넌트가 없다면 프리팹이 망가진 것이니 시끄럽게 알려야 합니다. 반면 에디터에서 Play 를 멈출 때는 프레임워크의 컴포넌트들이 우리보다 먼저 파괴될 수 있습니다. 그때 없는 것은 정상이라 조용히 넘어가야 합니다. 여기서도 로그를 남기면 Play 를 멈출 때마다 빨간 에러가 찍힙니다.

실행해 보기

Play 를 누르고 캐릭터 하나를 클릭합니다.
Character selected: Girl
Plain Text
복사
한 번 더 클릭해도 로그는 다시 나오지 않습니다. 첫 클릭에서 구독을 끊었기 때문입니다. 캐릭터 쪽은 여전히 "선택됐다"고 외치고 있지만 듣는 사람이 없습니다. 이벤트는 듣는 쪽이 없어도 에러가 아닙니다.
💡
Fire 는 다음 프레임에 도착합니다. Fire 는 이벤트를 대기열에 넣기만 하고 돌아옵니다. 핸들러는 다음 프레임에 프레임워크가 대기열을 비우면서 불립니다. 즉시 불리는 FireNow 도 있지만, 핸들러 안에서 또 이벤트를 쏘면 호출이 겹겹이 쌓여 순서를 따라가기 어려워집니다. 이 책은 Fire 만 씁니다. "이벤트를 쏜 바로 다음 줄에서는 아직 아무 일도 일어나지 않았다"는 것만 기억하면 됩니다.

3.8 고른 뒤 치우기

선택이 끝났으니 선택용 캐릭터 둘을 치워야 합니다. 그러려면 SurvivalGame 이 그 둘을 가리킬 수 있어야 합니다.
여기서 문제가 하나 있습니다. ShowEntity 는 아무것도 돌려주지 않습니다. 3.4 절에서 본 대로 요청과 완성 사이에 시간이 걸리기 때문입니다. 그럼 완성된 엔티티는 어떻게 받을까요? 답은 방금 배운 것입니다. 프레임워크가 "엔티티가 다 떴다"는 이벤트를 쏴 줍니다.
프레임워크가 쏘는 이벤트
언제
들어 있는 것
ShowEntitySuccessEventArgs
엔티티가 화면에 나타난 직후 (OnShow 가 끝난 뒤)
Entity, EntityLogicType, UserData
ShowEntityFailureEventArgs
띄우지 못했을 때 (주소를 못 찾는 등)
EntityId, EntityAssetName, ErrorMessage, UserData

GameBase 에 공통 처리 넣기

이 두 이벤트는 어떤 게임 모드든 필요합니다. 그래서 2장에서 "3장에서 추가한다"고 미뤄 둔 부분을 GameBase 에 넣습니다. 파일 맨 위에 using 을 추가하고,
// Assets/GameMain/Game/GameBase.cs using GameFramework; using GameFramework.Event; using UnityGameFramework.Runtime;
C#
복사
클래스의 Update 아래에 다음을 추가합니다.
protected virtual void OnShowEntitySuccess(object sender, GameEventArgs e) { } protected virtual void OnShowEntityFailure(object sender, GameEventArgs e) { // 이벤트 ID 로 등록했으니 캐스팅은 항상 성공해야 한다. 실패하면 구독 등록이 잘못된 것이다. ShowEntityFailureEventArgs ne = e as ShowEntityFailureEventArgs; if (ne == null) { GameAssert.Unreachable("ShowEntityFailure 핸들러에 다른 타입의 이벤트 인자가 들어왔다."); return; } ReleaseSpawnData(ne); Log.Warning("Show entity failure with error message '{0}'.", ne.ErrorMessage); } /// <summary> /// 스폰이 실패했을 때 <see cref="EntityData"/> 를 풀에 돌려준다. /// /// 실패하면 OnShow 가 아예 불리지 않아 <see cref="EntityLogicBase"/> 가 소유권을 /// 잡지 못한다. 여기서 놓치면 그 데이터는 영영 풀로 돌아가지 않는다. /// /// <b>한 실패 이벤트당 정확히 1회만 불러야 한다.</b> /// </summary> protected static void ReleaseSpawnData(ShowEntityFailureEventArgs ne) { EntityData data = ne.UserData as EntityData; if (data == null) return; // 이미 소유자가 생긴 데이터는 건드리지 않는다. // 코어는 엔티티를 등록한 뒤에 OnShow 를 부르고, 그 이후 구간에서 난 예외도 // 같은 userData 로 실패 이벤트를 낸다. 그 경우 EntityLogicBase 가 이미 소유권을 // 잡았으므로 회수 시 거기서 반납된다 — 여기서 또 반납하면 이중 Release 다. EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent != null && entityComponent.HasEntity(ne.EntityId)) return; ReferencePool.Release(data); }
C#
복사

코드 읽기

3.5 절에서 "엔티티가 화면에 있는 동안은 그 엔티티가 주문서의 주인"이라고 했습니다. 그럼 엔티티가 아예 뜨지 못했다면 주문서는 누가 돌려줄까요? OnShow 가 불리지 않았으니 받아 든 사람이 없습니다. 주문만 받고 음식은 못 나간 상황입니다. 그 주문서를 치우는 것이 ReleaseSpawnData 입니다.
중간의 HasEntity 검사는 드문 경우를 위한 것입니다. 엔티티가 등록까지는 됐는데 우리 OnShow 코드 안에서 예외가 터진 경우, 프레임워크는 이것도 "실패"로 알립니다. 하지만 이때는 EntityLogicBase.OnShow 가 이미 주문서를 받아 든 뒤라, 여기서 또 돌려주면 두 번이 됩니다. 그래서 "그 번호의 엔티티가 실제로 존재하면 손대지 않는다"고 한 번 더 확인합니다.
주문서 한 장이 걷는 길을 정리하면 이렇습니다. 어느 길로 가든 반납은 정확히 한 번입니다.
graph TD
    A["Create<br>(풀에서 꺼냄)"] --> B["ShowEntity 에 넘김"]
    B -->|"성공"| C["OnShow 가 받아 든다"]
    C --> D["OnHide 에서 반납"]
    B -->|"실패"| E["실패 이벤트"]
    E --> F["ReleaseSpawnData 가 반납"]
Mermaid
복사

완성된 엔티티 기억해 두기

SurvivalGame.cs 로 돌아옵니다. "런타임 상태" 필드에 선택 캐릭터를 가리킬 변수 둘을 추가합니다.
private PlayerSelectLogic mGirlSelect = null; private PlayerSelectLogic mBoySelect = null;
C#
복사
ResetRoundState 에도 추가합니다.
private void ResetRoundState() { Score = 0; mGameTime = 0f; mGirlSelect = null; mBoySelect = null; }
C#
복사
SubscribeEvents 와 UnsubscribeEvents 에 프레임워크 이벤트 둘을 더합니다.
private void SubscribeEvents() { // 초기화 시점에 EventComponent 가 없으면 게임이 성립하지 않는다 — 시끄럽게 알린다. EventComponent events = RequireEventComponent(); if (events == null) return; events.Subscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess); events.Subscribe(ShowEntityFailureEventArgs.EventId, OnShowEntityFailure); events.Subscribe(CharacterSelectedEventArgs.EventId, OnCharacterSelected); mCharacterSelectSubscribed = true; } private void UnsubscribeEvents() { // 앱 종료 경로에서는 EventComponent 가 먼저 파괴됐을 수 있다. // 그때는 구독도 함께 사라진 뒤라 조용히 빠져나가는 게 맞다. EventComponent events = FindEventComponent(); if (events == null) return; events.Unsubscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess); events.Unsubscribe(ShowEntityFailureEventArgs.EventId, OnShowEntityFailure); // 캐릭터를 고르지 않고 종료하면 아직 구독 상태다. UnsubscribeCharacterSelected(); }
C#
복사
성공 이벤트를 받아 참조를 잡아 두는 부분입니다.
protected override void OnShowEntitySuccess(object sender, GameEventArgs e) { ShowEntitySuccessEventArgs ne = e as ShowEntitySuccessEventArgs; if (ne == null) { GameAssert.Unreachable("ShowEntitySuccess 핸들러에 다른 타입의 이벤트 인자가 들어왔다."); return; } if (ne.EntityLogicType == typeof(PlayerSelectLogic)) { CacheSelectCharacter(ne.Entity); return; } } /// <summary>선택 캐릭터 참조를 잡아 둔다. 나중에 DismissSelectCharacters 가 쓴다.</summary> private void CacheSelectCharacter(Entity entity) { PlayerSelectLogic logic = entity.Logic as PlayerSelectLogic; if (logic == null) { Log.Error("PlayerSelectLogic 으로 스폰했는데 로직이 그 타입이 아니다: {0}", entity.EntityAssetName); return; } if (logic.CharacterKey == GirlCharacterKey) { mGirlSelect = logic; } else { mBoySelect = logic; } }
C#
복사
OnShowEntityFailure 는 따로 쓰지 않았습니다. GameBase 에 만들어 둔 기본 처리(주문서 반납 + 경고 로그)가 그대로 쓰입니다. 4장에서 "플레이어를 띄우지 못하면 판을 끝낸다"는 처리가 필요해질 때 오버라이드합니다.

치우기

이제 핸들러에서 둘을 치웁니다.
private void OnCharacterSelected(object sender, GameEventArgs e) { CharacterSelectedEventArgs ne = e as CharacterSelectedEventArgs; if (ne == null) { GameAssert.Unreachable("CharacterSelected 핸들러에 다른 타입의 이벤트 인자가 들어왔다."); return; } // 한 번만 처리한다. UnsubscribeCharacterSelected(); Log.Info("Character selected: {0}", ne.CharacterKey); DismissSelectCharacters(ne.CharacterKey); } /// <summary> /// 선택된 쪽은 즉시 회수, 선택받지 못한 쪽은 사망 연출 후 회수. /// HideEntity 를 직접 부르지 않고 SafeHide 를 쓴다 — 중복 Hide 는 코어에서 예외다. /// </summary> private void DismissSelectCharacters(string selectedCharacterKey) { bool girlSelected = selectedCharacterKey == GirlCharacterKey; PlayerSelectLogic selected = girlSelected ? mGirlSelect : mBoySelect; PlayerSelectLogic unselected = girlSelected ? mBoySelect : mGirlSelect; // 선택된 쪽을 먼저 치우는 순서를 유지한다. if (selected != null) { selected.SafeHide(); } if (unselected != null) { unselected.SafeHide(); // 3.9 절에서 DisableAndHide() 로 바꾼다 } // 회수한 뒤에는 참조를 반드시 비운다. // 엔티티는 풀에서 재사용되므로, 남겨 두면 나중에 같은 인스턴스가 다른 용도로 // 살아났을 때 그걸 선택 캐릭터로 착각해 건드리게 된다. mGirlSelect = null; mBoySelect = null; }
C#
복사

코드 읽기

ne.EntityLogicType 으로 "방금 뜬 엔티티가 어떤 종류인가"를 가립니다. 지금은 선택 캐릭터 하나뿐이지만, 뒤의 장에서 플레이어, 적, 아군이 이 if 아래에 한 갈래씩 추가됩니다. 엔티티가 뜰 때마다 모든 종류의 성공 이벤트가 이 메서드 하나로 들어온다는 점을 기억해 두세요.
girlSelected ? mGirlSelect : mBoySelect — "고른 쪽"과 "안 고른 쪽"에 이름을 붙여 두면 아래 코드가 "selected 를 치우고, unselected 를 치운다"로 그대로 읽힙니다. 조건을 변수 이름으로 풀어 쓰는 것만으로 주석이 필요 없어집니다.
if (selected != null) — 2장에서 미리 말한 주의점이 여기서 처음 실전에 나옵니다. selected 는 MonoBehaviour, 곧 Unity 오브젝트입니다. selected?.SafeHide() 라고 쓰면 안 됩니다. ?. 는 Unity 가 "이미 파괴됨"으로 표시해 둔 오브젝트를 살아 있다고 보고 호출해 버립니다.
마지막 두 줄에서 참조를 비웁니다. 이것이 풀을 쓸 때 가장 중요한 습관입니다. SafeHide 로 치운 Girl(Clone) 은 파괴되지 않고 풀로 돌아갑니다. 그리고 풀에 들어간 인스턴스는 언제든 다른 역할로 다시 나올 수 있습니다. 다음 장에서 만들 플레이어도 같은 Girl 주소를 쓰므로 같은 풀을 씁니다. mGirlSelect 를 비우지 않았다면, 이 변수는 어느 판엔가 "플레이어가 된 오브젝트"를 선택 캐릭터라고 믿고 가리키게 됩니다. 치웠으면 손을 뗀다. 변수에 남은 참조는 내 것이 아닙니다.

실행해 보기

Play → 캐릭터 클릭. 콘솔에 Character selected: Girl 이 찍히고 두 캐릭터가 동시에 사라집니다. Hierarchy 의 Entity Group - Player 아래를 보면 Girl(Clone) 과 Boy(Clone) 이 회색 글씨로 남아 있습니다. 파괴된 것이 아니라 꺼진 채(SetActive(false)) 풀에서 쉬고 있는 것입니다. 3.1 절의 그림에서 본 "풀에서 대기"가 바로 이 모습입니다.
동작은 맞습니다. 다만 고르지 않은 쪽이 그냥 뿅 사라지는 것이 심심합니다. 원작 Zombie Toys 에서는 고르지 않은 캐릭터가 쓰러진 뒤에 사라집니다. 마지막으로 이것을 만듭니다.

3.9 쓰러졌다 사라지기 — 첫 상태 기계

"쓰러지는 애니메이션을 틀고, 1.5초 기다린 뒤, 치운다." Unity 에서 "기다린 뒤"라고 하면 먼저 떠오르는 것은 코루틴입니다.
StartCoroutine(HideAfterDelay(1.5f)); // ❌ 엔티티에서는 쓰지 않는다
C#
복사
엔티티는 치워질 때 SetActive(false) 가 됩니다. 그리고 Unity 는 오브젝트가 꺼지는 순간 그 위에서 돌던 코루틴을 말없이 멈춥니다. 기다리는 도중에 다른 이유로 엔티티가 회수되면(예를 들어 그사이 판이 끝나면) 코루틴은 사라지고, 풀에서 다시 나온 인스턴스는 "기다리던 중"이었다는 사실을 잊습니다. 에러도 없이 연출만 어긋납니다.
그래서 "지금 무엇을 하는 중인가"를 상태로 명시합니다. 이 캐릭터의 일생은 두 단계뿐입니다.
graph LR
    S((" ")) --> A["Selectable<br>클릭을 기다린다"]
    A -->|"낙선 통보를 받으면"| B["Dying<br>쓰러지고, 1.5초 뒤 사라진다"]
    B --> E((" "))
Mermaid
복사
이런 것을 상태 기계(FSM) 라고 합니다. 2장의 프로시저가 바로 "게임 전체의 상태 기계"였습니다. 같은 도구를 이번에는 엔티티 하나에 씁니다.

FSM 이름

프레임워크는 상태 기계를 이름으로 구분합니다. 캐릭터가 둘이니 이름도 달라야 합니다. 엔티티 번호가 딱 맞습니다. 상태 기계를 쓰는 로직에 속성 하나로 둡니다. 아래의 PlayerSelectLogic 에 들어가고, 4장의 Player 와 5장의 Enemy 에도 같은 속성을 하나씩 둡니다.
/// <summary> /// FSM 이름. 코어는 (소유자 타입, 이름) 으로 식별하므로 인스턴스마다 달라야 한다. /// </summary> private string FsmName { // Entity 가 null 이면 프레임워크를 거치지 않고 프리팹에 붙여 둔 로직이다(규약 위반). // 그래도 이름이 겹치면 안 되므로 Unity 오브젝트의 고유 id 로 대신한다. get { return Entity != null ? Entity.Id.ToString() : GetEntityId().ToString(); } }
C#
복사

상태 셋

Assets/GameMain/Entity/EntityLogic/States 폴더를 만듭니다. 먼저 두 상태의 공통 부모입니다.
// Assets/GameMain/Entity/EntityLogic/States/PlayerSelectStateBase.cs using GameFramework.Fsm; namespace ToyBoxNightmare { /// <summary> /// 캐릭터 선택 엔티티 상태의 공통 베이스. /// /// <b>상태는 무상태(stateless)다.</b> 값은 전부 <c>fsm.Owner</c> 에서 읽는다 — /// 그래야 상태 인스턴스를 엔티티 풀 재사용 사이에 그대로 돌려쓸 수 있다. /// </summary> public abstract class PlayerSelectStateBase : FsmState<PlayerSelectLogic> { /// <summary> /// 회수 요청이 나간 뒤에는 어떤 상태도 돌지 않는다는 것을 한곳에서 보장한다. /// 파생은 <see cref="OnStateUpdate"/> 만 채우고 이 가드를 다시 쓰지 않는다. /// </summary> protected sealed override void OnUpdate(IFsm<PlayerSelectLogic> fsm, float elapseSeconds, float realElapseSeconds) { base.OnUpdate(fsm, elapseSeconds, realElapseSeconds); PlayerSelectLogic owner = fsm.Owner; if (!owner.CanRunStates) return; OnStateUpdate(fsm, owner, elapseSeconds); } protected abstract void OnStateUpdate(IFsm<PlayerSelectLogic> fsm, PlayerSelectLogic owner, float elapseSeconds); } }
C#
복사
클릭을 기다리는 상태입니다.
// Assets/GameMain/Entity/EntityLogic/States/PlayerSelectSelectableState.cs using GameFramework.Fsm; namespace ToyBoxNightmare { /// <summary> /// 고를 수 있는 상태. 마우스 클릭을 기다린다. /// </summary> public sealed class PlayerSelectSelectableState : PlayerSelectStateBase { protected override void OnEnter(IFsm<PlayerSelectLogic> fsm) { base.OnEnter(fsm); // 풀에서 재사용되므로 이전 판의 누름 상태가 남아 있으면 안 된다. fsm.Owner.ClearPressState(); } protected override void OnStateUpdate(IFsm<PlayerSelectLogic> fsm, PlayerSelectLogic owner, float elapseSeconds) { // 낙선 통보가 왔으면 이번 프레임 입력은 받지 않는다. // 상태를 밖에서 바꿀 수 없으므로(IFsm 에 ChangeState 가 없다) 소유자가 깃발만 세우고 // 여기서 폴링해 넘어간다. if (owner.IsRejected) { ChangeState<PlayerSelectDyingState>(fsm); return; } owner.TickSelectInput(); } } }
C#
복사
쓰러지는 상태입니다.
// Assets/GameMain/Entity/EntityLogic/States/PlayerSelectDyingState.cs using GameFramework.Fsm; namespace ToyBoxNightmare { /// <summary> /// 낙선 연출 상태. 쓰러지는 모습을 보여 준 뒤 스스로 회수된다. /// </summary> public sealed class PlayerSelectDyingState : PlayerSelectStateBase { protected override void OnEnter(IFsm<PlayerSelectLogic> fsm) { base.OnEnter(fsm); // 연출 시작은 상태 진입의 일부다. 소유자가 미리 해 두면 // "연출은 시작됐는데 상태는 아직 안 바뀐" 프레임이 생긴다. fsm.Owner.StartRejectedPresentation(); } protected override void OnStateUpdate(IFsm<PlayerSelectLogic> fsm, PlayerSelectLogic owner, float elapseSeconds) { owner.TickHideDelay(elapseSeconds); } } }
C#
복사

코드 읽기

상태 클래스는 2장의 프로시저와 생김새가 같습니다. OnEnter(들어올 때), OnUpdate(있는 동안 매 프레임), OnLeave(나갈 때). 실제로 프로시저가 이 FsmState 를 상속해서 만들어진 것입니다.
FsmState<PlayerSelectLogic> 의 꺾쇠 안은 이 상태 기계의 주인입니다. 상태 안에서는 fsm.Owner 로 주인을 꺼내 쓸 수 있습니다.
상태에는 변수가 없습니다. 세 클래스 어디에도 필드가 하나도 없습니다. 경과 시간도, 깃발도 전부 주인(PlayerSelectLogic)이 들고 있고, 상태는 주인에게 "이거 해라"고 시키기만 합니다. 이렇게 해 두면 상태 객체는 한 번만 만들어서 계속 돌려쓸 수 있고, "상태에 남은 지난 판의 값" 같은 버그가 구조적으로 생기지 않습니다. 되돌려야 할 값이 한 클래스에만 모여 있게 됩니다.
상태를 바꾸는 것은 상태 자신입니다. ChangeState<PlayerSelectDyingState>(fsm) 은 상태 클래스 안에서만 부를 수 있습니다. 밖에서 "너 이제 Dying 이야"라고 강제로 바꾸는 방법은 없습니다. 그래서 바깥(SurvivalGame)은 주인에게 깃발만 세우고(IsRejected), 상태가 매 프레임 그 깃발을 보다가 스스로 넘어갑니다. 번거로워 보이지만, 덕분에 "상태가 언제 바뀌는가"를 알려면 상태 클래스만 읽으면 됩니다.
공통 부모의 sealed override OnUpdate — 두 상태 모두 "주인이 이미 치워지는 중이면 아무것도 하지 않는다"가 필요합니다. 이것을 부모가 한 번만 검사하고, 통과했을 때만 OnStateUpdate 를 부릅니다. sealed 는 "자식이 이 메서드를 다시 덮어쓰지 못한다"는 뜻이라, 자식이 실수로 이 검사를 건너뛸 방법이 없습니다.

주인 쪽 — 완성된 PlayerSelectLogic

이제 PlayerSelectLogic 에 상태 기계를 만들고 부수는 코드, 그리고 상태가 부를 창구를 넣습니다. 고칠 곳이 많으니 완성된 파일 전체를 싣습니다. 3.6, 3.7 절에서 쓴 것 위에 덮어쓰면 됩니다.
// Assets/GameMain/Entity/EntityLogic/PlayerSelectLogic.cs using GameFramework.Fsm; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.InputSystem; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 캐릭터 선택 단계에서 씬에 배치되는 엔티티 로직. /// 클릭 시 CharacterSelectedEventArgs 를 발생시키고, /// 선택받지 못한 캐릭터는 사망 연출 후 HideEntity 된다. /// /// Addressables 키: "Girl", "Boy" (SurvivalGame 이 넘기는 문자열과 동일) /// </summary> public class PlayerSelectLogic : EntityLogicBase { [SerializeField] private string characterKey = "Girl"; [SerializeField] private CapsuleCollider capsuleCollider = null; [SerializeField] private Animator animator = null; [SerializeField] private Rigidbody rigidBody = null; // 프리팹 원본 감쇠값. DisableAndHide 이후 재사용될 때 되돌리기 위해 보관한다. private float mInitialLinearDamping = 0f; // 마우스를 이 캐릭터 위에서 눌렀는지. 누른 곳과 뗀 곳이 모두 자신일 때만 선택으로 친다. private bool mPressedOnSelf = false; /// <summary> /// 낙선 통보가 왔는가. 상태를 밖에서 바꿀 수 없으므로 /// <see cref="DisableAndHide"/> 는 이 깃발만 세우고, 상태가 폴링해서 넘어간다. /// </summary> private bool mRejected = false; /// <summary>낙선 연출 경과 시간.</summary> private float mRejectedElapsed = 0f; // ─── 상태 기계 ─── private IFsm<PlayerSelectLogic> mFsm = null; private FsmState<PlayerSelectLogic>[] mStates = null; private const float PickDistance = 1000f; /// <summary>사망 연출을 보여 주고 회수하기까지의 시간. 원본 낙선 연출 길이와 같다.</summary> private const float HideDelayAfterDeath = 1.5f; /// <summary>애니메이터 트리거 이름. 이름이 틀려도 Unity 는 조용히 무시한다.</summary> private const string DieTriggerParam = "Die"; public string CharacterKey => characterKey; protected internal override void OnInit(object userData) { base.OnInit(userData); capsuleCollider = GetComponent<CapsuleCollider>(); animator = GetComponentInChildren<Animator>(true); rigidBody = GetComponent<Rigidbody>(); if (rigidBody != null) { mInitialLinearDamping = rigidBody.linearDamping; } // 상태 인스턴스는 인스턴스당 1회만 만든다. 값을 전부 fsm.Owner 에서 읽으므로 // 풀 재사용 사이에 그대로 돌려쓸 수 있다. mStates = new FsmState<PlayerSelectLogic>[] { new PlayerSelectSelectableState(), new PlayerSelectDyingState(), }; WarnOnMissingComponents(); } private void WarnOnMissingComponents() { if (capsuleCollider == null) { Log.Error("PlayerSelectLogic '{0}': CapsuleCollider 가 없어 클릭 선택이 불가능하다.", Name); } if (animator == null) { Log.Warning("PlayerSelectLogic '{0}': Animator 를 찾지 못했다. 낙선 연출이 재생되지 않는다.", Name); } } protected internal override void OnShow(object userData) { base.OnShow(userData); // 풀에서 재사용되므로 DisableAndHide 가 남긴 상태를 반드시 되돌린다. // 이걸 빼면 재시작 시 낙선했던 캐릭터가 콜라이더가 꺼진 죽은 포즈로 등장한다. ResetVisualState(); mPressedOnSelf = false; mRejected = false; mRejectedElapsed = 0f; var selectData = userData as CharacterSelectData; if (selectData == null) { // 스폰 측 계약 위반. 프리팹 기본 키/위치로 남아 두 캐릭터가 겹칠 수 있다. Log.Error("Character select data is invalid."); // 배치는 못 했어도 클릭은 받아야 한다. 상태 기계 없이 두면 // 이 캐릭터를 영영 고를 수 없다. CreateStateMachine(); return; } characterKey = selectData.CharacterKey; CachedTransform.position = selectData.Position; CachedTransform.rotation = selectData.Rotation; // 상태 기계는 **맨 마지막**에 세운다. 상태가 소유자 값을 바로 읽기 때문이다. CreateStateMachine(); } protected internal override void OnHide(bool isShutdown, object userData) { // 가장 먼저 부순다. 회수된 엔티티의 상태가 계속 도는 것을 막는다. DestroyStateMachine(); base.OnHide(isShutdown, userData); } /// <summary> /// FSM 이름. 코어는 (소유자 타입, 이름) 으로 식별하므로 인스턴스마다 달라야 한다. /// </summary> private string FsmName { get { return Entity != null ? Entity.Id.ToString() : GetEntityId().ToString(); } } private void CreateStateMachine() { FsmComponent fsmComponent = GameEntry.GetComponent<FsmComponent>(); if (fsmComponent == null) { Log.Error("PlayerSelectLogic: FsmComponent 가 없다. 캐릭터를 고를 수 없다."); return; } mFsm = fsmComponent.CreateFsm(FsmName, this, mStates); mFsm.Start<PlayerSelectSelectableState>(); } /// <summary>멱등하다. 이미 부쉈거나 프레임워크가 먼저 내려갔으면 조용히 넘어간다.</summary> private void DestroyStateMachine() { if (mFsm == null) return; FsmComponent fsmComponent = GameEntry.GetComponent<FsmComponent>(); if (fsmComponent != null) { fsmComponent.DestroyFsm(mFsm); } mFsm = null; } private void ResetVisualState() { if (capsuleCollider != null) { capsuleCollider.enabled = true; } if (animator != null) { animator.Rebind(); animator.Update(0f); } if (rigidBody != null) { rigidBody.linearDamping = mInitialLinearDamping; } } /// <summary> /// 프레임 갱신은 전부 FSM 상태가 한다(FsmManager 가 이것보다 먼저 돈다). /// 여기에 로직을 다시 넣으면 상태 밖에 숨은 분기가 생기므로 그러지 말 것. /// </summary> protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); } // ─── 상태가 쓰는 창구 ─── /// <summary>상태를 굴려도 되는가. 회수 요청이 나갔으면 아니다.</summary> internal bool CanRunStates { get { return !IsHiding; } } /// <summary>낙선 통보가 왔는가. <see cref="PlayerSelectSelectableState"/> 가 폴링한다.</summary> internal bool IsRejected { get { return mRejected; } } /// <summary>누름 상태 초기화. 선택 대기 상태로 들어갈 때마다 부른다.</summary> internal void ClearPressState() { mPressedOnSelf = false; } internal void TickSelectInput() { Mouse mouse = Mouse.current; if (mouse == null) return; if (mouse.leftButton.wasPressedThisFrame) { mPressedOnSelf = IsPointerOnSelf(mouse); } if (!mouse.leftButton.wasReleasedThisFrame) return; // 누른 곳과 뗀 곳이 모두 자신일 때만 선택으로 친다. bool clickCompletedOnSelf = mPressedOnSelf && IsPointerOnSelf(mouse); mPressedOnSelf = false; if (clickCompletedOnSelf) { FireCharacterSelected(); } } /// <summary> /// 낙선 연출을 시작한다. <see cref="PlayerSelectDyingState"/> 진입 시 1회. /// </summary> internal void StartRejectedPresentation() { mRejectedElapsed = 0f; if (capsuleCollider != null) { capsuleCollider.enabled = false; } if (animator != null) { animator.SetTrigger(DieTriggerParam); } } /// <summary>연출 시간이 다 차면 회수한다.</summary> internal void TickHideDelay(float elapseSeconds) { mRejectedElapsed += elapseSeconds; if (mRejectedElapsed < HideDelayAfterDeath) return; SafeHide(); } private void FireCharacterSelected() { // Create 는 참조 풀에서 꺼내 오므로, 발행하지 못할 상황이면 만들기 전에 빠진다. EventComponent eventComponent = GameEntry.GetComponent<EventComponent>(); if (eventComponent == null) { Log.Error("EventComponent 가 없어 CharacterSelected 를 발행하지 못했다."); return; } eventComponent.Fire(this, CharacterSelectedEventArgs.Create(characterKey)); } /// <summary>마우스 커서가 이 캐릭터의 콜라이더 위에 있는가.</summary> private bool IsPointerOnSelf(Mouse mouse) { if (capsuleCollider == null || !capsuleCollider.enabled) return false; // UI 위 클릭은 무시한다. if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return false; Camera cam = Camera.main; if (cam == null) return false; Ray ray = cam.ScreenPointToRay(mouse.position.ReadValue()); // 자기 콜라이더만 검사하지 않고 Physics.Raycast 로 최근접을 본다. // 앞을 가로막은 물체가 있으면 뚫고 선택되지 않는다. if (!Physics.Raycast(ray, out RaycastHit hit, PickDistance)) return false; return hit.collider == capsuleCollider; } /// <summary> /// 선택받지 못한 캐릭터에게 SurvivalGame 이 호출한다. /// 깃발만 세운다 — 실제 연출과 회수는 <see cref="PlayerSelectDyingState"/> 가 한다. /// </summary> public void DisableAndHide() { // 이미 회수 요청이 나갔으면 연출을 시작하지 않는다. if (IsHiding) return; mRejected = true; } // Death 애니메이션 이벤트에서 호출 (선택적) private void DeathComplete() { if (rigidBody != null) rigidBody.linearDamping = 0f; } } }
C#
복사

코드 읽기

길어 보이지만 새로 들어온 것은 네 덩어리입니다.
① 상태 기계를 만들고 부수는 짝. OnShow 의 맨 끝에서 CreateStateMachine(), OnHide 의 맨 앞에서 DestroyStateMachine(). 나타날 때 만들고 사라질 때 부숩니다. 2장의 OnEnter / OnLeave 짝, 3.7 절의 Subscribe / Unsubscribe 짝과 같은 모양입니다.
순서에 이유가 있습니다. 상태는 시작하자마자 주인의 값을 읽으므로, 주인의 값을 다 채운 뒤에 만듭니다. 반대로 부술 때는 가장 먼저 부숩니다. 주인을 정리하는 도중에 상태가 한 번 더 돌면 반쯤 치워진 값을 읽게 되기 때문입니다. "늦게 만들고 먼저 부순다"는, 무언가에 의존하는 것을 다룰 때의 일반적인 순서입니다.
fsmComponent.CreateFsm(FsmName, this, mStates) 의 인자는 차례로 이름, 주인, 상태 목록입니다. 만든 뒤 mFsm.Start<PlayerSelectSelectableState>() 로 첫 상태를 정해 줘야 돌기 시작합니다.
② 상태 객체는 OnInit 에서 한 번만 만든다. mStates 배열은 OnInit 에서 만들어 두고, OnShow 마다 같은 배열로 상태 기계를 새로 만듭니다. 상태에 변수가 없으니 재사용해도 안전합니다. 3.1 절의 "한 번 알아 두면 되는 것은 OnInit, 나타날 때마다 정할 것은 OnShow"가 여기에도 적용됩니다.
③ OnShow 에서 전부 되돌린다. ResetVisualState() 와 그 아래 세 줄입니다. 쓰러진 캐릭터는 콜라이더가 꺼져 있습니다. 이 인스턴스가 다음 판에 풀에서 다시 나올 때 그대로면 클릭이 안 되는 캐릭터가 등장합니다. 애니메이터도 처음 상태에서 시작하도록 함께 되감아 둡니다. animator.Rebind() 는 애니메이터를 처음 상태로 되감고, animator.Update(0f) 는 그 결과를 이번 프레임에 바로 반영합니다.
StartRejectedPresentation() 이 바꾸는 것(콜라이더, 애니메이터)과 ResetVisualState() 가 되돌리는 것을 나란히 놓고 보세요. 바꾼 것은 전부 되돌리는 쪽에 있어야 합니다. 풀을 쓰는 코드에서 버그의 절반은 이 둘이 어긋나서 생깁니다.
④ 상태가 쓰는 창구. CanRunStates, IsRejected, ClearPressState(), TickSelectInput(), StartRejectedPresentation(), TickHideDelay() 는 internal 입니다. 상태 클래스가 불러야 하니 private 일 수는 없고, 그렇다고 아무나 부르라고 public 으로 열어 둘 것도 아니어서 그 중간을 골랐습니다. 밖에 공개된 것은 CharacterKey 와 DisableAndHide() 둘뿐입니다.
DisableAndHide() 는 깃발 하나만 세웁니다. 애니메이션을 틀지도, 타이머를 돌리지도 않습니다. 그 일은 전부 PlayerSelectDyingState 가 합니다.
OnUpdate 가 비었습니다. 3.7 절에서 여기에 넣었던 TickSelectInput() 호출이 PlayerSelectSelectableState 로 옮겨 갔습니다. 이제 "매 프레임 무엇을 하는가"는 전부 상태 클래스에 있습니다.

SurvivalGame 한 줄 바꾸기

DismissSelectCharacters 에서 고르지 않은 쪽을 치우던 줄을 바꿉니다.
if (unselected != null) { unselected.DisableAndHide(); }
C#
복사

실행해 보기

Play → Girl(초록 호박 모자)을 클릭합니다.
Girl 을 고른 뒤의 화면. 고른 쪽은 바로 사라지고, 고르지 않은 Boy 는 쓰러졌다가 1.5초 뒤 사라진다
고른 쪽은 즉시 사라지고, 고르지 않은 쪽은 쓰러졌다가 1.5초 뒤에 사라집니다. 콘솔에는 여전히 한 줄만 찍힙니다.
아래는 완성된 예제에서 같은 장면을 녹화한 것입니다. 마우스를 올린 쪽에 조명이 비치고, 클릭하면 고르지 않은 쪽이 쓰러지고, 고른 자리에는 다음 장에서 만들 플레이어가 나타납니다.
실제 플레이 — 마우스를 올리면 조명, 클릭하면 선택. 고르지 않은 캐릭터는 쓰러진 뒤 회수된다
Character selected: Girl
Plain Text
복사
지금은 고른 캐릭터도 사라진 채 빈 방만 남습니다. 다음 장에서 그 자리에 조작할 수 있는 플레이어가 나타납니다.

3.10 잘 안 될 때

증상
원인과 해결
캐릭터가 안 보이고 콘솔도 조용하다
Addressables Groups 창의 Play Mode Script 가 Use Asset Database (fastest) 인지 확인합니다. 3.8 절까지 마쳤다면 주소가 틀렸을 때 Show entity failure with error message '…' 경고가 나옵니다
Entity group 'Player' is not exist.
1장에서 Entity 컴포넌트에 Player 그룹을 등록하지 않았거나 철자가 다릅니다
Entity id '…' is already exist.
번호를 직접 넣었거나 같은 id 로 ShowEntity 를 두 번 불렀습니다. 번호는 EntitySerialId.Next() 로만 받습니다
두 캐릭터가 한가운데에 겹쳐 선다
콘솔에 Character select data is invalid. 가 있을 것입니다. ShowEntity 의 마지막 인자로 CharacterSelectData.Create(…) 를 넘겼는지 확인합니다
클릭해도 반응이 없다
① 씬의 카메라에 MainCamera 태그가 있는가 ② 콘솔에 CapsuleCollider 가 없어… 에러가 없는가 ③ Game 창을 한 번 클릭해 포커스를 준 뒤 다시 클릭했는가
클릭 한 번에 로그가 두 번 찍힌다
프리팹에 PlayerSelectLogic 이 미리 붙어 있습니다. 프리팹에서 떼어 냅니다
Play 를 멈출 때 Event '…' not exists specified handler.
이미 끊은 구독을 한 번 더 끊었습니다. UnsubscribeCharacterSelected 의 첫 줄 깃발 검사를 확인합니다
고르지 않은 쪽이 쓰러지지 않고 1.5초 뒤 그냥 사라진다
애니메이터를 못 찾았습니다. GetComponentInChildren<Animator>(true) 의 true 를 확인합니다. 콘솔에 Animator 를 찾지 못했다 경고가 있을 것입니다
고르지 않은 쪽이 영영 사라지지 않는다
DismissSelectCharacters 가 불리기 전에 mGirlSelect / mBoySelect 가 채워졌는지 확인합니다. ShowEntitySuccess 구독이 빠지면 둘 다 null 로 남습니다

이 장에서 만든 것

파일
내용
Utility/EntitySerialId.cs
엔티티 번호표
Utility/GameAssert.cs
프로그래머 실수를 잡는 어서트
Entity/EntityData/EntityData.cs
스폰 주문서의 공통 부모
Entity/EntityData/CharacterSelectData.cs
선택 캐릭터용 주문서
Entity/EntityLogic/EntityLogicBase.cs
모든 엔티티의 공통 부모 — 주문서 반납, SafeHide
Entity/EntityLogic/PlayerSelectLogic.cs
선택 캐릭터의 로직
Entity/EntityLogic/States/PlayerSelect…State.cs (3개)
선택 대기 / 낙선 연출 상태와 그 공통 부모
Game/CharacterSelectedEventArgs.cs
"캐릭터가 선택됐다" 이벤트
Game/GameBase.cs (수정)
엔티티 스폰 성공/실패의 기본 처리
Game/SurvivalGame.cs (수정)
선택 캐릭터 스폰, 이벤트 구독, 선택 후 정리
(경로는 모두 Assets/GameMain/ 아래입니다.)

정리

•
엔티티는 나타났다 사라지는 모든 것이다. ShowEntity 로 띄우고 SafeHide 로 치운다. 파괴되지 않고 풀에서 재사용된다
•
한 번 알아 둘 것은 OnInit, 나타날 때마다 정할 것은 OnShow, 치울 것은 OnHide. Awake / Start 는 쓰지 않는다
•
풀에서 다시 나온 인스턴스는 지난번 값을 갖고 있다. OnShow 에서 전부 되돌린다
•
프리팹은 주소 문자열로 부른다. 프리팹에 로직 스크립트를 붙이지 않는다
•
ShowEntity 는 바로 끝나지 않는다. 그 사이를 EntityData 가 잇고, 결과는 성공/실패 이벤트로 받는다
•
참조 풀에서 꺼낸 것은 정확히 한 번 돌려준다. 주문서는 엔티티가(실패하면 게임 객체가), 이벤트 인자는 프레임워크가 돌려준다
•
이벤트는 "외치는 쪽"과 "듣는 쪽"을 떼어 놓는다. Subscribe 와 Unsubscribe 는 반드시 짝이다
•
치운 엔티티의 참조는 바로 비운다
•
"기다렸다가 무엇을 한다"는 코루틴 대신 상태로 만든다. 상태는 변수를 갖지 않고 주인에게 시킨다

직접 해 보기

1.
SurvivalGame 의 GirlSelectPosition 과 BoySelectPosition 을 바꿔 두 캐릭터를 더 멀리, 또는 앞뒤로 세워 보세요. 코드에서 숫자만 바꿨는데 배치가 바뀝니다. 씬은 건드리지 않았습니다.
2.
Addressables Groups 창에서 Boy 의 주소를 일부러 Boy2 로 바꾸고 Play 해 보세요. 콘솔에 어떤 경고가 나오는지, Girl 은 정상적으로 뜨는지 확인합니다. 확인한 뒤 주소를 되돌립니다.
3.
HideDelayAfterDeath 를 0.3f 와 4f 로 바꿔 보세요. 쓰러지는 애니메이션이 끝나기 전에 사라지는 것과, 한참 누워 있다가 사라지는 것을 볼 수 있습니다.
4.
(조금 어려움) PlayerSelectLogic.OnShow 에서 ResetVisualState(); 줄을 지우면 지금은 아무 차이가 없습니다. 왜 그럴까요? 이 줄이 필요해지는 것은 같은 인스턴스가 두 번째로 나타날 때입니다. 9장에서 재시작을 만든 뒤 이 줄을 다시 지워 보면 차이가 보입니다.
더 읽기 — , ,
다음 장 — : 고른 캐릭터가 조작할 수 있는 플레이어가 되어 다시 나타납니다. WASD 로 걷고 마우스를 따라 돕니다.