using System; using System.Collections.Generic; using Hovl; 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 = 2f; // 노트가 생성돼 판정선까지 흐르는 시간(초) [Header("판정 윈도우 (초, 절대 시간차 기준)")] [SerializeField] private float _perfectWindow = 0.05f; [SerializeField] private float _goodWindow = 0.10f; [SerializeField] private float _badWindow = 0.15f; // 이 밖이면 입력 무시(헛침) [SerializeField] private GameObject StartButtonObj; public float SongTime => _audioSource.time; // 모든 타이밍의 기준 private List SongNoteList; private int _nextNoteIndex; // 다음에 소환할 노트 인덱스 private bool _isPlaying; //곡이 재생 중인지 (종료 감지용) 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; // 곡이 끝났을 때 (결과창) private void Awake() { _songTimeProvider = () => SongTime; // 한 번만 만들어 모든 노트가 공유 } private void Start() { ChangeSong(); } private void Update() { //재생 중이던 곡이 끝나면 주변음 복구 if (_isPlaying && !_audioSource.isPlaying) { StopSong(); return; } if (!_isPlaying) return; SpawnDueNotes(); // 테스트용: 스페이스로 판정 (실제 VR에서는 컨트롤러/콜라이더 입력으로 교체) if (Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame) OnPlayerInput(); } // 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; } // 곡 재생 + 채보 로드 public void StartSong() { SongNoteList = _currentChart.GenerateNotes(); _nextNoteIndex = 0; _activeNotes.Clear(); Score.Reset(); OnSongStarted?.Invoke(); // ScoreHud 표시 / ResultHud 숨김 OnScoreChanged?.Invoke(Score); // HUD 초기화(0점) StartButtonObj.SetActive(false); _audioSource.Play(); _isPlaying = true; //BGM·환경음을 낮춰 리듬게임 소리만 들리게 if (SoundManager.Instance != null) SoundManager.Instance.EnterMinigameMode(); } // 곡 정지 + 주변음 복구 public void StopSong() { if (!_isPlaying) return; // 중복 호출 방지(결과창 두 번 뜨는 것 차단) _audioSource.Stop(); _isPlaying = false; if (SoundManager.Instance != null) SoundManager.Instance.ExitMinigameMode(); OnSongFinished?.Invoke(Score); // 결과창 표시 } // 노트가 판정선을 지나쳐 스스로 Miss 처리될 때 호출 (노트는 이미 자기 파괴됨) private void OnNoteMissed(RhythmNoteInstance note) { _activeNotes.Remove(note); ApplyJudge(Result.Miss); Debug.Log("[Rhythm] Miss (지나침)"); } public void OnPlayerInput() { if (!_isPlaying || _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; } }