using System; using System.Collections.Generic; using System.Threading; using UnityEngine; using UnityEngine.Audio; public class SoundManager : MonoBehaviour, ISceneInitializable { public static SoundManager Instance { get; private set; } [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 AudioClip _defaultBgm; //평상시 BGM (시작 시 자동 재생) //대화 전용 BGM 등 일시적으로 기본 BGM을 덮는 곡. null이면 기본 BGM 재생 중. private AudioClip _overrideBgm; [Header("SFX Pool")] [SerializeField] private int _sfxPoolSize = 20; //시작 시 미리 생성할 SFX 소스 개수 //SFX 풀 - 대기중인 소스 private readonly Queue _sfxPool = new(); //BGM 전환(페이드)을 취소하기 위한 토큰. private CancellationTokenSource _bgmCts; private void Awake() { if (Instance == null) { Instance = this; //만들어진 자신을 인스턴스로 설정 Initialize(); } else { Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴 (씬마다 놓아도 첫 번째만 남음) } } // 씬이 바뀌면 이전 씬에서 남은 전용(Override) BGM을 정리한다 (ISceneInitializable). // 새 씬의 SceneBgm.Start가 SetDefaultBGM으로 그 씬의 곡을 넘겨주면 그대로 재생된다. public void OnSceneLoaded() => ClearOverrideBGM(); private void Initialize() { //BGM 소스 기본 설정 if (_bgmSource != null) { _bgmSource.outputAudioMixerGroup = _bgmGroup; _bgmSource.playOnAwake = false; _bgmSource.loop = true; } //SFX 풀 미리 생성 for (int i = 0; i < _sfxPoolSize; i++) { _sfxPool.Enqueue(CreateSfxSource()); } } private void Start() { if (_defaultBgm != null) StartBgmChange(_defaultBgm); } //=========================== BGM =========================== //기본 BGM 교체 (씬/분위기 전환용). 전용 BGM 재생 중이면 곡은 유지되고 복귀 시 반영됨. public void SetDefaultBGM(AudioClip clip) { if (_defaultBgm == clip) return; //같은 곡이면 페이드 재시작 없이 유지 _defaultBgm = clip; if (_overrideBgm == null) StartBgmChange(clip); } //전용 BGM으로 전환 (대화 노드 등). 이미 같은 곡이 재생 중이면 아무것도 안 함. public void PlayOverrideBGM(AudioClip clip) { if (clip == null || _overrideBgm == clip) return; _overrideBgm = clip; StartBgmChange(clip); } //전용 BGM을 멈추고 기본 BGM으로 복귀. 전용 BGM이 없었으면 아무것도 안 함. public void ClearOverrideBGM() { if (_overrideBgm == null) return; _overrideBgm = null; StartBgmChange(_defaultBgm); } //진행 중이던 전환(페이드)을 취소하고 새 전환을 시작 private void StartBgmChange(AudioClip clip) { if (_bgmSource == null) return; _bgmCts?.Cancel(); _bgmCts?.Dispose(); _bgmCts = new CancellationTokenSource(); _ = 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 =========================== //SFX 재생. 2D 게임이라 위치 개념 없이 항상 화면 전체에 동일하게 들린다. public void PlaySFX(AudioClip clip, float volume = 1f) { if (clip == null) return; AudioSource source = GetSfxSource(); 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; } //=========================== Looping SFX =========================== //타이핑 사운드처럼 "시작 → 임의 시점에 정지"가 필요한 루프 재생. //반환값은 정지에 쓰는 핸들이다. PlaySFX와 달리 자동 반납되지 않으니 반드시 StopLoop를 부를 것. public AudioSource PlayLoop(AudioClip clip, float volume = 1f) { if (clip == null) return null; AudioSource source = GetSfxSource(); source.loop = true; source.clip = clip; source.volume = volume; source.Play(); return source; } //루프 재생을 멈추고 소스를 풀에 반납한다. 이미 반납된 소스(중복 호출)는 무시한다. public void StopLoop(AudioSource source) { if (source == null || !source.gameObject.activeSelf) return; source.Stop(); source.loop = false; //풀에 돌아가는 소스는 항상 loop=false 상태여야 한다 source.clip = null; source.gameObject.SetActive(false); _sfxPool.Enqueue(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; source.spatialBlend = 0f; //2D — 거리 감쇠 없이 항상 동일하게 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); } }