2026-06-11 사운드시스템 (BaseScene참고)
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
public class GameManager : MonoBehaviour,ISceneInitializable
|
||||
{
|
||||
public static GameManager Instance;
|
||||
|
||||
//현재 플레이어가 위치한 구역 (구역 상태의 단일 진실 출처)
|
||||
public Zone CurrentZone { get; private set; } = Zone.None;
|
||||
|
||||
//구역이 바뀌면 발생. 사운드 등 여러 시스템이 구독해서 반응한다.
|
||||
public event Action<Zone> OnZoneChanged;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
@@ -17,8 +24,16 @@ private void Awake()
|
||||
}
|
||||
}
|
||||
|
||||
//구역 변경 요청 (ZoneTrigger 등에서 호출)
|
||||
public void SetZone(Zone zone)
|
||||
{
|
||||
if (zone == CurrentZone) return; //같은 구역이면 무시
|
||||
CurrentZone = zone;
|
||||
OnZoneChanged?.Invoke(zone); //구독자(SoundManager 등)에게 통지
|
||||
}
|
||||
|
||||
public void OnSceneLoaded()
|
||||
{
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
234
Assets/02_Scripts/Managers/SoundManager.cs
Normal file
234
Assets/02_Scripts/Managers/SoundManager.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
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<ZoneBgm> _zoneBgmList = new();
|
||||
|
||||
[Header("SFX Pool")]
|
||||
[SerializeField] private int _sfxPoolSize = 10; //시작 시 미리 생성할 SFX 소스 개수
|
||||
|
||||
//구역 → BGM 빠른 조회용 (런타임 변환)
|
||||
private readonly Dictionary<Zone, AudioClip> _zoneBgmMap = new();
|
||||
|
||||
//SFX 풀 - 대기중인 소스
|
||||
private readonly Queue<AudioSource> _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<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);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/SoundManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/SoundManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93d1361875b43e443adfb756664f5bd5
|
||||
Reference in New Issue
Block a user