using System; using System.Collections.Generic; using System.Threading; using UnityEngine; using UnityEngine.Audio; //인스펙터에서 구역별 BGM을 매핑하기 위한 구조체 [Serializable] public struct ZoneBgm { public Zone Zone; public AudioClip Clip; } public class SoundManager : MonoBehaviour { public static SoundManager Instance; [Header("Mixer")] [SerializeField] private AudioMixer _mainMixer; //메인 믹서 하나 [SerializeField] private AudioMixerGroup _bgmGroup; [SerializeField] private AudioMixerGroup _sfxGroup; [Header("BGM")] [SerializeField] private AudioSource _bgmSource; //BGM 전용 단일 오디오 소스 [SerializeField, Range(0f, 1f)] private float _bgmVolume = 1f; [SerializeField] private float _bgmFadeDuration = 1.5f; [SerializeField] private List _zoneBgmList = new(); [Header("SFX Pool")] [SerializeField] private int _sfxPoolSize = 10; //시작 시 미리 생성할 SFX 소스 개수 //구역 → BGM 빠른 조회용 (런타임 변환) private readonly Dictionary _zoneBgmMap = new(); //SFX 풀 - 대기중인 소스 private readonly Queue _sfxPool = new(); //BGM 전환(페이드)을 취소하기 위한 토큰. 구역을 빠르게 오갈 때 이전 전환을 취소함 private CancellationTokenSource _bgmCts; private void Awake() { if (Instance == null) { Instance = this; //만들어진 자신을 인스턴스로 설정 DontDestroyOnLoad(gameObject); Initialize(); } else { Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴 } } private void Initialize() { //구역별 BGM 매핑을 딕셔너리로 변환 foreach (var entry in _zoneBgmList) { _zoneBgmMap[entry.Zone] = entry.Clip; } //BGM 소스 기본 설정 if (_bgmSource != null) { _bgmSource.outputAudioMixerGroup = _bgmGroup; _bgmSource.playOnAwake = false; _bgmSource.loop = true; } //SFX 풀 미리 생성 for (int i = 0; i < _sfxPoolSize; i++) { _sfxPool.Enqueue(CreateSfxSource()); } } //모든 매니저의 Awake가 끝난 뒤 이벤트를 구독한다 private void Start() { if (Instance != this) return; //중복 인스턴스로 파괴 예정이면 구독하지 않음 if (GameManager.Instance != null) { GameManager.Instance.OnZoneChanged += HandleZoneChanged; } } private void OnDestroy() { if (GameManager.Instance != null) { GameManager.Instance.OnZoneChanged -= HandleZoneChanged; } } //=========================== BGM =========================== //구역이 바뀌면 호출됨 (GameManager.OnZoneChanged 구독) private void HandleZoneChanged(Zone zone) { AudioClip clip = _zoneBgmMap.GetValueOrDefault(zone); //없으면 null → 무음 처리 //이전 전환이 진행중이면 취소 _bgmCts?.Cancel(); _bgmCts?.Dispose(); _bgmCts = CancellationTokenSource.CreateLinkedTokenSource(this.destroyCancellationToken); _ = ChangeBGM(clip, _bgmCts.Token); } //페이드아웃 → 클립 교체 → 페이드인 (단일 소스라 순차 진행) private async Awaitable ChangeBGM(AudioClip clip, CancellationToken token) { try { //현재 재생중이면 먼저 페이드아웃 if (_bgmSource.isPlaying) { await BGMFade(_bgmSource.volume, 0f, token); _bgmSource.Stop(); } if (clip == null) return; //해당 구역에 BGM이 없으면 무음 유지 //새 BGM 페이드인 _bgmSource.clip = clip; _bgmSource.volume = 0f; _bgmSource.Play(); await BGMFade(0f, _bgmVolume, token); } catch (OperationCanceledException) { //구역이 다시 바뀌어 전환이 취소됨 - 정상 흐름 } } //BGM 소스 볼륨을 from→to로 부드럽게 이동 private async Awaitable BGMFade(float from, float to, CancellationToken token) { float t = 0f; while (t < _bgmFadeDuration) { t += Time.deltaTime; _bgmSource.volume = Mathf.Lerp(from, to, t / _bgmFadeDuration); await Awaitable.NextFrameAsync(token); } _bgmSource.volume = to; } //=========================== SFX =========================== //2D SFX 재생 (UI 사운드 등 위치 무관) public void PlaySFX(AudioClip clip, float volume = 1f) { if (clip == null) return; AudioSource source = GetSfxSource(); source.transform.localPosition = Vector3.zero; source.spatialBlend = 0f; //2D Play(source, clip, volume); _ = ReturnAfterPlay(source, clip.length); //재생이 끝나면 풀에 반납 } //3D SFX 재생 (VR 공간음향 - 특정 위치에서 들림) public void PlaySFXAt(AudioClip clip, Vector3 position, float volume = 1f) { if (clip == null) return; AudioSource source = GetSfxSource(); source.transform.position = position; source.spatialBlend = 1f; //3D Play(source, clip, volume); _ = ReturnAfterPlay(source, clip.length); //재생이 끝나면 풀에 반납 } private void Play(AudioSource source, AudioClip clip, float volume) { source.clip = clip; source.volume = volume; source.Play(); } //풀에서 사용 가능한 SFX 소스를 가져옴 (없으면 새로 생성 - 자동 증설) private AudioSource GetSfxSource() { AudioSource source = _sfxPool.Count > 0 ? _sfxPool.Dequeue() : CreateSfxSource(); source.gameObject.SetActive(true); return source; } //재생 길이만큼 대기 후 소스를 풀로 반납 private async Awaitable ReturnAfterPlay(AudioSource source, float duration) { try { await Awaitable.WaitForSecondsAsync(duration, this.destroyCancellationToken); } catch (OperationCanceledException) { return; //매니저 파괴 시 종료 } source.Stop(); source.clip = null; source.gameObject.SetActive(false); _sfxPool.Enqueue(source); } //SFX용 AudioSource를 자식 오브젝트로 생성 private AudioSource CreateSfxSource() { GameObject go = new("SFX_Source"); go.transform.SetParent(transform); go.SetActive(false); AudioSource source = go.AddComponent(); source.outputAudioMixerGroup = _sfxGroup; source.playOnAwake = false; return source; } //=========================== Mixer =========================== //믹서 노출 파라미터로 볼륨 조절 (0~1 → dB 변환) public void SetVolume(string exposedParam, float normalized) { //0에 가까우면 -80dB(무음), 그 외에는 로그 스케일로 변환 float db = normalized <= 0.0001f ? -80f : Mathf.Log10(normalized) * 20f; _mainMixer.SetFloat(exposedParam, db); } }