Projext/Assets/02_Scripts/Enemy/BossAI/BossSummonSystem.cs

291 lines
21 KiB
C#
Raw Normal View History

using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을
using UnityEngine.AI; // NavMesh 기능을 불러올거에요 -> NavMesh.SamplePosition을
using System.Collections; // 코루틴 기능을 불러올거에요 -> IEnumerator를
using System.Collections.Generic; // 리스트 기능을 불러올거에요 -> List<T>를
// ══════════════════════════════════════════════════════════
// 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<SummonWave> summonWaves = new List<SummonWave>(); // 리스트를 선언할거에요 -> 소환 웨이브 목록을
[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<GameObject> _spawnedMonsters = new List<GameObject>(); // 리스트를 선언할거에요 -> 소환된 몬스터 참조를 (추적용)
private Transform _bossTransform; // 변수를 선언할거에요 -> 보스 Transform 캐시를
private bool _initialized = false; // 변수를 선언할거에요 -> 초기화 완료 여부를
// ══════════════════════════════════════════════════════════
// 공개 속성 — 현재 살아있는 소환 몬스터 수
// ══════════════════════════════════════════════════════════
/// <summary>현재 살아있는 소환 몬스터 수</summary> // 설명할거에요 -> 살아있는 소환몹 수 속성을
public int AliveCount // 속성을 정의할거에요 -> 살아있는 소환 몬스터 수를
{
get // 읽을거에요 -> 값을
{
_spawnedMonsters.RemoveAll(m => m == null); // 제거할거에요 -> 파괴된(null) 참조를
return _spawnedMonsters.Count; // 반환할거에요 -> 살아있는 수를
}
}
// ══════════════════════════════════════════════════════════
// 이벤트 — 소환/전멸 시 외부 시스템에 알림
// ══════════════════════════════════════════════════════════
/// <summary>소환 웨이브 발동 시 호출 (int = 웨이브 인덱스)</summary> // 설명할거에요 -> 소환 이벤트를
public event System.Action<int> OnWaveTriggered; // 이벤트를 선언할거에요 -> 웨이브 발동 콜백을
/// <summary>소환된 몬스터가 모두 죽었을 때 호출</summary> // 설명할거에요 -> 전멸 이벤트를
public event System.Action OnAllSummonsDefeated; // 이벤트를 선언할거에요 -> 전멸 콜백을
// ══════════════════════════════════════════════════════════
// 초기화
// ══════════════════════════════════════════════════════════
private void Awake() // 함수를 실행할거에요 -> 컴포넌트 초기화를
{
_bossTransform = transform; // 캐싱할거에요 -> 보스 Transform을
_initialized = true; // 설정할거에요 -> 초기화 완료를
Debug.Log($"[BossSummon] Awake! 웨이브 수={summonWaves.Count} initialized={_initialized}"); // 로그를 찍을거에요 -> 초기화 상태 확인용
}
/// <summary>전투 시작 시 호출 — 모든 웨이브 상태를 초기화해요</summary> // 설명할거에요 -> 전투 초기화 함수를
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에서 매번 호출
// ══════════════════════════════════════════════════════════
/// <summary>현재 HP 비율을 확인하고, 임계값 이하면 소환을 발동해요</summary> // 설명할거에요 -> 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); // 그릴거에요 -> 최소 거리 구체를
}
}