291 lines
21 KiB
C#
291 lines
21 KiB
C#
|
|
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); // 그릴거에요 -> 최소 거리 구체를
|
||
|
|
}
|
||
|
|
}
|