5 분 소요

Intro

이번 포스팅은 게임에서 빠질 수 없는 “사운드” 를 책임/관리할 클래스를 구현해보았습니다.

사운드는 두가지가 있습니다.

  1. BGM : 배경음악
    • 배경음악은 한번에 하나만 출력이되게할 것입니다.
  2. SFX : 효과음
    • 효과음은 배경음악과 다르게 여러개의 효과음이 동시에 출력될 수 있어야합니다.
    • 예를들어, 몬스터가 처치될 때 무기를 휘두르는 소리, 몬스터가 죽을 때 내는 소리, 아이템이 떨어지는 소리 등

게임에서 사용될 BGM 과 SFX 파일을 구하고, JSON 파일에 각 사운드의 데이터를 작성합니다.

그리고 AudioManager 클래스를 두어, 사운드 데이터를 불러오고 사용하는것 까지를 구현해보았습니다.

사운드 준비

음원은 무료 사운드를 제공해주는 사이트들에서 구할 수 있었습니다.

image

Sounds 폴더를 만들어 여기에 음원을 두고, Addressable 에 등록합니다.

image

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 이 출력되지 않도록 하였습니다.

image


아래는 씬별 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 과 효과음이 정상적으로 출력되는것을 확인할 수 있었습니다.

댓글남기기