2026-06-11 사운드시스템 (BaseScene참고)

This commit is contained in:
skrwns304@gmail.com
2026-06-11 15:32:56 +09:00
parent 6a20f521ce
commit 9f173a4ebc
23 changed files with 640 additions and 6 deletions

View File

@@ -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()
{
{
}
}

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 93d1361875b43e443adfb756664f5bd5

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 08bd0d3e4d808b04ea8e3acea901beb4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,38 @@
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.Splines;
//스플라인 선을 따라 깔리는 환경음(파도 등)
//소스 하나가 플레이어와 "스플라인 위에서 가장 가까운 지점"을 추적해
//어떤 모양의 해안선이든 방향성 + 거리감쇠를 처리한다. (닫힌 섬 형태 포함)
[RequireComponent(typeof(AudioSource))]
public class LineAmbienceAudio : MonoBehaviour
{
[Header("스플라인")]
[SerializeField] private SplineContainer _spline; //환경음이 깔릴 선 (해안선 등)
[Header("추적 대상")]
[SerializeField] private Transform _listener; //플레이어 (보통 메인 카메라). 비우면 자동으로 Camera.main 사용
private void LateUpdate()
{
Transform listener = _listener != null ? _listener : (Camera.main != null ? Camera.main.transform : null);
if (listener == null || _spline == null) return;
//리스너 위치를 스플라인 로컬 공간으로 변환 (컨테이너의 Transform 반영)
float3 localListener = _spline.transform.InverseTransformPoint(listener.position);
//스플라인 곡선 위에서 가장 가까운 지점을 구함 (Knot 사이 곡선까지 정확)
SplineUtility.GetNearestPoint(_spline.Spline, localListener, out float3 localNearest, out _);
//다시 월드 공간으로 변환해서 소스를 그 지점으로 이동
transform.position = _spline.transform.TransformPoint(localNearest);
}
//에디터에서 현재 소스가 추적중인 지점을 표시 (스플라인 자체는 SplineContainer가 그려줌)
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.yellow;
Gizmos.DrawSphere(transform.position, 0.5f);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9039653488e18f14496af8f3e47e084a

View File

@@ -0,0 +1,9 @@
//게임 내 공간 구역(Zone) 정의
//BGM 전환, 환경음, 게임플레이 등 여러 시스템에서 공통으로 사용하는 공용 타입.
public enum Zone
{
None,
Ocean,
Island,
Seaside
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d83d93eb32f8f6344b80bcaf21cc62dc

View File

@@ -0,0 +1,44 @@
using UnityEngine;
//구역마다 트리거 콜라이더를 하나씩 두고 어떤 구역인지 지정해서 사용.
[RequireComponent(typeof(Collider))]
public class ZoneTrigger : MonoBehaviour
{
[SerializeField] private Zone _zone; //이 영역이 나타내는 구역
[SerializeField] private string _playerTag = "Player"; //플레이어로 인식할 태그
private void Reset()
{
//이 컴포넌트를 붙이면 콜라이더를 자동으로 트리거로 설정
GetComponent<Collider>().isTrigger = true;
}
private void OnTriggerEnter(Collider other)
{
if (!other.CompareTag(_playerTag)) return;
if (GameManager.Instance == null) return;
//구역 상태만 바꾸면, BGM 등은 OnZoneChanged 구독자가 알아서 반응한다
GameManager.Instance.SetZone(_zone);
}
//에디터에서 구역 범위를 시각화
private void OnDrawGizmosSelected()
{
Collider col = GetComponent<Collider>();
if (col == null) return;
Gizmos.color = new Color(0f, 1f, 1f, 0.15f);
Gizmos.matrix = transform.localToWorldMatrix;
//박스/구 콜라이더만 간단히 표시
if (col is BoxCollider box)
{
Gizmos.DrawCube(box.center, box.size);
}
else if (col is SphereCollider sphere)
{
Gizmos.DrawSphere(sphere.center, sphere.radius);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3b4941db6b378ff41a5ba55f2112b16c