플레이어 환경 설정 및 오디오 제어 시스템
목차
1. 시스템 요구 사항
플레이어는 환경 설정 UI에서 배경음(BGM)과 효과음(SFX) 볼륨을 조절할 수 있어야 하고, 조절 결과는 게임 플레이에 즉시 반영되어야 한다.
또한 배경음과 효과음은 각각 음소거 토글을 제공해야 하며, UI 버튼의 상태가 현재 음소거 상태를 명확히 표현해야 한다.
ProximaB는 NGUI 기반이므로 UISlider.value(0~1)를 Unity AudioSource가 요구하는 표준 볼륨 범위(0~1)에 맞춰 일관되게 매핑해야 한다.
한편 UI 계층(SettingUi_)은 오디오를 직접 재생하거나 AudioSource를 직접 조작하는 실행 계층이 아니며, 사용자의 의도(볼륨 값, 음소거 on/off)를 전달하는 역할에 집중해야 한다.
실제 오디오 적용, 재생, 동적 생성과 같은 실행 책임은 SoundManager가 단일 진입점으로 담당해야 한다.
마지막으로 효과음은 상황에 따라 다양한 위치에서 발생하므로, 재생 요청 시점에 음소거 여부를 검사하여 불필요한 재생 코루틴이나 오브젝트 생성을 방지해야 한다.
2. 설계 목표
- SettingUi_는 계산/입력 제공 및 의도 전달, SoundManager는 적용/재생 실행으로 역할 분리
- NGUI 슬라이더 값(0~1)을 AudioSource 표준 볼륨 범위(0~1)에 맞춰 일관되게 처리
- 음소거 토글은 UI가 상태 변수를 설정하고, SoundManager가 실제 출력 차단을 수행
- BGM은 씬 전환에도 유지되는 구조를 전제로 반복 재생(Loop) 기반으로 관리
- SFX는 요청 기반으로 동적 재생하며, 음소거 시 즉시 return하여 비용을 최소화
- 발소리는 지형 타입을 enum으로 표준화하고, SoundManager의 클립 배열에서 랜덤 선택하여 재생
3. 흐름도
[SettingUi_ (NGUI)]
│ UISlider 조작 / 버튼 클릭
├─ BgmSoundControl(), SfxSoundControl() : 볼륨 값 반환(0~1)
├─ BgmOffClick()/BgmOnClick() : isBgUnMute 값 변경 + 버튼 상태 표시
└─ SfxOffClick()/SfxOnClick() : isSfxMute 값 변경 + 버튼 상태 표시
▼
[SoundManager (Audio Execution)]
│ Update에서 SettingUi_ 값을 폴링하여 bgmVolum/sfxVolum 갱신
│ BGM 오브젝트(AudioSource)에 volume 적용, enabled로 출력 on/off
│ PlayBGM() 호출 시 코루틴으로 BGM AudioSource 생성 및 재생
│ PlaySfx() 호출 시 음소거 검사 후 코루틴으로 3D SFX 오브젝트 생성/재생/파괴
▼
[PlayerController (Footstep Producer)]
│ SurfaceType 기반으로 대상 클립 배열 선택
└─ SoundManager.PlaySfx(...) 호출로 발소리 재생 요청
이 시스템은 'UI 입력 → SoundManager 상태 갱신 → 오디오 출력/재생' 흐름을 가진다.
UI는 값과 의도를 제공하고, 오디오 실행은 SoundManager로 집중된다.
발소리는 PlayerController가 상황을 판단하지만, 재생 자체는 SoundManager에게 위임되어 오디오 정책(음소거, 볼륨, 3D 감쇠)이 일관되게 적용된다.
4. 구현
4.1. 볼륨 값 계산과 제공
// SettingUi_.cs
public float BgmSoundControl()
{
bgmSound = Mathf.Lerp(0f, 1f, bgmSlider.value);
return bgmSound;
}
public float SfxSoundControl()
{
sfxSound = Mathf.Lerp(0f, 1f, sfxSlider.value);
return sfxSound;
}
NGUI의 UISlider.value는 0~1로 정규화된 값이기 때문에, 오디오 볼륨처럼 표준 범위가 0~1인 파라미터에는 사실상 직접 매핑이 가능하다.
여기서 Mathf.Lerp(0,1,t)는 t를 그대로 반환하는 것과 수학적으로 동일하지만, 코드 의도가 슬라이더를 특정 범위로 변환한다는 패턴을 유지한다는 점에서 의미가 있다.
즉 마우스 감도처럼 범위가 달라지는 설정과 동일한 방식으로 처리해, 설정 항목이 늘어나도 구조가 흔들리지 않게 만든다.
이 함수들이 중요한 이유는, SettingUi_가 적용을 하지 않고 값을 계산해 반환만 한다는 점이다.
UI가 AudioSource를 직접 만지기 시작하면 표현 계층과 실행 계층이 섞여서, 오디오 구조가 바뀔 때 UI까지 연쇄 수정이 발생한다.
반대로 반환만 하면, 오디오 적용 방식이 enabled 제어에서 mixer 제어로 바뀌어도 UI는 그대로 유지될 수 있다.
// SoundManager.cs
private void Update()
{
if (SettingUi_.instance != null)
{
bgmVolum = SettingUi_.instance.BgmSoundControl();
sfxVolum = SettingUi_.instance.SfxSoundControl();
}
if (GameObject.Find("BGM") != null)
{
GameObject.Find("BGM").GetComponent<AudioSource>().volume = bgmVolum;
GameObject.Find("BGM").GetComponent<AudioSource>().enabled = isBgUnMute;
}
}SoundManager.Update에서 폴링을 선택한 이유는 설정 변경이 즉시 반영되어야 한다는 UX 요구 때문이다.
슬라이더는 사용자가 드래그하는 동안 계속 값이 변하므로, 이벤트 기반으로도 가능하지만, NGUI 환경에서는 이벤트 연결 지점이 늘어날수록 UI-오디오 결합이 커지기 쉽다.
Update에서 한 곳에서만 읽으면 반영 누락이 발생하지 않고 구조가 단순해진다.
다만 여기에는 명확한 단점이 있다.
GameObject.Find는 문자열 기반 탐색이라 비용이 있고, Update에서 매 프레임 호출하면 불필요한 탐색 비용이 누적된다.
면접 관점에서는 이 지점이 가장 좋은 개선 포인트다.
지금 구조는 현재 규모에서 구현 단순성을 우선한 선택이고, 확장 시에는 bgmSource를 캐싱하거나, BGM 오브젝트를 생성할 때 참조를 저장해 Update에서는 직접 bgmSource.volume / bgmSource.enabled만 조정하는 방식이 더 바람직하다.
enabled로 mute를 제어하는 이유도 설명 가능해야 한다.
AudioSource.mute는 오디오 출력만 막을 뿐, 재생 상태/처리에 따라 토글했을 때 기대한 재개 동작이 프로젝트 상황에 따라 애매해질 수 있다.
enabled는 컴포넌트 자체를 끄는 방식이라 출력 차단이 더 강제적이고, 음소거 상태에서는 확실히 소리가 안 난다는 안정성을 준다.
대신 enabled를 끄면 AudioSource 업데이트가 멈추므로, 특정 상황에서는 재생 시점 제어가 필요할 수 있다.
이 프로젝트에서는 BGM을 단일 오브젝트로 단순 운영하므로 enabled가 더 직관적인 선택이다.
4.2. 배경음 음소거 토글 구조
public void BgmOffClick()
{
SoundManager.instance.isBgUnMute = false;
bgmOnBtn.SetActive(true);
bgmOffBtn.SetActive(false);
}
public void BgmOnClick()
{
SoundManager.instance.isBgUnMute = true;
bgmOnBtn.SetActive(false);
bgmOffBtn.SetActive(true);
}이 코드는 겉보기에는 버튼 토글이지만, 실제로는 UI가 실행을 하지 않고 의도만 전달하는 구조를 유지한다는 점에서 의미가 있다.
SettingUi_는 AudioSource에 접근하지 않고 SoundManager의 상태 변수(isBgUnMute)만 변경한다.
오디오 출력의 최종 제어는 SoundManager.Update에서 enabled = isBgUnMute로 수행된다.
이렇게 하면 오디오 구현이 바뀌어도 UI는 여전히 '켜기/끄기 의도' 만 전달하면 된다.
또한 버튼 오브젝트를 두 개로 나눠 SetActive로 스왑하는 방식은 NGUI에서 흔히 쓰는 패턴이다.
텍스트/스프라이트 변경으로 상태를 표현할 수도 있지만, 버튼 자체를 분리하면 현재 상태에서 가능한 입력만 남긴다는 UX를 만들기 쉽다.
대신 버튼 프리팹 수가 늘어날 수 있다는 단점이 있다. 이 프로젝트는 기능 명확성과 시각적 상태 표현을 우선한 선택이다.
// SoundManager.cs
private void Update()
{
if (GameObject.Find("BGM") != null)
{
...
GameObject.Find("BGM").GetComponent<AudioSource>().enabled = isBgUnMute;
}
}BgmOffClick, BgmOnClick을 통해 변수 isBgUnMute의 값이 설정되고, 그 값에 따라 SoundManager클래스의 Update에서 BGM을 음소거할지가 결정된다.
4.4. 배경음 재생 구조
public void PlayBGM() => StartCoroutine(PlayBGMIE(mainBgm, 0f, true));
IEnumerator PlayBGMIE(AudioClip bgm, float delayed, bool loop)
{
yield return new WaitForSeconds(delayed);
GameObject bgmObj = new GameObject("BGM");
bgmSource = bgmObj.AddComponent<AudioSource>();
bgmSource.clip = bgm;
bgmSource.loop = loop;
bgmSource.Play();
}BGM은 지속 재생되는 단일 트랙 성격이 강하므로 loop를 true로 두고 운영한다.
코루틴을 사용하는 이유는 delayed 파라미터로 재생 시작 시점을 유연하게 만들기 위함이다.
WaitForSeconds는 Unity timeScale의 영향을 받기 때문에, 만약 일시정지나 슬로우가 걸리면 딜레이도 함께 늘어난다.
이 프로젝트에서 BGM은 게임 흐름과 같이 움직여도 큰 문제가 없고, 단순한 지연 재생 요구를 만족시키기에 코루틴이 가장 간단하다.
GameObject를 런타임에 생성하고 AudioSource를 AddComponent로 붙이는 방식은 씬 구성에서 오디오 오브젝트를 강제하지 않는다는 장점이 있다.
즉 SoundManager만 있으면 언제든 BGM을 만들 수 있고, 프리팹 세팅 실수로 AudioSource가 빠져도 런타임에서 복구된다.
대신 현재 코드에는 BGM 오브젝트 중복 생성 방지 로직이 보이지 않는다.
PlayBGM을 여러 번 호출하면 BGM이 여러 개 생길 수 있으므로, 실무적으로는 기존 bgmSource가 있으면 재사용하거나 파괴 후 재생하는 보호 장치를 두는 것이 더 안전하다.
면접에서는 현재는 Start에서 1회 호출을 전제로 단순화했고, 확장 시 싱글 BGM 보장 로직을 추가한다고 설명하면 설득력이 높다.
private void Start()
{
PlayBGM();
}배경음은 시작하자마자 재생되어야 하기 때문에, Start에서 호출하였다.
4.4. 효과음 음소거 토글 구조
public void SfxOffClick()
{
SoundManager.instance.isSfxMute = true;
sfxOnBtn.SetActive(true);
sfxOffBtn.SetActive(false);
}
public void SfxOnClick()
{
SoundManager.instance.isSfxMute = false;
sfxOnBtn.SetActive(false);
sfxOffBtn.SetActive(true);
}SFX는 BGM과 달리 상황 발생 시점에만 재생된다.
그래서 음소거 적용도 Update에서 강제로 끄는 방식보다, PlaySfx 진입에서 즉시 return하는 편이 비용과 책임 관점에서 자연스럽다.
음소거 상태라면 코루틴을 만들 필요도 없고, Sfx 오브젝트를 생성할 이유도 없기 때문이다.
sfx == null 검사 역시 방어 코딩으로, 호출자가 실수로 null을 넘겨도 오디오 시스템 전체가 예외로 깨지지 않게 한다.
UI 토글 자체는 BGM과 동일하게 SoundManager의 상태 변수를 변경하고, UI 버튼 표시를 SetActive로 바꾼다.
public void PlaySfx(Vector3 pos, AudioClip sfx, float delayed, float volum)
{
if (isSfxMute || sfx == null) return;
...
}여기서 isSfxMute는 효과음을 생성해도 되는가를 판단하는 역할을 한다.
BGM은 이미 존재하고 있기 때문에 출력만 제어하면 된다.
SFX는 존재하지 않기 때문에, 생성 단계에서 차단해야 한다.
만약 SFX도 Update에서 일괄 제어하려고 한다면, 매 프레임 모든 SFX 오브젝트를 탐색하거나, 별도의 리스트를 관리해야 한다. 이는 오히려 복잡성을 증가시킨다.
4.5. 효과음 동적 생성 기반 3D 재생
public void PlaySfx(Vector3 pos, AudioClip sfx, float delayed, float volum)
{
if (isSfxMute || sfx == null) return;
StartCoroutine(PlaySfxIE(pos, sfx, delayed, volum));
}
IEnumerator PlaySfxIE(Vector3 pos, AudioClip sfx, float delayed, float volum)
{
yield return new WaitForSeconds(delayed);
GameObject sfxObj = new GameObject("Sfx");
AudioSource aud = sfxObj.AddComponent<AudioSource>();
sfxObj.transform.position = pos;
aud.clip = sfx;
aud.minDistance = 5.0f;
aud.maxDistance = 10.0f;
aud.volume = volum;
aud.Play();
Destroy(sfxObj, sfx.length);
}이 구현은 원샷 효과음은 재생할 때만 오브젝트를 만들고, 재생이 끝나면 파괴한다.
장점은 관리가 단순하고, 동시에 여러 효과음이 겹쳐도 각각 독립적으로 재생된다는 점이다.
특히 위치(pos)를 부여해 3D 사운드로 만들기 때문에, minDistance/maxDistance로 감쇠 범위를 정해 플레이어 위치에 따라 거리감이 생긴다.
단점은 오브젝트 생성/파괴가 빈번하면 GC 및 성능 부담이 생길 수 있다는 점이다.
발소리처럼 재생 빈도가 높은 SFX는 오브젝트 풀링을 적용하는 것이 더 좋을 수 있다.
다만 이 프로젝트에서는 구현 단순성과 명확성이 우선이고, 성능 병목이 관찰되면 풀링으로 개선 가능하다는 형태로 설명하면 납득이 된다.
4.6. 지형 타입 표준화와 랜덤 클립 선택
public enum SurfaceType { Grass, Forest, Sand, Cave }
IEnumerator PlaySound(SurfaceType type)
{
if (isRunning) yield break;
isRunning = true;
yield return new WaitForSeconds(0.2f);
var sm = SoundManager.instance;
AudioClip[] targetClips = null;
switch (type)
{
case SurfaceType.Grass: targetClips = sm.grassClips; break;
case SurfaceType.Forest: targetClips = sm.forestClips; break;
case SurfaceType.Sand: targetClips = sm.sandClips; break;
case SurfaceType.Cave: targetClips = sm.caveClips; break;
}
if (targetClips != null && targetClips.Length > 0)
{
int randomIndex = Random.Range(0, targetClips.Length);
sm.PlaySfx(transform.position, targetClips[randomIndex], 0, sm.sfxVolum);
}
isRunning = false;
}SurfaceType enum을 둔 이유는 지형을 문자열이나 레이어 숫자로 직접 분기하는 대신, 발소리 로직이 의미 있는 타입을 기준으로 동작하도록 표준화하기 위함이다.
enum은 오타 위험이 없고, 컴파일 타임에 케이스가 고정되므로 유지보수성이 올라간다.
switch로 SoundManager의 클립 배열을 선택하고, Random.Range로 배열 길이만큼 랜덤 인덱스를 뽑는 방식은 클립 개수가 늘거나 줄어도 코드 수정이 없다는 장점이 있다.
isRunning 플래그와 0.2초 딜레이는 발소리 재생 빈도를 제어한다.
코루틴 기반으로 일정 간격을 보장하면서, OnTrigger/Move 호출이 자주 일어나도 무한 재생이 발생하지 않게 한다.
다만 isRunning이 단일 플래그라서, 다른 종류의 발소리나 다른 행동 SFX와 결합할 때는 더 세분화된 쿨다운 구조(타입별 쿨타임, 시간 스탬프 기반)로 확장할 여지가 있다.
현재는 발소리 하나를 안정적으로 제한하는 목적에 맞춘 단순한 제어다.
void Move()
{
...
if (x != 0 || z != 0)
{
...
if (groundCheck)
{
if (isMeadow) StartCoroutine(PlaySound(SurfaceType.Grass));
else if (isForest) StartCoroutine(PlaySound(SurfaceType.Forest));
else if (isDesert) StartCoroutine(PlaySound(SurfaceType.Sand));
else if (isCave) StartCoroutine(PlaySound(SurfaceType.Cave));
}
}
...
}Move() 내부에서 PlaySound를 호출하는 이 구간은 플레이어의 행동 상태와 환경 상태를 조합해 오디오 실행 계층에 재생 요청을 전달하는 구조다.
먼저 x != 0 || z != 0 조건은 입력 기반 이동이 실제로 발생하고 있는지를 판별하는 단계로, 정지 상태에서는 발소리 로직 자체가 실행되지 않도록 상위에서 차단한다.
그 다음 groundCheck를 통해 플레이어가 지면에 접촉해 있는지를 확인하는데, 이는 공중 상태에서 발소리가 재생되는 비현실적인 상황을 방지하기 위한 물리적 조건 필터다.
이 두 조건을 통과한 이후에야 지형 상태 분기가 이루어지며, isMeadow, isForest, isDesert, isCave는 이미 충돌 계층에서 설정된 환경 상태 값이므로 Move()는 레이어를 직접 검사하지 않고 계산된 상태를 소비하는 역할만 수행한다는 점이 중요하다.
이렇게 분리함으로써 이동 로직이 지형 판정 책임까지 떠안지 않게 되고, 책임 경계가 유지된다.
이후 SurfaceType enum을 인자로 PlaySound에 전달하는데, 문자열이나 정수 대신 enum을 사용함으로써 타입 안정성을 확보하고 컴파일 단계에서 분기 누락을 방지한다.
StartCoroutine을 사용하는 이유는 Move()가 Update()에서 매 프레임 호출되는 구조이기 때문에, 발소리를 직접 호출하면 프레임 단위로 중첩 재생되는 문제가 발생하기 때문이다.
코루틴 내부에서 WaitForSeconds와 isRunning 플래그로 재생 간격을 제어함으로써, 이동 상태는 유지하되 발소리는 일정 주기로만 발생하도록 조정한다.
5. 개발 의도
이 게시글의 의도는 NGUI 기반 환경 설정 UI가 단순히 슬라이더를 움직이는 화면이 아니라, 게임의 오디오 실행 계층과 역할을 분리한 채로 실시간 반영까지 완성하는 구조를 보여주는 것이다.
SettingUi_는 사용자의 조작을 의미 있는 값으로 변환해 제공하고, SoundManager는 그 값을 받아 최종 출력과 재생을 통제한다.
음소거 또한 UI에서 오디오 컴포넌트를 직접 만지지 않고, 상태 변수로 의도를 전달한 뒤 SoundManager에서 일괄 적용한다.
발소리는 PlayerController가 상황을 판단하지만, 재생 정책(볼륨, 음소거, 3D 감쇠, 동적 생성)은 SoundManager를 통해 일관되게 처리되므로 시스템이 커져도 오디오 정책이 분산되지 않는다.
