서비스
교육 철학

05. 적과 스포너 — 데이터로 찍어내기

플레이어가 걸어 다니게 됐지만 방은 텅 비어 있습니다. 이 장에서는 방을 채웁니다. 토끼, 곰, 오리, 광대, 코끼리 — 다섯 종류의 좀비 인형이 방 가장자리 다섯 군데에서 주기적으로 나타나 플레이어를 쫓아옵니다.
적이 다섯 종류라고 하면 Zombunny.cs, ZomBear.cs, Hellephant.cs … 클래스를 다섯 개 만들어야 할 것 같습니다. 하지만 이 다섯은 하는 일이 똑같습니다. 플레이어를 쫓아가서, 가까워지면 때리고, 맞으면 체력이 줄고, 죽으면 쓰러집니다. 다른 것은 체력이 얼마인지, 얼마나 세게 때리는지 같은 숫자뿐입니다.
하는 일이 같고 숫자만 다르다면, 클래스는 하나면 되고 숫자는 표에 적으면 됩니다. 4장에서 "프리팹은 생김새, 로직은 역할"이라고 했습니다. 이 장에서 하나를 더합니다. 숫자는 표.
이 장의 결과. 다섯 종류의 적이 플레이어(가운데 초록 모자)를 둘러싼다. 적 클래스는 하나뿐이다
🎯
이 장에서 하는 일
1.
적의 종류와 스폰 지점을 표로 정의한다 (EnemyTable)
2.
표를 읽어 주기적으로 적을 띄우는 스포너를 만든다
3.
적이 길을 찾아 플레이어를 쫓아오게 한다 (NavMesh)
4.
가까이 오면 때리게 한다
5.
죽으면 쓰러지고 가라앉은 뒤 사라지게 하고, 점수를 올린다
🧰
시작하기 전에 확인할 것 — 예제 프로젝트에는 이미 되어 있습니다.
•
적 프리팹 다섯 개(Assets/Art/Prefabs/Characters/ 의 Zombunny, ZomBear, ZombieDuck, Clown, Hellephant)가 3장에서 한 것과 같은 방법으로 Addressables 에 파일 이름과 같은 주소로 등록돼 있다
•
1장에서 Entity 컴포넌트에 Enemy 그룹을 만들었다
•
씬에 NavMesh 가 베이크돼 있다 (Scene 창 오른쪽 아래 AI Navigation 오버레이에서 Show NavMesh 를 켜면 바닥이 파랗게 보입니다)
•
각 적 프리팹에 NavMeshAgent, Animator, 몸통용 CapsuleCollider 가 붙어 있다

5.1 표로 적을 정의하기

Assets/GameMain/Game 폴더에 표를 만듭니다. 이 파일에는 로직이 거의 없습니다. 값뿐입니다.
// Assets/GameMain/Game/EnemyTable.cs using System.Diagnostics; using UnityEngine; namespace ToyBoxNightmare { /// <summary> /// 적 한 종의 전투 스탯. /// </summary> public readonly struct EnemyStats { /// <summary>원본 트리거 구가 플레이어 콜라이더에 닿는 거리를 모델링하기 위한 보정값.</summary> private const float PlayerColliderRadius = 0.5f; /// <summary>Addressables 주소이자 종 식별자.</summary> public readonly string AssetName; public readonly int MaxHitPoints; public readonly int AttackDamage; public readonly float TimeBetweenAttacks; public readonly int ScoreValue; public readonly float MoveSpeed; public readonly float StoppingDistance; /// <summary>원본 프리팹의 공격 판정용 SphereCollider 반지름.</summary> public readonly float AttackSphereRadius; /// <summary> /// 실제 공격 판정 거리(XZ 평면 기준). /// 구 반지름에 플레이어 반지름을 더한 값으로 환산한다. /// 이 값이 StoppingDistance 보다 커야 적이 멈춘 자리에서 공격이 닿는다. /// </summary> public float AttackRange => AttackSphereRadius + PlayerColliderRadius; public EnemyStats(string assetName, int maxHitPoints, int attackDamage, float timeBetweenAttacks, int scoreValue, float moveSpeed, float stoppingDistance, float attackSphereRadius) { AssetName = assetName; MaxHitPoints = maxHitPoints; AttackDamage = attackDamage; TimeBetweenAttacks = timeBetweenAttacks; ScoreValue = scoreValue; MoveSpeed = moveSpeed; StoppingDistance = stoppingDistance; AttackSphereRadius = attackSphereRadius; } } /// <summary>스포너 한 지점의 설정.</summary> public readonly struct EnemySpawnPoint { public readonly string AssetName; public readonly Vector3 Position; /// <summary>스폰 간격(초).</summary> public readonly float Interval; /// <summary>이 종의 동시 생존 상한.</summary> public readonly int MaxAlive; public EnemySpawnPoint(string assetName, Vector3 position, float interval, int maxAlive) { AssetName = assetName; Position = position; Interval = interval; MaxAlive = maxAlive; } } /// <summary> /// 적 관련 상수와 테이블. 현재는 C# 상수지만, 튜닝이 잦아지면 /// ScriptableObject 나 DataTable 로 옮기기 쉬운 형태로 모아 두었다. /// </summary> public static class EnemyTable { /// <summary>추적 목적지 갱신 주기.</summary> public const float RetargetInterval = 0.5f; /// <summary>사망 연출 시작부터 회수까지의 시간.</summary> public const float DeathEffectTime = 2f; /// <summary>사망 후 바닥으로 가라앉는 속도.</summary> public const float SinkSpeed = 2.5f; /// <summary>엔티티 그룹 이름. GameFramework.prefab 의 mEntityGroups 와 일치해야 한다.</summary> public const string EntityGroup = "Enemy"; // 주소 HP 공격 간격 점수 속도 정지 공격구 private static readonly EnemyStats[] Table = { new EnemyStats("Zombunny", 100, 10, 0.5f, 10, 3.5f, 1.1f, 0.8f), new EnemyStats("ZomBear", 100, 10, 0.5f, 10, 3.5f, 1.1f, 0.8f), new EnemyStats("ZombieDuck", 120, 20, 1.0f, 20, 3.5f, 1.1f, 1.0f), new EnemyStats("Clown", 150, 30, 1.5f, 25, 3.5f, 1.1f, 0.8f), new EnemyStats("Hellephant", 200, 35, 2.0f, 50, 3.5f, 1.9f, 1.63f), }; /// <summary> /// 스폰 지점. 좌표는 베이크된 NavMesh 위에 있음을 확인했다. /// 동시 생존 상한 합계 13. /// </summary> public static readonly EnemySpawnPoint[] SpawnPoints = { new EnemySpawnPoint("Zombunny", new Vector3(-20.5f, 0f, 12.5f), 5f, 4), new EnemySpawnPoint("ZomBear", new Vector3( 22.5f, 0f, 15f), 6f, 3), new EnemySpawnPoint("Hellephant", new Vector3( 0f, 0f, 32f), 10f, 2), new EnemySpawnPoint("ZombieDuck", new Vector3( -0.86f, 0f, -33.1f), 10f, 2), new EnemySpawnPoint("Clown", new Vector3(-26.55f, 0f, 5.29f),15f, 2), }; public static bool TryGetStats(string assetName, out EnemyStats stats) { for (int i = 0; i < Table.Length; i++) { if (Table[i].AssetName == assetName) { stats = Table[i]; return true; } } stats = default; return false; } /// <summary> /// 테이블 정합성 검사. 두 가지를 본다. /// /// 1. 모든 SpawnPoints 의 주소가 Table 에 있는가. /// 없으면 그 지점은 스폰 틱마다 "Enemy stats not found" 만 찍고 영원히 아무것도 못 낸다. /// /// 2. 같은 주소를 쓰는 스폰 지점이 둘 이상인가. /// 스포너의 대기 카운트 처리가 "한 주소 = 한 지점" 을 전제로 짜여 있다. /// /// 에디터와 개발 빌드에서만 돈다. /// </summary> [Conditional("UNITY_EDITOR")] [Conditional("DEVELOPMENT_BUILD")] public static void Validate() { for (int i = 0; i < SpawnPoints.Length; i++) { string assetName = SpawnPoints[i].AssetName; // 스탯 값 자체는 필요 없다. 존재 여부만 본다. GameAssert.IsTrue(TryGetStats(assetName, out _), string.Format( "EnemyTable.SpawnPoints[{0}] 의 주소 '{1}' 가 Table 에 없다. 이 지점은 영원히 스폰하지 못한다.", i, assetName)); for (int j = i + 1; j < SpawnPoints.Length; j++) { GameAssert.IsTrue(SpawnPoints[j].AssetName != assetName, string.Format( "EnemyTable.SpawnPoints 의 주소 '{0}' 가 [{1}] 과 [{2}] 에 중복이다. " + "SurvivalGame 의 대기 카운트가 '한 주소 = 한 지점' 전제라 뒤쪽 지점이 상한에 고착된다.", assetName, i, j)); } } } } }
C#
복사

코드 읽기

