using System; using System.Collections.Generic; using System.Threading; using TMPro; using UnityEngine; using UnityEngine.UI; // ============================================================================ // WaveManager // ---------------------------------------------------------------------------- // 시간 제한 웨이브 시스템. 각 웨이브를 순서대로 진행하며, 시간 내 클리어 못 하면 패배. // // 흐름: // StartWaves() → for each wave: // - StartDelay 대기 // - OnWaveStart 발화 // - 적 스폰 시작 (백그라운드 비동기) // - 타이머 시작 // - 매 프레임: 적 다 잡았는지 / 타이머 만료됐는지 체크 // - 클리어 → OnWaveCleared → IntermissionDuration 대기 → 다음 웨이브 // - 타임아웃 → OnDefeat → 살아있는 적 정리 → 종료 // 모든 웨이브 클리어 → OnAllWavesCleared // // UI 연동: 외부 시스템이 4개 이벤트 구독하여 UI/사운드 트리거. // ============================================================================ public class WaveManager : MonoBehaviour { // ─── 웨이브 데이터 ─────────────────────────────────────────────────── [Header("Waves")] [SerializeField] private List _waves = new(); // 순서대로 진행할 웨이브들 [SerializeField] private float _intermissionDuration = 2f; // 웨이브 사이 대기 시간 ("Wave Clear!" 표시 시간) [SerializeField] private bool _startOnAwake = true; // Start에서 자동 시작 // ─── 스폰 위치 설정 (모든 웨이브 공통) ─────────────────────────────── [Header("Spawn Position")] [SerializeField] private Transform[] _spawnPoints; // 비워두면 자기 위치 [SerializeField] private float _spawnRadius = 0f; // 각 spawn point 주변 무작위 분산 [SerializeField] private Transform _enemyParent; // 스폰된 적을 묶을 부모 (Hierarchy 정리) [Header("On Defeat")] [SerializeField] private bool _destroyAliveEnemiesOnDefeat = true; // 패배 시 남은 적 정리 // ─── 외부에서 구독할 이벤트들 (UI/사운드 연동용) ───────────────────── public event Action OnWaveStart; // 웨이브 시작: (index, data) public event Action OnWaveCleared; // 웨이브 클리어: (index) public event Action OnAllWavesCleared; // 모든 웨이브 끝 (승리) public event Action OnDefeat; // 패배: 어느 웨이브에서 실패했는지 // ─── 외부 읽기 전용 상태 (UI 연동용) ──────────────────────────────── public int CurrentWaveIndex { get; private set; } public int TotalWaveCount => _waves != null ? _waves.Count : 0; public WaveData CurrentWave => _waves != null && CurrentWaveIndex >= 0 && CurrentWaveIndex < _waves.Count ? _waves[CurrentWaveIndex] : null; public float TimeRemaining { get; private set; } // 현재 웨이브 남은 시간 public int AliveCount => _aliveEnemies.Count; // 살아있는 적 수 public int RemainingToSpawn { get; private set; } // 아직 스폰 안 한 적 수 public bool IsRunning { get; private set; } // 웨이브 진행 중 public bool IsDefeated { get; private set; } // 패배 상태 public bool IsVictory { get; private set; } // 모든 웨이브 클리어 private readonly List _aliveEnemies = new(); private CancellationTokenSource _waveCts; // 전체 웨이브 진행 취소 토큰 (OnDestroy/StopWaves에서 취소) [SerializeField] private Button BossZoneButton; public TMP_Text WaveText; public TMP_Text GameClearText; private void Start() { OnAllWavesCleared += StageClearEvent; if (_startOnAwake) { _ = Util.RunDelayed(1f,StartWaves,destroyCancellationToken); } } private void OnDestroy() { _waveCts?.Cancel(); _waveCts?.Dispose(); } private async Awaitable WaveUIOn() { try { WaveText.text = $"Wave {CurrentWaveIndex + 1}"; WaveText.gameObject.SetActive(true); await Awaitable.WaitForSecondsAsync(3.0f, destroyCancellationToken); WaveText.gameObject.SetActive(false); } catch (System.OperationCanceledException) { } } // 외부에서 호출해 웨이브 시작 (예: UI 버튼, 트리거). public void StartWaves() { Debug.Log("aaaa"); if (_waves == null || _waves.Count == 0) return; RunAllWaves(); _ = WaveUIOn(); } // 진행 중인 웨이브 강제 중단. 게임 오버/메뉴 진입 등에 사용. public void StopWaves() { _waveCts?.Cancel(); IsRunning = false; } // 모든 웨이브를 순차 실행하는 메인 비동기 루프. // try/catch로 토큰 취소 시 예외 흡수 → OnDestroy 시 깔끔하게 종료. private async void RunAllWaves() { _waveCts?.Cancel(); _waveCts?.Dispose(); _waveCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken); CancellationToken token = _waveCts.Token; IsDefeated = false; IsVictory = false; try { for (CurrentWaveIndex = 0; CurrentWaveIndex < _waves.Count; CurrentWaveIndex++) { WaveData wave = _waves[CurrentWaveIndex]; if (wave == null) continue; if (wave.StartDelay > 0f) await Awaitable.WaitForSecondsAsync(wave.StartDelay, token); OnWaveStart?.Invoke(CurrentWaveIndex, wave); bool cleared = await RunWave(wave, token); if (!cleared) { IsDefeated = true; OnDefeat?.Invoke(CurrentWaveIndex); if (_destroyAliveEnemiesOnDefeat) DestroyAliveEnemies(); return; } OnWaveCleared?.Invoke(CurrentWaveIndex); if (CurrentWaveIndex < _waves.Count - 1 && _intermissionDuration > 0f) await Awaitable.WaitForSecondsAsync(_intermissionDuration, token); } IsVictory = true; OnAllWavesCleared?.Invoke(); } catch (OperationCanceledException) { } } // 한 웨이브를 처리. // 반환값: true = 클리어, false = 타임아웃(패배) // 본 루프는 매 프레임: // 1) 죽은 적 자동 정리 // 2) 모두 스폰 완료 + 살아있는 적 0 → 클리어 // 3) 타이머 0 도달 → 패배 // 4) 타이머 감소 private async Awaitable RunWave(WaveData wave, CancellationToken token) { IsRunning = true; TimeRemaining = wave.TimeLimit; _aliveEnemies.Clear(); // 이번 웨이브에서 스폰할 총 적 수 계산. int totalToSpawn = 0; foreach (var entry in wave.Spawns) if (entry != null) totalToSpawn += Mathf.Max(entry.Count, 0); RemainingToSpawn = totalToSpawn; // 적 스폰은 백그라운드로 흘려보내고, 본 루프는 시간/클리어만 체크. SpawnWaveEnemiesAsync(wave, token); while (true) { token.ThrowIfCancellationRequested(); _aliveEnemies.RemoveAll(e => e == null); // 클리어 조건: 모든 적이 스폰됐고 + 살아있는 적이 0. // (스폰 중에 이미 죽었다고 클리어 인정하면 안 되니까 RemainingToSpawn=0도 같이 체크) if (RemainingToSpawn == 0 && _aliveEnemies.Count == 0) { IsRunning = false; return true; } TimeRemaining -= Time.deltaTime; if (TimeRemaining <= 0f) { IsRunning = false; return false; // 패배 } await Awaitable.NextFrameAsync(token); } } // 적을 SpawnInterval 간격으로 스폰. 비동기로 흘려보내고 본 루프와 병렬 실행. // (스폰 중에도 본 루프의 타이머 카운트다운 + 클리어 체크가 계속 돌아감) private async void SpawnWaveEnemiesAsync(WaveData wave, CancellationToken token) { try { foreach (var entry in wave.Spawns) { if (entry == null || entry.EnemyPrefab == null) { RemainingToSpawn -= entry != null ? entry.Count : 0; continue; } for (int i = 0; i < entry.Count; i++) { token.ThrowIfCancellationRequested(); SpawnEnemy(entry.EnemyPrefab); RemainingToSpawn--; if (wave.SpawnInterval > 0f) await Awaitable.WaitForSecondsAsync(wave.SpawnInterval, token); } } } catch (OperationCanceledException) { } } private void SpawnEnemy(Enemy prefab) { Vector3 position = GetSpawnPosition(); Enemy enemy = Instantiate(prefab, position, Quaternion.identity, _enemyParent); _aliveEnemies.Add(enemy); } private Vector3 GetSpawnPosition() { Vector3 basePos; if (_spawnPoints != null && _spawnPoints.Length > 0) { Transform pick = _spawnPoints[UnityEngine.Random.Range(0, _spawnPoints.Length)]; basePos = pick != null ? pick.position : transform.position; } else { basePos = transform.position; } if (_spawnRadius > 0f) { Vector2 offset = UnityEngine.Random.insideUnitCircle * _spawnRadius; basePos.x += offset.x; basePos.y += offset.y; } return basePos; } private void DestroyAliveEnemies() { for (int i = _aliveEnemies.Count - 1; i >= 0; i--) { if (_aliveEnemies[i] != null) Destroy(_aliveEnemies[i].gameObject); } _aliveEnemies.Clear(); } // 디버그용 OnGUI 표시. 실제 게임 UI는 별도로 구성하고 이건 빠르게 확인용. private void OnGUI() { /* GUIStyle style = new GUIStyle(GUI.skin.box) { fontSize = 22, alignment = TextAnchor.MiddleCenter, richText = true, normal = { textColor = Color.white } }; string text; if (IsVictory) { text = "VICTORY"; } else if (IsDefeated) { text = $"DEFEAT\nWave {CurrentWaveIndex + 1} timed out"; } else if (IsRunning && CurrentWave != null) { string name = string.IsNullOrEmpty(CurrentWave.WaveName) ? $"Wave {CurrentWaveIndex + 1}/{TotalWaveCount}" : CurrentWave.WaveName; text = $"{name}\nTime: {TimeRemaining:F1}s\nEnemies: {AliveCount} (+{RemainingToSpawn} 대기)"; } else { return; } GUI.Box(new Rect(Screen.width / 2f - 180f, 20f, 360f, 110f), text, style); */ } private void OnDrawGizmosSelected() { Color pointColor = Color.cyan; Color radiusColor = new Color(0f, 1f, 1f, 0.25f); if (_spawnPoints != null && _spawnPoints.Length > 0) { foreach (var p in _spawnPoints) { if (p == null) continue; Gizmos.color = pointColor; Gizmos.DrawWireSphere(p.position, 0.3f); if (_spawnRadius > 0f) { Gizmos.color = radiusColor; Gizmos.DrawWireSphere(p.position, _spawnRadius); } } } else { Gizmos.color = pointColor; Gizmos.DrawWireSphere(transform.position, 0.3f); if (_spawnRadius > 0f) { Gizmos.color = radiusColor; Gizmos.DrawWireSphere(transform.position, _spawnRadius); } } } public void StageClearEvent() { BossZoneButton.gameObject.SetActive(true); } public void GameClear() { GameClearText.gameObject.SetActive(true); } }