본문 바로가기
Development/Internship

[멋사 로켓단 인턴쉽] 18일차 - 앱 출시 전 디테일 보강

by Mobics 2025. 9. 1.

목차


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

    25.09.01

    회의록

    >> 안건

    • 업무 업데이트
      • 질문 혹은 피드백 요청
    • 주요 안건
      • 앱 출시 작업 전 간단한 QA 및 밸런싱 논의
    • 향후 마일스톤
      • 앱 출시 및 안정화 작업

    >> 회의 내용

    • 퇴근하기 버튼을 누른 뒤 페이드 아웃 연출이 나오는 동안 버튼 클릭 모션이 나오지 않게
    • 결과창 UI 비율 재정렬
    • 결과창 숫자 카운팅 사운드 재할당 (소스찾기)
      • 숫자 올라갈 때 효과음
      • 카운팅 끝나고 완료 효과음
    • 결과창 로드 후 아래에서 돈다발 터지는 효과
    • 게임 오버(일과 종료 패널) 효과음

    - 4차 밸런싱 회의

    : 초반부 피버타임 때 증감 체감이 적은 부분을 보완하기위해 피버전용 입력딜레이 추가.

    • 서류 연출 딜레이 시간 조정 : (기존) 항상 0.6/0.5/0.4/0.3/0.2 --> (변경) 피버 때만 0.4/0.35/0.3/0.25/0.2

     

    - 추후 5차 밸런싱 필요

    • 3,4,5 단계 밸런스조정 필요해보입니다. 2,3,4단계에 걸쳐서 시간이 너무 많이 모입니다. =>컴퓨터로 대충해도 140초 이상 모입니다.
    • 5단계에서 시간이 줄어야하는데 현재 피버시에 클릭 잘하면 시간이 원상복구됩니다.(컴퓨터기준)
    • 모바일로 하면 시간을 더 많이 채울걸로 예상됩니다. =>모바일 테스트 후 조정 필요

    --> 2,3,4단계 조정 필요

    --> 5단계에서 일과시간 비축량이 너무 늘어나는 상황이 아니라 잃은 시간을 한 턴 정도 복구하는 정도에 그친다면 5단계는 문제가 없음.

     

    결과창 UI 재정렬

    : 지난 시간에 재시작 버튼을 추가하면서 기존에 세팅해놓은 UI 위치가 조금 틀어진 것 같다. 결과창에 있는 글자들이 전체적으로 살짝 위로 올라간 느낌이라 다시 정렬하였다.

    --> Vertical Layout Group의 Padding 중 Top 값과 Spacing을 조정해서 수정

    (좌)수정 전 / (우)수정 후

     

    게임 오버 효과음 추가

    : 게임 오버시, 효과음이 나왔으면 좋겠다는 팀원들의 의견에 따라 효과음을 찾아보았다.

    --> 호루라기 소리로 무료 SFX 중에 하나 찾아서 게임에 적용했다.

     

    >> 찾은 효과음

    https://pixabay.com/ko/sound-effects/whistle-84607/

     

    >> 적용

    : 만든 AudioManager를 통해 효과음을 적용해줄 함수를 작성하고, 함수에 찾은 SFX를 바인딩

     

    └ 최종 코드

    >> SFXController.cs

    : SerializeField로 gameOver 사운드를 받아서 이를 재생해주는 public 함수를 작성

    <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;
        [SerializeField] private AudioClip gameStart;
        [SerializeField] private AudioClip scoreCalculating;
        [SerializeField] private AudioClip scoreCalculated;
        [SerializeField] private AudioClip gameOver;
        
        
        // 여기까지
    
        private List<AudioSource> _sfxSources;                       // 단발성 AudioSource (풀링)
        private int _poolSize = 20;                                  // 단발성 AudioSource 풀의 개수
        private Dictionary<AudioClip, List<AudioSource>> _activeSFX;   // 개별 단발 SFX 추적
        
        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 초기화
            _sfxSources = new List<AudioSource>();
            _loopSources = new Dictionary<AudioClip, AudioSource>();
            _activeSFX = new Dictionary<AudioClip, List<AudioSource>>();
    
            // 풀 초기화
            for (int i = 0; i < _poolSize; i++)
            {
                var src = gameObject.AddComponent<AudioSource>();
                src.playOnAwake = false;
                _sfxSources.Add(src);
            }
        }
    
        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);
        public void PlayGameStart() => PlaySFX(gameStart);
        public void PlayScoreCalculating() => PlaySFX(scoreCalculating);
        public void StopScoreCalculating() => StopSFX(scoreCalculating);
        public void PlayScoreCalculated() => PlaySFX(scoreCalculated);
        public void PlayGameOver() => PlaySFX(gameOver);
        #endregion
    
        // 단발 SFX 재생 (풀링 + 추적)
        private void PlaySFX(AudioClip clip, float volume = 1f)
        {
            if (!_isSFXOn || clip == null) return;
    
            AudioSource src = GetAvailableSource();
            src.clip = clip;
            src.volume = volume;
            src.mute = !_isSFXOn;
            src.Play();
    
            // 개별 SFX 추적
            if (!_activeSFX.ContainsKey(clip))
                _activeSFX[clip] = new List<AudioSource>();
            _activeSFX[clip].Add(src);
    
            // 재생 완료 후 제거
            StartCoroutine(RemoveAfterPlay(src, clip));
        }
    
        private System.Collections.IEnumerator RemoveAfterPlay(AudioSource src, AudioClip clip)
        {
            yield return new WaitWhile(() => src.isPlaying);
            if (_activeSFX.ContainsKey(clip))
                _activeSFX[clip].Remove(src);
        }
    
        // 사용 가능한 AudioSource 가져오기 (풀링)
        private AudioSource GetAvailableSource()
        {
            foreach (var src in _sfxSources)
            {
                if (!src.isPlaying)
                    return src;
            }
    
            // 모두 사용 중이면 새로 생성
            var newSrc = gameObject.AddComponent<AudioSource>();
            newSrc.playOnAwake = false;
            _sfxSources.Add(newSrc);
            return newSrc;
        }
    
        // 특정 단발 SFX 강제 정지
        public void StopSFX(AudioClip clip)
        {
            if (!_activeSFX.ContainsKey(clip)) return;
    
            foreach (var src in _activeSFX[clip])
            {
                if (src != null && src.isPlaying)
                    src.Stop();
            }
            _activeSFX[clip].Clear();
        }
    
        // 모든 단발 SFX 강제 정지
        public void StopAllSFX()
        {
            foreach (var kv in _activeSFX)
            {
                foreach (var src in kv.Value)
                {
                    if (src != null && src.isPlaying)
                        src.Stop();
                }
                kv.Value.Clear();
            }
        }
        
        // 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;
    
            _isSFXOn = isSFXOn;
    
            // 단발
            foreach (var src in _sfxSources)
            {
                if (src != null) src.mute = !_isSFXOn;
            }
    
            // 루프
            foreach (var key in _loopSources)
            {
                if (key.Value != null) key.Value.mute = !_isSFXOn;
            }
        }
    }

     

    >> InGameController.cs

    : EndGame()에서 결과창이 호출될 때 효과음 재생코드 작성

    <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;
        private bool _newRecordOn;
        private bool _endAdmob;
        
        
        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;
            _newRecordOn = false;
            _endAdmob = false;
            
            // BGM 초기화
            AudioManager.Instance.BGM.SetBGMVolumeMax();    // 볼륨을 최대로
            AudioManager.Instance.BGM.SetBGMSpeedNormal();  // 배속을 기본으로
    
            //타이머 초기화
            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();
            
            docController._isClickable = false;
            
            // 시간 부족 SFX 반복 재생 중지
            AudioManager.Instance.SFX.StopTimeOutAlert();
            
            //ex.게임오버 연출, 결과창UI등
            
            var popupController = UIManager.Instance.popupUIController;
    
            if (!_useRetry)//재시작 활성화 시 엔드연출 스킵
            {
                //게임오버
                var gameOverUI = popupController.gameOverUIController;
                AudioManager.Instance.SFX.PlayGameOver();
                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;
            }
            
            //결과창이 떠야 게임 한판을 완료했다는 것이므로
            if (!_skipResultUI)
            {
                //광고호출
                NetworkManager.Instance.ShowAd();
    
                //광고 끝나기 전까지 대기
                while (!_endAdmob)
                {
                    yield return null;
                }
            
                //광고 제어 변수 초기화
                _endAdmob = false;
            
                //다음 광고 로드
                NetworkManager.Instance.LoadAd();
            }  
            
            //초기화.
            _initComplete = false;
            _skipResultUI = false;
            
            //재시작 필요 시.
            if (_useRetry)
            {
                //새 게임 코루틴 활성화
                yield return GameManager.Instance.StartCoroutine(SetInitGame());
                GameManager.Instance.StartCoroutine(RunSequence());
                
                //재시작을 위해 타이틀로 복귀하지 않고 기존 코루틴을 중단한다.
                yield break;
            }
            
            // BGM 초기화
            AudioManager.Instance.BGM.SetBGMVolumeMax();    // 볼륨을 최대로
            AudioManager.Instance.BGM.SetBGMSpeedNormal();  // 배속을 기본으로
            
            //인게임 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();
        }
        
        // New Record 체크 및 연출 재생
        public void CheckNewRecord(float currentScore)
        {
            if (_newRecordOn) return;   // 이미 NewRecord 연출이 재생됐으면 넘기도록
    
            float bestScore = PlayerPrefs.GetFloat("BestScore", 0f);
            if (currentScore > bestScore)
            {
                // New Record 연출
                InGameUIController.Instance.scoreUIController.ShowNewRecordImage();
                AudioManager.Instance.SFX.PlayNewRecordScoreBar();
    
                _newRecordOn = true;
            }
        }
    
        ///게임 끝내기, 호출 시 진행중인 게임이 끝납니다.
        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;
        }
    
        public void EndAdMob()
        {
            _endAdmob = true;
        }
    }