파일 가운데의 두 배열이 전부입니다. 주석으로 열 이름을 달아 두어 표처럼 읽힙니다.
적
체력
공격력
공격 간격
점수
나오는 간격
동시에 최대
Zombunny (토끼)
100
10
0.5초
10
5초
4마리
ZomBear (곰)
100
10
0.5초
10
6초
3마리
ZombieDuck (오리)
120
20
1초
20
10초
2마리
Clown (광대)
150
30
1.5초
25
15초
2마리
Hellephant (코끼리)
200
35
2초
50
10초
2마리
약한 적은 자주, 많이 나오고 강한 적은 드물게, 적게 나옵니다. 게임의 난이도가 이 표 한 장에 들어 있습니다. 토끼가 너무 많다 싶으면 5f, 4 를 8f, 3 으로 바꾸면 됩니다. 클래스는 건드리지 않습니다.
첫 열의 문자열이 세 가지 일을 합니다. "Zombunny" 는 ① Addressables 주소이고, ② Table 에서 스탯을 찾는 열쇠이고, ③ SpawnPoints 가 "어떤 적을 낼지" 가리키는 이름입니다. 셋을 같은 문자열로 묶어 두었기 때문에 새 적을 추가하는 일이 "프리팹 등록 + 표에 두 줄" 로 끝납니다.
readonly struct 는 "만든 뒤에는 값을 바꿀 수 없는 값 묶음"입니다. 표의 한 줄은 게임 도중에 바뀌면 안 됩니다. 누군가 실수로 Table[0].MaxHitPoints = 1 같은 코드를 쓰면 컴파일 에러가 납니다.
TryGetStats 는 4장의 MouseGround.TryGetGroundPoint 와 같은 Try… 모양입니다. 못 찾으면 false 를 돌려주고, out 에는 default(모든 값이 0 인 빈 스탯)를 넣습니다.
Validate() 는 표를 직접 쓴 사람의 실수를 잡습니다. SpawnPoints 에 "Zombuny" 라고 오타를 내면, 그 지점은 5초마다 조용히 실패하고 토끼만 안 나옵니다. 이런 실수는 게임을 한참 해 봐야 "어, 토끼가 없네" 하고 알아챕니다. 판이 시작될 때 표를 한 번 훑어서 시작하자마자 빨간 줄로 알려 줍니다. 3장의 GameAssert 처럼 [Conditional] 이라 출시 빌드에서는 호출 자체가 사라집니다.
💡
지금은 C# 배열이지만 모양이 이미 표입니다. 프레임워크에는 엑셀에서 뽑은 텍스트 파일을 읽어 들이는 DataTable 기능이 있습니다. 나중에 이 배열을 DataTable 로 옮기면 기획자가 코드를 열지 않고 숫자를 고칠 수 있습니다(10장의 과제입니다). 처음부터 표 모양으로 짜 두면 그 전환은 기계적인 작업이 됩니다.

5.2 적의 주문서 — EnemyData

4장의 PlayerData 와 같은 자리입니다. 표의 한 줄을 통째로 들고 갑니다.
// Assets/GameMain/Entity/EntityData/EnemyData.cs using GameFramework; using UnityEngine; namespace ToyBoxNightmare { /// <summary> /// 적 엔티티 데이터. 종별 수치는 <see cref="EnemyTable"/> 이 들고 있고, /// 여기서는 그중 한 종의 스냅샷을 붙들어 <see cref="Enemy"/> 에 넘긴다. /// </summary> public class EnemyData : TargetableObjectData { // 풀에서 재사용되며 스폰마다 다시 채워지므로 readonly 일 수 없다. private EnemyStats mStats; public static EnemyData Create(int entityId, int typeId, EnemyStats stats, Vector3 position) { // EnemyTable.TryGetStats 가 실패하면 default(EnemyStats) 가 넘어온다. // 그러면 MaxHitPoints 가 0 이라 적이 스폰되자마자 사망 연출로 들어간다. GameAssert.IsTrue(stats.MaxHitPoints > 0, "EnemyData 에 최대 체력 0 인 스탯이 들어왔다. EnemyTable.TryGetStats 반환값을 확인할 것."); EnemyData data = ReferencePool.Acquire<EnemyData>(); data.Fill(entityId, typeId); data.mStats = stats; data.Position = position; // TargetableObjectData 는 HitPoints 를 0 으로 두므로 여기서 반드시 채운다. data.HitPoints = stats.MaxHitPoints; return data; } public EnemyStats Stats => mStats; public override int MaxHitPoints => mStats.MaxHitPoints; // 자주 쓰는 스탯의 단축 접근자. 호출부가 data.Stats.X 대신 data.X 로 읽게 한다. public string AssetName => mStats.AssetName; public float MoveSpeed => mStats.MoveSpeed; public int AttackDamage => mStats.AttackDamage; public float TimeBetweenAttacks => mStats.TimeBetweenAttacks; public int ScoreValue => mStats.ScoreValue; public override void Clear() { base.Clear(); // 이전 종의 스탯을 물고 나가면 체력·속도·점수가 통째로 뒤바뀐다. mStats = default(EnemyStats); } } }
C#
복사
PlayerData 는 체력과 속도를 자기가 직접 들고 있었지만, EnemyData 는 표의 한 줄(EnemyStats)을 통째로 들고 있습니다. 주문서 클래스도 하나, 적 클래스도 하나입니다. 다섯 종류를 가르는 것은 Create 에 넘어오는 stats 뿐입니다.

5.3 스포너

"5초마다 토끼를 한 마리 낸다. 단, 이미 넷이 있으면 내지 않는다." 이 일을 하는 것이 스포너입니다.
스포너는 엔티티도, MonoBehaviour 도 아닙니다. SurvivalGame 이 new 로 만들어 들고 있다가 자기 Update 에서 직접 굴리는 평범한 C# 객체입니다. 2장에서 프로시저가 게임 객체를 굴리던 것과 같은 모양이 한 층 아래에서 반복됩니다.
graph TD
    P["ProcedureMain"] -->|"Update"| G["SurvivalGame"]
    G -->|"OnUpdate"| S["EnemySpawner"]
    S -->|"ShowEntity"| E["Enemy 엔티티들"]
Mermaid
복사
// Assets/GameMain/Game/EnemySpawner.cs using System.Collections.Generic; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 적 스포너. <see cref="EnemyTable.SpawnPoints"/> 의 지점마다 자기 타이머를 돌리다가 /// 주기가 되면 적 엔티티를 띄운다. /// /// <b>MonoBehaviour 가 아니다.</b> <see cref="SurvivalGame"/> 이 인스턴스를 들고 /// 자기 Update 에서 <see cref="OnUpdate"/> 를 직접 굴린다. /// /// 엔티티 표시 성공/실패 이벤트는 소유자가 받아 <see cref="DecrementPendingSpawn"/> 로 /// 중계한다. 스포너는 이벤트를 직접 구독하지 않는다. /// </summary> public class EnemySpawner { // 캐릭터를 고르기 전에는 돌지 않는다. private bool mSpawningEnabled = false; private readonly float[] mSpawnTimers = new float[EnemyTable.SpawnPoints.Length]; // ShowEntity 요청 후 아직 인스턴스화되지 않은 수. GetEntities 가 세지 못하므로 // 따로 들고 있지 않으면 로드 지연 동안 상한을 넘겨 과다 스폰한다. private readonly int[] mPendingSpawns = new int[EnemyTable.SpawnPoints.Length]; // 생존 수를 셀 때 쓰는 재사용 버퍼. // EntityComponent.GetEntities(string) 는 호출마다 배열을 새로 만든다. // 이 경로는 매 스폰 틱 × 스폰 지점 수만큼 도는 곳이라 // 무할당 오버로드 GetEntities(string, List<Entity>) 와 짝지어 쓴다. private readonly List<Entity> mAliveBuffer = new List<Entity>(); /// <summary> /// 판 상태 초기화. 소유자의 <c>SurvivalGame.ResetRoundState</c> 가 부른다. /// </summary> public void Reset() { mSpawningEnabled = false; for (int i = 0; i < mSpawnTimers.Length; i++) { mSpawnTimers[i] = 0f; mPendingSpawns[i] = 0; } } /// <summary> /// 스포너를 연다. 캐릭터 선택이 끝난 시점에 소유자가 부른다. /// 그 전까지는 <see cref="OnUpdate"/> 가 아무 일도 하지 않는다. /// </summary> public void Open() { mSpawningEnabled = true; } /// <summary> /// 소유자의 Update 가 매 프레임 부른다. /// 게임오버 뒤에는 소유자가 조기 리턴하므로 여기까지 오지 않는다(= 스폰 중단). /// </summary> public void OnUpdate(float elapseSeconds) { if (!mSpawningEnabled) return; // 반복문마다 다시 찾을 이유가 없다. EntityComponent entityComponent = GameEntry.GetComponent<EntityComponent>(); if (entityComponent == null) { Log.Error("EntityComponent 를 찾을 수 없어 적 스폰을 건너뛴다. GameFramework.prefab 구성을 확인할 것."); return; } for (int spawnIndex = 0; spawnIndex < EnemyTable.SpawnPoints.Length; spawnIndex++) { if (!TickSpawnTimer(spawnIndex, elapseSeconds)) continue; // 상한에 걸리면 이번 틱은 그냥 건너뛴다. if (IsSpawnLimitReached(entityComponent, spawnIndex)) continue; SpawnEnemy(entityComponent, spawnIndex); } } /// <summary> /// 스폰 타이머를 진행시킨다. 이번 틱이 스폰 차례면 타이머를 되감고 true 를 돌려준다. /// 상한에 걸려 스폰을 못 해도 타이머는 이미 되감긴 상태다. /// </summary> private bool TickSpawnTimer(int spawnIndex, float elapseSeconds) { GameAssert.InRange(spawnIndex, mSpawnTimers.Length, "spawnIndex"); mSpawnTimers[spawnIndex] += elapseSeconds; if (mSpawnTimers[spawnIndex] < EnemyTable.SpawnPoints[spawnIndex].Interval) return false; mSpawnTimers[spawnIndex] = 0f; return true; } /// <summary> /// 이 종이 동시 생존 상한에 도달했는가. /// 아직 로드 중인 요청(mPendingSpawns)까지 세지 않으면 로드 지연 동안 과다 스폰한다. /// /// <b>전제: 한 AssetName 은 스폰 지점 하나에만 쓰인다.</b> /// 이 전제는 <see cref="EnemyTable.Validate"/> 가 중복 주소를 잡아 지킨다. /// </summary> private bool IsSpawnLimitReached(EntityComponent entityComponent, int spawnIndex) { EnemySpawnPoint point = EnemyTable.SpawnPoints[spawnIndex]; // 배열을 새로 만드는 GetEntities(string) 대신 무할당 오버로드를 쓴다. entityComponent.GetEntities(point.AssetName, mAliveBuffer); int aliveCount = mAliveBuffer.Count; // 다음 틱까지 참조를 들고 있지 않는다. 엔티티는 풀에서 재사용되므로 // 남아 있는 참조는 이미 다른 용도로 살아난 인스턴스일 수 있다. mAliveBuffer.Clear(); int pendingCount = mPendingSpawns[spawnIndex]; return aliveCount + pendingCount >= point.MaxAlive; } private void SpawnEnemy(EntityComponent entityComponent, int spawnIndex) { EnemySpawnPoint point = EnemyTable.SpawnPoints[spawnIndex]; EnemyStats stats; if (!EnemyTable.TryGetStats(point.AssetName, out stats)) { // 스폰 지점 주소가 EnemyTable 에 없다 — 데이터 누락이다. Log.Error("Enemy stats not found for '{0}'.", point.AssetName); return; } int id = EntitySerialId.Next(); // 반드시 ShowEntity 보다 먼저 올린다. 성공/실패 콜백이 이 카운트를 되돌린다. mPendingSpawns[spawnIndex]++; entityComponent.ShowEntity( id, typeof(Enemy), point.AssetName, EnemyTable.EntityGroup, EnemyData.Create(id, SurvivalGame.DefaultEntityTypeId, stats, point.Position)); } /// <summary> /// 로드가 끝났거나 실패한 스폰 요청을 대기 카운트에서 뺀다. /// 소유자가 ShowEntitySuccess / ShowEntityFailure 양쪽에서 중계한다 — /// 한쪽이 빠지면 그 지점은 영원히 상한에 걸린 것으로 보인다. /// </summary> public void DecrementPendingSpawn(string assetName) { for (int i = 0; i < EnemyTable.SpawnPoints.Length; i++) { if (EnemyTable.SpawnPoints[i].AssetName != assetName) continue; if (mPendingSpawns[i] > 0) { mPendingSpawns[i]--; } // 주소는 스폰 지점당 하나뿐이므로 첫 일치에서 끝낸다. return; } } } }
C#
복사

