362 lines
13 KiB
C#
362 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Data;
|
|
using System.Threading;
|
|
using TMPro;
|
|
using UnityEditor.Playables;
|
|
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<WaveData> _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<int, WaveData> OnWaveStart; // 웨이브 시작: (index, data)
|
|
public event Action<int> OnWaveCleared; // 웨이브 클리어: (index)
|
|
public event Action OnAllWavesCleared; // 모든 웨이브 끝 (승리)
|
|
public event Action<int> 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<Enemy> _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) StartWaves();
|
|
}
|
|
|
|
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<bool> 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 = "<color=#88ff88><b>VICTORY</b></color>";
|
|
}
|
|
else if (IsDefeated)
|
|
{
|
|
text = $"<color=#ff8888><b>DEFEAT</b></color>\nWave {CurrentWaveIndex + 1} timed out";
|
|
}
|
|
else if (IsRunning && CurrentWave != null)
|
|
{
|
|
string name = string.IsNullOrEmpty(CurrentWave.WaveName)
|
|
? $"Wave {CurrentWaveIndex + 1}/{TotalWaveCount}"
|
|
: CurrentWave.WaveName;
|
|
|
|
text = $"<b>{name}</b>\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);
|
|
}
|
|
}
|