using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을 using UnityEngine.AI; // NavMesh 기능을 불러올거에요 -> NavMesh.SamplePosition을 using System.Collections; // 코루틴 기능을 불러올거에요 -> IEnumerator를 using System.Collections.Generic; // 리스트 기능을 불러올거에요 -> List를 // ══════════════════════════════════════════════════════════ // BossSummonSystem — HP 구간 트리거 기반 잡몹 소환 // // [설계 원칙] // ① HP가 특정 % 이하로 떨어지면 1회만 소환 (중복 방지) // ② 보스는 소환 중에도 계속 공격 가능 (보스 행동 차단 안 함) // ③ 소환 위치는 보스 주변 NavMesh 위 랜덤 (벽 안에 스폰 방지) // ④ Inspector에서 프리팹, 수량, HP 임계값을 자유롭게 설정 가능 // ⑤ 소환된 잡몹 전멸 시 선택적 이벤트 발동 (보스 버프 해제 등) // // [사용법] // 보스 오브젝트에 컴포넌트 추가 → Inspector에서 설정 // NorcielBoss.cs에서 TakeDamage 시 CheckSummon() 호출 // ══════════════════════════════════════════════════════════ public class BossSummonSystem : MonoBehaviour // 클래스를 정의할거에요 -> 보스 잡몹 소환 시스템을 { // ══════════════════════════════════════════════════════════ // 소환 단계 데이터 — Inspector에서 설정 // ══════════════════════════════════════════════════════════ [System.Serializable] // 직렬화할거에요 -> Inspector에서 편집 가능하게 public class SummonWave // 클래스를 정의할거에요 -> 소환 웨이브 1개의 설정을 { [Tooltip("이 HP 비율 이하에서 발동 (0.7 = 70%)")] // 설명할거에요 -> HP 임계값 설명을 [Range(0f, 1f)] // 범위를 제한할거에요 -> 0~1 사이로 public float hpThreshold = 0.7f; // 변수를 선언할거에요 -> HP 임계값을 (기본 70%) [Tooltip("소환할 몬스터 프리팹 (비어있으면 이 웨이브 건너뜀)")] // 설명할거에요 -> 프리팹 설명을 public GameObject monsterPrefab; // 변수를 선언할거에요 -> 소환할 몬스터 프리팹을 [Tooltip("한 번에 소환할 마릿수")] // 설명할거에요 -> 소환 수량 설명을 [Range(1, 10)] // 범위를 제한할거에요 -> 1~10마리 public int spawnCount = 3; // 변수를 선언할거에요 -> 소환 마릿수를 [HideInInspector] // 숨길거에요 -> Inspector에서 (코드가 관리) public bool triggered = false; // 변수를 선언할거에요 -> 이미 발동 여부를 (중복 방지) } // ══════════════════════════════════════════════════════════ // Inspector 설정 // ══════════════════════════════════════════════════════════ [Header("=== 소환 웨이브 설정 ===")] [Tooltip("HP 비율별 소환 웨이브 목록\n" + "예: [0.7, 3마리], [0.4, 4마리], [0.2, 5마리]")] // 설명할거에요 -> 웨이브 설명을 [SerializeField] private List summonWaves = new List(); // 리스트를 선언할거에요 -> 소환 웨이브 목록을 [Header("=== 소환 위치 설정 ===")] [Tooltip("보스 주변 소환 반경 (m)\n몬스터가 이 반경 안 NavMesh 위에 랜덤 스폰")] // 설명할거에요 -> 소환 반경 설명을 [SerializeField] private float spawnRadius = 8f; // 변수를 선언할거에요 -> 소환 반경을 [Tooltip("NavMesh 샘플링 최대 탐색 거리 (m)\n높으면 먼 NavMesh도 찾지만 느려질 수 있음")] // 설명할거에요 -> NavMesh 탐색 거리 설명을 [SerializeField] private float navMeshSampleRange = 10f; // 변수를 선언할거에요 -> NavMesh 탐색 범위를 [Tooltip("소환 시 보스로부터 최소 거리 (m)\n보스 바로 위에 겹쳐 스폰되는 것 방지")] // 설명할거에요 -> 최소 거리 설명을 [SerializeField] private float minSpawnDistance = 3f; // 변수를 선언할거에요 -> 최소 소환 거리를 [Header("=== 소환 연출 ===")] [Tooltip("소환 시 각 몬스터 사이 딜레이 (초)\n0이면 동시 스폰, 0.2~0.5면 순차 스폰 연출")] // 설명할거에요 -> 스폰 딜레이 설명을 [SerializeField] private float spawnDelay = 0.3f; // 변수를 선언할거에요 -> 몬스터 간 스폰 딜레이를 [Tooltip("소환 시 이펙트 프리팹 (없으면 생략)\n각 몬스터 스폰 위치에 1회 재생")] // 설명할거에요 -> 이펙트 설명을 [SerializeField] private GameObject spawnEffectPrefab; // 변수를 선언할거에요 -> 소환 이펙트 프리팹을 [Tooltip("소환 이펙트 자동 삭제 시간 (초)")] // 설명할거에요 -> 이펙트 삭제 시간 설명을 [SerializeField] private float effectLifetime = 2f; // 변수를 선언할거에요 -> 이펙트 자동 삭제 시간을 // ══════════════════════════════════════════════════════════ // 런타임 추적 // ══════════════════════════════════════════════════════════ private List _spawnedMonsters = new List(); // 리스트를 선언할거에요 -> 소환된 몬스터 참조를 (추적용) private Transform _bossTransform; // 변수를 선언할거에요 -> 보스 Transform 캐시를 private bool _initialized = false; // 변수를 선언할거에요 -> 초기화 완료 여부를 // ══════════════════════════════════════════════════════════ // 공개 속성 — 현재 살아있는 소환 몬스터 수 // ══════════════════════════════════════════════════════════ /// 현재 살아있는 소환 몬스터 수 // 설명할거에요 -> 살아있는 소환몹 수 속성을 public int AliveCount // 속성을 정의할거에요 -> 살아있는 소환 몬스터 수를 { get // 읽을거에요 -> 값을 { _spawnedMonsters.RemoveAll(m => m == null); // 제거할거에요 -> 파괴된(null) 참조를 return _spawnedMonsters.Count; // 반환할거에요 -> 살아있는 수를 } } // ══════════════════════════════════════════════════════════ // 이벤트 — 소환/전멸 시 외부 시스템에 알림 // ══════════════════════════════════════════════════════════ /// 소환 웨이브 발동 시 호출 (int = 웨이브 인덱스) // 설명할거에요 -> 소환 이벤트를 public event System.Action OnWaveTriggered; // 이벤트를 선언할거에요 -> 웨이브 발동 콜백을 /// 소환된 몬스터가 모두 죽었을 때 호출 // 설명할거에요 -> 전멸 이벤트를 public event System.Action OnAllSummonsDefeated; // 이벤트를 선언할거에요 -> 전멸 콜백을 // ══════════════════════════════════════════════════════════ // 초기화 // ══════════════════════════════════════════════════════════ private void Awake() // 함수를 실행할거에요 -> 컴포넌트 초기화를 { _bossTransform = transform; // 캐싱할거에요 -> 보스 Transform을 _initialized = true; // 설정할거에요 -> 초기화 완료를 Debug.Log($"[BossSummon] Awake! 웨이브 수={summonWaves.Count} initialized={_initialized}"); // 로그를 찍을거에요 -> 초기화 상태 확인용 } /// 전투 시작 시 호출 — 모든 웨이브 상태를 초기화해요 // 설명할거에요 -> 전투 초기화 함수를 public void InitializeBattle() // 함수를 정의할거에요 -> 전투 시작 시 소환 상태를 리셋하는 { foreach (var wave in summonWaves) // 반복할거에요 -> 모든 웨이브를 wave.triggered = false; // 초기화할거에요 -> 발동 플래그를 (재전투 시 다시 발동 가능) // ── 기존 소환 몬스터 정리 (재전투 시) ────────────── foreach (var mob in _spawnedMonsters) // 반복할거에요 -> 모든 소환 몬스터를 { if (mob != null) Destroy(mob); // 삭제할거에요 -> 남아있는 몬스터를 } _spawnedMonsters.Clear(); // 비울거에요 -> 추적 리스트를 } // ══════════════════════════════════════════════════════════ // HP 체크 — TakeDamage에서 매번 호출 // ══════════════════════════════════════════════════════════ /// 현재 HP 비율을 확인하고, 임계값 이하면 소환을 발동해요 // 설명할거에요 -> HP 기반 소환 체크 함수를 public void CheckSummon(float currentHp, float maxHp) // 함수를 정의할거에요 -> HP 비율로 소환 발동을 판단하는 { if (!_initialized) // 조건이 맞으면 실행할거에요 -> 초기화 안 됐으면 { _bossTransform = transform; // 안전장치: Awake 누락 시 수동 초기화 _initialized = true; // 설정할거에요 -> 강제 초기화를 Debug.LogWarning("[BossSummon] CheckSummon에서 강제 초기화! (Awake 미실행)"); // 경고를 찍을거에요 } if (maxHp <= 0f) return; // 중단할거에요 -> maxHP 유효하지 않으면 float ratio = currentHp / maxHp; // 계산할거에요 -> 현재 HP 비율을 (0.0~1.0) for (int i = 0; i < summonWaves.Count; i++) // 반복할거에요 -> 모든 웨이브를 { SummonWave wave = summonWaves[i]; // 가져올거에요 -> 현재 웨이브를 if (wave.triggered) continue; // 건너뛸거에요 -> 이미 발동된 웨이브는 if (wave.monsterPrefab == null) // 조건이 맞으면 실행할거에요 -> 프리팹 없으면 { Debug.LogWarning($"[BossSummon] 웨이브 #{i} 프리팹 NULL → 건너뜀"); // 경고를 찍을거에요 wave.triggered = true; // 표시할거에요 -> 다시 체크 안 하도록 continue; // 건너뛸거에요 } if (ratio > wave.hpThreshold) continue; // 건너뛸거에요 -> 아직 HP가 임계값 위면 // ── 소환 발동! ──────────────────────────────── wave.triggered = true; // 표시할거에요 -> 발동 완료를 (중복 방지) int logCount = Mathf.Max(wave.spawnCount, 1); // 계산할거에요 -> 실제 소환될 수량을 (0이하 방어) Debug.Log($"[BossSummon] 웨이브 #{i} 발동! HP={ratio * 100f:F0}% ≤ {wave.hpThreshold * 100f:F0}% 몬스터={logCount}마리(원본={wave.spawnCount}) 프리팹={wave.monsterPrefab.name}"); // 로그를 찍을거에요 StartCoroutine(SpawnWaveCoroutine(wave, i)); // 시작할거에요 -> 소환 코루틴을 OnWaveTriggered?.Invoke(i); // 호출할거에요 -> 웨이브 발동 이벤트를 } } // ══════════════════════════════════════════════════════════ // 소환 코루틴 — 순차 스폰 + 이펙트 // ══════════════════════════════════════════════════════════ private IEnumerator SpawnWaveCoroutine(SummonWave wave, int waveIndex) // 코루틴을 정의할거에요 -> 웨이브 소환을 순차 실행하는 { // ── [방어] spawnCount가 0 이하면 강제 보정 ───────────── // [Range(1,10)] 어트리뷰트는 Inspector UI만 제한하고, // 이미 직렬화된 값이 0이면 런타임에서 그대로 0을 반환해요. // Mathf.Max로 최소 1마리를 보장해서 "0마리 소환" 버그를 방지해요. int actualCount = Mathf.Max(wave.spawnCount, 1); // 보정할거에요 -> 최소 1마리를 보장 if (wave.spawnCount != actualCount) // 조건이 맞으면 실행할거에요 -> 보정이 발생했으면 { Debug.LogWarning($"[BossSummon] 웨이브 #{waveIndex} spawnCount={wave.spawnCount} → {actualCount}로 보정! (Inspector에서 값 재설정 권장)"); // 경고를 찍을거에요 -> 보정 사실을 } int spawned = 0; // 초기화할거에요 -> 소환 완료 카운터를 for (int i = 0; i < actualCount; i++) // 반복할거에요 -> 보정된 소환 수량만큼 { // ── 보스 주변 NavMesh 위 랜덤 위치 계산 ───────── Vector3 spawnPos = GetRandomNavMeshPosition(); // 계산할거에요 -> 소환 위치를 if (spawnPos == Vector3.zero) // 조건이 맞으면 실행할거에요 -> 유효한 위치 못 찾았으면 { Debug.LogWarning($"[Boss] 소환 위치 찾기 실패! (#{i})"); // 경고를 찍을거에요 continue; // 건너뛸거에요 -> 이 몬스터를 } // ── 소환 이펙트 재생 ───────────────────────── if (spawnEffectPrefab != null) // 조건이 맞으면 실행할거에요 -> 이펙트 프리팹 있으면 { GameObject fx = Instantiate(spawnEffectPrefab, spawnPos, Quaternion.identity); // 생성할거에요 -> 소환 이펙트를 Destroy(fx, effectLifetime); // 예약할거에요 -> 이펙트 자동 삭제를 } // ── 몬스터 생성 ────────────────────────────── GameObject mob = Instantiate(wave.monsterPrefab, spawnPos, Quaternion.identity); // 생성할거에요 -> 몬스터를 NavMesh 위에 _spawnedMonsters.Add(mob); // 추가할거에요 -> 추적 리스트에 spawned++; // 증가시킬거에요 -> 소환 카운터를 Debug.Log($"[Boss] 소환! 웨이브#{waveIndex} #{spawned}/{actualCount} 위치={spawnPos}"); // 로그를 찍을거에요 // ── 순차 스폰 딜레이 (연출용) ───────────────── if (spawnDelay > 0f && i < actualCount - 1) // 조건이 맞으면 실행할거에요 -> 마지막이 아니면 yield return new WaitForSeconds(spawnDelay); // 기다릴거에요 -> 스폰 딜레이만큼 } Debug.Log($"[Boss] 웨이브#{waveIndex} 소환 완료! 총 {spawned}마리"); // 로그를 찍을거에요 // ── 전멸 감시 시작 ───────────────────────────────── StartCoroutine(MonitorAliveCoroutine()); // 시작할거에요 -> 전멸 감시 코루틴을 } // ══════════════════════════════════════════════════════════ // 전멸 감시 — 소환 몬스터가 모두 죽었는지 체크 // ══════════════════════════════════════════════════════════ private bool _monitoring = false; // 변수를 선언할거에요 -> 감시 중복 방지 플래그를 private IEnumerator MonitorAliveCoroutine() // 코루틴을 정의할거에요 -> 소환 몬스터 전멸을 감시하는 { if (_monitoring) yield break; // 중단할거에요 -> 이미 감시 중이면 (중복 방지) _monitoring = true; // 설정할거에요 -> 감시 시작을 // ── 1초 간격으로 살아있는 몬스터 수 체크 ────────── while (AliveCount > 0) // 반복할거에요 -> 살아있는 몬스터가 있는 동안 { yield return new WaitForSeconds(1f); // 기다릴거에요 -> 1초를 (성능 절약) } // ── 전멸! ──────────────────────────────────────── _monitoring = false; // 해제할거에요 -> 감시 플래그를 Debug.Log("[Boss] 소환 몬스터 전멸!"); // 로그를 찍을거에요 OnAllSummonsDefeated?.Invoke(); // 호출할거에요 -> 전멸 이벤트를 } // ══════════════════════════════════════════════════════════ // NavMesh 위 랜덤 위치 계산 // ══════════════════════════════════════════════════════════ private Vector3 GetRandomNavMeshPosition() // 함수를 정의할거에요 -> 보스 주변 NavMesh 위 랜덤 위치를 반환하는 { // ── 최대 10회 시도 (유효한 NavMesh 위치를 못 찾으면 실패) ── for (int attempt = 0; attempt < 10; attempt++) // 반복할거에요 -> 최대 10번 시도 { // ── 보스 주변 랜덤 오프셋 계산 ──────────────── Vector2 randomCircle = Random.insideUnitCircle * spawnRadius; // 계산할거에요 -> XZ 평면 랜덤 위치를 Vector3 randomOffset = new Vector3(randomCircle.x, 0f, randomCircle.y); // 변환할거에요 -> 3D 오프셋으로 Vector3 candidatePos = _bossTransform.position + randomOffset; // 계산할거에요 -> 후보 위치를 // ── 최소 거리 체크 (보스와 너무 가까우면 재시도) ── float distFromBoss = Vector3.Distance(candidatePos, _bossTransform.position); // 계산할거에요 -> 보스로부터 거리를 if (distFromBoss < minSpawnDistance) continue; // 건너뛸거에요 -> 너무 가까우면 // ── NavMesh 위 가장 가까운 유효 위치 검색 ──────── NavMeshHit hit; // 선언할거에요 -> NavMesh 히트 결과를 if (NavMesh.SamplePosition(candidatePos, out hit, navMeshSampleRange, NavMesh.AllAreas)) // 조건이 맞으면 실행할거에요 -> NavMesh 위치 찾았으면 { return hit.position; // 반환할거에요 -> NavMesh 위 유효한 위치를 } } return Vector3.zero; // 반환할거에요 -> 실패 (유효한 위치를 못 찾음) } // ══════════════════════════════════════════════════════════ // 기즈모 — 씬 뷰에서 소환 반경 시각화 // ══════════════════════════════════════════════════════════ private void OnDrawGizmosSelected() // 함수를 정의할거에요 -> 씬 뷰 기즈모를 그리는 { Gizmos.color = new Color(1f, 0.5f, 0f, 0.3f); // 설정할거에요 -> 오렌지색 반투명을 Gizmos.DrawWireSphere(transform.position, spawnRadius); // 그릴거에요 -> 소환 반경 구체를 Gizmos.color = new Color(1f, 0f, 0f, 0.3f); // 설정할거에요 -> 빨간색 반투명을 Gizmos.DrawWireSphere(transform.position, minSpawnDistance); // 그릴거에요 -> 최소 거리 구체를 } }