코드 읽기

OnUpdate 의 반복문 세 줄이 스포너의 전부입니다. 지점마다 ① 때가 됐나 ② 자리가 있나 ③ 낸다. 조건에 안 맞으면 continue 로 다음 지점으로 넘어갑니다. 반복문 안에서 "안 되는 경우를 먼저 돌려보내는" 모양입니다.
SpawnEnemy 의 ShowEntity 는 3장, 4장과 같은 다섯 인자입니다. 달라진 점은 주소와 주문서가 표에서 온다는 것뿐입니다.

"오는 중"인 적도 세야 한다

이 스포너에서 가장 중요한 부분은 mPendingSpawns 입니다.
"토끼는 동시에 최대 4마리"를 지키려면 지금 살아 있는 토끼를 세면 될 것 같습니다. entityComponent.GetEntities("Zombunny", …) 가 그 일을 합니다. 그런데 3장에서 배운 대로 ShowEntity 는 바로 끝나지 않습니다. 요청했지만 아직 화면에 나타나지 않은 토끼는 이 숫자에 들어 있지 않습니다.
sequenceDiagram
    participant S as 스포너
    participant F as 프레임워크
    Note over S: 살아 있는 토끼 3
    S->>F: ShowEntity(토끼) — 4번째
    Note over F: 로드 중…
    Note over S: 다음 틱. 살아 있는 토끼는 여전히 3<br>→ "자리가 있네" → 5번째를 또 요청!
    F-->>S: 4번째 토끼 등장
    F-->>S: 5번째 토끼 등장 (상한 초과)
Mermaid
복사
그래서 요청을 보낼 때 "오는 중" 숫자를 하나 올리고(mPendingSpawns[spawnIndex]++), 상한을 따질 때는 살아 있는 수 + 오는 중인 수를 봅니다.
올린 숫자는 언젠가 내려야 합니다. 토끼가 도착하면 "오는 중"이 아니라 "살아 있는 중"이 되니까요. 그 시점은 3장에서 배운 성공 이벤트입니다. 그리고 하나 더 있습니다. 토끼가 도착하지 못했을 때도 내려야 합니다.
⚠️
올린 곳이 하나면 내리는 곳은 둘입니다. 성공과 실패. 실패 쪽을 빠뜨리면, 한 번 실패한 지점은 "오는 중 1"이 영원히 남습니다. 상한이 2 인 코끼리라면 그 뒤로는 하나밖에 안 나옵니다. 두 번 실패하면 영영 안 나옵니다. 에러 없이 "코끼리가 좀 덜 나오는 것 같은데?"로만 나타나는 버그입니다. 3장에서 주문서를 돌려주는 길이 성공·실패 둘이었던 것과 같은 구조입니다.
GetEntities(point.AssetName, mAliveBuffer) — GetEntities 에는 결과를 새 배열로 돌려주는 버전과, 우리가 준 리스트에 채워 주는 버전이 있습니다. 앞의 것은 부를 때마다 배열을 새로 만듭니다. 이 메서드는 게임 내내 계속 불리므로, 리스트 하나를 만들어 두고 계속 재사용하는 뒤의 것을 씁니다. 다 쓴 뒤 바로 Clear() 하는 것은 3장의 "치운 엔티티의 참조는 바로 비운다"와 같은 습관입니다.

SurvivalGame 에 연결하기

SurvivalGame.cs 에 스포너를 들입니다.
// ─── 스포너 ─── // 적 스폰은 통째로 EnemySpawner 가 맡는다. 여기서는 매 프레임 굴려 주고, // 엔티티 표시 성공/실패 콜백만 중계한다. private readonly EnemySpawner mSpawner = new EnemySpawner();
C#
복사
Initialize 에서 표를 검사하고,
public override void Initialize() { base.Initialize(); // 스폰 테이블 정합성은 판이 시작될 때 한 번만 본다. // [Conditional] 이라 릴리스 빌드에서는 이 호출 자체가 사라진다. EnemyTable.Validate(); ResetRoundState(); SubscribeEvents(); SpawnSelectCharacters(); }
C#
복사
ResetRoundState 의 끝에 mSpawner.Reset(); 을 추가합니다. 스포너는 캐릭터를 고른 순간에 엽니다.
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); StartRound(); DismissSelectCharacters(ne.CharacterKey); SpawnPlayer(ne.CharacterKey); } /// <summary>선택 시점부터를 게임 시작으로 본다. 스포너도 여기서 열린다.</summary> private void StartRound() { mGameTime = 0f; mSpawner.Open(); }
C#
복사
Update 의 끝에서 굴립니다.
mGameTime += elapseSeconds; mSpawner.OnUpdate(elapseSeconds);
C#
복사
그리고 "오는 중" 숫자를 내리는 두 곳입니다. OnShowEntitySuccess 에 갈래를 추가하고,
if (ne.EntityLogicType == typeof(Enemy)) { mSpawner.DecrementPendingSpawn(ne.Entity.EntityAssetName); return; }
C#
복사
OnShowEntityFailure 에도 추가합니다. 4장에서 넣은 플레이어 실패 처리 바로 아래입니다.
// 실패해도 대기 카운트를 빼야 한다. 안 그러면 그 종은 영원히 상한에 걸린 것으로 보인다. if (ne.EntityLogicType == typeof(Enemy)) { mSpawner.DecrementPendingSpawn(ne.EntityAssetName); } Log.Warning("Show entity failure: {0} ({1})", ne.ErrorMessage, ne.EntityAssetName);
C#
복사
스포너는 이벤트를 직접 구독하지 않습니다. 이벤트는 SurvivalGame 이 받아서 스포너에게 전해 줍니다. 이벤트를 구독하는 곳이 한 군데로 모여 있으면, "이 이벤트를 누가 듣고 있지?"를 찾을 때 파일 하나만 보면 됩니다.
아직 Enemy 클래스가 없어서 컴파일되지 않습니다. 이어서 만듭니다.

5.4 쫓아오는 적

길찾기는 Unity 가 한다

적은 방 안의 가구를 피해서 플레이어에게 와야 합니다. 이 계산은 Unity 의 NavMesh 가 합니다. 씬의 "걸을 수 있는 바닥"을 미리 계산해 둔 것이 NavMesh 이고, 그 위를 걸어 다니는 컴포넌트가 NavMeshAgent 입니다. 우리가 할 일은 에이전트에게 "저기로 가"(SetDestination)라고 말하는 것뿐입니다.
풀과 함께 쓸 때는 두 가지를 조심해야 합니다.
•
나타날 때 — 에이전트가 켜진 채로 transform.position 을 바꾸면 Unity 가 경고를 냅니다. 끄고, 옮기고, 켠 뒤 Warp 로 NavMesh 위에 올려놓습니다.
•
사라질 때 — 가던 길을 지우고(ResetPath) 에이전트를 끕니다. 켜진 채 풀에 들어가면 다음에 나올 때 지난번 길을 이어서 걷습니다.

적의 상태

