# ToyboxNightmare - 서바이벌 게임 루프 구현 작업 내역
---
## 1. 아키텍처 개요
이 프로젝트는 **GameFramework** (오픈소스 Unity 프레임워크)를 기반으로 한다.
프레임워크가 제공하는 핵심 시스템들을 이해해야 이번 작업 내용을 파악할 수 있다.
### 1-1. Procedure (절차/씬 상태 머신)
GameFramework의 **Procedure**는 게임의 실행 흐름을 FSM(유한 상태 머신)으로 관리하는 시스템이다.
`ProcedureBase`를 상속해 씬 전환, 로딩, 게임플레이 등 각 "단계"를 하나의 Procedure 클래스로 표현한다.
```
ProcedureBase
├─ OnInit(procedureOwner) // 한 번만 실행, 초기화
├─ OnEnter(procedureOwner) // 이 Procedure 진입 시
├─ OnUpdate(procedureOwner, elapseSeconds, realElapseSeconds) // 매 프레임
├─ OnLeave(procedureOwner, isShutdown) // 이 Procedure 떠날 때
└─ OnDestroy(procedureOwner) // 파괴 시
```
**이번 작업 - `ProcedureMain.cs`**
`ProcedureMain`은 실제 게임 플레이 단계를 담당하는 Procedure이다.
기존에는 껍데기만 있었고, 이번에 `SurvivalGame`과 연결했다.
```csharp
public class ProcedureMain : ProcedureBase
{
private SurvivalGame mGame = null;
protected override void OnEnter(ProcedureOwner procedureOwner)
{
mGame = new SurvivalGame();
mGame.Initialize(); // 게임 시작
}
protected override void OnUpdate(...)
{
mGame?.Update(elapseSeconds, realElapseSeconds); // 매 프레임 전달
}
protected override void OnLeave(...)
{
mGame?.Shutdown(); // 게임 종료 및 정리
mGame = null;
}
}
```
> Procedure는 게임의 "언제"를 담당한다. 게임이 시작되면 `ProcedureMain`에 진입하고,
> `SurvivalGame`의 생명주기 전체가 이 Procedure 안에서 돌아간다.
---
### 1-2. GameMode & GameBase (게임 모드 추상화)
**`GameMode`** 는 게임의 종류를 나타내는 enum이다. 현재는 `Survival` 하나만 존재한다.
```csharp
public enum GameMode : byte
{
Survival,
}
```
**`GameBase`** 는 모든 게임 모드의 공통 인터페이스(추상 클래스)다.
Procedure는 `GameBase`만 알고, 실제 게임 종류(Survival, etc.)는 GameBase 구현체가 담당한다.
```
GameBase (추상)
├─ abstract GameMode GameMode // 어떤 모드인지
├─ bool GameOver // 게임 종료 여부
├─ Initialize()
├─ Shutdown()
├─ Update(elapseSeconds, realElapseSeconds)
├─ OnShowEntitySuccess(...) // 엔티티 표시 성공 이벤트 (virtual)
└─ OnShowEntityFailure(...) // 엔티티 표시 실패 이벤트 (virtual)
```
**이번 작업 - `SurvivalGame.cs`**
`GameBase`를 구현한 서바이벌 모드 게임 클래스.
기존에는 플레이어 스폰 코드조차 전부 주석 처리된 빈 껍데기였다.
이번에 서바이벌 게임의 핵심 루프 전체를 구현했다.
| 항목 | 값 |
|------|----|
| 플레이어 스폰 위치 기준 적 스폰 반경 | 15f |
| 최대 동시 적 수 | 50마리 |
| 초기 적 스폰 간격 | 3초 |
| 최소 스폰 간격 (시간이 지날수록 짧아짐) | 0.5초 |
| 간격 감소 공식 | `Max(0.5, 3.0 - 생존시간 × 0.05)` |
```csharp
// Initialize: 이벤트 5개 구독, LevelSystem/UpgradeSystem 생성, 플레이어 스폰
public override void Initialize()
{
Instance = this;
LevelSystem = new LevelSystem();
UpgradeSystem = new UpgradeSystem();
events.Subscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
events.Subscribe(ShowEntityFailureEventArgs.EventId, OnShowEntityFailure);
events.Subscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
events.Subscribe(GameOverEventArgs.EventId, OnGameOver);
events.Subscribe(LevelUpEventArgs.EventId, OnLevelUp);
SpawnPlayer();
}
// Update: 생존시간 누적, 스폰 간격 점진적 단축, 적 스폰
public override void Update(float elapseSeconds, float realElapseSeconds)
{
mSurvivalTime += elapseSeconds;
mSpawnInterval = Mathf.Max(0.5f, InitSpawnInterval - mSurvivalTime * 0.05f);
mSpawnTimer += elapseSeconds;
if (mSpawnTimer >= mSpawnInterval && EnemyCount < MaxEnemyCount)
{
mSpawnTimer = 0f;
SpawnEnemy();
}
}
```
**이벤트 처리**
| 이벤트 | 처리 내용 |
|--------|-----------|
| `ShowEntitySuccessEventArgs` | Player 타입이면 무기 부착 + UpgradeSystem 초기화 |
| `ShowEntityFailureEventArgs` | 경고 로그 출력 |
| `HideEntityCompleteEventArgs` | `mEnemyIds`에서 ID 제거 (적 수 카운트 동기화) |
| `GameOverEventArgs` | `GameOver = true`, 생존 시간 로그 |
| `LevelUpEventArgs` | `UpgradeSystem.PickRandom(3)` → UpgradeForm UI 열기 |
---
## 2. Entity 시스템
GameFramework의 **Entity** 시스템은 게임 오브젝트를 오브젝트 풀과 함께 관리한다.
`EntityComponent.ShowEntity()` / `HideEntity()`로 스폰/반환하며, 직접 Destroy를 쓰지 않는다.
### 2-1. EntityData 계층 구조 (데이터 컨테이너)
엔티티를 스폰할 때 `userData`로 전달되는 데이터 클래스들이다.
엔티티 로직(동작)과 데이터(수치)를 분리하는 역할을 한다.
```
EntityData (추상) ─ 기존
├─ Id : int (EntitySerialId로 생성한 고유 ID)
├─ TypeId : int (DataTable RowId 예약, 현재 1 고정)
├─ Position : Vector3
└─ Rotation : Quaternion
├─ TargetableObjectData (추상) ─ 기존
│ ├─ HitPoints : int (현재 HP, 런타임에 변경됨)
│ ├─ MaxHitPoints : int (추상, 서브클래스에서 정의)
│ └─ HitPointRatio : float (HitPoints / MaxHitPoints)
│
│ ├─ [신규] PlayerData
│ │ ├─ MaxHitPoints = 100
│ │ ├─ MoveSpeed = 5f (업그레이드로 증가 가능)
│ │ └─ 생성 시 HitPoints = MaxHitPoints로 초기화
│ │
│ └─ [신규] EnemyData
│ ├─ MaxHitPoints = 30
│ ├─ MoveSpeed = 2f
│ ├─ AttackDamage = 10
│ └─ ExpReward = 5 (사망 시 경험치 보석에 전달)
│
├─ [신규] ExpGemData
│ ├─ ExpAmount = 5 (수집 시 LevelSystem에 전달)
│ └─ MoveSpeed = 4f (자석 이동 속도)
│
└─ [신규] ProjectileData
├─ Damage : int
├─ Speed : float
├─ Lifetime : float
└─ Direction : Vector3
```
### 2-2. EntityLogic 계층 구조 (동작 로직)
엔티티의 실제 행동을 담당하는 클래스들. `EntityLogic`을 상속하며 다음 생명주기를 가진다.
```
OnInit - 오브젝트 풀 생성 시 한 번만 호출
OnShow - 풀에서 꺼내 활성화될 때 (userData 수신)
OnUpdate - 매 프레임
OnHide - 풀로 반환될 때
```
```
EntityLogic (GF 제공)
└─ TargetableObject (추상) ─ 기존 수정
├─ IsDead : bool (HitPoints <= 0)
├─ ApplyDamage(attacker, damage) ← [수정] HP 0 시 OnDead() 실제 호출
├─ OnDead(attacker) ← [수정] HideEntity() 실제 호출
│
├─ [신규] Player
└─ [신규] Enemy
```
#### `TargetableObject.cs` 수정 내용
기존 코드에서 HP 0 판정과 `OnDead()` 호출이 모두 주석 처리되어 있었다.
이번에 주석을 해제하고 올바른 코드로 교체했다.
```csharp
// [수정 전] 주석 처리
//if (mTargetableObjectData.HitPoints <= 0)
// OnDead(attacker);
// [수정 후] 실제 동작
if (mTargetableObjectData.HitPoints <= 0)
OnDead(attacker);
// [수정 전] 주석 처리
//protected virtual void OnDead(Entity attacker)
// GameEntry.Entity.HideEntity(Entity);
// [수정 후] 올바른 컴포넌트 접근 방식으로 수정
protected virtual void OnDead(Entity attacker)
{
GameEntry.GetComponent<EntityComponent>().HideEntity(Entity);
}
```
---
### 2-3. 신규 EntityLogic 상세
#### `Player.cs`
플레이어 캐릭터 로직. `TargetableObject`를 상속한다.
- **싱글턴** : `Player.Instance`로 적/보석이 플레이어 위치를 참조한다.
- **이동** : `Input.GetAxisRaw("Horizontal/Vertical")`로 WASD 이동. `MoveSpeed`는 PlayerData에서 읽는다.
- **무기 부착** : `AttachWeapon<T>()` - 제네릭으로 무기 컴포넌트를 `AddComponent` 후 초기화.
- **외부 호출 API**
- `TakeDamage(attacker, damage)` - 적이 공격할 때 사용
- `HealHitPoints(amount)` - 업그레이드 "생명력 회복" 시 사용
- `UpgradeMoveSpeed(amount)` - 업그레이드 "이동속도 증가" 시 사용
- **사망 처리** : `OnDead()` 오버라이드 → `HideEntity` 대신 `GameOverEventArgs` 발행.
플레이어는 사망 연출을 위해 바로 숨기지 않는다.
```csharp
protected override void OnDead(Entity attacker)
{
Log.Info("Player is dead. Game Over!");
GameEntry.GetComponent<EventComponent>().Fire(this, GameOverEventArgs.Create());
// HideEntity 호출 안 함 - 사망 연출 유지
}
```
#### `Enemy.cs`
적 캐릭터 로직. `TargetableObject`를 상속한다.
- **추적** : 매 프레임 `Player.Instance` 위치로 이동 (MoveSpeed = 2f)
- **공격** : 공격 사거리(1.5f) 이내 진입 시 이동 멈추고, 1초 간격으로 플레이어에게 데미지(10)
- **사망** : `SurvivalGame.Instance.SpawnExpGem(위치, ExpReward)` 호출 후 `base.OnDead()` (HideEntity)
```csharp
protected override void OnDead(Entity attacker)
{
SurvivalGame.Instance?.SpawnExpGem(CachedTransform.position, mEnemyData.ExpReward);
base.OnDead(attacker); // → EntityComponent.HideEntity()
}
```
#### `ExpGem.cs`
경험치 보석 로직. `EntityLogic`을 직접 상속한다. (HP 없음)
- **자석 이동** : 플레이어가 반경 5f 이내에 들어오면 MoveSpeed(4f)로 끌려감
- **수집** : 반경 0.5f 이내 도달 시 `LevelSystem.AddExp(ExpAmount)` 호출 후 HideEntity
#### `Projectile.cs`
투사체 로직. `EntityLogic`을 직접 상속한다.
- **이동** : `OnShow` 시 받은 방향(Direction)으로 Speed만큼 직선 이동
- **수명** : `Lifetime` 경과 시 자동 HideEntity
- **충돌** : `OnTriggerEnter`에서 Enemy 감지 → `enemy.ApplyDamage()` 후 자신 HideEntity
---
## 3. 게임 시스템 (Game/)
### 3-1. LevelSystem
경험치와 레벨 상승을 관리한다. `SurvivalGame`이 인스턴스를 소유한다.
```
LevelSystem
├─ Level : int (초기값 1)
├─ CurrentExp : int
└─ RequiredExp: int (= Level × 100. 1레벨→100, 2레벨→200, ...)
```
`AddExp(amount)` 호출 시:
1. `CurrentExp += amount`
2. `CurrentExp >= RequiredExp`이면 레벨업 반복 처리 (while 루프, 한 번에 여러 레벨도 가능)
3. 레벨업마다 `LevelUpEventArgs.Create(Level)`을 이벤트 시스템에 Fire
### 3-2. UpgradeSystem
레벨업 시 제공할 업그레이드 풀을 관리한다. `SurvivalGame`이 소유한다.
**`Initialize(player)`** - Player 스폰 직후 호출. 플레이어 참조가 필요한 업그레이드를 클로저로 묶는다.
**기본 업그레이드 풀 (6종)**
| 이름 | 설명 | 효과 |
|------|------|------|
| 투사체 강화 | 투사체 데미지 +10 | `weapon.Damage += 10` |
| 속사 | 공격 간격 20% 감소 | `weapon.AttackInterval *= 0.8f` |
| 투사체 가속 | 투사체 속도 +3 | `weapon.Speed += 3f` |
| 이동 속도 증가 | 플레이어 이동 속도 +1 | `player.UpgradeMoveSpeed(1f)` |
| 범위 공격 추가 | AreaWeapon 장착 (중복 방지) | `player.AttachWeapon<AreaWeapon>()` |
| 생명력 회복 | HP +30 즉시 회복 | `player.HealHitPoints(30)` |
**`PickRandom(count)`** - 풀에서 중복 없이 랜덤하게 count개 선택. Fisher-Yates 방식의 인덱스 셔플로 구현.
### 3-3. UpgradeDefinition
업그레이드 하나를 나타내는 데이터 객체.
```csharp
public class UpgradeDefinition
{
public string Name { get; }
public string Description { get; }
private readonly Action mApply;
public void Apply() => mApply?.Invoke();
}
```
UI가 이름/설명을 표시하고, 플레이어가 선택하면 `Apply()`를 호출해 효과를 실행한다.
### 3-4. 커스텀 이벤트 (GameEventArgs)
GameFramework의 이벤트 시스템을 사용하기 위해 `GameEventArgs`를 상속해 커스텀 이벤트를 정의했다.
`ReferencePool.Acquire<T>()`로 객체 풀링을 활용한다.
**`GameOverEventArgs`**
- 데이터 없음. 플레이어 사망 시 Fire.
- `EventId = typeof(GameOverEventArgs).GetHashCode()`
**`LevelUpEventArgs`**
- `Level : int` 를 담아 Fire.
- `Clear()` 시 Level = 0 리셋 (풀 반환 시 초기화)
---
## 4. 무기 시스템 (Weapon/)
무기는 `Player` GameObject에 `AddComponent`로 붙는 MonoBehaviour 컴포넌트이다.
Entity 시스템과 별개로, Unity의 일반 컴포넌트 방식으로 동작한다.
### 4-1. WeaponBase (추상)
```csharp
public abstract class WeaponBase : MonoBehaviour
{
protected Player Owner { get; private set; }
public float AttackInterval { get; set; } // 최소 0.1f 강제
public void Initialize(Player owner) { ... }
private void Update() // Unity Update - 타이머로 Attack() 주기 호출
{
if (Owner == null || Owner.IsDead) return;
mAttackTimer += Time.deltaTime;
if (mAttackTimer >= attackInterval)
{
mAttackTimer = 0f;
Attack();
}
}
protected abstract void Attack();
}
```
> `Initialize` 시 `mAttackTimer = attackInterval`으로 초기화해서, 부착 즉시 첫 공격이 발동된다.
### 4-2. ProjectileWeapon
기본 무기. 플레이어 스폰 직후 자동으로 부착된다.
- **탐색** : `Physics.OverlapSphere(반경 20f)`로 주변 Enemy 전부 검색, 가장 가까운 Enemy 선택
- **발사** : `EntityComponent.ShowEntity()`로 `Projectile` 엔티티 스폰. 방향 벡터와 수치를 `ProjectileData`에 담아 전달
| 기본 수치 | 값 |
|-----------|----|
| 데미지 | 25 |
| 투사체 속도 | 10f |
| 투사체 수명 | 3초 |
| 적 탐색 반경 | 20f |
| 공격 간격 (WeaponBase) | 1초 |
### 4-3. AreaWeapon
레벨업 업그레이드로만 획득 가능한 두 번째 무기.
- **공격** : `Physics.OverlapSphere(반경 3f)`로 주변 Enemy 전부에게 데미지 15 적용
- 탐색과 공격이 동시에 이루어진다. (투사체 없음, 즉발)
- `player.GetComponent<AreaWeapon>()`으로 중복 부착을 방지한다.
---
## 5. UI 시스템 (UI/)
GameFramework의 **UIComponent** 위에 레벨업 선택 화면을 구현했다.
### 5-1. 데이터 흐름
```
LevelSystem.AddExp() → 레벨업
→ LevelUpEventArgs 발행
→ SurvivalGame.OnLevelUp()
→ UpgradeSystem.PickRandom(3) : List<UpgradeDefinition>
→ UpgradeFormData(options) 생성
→ UIComponent.OpenUIForm("UpgradeForm", "Default", formData)
→ UpgradeForm.OnOpen(userData)
```
### 5-2. UpgradeForm
`UIFormLogic`을 상속하는 GameFramework UI 폼.
- `OnOpen` 시 `Time.timeScale = 0f`로 게임 일시 정지
- Inspector에서 연결된 `UpgradeItemUI[]` 배열(3개)에 업그레이드 옵션을 Setup
- 플레이어가 카드 선택 시 `OnSelectUpgrade(index)` → `mOptions[index].Apply()` → `CloseUIForm()`
- `OnClose` 시 `Time.timeScale = 1f`로 게임 재개
### 5-3. UpgradeItemUI
개별 업그레이드 카드 UI 컴포넌트.
- `TextMeshProUGUI` 2개 (이름, 설명) + `Button` 1개
- `Setup(name, description, onClick)` : `UpgradeForm`이 호출해 내용을 동적으로 채운다
- 클릭 리스너는 `Awake`에서 한 번만 등록하고, `mOnClick` Action을 교체하는 방식
---
## 6. Utility (Utility/)
### EntitySerialId
전역 엔티티 ID 생성기. 모든 엔티티 스폰 시 `EntitySerialId.Next()`로 고유 ID를 받는다.
```csharp
public static class EntitySerialId
{
private static int mNext = 0;
public static int Next() => ++mNext;
}
```
> 게임 세션 내에서 단조 증가하는 정수 ID를 보장한다. 씬 재시작 시 0으로 초기화된다.
---
## 7. 전체 게임 루프 흐름
```
[앱 시작]
└─ ProcedureMain.OnEnter()
├─ SurvivalGame 생성
└─ SurvivalGame.Initialize()
├─ 이벤트 5종 구독
├─ LevelSystem, UpgradeSystem 생성
└─ SpawnPlayer()
└─ EntityComponent.ShowEntity(id, typeof(Player), ...)
└─ (비동기 로드 완료 후) ShowEntitySuccessEventArgs 발행
└─ OnShowEntitySuccess()
├─ mPlayer = Player 참조 저장
├─ player.AttachWeapon<ProjectileWeapon>()
└─ UpgradeSystem.Initialize(player)
[매 프레임] ProcedureMain.OnUpdate() → SurvivalGame.Update()
├─ 생존 시간 누적
├─ 스폰 간격 갱신 (시간이 지날수록 빨라짐)
└─ 스폰 타이머 초과 & 적 수 < 50 → SpawnEnemy()
└─ 플레이어 기준 반경 15f 랜덤 위치에 Enemy 스폰
[Player 매 프레임]
└─ WASD 이동
└─ ProjectileWeapon.Update() (Unity Update)
└─ 공격 간격마다 가장 가까운 적 방향으로 Projectile 스폰
[Enemy 매 프레임]
├─ Player.Instance 방향으로 이동
└─ 사거리 1.5f 이내 진입 시 1초마다 player.TakeDamage(10)
└─ TargetableObject.ApplyDamage()
└─ HP <= 0 → OnDead()
├─ (Enemy) ExpGem 스폰 → base.OnDead() → HideEntity
└─ (Player) GameOverEventArgs 발행
└─ SurvivalGame.OnGameOver() → GameOver = true
[ExpGem 매 프레임]
├─ 플레이어 반경 5f 이내 → 자석 이동
└─ 플레이어 반경 0.5f 이내 → LevelSystem.AddExp(5) → HideEntity
└─ CurrentExp >= RequiredExp → 레벨업
└─ LevelUpEventArgs 발행
└─ SurvivalGame.OnLevelUp()
├─ UpgradeSystem.PickRandom(3)
└─ UIComponent.OpenUIForm("UpgradeForm", ...)
├─ Time.timeScale = 0 (게임 일시 정지)
├─ 카드 3개 표시
└─ 선택 시 → Apply() → Time.timeScale = 1 (재개)
[ProcedureMain.OnLeave]
└─ SurvivalGame.Shutdown()
└─ 이벤트 5종 구독 해제, 적 ID Set 초기화, Instance = null
```
---
## 8. 파일 변경 요약
### 수정된 파일 (3개)
| 파일 | 변경 내용 |
|------|-----------|
| `TargetableObject.cs` | HP 0 시 `OnDead()` 실제 호출, `OnDead()` 에서 `HideEntity()` 실제 호출 |
| `SurvivalGame.cs` | 빈 껍데기 → 완전한 서바이벌 게임 루프 구현 |
| `ProcedureMain.cs` | `SurvivalGame` 생성·Update·Shutdown 연결, 불필요한 주석/보일러플레이트 정리 |
### 신규 파일 (26개 + .meta)
| 경로 | 파일 |
|------|------|
| `Entity/EntityData/` | `PlayerData.cs`, `EnemyData.cs`, `ExpGemData.cs`, `ProjectileData.cs` |
| `Entity/EntityLogic/` | `Player.cs`, `Enemy.cs`, `ExpGem.cs`, `Projectile.cs` |
| `Game/` | `LevelSystem.cs`, `UpgradeSystem.cs`, `UpgradeDefinition.cs`, `GameOverEventArgs.cs`, `LevelUpEventArgs.cs` |
| `Weapon/` | `WeaponBase.cs`, `ProjectileWeapon.cs`, `AreaWeapon.cs` |
| `UI/` | `UpgradeForm.cs`, `UpgradeFormData.cs`, `UpgradeItemUI.cs` |
| `Utility/` | `EntitySerialId.cs` |
