본문 바로가기
Development/Internship

[멋사 로켓단 인턴쉽] 9일차 - Result Effect 및 BGM Bug Fix

by Mobics 2025. 8. 19.

목차


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

    25.08.19

    회의록

    >> 안건

    • 업무 업데이트
      • 질문 혹은 피드백 요청
    • 주요 안건
      • 개발지속
      • 해상도 변경이슈

     

    >> 회의 내용

    • 해상도 9:16 비율로 변경
    • 현재 인게임 외적요소 완성 목표 - 수요일

     

    게임 결과창 이펙트 완성

    : 어제는 버그 때문에 테스트를 못해봤는데, 오늘 버그가 해결되어 테스트해보니 DOTween으로 만든 효과가 원하는 효과가 아니었다.

     

    >> 테스트를 위한 설정

    : ResultUIController에 'NewRecordText'를 만들어서 바인딩하였다.

    --> 'Panel', 'ResultPanel', 'Score' 모두 Layout Group Component를 가지고 있기 때문에 UI의 원하는 위치에 편하게 넣을 수 있도록 Layout Group의 영향을 받지 않는 ResultUI의 자식으로 생성

     

    >> 현재 적용되어 있는 효과

    : 기본적으로 게임 결과가 순차적으로 나오고 그 결과값도 0에서부터 점수가 올라서 결과값이 나오는 것은 잘 적용되었다. 하지만 아래 이미지처럼 Day의 단위는 나오지 않았고, New Record일 때 "New Record" Text가 오른쪽에 치우쳐서 나오고, 글자가 나온 뒤에 다시 사라지고 더 이상 나타나지 않았다.

     

    - 원하는 효과

    • Day 수를 표시할 때는 뒤에 단위('일') 도 같이 표시되도록
    • New Record일 때, Text가 해상도와 상관없이 점수의 위쪽 중앙에 나오도록
    • New Record Text의 효과가 서서히 등장한 다음 계속 깜빡거리도록

     

    >> 효과 적용하기

    1. Day 수 뒤에 단위도 표시되도록

    : DOTween 효과로 Day 수를 표시할 때 text 부분에 "일"을 추가

    seq.Append(DOTween.To(() => 0, x => dayText.text = x.ToString() + "일", resultData.Day, 1f));

     

    2. New Record일 때, Text가 해상도와 상관없이 점수의 위쪽 중앙에 나오도록

    : 우선 'NewRecordText'가 해상도와 상관없이 점수 부분과 묶이려면 'Score'의 자식으로 있어야 하는데, 'Score'에 있는 'Horizontal Layout Group' Component 때문에 배치가 자유롭지 않다.

    --> 'NewRecordText'를 Score의 자식으로 두되, Score에 있는 'Horizontal Layout Group'을 제거하고 새로 'Content'라는 빈 게임 오브젝트를 만들어서 Content에 'Horizontal Layout Group'을 추가한 다음 'Value Text'와 'Head Text'를 Content의 자식으로 넣었다.

    (좌)기존 구조 / (우)변경한 구조

     

    3. New Record Text가 서서히 등장한 다음 계속 깜빡거리도록

    : DOTween의 Sequence()를 활용하여 효과들의 순서를 정렬하고 DOFade와 DOScale을 활용하여 등장 및 깜빡임을 추가하였으며, Ease를 활용하여 좀 더 자연스러운 모션을 구현함.

    --> 모든 효과를 Sequence()에 넣으면 Sequence.SetLoops()를 통해 반복할 때 Scale 애니메이션까지 전부 반복하기 때문에 팝업 느낌을 주는 Scale 애니메이션까지만 Sequence()에 넣고 반짝이는 애니메이션을 만들어주는 DOFade에만 SetLoops()를 사용하여 반짝이는 애니메이션을 무한 반복 재생

     

    ※ DOTween의 Ease 기능을 잘 설명한 블로그

     

    [Asset] Unity3D 'DOTween' 13 : Ease에 대한 모든 것 / In, Out과 Flash / AnimationCurve를 활용한 Custom Ease

    Ease는 트윈을 사용할 때 모션의 움직임을 결정해주는 아주 중요한 기능으로, 시간의 흐름에 따라 변화되...

    blog.naver.com

     

    ▶ 테스트를 해보니 글자가 오른쪽 위에서 서서히 등장한 다음 깜빡거리기에 글자가 중앙을 기준으로 서서히 등장하도록 Pivot을 수정

    : DOScale()은 Pivot을 기준으로 확대/축소가 일어나기 때문

    • Anchor : 화면 해상도에 따라 UI의 위치를 어떻게 고정/비율 유지할지 결정
    • Pivot : 오브젝트 자체의 스케일/회전 기준점

     

    >> 최종적으로 효과가 적용된 모습

     

    └ 최종 코드

    - 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;
    
        [Header("New Record UI")]
        [SerializeField] private TMP_Text newRecordText;
    
        void Awake()
        {
            _quitButton.onClick.AddListener(OnClickQuitButton);
            if (newRecordText != null)
                newRecordText.gameObject.SetActive(false);
        }
        
        public void ShowPopup()
        {
            base.ShowPopup(gameObject);
        }
        
        public void ClosePopup()
        {
            base.ClosePopup(gameObject);
        }
        
        public void InitResultItem(GameResultData resultData)
        {
            // 처음에는 0으로 초기화
            dayText.text = "0";
            maxComboText.text = "0";
            scoreText.text = "0";
            
            // TODO: 유저의 최고기록 불러오기 (임시: PlayerPrefs)
            int bestScore = PlayerPrefs.GetInt("BestScore", 0);
            
            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(), resultData.Score, 1.5f)
                .OnComplete(() =>
                {
                    // New Record 체크
                    if (resultData.Score > bestScore)
                    {
                        PlayerPrefs.SetInt("BestScore", resultData.Score);
                        ShowNewRecordEffect();
                    }
                }));
        }
    
        // New Record 시, 효과
        public void ShowNewRecordEffect()
        {
            if (newRecordText != null)
            {
                newRecordText.gameObject.SetActive(true);
                newRecordText.alpha = 0f;
                newRecordText.transform.localScale = Vector3.zero * 0.8f;
                
                Sequence seq = DOTween.Sequence();
                // Fade In
                seq.Append(newRecordText.DOFade(1f, 0.5f));
                // 글자가 살짝 커졌다가 원래 크기로
                seq.Join(newRecordText.transform.DOScale(1.2f, 0.3f).SetEase(Ease.OutBack));
                seq.Append(newRecordText.transform.DOScale(1f, 0.2f).SetEase(Ease.InOutSine));
                // 번쩍거리는 효과 무한 반복
                seq.OnComplete(() =>
                {
                    newRecordText.DOFade(0.3f, 0.2f)
                        .SetLoops(-1, LoopType.Yoyo)
                        .SetEase(Ease.InOutSine);
                });
            }
        }
    
        public void OnClickQuitButton()
        {
            ClosePopup();
            GameManager.Instance.ResumeGame();
            GameManager.Instance.inGameController.QuitGame();
        }
    }

     

    Scene이 하나로 통합되면서 생긴 BGM 버그 해결

    : 다른 팀원께서 기존에 Raise, Title, InGame로 나뉘어 있던 Scene을 하나로 통합하셨다. 기존에 BGM을 재생하는 방식이 Scene의 이름을 검사하여 그에 맞게 BGM을 틀었던 구조였기 때문에 Scene이 통합되면서 BGM이 재생되지 않는 문제가 생겼다.

    --> BGM의 재생 방식을 Scene의 이름을 검사하는 것이 아니라 enum으로 존재하는 'GameState'에 따라 재생되도록 수정

    1. BGMController.cs에서 GameState를 매개변수로 받아서 Switch문으로 GameState에 따라 그에 해당하는 BGM이 실행되는 함수를 추가
    2. GameManager.cs에서 GameState를 어디서든 받아올 수 있는 함수를 추가
    3. BGM이 필요한 GameState가 Title과 InGame 이므로 각 State의 OnEnter()에서 BGM을 재생하도록 추가
    4. InGameController.cs에서 관리하는 게임시작/게임종료 Coroutine에서도 BGM을 재생 및 정지하도록 추가 --> 게임 중 일시정지 한 다음 Retry하거나 Quit 했을 때도 BGM이 정상작동 하도록 하기 위함

     

    └ 최종 코드

    1. BGMController.cs

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using static Constants;
    
    public class BGMController : Singleton<BGMController>
    {
        // BGM을 추가하실 때, 여기에 추가해주세요.
        [SerializeField] private AudioClip titleBGM;
        [SerializeField] private AudioClip gameBGM;
        
        // 여기까지
        
        private AudioSource _bgmSource;
        private AudioClip _currentBGM;     // 현재 재생해야 할 BGM 저장
        private bool _isBGMOn = true;       // BGM이 켜져있는지 여부
        public bool IsBGMOn() => _isBGMOn;
    
        protected override void Initialize()
        {
            SceneManager.sceneLoaded += OnSceneLoaded;
            _bgmSource = gameObject.AddComponent<AudioSource>();
        }
    
        private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
        {
            AudioManager.Instance.SetBGMController(this);
            
            // 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)
        {
            _currentBGM = clip;     // 현재 BGM 저장
            if (!_isBGMOn || clip == null) return;
    
            _bgmSource.clip = clip;
            _bgmSource.loop = true;
            _bgmSource.Play();
        }
    
        // BGM 중지
        public void StopBGM()
        {
            if (_bgmSource != null)
            {
                _bgmSource.Stop();
            }
        }
    
        // _isBGMOn값을 조정하고 그에 따라 BGM 재생 및 중지
        public void SetBGMOn(bool isBGMOn)
        {
            _isBGMOn = isBGMOn;
            if (_isBGMOn && _currentBGM != null)   // 재생
            {
                PlayBGM(_currentBGM);
            }
            else   // 중지
            {
                StopBGM();
            }
        }
    }

     

    2. GameManager.cs

    <hide/>
    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using static Constants;
    
    public class GameManager : Singleton<GameManager>
    {
        public InGameController inGameController;
        
        /// <summary>
        /// 게임은 상태패턴으로 관리됩니다. Title, InGame, Pause 세가지로 관리됩니다.
        /// IGameState 인터페이스를 기반으로 만들어졌으며, 각 상태에 진입(OnEnter)할 때 씬을 로드합니다.
        /// GameState는 ChangeGameState를 통해 변경합니다.
        /// </summary>
        private GameState _previousState;
        private GameState _currentState;
        //public GameState CurrentGameState => _currentState;
        private Dictionary<GameState, IGameState> _states = new Dictionary<GameState, IGameState>();
        public Action<GameState> GameStateChanged;
    
        //일시정지 관리.
        private bool _isPaused;
        
        protected override void Initialize()
        {
            //Initialize
            _states[GameState.Title] = new TitleState();
            _states[GameState.InGame] = new InGameState();
            _states[GameState.Pause] = new PauseState();
            
            inGameController = new InGameController();
            
            _isPaused = false;
            
            SceneManager.sceneLoaded += OnSceneLoaded;
        }
        
        private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
        {
            SceneManager.sceneLoaded -= OnSceneLoaded;
            StartCoroutine(LoadGameSystem());
        }
    
        //최초 실행시 초기화 진행
        private IEnumerator LoadGameSystem()
        {
            yield return StartCoroutine(inGameController.Initialize());
        }
        
        private void Update()
        {
            if (_currentState != GameState.None)
            {
                _states[_currentState].OnUpdate();
            }
            
            // //테스트용 입력
            // if (Input.GetKeyDown(KeyCode.Alpha1))
            // {
            //     GoToInGame();
            // }
            // else if (Input.GetKeyDown(KeyCode.Alpha2))
            // {
            //     //게임오버 시키기
            //     inGameController.QuitGame();
            //     //ReturnToTitle();
            // }
            // else if (Input.GetKeyDown(KeyCode.Alpha3))
            // {
            //     PauseGame();
            // }
            // else if (Input.GetKeyDown(KeyCode.Alpha4))
            // {
            //     ResumeGame();
            // }
        }
        
        ///게임을 시작합니다.
        public void GoToInGame()
        {
            ChangeGameState(GameState.InGame);
        }
    
        ///타이틀로 돌아갑니다.
        public void ReturnToTitle()
        {
            ChangeGameState(GameState.Title);
        }
    
        ///게임을 일시정지 합니다.
        public void PauseGame()
        {
            if (_isPaused)
            {
                Debug.LogWarning("Game is already paused");
                return;
            }
            
            ChangeGameState(GameState.Pause);
            _isPaused = true;
        }
    
        ///게임을 재개 합니다.
        public void ResumeGame()
        {
            if (!_isPaused)
            {
                Debug.LogWarning("Game is not paused");
                return;
            }
            
            ChangeGameState(GameState.Pause, true);
            _isPaused = false;
        }
    
        ///게임의 상태를 변경합니다.
        public void ChangeGameState(GameState newGameState, bool resume = false)
        {
            //기존 State 종료
            if (_currentState != GameState.None)
            {
                _states[_currentState].OnExit();
            }
    
            //일시정지 해제 시
            if (_currentState == GameState.Pause && newGameState == GameState.Pause && resume)
            {
                _currentState = _previousState;
            }
            else//새 State로 전환
            {
                _previousState = _currentState;
                _currentState = newGameState;
                
                _states[_currentState].OnEnter();
            }
            
            //State전환 후 실행할 Action이 있으면 실행
            GameStateChanged?.Invoke(_currentState);
        }
        
        ///TimeController가 필요할땐 이 함수를 쓰시면 됩니다.
        public TimeController GetTimeController()
        {
            return inGameController.timeController != null ? inGameController.timeController : null;
        }
        
        ///DocumentController가 필요할땐 이 함수를 쓰시면 됩니다.
        public DocumentController GetDocumentController()
        {
            return inGameController.docController != null ? inGameController.docController : null;
        }
    
        ///Classification이 필요할땐 이 함수를 쓰시면 됩니다.
        public Classification GetClassification()
        {
            return inGameController.classification != null ? inGameController.classification : null;
        }
    
        public GameState GetGameState()
        {
            return _currentState;
        }
    
        ///일시정지(백그라운드 상태) 되었을 때
        private void OnApplicationPause(bool pauseStatus)
        {
            //Debug.Log("OnApplicationPause: " + pauseStatus);
        }
    
        ///게임이 종료되었을 때
        private void OnApplicationQuit()
        {
            //Debug.Log("OnApplicationQuit");
        }
    
        public new void OnDestroy()
        {
            base.OnDestroy();
        }
    }

     

    3. TitleState.cs

    <hide/>
    using Unity.VisualScripting;
    using UnityEngine;
    
    public class TitleState : IGameState
    {
        public void OnEnter()
        {
            //SceneController.TransitionToScene(SceneState.Title);
            UIManager.Instance.titleUIController.ShowTitleUI();
            UIManager.Instance.titleUIController.ShowMainMenuUI();
            UIManager.Instance.titleUIController.ShowSubMenuUI();
            
            // BGM 재생
            AudioManager.Instance.BGM.PlayBGMByState(GameManager.Instance.GetGameState());
        }
    
        public void OnUpdate()
        {
            
        }
        
        public void OnExit()
        {
            UIManager.Instance.titleUIController.HideTitleUI();
            UIManager.Instance.titleUIController.HideMainMenuUI();
            UIManager.Instance.titleUIController.HideSubMenuUI();
        }
    }

     

    4. 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();
           
           // BGM 재생
           AudioManager.Instance.BGM.PlayBGMByState(GameManager.Instance.GetGameState());
        }
        
        public void OnUpdate()
        {
            
        }
        
        public void OnExit()
        {
           
        }
    
        IEnumerator StartGame()
        {
            yield return GameManager.Instance.inGameController.SetInitGame();
            yield return GameManager.Instance.inGameController.RunSequence();
        }
    }

     

    5. 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;
        }
    
        
        //게임 시작
        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();
            
            // BGM 재생
            AudioManager.Instance.BGM.PlayBGMByState(GameManager.Instance.GetGameState());
    
            //타이머, 문서 생성 시작.
            timeController.StartRunningTimer();
            docController.InitDocuments();
            
            while (!_gameFinished)
            {
                yield return null;
            }
        }
    
        //게임 종료. 결과 보고
        public IEnumerator EndGame()
        {
            //TODO: 게임 끝낼 시 실행할 로직
            
            //시간 정지
            timeController.StopTime();
            
            
            //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;
            
            //재시작 필요 시.
            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();
    
            //타이틀 씬으로 복귀
            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;
        }
    }