먼저 "쫓는다"와 "쫓을 사람이 없다" 두 상태로 시작합니다.
// Assets/GameMain/Entity/EntityLogic/States/EnemyStateBase.cs using GameFramework.Fsm; namespace ToyBoxNightmare { /// <summary> /// 적 상태의 공통 베이스. /// 상태는 무상태다. 모든 값은 <c>fsm.Owner</c> 에서 읽는다. /// </summary> public abstract class EnemyStateBase : FsmState<Enemy> { protected sealed override void OnUpdate(IFsm<Enemy> fsm, float elapseSeconds, float realElapseSeconds) { base.OnUpdate(fsm, elapseSeconds, realElapseSeconds); Enemy owner = fsm.Owner; // 데이터 없이 떴거나 이미 회수 요청이 나간 엔티티는 아무것도 하지 않는다. if (!owner.CanRunStates) return; OnStateUpdate(fsm, owner, elapseSeconds); } /// <summary>상태별 갱신.</summary> protected abstract void OnStateUpdate(IFsm<Enemy> fsm, Enemy owner, float elapseSeconds); } }
C#
복사
// Assets/GameMain/Entity/EntityLogic/States/EnemyChaseState.cs using GameFramework.Fsm; namespace ToyBoxNightmare { /// <summary>플레이어를 쫓으며 사거리 안에서 공격하는 상태.</summary> public sealed class EnemyChaseState : EnemyStateBase { protected override void OnStateUpdate(IFsm<Enemy> fsm, Enemy owner, float elapseSeconds) { Player player = EntityFinder.FindPlayer(); bool isPlayerSpawned = player != null && player.Available; if (!isPlayerSpawned) { owner.StopMoving(); ChangeState<EnemyIdleState>(fsm); return; } if (player.IsDead) { owner.StopMoving(); owner.NotifyPlayerDead(); ChangeState<EnemyIdleState>(fsm); return; } owner.TickChase(player, elapseSeconds); } } }
C#
복사
// Assets/GameMain/Entity/EntityLogic/States/EnemyIdleState.cs using GameFramework.Fsm; namespace ToyBoxNightmare { /// <summary> /// 쫓을 대상이 없는 상태. 플레이어가 아직 안 떴거나 이미 죽었을 때다. /// 플레이어가 죽었으면 승리 연출을 <b>한 번만</b> 재생한다(중복 방지는 소유자가 한다). /// </summary> public sealed class EnemyIdleState : EnemyStateBase { protected override void OnStateUpdate(IFsm<Enemy> fsm, Enemy owner, float elapseSeconds) { Player player = EntityFinder.FindPlayer(); bool isPlayerSpawned = player != null && player.Available; if (!isPlayerSpawned) { owner.StopMoving(); return; } if (player.IsDead) { owner.StopMoving(); owner.NotifyPlayerDead(); return; } ChangeState<EnemyChaseState>(fsm); } } }
C#
복사
공통 부모의 모양은 3장의 PlayerSelectStateBase 와 같습니다. OnUpdate 를 sealed 로 잠그고 "돌려도 되는가"를 한 번만 검사합니다.
두 상태는 4장의 카메라와 같은 질문을 합니다. "지금 플레이어가 있니?" 4장에서 만든 EntityFinder.FindPlayer() 가 플레이어를 돌려주는가. 적도 카메라처럼 게임의 흐름을 모릅니다. 플레이어가 있으면 쫓고, 없으면 섭니다.

주인 — Enemy (첫 버전)

// Assets/GameMain/Entity/EntityLogic/Enemy.cs using GameFramework.Fsm; using UnityEngine; using UnityEngine.AI; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 적 엔티티. NavMeshAgent 로 플레이어를 추적한다. /// </summary> public class Enemy : TargetableObject { // Animator 파라미터 이름. private const string PlayerDeadTriggerName = "PlayerDead"; // ─── 캐시된 참조 ─── private EnemyData mEnemyData = null; private NavMeshAgent mAgent = null; private Animator mAnimator = null; // ─── 전투 / 연출 상태 ─── private float mRetargetTimer = 0f; // ─── 상태 기계 ─── private IFsm<Enemy> mFsm = null; private FsmState<Enemy>[] mStates = null; // 플레이어 사망 연출을 한 번만 재생하기 위한 플래그 private bool mPlayerDeadNotified = false; /// <summary> /// NavMeshAgent 에 명령을 내려도 되는 상태인지. /// 셋 중 하나라도 어긋난 채로 SetDestination/isStopped 를 건드리면 Unity 가 에러를 뱉는다. /// </summary> private bool IsAgentReady => mAgent != null && mAgent.enabled && mAgent.isOnNavMesh; // ─── 생명주기 ─── protected internal override void OnInit(object userData) { base.OnInit(userData); // 상태 인스턴스는 여기서 한 번만 만든다. mStates = new FsmState<Enemy>[] { new EnemyChaseState(), new EnemyIdleState(), }; mAgent = GetComponent<NavMeshAgent>(); mAnimator = GetComponentInChildren<Animator>(true); if (mAnimator == null) { Log.Warning("Enemy '{0}' 에 Animator 가 없다. 사망/빙결 연출이 재생되지 않는다.", CachedTransform.name); } } protected internal override void OnShow(object userData) { base.OnShow(userData); mEnemyData = userData as EnemyData; if (mEnemyData == null) { Log.Error("Enemy data is invalid."); return; } ResetCombatState(); ResetPresentation(); CachedTransform.rotation = mEnemyData.Rotation; PlaceOnNavMesh(mEnemyData.Position, mEnemyData.Stats); // 상태 기계는 **맨 마지막**에 세운다. 상태가 소유자 값을 바로 읽기 때문이다. CreateStateMachine(); } /// <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("Enemy: FsmComponent 가 없다. 추격도 사망 연출도 돌지 않는다."); return; } mFsm = fsmComponent.CreateFsm(FsmName, this, mStates); mFsm.Start<EnemyChaseState>(); } /// <summary>멱등하다. 이미 부쉈거나 프레임워크가 먼저 내려갔으면 조용히 넘어간다.</summary> private void DestroyStateMachine() { if (mFsm == null) return; FsmComponent fsmComponent = GameEntry.GetComponent<FsmComponent>(); if (fsmComponent != null) { fsmComponent.DestroyFsm(mFsm); } mFsm = null; } /// <summary>풀에서 재사용되므로 이전 판의 상태를 전부 되돌린다.</summary> private void ResetCombatState() { mPlayerDeadNotified = false; // 임계값으로 채워 둔다. 0 으로 두면 첫 SetDestination 이 0.5초 뒤에나 // 나가서 스폰 직후 반 초 동안 제자리 달리기를 한다. mRetargetTimer = EnemyTable.RetargetInterval; } /// <summary>연출용 컴포넌트를 프리팹 초기 상태로 되돌린다.</summary> private void ResetPresentation() { if (mAnimator != null) { mAnimator.enabled = true; mAnimator.Rebind(); mAnimator.Update(0f); } } protected internal override void OnHide(bool isShutdown, object userData) { // 가장 먼저 부순다. 회수된 엔티티의 상태가 계속 도는 것을 막는다. DestroyStateMachine(); DisableAgent(); mEnemyData = null; base.OnHide(isShutdown, userData); } /// <summary> /// 프레임 갱신은 전부 FSM 상태가 한다(FsmManager 가 이것보다 먼저 돈다). /// </summary> protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); } // ─── 상태가 쓰는 창구 ─── /// <summary>상태를 굴려도 되는가. 데이터 없이 떴거나 회수 요청이 나갔으면 아니다.</summary> internal bool CanRunStates { get { return mEnemyData != null && !IsHiding; } } /// <summary> /// 목적지는 0.5초 주기로만 갱신한다(경로 계산 비용 절감). /// </summary> internal void TickChase(Player player, float elapseSeconds) { if (!IsAgentReady) return; mAgent.isStopped = false; mRetargetTimer += elapseSeconds; if (mRetargetTimer < EnemyTable.RetargetInterval) return; mRetargetTimer = 0f; // 추적 목적지는 발밑(트랜스폼 원점)이다. mAgent.SetDestination(player.CachedTransform.position); } /// <summary>플레이어가 죽으면 승리 연출로 전환한다. 적 애니메이터의 PlayerDead 트리거.</summary> internal void NotifyPlayerDead() { if (mPlayerDeadNotified || mAnimator == null) return; mPlayerDeadNotified = true; mAnimator.SetTrigger(PlayerDeadTriggerName); } // ─── NavMeshAgent 헬퍼 ─── private void PlaceOnNavMesh(Vector3 position, EnemyStats stats) { if (mAgent == null) { CachedTransform.position = position; return; } // 에이전트가 켜진 상태로 transform 을 옮기면 경고가 나므로 끈 뒤 옮기고 다시 켠다. mAgent.enabled = false; CachedTransform.position = position; mAgent.enabled = true; // Warp 는 NavMesh 폴리곤 위로 스냅시킨다. 스폰 좌표가 살짝 떠 있어도 안전하다. mAgent.Warp(position); mAgent.speed = stats.MoveSpeed; mAgent.stoppingDistance = stats.StoppingDistance; if (mAgent.isOnNavMesh) { mAgent.isStopped = false; } else { Log.Warning("Enemy '{0}' 가 NavMesh 위에 놓이지 못했다. 스폰 좌표 {1} 확인 필요.", stats.AssetName, position); } } internal void StopMoving() { if (IsAgentReady) { mAgent.isStopped = true; } } private void DisableAgent() { if (mAgent == null || !mAgent.enabled) return; if (mAgent.isOnNavMesh) { mAgent.isStopped = true; mAgent.ResetPath(); } mAgent.enabled = false; } } }
C#
복사

코드 읽기

뼈대는 4장의 Player 와 줄 단위로 같습니다. OnInit 에서 상태와 부품을 준비하고, OnShow 에서 되돌리고 → 주문서를 읽고 → 자리를 잡고 → 맨 끝에 상태 기계를 만들고, OnHide 의 맨 앞에서 부숩니다. 엔티티를 하나 더 만든다는 것은 이 틀에 그 엔티티만의 것을 채워 넣는 일입니다.
표의 값이 여기서 몸에 들어갑니다. PlaceOnNavMesh 의 mAgent.speed = stats.MoveSpeed; 와 mAgent.stoppingDistance = stats.StoppingDistance; 를 보세요. 프리팹의 NavMeshAgent 에 인스펙터로 적혀 있던 값을 표의 값으로 덮어씁니다. 수치를 바꿀 때 프리팹 다섯 개를 열어 볼 필요가 없습니다.
목적지를 매 프레임 갱신하지 않습니다. SetDestination 은 호출할 때마다 길을 다시 계산합니다. 적 13마리가 초당 60번씩 계산하면 낭비입니다. 플레이어는 0.5초 사이에 그리 멀리 가지 못하므로 0.5초에 한 번이면 충분합니다. mRetargetTimer 가 그 박자를 셉니다.
ResetCombatState 가 이 타이머를 0 이 아니라 RetargetInterval 로 채워 두는 것을 보세요. 0 으로 시작하면 적은 나타난 뒤 0.5초 동안 목적지 없이 서 있습니다. 가득 채워 두면 첫 프레임에 바로 목적지를 받습니다. "주기적으로 하는 일을 처음 한 번은 즉시 하고 싶을 때" 쓰는 흔한 요령입니다.
IsAgentReady — NavMeshAgent 는 까다롭습니다. 꺼져 있거나 NavMesh 위에 있지 않을 때 SetDestination 이나 isStopped 를 건드리면 Unity 가 에러를 냅니다. 세 가지 조건을 속성 하나로 묶어 두고, 에이전트를 건드리는 모든 곳의 첫 줄에서 확인합니다.

