using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; public enum Result { Perfect, Good, Bad, Miss } public class RhythmManager : MonoBehaviour { [SerializeField] private AudioSource _audioSource; [SerializeField] private RhythmChart _currentChart; [SerializeField] private RhythmNoteSpawner _spawner; [SerializeField] private float _leadTime = 1f; // 노트가 생성돼 판정선까지 흐르는 시간(초) [Header("판정 윈도우 (초, 절대 시간차 기준)")] [SerializeField] private float _perfectWindow = 0.05f; [SerializeField] private float _goodWindow = 0.10f; [SerializeField] private float _badWindow = 0.15f; // 이 밖이면 입력 무시(헛침) [Header("효과음")] [SerializeField] private AudioClip _hitSfx; // 내려칠 때마다 재생 (판정과 무관, 헛침 포함) [Header("오토플레이 (아이템)")] [SerializeField] private bool _autoPlay; // 켜지면 모든 노트를 판정선 도달 순간 자동 Perfect 처리 [SerializeField] private RhythmStick[] _autoPlaySticks; // 인덱스 = Note.Lane. 오토플레이 시 자동으로 휘두를 "자동 전용" 스틱들 (그랩 X) [Header("오토플레이 전환")] [Tooltip("자동 '체크'만 해도 즉시 숨길 수동(그랩) 스틱")] [SerializeField] private GameObject[] _manualSticks; [Tooltip("게임 시작 + 자동일 때만 숨길 실제 손 (체크만 했을 땐 유지 → 시작 버튼 등 조작 가능)")] [SerializeField] private GameObject[] _realHands; [Tooltip("게임 시작 + 자동일 때만 비활성화할 드럼패드 콜라이더")] [SerializeField] private Collider[] _drumPadColliders; // 자동 전용 스틱(손 포함)의 표시/숨김은 위 _autoPlaySticks의 GameObject를 그대로 토글한다. [SerializeField] private GameObject StartButtonObj; [Header("시작 카운트다운 (VR 준비 시간)")] [SerializeField] private float _countdownTime = 5f; // 시작 버튼 후 노래가 실제로 시작되기까지 대기 시간(초) [SerializeField] private AudioClip _countdownBeep; // 매초 카운트 효과음 (5,4,3,2,1) [SerializeField] private AudioClip _countdownGoSfx; // 카운트 끝(GO) 효과음 (선택) // 모든 타이밍의 기준. 오디오 클럭(dspTime) 기반이라 리드인 동안 음수(-leadTime→0)로 흐른다 public float SongTime => (float)(AudioSettings.dspTime - _dspSongStart); private List SongNoteList; private int _nextNoteIndex; // 다음에 소환할 노트 인덱스 private bool _isPlaying; //곡이 재생 중인지 (종료 감지용) private bool _isCountingDown; // 시작 카운트다운 진행 중 여부 (중복 클릭 방지용) private bool _armedApplied; // 반영된 armed 상태 (자동 체크 → 수동 스틱 숨김) private bool _activeApplied; // 반영된 active 상태 (자동+진행 중 → 자동 스틱/손/드럼패드) private double _dspSongStart; // 오디오가 실제 시작되는 dsp 시각 (= SongTime 0 지점) private float _clipLength; // 곡 길이 캐시 (종료 감지용) private Func _songTimeProvider; // 노트에 넘길 시간 제공자 (매 스폰마다 람다 재생성 방지용 캐시) private readonly List _activeNotes = new(); // 화면에 떠 있는(아직 처리 안 된) 노트들 // 점수 상태 (HUD가 읽음). 누적 로직은 RhythmScore가 전담 public RhythmScore Score { get; } = new(); public event Action OnSongStarted; // 곡 시작 (ScoreHud 표시 / ResultHud 숨김) public event Action OnJudged; // 노트 하나가 판정될 때마다 (HUD 연출용) public event Action OnScoreChanged; // 점수/콤보가 바뀔 때 (실시간 HUD) public event Action OnSongFinished; // 곡이 끝났을 때 (결과창) public event Action OnCountdownStarted; // 시작 버튼 직후 카운트다운 시작 (CountdownHud 표시) public event Action OnCountdownTick; // 남은 초 (5,4,3,2,1) — 매초 호출 public event Action OnCountdownFinished; // 카운트 종료(GO!) — 곡 시작 직전 private RhythmCat[] _rhythmCats; private void Awake() { _songTimeProvider = () => SongTime; // 한 번만 만들어 모든 노트가 공유 } private void Start() { ChangeSong(); InputManager.Instance.OnKey_Left_Event += OnPlayerInput; InputManager.Instance.OnKey_Right_Event += OnPlayerInput; _rhythmCats = FindObjectsByType(FindObjectsSortMode.None); // 첫 Sync가 현재 상태를 반드시 한 번 반영하도록 반대값으로 초기화 _armedApplied = !_autoPlay; _activeApplied = !AutoPlayActive; SyncAutoPlayState(); } private void Update() { SyncAutoPlayState(); // _autoPlay 변화를 매 프레임 감지(변화 없으면 즉시 반환) — 인스펙터 토글도 반영 if (!_isPlaying) return; //곡이 끝나면(리드인 지나 곡 길이 도달) 정지·주변음 복구 if (SongTime >= _clipLength) { StopSong(); return; } SpawnDueNotes(); if (_autoPlay) { DriveAutoPlaySticks(); // 다가오는 노트에 맞춰 스틱을 들었다 내림 (판정 전에 호출) AutoHitDueNotes(); // 판정선 도달한 노트 자동 Perfect } } // 오토플레이: 레인별로 가장 임박한 노트에 맞춰 스틱을 휘두름 private void DriveAutoPlaySticks() { if (_autoPlaySticks == null) return; for (int lane = 0; lane < _autoPlaySticks.Length; lane++) { RhythmStick stick = _autoPlaySticks[lane]; if (stick == null) continue; // 이 레인에서 아직 안 친(미래의) 노트 중 가장 가까운 타격 시각 float nextHit = float.PositiveInfinity; foreach (RhythmNoteInstance note in _activeNotes) { int noteLane = Mathf.Clamp(note.Lane, 0, _autoPlaySticks.Length - 1); if (noteLane != lane) continue; if (note.HitTime < SongTime) continue; // 이미 지난(곧 처리될) 노트 제외 if (note.HitTime < nextHit) nextHit = note.HitTime; } stick.Drive(nextHit - SongTime); // 다가오는 노트 없으면 +∞ → 대기 자세 } } // 오토플레이: 판정선에 도달한(HitTime을 지난) 노트를 자동으로 Perfect 처리 private void AutoHitDueNotes() { // 뒤에서부터 순회해야 처리한 노트를 안전하게 리스트에서 제거할 수 있다 for (int i = _activeNotes.Count - 1; i >= 0; i--) { RhythmNoteInstance note = _activeNotes[i]; if (SongTime < note.HitTime) continue; // 아직 판정선 도달 전이면 건너뜀 // 수동 입력과 동일한 연출: 내려치는 효과음 + 히트 이펙트 if (_hitSfx != null && SoundManager.Instance != null) SoundManager.Instance.PlaySFX(_hitSfx); if (note.TryGetComponent(out RhythmProjectile projectile)) projectile.Detonate(); _activeNotes.RemoveAt(i); Destroy(note.gameObject); ApplyJudge(Result.Perfect); // 시간차 ≈ 0 → 항상 Perfect } } // SongTime 기준으로 소환할 때가 된 노트들을 순서대로 생성 private void SpawnDueNotes() { // 판정 시각보다 _leadTime 먼저 생성해야 박자에 맞춰 판정선에 도달 while (_nextNoteIndex < SongNoteList.Count && //총 노트수보다 노트인덱스가 작고 SongTime >= SongNoteList[_nextNoteIndex].Time - _leadTime) //노래길이가 다음 노트의 노트타임 - 리드타임 보다 작을때까지만 { Note note = SongNoteList[_nextNoteIndex]; // spawnTime은 실제 프레임 시각이 아니라 이론값(note.Time - _leadTime)으로 줘야 보간이 정확 RhythmNoteInstance instance = _spawner.SpawnNote(note, note.Time - _leadTime, _songTimeProvider, OnNoteMissed); _activeNotes.Add(instance); _nextNoteIndex++; } } public void ChangeSong() { _audioSource.clip = _currentChart.SongClip; } // 오토플레이 아이템 사용: 곡 시작 전(또는 도중)에 호출하면 남은 노트가 전부 자동 Perfect public void EnableAutoPlay() { _autoPlay = true; SyncAutoPlayState(); // 자동 스틱 켜고, 수동 스틱·손·드럼패드 콜라이더 끔 } // UI Toggle 등에서 직접 연결용. 토글의 On Value Changed(bool)가 넘기는 값으로 켜고/끈다. public void SetAutoPlay(bool on) { if (on) EnableAutoPlay(); else DisableAutoPlay(); } // 오토플레이 수동 해제 (곡 도중에 꺼야 할 때). 곡 종료 시엔 StopSong이 알아서 끈다. public void DisableAutoPlay() { _autoPlay = false; if (_autoPlaySticks != null) // 들려있던 자동 스틱 대기 자세로 복귀 foreach (RhythmStick stick in _autoPlaySticks) if (stick != null) stick.ResetPose(); SyncAutoPlayState(); // 수동 스틱·손·드럼패드 콜라이더 복구 } // 오토플레이 상태를 두 단계로 나눠 비주얼에 반영 (변화 있을 때만): // - armed = 자동 '체크'만 해도 ON → 수동 스틱 숨김 (손/자동스틱은 그대로) // - active = 자동 + 곡 진행 중 → 자동 스틱 표시 + 실제 손 숨김 + 드럼패드 콜라이더 OFF // 자동 스틱/손 전환 기준: 자동이면서 카운트다운 시작~곡 종료 사이 private bool AutoPlayActive => _autoPlay && (_isCountingDown || _isPlaying); private void SyncAutoPlayState() { bool armed = _autoPlay; bool active = AutoPlayActive; if (armed != _armedApplied) { _armedApplied = armed; SetActiveAll(_manualSticks, !armed); // 자동 체크 시 수동 스틱만 숨김 } if (active != _activeApplied) { _activeApplied = active; if (_autoPlaySticks != null) // 자동 전용 스틱(손 포함) 표시/숨김 foreach (RhythmStick s in _autoPlaySticks) if (s != null) s.gameObject.SetActive(active); SetActiveAll(_realHands, !active); // 진행 중일 때만 실제 손 숨김 if (_drumPadColliders != null) foreach (Collider col in _drumPadColliders) if (col != null) col.enabled = !active; } } private static void SetActiveAll(GameObject[] objs, bool on) { if (objs == null) return; foreach (GameObject go in objs) if (go != null) go.SetActive(on); } // 시작 버튼 핸들러: 곧장 시작하지 않고 VR 준비 시간(카운트다운) 후 BeginSong 호출 public void StartSong() { if (_isPlaying) return; // 이미 재생 중이면 무시 if (_isCountingDown) return; // 카운트다운 진행 중 중복 클릭 차단 if (StartButtonObj != null) StartButtonObj.SetActive(false); // 버튼 즉시 숨김 _ = CountdownThenBeginAsync(); // fire-and-forget (await 결과 불필요) } // 매초 비프음 + 남은 초 이벤트를 보내며 _countdownTime 만큼 대기한 뒤 곡 시작 private async Awaitable CountdownThenBeginAsync() { _isCountingDown = true; SyncAutoPlayState(); // 자동이면 카운트다운 시작 순간 자동 스틱 표시 + 실제 손 숨김 try { OnCountdownStarted?.Invoke(); // CountdownHud 표시 int remaining = Mathf.Max(1, Mathf.CeilToInt(_countdownTime)); while (remaining > 0) { OnCountdownTick?.Invoke(remaining); // 화면에 숫자 표시 if (_countdownBeep != null && SoundManager.Instance != null) SoundManager.Instance.PlaySFX(_countdownBeep); // 매초 비프음 // destroyCancellationToken: 카운트다운 도중 오브젝트가 파괴되면 await가 취소돼 // 파괴된 객체에서 BeginSong이 호출되는 걸 막는다 await Awaitable.WaitForSecondsAsync(1f, destroyCancellationToken); remaining--; } } catch (OperationCanceledException) { return; // 파괴/취소 시 곡 시작하지 않고 종료 } finally { _isCountingDown = false; } OnCountdownFinished?.Invoke(); // GO! 표시 if (_countdownGoSfx != null && SoundManager.Instance != null) SoundManager.Instance.PlaySFX(_countdownGoSfx); BeginSong(); } // 곡 재생 + 채보 로드 (카운트다운 종료 후 실제 시작) private void BeginSong() { SongNoteList = _currentChart.GenerateNotes(); _nextNoteIndex = 0; _activeNotes.Clear(); Score.Reset(); OnSongStarted?.Invoke(); // ScoreHud 표시 / ResultHud 숨김 OnScoreChanged?.Invoke(Score); // HUD 초기화(0점) _clipLength = _audioSource.clip != null ? _audioSource.clip.length : 0f; // 리드인: 지금부터 _leadTime 뒤에 오디오 시작. // 그 사이 SongTime이 -_leadTime→0으로 흘러 첫 노트(0초 부근)도 충분히 날아온다 _dspSongStart = AudioSettings.dspTime + _leadTime; _audioSource.PlayScheduled(_dspSongStart); _isPlaying = true; SyncAutoPlayState(); // 자동이면 이 순간 자동 스틱 표시 + 실제 손 숨김 //BGM·환경음을 낮춰 리듬게임 소리만 들리게 if (SoundManager.Instance != null) SoundManager.Instance.EnterMinigameMode(); for(int i=0;i<_rhythmCats.Length;i++) { _rhythmCats[i].Dance(i*1f); } } // 곡 정지 + 주변음 복구 public void StopSong() { if (!_isPlaying) return; // 중복 호출 방지(결과창 두 번 뜨는 것 차단) _audioSource.Stop(); _isPlaying = false; if (_autoPlay && _autoPlaySticks != null) // 곡 끝나면 들려있던 스틱 대기 자세로 복귀 foreach (RhythmStick stick in _autoPlaySticks) if (stick != null) stick.ResetPose(); _autoPlay = false; // 아이템 효과는 한 곡만 — 곡이 끝나면 자동 해제 SyncAutoPlayState(); // 수동 스틱·손·드럼패드 콜라이더 복구 if (SoundManager.Instance != null) SoundManager.Instance.ExitMinigameMode(); OnSongFinished?.Invoke(Score); // 결과창 표시 for(int i=0;i<_rhythmCats.Length;i++) { _rhythmCats[i].DanceStop(); } } // 노트가 판정선을 지나쳐 스스로 Miss 처리될 때 호출 (노트는 이미 자기 파괴됨) private void OnNoteMissed(RhythmNoteInstance note) { _activeNotes.Remove(note); ApplyJudge(Result.Miss); Debug.Log("[Rhythm] Miss (지나침)"); } public void OnPlayerInput() { if (!_isPlaying) return; // 판정과 무관하게 내려칠 때마다 효과음 (헛침 포함) if (_hitSfx != null && SoundManager.Instance != null) SoundManager.Instance.PlaySFX(_hitSfx); if (_activeNotes.Count == 0) return; // 판정선에 가장 가까운(시간차 최소) 노트 탐색 RhythmNoteInstance target = null; float bestDiff = float.MaxValue; foreach (RhythmNoteInstance note in _activeNotes) { float diff = Mathf.Abs(SongTime - note.HitTime); if (diff < bestDiff) { bestDiff = diff; target = note; } } Result result = Judge(bestDiff); if (result == Result.Miss) return; // 히트 범위 밖이면 입력 무시 (노트 소비 안 함) // 성공 판정 → 히트 이펙트 + 노트 소비 if (result == Result.Perfect || result == Result.Good) { // 이펙트는 노트에서 분리돼 따로 재생되므로 노트를 바로 파괴해도 됨 if (target.TryGetComponent(out RhythmProjectile projectile)) projectile.Detonate(); } _activeNotes.Remove(target); Destroy(target.gameObject); ApplyJudge(result); Debug.Log($"[Rhythm] {result} (diff {bestDiff:F3}s)"); } // 판정 결과를 점수에 반영하고 이벤트로 알림 private void ApplyJudge(Result result) { Score.Apply(result); OnJudged?.Invoke(result); OnScoreChanged?.Invoke(Score); } // diff = 절대 시간차(초). 윈도우 안이면 Perfect/Good/Bad, 밖이면 Miss(입력 무시) public Result Judge(float diff) { if (diff <= _perfectWindow) return Result.Perfect; if (diff <= _goodWindow) return Result.Good; if (diff <= _badWindow) return Result.Bad; return Result.Miss; } }