본문 바로가기
Development/Internship

[멋사 로켓단 인턴쉽] 13일차 - Audio 관련 기능 추가 및 버그 수정

by Mobics 2025. 8. 25.

목차


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

    25.08.25

    회의록

    >> 안건

    • 업무 업데이트
      • 질문 혹은 피드백 요청
    • 주요 안건
      • 게임 몰입감 증대를 위한 디테일 보강
      • 몰입감을 해치는 요소, 버그 수정
      • 2차 밸런싱 회의
    • 향후 마일스톤
      • 앱인토스 앱 등록, API 호출코드 적용

    >> 회의 내용

    - 밸런싱 추가 회의

    : 지난 밸런싱 변경 내용

    난이도 밸런싱 기존 1차 안 2차 안
    1 DAY당 시간 120 → 30초 10초 10초
    장애물 처리 난이도 증가 단위 3일 5일 5일
    난이도 증가 최대 단계 무한대 3단계(1,5,10)
    (Max도달 1분40초)
    5단계(1,5,10,15,20)
    (Max도달 3분 20초)
    장애물 등장 확률 Day마다 5%씩 증가 5%/20%/40% 4%/10%/12%/20%/30%
    장애물 처리 난이도(처리횟수 증가) 3일 5일 1/1/2/2/3
    일과시간 실시간 감소주기 (1씩) Time.delta 1s/0.6s/0.2s 1s/0.8s/0.6s/0.4s/0.2s
    서류 처리 시 일과시간 증감수치 증가: Day
    감소: Day *5
    증가: 1/3/5
    감소: 3/7/12
    증가: 1/1/2/2/3
    감소: 3/5/7/9/12
    • DAY당 시간, 처리 난이도 증가 단위는 그대로 유지
      • 대신 최대 단계 확장
    • 최고난이도 갔을때
      • 어떻게 최대한 빨리 피버타임을 연속적으로 터트리냐가 생존의 핵심
    • Speed Up시 체감 연출이 조금 부족한 듯함
      • 난이도 증가 단계 별로 BGM 속도증가 추가하기.
    • 인게임 QA 사항 최종 피드백 진행

     

    Audio 토글 수정

    : Audio 토글을 눌렀을 때 BGM을 정지/재생하는 것이 아니라 Mute/UnMute하도록

     

    >> 수정한 코드

    <hide/>
    // BGM 음소거
    private void MuteBGM()
    {
        if (_bgmSource != null)
        {
            _bgmSource.mute = true;
        }
    }
    
    // BGM 음소거 해제
    private void UnmuteBGM()
    {
        if (_bgmSource != null)
        {
            _bgmSource.mute = false;
        }
    }
    
    // _isBGMOn값을 조정하고 그에 따라 BGM을 음소거 설정 및 해제
    public void SetBGMOn(bool isBGMOn)
    {
        _isBGMOn = isBGMOn;
        if (_isBGMOn)   // 음소거 해제
        {
            UnmuteBGM();
        }
        else   // 음소거
        {
            MuteBGM();
        }
    }

     

    게임 결과창에서 BGM의 볼륨을 절반으로 설정

    : 게임 결과창에서 BGM의 볼륨을 절반으로 설정하고, '퇴근하기' 버튼을 누르면 다시 BGM의 볼륨을 원상복귀

     

    >> 작성한 코드

    - BGMController.cs

    <hide/>
    // BGM 볼륨을 절반으로 설정
    public void SetBGMVolumeHalf()
    {
        if (_bgmSource != null)
        {
            _bgmSource.volume = 0.5f;
        }
    }
    
    // BGM 볼륨을 최대로 설정
    public void SetBGMVolumeMax()
    {
        if (_bgmSource != null)
        {
            _bgmSource.volume = 1f;
        }
    }

     

    - ResultUIController.cs

    <hide/>
    public void InitResultItem(GameResultData resultData)
    {
        // 점수 보내기
        //해당기능에서는 점수를 string 타입으로 받음. 임시로 정수 형변환을 시켰지만
        //추후 반올림같은 로직을 넣는다면 그렇게 한 결과값을 인수로 넣도록 수정할 것.
        NetworkManager.Instance.SendScore((int)resultData.Score);
        
        // FadeOut Panel 초기화
        fadeOutCanvasGroup.alpha = 0;
        
        // 퇴근 버튼 비활성화
        _quitButton.gameObject.SetActive(false);
        
        // New Record 이미지 비활성화
        newRecordImage.gameObject.SetActive(false);
        
        // BGM 볼륨을 절반으로 설정
        AudioManager.Instance.BGM.SetBGMVolumeHalf();
        
        // 처음에는 0으로 초기화
        dayText.text = "0";
        maxComboText.text = "0";
        scoreText.text = "0";
        
        // TODO: 유저의 최고기록 불러오기 (임시: PlayerPrefs)
        float bestScore = PlayerPrefs.GetFloat("BestScore", 0f);
        
        Sequence seq = DOTween.Sequence();
        
        // Day Count Up
        seq.Append(DOTween.To(() => 0, x => dayText.text = x.ToString() + "일", resultData.Day, 1f));
        seq.AppendInterval(0.2f);
        
        // MaxCombo Count Up
        seq.Append(DOTween.To(() => 0, x => maxComboText.text = x.ToString(), resultData.MaxCombo, 1f));
        seq.AppendInterval(0.2f);
    
        // Score Count Up
        seq.Append(DOTween.To(() => 0, x => scoreText.text = x.ToString("N0"), resultData.Score, 1.5f)
            .OnComplete(() =>
            {
                // 퇴근 버튼 활성화
                _quitButton.gameObject.SetActive(true);
                
                // New Record 체크
                if (resultData.Score > bestScore)
                {
                    PlayerPrefs.SetFloat("BestScore", resultData.Score);
                    ShowNewRecordEffect();
                }
            }));
    }

     

    - InGameController.cs

    <hide/>
    //게임 종료. 결과 보고
    public IEnumerator EndGame()
    {
        //TODO: 게임 끝낼 시 실행할 로직
        
        //시간 정지
        timeController.StopTime();
        
        // 시간 부족 SFX 반복 재생 중지
        AudioManager.Instance.SFX.StopTimeOutAlert();
        
        //ex.게임오버 연출, 결과창UI등
        
        var popupController = UIManager.Instance.popupUIController;
    
        if (!_useRetry)//재시작 활성화 시 엔드연출 스킵
        {
            //게임오버
            var gameOverUI = popupController.gameOverUIController;
            yield return gameOverUI.ShowSequence(); //게임오버 연출동안 딜레이
        }
        
        
        if (!_skipResultUI)//필요 시 스킵
        {
            //결과창
            var resultUI = popupController.resultUIController;
            popupController.ShowResultUI();
            resultUI.InitResultItem(new GameResultData(
                timeController._day,
                GameManager.Instance.GetClassification().GetMaxCombo(),
                GameManager.Instance.GetClassification().GetScore()));
        }
        
        //게임이 끝난 후, 바로 돌아가지 않고 대기.
        while (!_quitGame)
        {
            yield return null;
        }
        
        //초기화.
        _initComplete = false;
        _skipResultUI = false;
        
        // BGM 초기화
        AudioManager.Instance.BGM.SetBGMVolumeMax();    // 볼륨을 최대로
        
        //재시작 필요 시.
        if (_useRetry)
        {
            //새 게임 코루틴 활성화
            yield return GameManager.Instance.StartCoroutine(SetInitGame());
            GameManager.Instance.StartCoroutine(RunSequence());
            
            //재시작을 위해 타이틀로 복귀하지 않고 기존 코루틴을 중단한다.
            yield break;
        }
        
        //인게임 UI 닫기
        UIManager.Instance.inGameUIController.HideInGameUI();
        UIManager.Instance.inGameUIController.HideTimeUI();
        UIManager.Instance.inGameUIController.HideInteractionUI();
        UIManager.Instance.inGameUIController.HideScoreUI();
        UIManager.Instance.inGameUIController.HideFeverUI();
        UIManager.Instance.inGameUIController.HideBackgroundUI();
        UIManager.Instance.inGameUIController.HideClockUI();
        UIManager.Instance.inGameUIController.HideClassificationUI();
        UIManager.Instance.inGameUIController.HideWaitThreeSecondsUI();
        UIManager.Instance.inGameUIController.HideDifficultyUpEffectUI();
    
        //타이틀 씬으로 복귀
        GameManager.Instance.ReturnToTitle();
    }

     

    게임 난이도가 상승하면 BGM을 배속 재생하도록

    : 게임 난이도가 상승하면 BGM이 더 빨리 재생되고, '퇴근하기' 버튼을 누르면 다시 BGM의 배속을 원상복귀

    --> 게임 난이도에 따라 미리 설정해둔 값만큼 배속되도록 구현하고 Mathf.Clamp()를 활용하여 level 값의 최솟값, 최대값을 설정

     

    ▶ Action을 활용, 함수를 구독하여 난이도가 상승되면 BGM이 배속 재생되는 함수를 호출하도록 구현

    : 다른 팀원 분께서 만들어 놓으신 'OnLevelChanged'를 활용

     

    - DifficultyManager.cs (Singleton)

    public static event Action OnLevelChanged; //난이도 상승때 동작하기 위한 이벤트. 구독하면 됩니다.

     

    ※ Unity 공식 문서 - Mathf.Clamp()

    https://docs.unity3d.com/ScriptReference/Mathf.Clamp.html

     

    >> 작성한 코드

    - BGMController.cs

    <hide/>
    protected override void Initialize()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
        _bgmSource = gameObject.AddComponent<AudioSource>();
        DifficultyManager.OnLevelChanged += SetBGMSpeedFast;     // 난이도가 상승하면 자동 실행되도록 구독
    }
    
    // BGM 배속 조절
    private void SetBGMSpeedFast()
    {
        int level = DifficultyManager.Instance.GetLevel(GameManager.Instance.GetTimeController()._day);
        float[] bgmSpeeds = { 1f, 1.1f, 1.2f, 1.3f, 1.5f };
        int temp = Mathf.Clamp(level, 0, bgmSpeeds.Length - 1);     // level이 5 이상 넘어가는 것을 방지하기 위한 임시값
        
        _bgmSource.pitch = bgmSpeeds[temp];
    }
    
    // BGM 속도 정상화
    public void SetBGMSpeedNormal()
    {
        if (_bgmSource != null)
        {
            _bgmSource.pitch = 1f;
        }
    }

     

    - InGameController.cs

    <hide/>
    //게임 종료. 결과 보고
    public IEnumerator EndGame()
    {
        //TODO: 게임 끝낼 시 실행할 로직
        
        //시간 정지
        timeController.StopTime();
        
        // 시간 부족 SFX 반복 재생 중지
        AudioManager.Instance.SFX.StopTimeOutAlert();
        
        //ex.게임오버 연출, 결과창UI등
        
        var popupController = UIManager.Instance.popupUIController;
    
        if (!_useRetry)//재시작 활성화 시 엔드연출 스킵
        {
            //게임오버
            var gameOverUI = popupController.gameOverUIController;
            yield return gameOverUI.ShowSequence(); //게임오버 연출동안 딜레이
        }
        
        
        if (!_skipResultUI)//필요 시 스킵
        {
            //결과창
            var resultUI = popupController.resultUIController;
            popupController.ShowResultUI();
            resultUI.InitResultItem(new GameResultData(
                timeController._day,
                GameManager.Instance.GetClassification().GetMaxCombo(),
                GameManager.Instance.GetClassification().GetScore()));
        }
        
        //게임이 끝난 후, 바로 돌아가지 않고 대기.
        while (!_quitGame)
        {
            yield return null;
        }
        
        //초기화.
        _initComplete = false;
        _skipResultUI = false;
        
        // BGM 초기화
        AudioManager.Instance.BGM.SetBGMVolumeMax();    // 볼륨을 최대로
        AudioManager.Instance.BGM.SetBGMSpeedNormal();  // 배속을 기본으로
        
        //재시작 필요 시.
        if (_useRetry)
        {
            //새 게임 코루틴 활성화
            yield return GameManager.Instance.StartCoroutine(SetInitGame());
            GameManager.Instance.StartCoroutine(RunSequence());
            
            //재시작을 위해 타이틀로 복귀하지 않고 기존 코루틴을 중단한다.
            yield break;
        }
        
        //인게임 UI 닫기
        UIManager.Instance.inGameUIController.HideInGameUI();
        UIManager.Instance.inGameUIController.HideTimeUI();
        UIManager.Instance.inGameUIController.HideInteractionUI();
        UIManager.Instance.inGameUIController.HideScoreUI();
        UIManager.Instance.inGameUIController.HideFeverUI();
        UIManager.Instance.inGameUIController.HideBackgroundUI();
        UIManager.Instance.inGameUIController.HideClockUI();
        UIManager.Instance.inGameUIController.HideClassificationUI();
        UIManager.Instance.inGameUIController.HideWaitThreeSecondsUI();
        UIManager.Instance.inGameUIController.HideDifficultyUpEffectUI();
    
        //타이틀 씬으로 복귀
        GameManager.Instance.ReturnToTitle();
    }

     

    BGM 및 SFX 볼륨 조절

    : BGM 및 SFX의 볼륨을 조절

    --> SFX는 PlayOneShot()으로 재생되기 때문에 재생할 때 따로 Volume을 조절하여 재생할 수 있도록 코드 추가 (기본 값 1f)

     

    ※ Unity 공식 문서 - PlayOneShot()

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

     

    >> 작성한 코드

    - BGMController.cs

    <hide/>
    // BGM 재생 (반복 O)
    private void PlayBGM(AudioClip clip)
    {
        if (!_isBGMOn || clip == null) return;
    
        _bgmSource.clip = clip;
        _bgmSource.loop = true;
        _bgmSource.volume = 0.6f;
        _bgmSource.Play();
    }
    
    // BGM 볼륨을 절반으로 설정
    public void SetBGMVolumeHalf()
    {
        if (_bgmSource != null)
        {
            _bgmSource.volume = 0.3f;
        }
    }
    
    // BGM 볼륨을 기본으로 설정
    public void SetBGMVolumeMax()
    {
        if (_bgmSource != null)
        {
            _bgmSource.volume = 0.6f;
        }
    }

     

    - SFXController.cs

    <hide/>
    // 적용 예시
    public void PlayDocSuccess() => PlaySFX(docSuccess, 0.6f);
    public void PlayDocFail() => PlaySFX(docFail, 0.6f);
    
    // SFX 1번 재생
    private void PlaySFX(AudioClip clip, float volume = 1f)
    {
        if (!_isSFXOn || clip == null) return;
        _sfxSource.PlayOneShot(clip, volume);
    }

     

    버그 수정

    : LoopSFX 재생 중에 일시중지한 다음 Audio 토글로 소리를 껐다가 키면 LoopSFX가 재생이 안되는 버그

    --> Audio 토글로 소리를 끌 때, BGMController와 마찬가지로 SFX도 Mute로 끄도록 수정

     

    >> 수정 중에 생긴 버그 및 해결

    : 이미 Audio 토글이 OFF인 상태에서 LoopSFX가 재생되는 상황이 된 다음 Audio 토글을 ON하면 LoopSFX가 재생되지 않는 버그 발생

    --> PlayLoopSFX() 함수 안에 토글 bool값에 따른 처리를 삭제하여 해결

     

    ▶ 토글 값에 따른 처리를 삭제하니, Audio 토글이 OFF인 상태에서 LoopSFX가 재생되는 상황이 되면 LoopSFX가 재생되는 버그가 발생

    --> PlayLoopSFX()를 통해 SFX를 처음 세팅할 때, Audio 토글이 OFF인지 확인하여 OFF 상태라면 Mute 하도록 추가

     

    >> 작성한 코드

    - SFXController.cs

    <hide/>
    // SFX 반복 재생
    private void PlayLoopSFX(AudioClip clip)
    {
        if (clip == null) return;
        if (_loopSources.ContainsKey(clip)) return;     // 이미 재생 중이면 패스
        
        var src = gameObject.AddComponent<AudioSource>();
        src.playOnAwake = false;
        src.loop = true;
        src.clip = clip;
        if (!_isSFXOn) src.mute = true;
        src.Play();
        _loopSources[clip] = src;
    }
    
    // _isSFXOn 조정
    public void SetSFXOn(bool isSFXOn)
    {
        _isSFXOn = isSFXOn;
        
        // 단발성 AudioSource
        if (_sfxSource != null)
            _sfxSource.mute = !_isSFXOn;
        
        // 루프성 AudioSources
        foreach (var key in _loopSources)
        {
            if (key.Value != null)
                key.Value.mute = !_isSFXOn;
        }
    }

     

    BGMController, SFXController 코드 리팩토링

    : 현재 AudioManager가 Singleton으로 관리되고 있고 AudioManager를 통해 BGMController와 SFXController를 호출하여 사용할 수 있는데도 BGMController와 SFXController도 Singleton으로 관리되고 있다.

    --> 따라서 불필요한 BGMController와 SFXController의 Singleton을 제거

    • 각 Controller의 Singleton을 제거함에 따라 기존 코드를 AudioManager를 통해 접근하도록 수정
    • AudioManager, BGMController를 Prefab화 하면서 AudioManager에 각 Controller 바인딩

    >> 작성한 코드

    - BGMController.cs

    <hide/>
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using static Constants;
    
    public class BGMController : MonoBehaviour
    {
        private void Awake()
        {
            if (AudioManager.Instance != null)
                AudioManager.Instance.SetBGMController(this);
            
            SceneManager.sceneLoaded += OnSceneLoaded;
            DifficultyManager.OnLevelChanged += SetBGMSpeedFast;     // 난이도가 상승하면 자동 실행되도록 구독
            
            _bgmSource = gameObject.AddComponent<AudioSource>();
        }
    
        private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
        {
            // TODO: 유저 정보에 소리 설정이 OFF라면 재생되지 않도록
        }
    }

     

    - SFXController.cs

    <hide/>
    public class SFXController : MonoBehaviour
    {
        private void Awake()
        {
            if (AudioManager.Instance != null)
                AudioManager.Instance.SetSFXController(this);
            
            SceneManager.sceneLoaded += OnSceneLoaded;
            
            // AudioSource 초기화
            _sfxSource = gameObject.AddComponent<AudioSource>();
            _sfxSource.playOnAwake = false;
            _loopSources = new Dictionary<AudioClip, AudioSource>();
        }
    
        private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
        {
            
        }
    }

     

    최종 코드

    >> AudioManager.cs

    <hide/>
    using UnityEngine;
    
    public class AudioManager : Singleton<AudioManager>
    {
        [SerializeField] private BGMController bgmController;
        [SerializeField] private SFXController sfxController;
        
        // 각 Controller가 필요하시면 아래와 같이 호출해주세요.
        public BGMController BGM => bgmController;
        public SFXController SFX => sfxController;
    
        private bool _isAudioOn = true;     // Audio 토글이 On인지, Off인지
    
        protected override void Initialize()
        {
            base.Initialize();
            _isAudioOn = true;   // 기본값은 켜짐으로 설정
            
            // 필요 시, 초기화    ex) 저장된 설정 로드
        }
        
        public void SetBGMController(BGMController bgmController)
        {
            this.bgmController = bgmController;
        }
        
        public void SetSFXController(SFXController sfxController)
        {
            this.sfxController = sfxController;
        }
    
        // Audio 토글을 통해 BGM 및 SFX On/Off
        public void ToggleAudio()
        {
            _isAudioOn = !_isAudioOn;
            bgmController.SetBGMOn(_isAudioOn);
            sfxController.SetSFXOn(_isAudioOn);
        }
    
        public bool GetIsAudioOn()
        {
            return _isAudioOn;
        }
    }

     

    >> BGMController.cs

    <hide/>
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using static Constants;
    
    public class BGMController : MonoBehaviour
    {
        // BGM을 추가하실 때, 여기에 추가해주세요.
        [SerializeField] private AudioClip titleBGM;
        [SerializeField] private AudioClip gameBGM;
        
        // 여기까지
        
        private AudioSource _bgmSource;
        private bool _isBGMOn = true;       // BGM이 켜져있는지 여부
        public bool IsBGMOn() => _isBGMOn;
    
        private void Awake()
        {
            if (AudioManager.Instance != null)
                AudioManager.Instance.SetBGMController(this);
            
            SceneManager.sceneLoaded += OnSceneLoaded;
            DifficultyManager.OnLevelChanged += SetBGMSpeedFast;     // 난이도가 상승하면 자동 실행되도록 구독
            
            _bgmSource = gameObject.AddComponent<AudioSource>();
        }
    
        private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
        {
            // TODO: 유저 정보에 소리 설정이 OFF라면 재생되지 않도록
        }
    
        // BGM를 추가하신 뒤, 아래 함수 모음에 재생 함수를 작성해주세요. 그리고 작성하신 함수를 통해 사용하시면 됩니다.
        #region PlayBGM 함수 모음 
    
        public void PlayTitleBGM() => PlayBGM(titleBGM);
    
        public void PlayGameBGM() => PlayBGM(gameBGM);
    
        #endregion
    
        // Scene에 따라 BGM 재생
        public void PlayBGMByState(GameState currentState)
        {
            switch (currentState)
            {
                case GameState.Title:
                    PlayTitleBGM();
                    break;
                case GameState.InGame:
                    PlayGameBGM();
                    break;
                default:
                    StopBGM();
                    break;
            }
        }
        
        // BGM 재생 (반복 O)
        private void PlayBGM(AudioClip clip)
        {
            if (!_isBGMOn || clip == null) return;
    
            _bgmSource.clip = clip;
            _bgmSource.loop = true;
            _bgmSource.volume = 0.6f;
            _bgmSource.Play();
        }
    
        // BGM 중지
        private void StopBGM()
        {
            if (_bgmSource != null)
            {
                _bgmSource.Stop();
            }
        }
    
        // BGM 음소거
        private void MuteBGM()
        {
            if (_bgmSource != null)
            {
                _bgmSource.mute = true;
            }
        }
    
        // BGM 음소거 해제
        private void UnmuteBGM()
        {
            if (_bgmSource != null)
            {
                _bgmSource.mute = false;
            }
        }
    
        // BGM 볼륨을 절반으로 설정
        public void SetBGMVolumeHalf()
        {
            if (_bgmSource != null)
            {
                _bgmSource.volume = 0.3f;
            }
        }
    
        // BGM 볼륨을 기본으로 설정
        public void SetBGMVolumeMax()
        {
            if (_bgmSource != null)
            {
                _bgmSource.volume = 0.6f;
            }
        }
    
        // BGM 배속 조절
        private void SetBGMSpeedFast()
        {
            int level = DifficultyManager.Instance.GetLevel(GameManager.Instance.GetTimeController()._day);
            float[] bgmSpeeds = { 1f, 1.1f, 1.2f, 1.3f, 1.5f };
            int temp = Mathf.Clamp(level, 0, bgmSpeeds.Length - 1);     // level이 5 이상 넘어가는 것을 방지하기 위한 임시값
            
            _bgmSource.pitch = bgmSpeeds[temp];
        }
    
        // BGM 속도 정상화
        public void SetBGMSpeedNormal()
        {
            if (_bgmSource != null)
            {
                _bgmSource.pitch = 1f;
            }
        }
    
        // _isBGMOn값을 조정하고 그에 따라 BGM을 음소거 설정 및 해제
        public void SetBGMOn(bool isBGMOn)
        {
            _isBGMOn = isBGMOn;
            if (_isBGMOn)   // 음소거 해제
            {
                UnmuteBGM();
            }
            else   // 음소거
            {
                MuteBGM();
            }
        }
    }

     

    >> SFXController.cs

    <hide/>
    using System;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    
    public class SFXController : MonoBehaviour
    {
        // 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;
    
        private void Awake()
        {
            if (AudioManager.Instance != null)
                AudioManager.Instance.SetSFXController(this);
            
            SceneManager.sceneLoaded += OnSceneLoaded;
            
            // AudioSource 초기화
            _sfxSource = gameObject.AddComponent<AudioSource>();
            _sfxSource.playOnAwake = false;
            _loopSources = new Dictionary<AudioClip, AudioSource>();
        }
    
        private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
        {
            
        }
    
        // SFX를 추가하신 뒤, 아래 함수 모음에 재생 함수를 작성해주세요. 그리고 작성하신 함수를 통해 사용하시면 됩니다.
        // 1번 재생 : PlaySFX()
        // 반복 재생 : PlayLoopSFX()
        // 반복 재생 중지 : StopLoopSFX()
        #region PlaySFX 함수 모음
    
        public void PlayButtonClick() => PlaySFX(buttonClick);
    
        public void PlayStamp() => PlaySFX(stamp);
        
        public void PlayDocSuccess() => PlaySFX(docSuccess, 0.6f);
        public void PlayDocFail() => PlaySFX(docFail, 0.6f);
        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, float volume = 1f)
        {
            if (!_isSFXOn || clip == null) return;
            _sfxSource.PlayOneShot(clip, volume);
        }
        
        // SFX 반복 재생
        private void PlayLoopSFX(AudioClip clip)
        {
            if (clip == null) return;
            if (_loopSources.ContainsKey(clip)) return;     // 이미 재생 중이면 패스
            
            var src = gameObject.AddComponent<AudioSource>();
            src.playOnAwake = false;
            src.loop = true;
            src.clip = clip;
            if (!_isSFXOn) src.mute = true;
            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;
            
            // 단발성 AudioSource
            if (_sfxSource != null)
                _sfxSource.mute = !_isSFXOn;
            
            // 루프성 AudioSources
            foreach (var key in _loopSources)
            {
                if (key.Value != null)
                    key.Value.mute = !_isSFXOn;
            }
        }
    }

     

    >> ResultUIController.cs

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    using UnityEngine.UI;
    using DG.Tweening;
    
    public class ResultUIController : PopupController
    {
        [SerializeField] private RectTransform _rectTransform;
        [SerializeField] private CanvasGroup _canvasGroup;
        [SerializeField] private Button _quitButton;
    
        [SerializeField] private TMP_Text dayText;
        [SerializeField] private TMP_Text maxComboText;
        [SerializeField] private TMP_Text scoreText;
        
        [SerializeField] private Image newRecordImage;
        [SerializeField] private CanvasGroup fadeOutCanvasGroup;
        
        public Image errorCheckImage;
    
        void Awake()
        {
            _quitButton.onClick.AddListener(OnClickQuitButton);
            errorCheckImage.gameObject.SetActive(false);
        }
        
        public void ShowPopup()
        {
            base.ShowPopup(gameObject);
        }
        
        public void ClosePopup()
        {
            base.ClosePopup(gameObject);
            errorCheckImage.gameObject.SetActive(false);
        }
        
        public void InitResultItem(GameResultData resultData)
        {
            // 점수 보내기
            //해당기능에서는 점수를 string 타입으로 받음. 임시로 정수 형변환을 시켰지만
            //추후 반올림같은 로직을 넣는다면 그렇게 한 결과값을 인수로 넣도록 수정할 것.
            NetworkManager.Instance.SendScore((int)resultData.Score);
            
            // FadeOut Panel 초기화
            fadeOutCanvasGroup.alpha = 0;
            
            // 퇴근 버튼 비활성화
            _quitButton.gameObject.SetActive(false);
            
            // New Record 이미지 비활성화
            newRecordImage.gameObject.SetActive(false);
            
            // BGM 볼륨을 절반으로 설정
            AudioManager.Instance.BGM.SetBGMVolumeHalf();
            
            // 처음에는 0으로 초기화
            dayText.text = "0";
            maxComboText.text = "0";
            scoreText.text = "0";
            
            // TODO: 유저의 최고기록 불러오기 (임시: PlayerPrefs)
            float bestScore = PlayerPrefs.GetFloat("BestScore", 0f);
            
            Sequence seq = DOTween.Sequence();
            
            // Day Count Up
            seq.Append(DOTween.To(() => 0, x => dayText.text = x.ToString() + "일", resultData.Day, 1f));
            seq.AppendInterval(0.2f);
            
            // MaxCombo Count Up
            seq.Append(DOTween.To(() => 0, x => maxComboText.text = x.ToString(), resultData.MaxCombo, 1f));
            seq.AppendInterval(0.2f);
    
            // Score Count Up
            seq.Append(DOTween.To(() => 0, x => scoreText.text = x.ToString("N0"), resultData.Score, 1.5f)
                .OnComplete(() =>
                {
                    // 퇴근 버튼 활성화
                    _quitButton.gameObject.SetActive(true);
                    
                    // New Record 체크
                    if (resultData.Score > bestScore)
                    {
                        PlayerPrefs.SetFloat("BestScore", resultData.Score);
                        ShowNewRecordEffect();
                    }
                }));
        }
    
        // 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));
        }
    
        public void OnClickQuitButton()
        {
            // Title로 가기 전, FadeOut
            fadeOutCanvasGroup.DOFade(1f, 1f)
                .OnComplete(() =>
                {
                    ClosePopup();
                    GameManager.Instance.ResumeGame();
                    GameManager.Instance.inGameController.QuitGame();
                });
        }
    }

     

    >> InGameController.cs

    <hide/>
    using System.Collections;
    using UnityEngine;
    using Object = UnityEngine.Object;
    
    //인게임 주요 로직들을 제어합니다.
    public class InGameController
    {
        public TimeController timeController;
        public DocumentController docController;
        public Classification classification;
        
        public bool Initialized;
        
        private bool _initComplete;
        private bool _gameStarted;
        private bool _gameFinished;
        private bool _quitGame;
        private bool _skipResultUI;
        private bool _useRetry;
        
        
        public IEnumerator Initialize()
        {
            //TODO: 게임 실행시 초기화 할 로직
            //classification = new Classification();
            
            //씬 내 타이머, 문서생성 오브젝트 찾기
            if (timeController == null)
            {
                yield return new WaitUntil(() => timeController = Object.FindObjectOfType<TimeController>());
            }
            if (docController == null)
            {
                yield return new WaitUntil(() => docController = Object.FindObjectOfType<DocumentController>());
            }
            if(classification == null)
            {
                yield return new WaitUntil(() => classification = Object.FindObjectOfType<Classification>());
                classification.Initialize();
            }
            
            Initialized = true;
        }
        
        //게임이 시작되면 이 시퀀스를 통해 진행됩니다.
        public IEnumerator RunSequence()
        {
            //실행 필수 초기화 진행 체크
            yield return new WaitUntil(() => Initialized);
            
            //게임 시퀀스 실행
            yield return new WaitUntil(() => _initComplete);
            yield return StartGame();
    
            yield return new WaitUntil(() => _gameFinished);
            yield return EndGame();
        }
        
        //게임 시작 전 초기화
        public IEnumerator SetInitGame()
        {
            //실행 필수 초기화 완료전 까지 대기
            yield return new WaitUntil(() => Initialized);
            
            //TODO: 게임 시작시 초기화 할 로직
            _initComplete = false;
            
            _gameStarted = false;
            _gameFinished = false;
            _quitGame = false;
            _skipResultUI = false;
            _useRetry = false;
    
            //타이머 초기화
            timeController.InitTimeController();
            
            //서류 풀 초기화
            docController.ReloadDocument(true);
            
            //classification.InitScore();
            GameManager.Instance.GetClassification().InitScore();
            _initComplete = true;
            
            // NewRecord 이미지 위치 초기화
            InGameUIController.Instance.scoreUIController.InitNewRecordImage();
            
            // 시계 프레임 색상 초기화
            InGameUIController.Instance.clockUIController.InitClockFrameColor();
        }
    
        
        //게임 시작
        public IEnumerator StartGame()
        {
            _gameStarted = true;
            //인게임UI 보이기
            UIManager.Instance.inGameUIController.ShowTimeUI();
            UIManager.Instance.inGameUIController.ShowInteractionUI();
            UIManager.Instance.inGameUIController.ShowScoreUI();
            UIManager.Instance.inGameUIController.ShowFeverUI();
            UIManager.Instance.inGameUIController.ShowBackgroundUI();
            UIManager.Instance.inGameUIController.ShowClockUI();
            UIManager.Instance.inGameUIController.ShowClassificationUI();
            UIManager.Instance.inGameUIController.ShowWaitThreeSecondsUI();
            UIManager.Instance.inGameUIController.ShowDifficultyUpEffectUI();
            
            // 인게임 BGM 재생
            AudioManager.Instance.BGM.PlayBGMByState(GameManager.Instance.GetGameState());
            
            //321
            yield return UIManager.Instance.StartCoroutine(
                UIManager.Instance.inGameUIController.waitThreeSecondsUI.WaitThreeSeconds()
            );
    
            //난이도 상승시점 모니터링 시작
            DifficultyManager.Instance.InitLevelMonitor();
    
            //타이머, 문서 생성 시작.
            timeController.StartRunningTimer();
            docController.InitDocuments();
            
            while (!_gameFinished)
            {
                yield return null;
            }
        }
    
        //게임 종료. 결과 보고
        public IEnumerator EndGame()
        {
            //TODO: 게임 끝낼 시 실행할 로직
            
            //시간 정지
            timeController.StopTime();
            
            // 시간 부족 SFX 반복 재생 중지
            AudioManager.Instance.SFX.StopTimeOutAlert();
            
            //ex.게임오버 연출, 결과창UI등
            
            var popupController = UIManager.Instance.popupUIController;
    
            if (!_useRetry)//재시작 활성화 시 엔드연출 스킵
            {
                //게임오버
                var gameOverUI = popupController.gameOverUIController;
                yield return gameOverUI.ShowSequence(); //게임오버 연출동안 딜레이
            }
            
            
            if (!_skipResultUI)//필요 시 스킵
            {
                //결과창
                var resultUI = popupController.resultUIController;
                popupController.ShowResultUI();
                resultUI.InitResultItem(new GameResultData(
                    timeController._day,
                    GameManager.Instance.GetClassification().GetMaxCombo(),
                    GameManager.Instance.GetClassification().GetScore()));
            }
            
            //게임이 끝난 후, 바로 돌아가지 않고 대기.
            while (!_quitGame)
            {
                yield return null;
            }
            
            //초기화.
            _initComplete = false;
            _skipResultUI = false;
            
            // BGM 초기화
            AudioManager.Instance.BGM.SetBGMVolumeMax();    // 볼륨을 최대로
            AudioManager.Instance.BGM.SetBGMSpeedNormal();  // 배속을 기본으로
            
            //재시작 필요 시.
            if (_useRetry)
            {
                //새 게임 코루틴 활성화
                yield return GameManager.Instance.StartCoroutine(SetInitGame());
                GameManager.Instance.StartCoroutine(RunSequence());
                
                //재시작을 위해 타이틀로 복귀하지 않고 기존 코루틴을 중단한다.
                yield break;
            }
            
            //인게임 UI 닫기
            UIManager.Instance.inGameUIController.HideInGameUI();
            UIManager.Instance.inGameUIController.HideTimeUI();
            UIManager.Instance.inGameUIController.HideInteractionUI();
            UIManager.Instance.inGameUIController.HideScoreUI();
            UIManager.Instance.inGameUIController.HideFeverUI();
            UIManager.Instance.inGameUIController.HideBackgroundUI();
            UIManager.Instance.inGameUIController.HideClockUI();
            UIManager.Instance.inGameUIController.HideClassificationUI();
            UIManager.Instance.inGameUIController.HideWaitThreeSecondsUI();
            UIManager.Instance.inGameUIController.HideDifficultyUpEffectUI();
    
            //타이틀 씬으로 복귀
            GameManager.Instance.ReturnToTitle();
        }
    
        ///게임 끝내기, 호출 시 진행중인 게임이 끝납니다.
        public void Dispose()
        {
            //게임이 시작했을때만
            if(_gameStarted) _gameFinished = true;
        }
    
        ///게임 끝난 후, 호출 시 타이틀로 돌아갑니다.
        public void QuitGame()
        {
            if(_gameFinished) _quitGame = true;
        }
    
        ///결과 UI 스킵필요 시 게임 종료 전 호출
        public void SkipResultUI()
        {
            _skipResultUI = true;
        }
    
        //재시작 필요 시 먼저 호출.
        public void UseRetry()
        {
            _useRetry = true;
        }
    
        public bool GetGameStarted()
        {
            return _gameStarted;
        }
    }