실행해 보기

Play → 캐릭터를 고르고 5초쯤 기다립니다. 왼쪽 위에서 토끼가, 6초에 오른쪽에서 곰이 걸어옵니다. 가구를 돌아서 플레이어 앞에 와서 멈춥니다. 도망치면 따라옵니다. 아직 때리지는 않습니다.
Hierarchy 의 Entity Group - Enemy 아래에 적들이 쌓이는 것을 볼 수 있습니다.
Entity Group - Enemy ├ Zombunny(Clone) ├ ZomBear(Clone) ├ Zombunny(Clone) ├ ZombieDuck(Clone) ├ Hellephant(Clone) └ …
Plain Text
복사
1분쯤 두면 13마리(표의 "동시에 최대" 합계)에서 더 늘지 않습니다. 스포너의 상한이 동작하는 것입니다.
실제 플레이 — 다섯 종류의 적이 가구를 돌아 플레이어를 쫓아온다. 도망쳐도 따라붙는다

5.5 때리기

적이 멈춰 선 자리에서 플레이어를 때리게 합니다.
먼저 "플레이어까지의 거리"를 잴 기준점이 필요합니다. Player.cs 에 속성을 하나 추가합니다.
/// <summary> /// 피격/사거리 판정의 기준점. 콜라이더 중심이 트랜스폼 원점에서 /// XZ 로 약 0.3 어긋나 있어(center (0.18, 0.7, 0.24)) 원점을 쓰면 /// 접근 방향에 따라 판정 거리가 흔들린다. /// </summary> public Vector3 CenterPosition { get { return mCollider != null ? CachedTransform.TransformPoint(mCollider.center) : CachedTransform.position; } }
C#
복사
Girl 프리팹의 Capsule Collider 를 Inspector 에서 펼쳐 보면 Center 가 (0.18, 0.7, 0.24) 입니다. 캐릭터 모델의 몸통이 오브젝트의 원점(발밑)에서 살짝 비켜나 있습니다. "몸에 닿았는가"를 따지려면 발밑이 아니라 몸통 중심까지의 거리를 재야 합니다.
Enemy.cs 에 공격을 추가합니다. 필드에 타이머 하나,
private float mAttackTimer = 0f; private float mRetargetTimer = 0f;
C#
복사
ResetCombatState 의 첫 줄에 mAttackTimer = 0f;, 그리고 "상태가 쓰는 창구"에 메서드 둘을 넣습니다.
/// <summary> /// 공격 타이머는 사거리와 무관하게 계속 돌면서 /// 매 틱 "지금 사거리 안인가" 만 본다. 공격 성공 시에도 위상이 리셋되지 않는다. /// 그래서 첫 타격은 접촉 후 0~간격 사이의 임의 시점에 나간다. /// (타이머를 0 으로 리셋하면 접촉 즉시 확정 타격이 되어 더 가혹해진다.) /// </summary> internal void TickAttack(Player player, float elapseSeconds) { EnemyStats stats = mEnemyData.Stats; mAttackTimer += elapseSeconds; if (mAttackTimer < stats.TimeBetweenAttacks) return; mAttackTimer -= stats.TimeBetweenAttacks; // 공격 판정은 콜라이더 중심 기준이다. bool isPlayerInAttackRange = GetPlanarDistance(player.CenterPosition) <= stats.AttackRange; if (!isPlayerInAttackRange) return; player.ApplyDamage(Entity, stats.AttackDamage); } /// <summary> /// 공격 사거리는 XZ 평면에서 잰다. 캐릭터 콜라이더의 중심이 Y 로 올라가 있어 /// 3D 거리를 쓰면 접근 방향과 높이차에 따라 사거리가 흔들린다. /// </summary> private float GetPlanarDistance(Vector3 target) { Vector3 delta = target - CachedTransform.position; delta.y = 0f; return delta.magnitude; }
C#
복사
EnemyChaseState 의 마지막에 한 줄을 더합니다.
owner.TickChase(player, elapseSeconds); owner.TickAttack(player, elapseSeconds);
C#
복사

코드 읽기

player.ApplyDamage(Entity, stats.AttackDamage) — 4장에서 만든 TargetableObject.ApplyDamage 를 드디어 남이 부릅니다. 적은 플레이어의 체력이 어떻게 관리되는지, 죽으면 무슨 일이 일어나는지 모릅니다. "이만큼 때렸다"만 전합니다. 나머지는 4장에서 만든 길(OnDamaged → OnHitPointChanged → OnDead)을 따라 흘러갑니다.
mAttackTimer -= stats.TimeBetweenAttacks — 타이머를 = 0f 로 되감지 않고 간격만큼 뺍니다. 프레임은 정확히 0.5초에 맞춰 오지 않습니다. 0.52초에 조건이 참이 됐다면 0.02초가 남습니다. 0 으로 되감으면 그 0.02초를 버리게 되고, 빼면 다음 주기에 이월됩니다. 오래 돌려도 박자가 밀리지 않습니다.
타이머는 사거리 밖에서도 계속 돕니다. 주석에 적힌 대로입니다. "사거리에 들어온 순간부터 센다"로 만들면 닿자마자 정확히 0.5초 뒤에 맞습니다. 타이머가 늘 돌고 있으면 닿은 뒤 0~0.5초 사이의 어느 시점에 맞습니다. 스치듯 지나갈 때 맞을 수도 안 맞을 수도 있는, 조금 더 너그러운 판정이 됩니다. 이런 작은 선택이 게임의 손맛을 정합니다.
표의 두 숫자가 맞물려 있습니다. 적은 StoppingDistance(토끼 1.1) 거리에서 멈추고, AttackRange(토끼 0.8 + 0.5 = 1.3) 안이면 때립니다. 멈추는 거리가 때리는 거리보다 짧아야 멈춘 자리에서 공격이 닿습니다. 표를 고칠 때 이 관계를 깨면 "적이 코앞에 서서 아무것도 안 하는" 상황이 됩니다.

실행해 보기

Play → 캐릭터를 고르고 가만히 서 있습니다. 적이 다가와 때리기 시작하고, 체력 100 이 다 닳으면 4장에서 만든 대로 플레이어가 쓰러집니다. 1초 뒤 콘솔에 찍힙니다.
Game over. Final score: 0
Plain Text
복사
그 순간 적들이 추격을 멈추고 승리 애니메이션을 재생합니다. EnemyChaseState 가 player.IsDead 를 보고 NotifyPlayerDead() 를 부른 것입니다. 그리고 새 적이 더는 나오지 않습니다. SurvivalGame.Update 가 GameOver 에서 조기 반환해 스포너가 멈췄기 때문입니다.

5.6 쓰러지고 가라앉기

이제 반대로 적이 죽는 쪽입니다. 플레이어가 죽을 때처럼 연출 → 회수 순서를 밟는데, 적은 한 단계가 더 있습니다. 쓰러진 뒤 바닥으로 가라앉습니다.
graph LR
    S((" ")) --> C["Chase<br>쫓고 때린다"]
    C -->|"플레이어 없음·사망"| I["Idle"]
    I -->|"플레이어 생존"| C
    C -->|"체력 0"| D["Dying<br>쓰러지는 중"]
    I -->|"체력 0"| D
    D -->|"가라앉을 때가 됨"| K["Sinking<br>바닥으로 가라앉는다"]
    K -->|"2초 경과"| E((" "))
Mermaid
복사

죽었다는 소식 — EnemyDiedEventArgs

// Assets/GameMain/Game/EnemyDiedEventArgs.cs using GameFramework; using GameFramework.Event; using UnityEngine; namespace ToyBoxNightmare { /// <summary> /// 적이 죽었을 때 발행된다. 점수 집계가 이 이벤트를 구독한다. /// </summary> public class EnemyDiedEventArgs : GameEventArgs { public static readonly int EventId = typeof(EnemyDiedEventArgs).GetHashCode(); public override int Id => EventId; /// <summary>죽은 적의 Addressables 주소. 종별 카운트에 쓴다.</summary> public string EnemyAssetName { get; private set; } /// <summary>이 적을 잡았을 때 얻는 점수.</summary> public int ScoreValue { get; private set; } /// <summary>죽은 위치. 드롭 스폰 지점으로 쓴다.</summary> public Vector3 Position { get; private set; } public static EnemyDiedEventArgs Create(string enemyAssetName, int scoreValue, Vector3 position) { var args = ReferencePool.Acquire<EnemyDiedEventArgs>(); args.EnemyAssetName = enemyAssetName; args.ScoreValue = scoreValue; args.Position = position; return args; } public override void Clear() { EnemyAssetName = null; ScoreValue = 0; Position = Vector3.zero; } } }
C#
복사
세 번째 이벤트입니다. 이제 손이 기억할 것입니다. 실어 보내는 값이 셋으로 늘었으니 Clear 도 세 줄입니다.

상태 둘 추가

