유니티 RPG - 46. 오디오 관리자
Intro
이번 포스팅은 게임에서 빠질 수 없는 “사운드” 를 책임/관리할 클래스를 구현해보았습니다.
사운드는 두가지가 있습니다.
- BGM : 배경음악
- 배경음악은 한번에 하나만 출력이되게할 것입니다.
- SFX : 효과음
- 효과음은 배경음악과 다르게 여러개의 효과음이 동시에 출력될 수 있어야합니다.
- 예를들어, 몬스터가 처치될 때 무기를 휘두르는 소리, 몬스터가 죽을 때 내는 소리, 아이템이 떨어지는 소리 등
게임에서 사용될 BGM 과 SFX 파일을 구하고, JSON 파일에 각 사운드의 데이터를 작성합니다.
그리고 AudioManager 클래스를 두어, 사운드 데이터를 불러오고 사용하는것 까지를 구현해보았습니다.
사운드 준비
음원은 무료 사운드를 제공해주는 사이트들에서 구할 수 있었습니다.
Sounds 폴더를 만들어 여기에 음원을 두고, Addressable 에 등록합니다.
AudioManager 클래스
AudioManager 클래스는 사운드 데이터를 로드하여 캐싱하고, 음원을 실행할 수 있는 함수를 제공합니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
[System.Serializable]
public class BGMData
{
public string id;
public string clipName;
public float volume;
public bool loop;
}
[System.Serializable]
public class BGMList
{
public List<BGMData> bgmList;
}
[System.Serializable]
public class SFXData
{
public string id;
public string clipName;
public float volume;
public bool loop;
}
[System.Serializable]
public class SFXList
{
public List<SFXData> sfxList;
}
public class AudioManager : Singleton<AudioManager>
{
#region ** BGM Settings **
private AudioSource bgmSource;
#endregion
#region ** SFX Settings **
private List<AudioSource> sfxSource;
private int currentSFXIndex = 0;
private int sfxPoolSize = 10; // SFX 풀 사이즈
#endregion
private Dictionary<string, BGMData> bgmDictionary; // BGM 캐싱 데이터
private Dictionary<string, SFXData> sfxDictionary; // SFX 캐싱 데이터
private Dictionary<string, List<AudioSource>> loopedSFXMap = new(); // 반복재생되는 SFX를 추적하기위한 딕셔너리
protected override void Awake()
{
LoadBGMData();
LoadSFXData();
InitAudioSources();
}
// 초기화
private void InitAudioSources()
{
// BGM
GameObject bgmObject = new GameObject("BgmPlayer");
bgmObject.transform.parent = transform;
bgmSource = bgmObject.AddComponent<AudioSource>();
// SFX
GameObject sfxObject = new GameObject("SfxPlayer");
sfxObject.transform.parent = transform;
sfxSource = new List<AudioSource>();
for (int i = 0; i < sfxPoolSize; i++)
{
AudioSource source = sfxObject.AddComponent<AudioSource>();
sfxSource.Add(source);
}
}
// BGM 데이터 불러오기
private void LoadBGMData()
{
string path = Path.Combine(Application.persistentDataPath, "bgmData.json");
if(File.Exists(path))
{
string jsonData = File.ReadAllText(path);
BGMList bgmList = JsonUtility.FromJson<BGMList>(jsonData);
bgmDictionary = new Dictionary<string, BGMData>();
foreach(var bgm in bgmList.bgmList)
{
bgmDictionary[bgm.id] = bgm;
}
}
else
{
Debug.LogWarning("bgmData.json 파일이 없음.");
}
}
// BGM 데이터 불러오기
private void LoadSFXData()
{
string path = Path.Combine(Application.persistentDataPath, "sfxData.json");
if (File.Exists(path))
{
string jsonData = File.ReadAllText(path);
SFXList list = JsonUtility.FromJson<SFXList>(jsonData);
sfxDictionary = new Dictionary<string, SFXData>();
foreach (var sfx in list.sfxList)
{
sfxDictionary[sfx.id] = sfx;
}
}
else
{
Debug.LogWarning("sfxData.json 파일이 없음.");
}
}
// BGM 실행(ID)
public void PlayBGM(string bgmId)
{
// 이전 BGM 정지
if(bgmSource.isPlaying)
{
bgmSource.Stop();
}
// BGM ID로 BGM 실행
if(bgmDictionary.TryGetValue(bgmId, out BGMData bgmData))
{
ResourceManager.Instance.LoadSound(bgmId, bgm =>
{
// 성공
if (bgm != null)
{
bgmSource.clip = bgm;
bgmSource.volume = bgmData.volume;
bgmSource.loop = bgmData.loop;
bgmSource.Play();
}
else
{
Debug.Log($"오디오 클립을 찾을 수 없음 : {bgmData.clipName}");
}
});
}
else
{
Debug.Log($"BGM ID를 찾을 수 없음 : {bgmId}");
}
}
// SFX 실행(ID)
public void PlaySFX(string sfxId)
{
// BGM ID로 BGM 실행
if (sfxDictionary.TryGetValue(sfxId, out SFXData sfxData))
{
ResourceManager.Instance.LoadSound(sfxId, sfx =>
{
// 성공
if (sfx != null)
{
for(int index = 0; index < sfxPoolSize;index++)
{
int loopIndex = (index + currentSFXIndex) % sfxPoolSize;
if (sfxSource[loopIndex].isPlaying)
continue;
currentSFXIndex = loopIndex;
sfxSource[currentSFXIndex].clip = sfx;
sfxSource[currentSFXIndex].volume = sfxData.volume;
sfxSource[currentSFXIndex].loop = sfxData.loop;
sfxSource[currentSFXIndex].Play();
// 반복재생되는 SFX 는 추적대상
if(sfxSource[currentSFXIndex].loop)
{
// 추적 등록
if (!loopedSFXMap.ContainsKey(sfxId))
loopedSFXMap[sfxId] = new List<AudioSource>();
loopedSFXMap[sfxId].Add(sfxSource[currentSFXIndex]);
}
else
{
StartCoroutine(CleanupClipAfterPlay(sfxSource[currentSFXIndex]));
}
break;
}
}
else
{
Debug.Log($"오디오 클립을 찾을 수 없음 : {sfxData.clipName}");
}
});
}
else
{
Debug.Log($"BGM ID를 찾을 수 없음 : {sfxId}");
}
}
// SFX 중지(ID)
public void StopSFX(string sfxId)
{
// ID로 중지할 SFX 탐색
if(loopedSFXMap.TryGetValue(sfxId, out var sources))
{
// ID 키로 추적된 모든 AudioSource 중지
foreach(var src in sources)
{
if (src.isPlaying)
src.Stop();
src.clip = null;
}
// 추적 제거
loopedSFXMap.Remove(sfxId);
}
}
// 사운드 출력 후 AudioSource 정리
private IEnumerator CleanupClipAfterPlay(AudioSource source)
{
yield return new WaitWhile(() => source.isPlaying);
if(!source.loop)
{
source.clip = null;
}
}
}
- BGM 은 한번에 하나만 실행할 수 있으면 되어 AudioSource 컴포넌트가 하나 필요합니다.
- SFX 는 한번에 여러개 실행될 수 있어야 하므로 여러개의 AudioSource 가 필요합니다.
- 사용되지 않고있는 SFX AudioSource 를 통해 사운드를 출력하도록 합니다.
- 사운드를 실행하기 위해서는 사운드 파일의 이름(id)가 필요합니다.
- JSON 파일로 작성된 사운드 데이터를 캐싱하여 딕셔너리에 저장합니다.
- PlayBGM, PlaySFX() 함수를 통해 ‘id’를 가지고 어드레서블에 등록된 사운드를 가져와 실행합니다.
- SFX 의 경우 사용된 후에는 AudioClip 을 비우도록합니다.
- 반복 재생되는 SFX 의 경우 효과음을 끄기위한 함수 StopSFX() 가 필요합니다.
- 끄기위해 loopedSFXMap 이라는 딕셔너리를 두어, 반복재생되는 SFX 의 경우 여기에 추가해둡니다.
- loopedSFXMap 에 있는 반복재생되는 SFX를 끄기위해서 사운드 Id로 되어있는 모든 AudioSource 들을 가져와 재생종료합니다.
- 재생을 종료하고, 해당 사운드 Id 의 loopedSFXMap 를 제거합니다.
- 씬별로 지정된 BGM 를 출력하기위해 SceneBGMInfo 라는 게임 오브젝트를 씬마다 두어 씬별 BGM 를 출력하도록 합니다.
- 씬이 변경될 때 이전 씬의 BGM 이 출력되지 않도록 하였습니다.
아래는 씬별 BGM 정보를 가지고있는 클래스입니다.
AuduoManager 인스턴스에 접근하여 PlayBGM() 함수 호출로 배경음악을 출력합니다.
public class SceneBGMInfo : MonoBehaviour
{
[Tooltip("이 씬에서 재생한 BGM Key")]
[SerializeField] private string bgmKey;
private void Start()
{
if(!string.IsNullOrEmpty(bgmKey))
{
AudioManager.Instance.PlayBGM(bgmKey);
}
}
}
사운드 데이터 JSON 파일
사운드 데이터를 저장할 JSON 파일의 형식은 아래와 같습니다.
// SfxData.json
{
"sfxList": [
{
"id": "Gold",
"clipName": "Gold.mp3",
"volume": 1.0,
"loop": false
},
{
"id": "InventoryChange",
"clipName": "InventoryChange.mp3",
"volume": 1.0,
"loop": false
}
]
}
id 와 오디오 클립 이름을 같게 지정하여
PlaySFX() 함수를 사용할 때 필요한 id 를 넘기고, ResourceManager 에서 id + .mp3 확장자로 사운드 에셋을 로드합니다.
따라서 ResourceManager 에 아래의 코드를 추가하여야합니다.
// 사운드 불러오기
public void LoadSound(string clipName, System.Action<AudioClip> onLoaded)
{
Addressables.LoadAssetAsync<AudioClip>(soundRef + clipName + ".mp3").Completed += handle =>
{
if (handle.Status == AsyncOperationStatus.Succeeded)
{
AudioClip loadedClip = handle.Result;
onLoaded?.Invoke(loadedClip);
}
else
{
Debug.Log($"다음 Clip을 가져오는데 실패함.{clipName}");
onLoaded?.Invoke(null);
}
};
}
- 다른 에셋들을 Load 할 때와 마찬가지로 작성하였습니다.
효과음 사용 예시
상점에서 아이템을 구매하거나, NPC 의 대화텍스트가 출력될 때 효과음을 출력하는 코드입니다.
// StoreItemSlotUI.cs
// 구매 버튼 이벤트
private void PerchaseItem()
{
if(DataManager.Instance.GetPlayerData().Gold >= price)
{
AudioManager.Instance.PlaySFX("Gold");
inventory.AddItem(curItemData);
DataManager.Instance.GetPlayerData().UseGold(price);
}
else
{
Debug.Log("골드가 부족합니다.");
}
}
// DialogueManager.cs
// 타이핑 효과
private IEnumerator TypePage(string page)
{
isTypipng = true;
dialogueText.text = "";
AudioManager.Instance.PlaySFX("DialogueEffect");
foreach (char letter in page.ToCharArray())
{
dialogueText.text += letter;
yield return new WaitForSeconds(typingSpeed);
}
isTypipng = false;
AudioManager.Instance.StopSFX("DialogueEffect");
}
플레이 영상
게임을 실행하여 BGM 과 효과음이 정상적으로 출력되는것을 확인할 수 있었습니다.
댓글남기기