본문 바로가기
Development/Internship

[멋사 로켓단 인턴쉽] 12일차 - SFXController 리팩토링

by Mobics 2025. 8. 22.

목차


    멋쟁이사자처럼 로켓단 인턴쉽

    25.08.22

    회의록

    >> 안건

    • 업무 업데이트
      • 질문 혹은 피드백 요청
    • 주요 안건
      • SFX, BGM 선별 및 할당
      • 튜토리얼 이미지 적용
    • 향후 마일스톤
      • 디테일 보강

    >> 회의 내용

    • SFX 및 BGM 선별 및 선정 후 게임에 적용
    • 튜토리얼 완성
    • Itch.io 업로드 완료

     

    필요한 BGM 및 SFX 고민

    : 이전 시간에 고민했던 BGM 및 SFX 목록이다.

     

    - 게임에 필요한 BGM 및 SFX 목록 정리

    • BGM
      • Title BGM
      • InGame BGM
    • SFX
      • Click SFX
      • UI Close SFX
      • InGame Count SFX
      • (InGame Start SFX)
      • Stamp SFX
      • (InGame NewRecord SFX)
      • Obstacle SFX - 종류별로
      • Result NewRecord SFX

    --> 근데 다른 팀원 분께서 근무시간 외에 SFX를 다 찾아오셨다.

     

    - 팀원 분이 찾아오신 SFX 목록

    • Common
      • Click SFX
    • InGame 
      • Stamp SFX
      • Document SFX
      • Document Classfication SFX
        1. 성공
        2. 실패
      • Obstacle SFX - 종류별로
        1. 벌레, 포스트잇 타격소리
        2. 손 쳐내는 소리
        3. 파일철 / 서류봉투에서 꺼내는 소리
      • Speed Up SFX

     

    버그 수정

    : 게임 시작을 눌렀을 때, InGameBGM이 재생되고 3초 센 뒤에 InGameBGM이 다시 재생되는 버그

    --> InGameState.cs의 OnEnter()과 InGameController.cs의 StartGame()에서 둘다 BGM을 호출했기 때문에 발생한 버그, InGameState.cs의 OnEnter()의 BGM 재생 코드를 삭제

     

    SFXController 리팩토링

    : SFX를 동시에 재생할 수 있고 필요하면 SFX를 반복재생 할 수 있도록 리팩토링

    --> 오브젝트 풀링 방식으로 구현하여 AudioSource를 필요한 만큼 만들고 사용하도록

     

    >> 작성한 코드

    <hide/>
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    
    public class SFXController : Singleton<SFXController>
    {
        // SFX를 추가하실 때 여기에 추가해주세요.
        [Header("Common")]
        [SerializeField] private AudioClip buttonClick;
    
        [Header("InGame")]
        [SerializeField] private AudioClip stamp;
        [SerializeField] private AudioClip docSuccess;
        [SerializeField] private AudioClip docFail;
        [SerializeField] private AudioClip docSwap;
        [SerializeField] private AudioClip obsBugPostHit;
        [SerializeField] private AudioClip obsProcessTry;
        [SerializeField] private AudioClip obsHandHit;
        [SerializeField] private AudioClip obsFileEnvelopeOut;
        [SerializeField] private AudioClip speedUp;
        [SerializeField] private AudioClip timeOutAlert;
        
        
        // 여기까지
    
        private List<AudioSource> _sfxSources = new List<AudioSource>();
        private const int INITIAL_POOL_SIZE = 11;
    
        private bool _isSFXOn = true;
        public bool IsSFXOn() => _isSFXOn;
    
        protected override void Initialize()
        {
            SceneManager.sceneLoaded += OnSceneLoaded;
    
            // 초기 풀 생성
            for (int i = 0; i < INITIAL_POOL_SIZE; i++)
            {
                var src = gameObject.AddComponent<AudioSource>();
                src.playOnAwake = false;
                src.loop = false;
                _sfxSources.Add(src);
            }
        }
    
        private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
        {
        	//SceneManager.sceneLoaded -= OnSceneLoaded;
            AudioManager.Instance.SetSFXController(this);
        }
    
        // SFX를 추가하신 뒤, 아래 함수 모음에 재생 함수를 작성해주세요. 그리고 작성하신 함수를 통해 사용하시면 됩니다.
        #region PlaySFX 함수 모음
        
        public void PlayButtonClick() => PlaySFX(buttonClick);
        public void PlayStamp() => PlaySFX(stamp);
        public void PlayDocSuccess() => PlaySFX(docSuccess);
        public void PlayDocFail() => PlaySFX(docFail);
        public void PlayDocSwap() => PlaySFX(docSwap);
        public void PlayObsBugPostHit() => PlaySFX(obsBugPostHit);
        public void PlayObsProcessTry() => PlaySFX(obsProcessTry);
        public void PlayObsHandHit() => PlaySFX(obsHandHit);
        public void PlayObsFileEnvelopeOut() => PlaySFX(obsFileEnvelopeOut);
        public void PlaySpeedUp() => PlaySFX(speedUp);
        public AudioSource PlayTimeOutAlert() => PlayLoopSFX(timeOutAlert);
        
        #endregion
    
        private void PlaySFX(AudioClip clip)
        {
            if (!_isSFXOn || clip == null) return;
    
            var src = GetFreeAudioSource();
            src.PlayOneShot(clip);
        }
    
        public AudioSource PlayLoopSFX(AudioClip clip)
        {
            if (!_isSFXOn || clip == null) return null;
    
            var src = GetFreeAudioSource();
            src.loop = true;
            src.clip = clip;
            src.Play();
    
            return src;
        }
    
        public void StopLoopSFX(AudioSource src)
        {
            if (src != null && src.isPlaying)
            {
                src.Stop();
                src.loop = false;
                src.clip = null;
            }
        }
    
        private AudioSource GetFreeAudioSource()
        {
            foreach (var src in _sfxSources)
            {
                if (!src.isPlaying) return src;
            }
    
            // 다 쓰고 있으면 하나 더 추가
            var newSrc = gameObject.AddComponent<AudioSource>();
            newSrc.playOnAwake = false;
            _sfxSources.Add(newSrc);
            return newSrc;
        }
    
        public void SetSFXOn(bool isSFXOn)
        {
            _isSFXOn = isSFXOn;
    
            if (!_isSFXOn)
            {
                // 꺼지면 모든 SFX 정지
                foreach (var src in _sfxSources)
                    src.Stop();
            }
        }
    }

     

    >> 테스트

    : 코드를 작성하고 테스트해보니, 문제가 여러 개 있었다.

    1. 막상 AudioSource를 만든 만큼 활용하지 않았다.
    2. SFX를 반복재생하려고 할 때 PlayLoopSFX()에서 AudioSource를 반환하고 SFX의 반복재생을 중지할 때는 그 AudioSource를 받아서 멈추려고 했으나 그렇게 되면 SFX를 반복재생하려고 하는 곳마다 Audio 관련 코드가 많이 늘어난다.

    --> 알고보니 AudioSource.PlayOneShot()을 사용하면 따로 AudioSource의 Clip에 영향을 주지 않을 뿐더러 PlayOneShot()으로 재생한 다른 AudioClip과도 상관없이 재생이 가능했다. 즉, 오브젝트 풀링 방식으로 구현할 필요가 없다.

     

    ▶ 다시 SFXController를 오브젝트 풀링 방식에서 원래대로 복구하고 반복 재생이 필요한 SFX 따로 관리할 Dictionary를 만들어서 Audio 관련 코드는 오직 SFXController에서만 작성되도록 구현

     

    ※ AudioSource.PlayOneShot()의 공식 레퍼런스

    https://docs.unity3d.com/ScriptReference/AudioSource.PlayOneShot.html

     

    └ 최종 코드

    - SFXController.cs

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    
    public class SFXController : Singleton<SFXController>
    {
        // SFX를 추가하실 때 여기에 추가해주세요.
        [Header("Common")]
        [SerializeField] private AudioClip buttonClick;
        
        [Header("InGame")]
        [SerializeField] private AudioClip stamp;
        [SerializeField] private AudioClip docSuccess;
        [SerializeField] private AudioClip docFail;
        [SerializeField] private AudioClip docSwap;
        [SerializeField] private AudioClip obsBugPostHit;
        [SerializeField] private AudioClip obsProcessTry;
        [SerializeField] private AudioClip obsHandHit;
        [SerializeField] private AudioClip obsFileEnvelopeOut;
        [SerializeField] private AudioClip newRecordResult;
        [SerializeField] private AudioClip newRecordScoreBar;
        [SerializeField] private AudioClip speedUp;
        [SerializeField] private AudioClip fever;
        [SerializeField] private AudioClip timeOutAlert;
        
        
        // 여기까지
    
        private AudioSource _sfxSource;                             // 단발성 AudioSource
        private Dictionary<AudioClip, AudioSource> _loopSources;    // 반복용 AudioSource
        private bool _isSFXOn = true;       // SFX가 켜져있는지 여부
        public bool GetIsSFXOn() => _isSFXOn;
        
        protected override void Initialize()
        {
            SceneManager.sceneLoaded += OnSceneLoaded;
            
            // AudioSource 초기화
            _sfxSource = gameObject.AddComponent<AudioSource>();
            _sfxSource.playOnAwake = false;
            _loopSources = new Dictionary<AudioClip, AudioSource>();
        }
    
        private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
        {
            AudioManager.Instance.SetSFXController(this);
        }
    
        // SFX를 추가하신 뒤, 아래 함수 모음에 재생 함수를 작성해주세요. 그리고 작성하신 함수를 통해 사용하시면 됩니다.
        // 1번 재생 : PlaySFX()
        // 반복 재생 : PlayLoopSFX()
        // 반복 재생 중지 : StopLoopSFX()
        #region PlaySFX 함수 모음
    
        public void PlayButtonClick() => PlaySFX(buttonClick);
    
        public void PlayStamp() => PlaySFX(stamp);
        
        public void PlayDocSuccess() => PlaySFX(docSuccess);
        public void PlayDocFail() => PlaySFX(docFail);
        public void PlayDocSwap() => PlaySFX(docSwap);
        public void PlayObsBugPostHit() => PlaySFX(obsBugPostHit);
        public void PlayObsProcessTry() => PlaySFX(obsProcessTry);
        public void PlayObsHandHit() => PlaySFX(obsHandHit);
        public void PlayObsFileEnvelopeOut() => PlaySFX(obsFileEnvelopeOut);
        public void PlaySpeedUp() => PlaySFX(speedUp);
        public void PlayFever() => PlaySFX(fever);
        public void PlayTimeOutAlert() => PlayLoopSFX(timeOutAlert);
        public void StopTimeOutAlert() => StopLoopSFX(timeOutAlert);
        public void PlayNewRecordResult() => PlaySFX(newRecordResult);
        public void PlayNewRecordScoreBar() => PlaySFX(newRecordScoreBar);
        #endregion
    
        // SFX 1번 재생
        private void PlaySFX(AudioClip clip)
        {
            if (!_isSFXOn || clip == null) return;
            _sfxSource.PlayOneShot(clip);
        }
        
        // SFX 반복 재생
        private void PlayLoopSFX(AudioClip clip)
        {
            if (!_isSFXOn || clip == null) return;
            if (_loopSources.ContainsKey(clip)) return;     // 이미 재생 중이면 패스
            
            var src = gameObject.AddComponent<AudioSource>();
            src.playOnAwake = false;
            src.loop = true;
            src.clip = clip;
            src.Play();
            _loopSources[clip] = src;
        }
        
        // SFX 반복 재생 중지
        private void StopLoopSFX(AudioClip clip)
        {
            if (clip == null || !_loopSources.ContainsKey(clip)) return;
    
            var src = _loopSources[clip];
            if (src != null && src.isPlaying)
            {
                src.Stop();
                Destroy(src);
            }
            _loopSources.Remove(clip);
        }
    
        // _isSFXOn 조정
        public void SetSFXOn(bool isSFXOn)
        {
            _isSFXOn = isSFXOn;
    
            if (!_isSFXOn)  // 모든 SFX 정지
            {
                _sfxSource.Stop();
                
                foreach (var key in _loopSources)
                    if (key.Value != null) key.Value.Stop();
                
                _loopSources.Clear();
            }
        }
    }

     

     

    └ 발견한 버그

    • LoopSFX 재생 중에 일시중지한 다음 소리를 껐다가 키면 LoopSFX 재생이 안되는 버그
    • 결과창으로 넘어가서도 계속 LoopSFX가 재생되는 버그 --> 타이틀로 가서도 계속 재생 / 다시 게임을 시작해서 카운팅이 끝나고 게임이 시작해야 풀림

    ▶ 계속 LoopSFX가 재생되는 버그는 다른 팀원 분께서 InGameController.cs의 EndGame() 함수에서 게임이 끝나고 결과창UI가 나오기 전에 LoopSFX를 꺼주는 함수를 호출해서 해결하셨다.

    --> 남은 버그는 추후 수정 예정

     

    New Record시, SFX가 재생되도록 적용

    : New Record SFX가 추가되어 New Record시, SFX가 재생되도록 적용해주었다. (게임 중 + 결과창)

    --> 결과창에서 New Record시, DOTween 효과 도중에 Audio를 재생하기 위해 Seqeunce 안에 콜백(Action)을 삽입.

     

    └ 최종 코드

    1. ResultUIController.cs

    <hide/>
    // New Record 시, 효과
    private void ShowNewRecordEffect()
    {
        newRecordImage.gameObject.SetActive(true);
        
        // 초기화 (작고 안 보이는 상태)
        newRecordImage.color = new Color(1f, 1f, 1f, 0f);
        newRecordImage.rectTransform.localScale = Vector3.zero * 0.8f;
        
        Sequence seq = DOTween.Sequence();
        // Fade In + Scale Up
        seq.Append(newRecordImage.DOFade(1f, 0.5f));
        seq.Join(newRecordImage.rectTransform.DOScale(1.2f, 0.3f).SetEase(Ease.OutBack));
        // SFX 재생
        seq.AppendCallback(() =>
        {
            AudioManager.Instance.SFX.PlayNewRecordResult();
        });
        // 살짝 튕기면서 원래 크기로
        seq.Append(newRecordImage.rectTransform.DOScale(1f, 0.2f).SetEase(Ease.OutBack));
        // 착! 강조
        seq.Append(newRecordImage.rectTransform.DOScale(0.95f, 0.1f).SetEase(Ease.InQuad));
        seq.Append(newRecordImage.rectTransform.DOScale(1f, 0.15f).SetEase(Ease.OutQuad));
    }

     

    2. InGameState.cs

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using Unity.VisualScripting;
    using UnityEngine;
    
    public class InGameState : IGameState
    {
        public void OnEnter()
        { 
           GameManager.Instance.StartCoroutine(StartGame());
           UIManager.Instance.inGameUIController.ShowInGameUI();
        }
        
        public void OnUpdate()
        {
            // TODO: 유저의 최고기록 불러오기 (임시: 'K'를 누르면 연출 재생)
            // New Record시, 점수판에 New Record Image 연출 재생
            if (Input.GetKeyDown(KeyCode.K))
            {
                AudioManager.Instance.SFX.PlayNewRecordScoreBar();
                InGameUIController.Instance.scoreUIController.ShowNewRecordImage();
            }
        }
        
        public void OnExit()
        {
           
        }
    
        IEnumerator StartGame()
        {
            yield return GameManager.Instance.inGameController.SetInitGame();
            yield return GameManager.Instance.inGameController.RunSequence();
        }
    }