// Assets/GameMain/Entity/EntityLogic/States/EnemyDyingState.cs using GameFramework.Fsm; namespace ToyBoxNightmare { /// <summary> /// 쓰러지는 연출 구간. 침하는 다음 상태가 맡는다. /// /// 침하 시작 신호는 둘이다 — FBX 에 baked 된 <c>StartSinking</c> 애니메이션 이벤트(4종), /// 그리고 그 이벤트가 없는 ZomBear 를 위한 시간 비율 폴백. 소유자가 둘을 합쳐 /// 하나의 조건으로 노출한다(상태는 밖에서 못 바뀌므로 폴링해야 한다). /// </summary> public sealed class EnemyDyingState : EnemyStateBase { protected override void OnStateUpdate(IFsm<Enemy> fsm, Enemy owner, float elapseSeconds) { owner.TickDeathTimer(elapseSeconds); if (owner.ShouldStartSinking) { ChangeState<EnemySinkingState>(fsm); return; } // 침하 없이 연출 시간이 다 차는 경우는 폴백 비율(0.5) 때문에 정상 경로에서는 // 오지 않지만, 비율을 1 로 바꿔도 엔티티가 남지 않도록 여기서도 회수한다. if (!owner.IsDeathEffectFinished) return; owner.SafeHide(); } } }
C#
복사
// Assets/GameMain/Entity/EntityLogic/States/EnemySinkingState.cs using GameFramework.Fsm; namespace ToyBoxNightmare { /// <summary>바닥으로 가라앉으며 연출 시간을 채우는 상태. 다 차면 회수한다.</summary> public sealed class EnemySinkingState : EnemyStateBase { protected override void OnStateUpdate(IFsm<Enemy> fsm, Enemy owner, float elapseSeconds) { owner.TickDeathTimer(elapseSeconds); owner.TickSinking(elapseSeconds); if (!owner.IsDeathEffectFinished) return; owner.SafeHide(); } } }
C#
복사
살아 있는 두 상태가 사망으로 넘어가도록 EnemyStateBase 에 4장의 PlayerStateBase 와 같은 도우미를 추가합니다.
/// <summary>살아있는 상태들이 공통으로 보는 전이. 사망이 시작됐으면 사망 연출로 넘어간다.</summary> protected bool TryChangeToDying(IFsm<Enemy> fsm, Enemy owner) { if (!owner.IsDying) return false; ChangeState<EnemyDyingState>(fsm); return true; }
C#
복사
그리고 EnemyChaseState 와 EnemyIdleState 의 OnStateUpdate 첫 줄에 각각 넣습니다.
if (TryChangeToDying(fsm, owner)) return;
C#
복사

주인 — 완성된 Enemy

Enemy.cs 에 사망 처리를 넣습니다. 이 장이 끝난 시점의 파일 전체입니다.
// Assets/GameMain/Entity/EntityLogic/Enemy.cs using GameFramework.Fsm; using UnityEngine; using UnityEngine.AI; using UnityGameFramework.Runtime; namespace ToyBoxNightmare { /// <summary> /// 적 엔티티. NavMeshAgent 로 플레이어를 추적하고, 사거리 안에서 주기적으로 공격한다. /// 사망하면 즉시 사라지지 않고 연출(정지 → 침하) 후 회수된다. /// </summary> public class Enemy : TargetableObject { // ─── 튜닝 상수 ─── /// <summary> /// StartSinking 애니메이션 이벤트가 없는 종(ZomBear)을 위한 폴백 시점. /// 사망 연출 전체 길이 중 이 비율만큼 지나면 강제로 침하를 시작한다. /// </summary> private const float SinkFallbackRatio = 0.5f; // Animator 파라미터 이름. private const string DeadTriggerName = "Dead"; private const string PlayerDeadTriggerName = "PlayerDead"; // ─── 캐시된 참조 ─── private EnemyData mEnemyData = null; private NavMeshAgent mAgent = null; private Animator mAnimator = null; /// <summary> /// 루트의 콜라이더 전부. 적 프리팹은 CapsuleCollider(몸통)와 SphereCollider(트리거)를 /// 둘 다 달고 있어, 몸통만 꺼서는 시체가 히트스캔을 계속 막는다. /// </summary> private Collider[] mColliders = null; // 프리팹 원본의 자식별 레이어. EntityLogic.OnHide 가 SetLayerRecursively 로 // 계층 전체를 루트 레이어로 평탄화해 버리므로, 재사용 전에 되돌려야 // 2세대 인스턴스가 1세대와 다르게 동작하는 일을 막을 수 있다. private Transform[] mLayerTargets = null; private int[] mOriginalLayers = null; // ─── 전투 / 연출 상태 ─── private float mAttackTimer = 0f; private float mRetargetTimer = 0f; // 사망 연출 상태. // 단계(쓰러짐 → 침하) 자체는 FSM 상태가 들고, 여기에는 상태가 폴링할 조건만 남는다. private bool mDeathRequested = false; private bool mSinkRequested = false; private float mDeathTimer = 0f; // ─── 상태 기계 ─── // 추격 / 대기 / 쓰러짐 / 침하 네 상태를 GameFramework FSM 으로 돌린다. private IFsm<Enemy> mFsm = null; private FsmState<Enemy>[] mStates = null; // 플레이어 사망 연출을 한 번만 재생하기 위한 플래그 private bool mPlayerDeadNotified = false; // ─── 상태 질의 ─── /// <summary>이 적의 공격 사거리. 데이터가 없으면 0.</summary> public float AttackRange => mEnemyData != null ? mEnemyData.Stats.AttackRange : 0f; /// <summary> /// 사망 연출에 들어갔는지. 쓰러지는 중과 침하 중을 모두 포함한다. /// </summary> internal bool IsDying => mDeathRequested; private bool IsAgentReady => mAgent != null && mAgent.enabled && mAgent.isOnNavMesh; // ─── 생명주기 ─── protected internal override void OnInit(object userData) { base.OnInit(userData); mStates = new FsmState<Enemy>[] { new EnemyChaseState(), new EnemyIdleState(), new EnemyDyingState(), new EnemySinkingState(), }; mAgent = GetComponent<NavMeshAgent>(); mAnimator = GetComponentInChildren<Animator>(true); mColliders = GetComponents<Collider>(); WarnOnMissingPrefabParts(); // OnInit 은 인스턴스당 1회, 첫 OnHide(레이어 평탄화)보다 먼저 돈다. // 따라서 여기서 뜬 스냅샷이 프리팹 원본 값이다. CacheChildLayers(); } /// <summary> /// 프리팹 구성 실수를 스폰 시점에 드러낸다. 아래 둘은 없어도 예외는 안 나지만 /// 적이 맞지 않거나 사망 연출이 통째로 생략돼 원인을 찾기 어렵다. /// </summary> private void WarnOnMissingPrefabParts() { if (mColliders == null || mColliders.Length == 0) { Log.Warning("Enemy '{0}' 루트에 Collider 가 없다. 히트스캔·트리거 판정이 전부 빗나간다.", CachedTransform.name); } if (mAnimator == null) { Log.Warning("Enemy '{0}' 에 Animator 가 없다. 사망/빙결 연출이 재생되지 않는다.", CachedTransform.name); } } private void CacheChildLayers() { mLayerTargets = GetComponentsInChildren<Transform>(true); mOriginalLayers = new int[mLayerTargets.Length]; for (int i = 0; i < mLayerTargets.Length; i++) { mOriginalLayers[i] = mLayerTargets[i].gameObject.layer; } } private void RestoreChildLayers() { if (mLayerTargets == null || mOriginalLayers == null) return; GameAssert.IsTrue(mLayerTargets.Length == mOriginalLayers.Length, "레이어 스냅샷 길이가 대상 수와 다르다. CacheChildLayers 호출 시점을 확인할 것."); // 두 배열은 항상 같은 길이지만, 어긋나더라도 IndexOutOfRange 로 터지지는 않게 한다. int count = Mathf.Min(mLayerTargets.Length, mOriginalLayers.Length); for (int i = 0; i < count; i++) { if (mLayerTargets[i] != null) { mLayerTargets[i].gameObject.layer = mOriginalLayers[i]; } } } protected internal override void OnShow(object userData) { base.OnShow(userData); mEnemyData = userData as EnemyData; if (mEnemyData == null) { Log.Error("Enemy data is invalid."); return; } ResetCombatState(); ResetPresentation(); CachedTransform.rotation = mEnemyData.Rotation; PlaceOnNavMesh(mEnemyData.Position, mEnemyData.Stats); // 상태 기계는 **맨 마지막**에 세운다. 상태가 소유자 값을 바로 읽기 때문이다. CreateStateMachine(); } /// <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("Enemy: FsmComponent 가 없다. 추격도 사망 연출도 돌지 않는다."); return; } mFsm = fsmComponent.CreateFsm(FsmName, this, mStates); mFsm.Start<EnemyChaseState>(); } /// <summary>멱등하다. 이미 부쉈거나 프레임워크가 먼저 내려갔으면 조용히 넘어간다.</summary> private void DestroyStateMachine() { if (mFsm == null) return; FsmComponent fsmComponent = GameEntry.GetComponent<FsmComponent>(); if (fsmComponent != null) { fsmComponent.DestroyFsm(mFsm); } mFsm = null; } /// <summary>풀에서 재사용되므로 이전 판의 상태를 전부 되돌린다.</summary> private void ResetCombatState() { mAttackTimer = 0f; mDeathRequested = false; mSinkRequested = false; mDeathTimer = 0f; mPlayerDeadNotified = false; // 임계값으로 채워 둔다. 0 으로 두면 첫 SetDestination 이 0.5초 뒤에나 // 나가서 스폰 직후 반 초 동안 제자리 달리기를 한다. mRetargetTimer = EnemyTable.RetargetInterval; } /// <summary>연출용 컴포넌트를 프리팹 초기 상태로 되돌린다.</summary> private void ResetPresentation() { RestoreChildLayers(); SetCollidersEnabled(true); if (mAnimator != null) { mAnimator.enabled = true; mAnimator.Rebind(); mAnimator.Update(0f); } } protected internal override void OnHide(bool isShutdown, object userData) { // 가장 먼저 부순다. 회수된 엔티티의 상태가 계속 도는 것을 막는다. DestroyStateMachine(); DisableAgent(); // 데이터는 base 체인 끝(EntityLogicBase)에서 ReferencePool 로 반납된다. mEnemyData = null; base.OnHide(isShutdown, userData); } // ─── 이동 / 공격 ─── /// <summary> /// 프레임 갱신은 전부 FSM 상태가 한다(FsmManager 가 이것보다 먼저 돈다). /// 여기에 로직을 다시 넣으면 상태 밖에 숨은 분기가 생기므로 그러지 말 것. /// </summary> protected internal override void OnUpdate(float elapseSeconds, float realElapseSeconds) { base.OnUpdate(elapseSeconds, realElapseSeconds); } // ─── 상태가 쓰는 창구 ─── /// <summary>상태를 굴려도 되는가. 데이터 없이 떴거나 회수 요청이 나갔으면 아니다.</summary> internal bool CanRunStates { get { return mEnemyData != null && !IsHiding; } } internal void TickChase(Player player, float elapseSeconds) { if (!IsAgentReady) return; mAgent.isStopped = false; mRetargetTimer += elapseSeconds; if (mRetargetTimer < EnemyTable.RetargetInterval) return; mRetargetTimer = 0f; // 추적 목적지는 발밑(트랜스폼 원점)이다. 공격 판정만 콜라이더 중심을 쓴다. mAgent.SetDestination(player.CachedTransform.position); } internal void TickAttack(Player player, float elapseSeconds) { EnemyStats stats = mEnemyData.Stats; mAttackTimer += elapseSeconds; if (mAttackTimer < stats.TimeBetweenAttacks) return; mAttackTimer -= stats.TimeBetweenAttacks; // 공격 판정은 콜라이더 중심 기준이다. bool isPlayerInAttackRange = GetPlanarDistance(player.CenterPosition) <= stats.AttackRange; if (!isPlayerInAttackRange) return; player.ApplyDamage(Entity, stats.AttackDamage); } private float GetPlanarDistance(Vector3 target) { Vector3 delta = target - CachedTransform.position; delta.y = 0f; return delta.magnitude; } // ─── 통지 ─── /// <summary>플레이어가 죽으면 승리 연출로 전환한다. 적 애니메이터의 PlayerDead 트리거.</summary> internal void NotifyPlayerDead() { if (mPlayerDeadNotified || mAnimator == null) return; mPlayerDeadNotified = true; mAnimator.SetTrigger(PlayerDeadTriggerName); } // ─── 사망 ─── /// <summary> /// 주의: <c>base.OnDead</c> 를 부르지 않는다. base 는 즉시 SafeHide 하므로 /// 연출이 통째로 생략되고, 연출 종료 시점의 Hide 와 겹쳐 중복 호출이 된다. /// </summary> protected override void OnDead(Entity attacker) { if (IsDying) return; mDeathRequested = true; mSinkRequested = false; mDeathTimer = 0f; // 침하 중에 에이전트가 Y 를 NavMesh 높이로 되끌어올리므로 먼저 끈다. DisableAgent(); // 트리거 구까지 전부 끈다. 몸통만 끄면 시체가 가라앉는 동안 // 트리거 구가 총알을 계속 가로채, 뒤에 있는 적에게 한 발도 안 들어간다. SetCollidersEnabled(false); if (mAnimator != null) { mAnimator.SetTrigger(DeadTriggerName); } FireEnemyDiedEvent(); } /// <summary>사망 통지. 스코어 집계와 드롭 위치가 이 이벤트에 실린다.</summary> private void FireEnemyDiedEvent() { if (mEnemyData == null) { // OnShow 가 EnemyData 아닌 userData 를 받았을 때만 도달한다(설정 실수). Log.Error("Enemy data is invalid. EnemyDied 이벤트를 발행하지 못했다."); return; } EventComponent eventComponent = GameEntry.GetComponent<EventComponent>(); if (eventComponent == null) { Log.Error("EventComponent 를 찾지 못했다. GameFramework.prefab 구성을 확인할 것."); return; } eventComponent.Fire(this, EnemyDiedEventArgs.Create( mEnemyData.AssetName, mEnemyData.ScoreValue, CachedTransform.position)); } /// <summary>사망 애니메이션 이벤트에서 호출된다(ZomBear 를 제외한 4종의 FBX 에 baked).</summary> private void StartSinking() { // 사망 연출 중이 아닐 때 들어온 이벤트는 무시한다. if (!mDeathRequested) return; // 상태를 밖에서 바꿀 수 없으므로 요청만 세우고 // EnemyDyingState 가 폴링해서 침하로 넘어간다. mSinkRequested = true; } /// <summary>사망 연출 타이머를 진행시킨다. 쓰러짐·침하 두 상태가 공통으로 부른다.</summary> internal void TickDeathTimer(float elapseSeconds) { mDeathTimer += elapseSeconds; } /// <summary> /// 침하를 시작할 때인가. 신호가 둘이다 — /// FBX 에 baked 된 StartSinking 애니메이션 이벤트, 그리고 /// 그 이벤트가 없는 ZomBear 를 위해 연출 시간의 절반 지점에서 걸리는 폴백. /// </summary> internal bool ShouldStartSinking { get { return mSinkRequested || mDeathTimer >= EnemyTable.DeathEffectTime * SinkFallbackRatio; } } /// <summary>연출 시간이 다 찼는가. 회수 시점이다.</summary> internal bool IsDeathEffectFinished { get { return mDeathTimer >= EnemyTable.DeathEffectTime; } } /// <summary>바닥으로 가라앉는다.</summary> internal void TickSinking(float elapseSeconds) { CachedTransform.Translate(Vector3.down * (EnemyTable.SinkSpeed * elapseSeconds), Space.World); } // ─── NavMeshAgent 헬퍼 ─── private void PlaceOnNavMesh(Vector3 position, EnemyStats stats) { if (mAgent == null) { CachedTransform.position = position; return; } // 에이전트가 켜진 상태로 transform 을 옮기면 경고가 나므로 끈 뒤 옮기고 다시 켠다. mAgent.enabled = false; CachedTransform.position = position; mAgent.enabled = true; // Warp 는 NavMesh 폴리곤 위로 스냅시킨다. 스폰 좌표가 살짝 떠 있어도 안전하다. mAgent.Warp(position); mAgent.speed = stats.MoveSpeed; mAgent.stoppingDistance = stats.StoppingDistance; if (mAgent.isOnNavMesh) { mAgent.isStopped = false; } else { Log.Warning("Enemy '{0}' 가 NavMesh 위에 놓이지 못했다. 스폰 좌표 {1} 확인 필요.", stats.AssetName, position); } } internal void StopMoving() { if (IsAgentReady) { mAgent.isStopped = true; } } private void SetCollidersEnabled(bool isEnabled) { if (mColliders == null) return; for (int i = 0; i < mColliders.Length; i++) { if (mColliders[i] != null) { mColliders[i].enabled = isEnabled; } } } private void DisableAgent() { if (mAgent == null || !mAgent.enabled) return; if (mAgent.isOnNavMesh) { mAgent.isStopped = true; mAgent.ResetPath(); } mAgent.enabled = false; } } }
C#
복사
📝
예제 프로젝트의 Enemy.cs 는 이보다 깁니다. 무기에 맞았을 때의 효과(얼기, 도망치기, 지속 피해)와 피격 이펙트가 6장에서, 효과음이 8장에서 더해집니다.

