Files
Dino_Love_Simulation/Assets/02_Scripts/Managers/SoundManager.cs
2026-07-22 19:26:44 +09:00

237 lines
8.0 KiB
C#

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<AudioSource> _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 ===========================
//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 공간음향 - 특정 위치에서 들림)
//minDistance: 이 거리 안에선 풀 볼륨으로 들림. 하늘 높이 터지는 폭죽처럼 멀리서 나는 큰 소리는
//크게(예: 30~50) 잡아야 거리 감쇠로 사라지지 않는다. maxDistance: 감쇠 계산 상한.
public void PlaySFXAt(AudioClip clip, Vector3 position, float volume = 1f,
float minDistance = 1f, float maxDistance = 500f)
{
if (clip == null) return;
AudioSource source = GetSfxSource();
source.transform.position = position;
source.spatialBlend = 1f; //3D
source.rolloffMode = AudioRolloffMode.Logarithmic;
source.minDistance = minDistance;
source.maxDistance = maxDistance;
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<AudioSource>();
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);
}
}