코드 읽기

OnDead 는 플레이어의 것과 같은 모양입니다. 깃발(mDeathRequested)을 세우고, 더 움직이지도 맞지도 않게 하고, 쓰러지는 애니메이션을 겁니다. 사라지는 것은 상태의 타이머가 맡습니다.
다른 점이 하나 있습니다. 플레이어는 사망 소식을 연출이 끝난 뒤에 알렸는데, 적은 OnDead 에서 바로 알립니다(FireEnemyDiedEvent). 플레이어의 죽음은 판 전체를 끝내는 사건이라 연출을 기다려야 했지만, 적의 죽음은 점수가 오르는 것뿐이라 기다릴 이유가 없습니다. 오히려 쓰러뜨린 순간 점수가 올라야 손맛이 있습니다. "언제 알릴 것인가"는 그 소식을 받은 쪽이 무엇을 하는가에 따라 정합니다.
가라앉기 시작하는 신호가 둘입니다. ShouldStartSinking 을 보세요.
•
적 모델의 사망 애니메이션에는 "이제 가라앉아도 돼"라는 애니메이션 이벤트가 심어져 있습니다. Unity 는 그 시점에 같은 오브젝트의 StartSinking() 메서드를 이름으로 찾아 부릅니다. 그래서 이 메서드는 코드 어디에서도 부르지 않는데 private 으로 존재합니다.
•
그런데 다섯 중 곰(ZomBear)의 애니메이션에는 이 이벤트가 없습니다. 이벤트만 믿으면 곰은 영원히 가라앉지 않습니다. 그래서 "연출 시간의 절반(1초)이 지나면 무조건 가라앉는다"는 대비책을 함께 둡니다.
에셋에 심어진 신호에 기대는 코드는 이렇게 신호가 오지 않을 때의 길을 같이 만들어 둡니다. 에셋은 코드보다 쉽게 바뀌고, 바뀌어도 컴파일 에러가 나지 않기 때문입니다.
StartSinking() 이 상태를 직접 바꾸지 않고 mSinkRequested 깃발만 세우는 것도 눈여겨보세요. 3장의 DisableAndHide() 와 같습니다. 상태는 밖에서 바꿀 수 없으니, 깃발을 세우고 상태가 그것을 봅니다.
가라앉기 전에 에이전트를 끕니다. NavMeshAgent 는 자기가 붙은 오브젝트를 NavMesh 표면 높이에 붙잡아 둡니다. 켜 둔 채로 아래로 내리면 매 프레임 도로 끌어올려져 부들부들 떱니다. OnDead 의 DisableAgent() 가 이것을 막습니다.
콜라이더를 전부 끕니다. 적 프리팹에는 몸통 콜라이더 말고도 큰 트리거 구가 하나 더 붙어 있습니다. 몸통만 끄면 쓰러진 적의 트리거 구가 2초 동안 그 자리에 남아, 6장에서 쏠 총알을 대신 맞아 줍니다. 그래서 GetComponents<Collider>() 로 전부 잡아 두고 전부 끕니다.
💡
CacheChildLayers / RestoreChildLayers 는 프레임워크의 동작 하나를 되돌리기 위한 것입니다. 엔티티가 회수될 때 프레임워크는 그 오브젝트와 모든 자식의 레이어를 루트의 레이어로 통일합니다. 적 프리팹의 자식 중에는 일부러 다른 레이어에 둔 것이 있는데, 한 번 회수되고 나면 그 구분이 사라집니다. 그래서 OnInit 에서(아직 한 번도 회수되지 않았을 때) 원래 레이어를 적어 두고, OnShow 마다 되돌립니다. "첫 번째로 나온 적과 풀에서 다시 나온 적이 다르게 동작한다"는 종류의 버그를 막습니다.

점수 올리기 — SurvivalGame

듣는 쪽입니다. SubscribeEvents / UnsubscribeEvents 에 한 줄씩 추가합니다.
events.Subscribe(EnemyDiedEventArgs.EventId, OnEnemyDied);
C#
복사
events.Unsubscribe(EnemyDiedEventArgs.EventId, OnEnemyDied);
C#
복사
핸들러와 점수 메서드입니다.
// ─── 점수 ─── /// <summary>점수를 더한다.</summary> public void AddScore(int delta) { if (delta == 0) return; Score += delta; }
C#
복사
private void OnEnemyDied(object sender, GameEventArgs e) { // 게임오버 확정 후 도착한 처치는 점수에 넣지 않는다. // Fire 는 큐잉이라 사망과 같은 프레임의 처치가 한 프레임 늦게 도착할 수 있다. if (GameOver) return; EnemyDiedEventArgs ne = e as EnemyDiedEventArgs; if (ne == null) { GameAssert.Unreachable("EnemyDied 핸들러에 다른 타입의 이벤트 인자가 들어왔다."); return; } AddScore(ne.ScoreValue); }
C#
복사
적은 SurvivalGame 을 모릅니다. "나 죽었고, 내 점수는 10점이야"라고 외칠 뿐입니다. 7장에서는 화면의 점수판이, 8장에서는 아군 소환 시스템이 같은 이벤트에 귀를 기울입니다. 그때도 Enemy.cs 는 한 글자도 바뀌지 않습니다.

실행해 보기

아직 무기가 없으니, 4장처럼 확인용 키를 잠깐 넣습니다. SurvivalGame.cs 맨 위에 using UnityEngine.InputSystem; 을 추가하고, Update 의 끝에 넣습니다.
mSpawner.OnUpdate(elapseSeconds); // 확인용: K 를 누르면 화면의 적 전부에게 큰 피해를 준다. 확인한 뒤 지운다 Keyboard keyboard = Keyboard.current; if (keyboard != null && keyboard.kKey.wasPressedThisFrame) { Entity[] loadedEntities = GameEntry.GetComponent<EntityComponent>().GetAllLoadedEntities(); for (int i = 0; i < loadedEntities.Length; i++) { Enemy enemy = loadedEntities[i].Logic as Enemy; if (enemy != null) { enemy.ApplyDamage(null, 1000); } } }
C#
복사
점수가 오르는 것도 보고 싶으니 OnEnemyDied 의 AddScore(ne.ScoreValue); 아래에 한 줄을 넣습니다.
Log.Info("처치: {0} (+{1}) → 점수 {2}", ne.EnemyAssetName, ne.ScoreValue, Score); // 확인용
C#
복사
Play → 캐릭터를 고르고, 적이 서너 마리 모였을 때 K 를 누릅니다.
K 를 누른 뒤. 적들이 쓰러지고, 바닥으로 가라앉고, 2초 뒤 사라진다
처치: Zombunny (+10) → 점수 10 처치: ZomBear (+10) → 점수 20 처치: Zombunny (+10) → 점수 30 처치: Hellephant (+50) → 점수 80
Plain Text
복사
적들이 한꺼번에 쓰러지고, 바닥으로 스르르 가라앉은 뒤 사라집니다. 잠시 뒤 스포너가 빈자리를 다시 채우기 시작합니다. Hierarchy 의 Entity Group - Enemy 를 보면 죽은 적들이 회색으로 남아 있다가, 새 적이 나올 때 그중 하나가 다시 흰색이 됩니다. 풀이 재사용하는 모습입니다.
적이 입을 멈추고 찍은 에디터. ① Entity Group - Enemy — 흰색은 지금 떠 있는 적, 회색은 죽어서 풀로 돌아간 적 ② 적의 Inspector ③ 화면 속의 적
① 에서 흰색과 회색이 섞여 있는 것이 보입니다. 회색 줄은 한 번 죽었던 적이 꺼진 채 풀에서 기다리는 것이고, 스포너가 같은 종류를 주문하면 그중 하나가 다시 흰색이 됩니다. ② 에서 Nav Mesh Agent 와 콜라이더는 프리팹에 있던 것이고, 맨 아래 Entity 와 Enemy 는 프레임워크가 실행 중에 붙인 것입니다. 레이어가 Shootable 인 것도 확인해 두세요. 6장의 무기가 이 레이어만 맞힙니다.
확인이 끝났으면 K 키 코드와 확인용 로그, using UnityEngine.InputSystem; 을 지웁니다.

5.7 잘 안 될 때

증상
원인과 해결
Play 하자마자 [사전조건 위반] EnemyTable.SpawnPoints[…] 의 주소 '…' 가 Table 에 없다
SpawnPoints 와 Table 의 이름이 다릅니다. 오타를 찾습니다. Validate 가 제 일을 한 것입니다
Entity group 'Enemy' is not exist.
1장에서 Entity 컴포넌트에 Enemy 그룹을 만들지 않았습니다
Show entity failure: … (Zombunny) 경고
그 적의 프리팹이 Addressables 에 없거나 주소가 다릅니다
적이 나타나지만 제자리에서 움직이지 않는다. NavMesh 위에 놓이지 못했다 경고
씬에 NavMesh 가 베이크돼 있지 않거나, 스폰 좌표가 NavMesh 바깥입니다
"SetDestination" can only be called on an active agent that has been placed on a NavMesh.
에이전트를 건드리는 메서드 첫 줄의 IsAgentReady 검사를 빠뜨렸습니다
어떤 종이 한두 번 나온 뒤 다시는 안 나온다
"오는 중" 숫자를 한쪽에서만 내렸습니다. OnShowEntitySuccess 와 OnShowEntityFailure 양쪽에 DecrementPendingSpawn 이 있는지 확인합니다
적이 코앞에 서 있기만 하고 때리지 않는다
① EnemyChaseState 에 TickAttack 호출이 있는가 ② 표에서 StoppingDistance 가 AttackRange 보다 크지 않은가
죽은 적이 가라앉지 않고 부들부들 떤다
OnDead 에서 DisableAgent() 를 부르지 않았습니다
풀에서 다시 나온 적이 맞지 않는다 (6장의 무기가 통과한다)
ResetPresentation 의 SetCollidersEnabled(true) 가 빠졌습니다. 죽을 때 꺼 둔 콜라이더가 그대로입니다
Already exist FSM '…'.
OnHide 에서 DestroyStateMachine() 을 부르지 않았습니다

이 장에서 만든 것

파일
내용
Game/EnemyTable.cs
적 다섯 종의 수치와 스폰 지점. 표 검사
Game/EnemySpawner.cs
표를 읽어 주기적으로 적을 띄운다
Game/EnemyDiedEventArgs.cs
"적이 죽었다" 이벤트
Entity/EntityData/EnemyData.cs
적 주문서 — 표의 한 줄을 들고 간다
Entity/EntityLogic/Enemy.cs
적 로직. 다섯 종이 이 클래스 하나를 쓴다
Entity/EntityLogic/States/Enemy…State.cs (5개)
Chase / Idle / Dying / Sinking 과 공통 부모
Entity/EntityLogic/Player.cs (수정)
CenterPosition 추가
Game/SurvivalGame.cs (수정)
스포너 소유·구동, 대기 카운트 중계, 점수

정리

•
하는 일이 같고 숫자만 다르면 클래스는 하나, 숫자는 표. 새 종류를 추가하는 일이 표에 줄을 더하는 일이 된다
•
표를 사람이 손으로 쓴다면 시작할 때 한 번 검사한다. 조용히 틀리는 표가 가장 위험하다
•
스포너처럼 판 단위로 살고 화면에 모습이 없는 것은 게임 객체가 들고 굴리는 평범한 C# 객체로 만든다
•
비동기로 만들어지는 것의 수를 셀 때는 "오는 중" 도 센다. 올린 곳이 하나면 내리는 곳은 성공과 실패, 둘이다
•
엔티티를 하나 더 만드는 일은 같은 틀(OnInit 준비 → OnShow 되돌리고 읽고 상태 기계 → OnHide 부수고 놓기)을 채우는 일이다
•
주기적으로 하는 일은 타이머를 간격만큼 빼서 되감는다. 처음 한 번을 즉시 하려면 타이머를 가득 채워서 시작한다
•
에셋에 심어 둔 신호(애니메이션 이벤트)에 기댈 때는 신호가 안 올 때의 길을 같이 만든다
•
"언제 알릴 것인가"는 그 소식을 받는 쪽이 무엇을 하는가로 정한다

직접 해 보기

1.
EnemyTable 에서 토끼의 스폰 설정을 5f, 4 에서 1f, 10 으로 바꿔 보세요. 클래스는 한 줄도 건드리지 않고 게임이 완전히 달라집니다. 확인한 뒤 되돌립니다.
2.
SpawnPoints 의 "Clown" 을 "Clovn" 으로 바꾸고 Play 해 보세요. 어떤 메시지가 언제 나오나요? 그다음 Initialize 의 EnemyTable.Validate(); 를 주석 처리하고 다시 해 보세요. 이번에는 문제를 알아채는 데 얼마나 걸리나요?
3.
SpawnPoints 에 토끼 지점을 하나 더 추가해 보세요(new EnemySpawnPoint("Zombunny", new Vector3(20f, 0f, -10f), 5f, 4)). Validate 가 무엇이라고 말하나요? 5.3 절의 DecrementPendingSpawn 을 다시 읽고, 왜 "한 주소 = 한 지점"이어야 하는지 설명해 보세요.
4.
Enemy.ResetCombatState 에서 mRetargetTimer = EnemyTable.RetargetInterval; 을 mRetargetTimer = 0f; 로 바꾸고, 적이 나타나는 순간을 지켜보세요. 차이가 보이나요?
5.
(조금 어려움) 새 적을 추가해 보세요. Zombunny 프리팹을 복제해 이름을 BigBunny 로 바꾸고 Scale 을 2 로 키운 뒤, Addressables 에 BigBunny 주소로 등록합니다. 그리고 표 두 곳에 한 줄씩 추가합니다. 체력 300, 점수 40, 20초마다 한 마리.
다음 장 — 06. 무기 4종 — 투사체와 이펙트도 엔티티다: 반격할 차례입니다. 번개, 냉기, 악취, 슬라임 — 성격이 전혀 다른 무기 넷을 만듭니다.